| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115 |
- //! 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<dyn std::error::Error>>;
- struct Fixture {
- app: axum::Router,
- state: AppState,
- issuer: String,
- }
- fn empty_lookup(_: &str) -> Option<String> {
- 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"),
- "<html><body>spa-shell</body></html>",
- )
- .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=<value>`), 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<Body>,
- ) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
- Ok(app.oneshot(request).await?)
- }
- fn get_request(uri: &str, cookie: Option<&str>) -> Request<Body> {
- 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<Body> {
- 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<Body>,
- ) -> Result<String, Box<dyn std::error::Error>> {
- 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<String, String> {
- 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<Body>, 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<String> {
- 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<Body>, 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<dyn std::error::Error>> {
- 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(())
- }
|