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            && 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                // A certificate carries no role list. An mTLS
248                // deployment names the principal in the policy.
249                roles: Vec::new(),
250                namespaces: Vec::new(),
251            });
252        }
253
254        // 2. Try JWT
255        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        // 3. Try API key — skip if token looks like a JWT (has 2+ dot separators)
263        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                        // API key validation failed
287                    }
288                }
289            }
290        }
291
292        Err(AuthError::NoAuth)
293    }
294
295    /// Authenticate a request, consulting a JWKS endpoint when one is
296    /// configured.
297    ///
298    /// Identical to [`authenticate`](Self::authenticate) except that the
299    /// JWT step can fetch and cache the issuer's published keys. The two
300    /// share every decision — which method wins, when a failure may fall
301    /// through to a weaker one — so they cannot drift apart; only the JWT
302    /// call differs, because fetching keys needs to await.
303    ///
304    /// # Errors
305    ///
306    /// As [`authenticate`](Self::authenticate).
307    pub async fn authenticate_async(
308        &self,
309        bearer_token: Option<&str>,
310        client_cert_cn: Option<&str>,
311    ) -> Result<AuthContext, AuthError> {
312        // Only the JWT step can differ, and only when a JWKS cache exists.
313        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        // A presented client certificate wins outright, exactly as in the
322        // sync path, so delegate rather than restating the rule.
323        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        // Not a JWT — the API-key and no-provider paths are unchanged.
338        self.authenticate(bearer_token, client_cert_cn)
339    }
340
341    /// Turn a JWT validation result into a decision.
342    ///
343    /// `None` means "this was not a JWT, try the next method". Anything
344    /// else is final: a structurally valid JWT that failed must never fall
345    /// through to API-key authentication, which would let an attacker
346    /// downgrade to the weaker method by presenting a broken token.
347    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    /// The JWT validator, when one is configured.
383    #[must_use]
384    pub fn jwt_validator(&self) -> Option<&JwtValidator> {
385        self.jwt_validator.as_ref()
386    }
387
388    /// Attach a JWKS cache to the JWT validator.
389    ///
390    /// Called at startup once the issuer's keys have been fetched.
391    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    /// Check whether any authentication method is configured.
398    #[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!!"; // 32 bytes
462        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        // Both JWT and mTLS provided — mTLS wins
563        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        // Exact matches
578        assert!(middleware.is_exempt("/health"));
579        assert!(middleware.is_exempt("/ready"));
580        assert!(middleware.is_exempt("/metrics"));
581
582        // Sub-paths under exempt directories
583        assert!(middleware.is_exempt("/health/live"));
584        assert!(middleware.is_exempt("/metrics/prometheus"));
585
586        // Non-exempt paths
587        assert!(!middleware.is_exempt("/api/v1/write"));
588
589        // Segment-boundary: /healthz is NOT under /health
590        assert!(!middleware.is_exempt("/healthz"));
591        assert!(!middleware.is_exempt("/health-admin"));
592
593        // Path traversal attacks must NOT bypass
594        assert!(!middleware.is_exempt("/health/../api/v1/write"));
595        assert!(!middleware.is_exempt("/health/../../secret"));
596
597        // Normalized double-slashes
598        assert!(middleware.is_exempt("/health//live"));
599
600        // Query string stripping
601        assert!(middleware.is_exempt("/health?foo=bar"));
602        assert!(!middleware.is_exempt("/healthz?x=1"));
603    }
604
605    /// Percent-encoded path segments must be decoded before
606    /// normalization to prevent auth bypass via `%2F` and `%2E`.
607    #[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        // %2F = '/' — must not bypass path traversal protection
616        assert!(!middleware.is_exempt("/health%2F..%2Fapi%2Fv1%2Fwrite"));
617        assert!(!middleware.is_exempt("/health%2F..%2F..%2Fsecret"));
618
619        // %2E = '.' — must be decoded and resolved
620        assert!(!middleware.is_exempt("/health/%2E%2E/api/v1/write"));
621        assert!(!middleware.is_exempt("/health/%2e%2e/secret"));
622
623        // Double-encoded %252F must NOT be decoded twice (stays literal)
624        // (we only do one decode pass — double-decode is a separate vuln)
625        assert!(middleware.is_exempt("/health/%252E%252E"));
626
627        // Normal exempt paths still work
628        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}