From 49c545892bf4dc5d864b7b890af9594dd62ddbc4 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Sat, 29 Aug 2026 00:13:47 +0200 Subject: [PATCH 1/7] Harden login session issuance --- gateway/src/auth/handlers.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/gateway/src/auth/handlers.rs b/gateway/src/auth/handlers.rs index c04daef..a0512fd 100644 --- a/gateway/src/auth/handlers.rs +++ b/gateway/src/auth/handlers.rs @@ -139,7 +139,17 @@ pub async fn login_handler( .await .map_err(|e| AppError::internal("refresh token insert", e))?; - session + // A pre-authentication session ID may already be known to another party + // (for example after bootstrapping a CSRF token). Rotate it before attaching + // authenticated state so possession of the old ID cannot inherit the login. + if session.id().is_some() + && let Err(error) = session.cycle_id().await + { + remove_unissued_refresh_token(pool, refresh_jti).await; + return Err(AppError::internal("session id rotation", error)); + } + + if let Err(error) = session .insert( crate::session::SESSION_USER_KEY, crate::session::SessionUser { @@ -147,7 +157,10 @@ pub async fn login_handler( }, ) .await - .map_err(|e| AppError::internal("session insert", e))?; + { + remove_unissued_refresh_token(pool, refresh_jti).await; + return Err(AppError::internal("session insert", error)); + } Ok(Json(LoginResponse { token, @@ -156,6 +169,23 @@ pub async fn login_handler( })) } +/// Remove a refresh credential that was persisted for a login which could not +/// complete its session transition. Cleanup is best-effort because the original +/// session-store error remains the request's primary failure. +async fn remove_unissued_refresh_token(pool: &sqlx::PgPool, refresh_jti: Uuid) { + if let Err(error) = sqlx::query("DELETE FROM refresh_tokens WHERE jti = $1") + .bind(refresh_jti) + .execute(pool) + .await + { + tracing::error!( + refresh_jti = %refresh_jti, + error = %error, + "failed to remove refresh token after login session failure" + ); + } +} + #[utoipa::path( post, path = "/auth/refresh", From 03e8a3a59b5bacf2253cea6048ba3accad0baf0a Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Sat, 29 Aug 2026 00:14:05 +0200 Subject: [PATCH 2/7] Test login session ID rotation --- e2e-tests/tests/gateway_session_test.rs | 111 ++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 e2e-tests/tests/gateway_session_test.rs diff --git a/e2e-tests/tests/gateway_session_test.rs b/e2e-tests/tests/gateway_session_test.rs new file mode 100644 index 0000000..0f4849b --- /dev/null +++ b/e2e-tests/tests/gateway_session_test.rs @@ -0,0 +1,111 @@ +//! Session-security regressions for the gateway. + +use std::time::Duration; + +use anyhow::{Context, ensure}; +use e2e_tests::common::GatewayEnv; +use reqwest::header::{COOKIE, SET_COOKIE}; + +const TEST_PASSWORD: &str = "synthetic-gateway-test-password"; +const TEST_USER_ID: &str = "00000000-0000-0000-0000-000000000001"; +const SESSION_COOKIE: &str = "rwf_session"; + +fn test_client() -> anyhow::Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .context("failed to build reqwest client") +} + +fn response_cookie(response: &reqwest::Response, name: &str) -> anyhow::Result { + for header in response.headers().get_all(SET_COOKIE) { + let header = header.to_str().context("Set-Cookie header was not UTF-8")?; + let Some((cookie_name, rest)) = header.split_once('=') else { + continue; + }; + if cookie_name.trim() != name { + continue; + } + + let value = rest.split(';').next().unwrap_or_default(); + ensure!(!value.is_empty(), "{name} cookie had an empty value"); + return Ok(value.to_owned()); + } + + anyhow::bail!("response did not set the {name} cookie") +} + +#[tokio::test] +async fn login_rotates_pre_auth_session_id_and_invalidates_the_old_cookie() -> anyhow::Result<()> { + let gateway = GatewayEnv::start().await?; + let base = gateway.base_url(); + let client = test_client()?; + + // CSRF bootstrap deliberately creates useful pre-authentication session + // state. Capture that cookie as if another party already knew its ID. + let csrf_response = client + .get(format!("{base}/auth/csrf")) + .send() + .await + .context("failed to bootstrap CSRF session")? + .error_for_status() + .context("CSRF bootstrap failed")?; + let old_session = response_cookie(&csrf_response, SESSION_COOKIE)?; + + // Authenticate while presenting the known pre-authentication session ID. + let login_response = client + .post(format!("{base}/auth/login")) + .header(COOKIE, format!("{SESSION_COOKIE}={old_session}")) + .json(&serde_json::json!({ + "user_id": TEST_USER_ID, + "password": TEST_PASSWORD, + })) + .send() + .await + .context("failed to login with pre-authentication session")? + .error_for_status() + .context("login failed")?; + let new_session = response_cookie(&login_response, SESSION_COOKIE)?; + + ensure!( + old_session != new_session, + "login retained the pre-authentication session ID" + ); + + // The exact old credential must no longer resolve to authenticated state. + let stale_whoami: serde_json::Value = client + .get(format!("{base}/session/whoami")) + .header(COOKIE, format!("{SESSION_COOKIE}={old_session}")) + .send() + .await + .context("failed to query stale session")? + .error_for_status() + .context("stale session whoami failed")? + .json() + .await + .context("stale session whoami returned invalid JSON")?; + ensure!( + stale_whoami["user_id"].is_null(), + "old pre-authentication session inherited authenticated state: {stale_whoami}" + ); + + // The rotated credential must still carry the authenticated session state. + let active_whoami: serde_json::Value = client + .get(format!("{base}/session/whoami")) + .header(COOKIE, format!("{SESSION_COOKIE}={new_session}")) + .send() + .await + .context("failed to query rotated session")? + .error_for_status() + .context("rotated session whoami failed")? + .json() + .await + .context("rotated session whoami returned invalid JSON")?; + ensure!( + active_whoami["user_id"] == TEST_USER_ID, + "rotated session did not retain authenticated state: {active_whoami}" + ); + + gateway.shutdown().await; + Ok(()) +} From 9047865c3b89128bcce492a898e190741a785136 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Sat, 29 Aug 2026 00:18:42 +0200 Subject: [PATCH 3/7] Persist authenticated sessions before issuing credentials --- gateway/src/auth/handlers.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gateway/src/auth/handlers.rs b/gateway/src/auth/handlers.rs index a0512fd..2280dd4 100644 --- a/gateway/src/auth/handlers.rs +++ b/gateway/src/auth/handlers.rs @@ -162,6 +162,15 @@ pub async fn login_handler( return Err(AppError::internal("session insert", error)); } + // tower-sessions normally persists modified state after the handler returns. + // Persist once here as well so a store failure is observable while we can + // still compensate the refresh-token insert rather than returning a valid + // refresh credential for a login whose authenticated session was not saved. + if let Err(error) = session.save().await { + remove_unissued_refresh_token(pool, refresh_jti).await; + return Err(AppError::internal("session save", error)); + } + Ok(Json(LoginResponse { token, refresh_token: raw_refresh, From 9d43dbd8b32d099a98f2b7c12fc9f62286ed3bea Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 15:58:33 +0200 Subject: [PATCH 4/7] Test refresh-token replay family revocation --- e2e-tests/tests/gateway_session_test.rs | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/e2e-tests/tests/gateway_session_test.rs b/e2e-tests/tests/gateway_session_test.rs index 0f4849b..29da283 100644 --- a/e2e-tests/tests/gateway_session_test.rs +++ b/e2e-tests/tests/gateway_session_test.rs @@ -109,3 +109,74 @@ async fn login_rotates_pre_auth_session_id_and_invalidates_the_old_cookie() -> a gateway.shutdown().await; Ok(()) } + +#[tokio::test] +async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> anyhow::Result<()> { + let gateway = GatewayEnv::start().await?; + let base = gateway.base_url(); + let client = test_client()?; + + let login: serde_json::Value = client + .post(format!("{base}/auth/login")) + .json(&serde_json::json!({ + "user_id": TEST_USER_ID, + "password": TEST_PASSWORD, + })) + .send() + .await + .context("failed to login before refresh rotation")? + .error_for_status() + .context("login before refresh rotation failed")? + .json() + .await + .context("login response returned invalid JSON")?; + let old_refresh = login["refresh_token"] + .as_str() + .context("login response did not contain a refresh token")? + .to_owned(); + + let rotation: serde_json::Value = client + .post(format!("{base}/auth/refresh")) + .json(&serde_json::json!({ "refresh_token": old_refresh })) + .send() + .await + .context("failed to rotate refresh token")? + .error_for_status() + .context("refresh-token rotation failed")? + .json() + .await + .context("refresh-token rotation returned invalid JSON")?; + let new_refresh = rotation["refresh_token"] + .as_str() + .context("refresh-token rotation did not return a replacement")? + .to_owned(); + + // Reusing the exact old capability is the replay signal. It must fail and + // revoke every still-active credential in that token family. + let replay = client + .post(format!("{base}/auth/refresh")) + .json(&serde_json::json!({ "refresh_token": old_refresh })) + .send() + .await + .context("failed to replay rotated refresh token")?; + ensure!( + replay.status() == reqwest::StatusCode::UNAUTHORIZED, + "replayed refresh token was accepted: {}", + replay.status() + ); + + let replacement_after_replay = client + .post(format!("{base}/auth/refresh")) + .json(&serde_json::json!({ "refresh_token": new_refresh })) + .send() + .await + .context("failed to probe replacement after replay")?; + ensure!( + replacement_after_replay.status() == reqwest::StatusCode::UNAUTHORIZED, + "refresh-token replay did not revoke the replacement family: {}", + replacement_after_replay.status() + ); + + gateway.shutdown().await; + Ok(()) +} From 24b4af53b9e9d16f8a6dc781bb40c53f8da58faf Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 16:05:04 +0200 Subject: [PATCH 5/7] Share gateway fixture across auth security tests --- e2e-tests/tests/gateway_session_test.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/e2e-tests/tests/gateway_session_test.rs b/e2e-tests/tests/gateway_session_test.rs index 29da283..275fce6 100644 --- a/e2e-tests/tests/gateway_session_test.rs +++ b/e2e-tests/tests/gateway_session_test.rs @@ -3,13 +3,19 @@ use std::time::Duration; use anyhow::{Context, ensure}; -use e2e_tests::common::GatewayEnv; +use e2e_tests::common::{GatewayEnv, SharedServer}; use reqwest::header::{COOKIE, SET_COOKIE}; const TEST_PASSWORD: &str = "synthetic-gateway-test-password"; const TEST_USER_ID: &str = "00000000-0000-0000-0000-000000000001"; const SESSION_COOKIE: &str = "rwf_session"; +static GATEWAY: SharedServer = SharedServer::new(); + +async fn get_gateway() -> anyhow::Result<&'static GatewayEnv> { + GATEWAY.get(|| async { GatewayEnv::start().await }).await +} + fn test_client() -> anyhow::Result { reqwest::Client::builder() .timeout(Duration::from_secs(5)) @@ -37,7 +43,7 @@ fn response_cookie(response: &reqwest::Response, name: &str) -> anyhow::Result anyhow::Result<()> { - let gateway = GatewayEnv::start().await?; + let gateway = get_gateway().await?; let base = gateway.base_url(); let client = test_client()?; @@ -106,13 +112,12 @@ async fn login_rotates_pre_auth_session_id_and_invalidates_the_old_cookie() -> a "rotated session did not retain authenticated state: {active_whoami}" ); - gateway.shutdown().await; Ok(()) } #[tokio::test] async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> anyhow::Result<()> { - let gateway = GatewayEnv::start().await?; + let gateway = get_gateway().await?; let base = gateway.base_url(); let client = test_client()?; @@ -177,6 +182,5 @@ async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> any replacement_after_replay.status() ); - gateway.shutdown().await; Ok(()) } From 20c0840cc378af4573de4a0d3f6c6ee9a2013f88 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 16:09:18 +0200 Subject: [PATCH 6/7] Prove active refresh descendant before replay --- e2e-tests/tests/gateway_session_test.rs | 66 +++++++++++++++---------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/e2e-tests/tests/gateway_session_test.rs b/e2e-tests/tests/gateway_session_test.rs index 275fce6..2371d3d 100644 --- a/e2e-tests/tests/gateway_session_test.rs +++ b/e2e-tests/tests/gateway_session_test.rs @@ -41,6 +41,29 @@ fn response_cookie(response: &reqwest::Response, name: &str) -> anyhow::Result anyhow::Result { + let rotation: serde_json::Value = client + .post(format!("{base}/auth/refresh")) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .await + .context("failed to rotate refresh token")? + .error_for_status() + .context("refresh-token rotation failed")? + .json() + .await + .context("refresh-token rotation returned invalid JSON")?; + + rotation["refresh_token"] + .as_str() + .context("refresh-token rotation did not return a replacement") + .map(str::to_owned) +} + #[tokio::test] async fn login_rotates_pre_auth_session_id_and_invalidates_the_old_cookie() -> anyhow::Result<()> { let gateway = get_gateway().await?; @@ -116,7 +139,7 @@ async fn login_rotates_pre_auth_session_id_and_invalidates_the_old_cookie() -> a } #[tokio::test] -async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> anyhow::Result<()> { +async fn replaying_rotated_refresh_token_revokes_the_active_family() -> anyhow::Result<()> { let gateway = get_gateway().await?; let base = gateway.base_url(); let client = test_client()?; @@ -135,32 +158,23 @@ async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> any .json() .await .context("login response returned invalid JSON")?; - let old_refresh = login["refresh_token"] + let original_refresh = login["refresh_token"] .as_str() .context("login response did not contain a refresh token")? .to_owned(); - let rotation: serde_json::Value = client - .post(format!("{base}/auth/refresh")) - .json(&serde_json::json!({ "refresh_token": old_refresh })) - .send() - .await - .context("failed to rotate refresh token")? - .error_for_status() - .context("refresh-token rotation failed")? - .json() - .await - .context("refresh-token rotation returned invalid JSON")?; - let new_refresh = rotation["refresh_token"] - .as_str() - .context("refresh-token rotation did not return a replacement")? - .to_owned(); + // Rotate twice before replaying anything. The second successful rotation is + // an observable proof that the first replacement was genuinely usable; this + // prevents the final 401 assertion from false-greening on an already-dead + // replacement token. + let first_replacement = rotate_refresh(&client, &base, &original_refresh).await?; + let active_descendant = rotate_refresh(&client, &base, &first_replacement).await?; - // Reusing the exact old capability is the replay signal. It must fail and - // revoke every still-active credential in that token family. + // Reusing the exact original capability is the replay signal. It must fail + // and revoke every still-active descendant in that token family. let replay = client .post(format!("{base}/auth/refresh")) - .json(&serde_json::json!({ "refresh_token": old_refresh })) + .json(&serde_json::json!({ "refresh_token": original_refresh })) .send() .await .context("failed to replay rotated refresh token")?; @@ -170,16 +184,16 @@ async fn replaying_rotated_refresh_token_revokes_the_replacement_family() -> any replay.status() ); - let replacement_after_replay = client + let descendant_after_replay = client .post(format!("{base}/auth/refresh")) - .json(&serde_json::json!({ "refresh_token": new_refresh })) + .json(&serde_json::json!({ "refresh_token": active_descendant })) .send() .await - .context("failed to probe replacement after replay")?; + .context("failed to probe active descendant after replay")?; ensure!( - replacement_after_replay.status() == reqwest::StatusCode::UNAUTHORIZED, - "refresh-token replay did not revoke the replacement family: {}", - replacement_after_replay.status() + descendant_after_replay.status() == reqwest::StatusCode::UNAUTHORIZED, + "refresh-token replay did not revoke the active family: {}", + descendant_after_replay.status() ); Ok(()) From 941840c230dbb0ac340ce03b12ed3ef8dba452f2 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 16:14:08 +0200 Subject: [PATCH 7/7] Clear auth state after session save failure --- gateway/src/auth/handlers.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gateway/src/auth/handlers.rs b/gateway/src/auth/handlers.rs index 2280dd4..ea12361 100644 --- a/gateway/src/auth/handlers.rs +++ b/gateway/src/auth/handlers.rs @@ -167,6 +167,11 @@ pub async fn login_handler( // still compensate the refresh-token insert rather than returning a valid // refresh credential for a login whose authenticated session was not saved. if let Err(error) = session.save().await { + // The outer SessionManagerLayer will still inspect this Session after the + // handler returns. Remove authenticated in-memory state first so a + // transient retry by that middleware cannot turn this 500 response into + // a successfully persisted authenticated session/cookie. + session.clear().await; remove_unissued_refresh_token(pool, refresh_jti).await; return Err(AppError::internal("session save", error)); }