From 8c3c11798bcd68bd629705550f4c1b0e9c45443d Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 18:13:08 +0200 Subject: [PATCH 1/6] Pin gateway logout credential scope --- e2e-tests/tests/gateway_logout_test.rs | 123 ++++++++++++++++--------- 1 file changed, 79 insertions(+), 44 deletions(-) diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs index 10d44db..f95381b 100644 --- a/e2e-tests/tests/gateway_logout_test.rs +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -38,14 +38,11 @@ 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 +51,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 +69,60 @@ 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 whoami_a_before = whoami(&client, gateway, &cookie_a).await?; + let whoami_b_before = whoami(&client, gateway, &cookie_b).await?; assert_eq!( - whoami_before + whoami_a_before + .get("user_id") + .and_then(serde_json::Value::as_str), + Some(TEST_USER_ID) + ); + assert_eq!( + whoami_b_before .get("user_id") .and_then(serde_json::Value::as_str), Some(TEST_USER_ID) @@ -92,7 +130,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 +150,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 whoami_a_after = whoami(&client, gateway, &cookie_a).await?; assert!( - whoami_after + whoami_a_after .get("user_id") .is_some_and(serde_json::Value::is_null), - "session remained authenticated after /auth/logout: {whoami_after}" + "logout session remained authenticated: {whoami_a_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 whoami_b_after = whoami(&client, gateway, &cookie_b).await?; + assert_eq!( + whoami_b_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(()) } From bac41f669b023306ec2b33e204a442e4bd43ddb5 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 18:15:12 +0200 Subject: [PATCH 2/6] Temporarily apply logout contract docs --- .github/workflows/apply-logout-contract.yml | 193 ++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 .github/workflows/apply-logout-contract.yml diff --git a/.github/workflows/apply-logout-contract.yml b/.github/workflows/apply-logout-contract.yml new file mode 100644 index 0000000..9e79fec --- /dev/null +++ b/.github/workflows/apply-logout-contract.yml @@ -0,0 +1,193 @@ +name: Apply logout contract docs + +on: + push: + branches: + - docs/logout-session-contract + paths: + - .github/workflows/apply-logout-contract.yml + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + ref: docs/logout-session-contract + + - name: Apply exact documentation edits + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected one match, found {count}") + p.write_text(text.replace(old, new, 1)) + + replace_once( + "gateway/src/auth/handlers.rs", + ''' (status = 200, description = "Logged out", body = LogoutResponse), + (status = 401, description = "Invalid token"), + ), + tag = "auth", + )] + /// Revoke outstanding refresh tokens for the authenticated subject. + /// + /// # Errors + /// Returns an internal error if the refresh-token store is unavailable or the + /// revocation query fails.''', + ''' (status = 200, description = "Subject refresh tokens revoked and current cookie session flushed", body = LogoutResponse), + (status = 401, description = "Invalid token"), + ), + tag = "auth", + )] + /// 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 + /// revocation query fails.''' + ) + + replace_once( + "gateway/src/session.rs", + '''//! Sessions are the secondary auth mechanism alongside JWTs — the gateway + //! 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`.''', + '''//! Sessions are the secondary auth mechanism alongside JWTs — the gateway + //! 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.''' + ) + + replace_once( + "gateway/src/session.rs", + '''/// Build the session-manager layer used by the gateway router. + #[must_use] + pub fn session_layer(config: &SessionSettings) -> SessionManagerLayer {''', + '''/// 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 {''' + ) + + replace_once( + "gateway/src/session.rs", + '''/// 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. + async fn logout(session: Session) -> Result, AppError> {''', + '''/// 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> {''' + ) + + replace_once( + "README.md", + '''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.''', + '''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. `/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.''' + ) + + replace_once( + "README.md", + '''The two auth styles are examples of different threat models, not an argument that + every endpoint should use both at once.''', + '''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.''' + ) + + replace_once( + "SKILL.md", + '''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.''' + ) + + replace_once( + "SKILL.md", + '''The development Compose profile explicitly disables the Secure cookie flag + because it serves plain localhost HTTP. Production defaults secure.''', + '''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.''' + ) + PY + + - name: Apply pinned Rust formatting + run: | + rustup toolchain install 1.94.0 --profile minimal + cargo +1.94.0 fmt --all + + - name: Commit result + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md SKILL.md gateway/src/auth/handlers.rs gateway/src/session.rs e2e-tests/tests/gateway_logout_test.rs + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + git commit -m "Document gateway logout and session scope" + git push From d8a0e24ac9fb3d13a0c89ed208f5f441bd4f8da3 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 18:16:47 +0200 Subject: [PATCH 3/6] Install rustfmt in helper workflow --- .github/workflows/apply-logout-contract.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/apply-logout-contract.yml b/.github/workflows/apply-logout-contract.yml index 9e79fec..88b9faf 100644 --- a/.github/workflows/apply-logout-contract.yml +++ b/.github/workflows/apply-logout-contract.yml @@ -177,7 +177,7 @@ jobs: - name: Apply pinned Rust formatting run: | - rustup toolchain install 1.94.0 --profile minimal + rustup toolchain install 1.94.0 --profile minimal --component rustfmt cargo +1.94.0 fmt --all - name: Commit result From 1bdb25b3e089970ea30ed5b6b0eba07772079b92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:17:05 +0000 Subject: [PATCH 4/6] Document gateway logout and session scope --- README.md | 13 +++++++++++-- SKILL.md | 24 ++++++++++++++++++++---- e2e-tests/tests/gateway_logout_test.rs | 14 ++++++++------ gateway/src/auth/handlers.rs | 9 +++++++-- gateway/src/session.rs | 20 +++++++++++++++++--- 5 files changed, 63 insertions(+), 17 deletions(-) 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 f95381b..cd07ccf 100644 --- a/e2e-tests/tests/gateway_logout_test.rs +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -38,10 +38,7 @@ fn session_cookie(response: &reqwest::Response) -> Result { .context("login response did not set the gateway session cookie") } -async fn login( - client: &reqwest::Client, - gateway: &GatewayEnv, -) -> Result<(String, String, String)> { +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!({ @@ -72,7 +69,11 @@ async fn login( Ok((cookie, access_token, refresh_token)) } -async fn whoami(client: &reqwest::Client, gateway: &GatewayEnv, cookie: &str) -> Result { +async fn whoami( + client: &reqwest::Client, + gateway: &GatewayEnv, + cookie: &str, +) -> Result { client .get(format!("{}/session/whoami", gateway.base_url())) .header(COOKIE, cookie) @@ -104,7 +105,8 @@ async fn assert_refresh_revoked( /// `/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<()> { +async fn auth_logout_revokes_subject_refresh_state_and_flushes_only_current_session() -> Result<()> +{ let gateway = get_gateway().await?; let client = test_client()?; 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() From 390b374658fdf34c702d18551f37eb83148e0013 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Mon, 31 Aug 2026 18:17:39 +0200 Subject: [PATCH 5/6] Remove temporary logout docs helper --- .github/workflows/apply-logout-contract.yml | 193 -------------------- 1 file changed, 193 deletions(-) delete mode 100644 .github/workflows/apply-logout-contract.yml diff --git a/.github/workflows/apply-logout-contract.yml b/.github/workflows/apply-logout-contract.yml deleted file mode 100644 index 88b9faf..0000000 --- a/.github/workflows/apply-logout-contract.yml +++ /dev/null @@ -1,193 +0,0 @@ -name: Apply logout contract docs - -on: - push: - branches: - - docs/logout-session-contract - paths: - - .github/workflows/apply-logout-contract.yml - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - with: - ref: docs/logout-session-contract - - - name: Apply exact documentation edits - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - p = Path(path) - text = p.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected one match, found {count}") - p.write_text(text.replace(old, new, 1)) - - replace_once( - "gateway/src/auth/handlers.rs", - ''' (status = 200, description = "Logged out", body = LogoutResponse), - (status = 401, description = "Invalid token"), - ), - tag = "auth", - )] - /// Revoke outstanding refresh tokens for the authenticated subject. - /// - /// # Errors - /// Returns an internal error if the refresh-token store is unavailable or the - /// revocation query fails.''', - ''' (status = 200, description = "Subject refresh tokens revoked and current cookie session flushed", body = LogoutResponse), - (status = 401, description = "Invalid token"), - ), - tag = "auth", - )] - /// 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 - /// revocation query fails.''' - ) - - replace_once( - "gateway/src/session.rs", - '''//! Sessions are the secondary auth mechanism alongside JWTs — the gateway - //! 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`.''', - '''//! Sessions are the secondary auth mechanism alongside JWTs — the gateway - //! 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.''' - ) - - replace_once( - "gateway/src/session.rs", - '''/// Build the session-manager layer used by the gateway router. - #[must_use] - pub fn session_layer(config: &SessionSettings) -> SessionManagerLayer {''', - '''/// 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 {''' - ) - - replace_once( - "gateway/src/session.rs", - '''/// 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. - async fn logout(session: Session) -> Result, AppError> {''', - '''/// 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> {''' - ) - - replace_once( - "README.md", - '''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.''', - '''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. `/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.''' - ) - - replace_once( - "README.md", - '''The two auth styles are examples of different threat models, not an argument that - every endpoint should use both at once.''', - '''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.''' - ) - - replace_once( - "SKILL.md", - '''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.''' - ) - - replace_once( - "SKILL.md", - '''The development Compose profile explicitly disables the Secure cookie flag - because it serves plain localhost HTTP. Production defaults secure.''', - '''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.''' - ) - PY - - - name: Apply pinned Rust formatting - run: | - rustup toolchain install 1.94.0 --profile minimal --component rustfmt - cargo +1.94.0 fmt --all - - - name: Commit result - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add README.md SKILL.md gateway/src/auth/handlers.rs gateway/src/session.rs e2e-tests/tests/gateway_logout_test.rs - if git diff --cached --quiet; then - echo "No changes to commit" - exit 0 - fi - git commit -m "Document gateway logout and session scope" - git push From 4372f5140a01478f5ee45e5ac738197e160cdf49 Mon Sep 17 00:00:00 2001 From: Code and Solder Date: Sat, 5 Sep 2026 01:14:59 +0200 Subject: [PATCH 6/6] Fix logout regression lint names --- e2e-tests/tests/gateway_logout_test.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/e2e-tests/tests/gateway_logout_test.rs b/e2e-tests/tests/gateway_logout_test.rs index cd07ccf..56ddca5 100644 --- a/e2e-tests/tests/gateway_logout_test.rs +++ b/e2e-tests/tests/gateway_logout_test.rs @@ -115,16 +115,16 @@ async fn auth_logout_revokes_subject_refresh_state_and_flushes_only_current_sess let (cookie_b, _access_b, refresh_b) = login(&client, gateway).await?; assert_ne!(cookie_a, cookie_b, "independent logins reused a session ID"); - let whoami_a_before = whoami(&client, gateway, &cookie_a).await?; - let whoami_b_before = whoami(&client, gateway, &cookie_b).await?; + let current_session_before = whoami(&client, gateway, &cookie_a).await?; + let other_session_before = whoami(&client, gateway, &cookie_b).await?; assert_eq!( - whoami_a_before + current_session_before .get("user_id") .and_then(serde_json::Value::as_str), Some(TEST_USER_ID) ); assert_eq!( - whoami_b_before + other_session_before .get("user_id") .and_then(serde_json::Value::as_str), Some(TEST_USER_ID) @@ -162,20 +162,20 @@ async fn auth_logout_revokes_subject_refresh_state_and_flushes_only_current_sess // The exact cookie used for logout must be dead server-side, not merely // deleted by Set-Cookie on a cooperative browser. - let whoami_a_after = whoami(&client, gateway, &cookie_a).await?; + let current_session_after = whoami(&client, gateway, &cookie_a).await?; assert!( - whoami_a_after + current_session_after .get("user_id") .is_some_and(serde_json::Value::is_null), - "logout session remained authenticated: {whoami_a_after}" + "logout session remained authenticated: {current_session_after}" ); // 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 whoami_b_after = whoami(&client, gateway, &cookie_b).await?; + let other_session_after = whoami(&client, gateway, &cookie_b).await?; assert_eq!( - whoami_b_after + other_session_after .get("user_id") .and_then(serde_json::Value::as_str), Some(TEST_USER_ID),