| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766 |
- //! 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<dyn std::error::Error>>;
- struct Fixture {
- app: axum::Router,
- state: AppState,
- issuer: String,
- public_jwk: serde_json::Value,
- jkt: String,
- }
- fn empty_lookup(_: &str) -> Option<String> {
- 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"),
- "<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 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<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())
- }
- 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()
- }
- 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}")
- }
- /// `/oauth/start` -> bound `(state, sid)`.
- async fn start_flow(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 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<String, Box<dyn std::error::Error>> {
- 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<dyn std::error::Error>> {
- 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<String> = 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))
- }
|