diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs new file mode 100644 index 0000000..10d44db --- /dev/null +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -0,0 +1,153 @@ +//! 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(()) +} diff --git a/gateway/src/auth/middleware.rs b/gateway/src/auth/middleware.rs index 8ea5ee1..605b078 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,13 @@ 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() + && let Err(error) = session.flush().await + { + return AppError::internal("session logout", error).into_response(); + } + + response }