specado_core/
auth.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use thiserror::Error;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(tag = "type", rename_all = "lowercase")]
7pub enum AuthScheme {
8    Bearer {
9        token_env: String,
10    },
11    #[serde(rename = "apikey")]
12    ApiKey {
13        header: String,
14        key_env: String,
15    },
16    Custom {
17        headers: HashMap<String, String>,
18    },
19}
20
21#[derive(Debug, Error, Clone, PartialEq, Eq)]
22pub enum AuthError {
23    #[error("Missing environment variable: {0}")]
24    MissingEnvVar(String),
25    #[error("Invalid auth configuration: {0}")]
26    InvalidConfig(String),
27}
28
29pub struct AuthHandler {
30    scheme: AuthScheme,
31}
32
33impl AuthHandler {
34    pub fn new(scheme: AuthScheme) -> Self {
35        Self { scheme }
36    }
37
38    pub fn scheme(&self) -> &AuthScheme {
39        &self.scheme
40    }
41
42    pub fn validate(&self) -> Result<(), AuthError> {
43        match &self.scheme {
44            AuthScheme::Bearer { token_env } => {
45                Self::require_env(token_env)?;
46            }
47            AuthScheme::ApiKey { key_env, .. } => {
48                Self::require_env(key_env)?;
49            }
50            AuthScheme::Custom { headers } => {
51                for value in headers.values() {
52                    Self::expand_env_var(value)?;
53                }
54            }
55        }
56        Ok(())
57    }
58
59    pub fn inject_headers(&self, headers: &mut HashMap<String, String>) -> Result<(), AuthError> {
60        match &self.scheme {
61            AuthScheme::Bearer { token_env } => {
62                let token = Self::require_env(token_env)?;
63                headers.insert("Authorization".to_string(), format!("Bearer {}", token));
64            }
65            AuthScheme::ApiKey { header, key_env } => {
66                let key = Self::require_env(key_env)?;
67                headers.insert(header.clone(), key);
68            }
69            AuthScheme::Custom { headers: custom } => {
70                for (key, value_template) in custom {
71                    let value = Self::expand_env_var(value_template)?;
72                    headers.insert(key.clone(), value);
73                }
74            }
75        }
76        Ok(())
77    }
78
79    fn require_env(var: &str) -> Result<String, AuthError> {
80        std::env::var(var).map_err(|_| AuthError::MissingEnvVar(var.to_string()))
81    }
82
83    fn expand_env_var(template: &str) -> Result<String, AuthError> {
84        if let Some(stripped) = template
85            .strip_prefix("${ENV:")
86            .and_then(|s| s.strip_suffix('}'))
87        {
88            Self::require_env(stripped)
89        } else if template.contains("${ENV:") {
90            Err(AuthError::InvalidConfig(template.to_string()))
91        } else {
92            Ok(template.to_string())
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn unset(name: &str) {
102        std::env::remove_var(name);
103    }
104
105    #[test]
106    fn bearer_missing_env_returns_error() {
107        let var = "SPECADO_TEST_TOKEN";
108        unset(var);
109        let handler = AuthHandler::new(AuthScheme::Bearer {
110            token_env: var.to_string(),
111        });
112        let mut headers = HashMap::new();
113        let err = handler.inject_headers(&mut headers).unwrap_err();
114        assert!(matches!(err, AuthError::MissingEnvVar(m) if m == var));
115    }
116
117    #[test]
118    fn injects_bearer_header() {
119        let var = "SPECADO_TEST_TOKEN_OK";
120        std::env::set_var(var, "token-value");
121        let handler = AuthHandler::new(AuthScheme::Bearer {
122            token_env: var.to_string(),
123        });
124        let mut headers = HashMap::new();
125        handler.inject_headers(&mut headers).unwrap();
126        assert_eq!(headers.get("Authorization").unwrap(), "Bearer token-value");
127        unset(var);
128    }
129
130    #[test]
131    fn custom_env_expansion() {
132        let key = "SPECADO_CUSTOM_KEY";
133        std::env::set_var(key, "123");
134        let handler = AuthHandler::new(AuthScheme::Custom {
135            headers: HashMap::from([(
136                "X-Api-Key".to_string(),
137                "${ENV:SPECADO_CUSTOM_KEY}".to_string(),
138            )]),
139        });
140        let mut headers = HashMap::new();
141        handler.inject_headers(&mut headers).unwrap();
142        assert_eq!(headers["X-Api-Key"], "123");
143        unset(key);
144    }
145
146    #[test]
147    fn validate_checks_custom_placeholders() {
148        let handler = AuthHandler::new(AuthScheme::Custom {
149            headers: HashMap::from([("X-Thing".to_string(), "${ENV:SPECADO_MISSING}".to_string())]),
150        });
151        let err = handler.validate().unwrap_err();
152        assert!(matches!(err, AuthError::MissingEnvVar(var) if var == "SPECADO_MISSING"));
153    }
154
155    #[test]
156    fn invalid_placeholder_returns_invalid_config() {
157        let handler = AuthHandler::new(AuthScheme::Custom {
158            headers: HashMap::from([("X-Thing".to_string(), "${ENV:UNTERMINATED".to_string())]),
159        });
160        let err = handler.validate().unwrap_err();
161        assert!(matches!(err, AuthError::InvalidConfig(_)));
162    }
163}