Skip to main content

chronix_security/auth/
middleware.rs

1//! Unified authentication middleware.
2//!
3//! Chains multiple authentication methods: mTLS → JWT → API Key.
4//! The first successful method wins. If no method succeeds, returns 401.
5//!
6//! When no authentication is configured, all requests pass through
7//! with a warning log message.
8
9use 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/// The authentication method that was used.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum AuthMethod {
18    /// Authenticated via API key.
19    ApiKey,
20    /// Authenticated via JWT token.
21    Jwt,
22    /// Authenticated via mTLS client certificate.
23    Mtls,
24    /// No authentication configured — unauthenticated passthrough.
25    None,
26}
27
28/// Authentication context attached to each request.
29#[derive(Debug, Clone)]
30pub struct AuthContext {
31    /// The principal name (e.g., key name, JWT subject, certificate CN).
32    pub principal: String,
33    /// Which authentication method was used.
34    pub method: AuthMethod,
35    /// Additional claims from JWT (empty for API key / mTLS).
36    pub claims: std::collections::HashMap<String, serde_json::Value>,
37    /// Whether this credential may perform administrative operations.
38    ///
39    /// Carried on the credential rather than derived from a policy engine,
40    /// because the policy engine is optional and "no policy" must not mean
41    /// "everyone is an admin".
42    pub admin: bool,
43    /// Namespaces this credential may act in; empty means unrestricted.
44    ///
45    /// Authentication answers *who is calling*; this answers *whose data
46    /// they may touch*. Keeping the second on the credential is what stops
47    /// a valid key from reading another tenant simply by changing a header.
48    pub namespaces: Vec<String>,
49}
50
51impl AuthContext {
52    /// Whether this credential may act in `namespace`.
53    #[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
59/// Read the namespace confinement out of a JWT's claims.
60///
61/// Accepts `namespaces` as an array of strings or as a single string, which
62/// is what identity providers actually emit: a one-element list is often
63/// flattened to a bare string on the way through.
64fn 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/// Authentication middleware configuration.
78#[derive(Debug, Clone, Default)]
79pub struct AuthConfig {
80    /// API key store (optional).
81    pub api_key_store: Option<ApiKeyStore>,
82    /// JWT configuration (optional).
83    pub jwt_config: Option<JwtConfig>,
84    /// mTLS configuration (optional).
85    pub mtls_config: Option<MtlsConfig>,
86    /// Paths exempt from authentication (e.g., `/health`, `/ready`, `/metrics`).
87    pub exempt_paths: Vec<String>,
88    /// Allow anonymous access when no auth providers are configured.
89    /// Defaults to `false` — the middleware rejects all requests when
90    /// no provider is configured unless this is explicitly set.
91    pub allow_anonymous: bool,
92}
93
94/// The authentication middleware.
95///
96/// Checks all configured authentication methods in order:
97/// mTLS → JWT → API key. First success wins.
98#[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    /// Create a new authentication middleware from configuration.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`AuthError::Config`] if the JWT configuration contains
114    /// an unrecognized algorithm string.
115    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    /// Check if a path is exempt from authentication.
134    ///
135    /// Uses **segment-boundary matching** instead of raw prefix matching
136    /// to prevent bypass via path manipulation (e.g., `/healthz`,
137    /// `/health/../api/v1/write`, `/health%2F..`).
138    ///
139    /// **** Percent-decodes the path before normalization to prevent
140    /// bypass via `%2F` (/) and `%2E` (.) encoded characters.
141    ///
142    /// A path matches an exempt entry if it is either an exact match
143    /// or the exempt path is a proper directory prefix (path starts with
144    /// `exempt + "/"`).
145    #[must_use]
146    pub fn is_exempt(&self, path: &str) -> bool {
147        // Strip query string and normalize the path.
148        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    /// Normalize a URL path by resolving `.`, `..`, and double-slash
156    /// segments. Returns a canonical path that starts with `/`.
157    ///
158    /// **** Percent-decodes the path first, so `%2F` → `/` and
159    /// `%2E` → `.` are resolved before segment matching.
160    fn normalize_path(path: &str) -> String {
161        // Decode percent-encoded characters first
162        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    /// Authenticate a request.
177    ///
178    /// Parameters:
179    /// - `bearer_token`: The value from `Authorization: Bearer <token>` header
180    /// - `client_cert_cn`: The client certificate CN (from mTLS handshake)
181    ///
182    /// Returns the authentication context on success.
183    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        // 1. Try mTLS — if a client certificate is presented and mTLS is
213        //    enabled, it MUST succeed.  Falling through to weaker methods
214        //    (JWT / API-key) would be a security downgrade.
215        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        // 2. Try JWT
235        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        // 3. Try API key — skip if token looks like a JWT (has 2+ dot separators)
244        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                            // API key validation failed
265                        }
266                    }
267                }
268            }
269        }
270
271        Err(AuthError::NoAuth)
272    }
273
274    /// Authenticate a request, consulting a JWKS endpoint when one is
275    /// configured.
276    ///
277    /// Identical to [`authenticate`](Self::authenticate) except that the
278    /// JWT step can fetch and cache the issuer's published keys. The two
279    /// share every decision — which method wins, when a failure may fall
280    /// through to a weaker one — so they cannot drift apart; only the JWT
281    /// call differs, because fetching keys needs to await.
282    ///
283    /// # Errors
284    ///
285    /// As [`authenticate`](Self::authenticate).
286    pub async fn authenticate_async(
287        &self,
288        bearer_token: Option<&str>,
289        client_cert_cn: Option<&str>,
290    ) -> Result<AuthContext, AuthError> {
291        // Only the JWT step can differ, and only when a JWKS cache exists.
292        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        // A presented client certificate wins outright, exactly as in the
301        // sync path, so delegate rather than restating the rule.
302        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        // Not a JWT — the API-key and no-provider paths are unchanged.
316        self.authenticate(bearer_token, client_cert_cn)
317    }
318
319    /// Turn a JWT validation result into a decision.
320    ///
321    /// `None` means "this was not a JWT, try the next method". Anything
322    /// else is final: a structurally valid JWT that failed must never fall
323    /// through to API-key authentication, which would let an attacker
324    /// downgrade to the weaker method by presenting a broken token.
325    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    /// The JWT validator, when one is configured.
363    #[must_use]
364    pub fn jwt_validator(&self) -> Option<&JwtValidator> {
365        self.jwt_validator.as_ref()
366    }
367
368    /// Attach a JWKS cache to the JWT validator.
369    ///
370    /// Called at startup once the issuer's keys have been fetched.
371    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    /// Check whether any authentication method is configured.
378    #[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!!"; // 32 bytes
442        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        // Both JWT and mTLS provided — mTLS wins
543        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        // Exact matches
558        assert!(middleware.is_exempt("/health"));
559        assert!(middleware.is_exempt("/ready"));
560        assert!(middleware.is_exempt("/metrics"));
561
562        // Sub-paths under exempt directories
563        assert!(middleware.is_exempt("/health/live"));
564        assert!(middleware.is_exempt("/metrics/prometheus"));
565
566        // Non-exempt paths
567        assert!(!middleware.is_exempt("/api/v1/write"));
568
569        // Segment-boundary: /healthz is NOT under /health
570        assert!(!middleware.is_exempt("/healthz"));
571        assert!(!middleware.is_exempt("/health-admin"));
572
573        // Path traversal attacks must NOT bypass
574        assert!(!middleware.is_exempt("/health/../api/v1/write"));
575        assert!(!middleware.is_exempt("/health/../../secret"));
576
577        // Normalized double-slashes
578        assert!(middleware.is_exempt("/health//live"));
579
580        // Query string stripping
581        assert!(middleware.is_exempt("/health?foo=bar"));
582        assert!(!middleware.is_exempt("/healthz?x=1"));
583    }
584
585    /// Percent-encoded path segments must be decoded before
586    /// normalization to prevent auth bypass via `%2F` and `%2E`.
587    #[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        // %2F = '/' — must not bypass path traversal protection
596        assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
597        assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
598
599        // %2E = '.' — must be decoded and resolved
600        assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
601        assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
602
603        // Double-encoded %252F must NOT be decoded twice (stays literal)
604        // (we only do one decode pass — double-decode is a separate vuln)
605        assert!(middleware.is_exempt("/health/%252E%252E"));
606
607        // Normal exempt paths still work
608        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}