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}