stremio_addon_core/
auth.rs1use crate::config::UserConfig;
2use axum::http::HeaderMap;
3use serde::Deserialize;
4use std::fmt;
5use subtle::ConstantTimeEq;
6use thiserror::Error;
7
8#[derive(Clone)]
9pub struct AuthConfig {
10 key: Option<String>,
11 required: bool,
12}
13
14impl AuthConfig {
15 pub fn required(key: impl Into<String>) -> Self {
16 Self {
17 key: Some(key.into()),
18 required: true,
19 }
20 }
21
22 pub fn disabled() -> Self {
23 Self {
24 key: None,
25 required: false,
26 }
27 }
28
29 pub fn is_required(&self) -> bool {
30 self.required
31 }
32
33 pub fn validate(
34 &self,
35 user_config: Option<&UserConfig>,
36 query: Option<&AuthQuery>,
37 headers: &HeaderMap,
38 path_key: Option<&str>,
39 ) -> Result<(), AuthError> {
40 if !self.required {
41 return Ok(());
42 }
43
44 let expected = self.key.as_deref().ok_or(AuthError::Misconfigured)?;
45 let candidate = auth_from_config(user_config)
46 .or(path_key)
47 .or_else(|| auth_from_query(query))
48 .or_else(|| auth_from_bearer(headers))
49 .or_else(|| auth_from_header(headers));
50
51 match candidate {
52 Some(value) if constant_time_eq(value.as_bytes(), expected.as_bytes()) => Ok(()),
53 Some(_) => Err(AuthError::Invalid),
54 None => Err(AuthError::Missing),
55 }
56 }
57}
58
59impl fmt::Debug for AuthConfig {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.debug_struct("AuthConfig")
62 .field("key", &self.key.as_ref().map(|_| "<redacted>"))
63 .field("required", &self.required)
64 .finish()
65 }
66}
67
68#[derive(Clone, Debug, Default, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct AuthQuery {
71 pub auth_key: Option<String>,
72 #[serde(default)]
73 pub key: Option<String>,
74 #[serde(default)]
75 pub sig: Option<String>,
76}
77
78#[derive(Debug, Error, PartialEq, Eq)]
79pub enum AuthError {
80 #[error("addon auth is not configured")]
81 Misconfigured,
82 #[error("missing addon auth key")]
83 Missing,
84 #[error("invalid addon auth key")]
85 Invalid,
86}
87
88fn auth_from_config(config: Option<&UserConfig>) -> Option<&str> {
89 config.and_then(|cfg| cfg.auth_key.as_deref())
90}
91
92fn auth_from_query(query: Option<&AuthQuery>) -> Option<&str> {
93 query.and_then(|q| q.auth_key.as_deref().or(q.key.as_deref()))
94}
95
96fn auth_from_bearer(headers: &HeaderMap) -> Option<&str> {
97 let value = headers.get(http::header::AUTHORIZATION)?.to_str().ok()?;
98 value.strip_prefix("Bearer ")
99}
100
101fn auth_from_header(headers: &HeaderMap) -> Option<&str> {
102 headers.get("x-addon-auth")?.to_str().ok()
103}
104
105fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
106 left.ct_eq(right).into()
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use http::HeaderValue;
113
114 #[test]
115 fn accepts_config_auth() {
116 let auth = AuthConfig::required("secret");
117 let cfg = UserConfig {
118 auth_key: Some("secret".to_string()),
119 ..UserConfig::default()
120 };
121
122 assert!(auth
123 .validate(Some(&cfg), None, &HeaderMap::new(), None)
124 .is_ok());
125 }
126
127 #[test]
128 fn accepts_query_auth() {
129 let auth = AuthConfig::required("secret");
130 let query = AuthQuery {
131 auth_key: Some("secret".to_string()),
132 key: None,
133 sig: None,
134 };
135
136 assert!(auth
137 .validate(None, Some(&query), &HeaderMap::new(), None)
138 .is_ok());
139 }
140
141 #[test]
142 fn accepts_bearer_auth() {
143 let auth = AuthConfig::required("secret");
144 let mut headers = HeaderMap::new();
145 headers.insert(
146 http::header::AUTHORIZATION,
147 HeaderValue::from_static("Bearer secret"),
148 );
149
150 assert!(auth.validate(None, None, &headers, None).is_ok());
151 }
152
153 #[test]
154 fn rejects_missing_auth() {
155 let auth = AuthConfig::required("secret");
156 assert_eq!(
157 auth.validate(None, None, &HeaderMap::new(), None)
158 .unwrap_err(),
159 AuthError::Missing
160 );
161 }
162
163 #[test]
164 fn rejects_invalid_auth() {
165 let auth = AuthConfig::required("secret");
166 let query = AuthQuery {
167 auth_key: Some("wrong".to_string()),
168 key: None,
169 sig: None,
170 };
171
172 assert_eq!(
173 auth.validate(None, Some(&query), &HeaderMap::new(), None)
174 .unwrap_err(),
175 AuthError::Invalid
176 );
177 }
178
179 #[test]
180 fn accepts_path_key_auth() {
181 let auth = AuthConfig::required("secret");
182 assert!(auth
183 .validate(None, None, &HeaderMap::new(), Some("secret"))
184 .is_ok());
185 }
186}