From d377057b6b9e736e4b9c2b0dde1357367f93d3ea Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:33:10 +0200 Subject: [PATCH 1/6] Make auth logout clear session state --- gateway/src/auth/middleware.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/gateway/src/auth/middleware.rs b/gateway/src/auth/middleware.rs index 8ea5ee1..af22b0c 100644 --- a/gateway/src/auth/middleware.rs +++ b/gateway/src/auth/middleware.rs @@ -4,6 +4,10 @@ //! prefix, validates the token using the configured `EdDSA` public key, and //! injects the parsed [`super::jwt::Claims`] into request extensions. //! +//! Successful `/auth/logout` requests also flush the server-side session so +//! the gateway's Bearer-token and cookie-session auth mechanisms cannot remain +//! authenticated independently after the unified logout endpoint succeeds. +//! //! # Usage //! //! ```ignore @@ -13,6 +17,7 @@ use axum::extract::{Request, State}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; +use tower_sessions::Session; use super::error::AppError; use super::jwt::validate_jwt; @@ -22,8 +27,10 @@ use crate::gateway::GatewayState; /// header. /// /// On success the request is forwarded with [`super::jwt::Claims`] injected -/// into the request extensions (accessible via `Extension`). On -/// failure a 401 is returned. +/// into the request extensions (accessible via `Extension`). On a +/// successful `/auth/logout` response, the current cookie session is flushed +/// before returning success to the client. On authentication failure a 401 is +/// returned; a session-store failure during logout is returned as a 500. /// /// Apply it with: /// ```ignore @@ -31,9 +38,11 @@ use crate::gateway::GatewayState; /// ``` pub async fn auth_middleware( State(state): State, + session: Session, mut request: Request, next: Next, ) -> Response { + let is_logout = request.uri().path() == "/auth/logout"; let auth_header = request .headers() .get("Authorization") @@ -65,5 +74,12 @@ pub async fn auth_middleware( // extract them via `Extension`. request.extensions_mut().insert(claims); - next.run(request).await + let response = next.run(request).await; + if is_logout && response.status().is_success() { + if let Err(error) = session.flush().await { + return AppError::internal("session logout", error).into_response(); + } + } + + response } From 23fe0b0758927b74a4ef475193949c096bab20fb Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:33:41 +0200 Subject: [PATCH 2/6] Test unified gateway logout --- e2e-tests/tests/gateway_logout_test.rs | 151 +++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 e2e-tests/tests/gateway_logout_test.rs diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs new file mode 100644 index 0000000..668371f --- /dev/null +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -0,0 +1,151 @@ +//! Regression coverage for the gateway's unified logout semantics. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use e2e_tests::common::GatewayEnv; +use reqwest::header::{AUTHORIZATION, COOKIE, SET_COOKIE}; + +use e2e_tests::common::once::SharedServer; + +const TEST_PASSWORD: &str = "synthetic-gateway-test-password"; +const TEST_USER_ID: &str = "00000000-0000-0000-0000-000000000001"; + +static GATEWAY: SharedServer = SharedServer::new(); + +async fn get_gateway() -> Result<&'static GatewayEnv> { + GATEWAY.get(|| async { GatewayEnv::start().await }).await +} + +fn test_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .context("failed to build reqwest client") +} + +fn session_cookie(response: &reqwest::Response) -> Result { + let prefix = format!("{}=", gateway_example::session::SESSION_COOKIE_NAME); + + response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .filter_map(|value| value.split(';').next()) + .find(|cookie| cookie.starts_with(&prefix)) + .map(str::to_owned) + .context("login response did not set the gateway session cookie") +} + +/// Login establishes both auth mechanisms, so `/auth/logout` must tear down +/// both the refresh-token family and the server-side cookie session. +#[tokio::test] +async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { + let gateway = get_gateway().await?; + let client = test_client()?; + + let login_response = client + .post(format!("{}/auth/login", gateway.base_url())) + .json(&serde_json::json!({ + "user_id": TEST_USER_ID, + "password": TEST_PASSWORD, + })) + .send() + .await + .context("failed to log in")?; + assert_eq!(login_response.status(), reqwest::StatusCode::OK); + + let cookie = session_cookie(&login_response)?; + let login: serde_json::Value = login_response + .json() + .await + .context("login response is not valid JSON")?; + let access_token = login + .get("token") + .and_then(serde_json::Value::as_str) + .context("login response is missing access token")? + .to_owned(); + let refresh_token = login + .get("refresh_token") + .and_then(serde_json::Value::as_str) + .context("login response is missing refresh token")? + .to_owned(); + + let whoami_before = client + .get(format!("{}/session/whoami", gateway.base_url())) + .header(COOKIE, &cookie) + .send() + .await + .context("failed to read session before logout")?; + assert_eq!(whoami_before.status(), reqwest::StatusCode::OK); + let whoami_before: serde_json::Value = whoami_before + .json() + .await + .context("pre-logout whoami response is not valid JSON")?; + assert_eq!( + whoami_before.get("user_id").and_then(serde_json::Value::as_str), + Some(TEST_USER_ID) + ); + + let csrf_response = client + .get(format!("{}/auth/csrf", gateway.base_url())) + .header(COOKIE, &cookie) + .send() + .await + .context("failed to bootstrap CSRF token")?; + assert_eq!(csrf_response.status(), reqwest::StatusCode::OK); + let csrf: serde_json::Value = csrf_response + .json() + .await + .context("CSRF response is not valid JSON")?; + let csrf_token = csrf + .get("csrf_token") + .and_then(serde_json::Value::as_str) + .context("CSRF response is missing csrf_token")?; + let csrf_header = csrf + .get("header") + .and_then(serde_json::Value::as_str) + .context("CSRF response is missing header name")?; + + let logout_response = client + .post(format!("{}/auth/logout", gateway.base_url())) + .header(COOKIE, &cookie) + .header(AUTHORIZATION, format!("Bearer {access_token}")) + .header(csrf_header, csrf_token) + .send() + .await + .context("failed to call unified logout")?; + assert_eq!(logout_response.status(), reqwest::StatusCode::OK); + + // Sending the old cookie explicitly must no longer recover session state; + // this verifies server-side invalidation rather than relying on a browser + // honoring the deletion Set-Cookie response. + let whoami_after = client + .get(format!("{}/session/whoami", gateway.base_url())) + .header(COOKIE, &cookie) + .send() + .await + .context("failed to read session after logout")?; + assert_eq!(whoami_after.status(), reqwest::StatusCode::OK); + let whoami_after: serde_json::Value = whoami_after + .json() + .await + .context("post-logout whoami response is not valid JSON")?; + assert!( + whoami_after + .get("user_id") + .is_some_and(serde_json::Value::is_null), + "session remained authenticated after /auth/logout: {whoami_after}" + ); + + let refresh_response = client + .post(format!("{}/auth/refresh", gateway.base_url())) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .await + .context("failed to probe revoked refresh token")?; + assert_eq!(refresh_response.status(), reqwest::StatusCode::UNAUTHORIZED); + + Ok(()) +} From bc30ef51f0033e701a084901026937bf80f99976 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:34:03 +0200 Subject: [PATCH 3/6] noop --- README.tmp | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 README.tmp diff --git a/README.tmp b/README.tmp new file mode 100644 index 0000000..e69de29 From 81c74eab2a97a6df9432cf6886d4afe7ef33fbaa Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:34:19 +0200 Subject: [PATCH 4/6] Remove accidental temporary file --- README.tmp | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 README.tmp diff --git a/README.tmp b/README.tmp deleted file mode 100644 index e69de29..0000000 From 8c2da7820251b5cd7bf0e54a7ec58f3fab0604e8 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:35:33 +0200 Subject: [PATCH 5/6] Apply canonical Rust formatting --- e2e-tests/tests/gateway_logout_test.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs index 668371f..10d44db 100644 --- a/e2e-tests/tests/gateway_logout_test.rs +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -84,7 +84,9 @@ async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { .await .context("pre-logout whoami response is not valid JSON")?; assert_eq!( - whoami_before.get("user_id").and_then(serde_json::Value::as_str), + whoami_before + .get("user_id") + .and_then(serde_json::Value::as_str), Some(TEST_USER_ID) ); From 12a10a45d8684aea355cd9162cbbf1d42a48fdac Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Fri, 28 Aug 2026 21:37:09 +0200 Subject: [PATCH 6/6] Satisfy Clippy logout guard --- gateway/src/auth/middleware.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gateway/src/auth/middleware.rs b/gateway/src/auth/middleware.rs index af22b0c..605b078 100644 --- a/gateway/src/auth/middleware.rs +++ b/gateway/src/auth/middleware.rs @@ -75,10 +75,11 @@ pub async fn auth_middleware( request.extensions_mut().insert(claims); let response = next.run(request).await; - if is_logout && response.status().is_success() { - if let Err(error) = session.flush().await { - return AppError::internal("session logout", error).into_response(); - } + if is_logout + && response.status().is_success() + && let Err(error) = session.flush().await + { + return AppError::internal("session logout", error).into_response(); } response