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