1use crate::auth::api_key::ApiKeyStore;
10use crate::auth::error::AuthError;
11use crate::auth::jwt::{JwtConfig, JwtValidator};
12use crate::auth::mtls::{MtlsConfig, MtlsValidator};
13use percent_encoding::percent_decode_str;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum AuthMethod {
18 ApiKey,
20 Jwt,
22 Mtls,
24 None,
26}
27
28#[derive(Debug, Clone)]
30pub struct AuthContext {
31 pub principal: String,
33 pub method: AuthMethod,
35 pub claims: std::collections::HashMap<String, serde_json::Value>,
37 pub admin: bool,
43 pub roles: Vec<String>,
52 pub namespaces: Vec<String>,
58}
59
60impl AuthContext {
61 #[must_use]
63 pub fn allows_namespace(&self, namespace: &str) -> bool {
64 self.namespaces.is_empty() || self.namespaces.iter().any(|n| n == namespace)
65 }
66
67 #[must_use]
73 pub fn principal(&self) -> crate::authz::ChronixPrincipal {
74 crate::authz::ChronixPrincipal::new(&self.principal).with_roles(self.roles.clone())
75 }
76}
77
78fn namespaces_from_claims(
84 extra: &std::collections::HashMap<String, serde_json::Value>,
85) -> Vec<String> {
86 match extra.get("namespaces") {
87 Some(serde_json::Value::Array(items)) => items
88 .iter()
89 .filter_map(|v| v.as_str().map(str::to_string))
90 .collect(),
91 Some(serde_json::Value::String(one)) => vec![one.clone()],
92 _ => Vec::new(),
93 }
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct AuthConfig {
99 pub api_key_store: Option<ApiKeyStore>,
101 pub jwt_config: Option<JwtConfig>,
103 pub mtls_config: Option<MtlsConfig>,
105 pub exempt_paths: Vec<String>,
107 pub allow_anonymous: bool,
111}
112
113#[derive(Debug, Clone)]
118pub struct AuthMiddleware {
119 api_key_store: Option<ApiKeyStore>,
120 jwt_validator: Option<JwtValidator>,
121 mtls_validator: Option<MtlsValidator>,
122 exempt_paths: Vec<String>,
123 any_configured: bool,
124 allow_anonymous: bool,
125}
126
127impl AuthMiddleware {
128 pub fn new(config: AuthConfig) -> Result<Self, crate::auth::error::AuthError> {
135 let any_configured = config.api_key_store.is_some()
136 || config.jwt_config.is_some()
137 || config.mtls_config.as_ref().is_some_and(|c| c.enabled);
138
139 let jwt_validator = config.jwt_config.map(JwtValidator::new).transpose()?;
140 let mtls_validator = config.mtls_config.map(MtlsValidator::new);
141
142 Ok(Self {
143 api_key_store: config.api_key_store,
144 jwt_validator,
145 mtls_validator,
146 exempt_paths: config.exempt_paths,
147 any_configured,
148 allow_anonymous: config.allow_anonymous,
149 })
150 }
151
152 #[must_use]
165 pub fn is_exempt(&self, path: &str) -> bool {
166 let path = path.split('?').next().unwrap_or(path);
168 let normalized = Self::normalize_path(path);
169 self.exempt_paths
170 .iter()
171 .any(|exempt| normalized == *exempt || normalized.starts_with(&format!("{exempt}/")))
172 }
173
174 fn normalize_path(path: &str) -> String {
180 let decoded = percent_decode_str(path).decode_utf8_lossy();
182 let mut segments: Vec<&str> = Vec::new();
183 for seg in decoded.split('/') {
184 match seg {
185 "" | "." => {}
186 ".." => {
187 segments.pop();
188 }
189 s => segments.push(s),
190 }
191 }
192 format!("/{}", segments.join("/"))
193 }
194
195 pub fn authenticate(
203 &self,
204 bearer_token: Option<&str>,
205 client_cert_cn: Option<&str>,
206 ) -> Result<AuthContext, AuthError> {
207 if !self.any_configured {
208 if self.allow_anonymous {
209 tracing::warn!(
210 "no authentication method configured and allow_anonymous=true -- \
211 allowing unauthenticated access"
212 );
213 return Ok(AuthContext {
214 principal: "anonymous".into(),
215 method: AuthMethod::None,
216 claims: std::collections::HashMap::new(),
217 admin: false,
218 roles: Vec::new(),
219 namespaces: Vec::new(),
220 });
221 }
222 tracing::warn!(
223 "no authentication method configured — rejecting request; \
224 configure at least one auth provider (JWT, API key, or mTLS) \
225 or set 'auth.allow_anonymous = true' to permit unauthenticated access"
226 );
227 return Err(AuthError::Config(
228 "no authentication method configured".into(),
229 ));
230 }
231
232 if let Some(ref mtls) = self.mtls_validator {
236 if let Some(cn) = client_cert_cn {
237 if mtls.is_enabled() {
238 let identity = mtls.extract_identity_from_cn(cn).map_err(|e| {
239 AuthError::InvalidCertificate(format!(
240 "mTLS certificate validation failed: {e}"
241 ))
242 })?;
243 return Ok(AuthContext {
244 principal: identity.principal().to_string(),
245 method: AuthMethod::Mtls,
246 claims: std::collections::HashMap::new(),
247 admin: false,
248 roles: Vec::new(),
251 namespaces: Vec::new(),
252 });
253 }
254 }
255 }
256
257 if let Some(ref jwt) = self.jwt_validator {
259 if let Some(token) = bearer_token {
260 if let Some(outcome) = self.judge_jwt(token, jwt.validate(token)) {
261 return outcome;
262 }
263 }
264 }
265
266 if let Some(ref store) = self.api_key_store {
268 if let Some(token) = bearer_token {
269 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
270 if !looks_like_jwt {
271 match store.validate(token) {
272 Ok(name) => {
273 let namespaces = store.namespaces_for(&name).to_vec();
274 let admin = store.is_admin(&name);
275 let roles = store.roles(&name);
276 return Ok(AuthContext {
277 principal: name,
278 method: AuthMethod::ApiKey,
279 claims: std::collections::HashMap::new(),
280 admin,
281 roles,
282 namespaces,
283 });
284 }
285 Err(AuthError::ExpiredApiKey(name)) => {
286 return Err(AuthError::ExpiredApiKey(name));
287 }
288 Err(_) => {
289 }
291 }
292 }
293 }
294 }
295
296 Err(AuthError::NoAuth)
297 }
298
299 pub async fn authenticate_async(
312 &self,
313 bearer_token: Option<&str>,
314 client_cert_cn: Option<&str>,
315 ) -> Result<AuthContext, AuthError> {
316 let jwks_path = self
318 .jwt_validator
319 .as_ref()
320 .is_some_and(|j| j.jwks_cache().is_some());
321 if !jwks_path {
322 return self.authenticate(bearer_token, client_cert_cn);
323 }
324
325 if let Some(ref mtls) = self.mtls_validator {
328 if client_cert_cn.is_some() && mtls.is_enabled() {
329 return self.authenticate(bearer_token, client_cert_cn);
330 }
331 }
332
333 if let (Some(jwt), Some(token)) = (self.jwt_validator.as_ref(), bearer_token) {
334 let result = jwt.validate_async(token).await;
335 if let Some(outcome) = self.judge_jwt(token, result) {
336 return outcome;
337 }
338 }
339
340 self.authenticate(bearer_token, client_cert_cn)
342 }
343
344 fn judge_jwt(
351 &self,
352 token: &str,
353 result: Result<crate::auth::jwt::JwtClaims, AuthError>,
354 ) -> Option<Result<AuthContext, AuthError>> {
355 let jwt = self.jwt_validator.as_ref()?;
356 match result {
357 Ok(claims) => {
358 let principal = jwt
359 .extract_principal(&claims)
360 .unwrap_or_else(|| "unknown".into());
361 let namespaces = namespaces_from_claims(&claims.extra);
362 let admin = claims
363 .extra
364 .get("admin")
365 .and_then(serde_json::Value::as_bool)
366 .unwrap_or(false);
367 let roles = jwt.extract_roles(&claims);
368 Some(Ok(AuthContext {
369 principal,
370 method: AuthMethod::Jwt,
371 claims: claims.extra,
372 admin,
373 roles,
374 namespaces,
375 }))
376 }
377 Err(AuthError::ExpiredJwt) => Some(Err(AuthError::ExpiredJwt)),
378 Err(e) => {
379 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
380 if looks_like_jwt {
381 Some(Err(e))
382 } else {
383 None
384 }
385 }
386 }
387 }
388
389 #[must_use]
391 pub fn jwt_validator(&self) -> Option<&JwtValidator> {
392 self.jwt_validator.as_ref()
393 }
394
395 pub fn set_jwks_cache(&mut self, cache: std::sync::Arc<crate::auth::oidc::JwksCache>) {
399 if let Some(validator) = self.jwt_validator.take() {
400 self.jwt_validator = Some(validator.with_jwks_cache(cache));
401 }
402 }
403
404 #[must_use]
406 pub fn is_configured(&self) -> bool {
407 self.any_configured
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use zeroize::Zeroizing;
415
416 #[test]
417 fn no_auth_configured_rejects_by_default() {
418 let middleware = AuthMiddleware::new(AuthConfig::default()).unwrap();
419 let result = middleware.authenticate(None, None);
420 assert!(result.is_err(), "should reject when no auth is configured");
421 }
422
423 #[test]
424 fn no_auth_configured_allows_with_anonymous() {
425 let middleware = AuthMiddleware::new(AuthConfig {
426 allow_anonymous: true,
427 ..Default::default()
428 })
429 .unwrap();
430 let ctx = middleware.authenticate(None, None).unwrap();
431 assert_eq!(ctx.method, AuthMethod::None);
432 assert_eq!(ctx.principal, "anonymous");
433 }
434
435 #[test]
436 fn api_key_authentication() {
437 let mut store = ApiKeyStore::new();
438 let key = store.create_key("test-key", None).unwrap();
439
440 let middleware = AuthMiddleware::new(AuthConfig {
441 api_key_store: Some(store),
442 ..Default::default()
443 })
444 .unwrap();
445
446 let ctx = middleware.authenticate(Some(&key), None).unwrap();
447 assert_eq!(ctx.method, AuthMethod::ApiKey);
448 assert_eq!(ctx.principal, "test-key");
449 }
450
451 #[test]
452 fn api_key_invalid_rejected() {
453 let mut store = ApiKeyStore::new();
454 let _key = store.create_key("k", None).unwrap();
455
456 let middleware = AuthMiddleware::new(AuthConfig {
457 api_key_store: Some(store),
458 ..Default::default()
459 })
460 .unwrap();
461
462 let err = middleware.authenticate(Some("wrong"), None).unwrap_err();
463 assert!(matches!(err, AuthError::NoAuth));
464 }
465
466 #[test]
467 fn jwt_authentication() {
468 let secret = "test-jwt-secret-for-middleware!!"; let config = JwtConfig {
470 secret: Some(Zeroizing::new(secret.into())),
471 ..Default::default()
472 };
473
474 let claims = crate::auth::jwt::JwtClaims {
475 sub: Some("jwt-user".into()),
476 exp: Some(
477 std::time::SystemTime::now()
478 .duration_since(std::time::UNIX_EPOCH)
479 .unwrap()
480 .as_secs()
481 + 3600,
482 ),
483 iss: None,
484 aud: None,
485 iat: None,
486 email: None,
487 jti: None,
488 extra: std::collections::HashMap::new(),
489 };
490
491 let token = jsonwebtoken::encode(
492 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
493 &claims,
494 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
495 )
496 .unwrap();
497
498 let middleware = AuthMiddleware::new(AuthConfig {
499 jwt_config: Some(config),
500 ..Default::default()
501 })
502 .unwrap();
503
504 let ctx = middleware.authenticate(Some(&token), None).unwrap();
505 assert_eq!(ctx.method, AuthMethod::Jwt);
506 assert_eq!(ctx.principal, "jwt-user");
507 }
508
509 #[test]
510 fn mtls_authentication() {
511 let middleware = AuthMiddleware::new(AuthConfig {
512 mtls_config: Some(MtlsConfig {
513 enabled: true,
514 use_cn: true,
515 ..Default::default()
516 }),
517 ..Default::default()
518 })
519 .unwrap();
520
521 let ctx = middleware
522 .authenticate(None, Some("client.internal"))
523 .unwrap();
524 assert_eq!(ctx.method, AuthMethod::Mtls);
525 assert_eq!(ctx.principal, "client.internal");
526 }
527
528 #[test]
529 fn mtls_takes_priority_over_jwt() {
530 let secret = "test-secret-at-least-32-bytes!!!";
531 let claims = crate::auth::jwt::JwtClaims {
532 sub: Some("jwt-user".into()),
533 exp: Some(
534 std::time::SystemTime::now()
535 .duration_since(std::time::UNIX_EPOCH)
536 .unwrap()
537 .as_secs()
538 + 3600,
539 ),
540 iss: None,
541 aud: None,
542 iat: None,
543 email: None,
544 jti: None,
545 extra: std::collections::HashMap::new(),
546 };
547
548 let token = jsonwebtoken::encode(
549 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
550 &claims,
551 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
552 )
553 .unwrap();
554
555 let middleware = AuthMiddleware::new(AuthConfig {
556 jwt_config: Some(JwtConfig {
557 secret: Some(Zeroizing::new(secret.into())),
558 ..Default::default()
559 }),
560 mtls_config: Some(MtlsConfig {
561 enabled: true,
562 use_cn: true,
563 ..Default::default()
564 }),
565 ..Default::default()
566 })
567 .unwrap();
568
569 let ctx = middleware
571 .authenticate(Some(&token), Some("mtls-client"))
572 .unwrap();
573 assert_eq!(ctx.method, AuthMethod::Mtls);
574 }
575
576 #[test]
577 fn exempt_paths() {
578 let middleware = AuthMiddleware::new(AuthConfig {
579 exempt_paths: vec!["/health".into(), "/ready".into(), "/metrics".into()],
580 ..Default::default()
581 })
582 .unwrap();
583
584 assert!(middleware.is_exempt("/health"));
586 assert!(middleware.is_exempt("/ready"));
587 assert!(middleware.is_exempt("/metrics"));
588
589 assert!(middleware.is_exempt("/health/live"));
591 assert!(middleware.is_exempt("/metrics/prometheus"));
592
593 assert!(!middleware.is_exempt("/api/v1/write"));
595
596 assert!(!middleware.is_exempt("/healthz"));
598 assert!(!middleware.is_exempt("/health-admin"));
599
600 assert!(!middleware.is_exempt("/health/../api/v1/write"));
602 assert!(!middleware.is_exempt("/health/../../secret"));
603
604 assert!(middleware.is_exempt("/health//live"));
606
607 assert!(middleware.is_exempt("/health?foo=bar"));
609 assert!(!middleware.is_exempt("/healthz?x=1"));
610 }
611
612 #[test]
615 fn percent_encoding_bypass_prevented() {
616 let middleware = AuthMiddleware::new(AuthConfig {
617 exempt_paths: vec!["/health".into(), "/metrics".into()],
618 ..Default::default()
619 })
620 .unwrap();
621
622 assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
624 assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
625
626 assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
628 assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
629
630 assert!(middleware.is_exempt("/health/%252E%252E"));
633
634 assert!(middleware.is_exempt("/health"));
636 assert!(middleware.is_exempt("/metrics"));
637 assert!(middleware.is_exempt("/health/sub"));
638 }
639
640 #[test]
641 fn no_bearer_with_auth_configured_fails() {
642 let mut store = ApiKeyStore::new();
643 let _key = store.create_key("k", None).unwrap();
644
645 let middleware = AuthMiddleware::new(AuthConfig {
646 api_key_store: Some(store),
647 ..Default::default()
648 })
649 .unwrap();
650
651 let err = middleware.authenticate(None, None).unwrap_err();
652 assert!(matches!(err, AuthError::NoAuth));
653 }
654}