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 namespaces: Vec<String>,
49}
50
51impl AuthContext {
52 #[must_use]
54 pub fn allows_namespace(&self, namespace: &str) -> bool {
55 self.namespaces.is_empty() || self.namespaces.iter().any(|n| n == namespace)
56 }
57}
58
59fn namespaces_from_claims(
65 extra: &std::collections::HashMap<String, serde_json::Value>,
66) -> Vec<String> {
67 match extra.get("namespaces") {
68 Some(serde_json::Value::Array(items)) => items
69 .iter()
70 .filter_map(|v| v.as_str().map(str::to_string))
71 .collect(),
72 Some(serde_json::Value::String(one)) => vec![one.clone()],
73 _ => Vec::new(),
74 }
75}
76
77#[derive(Debug, Clone, Default)]
79pub struct AuthConfig {
80 pub api_key_store: Option<ApiKeyStore>,
82 pub jwt_config: Option<JwtConfig>,
84 pub mtls_config: Option<MtlsConfig>,
86 pub exempt_paths: Vec<String>,
88 pub allow_anonymous: bool,
92}
93
94#[derive(Debug, Clone)]
99pub struct AuthMiddleware {
100 api_key_store: Option<ApiKeyStore>,
101 jwt_validator: Option<JwtValidator>,
102 mtls_validator: Option<MtlsValidator>,
103 exempt_paths: Vec<String>,
104 any_configured: bool,
105 allow_anonymous: bool,
106}
107
108impl AuthMiddleware {
109 pub fn new(config: AuthConfig) -> Result<Self, crate::auth::error::AuthError> {
116 let any_configured = config.api_key_store.is_some()
117 || config.jwt_config.is_some()
118 || config.mtls_config.as_ref().is_some_and(|c| c.enabled);
119
120 let jwt_validator = config.jwt_config.map(JwtValidator::new).transpose()?;
121 let mtls_validator = config.mtls_config.map(MtlsValidator::new);
122
123 Ok(Self {
124 api_key_store: config.api_key_store,
125 jwt_validator,
126 mtls_validator,
127 exempt_paths: config.exempt_paths,
128 any_configured,
129 allow_anonymous: config.allow_anonymous,
130 })
131 }
132
133 #[must_use]
146 pub fn is_exempt(&self, path: &str) -> bool {
147 let path = path.split('?').next().unwrap_or(path);
149 let normalized = Self::normalize_path(path);
150 self.exempt_paths
151 .iter()
152 .any(|exempt| normalized == *exempt || normalized.starts_with(&format!("{exempt}/")))
153 }
154
155 fn normalize_path(path: &str) -> String {
161 let decoded = percent_decode_str(path).decode_utf8_lossy();
163 let mut segments: Vec<&str> = Vec::new();
164 for seg in decoded.split('/') {
165 match seg {
166 "" | "." => {}
167 ".." => {
168 segments.pop();
169 }
170 s => segments.push(s),
171 }
172 }
173 format!("/{}", segments.join("/"))
174 }
175
176 pub fn authenticate(
184 &self,
185 bearer_token: Option<&str>,
186 client_cert_cn: Option<&str>,
187 ) -> Result<AuthContext, AuthError> {
188 if !self.any_configured {
189 if self.allow_anonymous {
190 tracing::warn!(
191 "no authentication method configured and allow_anonymous=true -- \
192 allowing unauthenticated access"
193 );
194 return Ok(AuthContext {
195 principal: "anonymous".into(),
196 method: AuthMethod::None,
197 claims: std::collections::HashMap::new(),
198 admin: false,
199 namespaces: Vec::new(),
200 });
201 }
202 tracing::warn!(
203 "no authentication method configured — rejecting request; \
204 configure at least one auth provider (JWT, API key, or mTLS) \
205 or set 'auth.allow_anonymous = true' to permit unauthenticated access"
206 );
207 return Err(AuthError::Config(
208 "no authentication method configured".into(),
209 ));
210 }
211
212 if let Some(ref mtls) = self.mtls_validator {
216 if let Some(cn) = client_cert_cn {
217 if mtls.is_enabled() {
218 let identity = mtls.extract_identity_from_cn(cn).map_err(|e| {
219 AuthError::InvalidCertificate(format!(
220 "mTLS certificate validation failed: {e}"
221 ))
222 })?;
223 return Ok(AuthContext {
224 principal: identity.principal().to_string(),
225 method: AuthMethod::Mtls,
226 claims: std::collections::HashMap::new(),
227 admin: false,
228 namespaces: Vec::new(),
229 });
230 }
231 }
232 }
233
234 if let Some(ref jwt) = self.jwt_validator {
236 if let Some(token) = bearer_token {
237 if let Some(outcome) = self.judge_jwt(token, jwt.validate(token)) {
238 return outcome;
239 }
240 }
241 }
242
243 if let Some(ref store) = self.api_key_store {
245 if let Some(token) = bearer_token {
246 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
247 if !looks_like_jwt {
248 match store.validate(token) {
249 Ok(name) => {
250 let namespaces = store.namespaces_for(&name).to_vec();
251 let admin = store.is_admin(&name);
252 return Ok(AuthContext {
253 principal: name,
254 method: AuthMethod::ApiKey,
255 claims: std::collections::HashMap::new(),
256 admin,
257 namespaces,
258 });
259 }
260 Err(AuthError::ExpiredApiKey(name)) => {
261 return Err(AuthError::ExpiredApiKey(name));
262 }
263 Err(_) => {
264 }
266 }
267 }
268 }
269 }
270
271 Err(AuthError::NoAuth)
272 }
273
274 pub async fn authenticate_async(
287 &self,
288 bearer_token: Option<&str>,
289 client_cert_cn: Option<&str>,
290 ) -> Result<AuthContext, AuthError> {
291 let jwks_path = self
293 .jwt_validator
294 .as_ref()
295 .is_some_and(|j| j.jwks_cache().is_some());
296 if !jwks_path {
297 return self.authenticate(bearer_token, client_cert_cn);
298 }
299
300 if let Some(ref mtls) = self.mtls_validator {
303 if client_cert_cn.is_some() && mtls.is_enabled() {
304 return self.authenticate(bearer_token, client_cert_cn);
305 }
306 }
307
308 if let (Some(jwt), Some(token)) = (self.jwt_validator.as_ref(), bearer_token) {
309 let result = jwt.validate_async(token).await;
310 if let Some(outcome) = self.judge_jwt(token, result) {
311 return outcome;
312 }
313 }
314
315 self.authenticate(bearer_token, client_cert_cn)
317 }
318
319 fn judge_jwt(
326 &self,
327 token: &str,
328 result: Result<crate::auth::jwt::JwtClaims, AuthError>,
329 ) -> Option<Result<AuthContext, AuthError>> {
330 let jwt = self.jwt_validator.as_ref()?;
331 match result {
332 Ok(claims) => {
333 let principal = jwt
334 .extract_principal(&claims)
335 .unwrap_or_else(|| "unknown".into());
336 let namespaces = namespaces_from_claims(&claims.extra);
337 let admin = claims
338 .extra
339 .get("admin")
340 .and_then(serde_json::Value::as_bool)
341 .unwrap_or(false);
342 Some(Ok(AuthContext {
343 principal,
344 method: AuthMethod::Jwt,
345 claims: claims.extra,
346 admin,
347 namespaces,
348 }))
349 }
350 Err(AuthError::ExpiredJwt) => Some(Err(AuthError::ExpiredJwt)),
351 Err(e) => {
352 let looks_like_jwt = token.bytes().filter(|&b| b == b'.').count() >= 2;
353 if looks_like_jwt {
354 Some(Err(e))
355 } else {
356 None
357 }
358 }
359 }
360 }
361
362 #[must_use]
364 pub fn jwt_validator(&self) -> Option<&JwtValidator> {
365 self.jwt_validator.as_ref()
366 }
367
368 pub fn set_jwks_cache(&mut self, cache: std::sync::Arc<crate::auth::oidc::JwksCache>) {
372 if let Some(validator) = self.jwt_validator.take() {
373 self.jwt_validator = Some(validator.with_jwks_cache(cache));
374 }
375 }
376
377 #[must_use]
379 pub fn is_configured(&self) -> bool {
380 self.any_configured
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use zeroize::Zeroizing;
388
389 #[test]
390 fn no_auth_configured_rejects_by_default() {
391 let middleware = AuthMiddleware::new(AuthConfig::default()).unwrap();
392 let result = middleware.authenticate(None, None);
393 assert!(result.is_err(), "should reject when no auth is configured");
394 }
395
396 #[test]
397 fn no_auth_configured_allows_with_anonymous() {
398 let middleware = AuthMiddleware::new(AuthConfig {
399 allow_anonymous: true,
400 ..Default::default()
401 })
402 .unwrap();
403 let ctx = middleware.authenticate(None, None).unwrap();
404 assert_eq!(ctx.method, AuthMethod::None);
405 assert_eq!(ctx.principal, "anonymous");
406 }
407
408 #[test]
409 fn api_key_authentication() {
410 let mut store = ApiKeyStore::new();
411 let key = store.create_key("test-key", None).unwrap();
412
413 let middleware = AuthMiddleware::new(AuthConfig {
414 api_key_store: Some(store),
415 ..Default::default()
416 })
417 .unwrap();
418
419 let ctx = middleware.authenticate(Some(&key), None).unwrap();
420 assert_eq!(ctx.method, AuthMethod::ApiKey);
421 assert_eq!(ctx.principal, "test-key");
422 }
423
424 #[test]
425 fn api_key_invalid_rejected() {
426 let mut store = ApiKeyStore::new();
427 let _key = store.create_key("k", None).unwrap();
428
429 let middleware = AuthMiddleware::new(AuthConfig {
430 api_key_store: Some(store),
431 ..Default::default()
432 })
433 .unwrap();
434
435 let err = middleware.authenticate(Some("wrong"), None).unwrap_err();
436 assert!(matches!(err, AuthError::NoAuth));
437 }
438
439 #[test]
440 fn jwt_authentication() {
441 let secret = "test-jwt-secret-for-middleware!!"; let config = JwtConfig {
443 secret: Some(Zeroizing::new(secret.into())),
444 ..Default::default()
445 };
446
447 let claims = crate::auth::jwt::JwtClaims {
448 sub: Some("jwt-user".into()),
449 exp: Some(
450 std::time::SystemTime::now()
451 .duration_since(std::time::UNIX_EPOCH)
452 .unwrap()
453 .as_secs()
454 + 3600,
455 ),
456 iss: None,
457 aud: None,
458 iat: None,
459 email: None,
460 jti: None,
461 extra: std::collections::HashMap::new(),
462 };
463
464 let token = jsonwebtoken::encode(
465 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
466 &claims,
467 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
468 )
469 .unwrap();
470
471 let middleware = AuthMiddleware::new(AuthConfig {
472 jwt_config: Some(config),
473 ..Default::default()
474 })
475 .unwrap();
476
477 let ctx = middleware.authenticate(Some(&token), None).unwrap();
478 assert_eq!(ctx.method, AuthMethod::Jwt);
479 assert_eq!(ctx.principal, "jwt-user");
480 }
481
482 #[test]
483 fn mtls_authentication() {
484 let middleware = AuthMiddleware::new(AuthConfig {
485 mtls_config: Some(MtlsConfig {
486 enabled: true,
487 use_cn: true,
488 ..Default::default()
489 }),
490 ..Default::default()
491 })
492 .unwrap();
493
494 let ctx = middleware
495 .authenticate(None, Some("client.internal"))
496 .unwrap();
497 assert_eq!(ctx.method, AuthMethod::Mtls);
498 assert_eq!(ctx.principal, "client.internal");
499 }
500
501 #[test]
502 fn mtls_takes_priority_over_jwt() {
503 let secret = "test-secret-at-least-32-bytes!!!";
504 let claims = crate::auth::jwt::JwtClaims {
505 sub: Some("jwt-user".into()),
506 exp: Some(
507 std::time::SystemTime::now()
508 .duration_since(std::time::UNIX_EPOCH)
509 .unwrap()
510 .as_secs()
511 + 3600,
512 ),
513 iss: None,
514 aud: None,
515 iat: None,
516 email: None,
517 jti: None,
518 extra: std::collections::HashMap::new(),
519 };
520
521 let token = jsonwebtoken::encode(
522 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
523 &claims,
524 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
525 )
526 .unwrap();
527
528 let middleware = AuthMiddleware::new(AuthConfig {
529 jwt_config: Some(JwtConfig {
530 secret: Some(Zeroizing::new(secret.into())),
531 ..Default::default()
532 }),
533 mtls_config: Some(MtlsConfig {
534 enabled: true,
535 use_cn: true,
536 ..Default::default()
537 }),
538 ..Default::default()
539 })
540 .unwrap();
541
542 let ctx = middleware
544 .authenticate(Some(&token), Some("mtls-client"))
545 .unwrap();
546 assert_eq!(ctx.method, AuthMethod::Mtls);
547 }
548
549 #[test]
550 fn exempt_paths() {
551 let middleware = AuthMiddleware::new(AuthConfig {
552 exempt_paths: vec!["/health".into(), "/ready".into(), "/metrics".into()],
553 ..Default::default()
554 })
555 .unwrap();
556
557 assert!(middleware.is_exempt("/health"));
559 assert!(middleware.is_exempt("/ready"));
560 assert!(middleware.is_exempt("/metrics"));
561
562 assert!(middleware.is_exempt("/health/live"));
564 assert!(middleware.is_exempt("/metrics/prometheus"));
565
566 assert!(!middleware.is_exempt("/api/v1/write"));
568
569 assert!(!middleware.is_exempt("/healthz"));
571 assert!(!middleware.is_exempt("/health-admin"));
572
573 assert!(!middleware.is_exempt("/health/../api/v1/write"));
575 assert!(!middleware.is_exempt("/health/../../secret"));
576
577 assert!(middleware.is_exempt("/health//live"));
579
580 assert!(middleware.is_exempt("/health?foo=bar"));
582 assert!(!middleware.is_exempt("/healthz?x=1"));
583 }
584
585 #[test]
588 fn percent_encoding_bypass_prevented() {
589 let middleware = AuthMiddleware::new(AuthConfig {
590 exempt_paths: vec!["/health".into(), "/metrics".into()],
591 ..Default::default()
592 })
593 .unwrap();
594
595 assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
597 assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
598
599 assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
601 assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
602
603 assert!(middleware.is_exempt("/health/%252E%252E"));
606
607 assert!(middleware.is_exempt("/health"));
609 assert!(middleware.is_exempt("/metrics"));
610 assert!(middleware.is_exempt("/health/sub"));
611 }
612
613 #[test]
614 fn no_bearer_with_auth_configured_fails() {
615 let mut store = ApiKeyStore::new();
616 let _key = store.create_key("k", None).unwrap();
617
618 let middleware = AuthMiddleware::new(AuthConfig {
619 api_key_store: Some(store),
620 ..Default::default()
621 })
622 .unwrap();
623
624 let err = middleware.authenticate(None, None).unwrap_err();
625 assert!(matches!(err, AuthError::NoAuth));
626 }
627}