//! Full-flow integration suite against a `wiremock` fake AS (todo 21). //! //! The fake AS implements the d-OAuth shape: RFC 8414 discovery (PAR + //! introspection + revocation endpoints, DPoP ES256, no userinfo), `/par`, //! `/token`, `/introspect`, `/revoke`. Every test drives the real router with //! `tower::ServiceExt::oneshot`: no ports are bound, no external servers //! start, no real network is touched. use axum::body::Body; use axum::http::{Request, StatusCode}; use secrecy::{ExposeSecret as _, SecretString}; use server::app::build_app; use server::config::ServerConfig; use server::session::SESSION_COOKIE; use server::state::AppState; use tower::ServiceExt as _; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path}, }; type TestResult = Result<(), Box>; struct Fixture { app: axum::Router, state: AppState, issuer: String, public_jwk: serde_json::Value, jkt: String, } fn empty_lookup(_: &str) -> Option { None } /// Build an app whose profile points at the fake AS, with an in-memory DPoP /// key. The key's public JWK + thumbprint are kept for proof verification. fn fixture_app(fake: &MockServer) -> (Fixture, tempfile::TempDir) { let dir = tempfile::tempdir().expect("temp dir is creatable"); let dist = dir.path().join("dist"); std::fs::create_dir_all(&dist).expect("fixture dist is creatable"); std::fs::write( dist.join("index.html"), "spa-shell", ) .expect("fixture index is writable"); let issuer = fake.uri(); let config_json = format!( r#"{{ "server": {{ "dist_dir": {} }}, "oauth": {{ "issuer": "{issuer}", "redirect_uri": "http://127.0.0.1:3000/callback", "scope": "demo.read" }} }}"#, serde_json::to_string(&dist).expect("path serializes") ); std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable"); std::fs::write( dir.path().join("client.json"), r#"{"client_id": "demo-client", "client_secret": "demo-secret"}"#, ) .expect("client fixture is writable"); let config = ServerConfig::load_from_paths_with( Some(&dir.path().join("config.json")), Some(&dir.path().join("client.json")), &empty_lookup, ) .expect("fixture config loads"); let key = oauth_core::dpop::key::DpopKey::generate().expect("dpop key generates"); let public_jwk = key.public_jwk(); let jkt = key.thumbprint().expect("thumbprint computes"); let state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key); let app = build_app(state.clone()); ( Fixture { app, state, issuer, public_jwk, jkt, }, dir, ) } /// d-OAuth-shaped discovery: PAR + introspection + revocation + DPoP ES256, /// no userinfo endpoint. async fn mount_discovery(fake: &MockServer, issuer: &str) { let doc = serde_json::json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/authorize"), "token_endpoint": format!("{issuer}/token"), "pushed_authorization_request_endpoint": format!("{issuer}/par"), "introspection_endpoint": format!("{issuer}/introspect"), "revocation_endpoint": format!("{issuer}/revoke"), "dpop_signing_alg_values_supported": ["ES256"], }); Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server")) .respond_with(ResponseTemplate::new(200).set_body_json(&doc)) .mount(fake) .await; } async fn mount_par(fake: &MockServer) { Mock::given(method("POST")) .and(path("/par")) .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ "request_uri": "urn:ietf:params:oauth:request_uri:par-handle-1", "expires_in": 90 }))) .mount(fake) .await; } fn token_ok(access: &str, refresh: &str) -> ResponseTemplate { ResponseTemplate::new(200).set_body_json(serde_json::json!({ "access_token": access, "token_type": "DPoP", "expires_in": 3600, "refresh_token": refresh, "scope": "demo.read", })) } async fn mount_token(fake: &MockServer, template: ResponseTemplate) { Mock::given(method("POST")) .and(path("/token")) .respond_with(template) .mount(fake) .await; } async fn mount_introspect(fake: &MockServer, body: serde_json::Value) { Mock::given(method("POST")) .and(path("/introspect")) .respond_with(ResponseTemplate::new(200).set_body_json(&body)) .mount(fake) .await; } async fn mount_revoke(fake: &MockServer) { Mock::given(method("POST")) .and(path("/revoke")) .respond_with(ResponseTemplate::new(200)) .mount(fake) .await; } fn active_introspection(exp: i64, iat: i64, jkt: &str) -> serde_json::Value { serde_json::json!({ "active": true, "scope": "demo.read", "client_id": "demo-client", "token_type": "Bearer", "exp": exp, "iat": iat, "sub": "demo@example.com", "cnf": { "jkt": jkt }, }) } fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock available") .as_secs() as i64 } async fn send( app: axum::Router, request: Request, ) -> Result, Box> { Ok(app.oneshot(request).await?) } fn get_request(uri: &str, cookie: Option<&str>) -> Request { let mut builder = Request::builder().uri(uri); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } builder.body(Body::empty()).expect("request builds") } fn post_request(uri: &str, cookie: &str, csrf: &str, origin: Option<&str>) -> Request { let mut builder = Request::builder() .uri(uri) .method("POST") .header("cookie", cookie) .header("x-csrf-token", csrf); if let Some(origin) = origin { builder = builder.header("origin", origin); } builder.body(Body::empty()).expect("request builds") } async fn body_text( response: axum::http::Response, ) -> Result> { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; Ok(std::str::from_utf8(&bytes)?.to_owned()) } fn header_value(response: &axum::http::Response, name: &str) -> String { response .headers() .get(name) .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned() } fn sid_from_set_cookie(set_cookie: &str) -> Option { set_cookie.split(';').next().and_then(|pair| { let (name, value) = pair.split_once('=')?; (name.trim() == SESSION_COOKIE).then(|| value.trim().to_owned()) }) } fn cookie_for(sid: &str) -> String { format!("{SESSION_COOKIE}={sid}") } /// `/oauth/start` -> bound `(state, sid)`. async fn start_flow(fixture: &Fixture) -> Result<(String, String), Box> { let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?; assert_eq!(response.status(), StatusCode::FOUND); let location = header_value(&response, "location"); assert!( location.contains("/authorize"), "unexpected Location: {location}" ); assert!( location.contains("request_uri"), "PAR handle missing: {location}" ); let sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("sid cookie"); let state = fixture .state .pending() .pending_state_for_session(&sid) .expect("state bound to the session"); Ok((state, sid)) } /// `/callback` happy path -> rotated sid. async fn callback_flow( fixture: &Fixture, state: &str, sid: &str, ) -> Result> { let response = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}"), Some(&cookie_for(sid)), ), ) .await?; assert_eq!(response.status(), StatusCode::FOUND); assert_eq!(header_value(&response, "location"), "/dashboard"); let next = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie"); assert_ne!(sid, next, "session id must rotate on login"); Ok(next) } async fn session_json( fixture: &Fixture, sid: &str, ) -> Result<(StatusCode, serde_json::Value), Box> { let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(sid))), ) .await?; let status = response.status(); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body).expect("session body is JSON on every status"); Ok((status, json)) } // ---- happy path: the whole flow end to end ---- #[tokio::test] async fn full_flow_start_to_logout() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let now = now_secs(); mount_introspect( &fake, active_introspection(now + 300, now - 10, &fixture.jkt), ) .await; mount_revoke(&fake).await; // `/oauth/start` -> extract `state`. let (state, sid) = start_flow(&fixture).await?; // `/callback` -> session + rotation. let sid = callback_flow(&fixture, &state, &sid).await?; // `/api/session` -> subject + scopes + DPoP display type. let (status, json) = session_json(&fixture, &sid).await?; assert_eq!(status, StatusCode::OK); assert_eq!(json["subject"], "demo@example.com"); assert_eq!(json["scope"], serde_json::json!(["demo.read"])); assert_eq!(json["token_type"], "DPoP"); assert_eq!( json["cnf"]["jkt"], serde_json::Value::String(fixture.jkt.clone()) ); let csrf = json["csrf_token"] .as_str() .expect("csrf_token served") .to_owned(); // `/api/refresh` -> rotation receipt, no tokens in the body. let response = send( fixture.app.clone(), post_request("/api/refresh", &cookie_for(&sid), &csrf, None), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; assert!( body.contains("refreshed"), "unexpected refresh body: {body}" ); for forbidden in ["access-1", "refresh-1", "demo-secret"] { assert!( !body.contains(forbidden), "secret leaked into refresh: {body}" ); } // `/api/logout` -> cleared session + AS note. let response = send( fixture.app.clone(), post_request("/api/logout", &cookie_for(&sid), &csrf, None), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; assert!( body.contains("provider logout endpoint"), "logout note missing: {body}" ); assert!( fixture.state.sessions().get(&sid).is_none(), "logout clears the session" ); // Post-logout session probe is 401. let (status, _) = session_json(&fixture, &sid).await?; assert_eq!(status, StatusCode::UNAUTHORIZED); // Both tokens were revoked best-effort. let sent = fake.received_requests().await.unwrap_or_default(); assert_eq!( sent .iter() .filter(|request| request.url.path() == "/revoke") .count(), 2, "refresh + access must both be revoked" ); Ok(()) } // ---- DPoP proofs verify against the JWK the client sent ---- #[tokio::test] async fn dpop_proofs_verify_against_the_sent_jwk() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let now = now_secs(); mount_introspect( &fake, active_introspection(now + 300, now - 10, &fixture.jkt), ) .await; let (state, sid) = start_flow(&fixture).await?; let sid = callback_flow(&fixture, &state, &sid).await?; let (_, json) = session_json(&fixture, &sid).await?; let csrf = json["csrf_token"] .as_str() .expect("csrf_token served") .to_owned(); // Refresh so the fake AS sees two proofs (exchange + rotation). let response = send( fixture.app.clone(), post_request("/api/refresh", &cookie_for(&sid), &csrf, None), ) .await?; assert_eq!(response.status(), StatusCode::OK); let sent = fake.received_requests().await.unwrap_or_default(); let proofs: Vec = sent .iter() .filter(|request| request.url.path() == "/token") .filter_map(|request| { request .headers .get("dpop") .and_then(|value| value.to_str().ok()) .map(str::to_owned) }) .collect(); assert_eq!(proofs.len(), 2, "exchange + refresh each send a proof"); let mut jtis = Vec::new(); for proof in &proofs { let verified = oauth_core::dpop::proof::verify_proof(proof).expect("proof verifies"); assert_eq!(verified.htm, "POST"); assert_eq!(verified.htu, format!("{}/token", fixture.issuer)); let sent_jwk = verified.header.get("jwk").expect("proof carries its JWK"); assert_eq!(sent_jwk, &fixture.public_jwk, "proof key is the client key"); let jkt = oauth_core::dpop::key::thumbprint_of(sent_jwk).expect("thumbprint computes"); assert_eq!( jkt, fixture.jkt, "bound key matches the introspected cnf.jkt" ); jtis.push(verified.jti.clone()); } assert_ne!(jtis[0], jtis[1], "each proof carries a fresh jti"); Ok(()) } // ---- failure paths ---- #[tokio::test] async fn state_mismatch_never_exchanges() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let (_state, sid) = start_flow(&fixture).await?; let response = send( fixture.app.clone(), get_request( "/callback?code=authcode-1&state=forged-state", Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let sent = fake.received_requests().await.unwrap_or_default(); assert!( sent.iter().all(|request| request.url.path() != "/token"), "state mismatch must never trigger an exchange" ); Ok(()) } #[tokio::test] async fn discovery_503_yields_a_typed_error_page() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server")) .respond_with(ResponseTemplate::new(503)) .mount(&fake) .await; Mock::given(method("GET")) .and(path("/.well-known/openid-configuration")) .respond_with(ResponseTemplate::new(503)) .mount(&fake) .await; let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?; assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = body_text(response).await?; assert!( body.contains("unavailable"), "expected a typed error page, got: {body}" ); for forbidden in ["demo-secret", "code_verifier", "verifier"] { assert!( !body.contains(forbidden), "secret leaked into error page: {body}" ); } Ok(()) } #[tokio::test] async fn invalid_client_exchange_fails_closed_without_session() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token( &fake, ResponseTemplate::new(401).set_body_json(serde_json::json!({ "error": "invalid_client", "error_description": "unknown client", })), ) .await; let (state, sid) = start_flow(&fixture).await?; let response = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}"), Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = body_text(response).await?; assert!( !body.contains("demo-secret"), "secret leaked into error page: {body}" ); let (status, _) = session_json(&fixture, &sid).await?; assert_eq!( status, StatusCode::UNAUTHORIZED, "failed exchange creates no session" ); Ok(()) } #[tokio::test] async fn refresh_reuse_invalid_grant_clears_the_session() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; // First `/token` call (the exchange) succeeds; the refresh reuses a revoked // family member and is rejected. Mock::given(method("POST")) .and(path("/token")) .respond_with(token_ok("access-1", "refresh-1")) .up_to_n_times(1) .mount(&fake) .await; Mock::given(method("POST")) .and(path("/token")) .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": "invalid_grant", "error_description": "refresh token reused", }))) .mount(&fake) .await; let now = now_secs(); mount_introspect( &fake, active_introspection(now + 300, now - 10, &fixture.jkt), ) .await; let (state, sid) = start_flow(&fixture).await?; let sid = callback_flow(&fixture, &state, &sid).await?; let (_, json) = session_json(&fixture, &sid).await?; let csrf = json["csrf_token"] .as_str() .expect("csrf_token served") .to_owned(); let response = send( fixture.app.clone(), post_request("/api/refresh", &cookie_for(&sid), &csrf, None), ) .await?; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let body = body_text(response).await?; assert!( body.contains("invalid_grant"), "expected the reuse code, got: {body}" ); assert!( fixture.state.sessions().get(&sid).is_none(), "reuse must clear the session" ); Ok(()) } #[tokio::test] async fn csrf_and_origin_rejections_leave_the_session_intact() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let now = now_secs(); mount_introspect( &fake, active_introspection(now + 300, now - 10, &fixture.jkt), ) .await; let (state, sid) = start_flow(&fixture).await?; let sid = callback_flow(&fixture, &state, &sid).await?; let (_, json) = session_json(&fixture, &sid).await?; let csrf = json["csrf_token"] .as_str() .expect("csrf_token served") .to_owned(); // Foreign `Origin` is rejected. let response = send( fixture.app.clone(), post_request( "/api/refresh", &cookie_for(&sid), &csrf, Some("https://evil.example"), ), ) .await?; assert_eq!(response.status(), StatusCode::FORBIDDEN); // Missing CSRF token is rejected. let response = send( fixture.app.clone(), Request::builder() .uri("/api/refresh") .method("POST") .header("cookie", cookie_for(&sid)) .body(Body::empty()) .expect("request builds"), ) .await?; assert_eq!(response.status(), StatusCode::FORBIDDEN); // Cross-site fetch metadata is rejected. let response = send( fixture.app.clone(), Request::builder() .uri("/api/logout") .method("POST") .header("cookie", cookie_for(&sid)) .header("x-csrf-token", csrf.clone()) .header("sec-fetch-site", "cross-site") .body(Body::empty()) .expect("request builds"), ) .await?; assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!( fixture.state.sessions().get(&sid).is_some(), "rejected POSTs must not touch the session" ); Ok(()) } #[tokio::test] async fn cookie_flags_are_hardened_through_the_flow() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let now = now_secs(); mount_introspect( &fake, active_introspection(now + 300, now - 10, &fixture.jkt), ) .await; mount_revoke(&fake).await; // Start cookie: opaque id only, hardened attributes. let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?; let start_cookie = header_value(&start, "set-cookie"); for expected in ["sid=", "Path=/", "HttpOnly", "SameSite=Lax"] { assert!( start_cookie.contains(expected), "start cookie misses {expected}: {start_cookie}" ); } assert!( !start_cookie.contains("access-1"), "token in cookie: {start_cookie}" ); let sid = sid_from_set_cookie(&start_cookie).expect("sid cookie"); let state = fixture .state .pending() .pending_state_for_session(&sid) .expect("state bound to the session"); // Login rotates the id with the same flags. let sid = callback_flow(&fixture, &state, &sid).await?; let (_, json) = session_json(&fixture, &sid).await?; let csrf = json["csrf_token"] .as_str() .expect("csrf_token served") .to_owned(); let response = send( fixture.app.clone(), post_request("/api/logout", &cookie_for(&sid), &csrf, None), ) .await?; let cleared = header_value(&response, "set-cookie"); assert!( cleared.contains("Max-Age=0"), "logout must clear the cookie: {cleared}" ); Ok(()) } #[tokio::test] async fn no_secret_reaches_logs_or_debug_output() { let line = server::http_client::loggable_request( "POST", "https://as.example/token?code=AUTH-C0DE", true, true, ); let decorated = format!( "{line} secret={} token={}", server::http_client::redact_secret("CLIENT-S3CRET"), server::http_client::redact_secret("ACCESS-T0KEN"), ); for sensitive in ["CLIENT-S3CRET", "ACCESS-T0KEN", "AUTH-C0DE"] { assert!( !decorated.contains(sensitive), "secret leaked into log line: {decorated:?}" ); } let store = fixture_store(); let created = store.create().expect("session creates"); store.store_tokens( &created.id, Some(SecretString::from("ACCESS-S3CRET")), Some(SecretString::from("REFRESH-S3CRET")), Some("DPoP".to_owned()), ); let rendered = format!("{:?}", store.get(&created.id).expect("session lives")); for sensitive in ["ACCESS-S3CRET", "REFRESH-S3CRET"] { assert!( !rendered.contains(sensitive), "secret leaked into session Debug: {rendered:?}" ); } let cookie = server::session::set_cookie_value(&created.id, false, std::time::Duration::from_secs(60)); assert!(!cookie.contains("ACCESS-S3CRET")); let csrf = created.csrf_token.expose_secret().to_owned(); assert!(store.validate_csrf(&created.id, &csrf)); } fn fixture_store() -> server::session::SessionStore { server::session::SessionStore::new(std::time::Duration::from_secs(3600)) }