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}