//! BFF OAuth routes against a `wiremock` fake AS (todos 15-18). //! //! Drives the router with `tower::ServiceExt::oneshot`: no ports are bound, //! no servers start. Each test mounts its own fake AS (discovery, PAR, //! token, introspection, revocation) and its own [`AppState`] so discovery //! and introspection caches never leak across tests. use std::collections::HashMap; 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::{body_string_contains, method, path}, }; type TestResult = Result<(), Box>; struct Fixture { app: axum::Router, state: AppState, issuer: String, } fn empty_lookup(_: &str) -> Option { None } /// Build an app whose profile points at the fake AS. The DPoP key is /// generated in memory and injected (no key file is read). 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 state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key); let app = build_app(state.clone()); (Fixture { app, state, issuer }, dir) } /// Mount the discovery document on the fake AS. `with_par` controls whether /// the PAR endpoint is advertised (plain-authorize fallback when absent). async fn mount_discovery(fake: &MockServer, issuer: &str, with_par: bool) { let mut doc = serde_json::json!({ "issuer": issuer, "authorization_endpoint": format!("{issuer}/authorize"), "token_endpoint": format!("{issuer}/token"), "introspection_endpoint": format!("{issuer}/introspect"), "revocation_endpoint": format!("{issuer}/revoke"), "dpop_signing_alg_values_supported": ["ES256"], }); if with_par { doc["pushed_authorization_request_endpoint"] = serde_json::json!(format!("{issuer}/par")); } 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; } /// Introspection answer for one specific token (the form body carries /// `token=`), so a renewal test can answer the old and rotated access /// tokens differently. async fn mount_introspect_for(fake: &MockServer, token: &str, body: serde_json::Value) { Mock::given(method("POST")) .and(path("/introspect")) .and(body_string_contains(format!("token={token}"))) .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, with_cnf: bool) -> serde_json::Value { let mut body = serde_json::json!({ "active": true, "scope": "demo.read", "client_id": "demo-client", "token_type": "Bearer", "exp": exp, "iat": iat, "sub": "demo@example.com", }); if with_cnf { body["cnf"] = serde_json::json!({ "jkt": "thumbprint-of-dpop-key" }); } body } 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()) } /// Decode an `application/x-www-form-urlencoded` body (no extra dep). fn decode_form(raw: &str) -> HashMap { raw .split('&') .filter_map(|pair| { let (name, value) = pair.split_once('=')?; Some((percent_decode(name), percent_decode(value))) }) .collect() } /// Minimal percent-decoder for ASCII form bodies. fn percent_decode(raw: &str) -> String { let mut decoded = String::with_capacity(raw.len()); let mut bytes = raw.as_bytes().iter(); while let Some(byte) = bytes.next() { match byte { b'%' => { let high = bytes.next().copied().unwrap_or(b'0'); let low = bytes.next().copied().unwrap_or(b'0'); let pair = [high, low]; match std::str::from_utf8(&pair) { Ok(hex) => match u8::from_str_radix(hex, 16) { Ok(value) => decoded.push(value as char), Err(_) => decoded.push_str(&format!("%{hex}")), }, Err(_) => decoded.push('%'), } } b'+' => decoded.push(' '), _ => decoded.push(*byte as char), } } decoded } 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() } /// `sid` from a `Set-Cookie` response header. 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}") } // ---- todo 15: GET /oauth/start ---- #[tokio::test] async fn start_redirects_with_par_request_uri_and_binds_pending() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; 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=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Apar-handle-1") || location.contains("request_uri=urn:ietf:params:oauth:request_uri:par-handle-1"), "PAR handle missing from Location: {location}" ); assert!( location.contains("client_id=demo-client"), "client_id missing: {location}" ); for forbidden in ["code_verifier", "verifier", "demo-secret", "S3CRET"] { assert!( !location.contains(forbidden), "secret in redirect: {location}" ); } let set_cookie = header_value(&response, "set-cookie"); let sid = sid_from_set_cookie(&set_cookie).expect("start sets the sid cookie"); assert_eq!( fixture.state.pending().len(), 1, "pending map holds the request" ); let bound = fixture .state .pending() .pending_state_for_session(&sid) .expect("pending record is bound to the session"); assert!(!bound.is_empty()); let sent = fake.received_requests().await.unwrap_or_default(); assert!( sent.iter().any(|request| request.url.path() == "/par"), "PAR was not pushed" ); Ok(()) } #[tokio::test] async fn start_without_par_falls_back_to_plain_authorize_redirect() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, false).await; 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("response_type%3Dcode") || location.contains("response_type=code")); assert!( location.contains("code_challenge_method=S256"), "PKCE S256 missing: {location}" ); assert!( !location.contains("request_uri"), "no PAR handle expected: {location}" ); assert_eq!(fixture.state.pending().len(), 1); Ok(()) } #[tokio::test] async fn start_with_discovery_unavailable_yields_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}" ); assert!( fixture.state.pending().is_empty(), "nothing pending after a failed start" ); Ok(()) } #[tokio::test] async fn oauth_redirects_are_no_store() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?; assert_eq!(start.status(), StatusCode::FOUND); assert!(header_value(&start, "location").contains("/authorize")); assert_no_store(&start, "oauth/start"); let sid = sid_from_set_cookie(&header_value(&start, "set-cookie")).expect("sid cookie"); let state = fixture .state .pending() .pending_state_for_session(&sid) .expect("state bound to the session"); let done = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}"), Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(done.status(), StatusCode::FOUND); assert_no_store(&done, "callback"); Ok(()) } fn assert_no_store(response: &axum::http::Response, route: &str) { assert_eq!( header_value(response, "cache-control"), "no-store", "{route} must not be cacheable" ); assert_eq!( header_value(response, "pragma"), "no-cache", "{route} pragma" ); assert_eq!(header_value(response, "expires"), "0", "{route} expiry"); } // ---- todo 16: GET /callback ---- /// Drive `/oauth/start` then `/callback` with the bound state (happy path). async fn start_state(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 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)) } #[tokio::test] async fn callback_happy_path_creates_session_rotates_and_redirects_to_dashboard() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let (state, sid) = start_state(&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::FOUND); assert_eq!(header_value(&response, "location"), "/dashboard"); let next_sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie"); assert_ne!(sid, next_sid, "session id must rotate on login"); assert!( fixture.state.sessions().get(&sid).is_none(), "old id must die" ); let session = fixture .state .sessions() .get(&next_sid) .expect("new session lives"); assert_eq!( session .access_token() .expect("access stored") .expose_secret(), "access-1" ); assert_eq!( session .refresh_token() .expect("refresh stored") .expose_secret(), "refresh-1" ); assert_eq!(session.token_type(), Some("DPoP")); let sent = fake.received_requests().await.unwrap_or_default(); let token_call = sent .iter() .find(|request| request.url.path() == "/token") .expect("code exchanged"); assert!( token_call.headers.get("dpop").is_some(), "exchange needs a DPoP proof" ); let body = decode_form(&String::from_utf8_lossy(&token_call.body)); assert_eq!( body.get("redirect_uri").map(String::as_str), Some("http://127.0.0.1:3000/callback") ); assert!( body .get("code_verifier") .is_some_and(|value| !value.is_empty()) ); Ok(()) } #[tokio::test] async fn callback_unknown_state_is_400_without_exchange() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let response = send( fixture.app.clone(), get_request("/callback?code=authcode-1&state=no-such-state", None), ) .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"), "unknown state must never trigger an exchange" ); Ok(()) } #[tokio::test] async fn callback_replayed_state_is_400() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let (state, sid) = start_state(&fixture).await?; let first = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}"), Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(first.status(), StatusCode::FOUND); let replay = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}"), Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(replay.status(), StatusCode::BAD_REQUEST); Ok(()) } #[tokio::test] async fn callback_access_denied_maps_to_user_facing_denial() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let response = send( fixture.app.clone(), get_request( "/callback?error=access_denied&error_description=the+user+denied+consent", None, ), ) .await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_text(response).await?; assert!( body.contains("denied"), "expected a denial page, got: {body}" ); let sent = fake.received_requests().await.unwrap_or_default(); assert!( sent.iter().all(|request| request.url.path() != "/token"), "denial must not exchange" ); Ok(()) } #[tokio::test] async fn callback_invalid_scope_names_the_required_scope() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let response = send( fixture.app.clone(), get_request( "/callback?error=invalid_scope&error_description=the+requested+scope+is+unknown", None, ), ) .await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_text(response).await?; assert!( body.contains("Scope not granted"), "expected the scope-not-granted page, got: {body}" ); assert!( body.contains("demo.read"), "the page must name the configured scope, got: {body}" ); let sent = fake.received_requests().await.unwrap_or_default(); assert!( sent.iter().all(|request| request.url.path() != "/token"), "invalid_scope must not exchange" ); Ok(()) } #[tokio::test] async fn callback_iss_mismatch_is_rejected() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let (state, sid) = start_state(&fixture).await?; let response = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}&iss=https%3A%2F%2Fevil.example"), 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"), "iss mismatch must not exchange" ); Ok(()) } #[tokio::test] async fn callback_matching_iss_is_accepted() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_par(&fake).await; mount_token(&fake, token_ok("access-1", "refresh-1")).await; let (state, sid) = start_state(&fixture).await?; let iss = urlencoding_like(&fixture.issuer); let response = send( fixture.app.clone(), get_request( &format!("/callback?code=authcode-1&state={state}&iss={iss}"), Some(&cookie_for(&sid)), ), ) .await?; assert_eq!(response.status(), StatusCode::FOUND); assert_eq!(header_value(&response, "location"), "/dashboard"); Ok(()) } /// Percent-encode an issuer for the `iss` query parameter without a new dep. fn urlencoding_like(raw: &str) -> String { let mut encoded = String::with_capacity(raw.len()); for byte in raw.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { encoded.push(byte as char) } _ => encoded.push_str(&format!("%{byte:02X}")), } } encoded } // ---- todo 17: GET /api/session ---- /// Authenticated session holding `access-1`/`refresh-1` plus its CSRF token. fn authenticated_session(fixture: &Fixture) -> (String, String) { let created = fixture.state.sessions().create().expect("session creates"); assert!(fixture.state.sessions().store_tokens( &created.id, Some(SecretString::from("access-1".to_owned())), Some(SecretString::from("refresh-1".to_owned())), Some("DPoP".to_owned()), )); let csrf = fixture .state .sessions() .get(&created.id) .expect("session lives") .csrf_token() .expose_secret() .to_owned(); (created.id, csrf) } #[tokio::test] async fn session_api_returns_subject_scopes_cnf_and_csrf_token() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock available") .as_secs() as i64; mount_introspect(&fake, active_introspection(now + 300, now - 10, true)).await; let (sid, csrf) = authenticated_session(&fixture); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&sid))), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!(json["issuer"], fixture.issuer); assert_eq!(json["client_id"], "demo-client"); assert_eq!(json["subject"], "demo@example.com"); assert_eq!(json["scope"], serde_json::json!(["demo.read"])); assert_eq!( json["missing_scopes"], serde_json::json!([]), "a grant covering the required scope reports no missing scope, got: {body}" ); assert_eq!( json["token_type"], "DPoP", "cnf.jkt must surface as DPoP, got: {body}" ); assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key"); assert_eq!(json["iat"], now - 10); assert_eq!(json["exp"], now + 300); let expires_in = json["expires_in"].as_i64().expect("expires_in is a number"); assert!( (290..=300).contains(&expires_in), "unexpected expires_in {expires_in}" ); assert_eq!(json["csrf_token"], serde_json::Value::String(csrf)); for forbidden in ["access-1", "refresh-1", "demo-secret", "S3CRET"] { assert!( !body.contains(forbidden), "secret leaked into /api/session: {body}" ); } Ok(()) } #[tokio::test] async fn session_api_without_cnf_falls_back_to_stored_token_type() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock available") .as_secs() as i64; mount_introspect(&fake, active_introspection(now + 60, now - 5, false)).await; let (sid, _) = authenticated_session(&fixture); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&sid))), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!( json["token_type"], "DPoP", "stored exchange type wins, got: {body}" ); assert!(json.get("cnf").is_none_or(|value| value.is_null())); Ok(()) } #[tokio::test] async fn session_api_inactive_with_valid_refresh_renews() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock available") .as_secs() as i64; mount_introspect_for(&fake, "access-1", serde_json::json!({ "active": false })).await; mount_introspect_for( &fake, "access-2", active_introspection(now + 300, now - 1, true), ) .await; mount_token(&fake, token_ok("access-2", "refresh-2")).await; let (sid, _) = authenticated_session(&fixture); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&sid))), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!( json["exp"], now + 300, "renewed view must carry the new expiry, got: {body}" ); assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key"); assert_eq!(json["subject"], "demo@example.com"); let session = fixture .state .sessions() .get(&sid) .expect("a valid refresh token must keep the session alive"); assert_eq!( session.access_token().expect("new access").expose_secret(), "access-2" ); assert_eq!( session .refresh_token() .expect("rotated refresh") .expose_secret(), "refresh-2" ); for forbidden in ["access-2", "refresh-2", "demo-secret"] { assert!(!body.contains(forbidden), "token leaked into {body}"); } Ok(()) } #[tokio::test] async fn session_api_active_within_skew_renews() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock available") .as_secs() as i64; mount_introspect_for( &fake, "access-1", active_introspection(now + 10, now - 300, true), ) .await; mount_introspect_for( &fake, "access-2", active_introspection(now + 300, now - 1, true), ) .await; mount_token(&fake, token_ok("access-2", "refresh-2")).await; let (sid, _) = authenticated_session(&fixture); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&sid))), ) .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_text(response).await?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!( json["exp"], now + 300, "a token inside the skew window must be renewed, got: {body}" ); let session = fixture .state .sessions() .get(&sid) .expect("session survives the renewal"); assert_eq!( session.access_token().expect("new access").expose_secret(), "access-2" ); Ok(()) } #[tokio::test] async fn session_api_inactive_with_invalid_grant_clears_the_session() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_introspect(&fake, serde_json::json!({ "active": false })).await; mount_token( &fake, ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": "invalid_grant", "error_description": "refresh token reused", })), ) .await; let (sid, _) = authenticated_session(&fixture); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&sid))), ) .await?; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!( header_value(&response, "set-cookie").contains("Max-Age=0"), "cookie must clear" ); assert!( fixture.state.sessions().get(&sid).is_none(), "reuse detection must still end the session" ); Ok(()) } #[tokio::test] async fn session_api_inactive_without_refresh_token_clears_the_session() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_introspect(&fake, serde_json::json!({ "active": false })).await; let created = fixture.state.sessions().create().expect("session creates"); assert!(fixture.state.sessions().store_tokens( &created.id, Some(SecretString::from("access-1".to_owned())), None, Some("DPoP".to_owned()), )); let response = send( fixture.app.clone(), get_request("/api/session", Some(&cookie_for(&created.id))), ) .await?; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!( header_value(&response, "set-cookie").contains("Max-Age=0"), "cookie must clear" ); assert!( fixture.state.sessions().get(&created.id).is_none(), "a session with no refresh token cannot be renewed" ); Ok(()) } #[tokio::test] async fn session_api_unauthenticated_is_401() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let response = send(fixture.app.clone(), get_request("/api/session", None)).await?; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); Ok(()) } // ---- todo 18: POST /api/refresh + POST /api/logout ---- #[tokio::test] async fn refresh_logout_rotation_replaces_the_refresh_token() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_token(&fake, token_ok("access-2", "refresh-2")).await; let (sid, csrf) = authenticated_session(&fixture); 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-2", "refresh-2", "demo-secret"] { assert!( !body.contains(forbidden), "token leaked into refresh answer: {body}" ); } let session = fixture .state .sessions() .get(&sid) .expect("session survives rotation"); assert_eq!( session.access_token().expect("new access").expose_secret(), "access-2" ); assert_eq!( session .refresh_token() .expect("new refresh") .expose_secret(), "refresh-2" ); let sent = fake.received_requests().await.unwrap_or_default(); let refresh_call = sent .iter() .find(|request| request.url.path() == "/token") .expect("refresh posted"); assert!( refresh_call.headers.get("dpop").is_some(), "refresh needs a fresh DPoP proof" ); Ok(()) } #[tokio::test] async fn refresh_logout_invalid_grant_clears_the_session() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_token( &fake, ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": "invalid_grant", "error_description": "refresh token reused", })), ) .await; let (sid, csrf) = authenticated_session(&fixture); let response = send( fixture.app.clone(), post_request("/api/refresh", &cookie_for(&sid), &csrf, None), ) .await?; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert!( fixture.state.sessions().get(&sid).is_none(), "reuse must clear the session" ); assert!( fixture.state.sessions().is_empty(), "no session may survive invalid_grant" ); Ok(()) } #[tokio::test] async fn refresh_logout_revokes_best_effort_and_clears() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; mount_revoke(&fake).await; let (sid, csrf) = authenticated_session(&fixture); 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?; let json: serde_json::Value = serde_json::from_str(&body)?; assert_eq!(json["ok"], true); assert!( json["note"] .as_str() .unwrap_or_default() .contains("provider logout endpoint"), "logout must document the AS-session limit, got: {body}" ); assert!( fixture.state.sessions().get(&sid).is_none(), "logout clears the session" ); let sent = fake.received_requests().await.unwrap_or_default(); let revocations = sent .iter() .filter(|request| request.url.path() == "/revoke") .count(); assert_eq!( revocations, 2, "refresh + access must both be revoked best-effort" ); Ok(()) } #[tokio::test] async fn refresh_logout_foreign_origin_post_is_403() -> TestResult { let fake = MockServer::start().await; let (fixture, _dir) = fixture_app(&fake); mount_discovery(&fake, &fixture.issuer, true).await; let (sid, csrf) = authenticated_session(&fixture); 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); assert!( fixture.state.sessions().get(&sid).is_some(), "rejected POST must not touch the session" ); Ok(()) }