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 && let Some(cn) = client_cert_cn
237 && mtls.is_enabled()
238 {
239 let identity = mtls.extract_identity_from_cn(cn).map_err(|e| {
240 AuthError::InvalidCertificate(format!("mTLS certificate validation failed: {e}"))
241 })?;
242 return Ok(AuthContext {
243 principal: identity.principal().to_string(),
244 method: AuthMethod::Mtls,
245 claims: std::collections::HashMap::new(),
246 admin: false,
247 roles: Vec::new(),
250 namespaces: Vec::new(),
251 });
252 }
253
254 if let Some(ref jwt) = self.jwt_validator
256 && let Some(token) = bearer_token
257 && let Some(outcome) = self.judge_jwt(token, jwt.validate(token))
258 {
259 return outcome;
260 }
261
262 if let Some(ref store) = self.api_key_store
264 && let Some(token) = bearer_token
265 {
266 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
267 if !looks_like_jwt {
268 match store.validate(token) {
269 Ok(name) => {
270 let namespaces = store.namespaces_for(&name).to_vec();
271 let admin = store.is_admin(&name);
272 let roles = store.roles(&name);
273 return Ok(AuthContext {
274 principal: name,
275 method: AuthMethod::ApiKey,
276 claims: std::collections::HashMap::new(),
277 admin,
278 roles,
279 namespaces,
280 });
281 }
282 Err(AuthError::ExpiredApiKey(name)) => {
283 return Err(AuthError::ExpiredApiKey(name));
284 }
285 Err(_) => {
286 }
288 }
289 }
290 }
291
292 Err(AuthError::NoAuth)
293 }
294
295 pub async fn authenticate_async(
308 &self,
309 bearer_token: Option<&str>,
310 client_cert_cn: Option<&str>,
311 ) -> Result<AuthContext, AuthError> {
312 let jwks_path = self
314 .jwt_validator
315 .as_ref()
316 .is_some_and(|j| j.jwks_cache().is_some());
317 if !jwks_path {
318 return self.authenticate(bearer_token, client_cert_cn);
319 }
320
321 if let Some(ref mtls) = self.mtls_validator
324 && client_cert_cn.is_some()
325 && mtls.is_enabled()
326 {
327 return self.authenticate(bearer_token, client_cert_cn);
328 }
329
330 if let (Some(jwt), Some(token)) = (self.jwt_validator.as_ref(), bearer_token) {
331 let result = jwt.validate_async(token).await;
332 if let Some(outcome) = self.judge_jwt(token, result) {
333 return outcome;
334 }
335 }
336
337 self.authenticate(bearer_token, client_cert_cn)
339 }
340
341 fn judge_jwt(
348 &self,
349 token: &str,
350 result: Result<crate::auth::jwt::JwtClaims, AuthError>,
351 ) -> Option<Result<AuthContext, AuthError>> {
352 let jwt = self.jwt_validator.as_ref()?;
353 match result {
354 Ok(claims) => {
355 let principal = jwt
356 .extract_principal(&claims)
357 .unwrap_or_else(|| "unknown".into());
358 let namespaces = namespaces_from_claims(&claims.extra);
359 let admin = claims
360 .extra
361 .get("admin")
362 .and_then(serde_json::Value::as_bool)
363 .unwrap_or(false);
364 let roles = jwt.extract_roles(&claims);
365 Some(Ok(AuthContext {
366 principal,
367 method: AuthMethod::Jwt,
368 claims: claims.extra,
369 admin,
370 roles,
371 namespaces,
372 }))
373 }
374 Err(AuthError::ExpiredJwt) => Some(Err(AuthError::ExpiredJwt)),
375 Err(e) => {
376 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
377 if looks_like_jwt { Some(Err(e)) } else { None }
378 }
379 }
380 }
381
382 #[must_use]
384 pub fn jwt_validator(&self) -> Option<&JwtValidator> {
385 self.jwt_validator.as_ref()
386 }
387
388 pub fn set_jwks_cache(&mut self, cache: std::sync::Arc<crate::auth::oidc::JwksCache>) {
392 if let Some(validator) = self.jwt_validator.take() {
393 self.jwt_validator = Some(validator.with_jwks_cache(cache));
394 }
395 }
396
397 #[must_use]
399 pub fn is_configured(&self) -> bool {
400 self.any_configured
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use zeroize::Zeroizing;
408
409 #[test]
410 fn no_auth_configured_rejects_by_default() {
411 let middleware = AuthMiddleware::new(AuthConfig::default()).unwrap();
412 let result = middleware.authenticate(None, None);
413 assert!(result.is_err(), "should reject when no auth is configured");
414 }
415
416 #[test]
417 fn no_auth_configured_allows_with_anonymous() {
418 let middleware = AuthMiddleware::new(AuthConfig {
419 allow_anonymous: true,
420 ..Default::default()
421 })
422 .unwrap();
423 let ctx = middleware.authenticate(None, None).unwrap();
424 assert_eq!(ctx.method, AuthMethod::None);
425 assert_eq!(ctx.principal, "anonymous");
426 }
427
428 #[test]
429 fn api_key_authentication() {
430 let mut store = ApiKeyStore::new();
431 let key = store.create_key("test-key", None).unwrap();
432
433 let middleware = AuthMiddleware::new(AuthConfig {
434 api_key_store: Some(store),
435 ..Default::default()
436 })
437 .unwrap();
438
439 let ctx = middleware.authenticate(Some(&key), None).unwrap();
440 assert_eq!(ctx.method, AuthMethod::ApiKey);
441 assert_eq!(ctx.principal, "test-key");
442 }
443
444 #[test]
445 fn api_key_invalid_rejected() {
446 let mut store = ApiKeyStore::new();
447 let _key = store.create_key("k", None).unwrap();
448
449 let middleware = AuthMiddleware::new(AuthConfig {
450 api_key_store: Some(store),
451 ..Default::default()
452 })
453 .unwrap();
454
455 let err = middleware.authenticate(Some("wrong"), None).unwrap_err();
456 assert!(matches!(err, AuthError::NoAuth));
457 }
458
459 #[test]
460 fn jwt_authentication() {
461 let secret = "test-jwt-secret-for-middleware!!"; let config = JwtConfig {
463 secret: Some(Zeroizing::new(secret.into())),
464 ..Default::default()
465 };
466
467 let claims = crate::auth::jwt::JwtClaims {
468 sub: Some("jwt-user".into()),
469 exp: Some(
470 std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .unwrap()
473 .as_secs()
474 + 3600,
475 ),
476 iss: None,
477 aud: None,
478 iat: None,
479 email: None,
480 jti: None,
481 extra: std::collections::HashMap::new(),
482 };
483
484 let token = jsonwebtoken::encode(
485 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
486 &claims,
487 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
488 )
489 .unwrap();
490
491 let middleware = AuthMiddleware::new(AuthConfig {
492 jwt_config: Some(config),
493 ..Default::default()
494 })
495 .unwrap();
496
497 let ctx = middleware.authenticate(Some(&token), None).unwrap();
498 assert_eq!(ctx.method, AuthMethod::Jwt);
499 assert_eq!(ctx.principal, "jwt-user");
500 }
501
502 #[test]
503 fn mtls_authentication() {
504 let middleware = AuthMiddleware::new(AuthConfig {
505 mtls_config: Some(MtlsConfig {
506 enabled: true,
507 use_cn: true,
508 ..Default::default()
509 }),
510 ..Default::default()
511 })
512 .unwrap();
513
514 let ctx = middleware
515 .authenticate(None, Some("client.internal"))
516 .unwrap();
517 assert_eq!(ctx.method, AuthMethod::Mtls);
518 assert_eq!(ctx.principal, "client.internal");
519 }
520
521 #[test]
522 fn mtls_takes_priority_over_jwt() {
523 let secret = "test-secret-at-least-32-bytes!!!";
524 let claims = crate::auth::jwt::JwtClaims {
525 sub: Some("jwt-user".into()),
526 exp: Some(
527 std::time::SystemTime::now()
528 .duration_since(std::time::UNIX_EPOCH)
529 .unwrap()
530 .as_secs()
531 + 3600,
532 ),
533 iss: None,
534 aud: None,
535 iat: None,
536 email: None,
537 jti: None,
538 extra: std::collections::HashMap::new(),
539 };
540
541 let token = jsonwebtoken::encode(
542 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
543 &claims,
544 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
545 )
546 .unwrap();
547
548 let middleware = AuthMiddleware::new(AuthConfig {
549 jwt_config: Some(JwtConfig {
550 secret: Some(Zeroizing::new(secret.into())),
551 ..Default::default()
552 }),
553 mtls_config: Some(MtlsConfig {
554 enabled: true,
555 use_cn: true,
556 ..Default::default()
557 }),
558 ..Default::default()
559 })
560 .unwrap();
561
562 let ctx = middleware
564 .authenticate(Some(&token), Some("mtls-client"))
565 .unwrap();
566 assert_eq!(ctx.method, AuthMethod::Mtls);
567 }
568
569 #[test]
570 fn exempt_paths() {
571 let middleware = AuthMiddleware::new(AuthConfig {
572 exempt_paths: vec!["/health".into(), "/ready".into(), "/metrics".into()],
573 ..Default::default()
574 })
575 .unwrap();
576
577 assert!(middleware.is_exempt("/health"));
579 assert!(middleware.is_exempt("/ready"));
580 assert!(middleware.is_exempt("/metrics"));
581
582 assert!(middleware.is_exempt("/health/live"));
584 assert!(middleware.is_exempt("/metrics/prometheus"));
585
586 assert!(!middleware.is_exempt("/api/v1/write"));
588
589 assert!(!middleware.is_exempt("/healthz"));
591 assert!(!middleware.is_exempt("/health-admin"));
592
593 assert!(!middleware.is_exempt("/health/../api/v1/write"));
595 assert!(!middleware.is_exempt("/health/../../secret"));
596
597 assert!(middleware.is_exempt("/health//live"));
599
600 assert!(middleware.is_exempt("/health?foo=bar"));
602 assert!(!middleware.is_exempt("/healthz?x=1"));
603 }
604
605 #[test]
608 fn percent_encoding_bypass_prevented() {
609 let middleware = AuthMiddleware::new(AuthConfig {
610 exempt_paths: vec!["/health".into(), "/metrics".into()],
611 ..Default::default()
612 })
613 .unwrap();
614
615 assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
617 assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
618
619 assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
621 assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
622
623 assert!(middleware.is_exempt("/health/%252E%252E"));
626
627 assert!(middleware.is_exempt("/health"));
629 assert!(middleware.is_exempt("/metrics"));
630 assert!(middleware.is_exempt("/health/sub"));
631 }
632
633 #[test]
634 fn no_bearer_with_auth_configured_fails() {
635 let mut store = ApiKeyStore::new();
636 let _key = store.create_key("k", None).unwrap();
637
638 let middleware = AuthMiddleware::new(AuthConfig {
639 api_key_store: Some(store),
640 ..Default::default()
641 })
642 .unwrap();
643
644 let err = middleware.authenticate(None, None).unwrap_err();
645 assert!(matches!(err, AuthError::NoAuth));
646 }
647}