//! `test(e2e_flow)` full-browser authorization-code flow over the live router. //! //! The existing e2e suites stop at `303 /login?request_id=` (or mint codes //! directly through [`d_oauth::protocol::code::CodeIssuer`]); no test drives //! the complete browser flow. These tests do: `GET /authorize` (RFC 6749 //! §4.1.1 + OAuth 2.1 draft §4.1.3) → `GET /login` → `POST /login` (password + //! TOTP) → `GET /consent` → `POST /consent` → code on the registered redirect //! (RFC 9700 §4.5.3, `code` + `state` + RFC 9207 `iss`) → `POST /token` with //! the PKCE verifier (RFC 7636 §4.5) → tokens. //! //! A public client is used so the flow needs no PAR/JAR (a confidential //! client with only plain query params is rejected with `invalid_request`). //! Public clients are DPoP-bound end to end: the SAME [`DpopKey`] proves //! `GET /authorize` (its `jkt` is stashed in the pending-auth record) and //! `POST /token` (the redemption checks the proof `jkt` against the code's //! `cnf` binding), so one key spans the whole flow. #[allow(dead_code)] #[path = "e2e/harness.rs"] mod harness; #[path = "e2e/dpop.rs"] mod dpop; use std::time::Duration; use d_oauth::db::{ TokenEndpointAuthMethod, user_repo::{self, NewUser}, user_scope_repo, }; use dpop::DpopKey; use harness::{ TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client, seed_scope, }; /// Fixture public client (no secret: DPoP is its sender constraint). const CLIENT_ID: &str = "e2e-flow-public"; /// Registered loopback redirect (RFC 8252 loopback exception applies). const REDIRECT_URI: &str = "http://127.0.0.1:9/cb"; /// Catalog scope exercised by the flow. const SCOPE: &str = "read"; /// `state` echoed from authorize to the code redirect (unreserved chars only). const STATE: &str = "e2e-flow-state"; /// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`). const SESSION_COOKIE: &str = "session_id"; /// Uniform credential-rejection page text (`INVALID_CREDENTIALS`). const INVALID_CREDENTIALS: &str = "Invalid email, password, or code."; /// One browser driving the flow: live world, PKCE pair, DPoP key, login /// session material, and the provisioned user's credentials. struct Browser { world: World, dpop: DpopKey, verifier: String, request_id: String, presession: String, login_csrf: String, email: String, password: String, totp_secret: String, } /// Seed the world (public client + scope + redirect) and one TOTP-enrolled /// user entitled to the scope; drive `GET /authorize` → `GET /login`. async fn begin_browser(email: &str, password: &str) -> Result { let world = World::start(open_rate_limit()).await?; seed_client( &world.pool, &world.state.keys, CLIENT_ID, None, &["authorization_code", "refresh_token"], SCOPE, TokenEndpointAuthMethod::None, ) .await?; seed_scope(&world.pool, SCOPE).await?; attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?; let totp_secret = seed_user(&world, email, password).await?; let (challenge, verifier) = oauth2_pkce(); let dpop = DpopKey::generate()?; let proof = dpop.proof("GET", &format!("{}/authorize", world.state.config.issuer))?; let (status, headers, body) = world .get( &format!( "/authorize?client_id={CLIENT_ID}&redirect_uri={}&response_type=code&scope={SCOPE}\ &state={STATE}&code_challenge={challenge}&code_challenge_method=S256", encoded(REDIRECT_URI) ), &[("dpop", proof.as_str())], ) .await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("authorize must 303 to login: {status} {body}"), )?; let request_id = query_value(&location_of(&headers)?, "request_id")?; // The authorize layer stashes the verified DPoP thumbprint in the // pending-auth record the later code issuance binds (RFC 9700 §4.5.3). let mut conn = world.state.redis.clone(); let record = d_oauth::store::pending::get(&mut conn, &world.store, &request_id) .await .map_err(|error| fail(format!("pending read failed: {error}")))? .ok_or_else(|| fail("pending record must exist"))?; let expected = d_oauth::jose::jwk::thumbprint(&dpop.jwk()) .map_err(|error| fail(format!("jwk thumbprint: {error}")))?; ensure( record.dpop_jkt.as_deref() == Some(expected.as_str()), "pending record must carry the verified DPoP thumbprint", )?; let (status, headers, body) = world .get(&format!("/login?request_id={request_id}"), &[]) .await?; ensure( status == reqwest::StatusCode::OK, format!("login page must render: {status} {body}"), )?; let presession = cookie_value(&headers, SESSION_COOKIE)?; let login_csrf = hidden_input(&body, "csrf")?; Ok(Browser { world, dpop, verifier, request_id, presession, login_csrf, email: email.to_owned(), password: password.to_owned(), totp_secret, }) } /// Provision one enabled user (known password + TOTP secret, entitled to /// `SCOPE`) through the REAL repository path, using the server's own Argon2 /// parameters so verification matches production exactly. async fn seed_user(world: &World, email: &str, password: &str) -> Result { let raw = &world.state.config.argon2; let params = d_oauth::crypto::secret::Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32) .map_err(|error| fail(format!("argon2 params: {error}")))?; let hash = d_oauth::crypto::secret::hash_secret(password, ¶ms) .map_err(|error| fail(format!("hash password: {error}")))?; let created = user_repo::create_user( &world.pool, &world.state.keys, world.state.config.pii_dual_write, &NewUser { email: email.to_owned(), password_hash: hash, is_admin: false, must_change_password: false, }, ) .await?; let secret = d_oauth::crypto::totp::generate_secret() .map_err(|error| fail(format!("totp secret: {error}")))?; let blob = d_oauth::crypto::totp::encrypt_secret( &world.state.keys, &secret, !world.state.config.pii_dual_write, ) .map_err(|error| fail(format!("totp seal: {error}")))?; ensure( user_repo::set_totp_secret(&world.pool, created.id, &blob).await?, "totp enroll must update the user row", )?; user_scope_repo::entitle(&world.pool, created.id, SCOPE, None).await?; Ok(secret) } /// Current TOTP code from the wall clock, computed the way the server /// verifies it (RFC 6238, ±1-step window). Steps back from the step edge so /// the code cannot roll over between generation and the `POST /login`. async fn totp_now(secret: &str) -> Result { let step = d_oauth::crypto::totp::TOTP_STEP_SECS; let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64; if now % step >= step - 2 { tokio::time::sleep(Duration::from_secs(3)).await; } let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64; let verifier = d_oauth::crypto::totp::build_totp(secret) .map_err(|error| fail(format!("totp verifier: {error}")))?; let counter = now / step; Ok(format!("{:06}", verifier.generate(counter * step))) } /// `POST /login` as the browser: presession cookie + form credentials. async fn post_login( browser: &Browser, password: &str, code: &str, ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> { let cookie = format!("{SESSION_COOKIE}={}", browser.presession); browser .world .post_form( "/login", &[ ("email", browser.email.as_str()), ("password", password), ("code", code), ("csrf", browser.login_csrf.as_str()), ], None, &[("cookie", cookie.as_str())], ) .await } /// `GET /consent` as the browser holding `session`. async fn get_consent( browser: &Browser, session: &str, ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> { let cookie = format!("{SESSION_COOKIE}={session}"); browser .world .get("/consent", &[("cookie", cookie.as_str())]) .await } /// `POST /consent` with the bound `request_id` and the session form token. async fn post_consent( browser: &Browser, session: &str, request_id: &str, csrf: &str, decision: &str, ) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> { let cookie = format!("{SESSION_COOKIE}={session}"); browser .world .post_form( "/consent", &[ ("request_id", request_id), ("csrf", csrf), ("decision", decision), ], None, &[("cookie", cookie.as_str())], ) .await } /// Happy path: the full browser flow issues a code and redeems it for /// DPoP-bound tokens; the code is single-use (RFC 6749 §4.1.2: a second /// redemption is `invalid_grant`). #[tokio::test] async fn e2e_flow_browser_authorize_login_consent_token() -> Result<(), TestError> { let browser = begin_browser("flow-happy@example.test", "correct-horse-28-happy-path").await?; let code_2fa = totp_now(&browser.totp_secret).await?; let (status, headers, _) = post_login(&browser, &browser.password.clone(), &code_2fa).await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("login must 303 to consent: {status}"), )?; let location = location_of(&headers)?; ensure( location == "/consent", format!("login must land on consent, got {location}"), )?; let session = cookie_value(&headers, SESSION_COOKIE)?; ensure( session != browser.presession, "login must rotate the session id", )?; let (status, _, body) = get_consent(&browser, &session).await?; ensure( status == reqwest::StatusCode::OK, format!("consent must render: {status} {body}"), )?; let bound = hidden_input(&body, "request_id")?; ensure( bound == browser.request_id, "consent must serve the bound authorization request", )?; let consent_csrf = hidden_input(&body, "csrf")?; let (status, headers, _) = post_consent(&browser, &session, &bound, &consent_csrf, "approve").await?; ensure( status == reqwest::StatusCode::FOUND, format!("consent approval must 302 to the redirect: {status}"), )?; let location = location_of(&headers)?; ensure( location.starts_with(REDIRECT_URI), format!("code must return to the registered redirect, got {location}"), )?; let code = query_value(&location, "code")?; ensure( query_value(&location, "state")? == STATE, "code redirect must echo state", )?; ensure( has_query_key(&location, "iss"), "code redirect must carry iss (RFC 9207)", )?; // Authorization-code redemption (RFC 7636 §4.5): the same DPoP key that // proved `/authorize` proves `/token`, binding the minted token. let proof = browser.dpop.proof( "POST", &format!("{}/token", browser.world.state.config.issuer), )?; let (status, _, body) = browser .world .post_form( "/token", &[ ("grant_type", "authorization_code"), ("code", code.as_str()), ("redirect_uri", REDIRECT_URI), ("client_id", CLIENT_ID), ("code_verifier", browser.verifier.as_str()), ], None, &[("dpop", proof.as_str())], ) .await?; ensure( status == reqwest::StatusCode::OK, format!("code redemption must mint: {status} {body}"), )?; let tokens = json_body(&body)?; ensure( tokens["token_type"] == "DPoP", format!("public issuance must be DPoP-bound, got {tokens}"), )?; ensure( tokens["access_token"] .as_str() .is_some_and(|token| !token.is_empty()), "redemption must return an access token", )?; ensure( tokens["refresh_token"] .as_str() .is_some_and(|token| !token.is_empty()), "a registered refresh_token grant must return a refresh token", )?; ensure( tokens["scope"] == SCOPE, format!("tokens must carry the scope, got {tokens}"), )?; // The code is single-use: a second redemption fails closed. let replay = browser.dpop.proof( "POST", &format!("{}/token", browser.world.state.config.issuer), )?; let (status, _, body) = browser .world .post_form( "/token", &[ ("grant_type", "authorization_code"), ("code", code.as_str()), ("redirect_uri", REDIRECT_URI), ("client_id", CLIENT_ID), ("code_verifier", browser.verifier.as_str()), ], None, &[("dpop", replay.as_str())], ) .await?; ensure( status == reqwest::StatusCode::BAD_REQUEST, format!("code replay must be 400, got {status}: {body}"), )?; ensure( json_body(&body)?["error"] == "invalid_grant", "code replay must be invalid_grant", )?; Ok(()) } /// A wrong password is rejected uniformly (401, no session upgrade): the /// presession never becomes an authenticated session, so `/consent` bounces /// back to `/login` and no code can ever issue for the flow. #[tokio::test] async fn e2e_flow_login_wrong_password_rejected() -> Result<(), TestError> { let browser = begin_browser("flow-badpw@example.test", "correct-horse-28-right-one").await?; let code_2fa = totp_now(&browser.totp_secret).await?; let (status, headers, body) = post_login(&browser, "wrong-password-00000000", &code_2fa).await?; ensure( status == reqwest::StatusCode::UNAUTHORIZED, format!("wrong password must be 401, got {status}: {body}"), )?; ensure( body.contains(INVALID_CREDENTIALS), "rejection must render the uniform message", )?; ensure( cookie_value(&headers, SESSION_COOKIE).is_err(), "rejection must not upgrade the session", )?; let (status, headers, _) = get_consent(&browser, &browser.presession).await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("without a session consent must bounce to login: {status}"), )?; ensure( location_of(&headers)? == "/login", "the bounce target must be the login page", )?; Ok(()) } /// Denying consent redirects to the registered redirect with /// `error=access_denied` + echoed `state` (RFC 6749 §4.1.2.1) and issues no /// code. #[tokio::test] async fn e2e_flow_consent_deny_redirects_access_denied() -> Result<(), TestError> { let browser = begin_browser("flow-deny@example.test", "correct-horse-28-deny-case").await?; let code_2fa = totp_now(&browser.totp_secret).await?; let (status, headers, body) = post_login(&browser, &browser.password.clone(), &code_2fa).await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("login must 303 to consent: {status} {body}"), )?; let session = cookie_value(&headers, SESSION_COOKIE)?; let (status, _, body) = get_consent(&browser, &session).await?; ensure( status == reqwest::StatusCode::OK, format!("consent must render: {status}"), )?; let bound = hidden_input(&body, "request_id")?; let consent_csrf = hidden_input(&body, "csrf")?; let (status, headers, _) = post_consent(&browser, &session, &bound, &consent_csrf, "deny").await?; ensure( status == reqwest::StatusCode::FOUND, format!("consent denial must 302 to the redirect: {status}"), )?; let location = location_of(&headers)?; ensure( location.starts_with(REDIRECT_URI), format!("denial must return to the registered redirect, got {location}"), )?; ensure( query_value(&location, "error")? == "access_denied", "denial must carry error=access_denied", )?; ensure( query_value(&location, "state")? == STATE, "denial must echo state", )?; ensure( !has_query_key(&location, "code"), "denial must issue zero codes", )?; Ok(()) } /// PKCE pair from the `oauth2` crate (baseline-flow material only). fn oauth2_pkce() -> (String, String) { let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256(); (challenge.as_str().to_owned(), verifier.secret().to_owned()) } /// Percent-encode a value for query embedding. fn encoded(value: &str) -> String { let mut out = String::with_capacity(value.len()); for byte in value.bytes() { if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) { out.push(byte as char); } else { out.push_str(&format!("%{byte:02X}")); } } out } /// Read the `location` header or fail the test. fn location_of(headers: &reqwest::header::HeaderMap) -> Result { headers .get("location") .and_then(|value| value.to_str().ok()) .map(str::to_owned) .ok_or_else(|| fail("response lacks location header")) } /// Read a cookie value from the `set-cookie` headers or fail the test. fn cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Result { let prefix = format!("{name}="); let mut found = None; for value in headers.get_all("set-cookie").iter() { let text = value .to_str() .map_err(|_| fail("set-cookie is not visible ascii"))?; if let Some(rest) = text.strip_prefix(prefix.as_str()) { let end = rest.find(';').unwrap_or(rest.len()); found = Some(rest[..end].to_owned()); } } found.ok_or_else(|| fail(format!("response lacks {name} cookie"))) } /// Read a hidden form field (`name=".." value=".."`) from a page or fail. fn hidden_input(body: &str, name: &str) -> Result { let needle = format!("name=\"{name}\" value=\""); let start = body .find(needle.as_str()) .ok_or_else(|| fail(format!("page lacks hidden {name}")))?; let rest = &body[start + needle.len()..]; let end = rest .find('"') .ok_or_else(|| fail(format!("hidden {name} is unterminated")))?; Ok(rest[..end].to_owned()) } /// Read one query value from a redirect location or fail the test. fn query_value(location: &str, key: &str) -> Result { let query = location .split_once('?') .map(|pair| pair.1) .ok_or_else(|| fail(format!("location carries no query: {location}")))?; let prefix = format!("{key}="); for pair in query.split('&') { if let Some(value) = pair.strip_prefix(prefix.as_str()) { return Ok(value.to_owned()); } } Err(fail(format!("location lacks {key}: {location}"))) } /// Whether a redirect location carries a query key. fn has_query_key(location: &str, key: &str) -> bool { let Some(query) = location.split_once('?').map(|pair| pair.1) else { return false; }; let prefix = format!("{key}="); query .split('&') .any(|pair| pair.strip_prefix(prefix.as_str()).is_some()) }