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}