Skip to main content

stremio_addon_core/
auth.rs

1use 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}