Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
24 changes: 20 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
125 changes: 81 additions & 44 deletions e2e-tests/tests/gateway_logout_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,8 @@ fn session_cookie(response: &reqwest::Response) -> Result<String> {
.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,
Expand All @@ -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")?;
Expand All @@ -72,27 +66,73 @@ 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<serde_json::Value> {
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)
);

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")?;
Expand All @@ -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(())
}
9 changes: 7 additions & 2 deletions gateway/src/auth/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions gateway/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<MemoryStore> {
SessionManagerLayer::new(MemoryStore::default())
Expand Down Expand Up @@ -60,8 +70,12 @@ async fn whoami(session: Session) -> Result<axum::Json<serde_json::Value>, 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<axum::Json<serde_json::Value>, AppError> {
session
.flush()
Expand Down
Loading