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