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    /// Cedar roles this credential carries, as `Chronix::Role` memberships.
44    ///
45    /// Resolved **once**, here, from whichever credential authenticated —
46    /// a key's `roles` list or the JWT claim named by `auth.jwt.role_claim`.
47    /// They used to be re-derived inside the administrative gate alone, so
48    /// the same role granted an admin operation and matched nothing in a
49    /// namespace policy, and an API key had no roles at all: every policy in
50    /// the documentation is written `principal in Chronix::Role::"…"`.
51    pub roles: Vec<String>,
52    /// Namespaces this credential may act in; empty means unrestricted.
53    ///
54    /// Authentication answers *who is calling*; this answers *whose data
55    /// they may touch*. Keeping the second on the credential is what stops
56    /// a valid key from reading another tenant simply by changing a header.
57    pub namespaces: Vec<String>,
58}
59
60impl AuthContext {
61    /// Whether this credential may act in `namespace`.
62    #[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    /// The Cedar principal for this credential.
68    ///
69    /// **The only producer.** Every gate builds its principal from here, so
70    /// none of them can be looking at a different identity or a different
71    /// role set from the others.
72    #[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
78/// Read the namespace confinement out of a JWT's claims.
79///
80/// Accepts `namespaces` as an array of strings or as a single string, which
81/// is what identity providers actually emit: a one-element list is often
82/// flattened to a bare string on the way through.
83fn 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/// Authentication middleware configuration.
97#[derive(Debug, Clone, Default)]
98pub struct AuthConfig {
99    /// API key store (optional).
100    pub api_key_store: Option<ApiKeyStore>,
101    /// JWT configuration (optional).
102    pub jwt_config: Option<JwtConfig>,
103    /// mTLS configuration (optional).
104    pub mtls_config: Option<MtlsConfig>,
105    /// Paths exempt from authentication (e.g., `/health`, `/ready`, `/metrics`).
106    pub exempt_paths: Vec<String>,
107    /// Allow anonymous access when no auth providers are configured.
108    /// Defaults to `false` — the middleware rejects all requests when
109    /// no provider is configured unless this is explicitly set.
110    pub allow_anonymous: bool,
111}
112
113/// The authentication middleware.
114///
115/// Checks all configured authentication methods in order:
116/// mTLS → JWT → API key. First success wins.
117#[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    /// Create a new authentication middleware from configuration.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`AuthError::Config`] if the JWT configuration contains
133    /// an unrecognized algorithm string.
134    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    /// Check if a path is exempt from authentication.
153    ///
154    /// Uses **segment-boundary matching** instead of raw prefix matching
155    /// to prevent bypass via path manipulation (e.g., `/healthz`,
156    /// `/health/../api/v1/write`, `/health%2F..`).
157    ///
158    /// **** Percent-decodes the path before normalization to prevent
159    /// bypass via `%2F` (/) and `%2E` (.) encoded characters.
160    ///
161    /// A path matches an exempt entry if it is either an exact match
162    /// or the exempt path is a proper directory prefix (path starts with
163    /// `exempt + "/"`).
164    #[must_use]
165    pub fn is_exempt(&self, path: &str) -> bool {
166        // Strip query string and normalize the path.
167        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    /// Normalize a URL path by resolving `.`, `..`, and double-slash
175    /// segments. Returns a canonical path that starts with `/`.
176    ///
177    /// **** Percent-decodes the path first, so `%2F` → `/` and
178    /// `%2E` → `.` are resolved before segment matching.
179    fn normalize_path(path: &str) -> String {
180        // Decode percent-encoded characters first
181        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    /// Authenticate a request.
196    ///
197    /// Parameters:
198    /// - `bearer_token`: The value from `Authorization: Bearer <token>` header
199    /// - `client_cert_cn`: The client certificate CN (from mTLS handshake)
200    ///
201    /// Returns the authentication context on success.
202    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        // 1. Try mTLS — if a client certificate is presented and mTLS is
233        //    enabled, it MUST succeed.  Falling through to weaker methods
234        //    (JWT / API-key) would be a security downgrade.
235        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                        // A certificate carries no role list. An mTLS
249                        // deployment names the principal in the policy.
250                        roles: Vec::new(),
251                        namespaces: Vec::new(),
252                    });
253                }
254            }
255        }
256
257        // 2. Try JWT
258        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        // 3. Try API key — skip if token looks like a JWT (has 2+ dot separators)
267        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                            // API key validation failed
290                        }
291                    }
292                }
293            }
294        }
295
296        Err(AuthError::NoAuth)
297    }
298
299    /// Authenticate a request, consulting a JWKS endpoint when one is
300    /// configured.
301    ///
302    /// Identical to [`authenticate`](Self::authenticate) except that the
303    /// JWT step can fetch and cache the issuer's published keys. The two
304    /// share every decision — which method wins, when a failure may fall
305    /// through to a weaker one — so they cannot drift apart; only the JWT
306    /// call differs, because fetching keys needs to await.
307    ///
308    /// # Errors
309    ///
310    /// As [`authenticate`](Self::authenticate).
311    pub async fn authenticate_async(
312        &self,
313        bearer_token: Option<&str>,
314        client_cert_cn: Option<&str>,
315    ) -> Result<AuthContext, AuthError> {
316        // Only the JWT step can differ, and only when a JWKS cache exists.
317        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        // A presented client certificate wins outright, exactly as in the
326        // sync path, so delegate rather than restating the rule.
327        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        // Not a JWT — the API-key and no-provider paths are unchanged.
341        self.authenticate(bearer_token, client_cert_cn)
342    }
343
344    /// Turn a JWT validation result into a decision.
345    ///
346    /// `None` means "this was not a JWT, try the next method". Anything
347    /// else is final: a structurally valid JWT that failed must never fall
348    /// through to API-key authentication, which would let an attacker
349    /// downgrade to the weaker method by presenting a broken token.
350    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    /// The JWT validator, when one is configured.
390    #[must_use]
391    pub fn jwt_validator(&self) -> Option<&JwtValidator> {
392        self.jwt_validator.as_ref()
393    }
394
395    /// Attach a JWKS cache to the JWT validator.
396    ///
397    /// Called at startup once the issuer's keys have been fetched.
398    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    /// Check whether any authentication method is configured.
405    #[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!!"; // 32 bytes
469        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        // Both JWT and mTLS provided — mTLS wins
570        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        // Exact matches
585        assert!(middleware.is_exempt("/health"));
586        assert!(middleware.is_exempt("/ready"));
587        assert!(middleware.is_exempt("/metrics"));
588
589        // Sub-paths under exempt directories
590        assert!(middleware.is_exempt("/health/live"));
591        assert!(middleware.is_exempt("/metrics/prometheus"));
592
593        // Non-exempt paths
594        assert!(!middleware.is_exempt("/api/v1/write"));
595
596        // Segment-boundary: /healthz is NOT under /health
597        assert!(!middleware.is_exempt("/healthz"));
598        assert!(!middleware.is_exempt("/health-admin"));
599
600        // Path traversal attacks must NOT bypass
601        assert!(!middleware.is_exempt("/health/../api/v1/write"));
602        assert!(!middleware.is_exempt("/health/../../secret"));
603
604        // Normalized double-slashes
605        assert!(middleware.is_exempt("/health//live"));
606
607        // Query string stripping
608        assert!(middleware.is_exempt("/health?foo=bar"));
609        assert!(!middleware.is_exempt("/healthz?x=1"));
610    }
611
612    /// Percent-encoded path segments must be decoded before
613    /// normalization to prevent auth bypass via `%2F` and `%2E`.
614    #[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        // %2F = '/' — must not bypass path traversal protection
623        assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
624        assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
625
626        // %2E = '.' — must be decoded and resolved
627        assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
628        assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
629
630        // Double-encoded %252F must NOT be decoded twice (stays literal)
631        // (we only do one decode pass — double-decode is a separate vuln)
632        assert!(middleware.is_exempt("/health/%252E%252E"));
633
634        // Normal exempt paths still work
635        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}