diff --git a/README.md b/README.md index d73752a..e4a212a 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,12 @@ store and password hashing. Access JWTs are short-lived (15 minutes by default). Refresh tokens are random opaque values; only their hashes are stored. Rotation is transactional, records replacement lineage, and revokes the whole token family if an already-used token -is replayed. Logout revokes refresh state; an already-issued access JWT remains -valid until its `exp` unless you add a separate access-token revocation system. +is replayed. `/auth/logout` revokes every outstanding refresh token for the +authenticated subject and flushes only the cookie session attached to that +request; other cookie sessions for the same subject are not enumerated. An +already-issued access JWT remains valid until its `exp` unless you add a separate +access-token revocation system. `/session/logout` only flushes the current cookie +session and does not revoke refresh-token state. ## Configuration @@ -150,6 +154,11 @@ The gateway demonstrates a cookie session alongside Bearer JWTs: - session-store failures return 5xx instead of HTTP 200 error payloads, - CSP is added without overwriting an existing policy. +The session backend in this reference implementation is `tower_sessions::MemoryStore`. +Its state is process-local: a gateway restart invalidates sessions and multiple +gateway replicas do not share them. Use a shared session store for horizontally +scaled production deployments that need stable server-side sessions. + The two auth styles are examples of different threat models, not an argument that every endpoint should use both at once. diff --git a/SKILL.md b/SKILL.md index 5f92269..ee66240 100644 --- a/SKILL.md +++ b/SKILL.md @@ -203,10 +203,13 @@ A production application should replace this demo pair with a real user store and password hashing (for example Argon2id), not expand the shared-password pattern. -Access JWTs are short-lived. Logout/replay revokes refresh-token state; an -already-issued access token remains valid until its `exp` unless a separate -access-token revocation mechanism is added. Do not claim `jti` alone provides -revocation. +Access JWTs are short-lived. `/auth/logout` revokes every outstanding refresh +token for the authenticated subject and flushes only the cookie session attached +to that request; it does not enumerate other server-side sessions for the same +subject. Already-issued access tokens remain valid until their `exp` unless a +separate access-token revocation mechanism is added. `/session/logout` only +flushes the current cookie session and does not revoke refresh-token state. Do +not claim `jti` alone provides revocation. ### 13. Refresh-token rotation is transactional @@ -296,6 +299,19 @@ threat models distinct: protected because they establish/use other credentials, - CSP is set without overwriting an existing policy. +The gateway currently uses `tower_sessions::MemoryStore`. That is deliberate for +a compact single-process example, but its records are process-local: restarting +the gateway invalidates sessions, and horizontally scaled replicas do not share +authenticated session state. Production deployments that require stable sessions +across restarts or replicas need a shared `SessionStore` (or should omit this +secondary server-side auth mechanism). + +Logout scope is intentionally explicit: `/auth/logout` performs subject-wide +refresh-token revocation plus a flush of the current request's cookie session. +It does not enumerate or flush other cookie sessions. `/session/logout` is +session-only. Existing access JWTs remain valid until expiry unless a separate +revocation mechanism is introduced. + The development Compose profile explicitly disables the Secure cookie flag because it serves plain localhost HTTP. Production defaults secure. diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs index 10d44db..56ddca5 100644 --- a/e2e-tests/tests/gateway_logout_test.rs +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -38,14 +38,8 @@ fn session_cookie(response: &reqwest::Response) -> Result { .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 +async fn login(client: &reqwest::Client, gateway: &GatewayEnv) -> Result<(String, String, String)> { + let response = client .post(format!("{}/auth/login", gateway.base_url())) .json(&serde_json::json!({ "user_id": TEST_USER_ID, @@ -54,10 +48,10 @@ async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { .send() .await .context("failed to log in")?; - assert_eq!(login_response.status(), reqwest::StatusCode::OK); + assert_eq!(response.status(), reqwest::StatusCode::OK); - let cookie = session_cookie(&login_response)?; - let login: serde_json::Value = login_response + let cookie = session_cookie(&response)?; + let login: serde_json::Value = response .json() .await .context("login response is not valid JSON")?; @@ -72,19 +66,65 @@ async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { .context("login response is missing refresh token")? .to_owned(); - let whoami_before = client + Ok((cookie, access_token, refresh_token)) +} + +async fn whoami( + client: &reqwest::Client, + gateway: &GatewayEnv, + cookie: &str, +) -> Result { + client .get(format!("{}/session/whoami", gateway.base_url())) - .header(COOKIE, &cookie) + .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 + .context("failed to read session")? + .error_for_status() + .context("session whoami failed")? .json() .await - .context("pre-logout whoami response is not valid JSON")?; + .context("whoami response is not valid JSON") +} + +async fn assert_refresh_revoked( + client: &reqwest::Client, + gateway: &GatewayEnv, + refresh_token: &str, +) -> Result<()> { + let 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!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + Ok(()) +} + +/// `/auth/logout` revokes every outstanding refresh token for the authenticated +/// subject, but it can only flush the cookie session attached to this request. +#[tokio::test] +async fn auth_logout_revokes_subject_refresh_state_and_flushes_only_current_session() -> Result<()> +{ + let gateway = get_gateway().await?; + let client = test_client()?; + + // Establish two independent browser-like sessions for the same subject. + let (cookie_a, access_a, refresh_a) = login(&client, gateway).await?; + let (cookie_b, _access_b, refresh_b) = login(&client, gateway).await?; + assert_ne!(cookie_a, cookie_b, "independent logins reused a session ID"); + + let current_session_before = whoami(&client, gateway, &cookie_a).await?; + let other_session_before = whoami(&client, gateway, &cookie_b).await?; assert_eq!( - whoami_before + current_session_before + .get("user_id") + .and_then(serde_json::Value::as_str), + Some(TEST_USER_ID) + ); + assert_eq!( + other_session_before .get("user_id") .and_then(serde_json::Value::as_str), Some(TEST_USER_ID) @@ -92,7 +132,7 @@ async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { let csrf_response = client .get(format!("{}/auth/csrf", gateway.base_url())) - .header(COOKIE, &cookie) + .header(COOKIE, &cookie_a) .send() .await .context("failed to bootstrap CSRF token")?; @@ -112,42 +152,39 @@ async fn auth_logout_revokes_refresh_state_and_flushes_session() -> Result<()> { let logout_response = client .post(format!("{}/auth/logout", gateway.base_url())) - .header(COOKIE, &cookie) - .header(AUTHORIZATION, format!("Bearer {access_token}")) + .header(COOKIE, &cookie_a) + .header(AUTHORIZATION, format!("Bearer {access_a}")) .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")?; + // The exact cookie used for logout must be dead server-side, not merely + // deleted by Set-Cookie on a cooperative browser. + let current_session_after = whoami(&client, gateway, &cookie_a).await?; assert!( - whoami_after + current_session_after .get("user_id") .is_some_and(serde_json::Value::is_null), - "session remained authenticated after /auth/logout: {whoami_after}" + "logout session remained authenticated: {current_session_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); + // The handler cannot enumerate other MemoryStore sessions for the subject, + // so a different cookie session remains authenticated. This is deliberate + // current behavior and is distinct from refresh-token revocation scope. + let other_session_after = whoami(&client, gateway, &cookie_b).await?; + assert_eq!( + other_session_after + .get("user_id") + .and_then(serde_json::Value::as_str), + Some(TEST_USER_ID), + "logout unexpectedly flushed another cookie session" + ); + + // Refresh revocation is subject-wide, so credentials from both logins die. + assert_refresh_revoked(&client, gateway, &refresh_a).await?; + assert_refresh_revoked(&client, gateway, &refresh_b).await?; Ok(()) } diff --git a/gateway/src/auth/handlers.rs b/gateway/src/auth/handlers.rs index c04daef..f337ace 100644 --- a/gateway/src/auth/handlers.rs +++ b/gateway/src/auth/handlers.rs @@ -208,12 +208,17 @@ pub async fn refresh_handler( post, path = "/auth/logout", responses( - (status = 200, description = "Logged out", body = LogoutResponse), + (status = 200, description = "Subject refresh tokens revoked and current cookie session flushed", body = LogoutResponse), (status = 401, description = "Invalid token"), ), tag = "auth", )] -/// Revoke outstanding refresh tokens for the authenticated subject. +/// Revoke every outstanding refresh token for the authenticated subject. +/// +/// After this handler succeeds, auth middleware flushes the cookie session +/// attached to this request. Other cookie sessions for the same subject are +/// not enumerated or flushed, and already-issued access JWTs remain valid +/// until `exp` unless a separate access-token revocation mechanism is added. /// /// # Errors /// Returns an internal error if the refresh-token store is unavailable or the diff --git a/gateway/src/session.rs b/gateway/src/session.rs index 9dbbd14..a339f98 100644 --- a/gateway/src/session.rs +++ b/gateway/src/session.rs @@ -4,6 +4,13 @@ //! issues an `HttpOnly`/`Secure`/`SameSite=Lax` session cookie on a successful //! login, and the `/session/whoami` route reads it back. CSRF protection is //! layered on top via `axum_tower_sessions_csrf`. +//! +//! This example intentionally uses `MemoryStore`, so session records are +//! process-local: restarting the gateway invalidates them, and separate +//! gateway replicas do not share authenticated session state. A production +//! horizontally scaled deployment should provide a shared `SessionStore` +//! (or omit server-side sessions) rather than treating this demo store as +//! durable or cluster-wide. use axum::Router; use serde::{Deserialize, Serialize}; @@ -25,7 +32,10 @@ pub struct SessionUser { pub user_id: String, } -/// Build the session-manager layer used by the gateway router. +/// Build the process-local demo session-manager layer used by the gateway. +/// +/// `MemoryStore` is intentionally suitable for this single-process example, +/// not as shared session state for a horizontally scaled deployment. #[must_use] pub fn session_layer(config: &SessionSettings) -> SessionManagerLayer { SessionManagerLayer::new(MemoryStore::default()) @@ -60,8 +70,12 @@ async fn whoami(session: Session) -> Result, AppEr }) } -/// Flush the session, removing the cookie and all stored data. Do not report a -/// successful logout if the backing store failed to persist the invalidation. +/// Flush only the current cookie session, removing its cookie and stored data. +/// +/// This route does not revoke refresh tokens or access JWTs. Use `/auth/logout` +/// when the caller needs the gateway's combined refresh-token/current-session +/// logout semantics. Do not report success if the backing store failed to +/// persist this session invalidation. async fn logout(session: Session) -> Result, AppError> { session .flush()