1use 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
16pub struct OAuthConfig {
17 pub enabled: bool,
19 pub client_id: Option<String>,
21 pub client_secret: Option<String>,
23 pub redirect_uri: Option<String>,
25 pub authorization_endpoint: Option<String>,
27 pub token_endpoint: Option<String>,
29 pub scopes: Vec<String>,
31 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 #[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 #[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 #[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 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 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 #[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 Ok(())
176 }
177
178 #[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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
187#[cfg(feature = "api-key")]
188pub struct ApiKeyConfig {
189 pub enabled: bool,
191 pub keys: Vec<String>,
198 #[serde(default = "default_header_name")]
200 pub header_name: String,
201 #[serde(default = "default_query_param_name")]
203 pub query_param_name: String,
204 #[serde(default)]
206 pub allow_query_param: bool,
207 #[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 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 #[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 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 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
392pub struct AuthConfig {
393 pub oauth: OAuthConfig,
395 #[cfg(feature = "api-key")]
397 pub api_key: ApiKeyConfig,
398}
399
400impl AuthConfig {
401 pub fn validate(&self) -> Result<()> {
403 self.oauth.validate()?;
404 #[cfg(feature = "api-key")]
405 self.api_key.validate()?;
406 Ok(())
407 }
408
409 #[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 #[must_use]
418 #[cfg(not(feature = "api-key"))]
419 pub fn is_enabled(&self) -> bool {
420 self.oauth.enabled
421 }
422}