Skip to main content

fraiseql_server/middleware/
oidc_auth.rs

1//! OIDC Authentication Middleware
2//!
3//! Provides JWT authentication for GraphQL endpoints using OIDC discovery.
4//! Supports Auth0, Keycloak, Okta, Cognito, Azure AD, and any OIDC-compliant provider.
5
6use std::sync::Arc;
7
8use axum::{
9    body::Body,
10    extract::State,
11    http::{Request, StatusCode, header},
12    middleware::Next,
13    response::{IntoResponse, Response},
14};
15use fraiseql_core::security::{AuthenticatedUser, OidcValidator};
16
17use crate::{
18    middleware::admin_scope::ADMIN_SCOPE,
19    token_revocation::{TokenRejection, TokenRevocationManager},
20};
21
22/// Build the `WWW-Authenticate: Bearer` challenge value.
23///
24/// The `realm` parameter is included only when an issuer is configured. In
25/// issuer-less mode (`IdPs` whose access tokens omit `iss`, validated via a
26/// pinned `jwks_uri`) the bare `Bearer` challenge is returned, since there is
27/// no issuer to advertise as the realm.
28fn bearer_challenge(issuer: Option<&str>) -> String {
29    issuer.map_or_else(|| "Bearer".to_string(), |issuer| format!("Bearer realm=\"{issuer}\""))
30}
31
32/// State for OIDC authentication middleware.
33#[derive(Clone)]
34pub struct OidcAuthState {
35    /// The OIDC validator.
36    pub validator:  Arc<OidcValidator>,
37    /// Optional token-revocation manager. When present, every authenticated request is
38    /// checked against the revocation store (single-`jti` revocation **and** the caller's
39    /// `revoke-all` epoch) after token validation succeeds — closing H8, where revoked
40    /// tokens were accepted until their natural `exp`.
41    pub revocation: Option<Arc<TokenRevocationManager>>,
42}
43
44impl OidcAuthState {
45    /// Create new OIDC auth state with no revocation enforcement.
46    ///
47    /// The server attaches a revocation manager via [`with_revocation`](Self::with_revocation)
48    /// when `[security.token_revocation]` is configured; constructing without one (e.g. in
49    /// tests) simply performs no revocation check.
50    #[must_use]
51    pub const fn new(validator: Arc<OidcValidator>) -> Self {
52        Self {
53            validator,
54            revocation: None,
55        }
56    }
57
58    /// Attach a token-revocation manager so authenticated requests are checked against
59    /// the revocation store. `None` leaves revocation enforcement disabled.
60    #[must_use]
61    pub fn with_revocation(mut self, revocation: Option<Arc<TokenRevocationManager>>) -> Self {
62        self.revocation = revocation;
63        self
64    }
65}
66
67/// Request extension containing the authenticated user.
68///
69/// After authentication middleware runs, handlers can extract this
70/// to access the authenticated user information.
71#[derive(Clone, Debug)]
72pub struct AuthUser(pub AuthenticatedUser);
73
74/// Request extension containing the `jti` claim of the validated bearer token.
75///
76/// Populated by [`oidc_auth_middleware`] immediately after `validate_token`
77/// succeeds — at that point the token's signature, expiry, audience, and
78/// (when enabled) replay-cache check have all been verified, so re-decoding
79/// the payload to extract `jti` carries no integrity risk.
80///
81/// `None` indicates the token had no `jti` claim. Handlers that must revoke
82/// the caller's current session (e.g. `POST /auth/revoke`) should treat
83/// `Some(jti)` as the only valid input — there is no per-request identifier
84/// to revoke without it.
85#[derive(Clone, Debug)]
86pub struct SessionJti(pub Option<String>);
87
88/// Minimal JWT payload deserializer used to extract the revocation-relevant claims
89/// (`jti` and `iat`) from an already-validated bearer token. The validator has performed
90/// the heavy integrity checks; this struct only pulls out the per-token identifier and
91/// issued-at time used by the revocation check.
92#[derive(serde::Deserialize)]
93struct RevocationClaims {
94    jti: Option<String>,
95    iat: Option<i64>,
96}
97
98/// Build the 401 response for a revoked / un-revocation-checkable token.
99fn revocation_rejected_response(rejection: &TokenRejection) -> Response {
100    let (www_authenticate, body) = match rejection {
101        TokenRejection::Revoked => (
102            "Bearer error=\"invalid_token\", error_description=\"Token has been revoked\"",
103            "Token has been revoked",
104        ),
105        TokenRejection::MissingJti => (
106            "Bearer error=\"invalid_token\", error_description=\"Token lacks required jti claim\"",
107            "Token lacks required jti claim",
108        ),
109        TokenRejection::StoreUnavailable => (
110            "Bearer error=\"invalid_token\", error_description=\"Revocation store unavailable\"",
111            "Revocation store unavailable",
112        ),
113    };
114    (
115        StatusCode::UNAUTHORIZED,
116        [(header::WWW_AUTHENTICATE, www_authenticate.to_string())],
117        body,
118    )
119        .into_response()
120}
121
122/// Enforce token revocation for an already-validated bearer token.
123///
124/// Decodes `jti`/`iat` from the token (safe via `insecure_decode` — `validate_token` has
125/// already verified signature/expiry/audience) and, when a revocation manager is present,
126/// rejects the request if the token's `jti` is revoked or its `iat` predates the user's
127/// `revoke-all` epoch. Returns the decoded `jti` (for `SessionJti`) on success, or a 401
128/// response to short-circuit the request. With no revocation manager it is a pure
129/// `jti`-decode (preserving prior behaviour).
130async fn check_revocation(
131    auth_state: &OidcAuthState,
132    user: &AuthenticatedUser,
133    token: &str,
134) -> Result<Option<String>, Response> {
135    let claims = jsonwebtoken::dangerous::insecure_decode::<RevocationClaims>(token)
136        .ok()
137        .map(|d| d.claims);
138    let jti = claims.as_ref().and_then(|c| c.jti.clone());
139    let iat = claims.as_ref().and_then(|c| c.iat);
140
141    if let Some(revocation) = auth_state.revocation.as_ref() {
142        if let Err(rejection) =
143            revocation.check_token(jti.as_deref(), user.user_id.as_str(), iat).await
144        {
145            tracing::debug!(
146                user_id = %user.user_id,
147                ?rejection,
148                "Token rejected by revocation check"
149            );
150            return Err(revocation_rejected_response(&rejection));
151        }
152    }
153
154    Ok(jti)
155}
156
157/// Extract the bearer token from a raw `Cookie` header value.
158///
159/// Looks for `__Host-access_token=<value>` in the semicolon-separated cookie
160/// string and returns the token value, stripping RFC 6265 double-quotes if
161/// present.  Returns `None` if the cookie is absent.
162///
163/// This is used as a fallback by [`oidc_auth_middleware`] when no
164/// `Authorization: Bearer` header is present, to support browser flows where
165/// the JWT is stored in an `HttpOnly` cookie inaccessible to client-side script.
166pub(crate) fn extract_access_token_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
167    headers.get(header::COOKIE).and_then(|v| v.to_str().ok()).and_then(|cookies| {
168        cookies.split(';').find_map(|part| {
169            let part = part.trim();
170            part.strip_prefix("__Host-access_token=")
171                .map(|v| v.trim_matches('"').to_owned())
172        })
173    })
174}
175
176/// OIDC authentication middleware.
177///
178/// Validates JWT tokens from the `Authorization: Bearer` header using
179/// OIDC/JWKS.  When no `Authorization` header is present, falls back to the
180/// `__Host-access_token` `HttpOnly` cookie set by the PKCE callback.
181///
182/// # Behavior
183///
184/// - If auth is required and no token (header or cookie): returns 401 Unauthorized
185/// - If token is invalid/expired: returns 401 Unauthorized
186/// - If token is valid: adds `AuthUser` to request extensions
187/// - If auth is optional and no token: allows request through (no `AuthUser`)
188///
189/// # Example
190///
191/// ```text
192/// // Requires: OIDC provider reachable for JWKS discovery, running Axum application.
193/// use axum::{middleware, Router};
194///
195/// let oidc_state = OidcAuthState::new(validator);
196/// let app = Router::new()
197///     .route("/graphql", post(graphql_handler))
198///     .layer(middleware::from_fn_with_state(oidc_state, oidc_auth_middleware));
199/// ```
200#[allow(clippy::cognitive_complexity)] // Reason: OIDC authentication middleware with token parsing, validation, and claims extraction
201pub async fn oidc_auth_middleware(
202    State(auth_state): State<OidcAuthState>,
203    mut request: Request<Body>,
204    next: Next,
205) -> Response {
206    // Prefer Authorization: Bearer header; fall back to __Host-access_token cookie.
207    // The token is extracted as an owned String to avoid borrow conflicts with
208    // request.extensions_mut() later in this function.
209    let token_string: Option<String> = {
210        let auth_header = request
211            .headers()
212            .get(header::AUTHORIZATION)
213            .and_then(|value| value.to_str().ok());
214
215        match auth_header {
216            Some(header_value) => {
217                if !header_value.starts_with("Bearer ") {
218                    tracing::debug!("Invalid Authorization header format");
219                    return (
220                        StatusCode::UNAUTHORIZED,
221                        [(
222                            header::WWW_AUTHENTICATE,
223                            "Bearer error=\"invalid_request\"".to_string(),
224                        )],
225                        "Invalid Authorization header format",
226                    )
227                        .into_response();
228                }
229                Some(header_value[7..].to_owned())
230            },
231            None => extract_access_token_cookie(request.headers()),
232        }
233    };
234
235    match token_string {
236        None => {
237            if auth_state.validator.is_required() {
238                tracing::debug!("Authentication required but no token found (header or cookie)");
239                return (
240                    StatusCode::UNAUTHORIZED,
241                    [(header::WWW_AUTHENTICATE, bearer_challenge(auth_state.validator.issuer()))],
242                    "Authentication required",
243                )
244                    .into_response();
245            }
246            // Auth is optional, continue without user context
247            next.run(request).await
248        },
249        Some(token) => {
250            // Validate token
251            match auth_state.validator.validate_token(&token).await {
252                Ok(user) => {
253                    tracing::debug!(
254                        user_id = %user.user_id,
255                        scopes = ?user.scopes,
256                        "User authenticated successfully"
257                    );
258                    // Enforce token revocation (single-`jti` + `revoke-all` epoch) and
259                    // surface the `jti` claim for downstream handlers that need to revoke
260                    // the caller's current session.  `check_revocation` re-decodes the
261                    // already-validated token (safe via `insecure_decode`).
262                    let jti = match check_revocation(&auth_state, &user, &token).await {
263                        Ok(jti) => jti,
264                        Err(response) => return response,
265                    };
266                    request.extensions_mut().insert(AuthUser(user));
267                    request.extensions_mut().insert(SessionJti(jti));
268                    next.run(request).await
269                },
270                Err(e) => {
271                    tracing::debug!(error = %e, "Token validation failed");
272                    let (www_authenticate, body) = match &e {
273                        fraiseql_core::security::SecurityError::TokenExpired { .. } => (
274                            "Bearer error=\"invalid_token\", error_description=\"Token has expired\"",
275                            "Token has expired",
276                        ),
277                        fraiseql_core::security::SecurityError::InvalidToken => (
278                            "Bearer error=\"invalid_token\", error_description=\"Token is invalid\"",
279                            "Token is invalid",
280                        ),
281                        _ => ("Bearer error=\"invalid_token\"", "Invalid or expired token"),
282                    };
283                    (
284                        StatusCode::UNAUTHORIZED,
285                        [(header::WWW_AUTHENTICATE, www_authenticate.to_string())],
286                        body,
287                    )
288                        .into_response()
289                },
290            }
291        },
292    }
293}
294
295/// Outcome of pulling a bearer token from a request (header first, cookie fallback).
296enum TokenExtraction {
297    /// A token string was found (`Authorization: Bearer …` or `__Host-access_token`).
298    Found(String),
299    /// An `Authorization` header was present but not in `Bearer <token>` form.
300    Malformed,
301    /// No token in either the header or the `__Host-access_token` cookie.
302    Absent,
303}
304
305/// Extract the bearer token from the `Authorization` header, falling back to the
306/// `__Host-access_token` cookie. Distinguishes a malformed header from an absent
307/// token so callers can return the right 401 body.
308fn extract_bearer_or_cookie(headers: &axum::http::HeaderMap) -> TokenExtraction {
309    let auth_header = headers.get(header::AUTHORIZATION).and_then(|value| value.to_str().ok());
310    match auth_header {
311        Some(value) => match value.strip_prefix("Bearer ") {
312            Some(token) => TokenExtraction::Found(token.to_owned()),
313            None => TokenExtraction::Malformed,
314        },
315        None => match extract_access_token_cookie(headers) {
316            Some(token) => TokenExtraction::Found(token),
317            None => TokenExtraction::Absent,
318        },
319    }
320}
321
322/// Mandatory authentication shared by [`admin_auth_middleware`] and
323/// [`required_auth_middleware`].
324///
325/// Extracts a bearer token (header or cookie) and rejects with 401 when it is absent,
326/// malformed, or invalid; on success it inserts `AuthUser` / `SessionJti` into the
327/// request extensions and returns the validated user.
328///
329/// Unlike [`oidc_auth_middleware`], the token is **always** required regardless of the
330/// validator's global `is_required()` flag. That flag governs only the anonymous data
331/// plane; honouring it on the admin plane is exactly the H5 bypass this layer closes
332/// (an admin router silently un-authed whenever a deployment runs with optional data
333/// auth).
334async fn authenticate_required(
335    auth_state: &OidcAuthState,
336    request: &mut Request<Body>,
337) -> Result<AuthenticatedUser, Response> {
338    let token = match extract_bearer_or_cookie(request.headers()) {
339        TokenExtraction::Found(token) => token,
340        TokenExtraction::Malformed => {
341            tracing::debug!("Admin/required auth: malformed Authorization header");
342            return Err((
343                StatusCode::UNAUTHORIZED,
344                [(header::WWW_AUTHENTICATE, "Bearer error=\"invalid_request\"".to_string())],
345                "Invalid Authorization header format",
346            )
347                .into_response());
348        },
349        TokenExtraction::Absent => {
350            tracing::debug!("Admin/required auth: no token (header or cookie)");
351            return Err((
352                StatusCode::UNAUTHORIZED,
353                [(header::WWW_AUTHENTICATE, bearer_challenge(auth_state.validator.issuer()))],
354                "Authentication required",
355            )
356                .into_response());
357        },
358    };
359
360    match auth_state.validator.validate_token(&token).await {
361        Ok(user) => {
362            // Enforce token revocation on the admin/required planes too — a revoked token
363            // must be rejected everywhere, not only on the data plane.
364            let jti = match check_revocation(auth_state, &user, &token).await {
365                Ok(jti) => jti,
366                Err(response) => return Err(response),
367            };
368            request.extensions_mut().insert(AuthUser(user.clone()));
369            request.extensions_mut().insert(SessionJti(jti));
370            Ok(user)
371        },
372        Err(e) => {
373            tracing::debug!(error = %e, "Admin/required auth: token validation failed");
374            let (www_authenticate, body) = match &e {
375                fraiseql_core::security::SecurityError::TokenExpired { .. } => (
376                    "Bearer error=\"invalid_token\", error_description=\"Token has expired\"",
377                    "Token has expired",
378                ),
379                fraiseql_core::security::SecurityError::InvalidToken => (
380                    "Bearer error=\"invalid_token\", error_description=\"Token is invalid\"",
381                    "Token is invalid",
382                ),
383                _ => ("Bearer error=\"invalid_token\"", "Invalid or expired token"),
384            };
385            Err((
386                StatusCode::UNAUTHORIZED,
387                [(header::WWW_AUTHENTICATE, www_authenticate.to_string())],
388                body,
389            )
390                .into_response())
391        },
392    }
393}
394
395/// Admin-plane authentication **and** authorization middleware (Phase 03 C3).
396///
397/// Requires a valid bearer token (always — see `authenticate_required`) **and** the
398/// `fraiseql:admin` scope. A missing/invalid token returns 401; a valid token without
399/// the admin scope returns 403. Applied to the true admin plane (observer admin API,
400/// design-audit API), it closes both H5 (admin routers un-authed when the global data
401/// plane is optional) and H6 (admin routers authenticated but not authorized — e.g. any
402/// end-user token could read observer `actions[].headers` webhook secrets or drive DLQ
403/// retry/delete).
404pub async fn admin_auth_middleware(
405    State(auth_state): State<OidcAuthState>,
406    mut request: Request<Body>,
407    next: Next,
408) -> Response {
409    match authenticate_required(&auth_state, &mut request).await {
410        Ok(user) => {
411            if user.has_scope(ADMIN_SCOPE) {
412                next.run(request).await
413            } else {
414                tracing::debug!(user_id = %user.user_id, "Admin scope missing — denying");
415                (StatusCode::FORBIDDEN, format!("Admin API requires '{ADMIN_SCOPE}' scope"))
416                    .into_response()
417            }
418        },
419        Err(response) => response,
420    }
421}
422
423/// Mandatory-authentication middleware (Phase 03 C3).
424///
425/// Requires a valid bearer token (any scope). Unlike [`oidc_auth_middleware`] it never
426/// defers to the validator's global `is_required()` flag, so a route an operator marked
427/// "require auth" actually rejects anonymous callers even when the data plane is
428/// optional (H5). Applied to the schema-exposing operator endpoints (introspection,
429/// schema export, schema metadata) where a valid non-admin token is still legitimate.
430pub async fn required_auth_middleware(
431    State(auth_state): State<OidcAuthState>,
432    mut request: Request<Body>,
433    next: Next,
434) -> Response {
435    match authenticate_required(&auth_state, &mut request).await {
436        Ok(_user) => next.run(request).await,
437        Err(response) => response,
438    }
439}
440
441#[cfg(test)]
442mod revocation_tests {
443    //! Request-path token-revocation enforcement (H8 + M-revoke-all).
444    //!
445    //! These exercise `check_revocation` — the exact post-validation step the middleware
446    //! runs — with a real `OidcAuthState` + `TokenRevocationManager` and a crafted token.
447    //! Driving the full middleware with a *valid* token needs a live JWKS endpoint
448    //! (out of scope here, see `auth_regression_test.rs`), so the enforcement logic is
449    //! tested at this seam; the wiring (both middlewares call it; the server attaches the
450    //! manager to every `OidcAuthState`) is structural.
451    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
452
453    use std::{collections::HashMap, sync::Arc};
454
455    use chrono::Utc;
456    use fraiseql_core::security::{AuthenticatedUser, OidcConfig, OidcValidator};
457
458    use super::{OidcAuthState, StatusCode, check_revocation};
459    use crate::token_revocation::{
460        InMemoryRevocationStore, RevocationStore, TokenRevocationManager,
461    };
462
463    fn validator() -> Arc<OidcValidator> {
464        let config = OidcConfig {
465            issuer:               Some("https://test.fraiseql.dev".to_string()),
466            audience:             Some("https://api.test.fraiseql.dev".to_string()),
467            required:             true,
468            additional_audiences: vec![],
469            jwks_cache_ttl_secs:  3600,
470            allowed_algorithms:   vec!["RS256".to_string()],
471            clock_skew_secs:      60,
472            jwks_uri:             None,
473            scope_claim:          "scope".to_string(),
474            require_jti:          false,
475            me:                   None,
476        };
477        // check_revocation never calls the validator; with_jwks_uri avoids async discovery.
478        Arc::new(OidcValidator::with_jwks_uri(config, "https://192.0.2.1/jwks".to_string()))
479    }
480
481    fn user(sub: &str) -> AuthenticatedUser {
482        AuthenticatedUser {
483            user_id:      fraiseql_core::types::UserId::new(sub),
484            scopes:       vec![],
485            expires_at:   Utc::now() + chrono::Duration::hours(1),
486            email:        None,
487            display_name: None,
488            extra_claims: HashMap::new(),
489        }
490    }
491
492    fn manager(store: Arc<dyn RevocationStore>) -> Arc<TokenRevocationManager> {
493        Arc::new(TokenRevocationManager::new(store, true, false, 3600))
494    }
495
496    /// Build a structurally-valid JWT. `insecure_decode` does not verify the signature, so
497    /// any signing key produces a token the revocation decode can read.
498    fn token(jti: Option<&str>, iat: Option<i64>) -> String {
499        let mut claims = serde_json::Map::new();
500        if let Some(j) = jti {
501            claims.insert("jti".to_owned(), j.into());
502        }
503        if let Some(i) = iat {
504            claims.insert("iat".to_owned(), i.into());
505        }
506        jsonwebtoken::encode(
507            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
508            &serde_json::Value::Object(claims),
509            &jsonwebtoken::EncodingKey::from_secret(b"test-secret"),
510        )
511        .unwrap()
512    }
513
514    #[tokio::test]
515    async fn no_manager_is_a_noop_passthrough() {
516        let state = OidcAuthState::new(validator());
517        let jti = check_revocation(&state, &user("alice"), &token(Some("j1"), Some(1000)))
518            .await
519            .expect("with no revocation manager the check is a pure jti decode");
520        assert_eq!(jti.as_deref(), Some("j1"));
521    }
522
523    #[tokio::test]
524    async fn revoked_jti_is_rejected_on_the_request_path() {
525        let store = Arc::new(InMemoryRevocationStore::new());
526        store.revoke("j1", 3600).await.unwrap();
527        let state = OidcAuthState::new(validator()).with_revocation(Some(manager(store)));
528
529        // H8: before this wiring the middleware decoded the jti but never consulted the
530        // revocation store, so a revoked token was accepted.
531        let result = check_revocation(&state, &user("alice"), &token(Some("j1"), Some(1000))).await;
532        let response = result.expect_err("a revoked jti must be rejected");
533        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
534    }
535
536    #[tokio::test]
537    async fn revoke_all_epoch_rejects_pre_epoch_token_accepts_post_epoch() {
538        let store = Arc::new(InMemoryRevocationStore::new());
539        store.revoke_all_for_user("alice", 3600).await.unwrap();
540        let now = Utc::now().timestamp();
541        let state = OidcAuthState::new(validator())
542            .with_revocation(Some(manager(Arc::clone(&store) as Arc<dyn RevocationStore>)));
543
544        // A token issued before the revoke-all epoch is rejected even though its jti was
545        // never individually revoked (M-revoke-all).
546        assert!(
547            check_revocation(&state, &user("alice"), &token(Some("j-old"), Some(now - 100)))
548                .await
549                .is_err(),
550            "a token issued before revoke-all must be rejected"
551        );
552        // A token issued after the epoch is accepted.
553        assert!(
554            check_revocation(&state, &user("alice"), &token(Some("j-new"), Some(now + 100)))
555                .await
556                .is_ok(),
557            "a token issued after revoke-all must be accepted"
558        );
559    }
560}