Skip to main content

crates_docs/server/auth/
config.rs

1//! Authentication configuration
2
3use crate::error::{Error, Result};
4use url::Url;
5
6#[cfg(feature = "api-key")]
7use api_keys_simplified::{
8    ApiKey, ApiKeyManagerV0, Environment, ExposeSecret, HashConfig, KeyConfig, KeyStatus,
9    SecureString,
10};
11
12use super::types::{GeneratedApiKey, OAuthProvider};
13
14/// OAuth configuration
15#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
16pub struct OAuthConfig {
17    /// Whether OAuth is enabled
18    #[serde(default)]
19    pub enabled: bool,
20    /// Client ID
21    #[serde(default)]
22    pub client_id: Option<String>,
23    /// Client secret
24    #[serde(default)]
25    pub client_secret: Option<String>,
26    /// Redirect URI
27    #[serde(default)]
28    pub redirect_uri: Option<String>,
29    /// Authorization endpoint
30    #[serde(default)]
31    pub authorization_endpoint: Option<String>,
32    /// Token endpoint
33    #[serde(default)]
34    pub token_endpoint: Option<String>,
35    /// Scopes
36    #[serde(default = "default_oauth_scopes")]
37    pub scopes: Vec<String>,
38    /// Authentication provider type
39    #[serde(default)]
40    pub provider: OAuthProvider,
41}
42
43fn default_oauth_scopes() -> Vec<String> {
44    vec![
45        "openid".to_string(),
46        "profile".to_string(),
47        "email".to_string(),
48    ]
49}
50
51impl Default for OAuthConfig {
52    fn default() -> Self {
53        Self {
54            enabled: false,
55            client_id: None,
56            client_secret: None,
57            redirect_uri: None,
58            authorization_endpoint: None,
59            token_endpoint: None,
60            scopes: vec![
61                "openid".to_string(),
62                "profile".to_string(),
63                "email".to_string(),
64            ],
65            provider: OAuthProvider::Custom,
66        }
67    }
68}
69
70impl OAuthConfig {
71    /// Create GitHub OAuth configuration
72    #[must_use]
73    pub fn github(client_id: String, client_secret: String, redirect_uri: String) -> Self {
74        Self {
75            enabled: true,
76            client_id: Some(client_id),
77            client_secret: Some(client_secret),
78            redirect_uri: Some(redirect_uri),
79            authorization_endpoint: Some("https://github.com/login/oauth/authorize".to_string()),
80            token_endpoint: Some("https://github.com/login/oauth/access_token".to_string()),
81            scopes: vec!["read:user".to_string(), "user:email".to_string()],
82            provider: OAuthProvider::GitHub,
83        }
84    }
85
86    /// Create Google OAuth configuration
87    #[must_use]
88    pub fn google(client_id: String, client_secret: String, redirect_uri: String) -> Self {
89        Self {
90            enabled: true,
91            client_id: Some(client_id),
92            client_secret: Some(client_secret),
93            redirect_uri: Some(redirect_uri),
94            authorization_endpoint: Some(
95                "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
96            ),
97            token_endpoint: Some("https://oauth2.googleapis.com/token".to_string()),
98            scopes: vec![
99                "openid".to_string(),
100                "https://www.googleapis.com/auth/userinfo.profile".to_string(),
101                "https://www.googleapis.com/auth/userinfo.email".to_string(),
102            ],
103            provider: OAuthProvider::Google,
104        }
105    }
106
107    /// Create Keycloak OAuth configuration
108    #[must_use]
109    pub fn keycloak(
110        client_id: String,
111        client_secret: String,
112        redirect_uri: String,
113        base_url: &str,
114        realm: &str,
115    ) -> Self {
116        let base = base_url.trim_end_matches('/');
117        Self {
118            enabled: true,
119            client_id: Some(client_id),
120            client_secret: Some(client_secret),
121            redirect_uri: Some(redirect_uri),
122            authorization_endpoint: Some(format!(
123                "{base}/realms/{realm}/protocol/openid-connect/auth"
124            )),
125            token_endpoint: Some(format!(
126                "{base}/realms/{realm}/protocol/openid-connect/token"
127            )),
128            scopes: vec![
129                "openid".to_string(),
130                "profile".to_string(),
131                "email".to_string(),
132            ],
133            provider: OAuthProvider::Keycloak,
134        }
135    }
136
137    /// Validate configuration
138    pub fn validate(&self) -> Result<()> {
139        if !self.enabled {
140            return Ok(());
141        }
142
143        if self.client_id.is_none() {
144            return Err(Error::config("client_id", "is required"));
145        }
146
147        if self.client_secret.is_none() {
148            return Err(Error::config("client_secret", "is required"));
149        }
150
151        if self.redirect_uri.is_none() {
152            return Err(Error::config("redirect_uri", "is required"));
153        }
154
155        if self.authorization_endpoint.is_none() {
156            return Err(Error::config("authorization_endpoint", "is required"));
157        }
158
159        if self.token_endpoint.is_none() {
160            return Err(Error::config("token_endpoint", "is required"));
161        }
162
163        // Validate URLs
164        if let Some(uri) = &self.redirect_uri {
165            Url::parse(uri)
166                .map_err(|e| Error::config("redirect_uri", format!("Invalid URL: {e}")))?;
167        }
168
169        if let Some(endpoint) = &self.authorization_endpoint {
170            Url::parse(endpoint).map_err(|e| {
171                Error::config("authorization_endpoint", format!("Invalid URL: {e}"))
172            })?;
173        }
174
175        if let Some(endpoint) = &self.token_endpoint {
176            Url::parse(endpoint)
177                .map_err(|e| Error::config("token_endpoint", format!("Invalid URL: {e}")))?;
178        }
179
180        Ok(())
181    }
182
183    /// Convert to rust-mcp-sdk `OAuthConfig`
184    #[cfg(feature = "auth")]
185    pub fn to_mcp_config(&self) -> Result<()> {
186        if !self.enabled {
187            return Err(Error::config("oauth", "is not enabled"));
188        }
189
190        // Temporarily return empty result, to be implemented when OAuth feature is complete
191        Ok(())
192    }
193
194    /// Convert to rust-mcp-sdk `OAuthConfig`
195    #[cfg(not(feature = "auth"))]
196    pub fn to_mcp_config(&self) -> Result<()> {
197        Err(Error::config("oauth", "feature is not enabled"))
198    }
199}
200
201/// API Key configuration
202#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
203#[cfg(feature = "api-key")]
204pub struct ApiKeyConfig {
205    /// Whether API key authentication is enabled
206    #[serde(default)]
207    pub enabled: bool,
208    /// List of valid API key hashes in PHC format.
209    ///
210    /// For backward compatibility, plain-text keys are also accepted and will be
211    /// verified with a constant-time string comparison fallback.
212    /// New deployments should store only hashed keys generated by
213    /// `ApiKeyConfig::generate_key()`.
214    #[serde(default)]
215    pub keys: Vec<String>,
216    /// Header name for API key (default: "X-API-Key")
217    #[serde(default = "default_header_name")]
218    pub header_name: String,
219    /// Query parameter name for API key (default: `api_key`)
220    #[serde(default = "default_query_param_name")]
221    pub query_param_name: String,
222    /// Whether to allow API key in query parameters (less secure)
223    #[serde(default)]
224    pub allow_query_param: bool,
225    /// API key prefix used by generated keys (e.g., "sk")
226    #[serde(default = "default_key_prefix")]
227    pub key_prefix: String,
228}
229
230#[cfg(feature = "api-key")]
231fn default_header_name() -> String {
232    "X-API-Key".to_string()
233}
234
235#[cfg(feature = "api-key")]
236fn default_query_param_name() -> String {
237    "api_key".to_string()
238}
239
240#[cfg(feature = "api-key")]
241fn default_key_prefix() -> String {
242    "sk".to_string()
243}
244
245#[cfg(feature = "api-key")]
246impl Default for ApiKeyConfig {
247    fn default() -> Self {
248        Self {
249            enabled: false,
250            keys: Vec::new(),
251            header_name: default_header_name(),
252            query_param_name: default_query_param_name(),
253            allow_query_param: false,
254            key_prefix: default_key_prefix(),
255        }
256    }
257}
258
259#[cfg(feature = "api-key")]
260impl ApiKeyConfig {
261    fn manager(&self) -> Result<ApiKeyManagerV0> {
262        ApiKeyManagerV0::init_default_config(self.key_prefix.clone())
263            .map_err(|e| Error::initialization("api_key_manager", e.to_string()))
264    }
265
266    fn legacy_manager(&self) -> Result<ApiKeyManagerV0> {
267        ApiKeyManagerV0::init(
268            self.key_prefix.clone(),
269            KeyConfig::default().disable_checksum(),
270            HashConfig::default(),
271            std::time::Duration::from_secs(10),
272        )
273        .map_err(|e| Error::initialization("api_key_manager", e.to_string()))
274    }
275
276    fn looks_like_hash(value: &str) -> bool {
277        value.starts_with("$argon2")
278    }
279
280    fn looks_like_legacy_hash(value: &str) -> bool {
281        value.starts_with("legacy:$argon2")
282    }
283
284    fn verify_plaintext_fallback(key: &str, stored_key: &str) -> bool {
285        use api_keys_simplified::SecureStringExt;
286
287        let provided = SecureString::from(key.to_string());
288        let expected = SecureString::from(stored_key.to_string());
289
290        provided.eq(&expected)
291    }
292
293    fn hash_legacy_key(&self, key: &str) -> Result<String> {
294        let manager = self.legacy_manager()?;
295        let seed = self.generate_key()?;
296        let secure = SecureString::from(key.to_string());
297        let hasher = manager.hasher();
298        let api_key = ApiKey::new(secure)
299            .into_hashed_with_phc(hasher, &seed.hash)
300            .map_err(|e| Error::initialization("api_key_hashing", e.to_string()))?;
301        Ok(format!("legacy:{}", api_key.expose_hash().hash()))
302    }
303
304    /// Validate configuration
305    pub fn validate(&self) -> Result<()> {
306        if !self.enabled {
307            return Ok(());
308        }
309
310        if self.keys.is_empty() {
311            tracing::warn!("API key authentication is enabled but no keys are configured");
312        }
313
314        if self.header_name.is_empty() {
315            return Err(Error::config("header_name", "cannot be empty"));
316        }
317
318        if self.allow_query_param && self.query_param_name.is_empty() {
319            return Err(Error::config(
320                "query_param_name",
321                "cannot be empty when allow_query_param is true",
322            ));
323        }
324
325        if self.key_prefix.is_empty() {
326            return Err(Error::config("key_prefix", "cannot be empty"));
327        }
328
329        let _ = self.manager()?;
330
331        Ok(())
332    }
333
334    /// Check if a key is valid
335    #[must_use]
336    pub fn is_valid_key(&self, key: &str) -> bool {
337        if !self.enabled {
338            return true;
339        }
340
341        let manager = self.manager().ok();
342        let legacy_manager = self.legacy_manager().ok();
343        let provided_key = SecureString::from(key.to_string());
344
345        // Evaluate every stored key without short-circuiting so that lookup
346        // time does not depend on which key (if any) matches. `iter().any()`
347        // would return as soon as the first match is found, leaking a timing
348        // side-channel about key position; fold-with-OR always visits all keys.
349        self.keys.iter().fold(false, |found, stored| {
350            let matched = if Self::looks_like_legacy_hash(stored) {
351                if let Some(legacy_manager) = &legacy_manager {
352                    let stored_hash = stored.trim_start_matches("legacy:");
353                    matches!(
354                        legacy_manager.verify(&provided_key, stored_hash),
355                        Ok(KeyStatus::Valid)
356                    )
357                } else {
358                    false
359                }
360            } else if Self::looks_like_hash(stored) {
361                if let Some(manager) = &manager {
362                    matches!(manager.verify(&provided_key, stored), Ok(KeyStatus::Valid))
363                } else {
364                    false
365                }
366            } else {
367                Self::verify_plaintext_fallback(key, stored)
368            };
369            found | matched
370        })
371    }
372
373    /// Generate a new API key and corresponding hash using api-keys-simplified.
374    ///
375    /// The returned plain-text key should be shown once and then discarded.
376    /// Persist only the returned hash.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if key generation fails
381    pub fn generate_key(&self) -> Result<GeneratedApiKey> {
382        let manager = self.manager()?;
383
384        let key = manager
385            .generate(Environment::production())
386            .map_err(|e| Error::initialization("api_key_generation", e.to_string()))?;
387
388        Ok(GeneratedApiKey {
389            key: key.key().expose_secret().to_string(),
390            key_id: key.expose_hash().key_id().to_owned(),
391            hash: key.expose_hash().hash().to_owned(),
392        })
393    }
394
395    /// Normalize API key material for storage.
396    ///
397    /// - Structured Argon2 hashes are kept as-is
398    /// - Legacy plain-text keys are converted to `legacy:`-prefixed Argon2 hashes
399    /// - Plain-text fallback remains supported for backward compatibility
400    ///
401    /// # Errors
402    ///
403    /// Returns an error if hashing legacy key material fails.
404    pub fn normalize_key_material(&self, key: &str) -> Result<String> {
405        if Self::looks_like_hash(key) || Self::looks_like_legacy_hash(key) {
406            Ok(key.to_string())
407        } else {
408            self.hash_legacy_key(key)
409        }
410    }
411}
412
413/// Authentication configuration (unified for OAuth and API Key)
414#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
415pub struct AuthConfig {
416    /// OAuth configuration
417    #[serde(default)]
418    pub oauth: OAuthConfig,
419    /// API key configuration
420    #[cfg(feature = "api-key")]
421    #[serde(default)]
422    pub api_key: ApiKeyConfig,
423}
424
425impl AuthConfig {
426    /// Validate configuration
427    pub fn validate(&self) -> Result<()> {
428        self.oauth.validate()?;
429        #[cfg(feature = "api-key")]
430        self.api_key.validate()?;
431        Ok(())
432    }
433
434    /// Check if any authentication is enabled
435    #[must_use]
436    #[cfg(feature = "api-key")]
437    pub fn is_enabled(&self) -> bool {
438        self.oauth.enabled || self.api_key.enabled
439    }
440
441    /// Check if any authentication is enabled
442    #[must_use]
443    #[cfg(not(feature = "api-key"))]
444    pub fn is_enabled(&self) -> bool {
445        self.oauth.enabled
446    }
447}