stremio-addon-core 0.1.2

Reusable Rust core for authenticated Stremio addon servers
Documentation
use crate::config::UserConfig;
use axum::http::HeaderMap;
use serde::Deserialize;
use std::fmt;
use subtle::ConstantTimeEq;
use thiserror::Error;

#[derive(Clone)]
pub struct AuthConfig {
    key: Option<String>,
    required: bool,
}

impl AuthConfig {
    pub fn required(key: impl Into<String>) -> Self {
        Self {
            key: Some(key.into()),
            required: true,
        }
    }

    pub fn disabled() -> Self {
        Self {
            key: None,
            required: false,
        }
    }

    pub fn is_required(&self) -> bool {
        self.required
    }

    pub fn validate(
        &self,
        user_config: Option<&UserConfig>,
        query: Option<&AuthQuery>,
        headers: &HeaderMap,
        path_key: Option<&str>,
    ) -> Result<(), AuthError> {
        if !self.required {
            return Ok(());
        }

        let expected = self.key.as_deref().ok_or(AuthError::Misconfigured)?;
        let candidate = auth_from_config(user_config)
            .or(path_key)
            .or_else(|| auth_from_query(query))
            .or_else(|| auth_from_bearer(headers))
            .or_else(|| auth_from_header(headers));

        match candidate {
            Some(value) if constant_time_eq(value.as_bytes(), expected.as_bytes()) => Ok(()),
            Some(_) => Err(AuthError::Invalid),
            None => Err(AuthError::Missing),
        }
    }
}

impl fmt::Debug for AuthConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("AuthConfig")
            .field("key", &self.key.as_ref().map(|_| "<redacted>"))
            .field("required", &self.required)
            .finish()
    }
}

#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthQuery {
    pub auth_key: Option<String>,
    #[serde(default)]
    pub key: Option<String>,
    #[serde(default)]
    pub sig: Option<String>,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum AuthError {
    #[error("addon auth is not configured")]
    Misconfigured,
    #[error("missing addon auth key")]
    Missing,
    #[error("invalid addon auth key")]
    Invalid,
}

fn auth_from_config(config: Option<&UserConfig>) -> Option<&str> {
    config.and_then(|cfg| cfg.auth_key.as_deref())
}

fn auth_from_query(query: Option<&AuthQuery>) -> Option<&str> {
    query.and_then(|q| q.auth_key.as_deref().or(q.key.as_deref()))
}

fn auth_from_bearer(headers: &HeaderMap) -> Option<&str> {
    let value = headers.get(http::header::AUTHORIZATION)?.to_str().ok()?;
    value.strip_prefix("Bearer ")
}

fn auth_from_header(headers: &HeaderMap) -> Option<&str> {
    headers.get("x-addon-auth")?.to_str().ok()
}

fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
    left.ct_eq(right).into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use http::HeaderValue;

    #[test]
    fn accepts_config_auth() {
        let auth = AuthConfig::required("secret");
        let cfg = UserConfig {
            auth_key: Some("secret".to_string()),
            ..UserConfig::default()
        };

        assert!(auth
            .validate(Some(&cfg), None, &HeaderMap::new(), None)
            .is_ok());
    }

    #[test]
    fn accepts_query_auth() {
        let auth = AuthConfig::required("secret");
        let query = AuthQuery {
            auth_key: Some("secret".to_string()),
            key: None,
            sig: None,
        };

        assert!(auth
            .validate(None, Some(&query), &HeaderMap::new(), None)
            .is_ok());
    }

    #[test]
    fn accepts_bearer_auth() {
        let auth = AuthConfig::required("secret");
        let mut headers = HeaderMap::new();
        headers.insert(
            http::header::AUTHORIZATION,
            HeaderValue::from_static("Bearer secret"),
        );

        assert!(auth.validate(None, None, &headers, None).is_ok());
    }

    #[test]
    fn rejects_missing_auth() {
        let auth = AuthConfig::required("secret");
        assert_eq!(
            auth.validate(None, None, &HeaderMap::new(), None)
                .unwrap_err(),
            AuthError::Missing
        );
    }

    #[test]
    fn rejects_invalid_auth() {
        let auth = AuthConfig::required("secret");
        let query = AuthQuery {
            auth_key: Some("wrong".to_string()),
            key: None,
            sig: None,
        };

        assert_eq!(
            auth.validate(None, Some(&query), &HeaderMap::new(), None)
                .unwrap_err(),
            AuthError::Invalid
        );
    }

    #[test]
    fn accepts_path_key_auth() {
        let auth = AuthConfig::required("secret");
        assert!(auth
            .validate(None, None, &HeaderMap::new(), Some("secret"))
            .is_ok());
    }
}