diff --git a/bindings/bymax-auth-wasm/src/jwt_edge.rs b/bindings/bymax-auth-wasm/src/jwt_edge.rs index 5f87768..0d90133 100644 --- a/bindings/bymax-auth-wasm/src/jwt_edge.rs +++ b/bindings/bymax-auth-wasm/src/jwt_edge.rs @@ -203,6 +203,7 @@ mod tests { mfa_verified: false, iat, exp, + epoch: 0, } } @@ -233,6 +234,7 @@ mod tests { mfa_verified: true, iat: 1_000, exp: 2_000, + epoch: 0, }; let token = sign(&claims, &HsKey::from_bytes(SECRET)).unwrap_or_default(); let json = verify_claims_json(&token, secret_string(), 0, 1_500); @@ -355,6 +357,7 @@ mod tests { mfa_verified: true, iat: 1_000, exp: 2_000, + epoch: 0, }, &HsKey::from_bytes(SECRET), ) diff --git a/bindings/bymax-auth-wasm/src/lib.rs b/bindings/bymax-auth-wasm/src/lib.rs index 49eff0f..7c499a0 100644 --- a/bindings/bymax-auth-wasm/src/lib.rs +++ b/bindings/bymax-auth-wasm/src/lib.rs @@ -150,6 +150,7 @@ mod tests { mfa_verified: false, iat, exp, + epoch: 0, }; sign(&claims, &HsKey::from_bytes(secret.as_bytes())).unwrap_or_default() } diff --git a/crates/bymax-auth-axum/src/extractors/mfa.rs b/crates/bymax-auth-axum/src/extractors/mfa.rs index 1e49974..cea534b 100644 --- a/crates/bymax-auth-axum/src/extractors/mfa.rs +++ b/crates/bymax-auth-axum/src/extractors/mfa.rs @@ -69,6 +69,7 @@ mod tests { mfa_verified: false, iat: 1_700_000_000, exp: future_exp(), + epoch: 0, }; let unverified = mint_token(&claims); let mut parts = parts_with_cookie(&unverified); diff --git a/crates/bymax-auth-axum/src/extractors/platform.rs b/crates/bymax-auth-axum/src/extractors/platform.rs index c7b9a47..2167c18 100644 --- a/crates/bymax-auth-axum/src/extractors/platform.rs +++ b/crates/bymax-auth-axum/src/extractors/platform.rs @@ -129,6 +129,7 @@ mod tests { mfa_verified: false, iat: 1_700_000_000, exp: 4_700_000_000, + epoch: 0, } } diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 898278f..bd9f0b5 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -244,6 +244,7 @@ pub fn mint_dashboard_token(sub: &str, role: &str, status: &str) -> String { mfa_verified: false, iat: 1_700_000_000, exp: 4_700_000_000, + epoch: 0, }; let key = bymax_auth_jwt::HsKey::from_bytes(JWT_SECRET.as_bytes()); bymax_auth_jwt::sign(&claims, &key).unwrap_or_default() @@ -261,6 +262,7 @@ pub fn mint_platform_token(sub: &str, role: &str) -> String { mfa_verified: false, iat: 1_700_000_000, exp: 4_700_000_000, + epoch: 0, }; let key = bymax_auth_jwt::HsKey::from_bytes(JWT_SECRET.as_bytes()); bymax_auth_jwt::sign(&claims, &key).unwrap_or_default() @@ -358,6 +360,13 @@ impl bymax_auth_core::traits::SessionStore for FailingStores { ) -> Result<(), bymax_auth_types::AuthError> { Err(fail()) } + async fn revoke_family( + &self, + kind: bymax_auth_core::traits::SessionKind, + family_id: &str, + ) -> Result<(), bymax_auth_types::AuthError> { + self.inner.revoke_family(kind, family_id).await + } async fn blacklist_access( &self, jti_or_hash: &str, @@ -373,6 +382,20 @@ impl bymax_auth_core::traits::SessionStore for FailingStores { } self.inner.is_blacklisted(jti_or_hash).await } + async fn current_epoch( + &self, + kind: bymax_auth_core::traits::SessionKind, + user_id: &str, + ) -> Result { + self.inner.current_epoch(kind, user_id).await + } + async fn bump_epoch( + &self, + kind: bymax_auth_core::traits::SessionKind, + user_id: &str, + ) -> Result { + self.inner.bump_epoch(kind, user_id).await + } } #[async_trait] diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 0043df1..1de0c09 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -214,6 +214,9 @@ impl AuthEngine { mfa_verified: snapshot.mfa_verified, iat: now, exp: now.saturating_add(i64::try_from(WS_TICKET_TTL_SECONDS).unwrap_or(i64::MAX)), + // This is a redeemed socket-authorization snapshot, never a re-verified access JWT, so + // the epoch is not consulted for it; it carries the inert default. + epoch: 0, }) } @@ -626,6 +629,7 @@ mod tests { mfa_verified: false, iat: now_unix(), exp: now_unix(), + epoch: 0, } } diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index f32a494..631661b 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -235,6 +235,10 @@ impl AuthEngine { device, ip: stored_ip, created_at: now_offset(), + // The family id is server-internal to the reuse-detection store and is not part of + // the new-session hook / eviction projection (which keys on the session hash), so + // this display record leaves it empty. + family_id: String::new(), }; self.sessions() .after_session_created(&record, &new_hash, hook_ctx) diff --git a/crates/bymax-auth-core/src/services/auth/password_reset.rs b/crates/bymax-auth-core/src/services/auth/password_reset.rs index 65b790f..1c1a3f6 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -408,10 +408,15 @@ impl AuthEngine { // Sessions are invalidated only after the password is durably updated. This is the // dashboard reset flow, so only dashboard sessions are revoked; platform-admin sessions // are a separate identity surface with their own credential-reset path and are not - // touched here. + // touched here. Revoking the refresh sessions stops rotation; bumping the token epoch + // additionally invalidates every already-issued (stateless) access token at once, so a + // reset takes effect immediately rather than lingering for the access-token lifetime. self.session_store() .revoke_all(crate::traits::SessionKind::Dashboard, &context.user_id) .await?; + self.session_store() + .bump_epoch(crate::traits::SessionKind::Dashboard, &context.user_id) + .await?; let hook_ctx = reset_context_hooks(context); let safe = self.project_user_for_hook(context).await; @@ -536,6 +541,7 @@ mod tests { device: "Chrome".to_owned(), ip: "1.2.3.4".to_owned(), created_at: time::OffsetDateTime::UNIX_EPOCH, + family_id: "fam-test".to_owned(), }; assert!( h.stores @@ -601,6 +607,49 @@ mod tests { )); } + #[tokio::test] + async fn reset_bumps_the_token_epoch_so_pre_reset_access_tokens_are_invalidated() { + // A reset revokes the refresh sessions AND advances the user's token epoch, so every + // already-issued (stateless) access token is rejected on its next verification rather + // than lingering for its remaining lifetime. + let Some(h) = token_harness() else { return }; + let id = h.seed(SeedUser::active("epoch@example.com", "pw")).await; + // Before the reset the user carries the inert epoch 0. + assert!(matches!( + h.stores.current_epoch(SessionKind::Dashboard, &id).await, + Ok(0) + )); + let known = "e".repeat(64); + assert!( + h.stores + .put_token( + &known, + &ResetContext { + user_id: id.clone(), + email: "epoch@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + 600, + ) + .await + .is_ok() + ); + let reset = ResetPasswordInput { + email: "epoch@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "brand-new-pw".to_owned(), + token: Some(known), + otp: None, + verified_token: None, + }; + assert!(h.engine.reset_password(reset).await.is_ok()); + // The reset advanced the epoch: any token stamped at 0 is now below the current value. + assert!(matches!( + h.stores.current_epoch(SessionKind::Dashboard, &id).await, + Ok(1) + )); + } + #[tokio::test] async fn token_binding_rejects_a_mismatched_email() { // A token whose stored context was bound to a different email is rejected on reset. diff --git a/crates/bymax-auth-core/src/services/auth/session_ops.rs b/crates/bymax-auth-core/src/services/auth/session_ops.rs index 4942795..bfbdc8e 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -287,6 +287,7 @@ mod tests { mfa_verified: false, iat: now - 1_000, exp: now - 500, + epoch: 0, }; let Ok(token) = h.engine.tokens().issue_access(&expired) else { return }; assert!( diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index eb0d643..63116df 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -299,6 +299,8 @@ impl MfaService { device, ip: stored_ip, created_at: now_offset(), + // Server-internal family id is not part of the hook/eviction projection. + family_id: String::new(), }; let hook_ctx: HookContext = self.hook_context(&safe.id, email, ip, user_agent); self.sessions diff --git a/crates/bymax-auth-core/src/services/mfa/manage.rs b/crates/bymax-auth-core/src/services/mfa/manage.rs index f24b2e5..a341e83 100644 --- a/crates/bymax-auth-core/src/services/mfa/manage.rs +++ b/crates/bymax-auth-core/src/services/mfa/manage.rs @@ -32,6 +32,8 @@ impl MfaService { self.reauth_gate(user_id, code, &view).await?; // The TOTP code verified; clear MFA, revoke sessions, and notify. self.persist_mfa(user_id, ctx, false, None, None).await?; + // Revoke the user's OTHER refresh sessions; the current session continues, so the token + // epoch is not bumped here (see the enable path for the rationale). self.session_store .revoke_all(session_kind(ctx), user_id) .await?; diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index aff7369..52f37e5 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -127,6 +127,10 @@ impl MfaService { Some(data.hashed_codes), ) .await?; + // Revoke the user's OTHER refresh sessions on the MFA-state change; the current session + // (which just performed the change) is expected to continue, so the token epoch is NOT + // bumped here — that stronger, sign-out-everywhere invalidation is reserved for a + // password reset and an explicit revoke-all. self.session_store .revoke_all(session_kind(ctx), user_id) .await?; diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 8fd1cf9..722b1fc 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -724,11 +724,13 @@ mod tests { } #[tokio::test] - async fn logout_cleans_the_grace_pointer_so_a_rotated_token_cannot_recover() { + async fn logout_cleans_the_grace_pointer_and_reuse_of_the_old_token_revokes_the_family() { // Login, then refresh (which plants a grace pointer for the OLD token). Logging out the - // OLD token must clean BOTH its primary key (already consumed by the rotation) AND its - // grace pointer, so a follow-up grace-window rotation of the old token now fails — proving - // the grace key was cleaned, not just the primary. + // OLD token cleans BOTH its primary key (already consumed by the rotation) AND its grace + // pointer, so a follow-up rotation of the old token can no longer recover through the + // grace window. The consumed-family marker outlives the grace pointer, so that follow-up + // is now caught as a REUSE of a consumed token — the signature of a stolen token — and + // revokes the whole family, taking the live rotated token down with it. let Some(h) = harness(platform_config()) else { return }; let id = seed_admin(&h.admins, "grace@admin.io", "pw"); let Some(svc) = h.engine.platform_auth() else { return }; @@ -737,23 +739,25 @@ mod tests { // Rotate: the old refresh token is consumed and a grace pointer is planted for it. let rotation = svc.refresh(&auth.refresh_token, "1.2.3.4", "agent").await; let Ok(rotated) = rotation else { return }; - // A second rotation of the OLD token would normally hit the grace window and succeed. - // After logging out the OLD token, the grace pointer is gone, so it can no longer recover. + // Logging out the OLD token cleans its grace pointer. assert!( svc.logout(&auth.access_token, &auth.refresh_token, &id) .await .is_ok() ); + // Replaying the OLD token can no longer recover through the (now-cleaned) grace window; it + // is rejected as a reuse of a consumed token, which revokes the family. assert!(matches!( svc.refresh(&auth.refresh_token, "1.2.3.4", "agent").await, Err(AuthError::RefreshTokenInvalid) )); - // The freshly rotated (live) token is unaffected and still rotates. - assert!( + // The reuse revoked the whole lineage, so even the freshly rotated (previously live) + // token no longer rotates — a stolen token can never keep a parallel chain alive. + assert!(matches!( svc.refresh(&rotated.refresh_token, "1.2.3.4", "agent") - .await - .is_ok() - ); + .await, + Err(AuthError::RefreshTokenInvalid) + )); } #[tokio::test] diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 977c8f9..0071a8c 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -502,6 +502,7 @@ mod tests { device: "Chrome on macOS".to_owned(), ip: "203.0.113.4".to_owned(), created_at: created, + family_id: "fam-test".to_owned(), } } @@ -1074,6 +1075,13 @@ mod tests { async fn revoke_all(&self, _kind: SessionKind, _user_id: &str) -> Result<(), AuthError> { Ok(()) } + async fn revoke_family( + &self, + _kind: SessionKind, + _family_id: &str, + ) -> Result<(), AuthError> { + Ok(()) + } async fn blacklist_access( &self, _jti_or_hash: &str, @@ -1084,6 +1092,16 @@ mod tests { async fn is_blacklisted(&self, _jti_or_hash: &str) -> Result { Ok(false) } + async fn current_epoch( + &self, + _kind: SessionKind, + _user_id: &str, + ) -> Result { + Ok(0) + } + async fn bump_epoch(&self, _kind: SessionKind, _user_id: &str) -> Result { + Ok(1) + } } #[tokio::test] @@ -1134,6 +1152,12 @@ mod tests { .is_ok() ); assert!(store.revoke_all(SessionKind::Dashboard, "u1").await.is_ok()); + assert!( + store + .revoke_family(SessionKind::Dashboard, "fam-1") + .await + .is_ok() + ); assert!(store.blacklist_access("jti", 60).await.is_ok()); assert!(matches!(store.is_blacklisted("jti").await, Ok(false))); } diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 666a439..64c5f2f 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -157,6 +157,12 @@ impl TokenManagerService { ) -> Result { let refresh = RawRefreshToken::generate(); let now = now_unix(); + // Stamp the user's current token epoch so a later bump (a reset or sign-out-everywhere) + // invalidates this token at verification. + let epoch = self + .session_store + .current_epoch(SessionKind::Dashboard, &user.id) + .await?; let claims = DashboardClaims { sub: user.id.clone(), jti: new_uuid_v4(), @@ -168,6 +174,7 @@ impl TokenManagerService { mfa_verified, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), + epoch, }; let access_token = self.issue_access(&claims)?; @@ -182,6 +189,9 @@ impl TokenManagerService { device, ip: stored_ip, created_at: now_offset(), + // A fresh login opens a new refresh-token family; every rotation inherits this id, + // so the whole lineage can be revoked together on reuse detection. + family_id: new_uuid_v4(), }; self.session_store .create_session( @@ -249,7 +259,11 @@ impl TokenManagerService { .await? { RotateOutcome::Rotated(_old) => { - let access_token = self.issue_access(&self.rotated_claims(&new_record))?; + let epoch = self + .session_store + .current_epoch(SessionKind::Dashboard, &new_record.user_id) + .await?; + let access_token = self.issue_access(&self.rotated_claims(&new_record, epoch))?; Ok(RotatedTokens { access_token, refresh_token: new.expose_secret().to_owned(), @@ -268,12 +282,26 @@ impl TokenManagerService { self.refresh_ttl_secs, ) .await?; - let access_token = self.issue_access(&self.rotated_claims(&fresh_record))?; + let epoch = self + .session_store + .current_epoch(SessionKind::Dashboard, &fresh_record.user_id) + .await?; + let access_token = self.issue_access(&self.rotated_claims(&fresh_record, epoch))?; Ok(RotatedTokens { access_token, refresh_token: fresh.expose_secret().to_owned(), }) } + RotateOutcome::Reused(family) => { + // A consumed refresh token was replayed after its grace window closed — the + // signature of a stolen token. Revoke the whole family (every live descendant + // of that login) so the thief's chain dies too, then reject: every holder must + // re-authenticate (§12.5.2, OWASP rotation with automatic reuse detection). + self.session_store + .revoke_family(SessionKind::Dashboard, &family) + .await?; + Err(AuthError::RefreshTokenInvalid) + } RotateOutcome::Invalid => Err(AuthError::RefreshTokenInvalid), } } @@ -309,6 +337,10 @@ impl TokenManagerService { ) -> Result { let refresh = RawRefreshToken::generate(); let now = now_unix(); + let epoch = self + .session_store + .current_epoch(SessionKind::Platform, &admin.id) + .await?; let claims = PlatformClaims { sub: admin.id.clone(), jti: new_uuid_v4(), @@ -318,6 +350,7 @@ impl TokenManagerService { mfa_verified, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), + epoch, }; let access_token = self.issue_platform_access(&claims)?; @@ -332,6 +365,8 @@ impl TokenManagerService { device, ip: stored_ip, created_at: now_offset(), + // A fresh platform login opens a new refresh-token family (section 12.5.2). + family_id: new_uuid_v4(), }; self.session_store .create_session( @@ -396,8 +431,12 @@ impl TokenManagerService { .await? { RotateOutcome::Rotated(_old) => { + let epoch = self + .session_store + .current_epoch(SessionKind::Platform, &new_record.user_id) + .await?; let access_token = - self.issue_platform_access(&self.rotated_platform_claims(&new_record))?; + self.issue_platform_access(&self.rotated_platform_claims(&new_record, epoch))?; Ok(RotatedTokens { access_token, refresh_token: new.expose_secret().to_owned(), @@ -416,13 +455,25 @@ impl TokenManagerService { self.refresh_ttl_secs, ) .await?; - let access_token = - self.issue_platform_access(&self.rotated_platform_claims(&fresh_record))?; + let epoch = self + .session_store + .current_epoch(SessionKind::Platform, &fresh_record.user_id) + .await?; + let access_token = self + .issue_platform_access(&self.rotated_platform_claims(&fresh_record, epoch))?; Ok(RotatedTokens { access_token, refresh_token: fresh.expose_secret().to_owned(), }) } + RotateOutcome::Reused(family) => { + // Post-grace replay of a consumed platform refresh token: revoke the whole + // family and reject, the platform-keyspace analogue of the dashboard path. + self.session_store + .revoke_family(SessionKind::Platform, &family) + .await?; + Err(AuthError::RefreshTokenInvalid) + } RotateOutcome::Invalid => Err(AuthError::RefreshTokenInvalid), } } @@ -443,14 +494,24 @@ impl TokenManagerService { if self.session_store.is_blacklisted(&claims.jti).await? { return Err(AuthError::TokenRevoked); } + // A token stamped below the admin's current epoch predates an invalidating event (a + // password reset or sign-out-everywhere) and is revoked. + if claims.epoch + < self + .session_store + .current_epoch(SessionKind::Platform, &claims.sub) + .await? + { + return Err(AuthError::TokenRevoked); + } Ok(claims) } /// Build the platform access claims for a rotated/recovered session. As with the dashboard /// rotation, `mfa_verified` is dropped (re-acquired only via the MFA challenge); the claims - /// carry no `tenant_id`. + /// carry no `tenant_id`. The `epoch` is the admin's current generation, read at rotation time. #[cfg(feature = "platform")] - fn rotated_platform_claims(&self, record: &SessionRecord) -> PlatformClaims { + fn rotated_platform_claims(&self, record: &SessionRecord, epoch: u64) -> PlatformClaims { let now = now_unix(); PlatformClaims { sub: record.user_id.clone(), @@ -461,6 +522,7 @@ impl TokenManagerService { mfa_verified: false, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), + epoch, } } @@ -478,6 +540,16 @@ impl TokenManagerService { if self.session_store.is_blacklisted(&claims.jti).await? { return Err(AuthError::TokenRevoked); } + // A token stamped below the user's current epoch predates an invalidating event (a + // password reset or sign-out-everywhere) and is revoked. + if claims.epoch + < self + .session_store + .current_epoch(SessionKind::Dashboard, &claims.sub) + .await? + { + return Err(AuthError::TokenRevoked); + } Ok(claims) } @@ -631,8 +703,9 @@ impl TokenManagerService { /// Build the access claims for a rotated/recovered session. Rotation always drops /// `mfa_verified` (the user re-acquires it only via the MFA challenge) and issues an /// empty `status` — status guards consult the repository/status cache, not the rotated - /// JWT, because the stored session record carries no live status. - fn rotated_claims(&self, record: &SessionRecord) -> DashboardClaims { + /// JWT, because the stored session record carries no live status. The `epoch` is the user's + /// current generation, read at rotation time. + fn rotated_claims(&self, record: &SessionRecord, epoch: u64) -> DashboardClaims { let now = now_unix(); DashboardClaims { sub: record.user_id.clone(), @@ -645,6 +718,7 @@ impl TokenManagerService { mfa_verified: false, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), + epoch, } } } @@ -678,6 +752,9 @@ fn identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> SessionR device, ip: stored_ip, created_at: now_offset(), + // Rotation inherits the seed's family unchanged, so every descendant of one login + // shares the id and the whole lineage is revocable together on reuse detection. + family_id: seed.family_id.clone(), } } @@ -695,6 +772,8 @@ fn platform_identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> device, ip: stored_ip, created_at: now_offset(), + // The platform rotation inherits the seed's family unchanged (section 12.5.2). + family_id: seed.family_id.clone(), } } @@ -711,6 +790,9 @@ fn placeholder_record(ip: &str, user_agent: &str) -> SessionRecord { device, ip: stored_ip, created_at: now_offset(), + // The placeholder is never stored (an absent live token yields only Grace/Reused/Invalid), + // so it carries no family. + family_id: String::new(), } } @@ -831,6 +913,90 @@ mod tests { )); } + #[tokio::test] + async fn reused_refresh_token_after_grace_revokes_the_whole_family() { + // Issue → rotate (the old token is consumed, a grace pointer planted). Drop the grace + // pointer to simulate the grace window closing. Replaying the consumed old token is now + // caught as a reuse: it is rejected AND the whole family is revoked, so the live rotated + // token can no longer rotate either — a stolen token cannot keep a parallel chain alive. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()); + let issued = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(issued) = issued else { return }; + let old_hash = RawRefreshToken::from_raw(issued.refresh_token.clone()).redis_hash(); + let rotated = svc + .reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await; + let Ok(rotated) = rotated else { return }; + // The freshly rotated token is live right up until the reuse is detected. + assert!( + store + .find_session(SessionKind::Dashboard, &rotated_hash(&rotated)) + .await + .is_ok() + ); + // Simulate the grace window elapsing so the old token is no longer grace-recoverable. + assert!( + store + .delete_grace_pointer(SessionKind::Dashboard, &old_hash) + .await + .is_ok() + ); + // Replaying the consumed old token is rejected as a detected reuse... + assert!(matches!( + svc.reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + // ...and the reuse revoked the whole family, so the live rotated token no longer rotates. + assert!(matches!( + svc.reissue_tokens(&rotated.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + } + + /// The store hash of a rotated pair's refresh token. + fn rotated_hash(rotated: &RotatedTokens) -> String { + RawRefreshToken::from_raw(rotated.refresh_token.clone()).redis_hash() + } + + #[cfg(feature = "platform")] + #[tokio::test] + async fn reused_platform_refresh_token_after_grace_revokes_the_family() { + // The platform-keyspace analogue: a replayed consumed platform refresh token, past its + // grace window, is rejected as a reuse and revokes the whole platform family. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()); + let issued = svc + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(issued) = issued else { return }; + let old_hash = RawRefreshToken::from_raw(issued.refresh_token.clone()).redis_hash(); + let rotated = svc + .reissue_platform_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await; + let Ok(rotated) = rotated else { return }; + assert!( + store + .delete_grace_pointer(SessionKind::Platform, &old_hash) + .await + .is_ok() + ); + assert!(matches!( + svc.reissue_platform_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + assert!(matches!( + svc.reissue_platform_tokens(&rotated.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + } + #[tokio::test] async fn blacklist_rejects_a_revoked_access_token() { // After revoking the access jti, verify_access reports the internal-only @@ -849,6 +1015,66 @@ mod tests { )); } + #[tokio::test] + async fn a_bumped_epoch_rejects_every_access_token_issued_before_the_bump() { + // A password reset / sign-out-everywhere bumps the user's token epoch. An access token + // stamped before the bump — a stateless JWT that logout's jti-blacklist could not reach + // without holding it — is now rejected on its next verification, so a reset takes effect + // immediately instead of lingering for the access-token lifetime. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()); + let issued = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(issued) = issued else { return }; + // Freshly issued: it verifies (stamped at the current epoch 0). + assert!(svc.verify_access(&issued.access_token).await.is_ok()); + // Bump the epoch (what a password reset does), then the pre-bump token is revoked... + assert!(store.bump_epoch(SessionKind::Dashboard, "u1").await.is_ok()); + assert!(matches!( + svc.verify_access(&issued.access_token).await, + Err(AuthError::TokenRevoked) + )); + // ...while a token issued AFTER the bump carries the new epoch and still verifies. + let after = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(after) = after else { return }; + assert!(svc.verify_access(&after.access_token).await.is_ok()); + } + + #[cfg(feature = "platform")] + #[tokio::test] + async fn a_bumped_platform_epoch_rejects_a_pre_bump_platform_token() { + // The platform-keyspace analogue: bumping a platform admin's epoch invalidates every + // platform access token issued before it, and a later-issued token still verifies. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()); + let issued = svc + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(issued) = issued else { return }; + assert!( + svc.verify_platform_access(&issued.access_token) + .await + .is_ok() + ); + assert!(store.bump_epoch(SessionKind::Platform, "p1").await.is_ok()); + assert!(matches!( + svc.verify_platform_access(&issued.access_token).await, + Err(AuthError::TokenRevoked) + )); + let after = svc + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(after) = after else { return }; + assert!( + svc.verify_platform_access(&after.access_token) + .await + .is_ok() + ); + } + #[tokio::test] async fn verify_access_maps_malformed_and_expired_tokens() { // A garbage token is token_invalid; an expired token is the internal-only @@ -872,6 +1098,7 @@ mod tests { mfa_verified: false, iat: now - 1_000, exp: now - 500, + epoch: 0, }; let Ok(token) = svc.issue_access(&expired) else { return }; assert!(matches!( diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index acc3219..fb895aa 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -296,6 +296,17 @@ pub struct InMemoryStores { sessions: Mutex>, session_index: Mutex>>, grace: Mutex>, + /// `cf:` consumed-token markers: an already-rotated token's hash → the family it belonged + /// to. Outlives the grace pointer (which the real store keys with the shorter grace TTL), + /// so a post-grace replay of the consumed token is detected as a reuse rather than a plain + /// invalid. Keyed by `(kind, old_hash)`. + consumed: Mutex>, + /// `fam:` family index: a family id → the set of its live session hashes, so a whole + /// lineage can be revoked on reuse detection. Keyed by `(kind, family_id)`. + families: Mutex>>, + /// `ep:`/`pep:` per-user token epoch (generation counter), keyed by `(kind, user_id)`. A + /// bump invalidates every access token stamped below the new value. Absent reads as `0`. + epochs: Mutex>, blacklist: Mutex>, otps: Mutex>, resend: Mutex>, @@ -357,6 +368,15 @@ impl SessionStore for InMemoryStores { created_at: detail.created_at, last_activity_at: detail.created_at, }); + // Register the new session in its family index (a fresh login, or the grace-path fork), + // so the whole lineage is revocable on reuse detection. A legacy record with no family + // simply carries no index entry. + if !detail.family_id.is_empty() { + lock(&self.families) + .entry((kind, detail.family_id.clone())) + .or_default() + .insert(token_hash.to_owned()); + } Ok(()) } @@ -386,11 +406,32 @@ impl SessionStore for InMemoryStores { last_activity_at: rotation.new_record.created_at, }); } + // Family bookkeeping: mark the consumed old token (so a post-grace replay is caught + // as a reuse, not a plain invalid) and move the family membership from old to new. + // Old and new share the inherited family id. + if !old_record.family_id.is_empty() { + lock(&self.consumed).insert( + (kind, rotation.old_hash.clone()), + old_record.family_id.clone(), + ); + if let Some(members) = + lock(&self.families).get_mut(&(kind, old_record.family_id.clone())) + { + members.remove(&rotation.old_hash); + members.insert(rotation.new_hash.clone()); + } + } return Ok(RotateOutcome::Rotated(old_record)); } if let Some(recovered) = lock(&self.grace).get(&(kind, rotation.old_hash.clone())) { return Ok(RotateOutcome::Grace(recovered.clone())); } + // Neither live nor in grace: a surviving consumed-token marker means this token was + // validly issued and already rotated — a reuse of a consumed token (its grace window + // has closed). Surface the compromised family for the caller to revoke. + if let Some(family) = lock(&self.consumed).get(&(kind, rotation.old_hash.clone())) { + return Ok(RotateOutcome::Reused(family.clone())); + } Ok(RotateOutcome::Invalid) } @@ -455,6 +496,28 @@ impl SessionStore for InMemoryStores { Ok(()) } + async fn revoke_family(&self, kind: SessionKind, family_id: &str) -> Result<(), AuthError> { + // Idempotent: an empty, unknown, or already-cleared family drops nothing. + if family_id.is_empty() { + return Ok(()); + } + let Some(hashes) = lock(&self.families).remove(&(kind, family_id.to_owned())) else { + return Ok(()); + }; + let mut sessions = lock(&self.sessions); + let mut index = lock(&self.session_index); + for hash in hashes { + // Every live descendant of the compromised login is deleted, and pruned from its + // owner's session index (all family members share one user). + if let Some(record) = sessions.remove(&(kind, hash.clone())) + && let Some(details) = index.get_mut(&(kind, record.user_id.clone())) + { + details.retain(|detail| detail.session_hash != hash); + } + } + Ok(()) + } + async fn blacklist_access( &self, jti_or_hash: &str, @@ -467,6 +530,20 @@ impl SessionStore for InMemoryStores { async fn is_blacklisted(&self, jti_or_hash: &str) -> Result { Ok(lock(&self.blacklist).contains(jti_or_hash)) } + + async fn current_epoch(&self, kind: SessionKind, user_id: &str) -> Result { + Ok(lock(&self.epochs) + .get(&(kind, user_id.to_owned())) + .copied() + .unwrap_or(0)) + } + + async fn bump_epoch(&self, kind: SessionKind, user_id: &str) -> Result { + let mut epochs = lock(&self.epochs); + let entry = epochs.entry((kind, user_id.to_owned())).or_insert(0); + *entry += 1; + Ok(*entry) + } } #[async_trait] @@ -993,6 +1070,10 @@ mod tests { } fn record(user: &str) -> SessionRecord { + record_in_family(user, "fam-1") + } + + fn record_in_family(user: &str, family: &str) -> SessionRecord { SessionRecord { user_id: user.to_owned(), tenant_id: Some("t1".to_owned()), @@ -1000,6 +1081,7 @@ mod tests { device: "Chrome".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + family_id: family.to_owned(), } } @@ -1071,6 +1153,77 @@ mod tests { assert!(matches!(store.is_blacklisted("jti").await, Ok(true))); } + #[tokio::test] + async fn session_store_detects_reuse_and_revokes_the_family() { + let store = InMemoryStores::new(); + let kind = SessionKind::Dashboard; + // A login in family "famA", then a rotation h1 -> h2 (same inherited family). + assert!( + store + .create_session(kind, "h1", &record_in_family("u1", "famA"), 60) + .await + .is_ok() + ); + let rotation = SessionRotation { + old_hash: "h1".to_owned(), + new_hash: "h2".to_owned(), + new_raw: "raw2".to_owned(), + new_record: record_in_family("u1", "famA"), + refresh_ttl: 60, + grace_ttl: 30, + }; + assert!(matches!( + store.rotate(kind, &rotation).await, + Ok(RotateOutcome::Rotated(_)) + )); + // Inside the grace window, replaying the consumed token recovers rather than trips reuse. + assert!(matches!( + store.rotate(kind, &rotation).await, + Ok(RotateOutcome::Grace(_)) + )); + // Once the grace pointer is gone (the window has closed), the surviving consumed marker + // makes the same replay a REUSE carrying the compromised family id. + assert!(store.delete_grace_pointer(kind, "h1").await.is_ok()); + assert!(matches!( + store.rotate(kind, &rotation).await, + Ok(RotateOutcome::Reused(family)) if family == "famA" + )); + // The live descendant h2 is present until the family is revoked; revoke_family then + // deletes it and clears the owner's index, and is idempotent on unknown/empty families. + assert!(matches!(store.find_session(kind, "h2").await, Ok(Some(_)))); + assert!(store.revoke_family(kind, "famA").await.is_ok()); + assert!(matches!(store.find_session(kind, "h2").await, Ok(None))); + assert!(matches!(store.list_sessions(kind, "u1").await, Ok(v) if v.is_empty())); + assert!(store.revoke_family(kind, "famA").await.is_ok()); + assert!(store.revoke_family(kind, "").await.is_ok()); + + // A legacy session with no family plants no consumed marker, so a post-grace replay is a + // plain Invalid, never a reuse. + assert!( + store + .create_session(kind, "g1", &record_in_family("u2", ""), 60) + .await + .is_ok() + ); + let legacy = SessionRotation { + old_hash: "g1".to_owned(), + new_hash: "g2".to_owned(), + new_raw: "rawg".to_owned(), + new_record: record_in_family("u2", ""), + refresh_ttl: 60, + grace_ttl: 30, + }; + assert!(matches!( + store.rotate(kind, &legacy).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!(store.delete_grace_pointer(kind, "g1").await.is_ok()); + assert!(matches!( + store.rotate(kind, &legacy).await, + Ok(RotateOutcome::Invalid) + )); + } + #[tokio::test] async fn otp_store_covers_put_verify_outcomes_and_resend() { let store = InMemoryStores::new(); diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 7288449..6d1a5b4 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -64,6 +64,14 @@ pub struct SessionRecord { /// Session creation time. #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, + /// The refresh-token **family** (login lineage) this session belongs to. Minted at login + /// and inherited unchanged across every rotation, so all descendants of one login share it. + /// It is the unit of reuse-detection revocation: presenting an already-consumed refresh + /// token (post-grace) revokes the whole family (section 12.5.2). Empty on a legacy record + /// written before families existed — such a record simply carries no family and is never a + /// reuse-revocation target; it is omitted from the wire when empty for byte-parity. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub family_id: String, } /// One session's display detail, returned by [`SessionStore::list_sessions`]. The @@ -132,7 +140,14 @@ pub enum RotateOutcome { /// The old token was already rotated but is inside the grace window; the caller mints /// a fresh token for this recovered record without planting a new grace pointer. Grace(SessionRecord), - /// Neither the live token nor a grace pointer was found — the refresh is invalid. + /// The old token was validly issued and already rotated, and its grace window has since + /// closed — a **reuse of a consumed refresh token**, the signature of a stolen token being + /// replayed. Carries the compromised **family id**; the caller revokes the whole family + /// (every live descendant of that login) and rejects the request, forcing re-authentication + /// (OWASP refresh-token rotation with automatic reuse detection, section 12.5.2). + Reused(String), + /// Neither the live token, a grace pointer, nor a consumed-family marker was found — the + /// refresh was never issued (or has fully aged out): a plain invalid refresh, not a reuse. Invalid, } @@ -220,6 +235,12 @@ pub trait SessionStore: Send + Sync { /// Revoke every session for a user in one transaction. async fn revoke_all(&self, kind: SessionKind, user_id: &str) -> Result<(), AuthError>; + /// Revoke every live session in a refresh-token **family** (one login lineage), deleting + /// each descendant's refresh/detail keys and clearing the family index. Called on + /// reuse-detection ([`RotateOutcome::Reused`]) to lock out a stolen token's whole chain. + /// Idempotent: an unknown or already-cleared family is a no-op. + async fn revoke_family(&self, kind: SessionKind, family_id: &str) -> Result<(), AuthError>; + /// Add a JTI (preferred) or full-JWT hash to the access-token blacklist for its /// remaining lifetime. async fn blacklist_access( @@ -231,6 +252,18 @@ pub trait SessionStore: Send + Sync { /// Whether an access JTI or JWT hash is blacklisted — consulted on every protected /// request. async fn is_blacklisted(&self, jti_or_hash: &str) -> Result; + + /// The user's current token **epoch** (generation counter), or `0` when none is stored. + /// Stamped into a freshly-issued access token and re-read on every verification: a token + /// whose stamped epoch is below this value was issued before an invalidating event and is + /// rejected. The `0` default keeps the mechanism inert for a user who has never had a bump. + async fn current_epoch(&self, kind: SessionKind, user_id: &str) -> Result; + + /// Atomically increment the user's token epoch and return the new value, invalidating every + /// outstanding access token for that user at once (a password reset or a sign-out-everywhere). + /// Idempotent in effect: each call advances the generation, and only tokens stamped at or + /// above the new value remain valid. + async fn bump_epoch(&self, kind: SessionKind, user_id: &str) -> Result; } /// One-time-password records for email verification and OTP-based password reset. @@ -524,6 +557,7 @@ mod tests { device: "Chrome on macOS".into(), ip: "203.0.113.4".into(), created_at: OffsetDateTime::UNIX_EPOCH, + family_id: "fam-1".into(), } } @@ -551,6 +585,8 @@ mod tests { assert!(json.contains("\"userId\":\"u1\"")); assert!(json.contains("\"tenantId\":\"t1\"")); assert!(json.contains("\"createdAt\":")); + // A present family id is on the wire as camelCase `familyId`. + assert!(json.contains("\"familyId\":\"fam-1\"")); let platform = SessionRecord { tenant_id: None, @@ -558,7 +594,18 @@ mod tests { }; assert!(!serde_json::to_string(&platform)?.contains("tenantId")); - // Round-trip parity. + // An empty family id (a legacy record) is omitted from the wire for byte-parity, and a + // record with no `familyId` key deserializes back to an empty family. + let legacy = SessionRecord { + family_id: String::new(), + ..session_record() + }; + let legacy_json = serde_json::to_string(&legacy)?; + assert!(!legacy_json.contains("familyId")); + let legacy_back: SessionRecord = serde_json::from_str(&legacy_json)?; + assert_eq!(legacy_back.family_id, ""); + + // Round-trip parity for the full record. let back: SessionRecord = serde_json::from_str(&json)?; assert_eq!(back, dashboard); Ok(()) @@ -628,6 +675,11 @@ mod tests { RotateOutcome::Rotated(record.clone()), RotateOutcome::Rotated(_) )); + // Reuse carries the compromised family id the caller revokes. + assert!(matches!( + RotateOutcome::Reused("fam-1".to_owned()), + RotateOutcome::Reused(family) if family == "fam-1" + )); assert!(matches!( RotateOutcome::Grace(record), RotateOutcome::Grace(_) diff --git a/crates/bymax-auth-jwt/src/hs256.rs b/crates/bymax-auth-jwt/src/hs256.rs index 154f3b3..e1020ec 100644 --- a/crates/bymax-auth-jwt/src/hs256.rs +++ b/crates/bymax-auth-jwt/src/hs256.rs @@ -193,6 +193,7 @@ mod tests { mfa_verified: false, iat, exp, + epoch: 0, } } @@ -224,6 +225,7 @@ mod tests { mfa_verified: true, iat: 1_000, exp: 2_000, + epoch: 0, }; let ptoken = sign(&platform, &key).unwrap_or_default(); assert_eq!( @@ -509,6 +511,7 @@ mod tests { span in 1i64..100_000, mfa_enabled in any::(), mfa_verified in any::(), + epoch in any::(), ) { // For any well-formed claims, a signed token verifies back to the same // claims at a time within the validity window — the codec's core invariant. @@ -517,7 +520,7 @@ mod tests { let claims = DashboardClaims { sub, jti, tenant_id: "t".to_owned(), role, token_type: DashboardType::Dashboard, status: "ACTIVE".to_owned(), - mfa_enabled, mfa_verified, iat, exp, + mfa_enabled, mfa_verified, iat, exp, epoch, }; let token = sign(&claims, &key).unwrap_or_default(); prop_assert_eq!(verify::(&token, &key, &opts_at(iat)).ok(), Some(claims)); diff --git a/crates/bymax-auth-jwt/src/keys.rs b/crates/bymax-auth-jwt/src/keys.rs index b34ceb4..ed75400 100644 --- a/crates/bymax-auth-jwt/src/keys.rs +++ b/crates/bymax-auth-jwt/src/keys.rs @@ -273,6 +273,7 @@ mod tests { mfa_verified: false, iat: 10, exp: 20, + epoch: 0, }; assert_eq!(JwtClaims::iat(&dashboard), 10); assert_eq!(JwtClaims::exp(&dashboard), 20); @@ -286,6 +287,7 @@ mod tests { mfa_verified: false, iat: 11, exp: 21, + epoch: 0, }; assert_eq!(JwtClaims::iat(&platform), 11); assert_eq!(JwtClaims::exp(&platform), 21); diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 6c1136d..c9461d3 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -28,8 +28,16 @@ pub enum Prefix { Rt, /// Access-JWT revocation blacklist (`rv`). Rv, + /// Dashboard per-user token epoch / generation counter (`ep`). + Ep, + /// Platform per-user token epoch / generation counter (`pep`). + Pep, /// Dashboard rotation grace pointer (`rp`). Rp, + /// Dashboard consumed-token family marker for reuse detection (`cf`). + Cf, + /// Dashboard refresh-token family index SET (`fam`). + Fam, /// Dashboard active-session index SET (`sess`). Sess, /// Dashboard per-session detail (`sd`). @@ -52,6 +60,10 @@ pub enum Prefix { Prt, /// Platform rotation grace pointer (`prp`). Prp, + /// Platform consumed-token family marker for reuse detection (`pcf`). + Pcf, + /// Platform refresh-token family index SET (`pfam`). + Pfam, /// Platform active-session index SET (`psess`). Psess, /// Platform per-session detail (`psd`). @@ -73,7 +85,11 @@ impl Prefix { match self { Self::Rt => "rt", Self::Rv => "rv", + Self::Ep => "ep", + Self::Pep => "pep", Self::Rp => "rp", + Self::Cf => "cf", + Self::Fam => "fam", Self::Sess => "sess", Self::Sd => "sd", Self::Lf => "lf", @@ -85,6 +101,8 @@ impl Prefix { Self::Inv => "inv", Self::Prt => "prt", Self::Prp => "prp", + Self::Pcf => "pcf", + Self::Pfam => "pfam", Self::Psess => "psess", Self::Psd => "psd", Self::MfaSetup => "mfa_setup", @@ -150,7 +168,11 @@ mod tests { let cases = [ (Prefix::Rt, "auth:rt:abc"), (Prefix::Rv, "auth:rv:abc"), + (Prefix::Ep, "auth:ep:abc"), + (Prefix::Pep, "auth:pep:abc"), (Prefix::Rp, "auth:rp:abc"), + (Prefix::Cf, "auth:cf:abc"), + (Prefix::Fam, "auth:fam:abc"), (Prefix::Sess, "auth:sess:abc"), (Prefix::Sd, "auth:sd:abc"), (Prefix::Lf, "auth:lf:abc"), @@ -162,6 +184,8 @@ mod tests { (Prefix::Inv, "auth:inv:abc"), (Prefix::Prt, "auth:prt:abc"), (Prefix::Prp, "auth:prp:abc"), + (Prefix::Pcf, "auth:pcf:abc"), + (Prefix::Pfam, "auth:pfam:abc"), (Prefix::Psess, "auth:psess:abc"), (Prefix::Psd, "auth:psd:abc"), (Prefix::MfaSetup, "auth:mfa_setup:abc"), diff --git a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua index 97bbaa2..e2ed9fc 100644 --- a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua +++ b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua @@ -1,23 +1,30 @@ --- refresh_rotate: atomic refresh-token rotation with a grace window (spec section 12.5.1). --- Prevents the double-rotation race: two concurrent requests carrying the same refresh --- token must never both mint a live session. +-- refresh_rotate: atomic refresh-token rotation with a grace window and reuse detection +-- (spec sections 12.5.1 / 12.5.2). Prevents the double-rotation race — two concurrent requests +-- carrying the same refresh token must never both mint a live session — and catches the replay +-- of an already-consumed token (a stolen token being reused) once its grace window has closed. -- --- KEYS[1] = rt:{sha256(old)} the live session key for the presented token --- KEYS[2] = rt:{sha256(new)} the destination key for the freshly minted token --- KEYS[3] = rp:{sha256(old)} the rotation grace pointer for the old token +-- KEYS[1] = rt:{sha256(old)} the live session key for the presented token +-- KEYS[2] = rt:{sha256(new)} the destination key for the freshly minted token +-- KEYS[3] = rp:{sha256(old)} the rotation grace pointer for the old token +-- KEYS[4] = cf:{sha256(old)} the consumed-family marker for the old token +-- KEYS[5] = fam:{family} the family index SET (the presented session's lineage) -- ARGV[1] = new session record JSON (the SessionRecord, never a raw token) -- ARGV[2] = refresh TTL in seconds (always > 0) -- ARGV[3] = grace TTL in seconds (0 means "no grace pointer": skip it entirely) +-- ARGV[4] = family id of the presented session ('' means "legacy, no family": skip family work) +-- ARGV[5] = sha256(old) the SET member to move out of the family +-- ARGV[6] = sha256(new) the SET member to move into the family -- --- Returns the consumed old-session JSON on a live rotation; "GRACE:" .. json when the old --- token was already rotated but is still inside the grace window; or false (nil) when --- neither the live token nor a grace pointer is present (an invalid refresh). +-- Returns the consumed old-session JSON on a live rotation; "GRACE:" .. json when the old token +-- was already rotated but is still inside the grace window; "REUSED:" .. family when the old +-- token was validly issued and already rotated and its grace window has closed (a reuse); or +-- false (nil) when none of those are present (an invalid refresh that was never issued). -- --- Write-before-delete ordering: the new session key (and, when grace_ttl > 0, the grace --- pointer) are written BEFORE the old key is removed. Redis does not roll back a script's --- earlier writes if a later command errors, so any failing SET aborts the script while the --- old token is still intact — the old refresh token is never consumed without the new --- session being persisted. +-- Write-before-delete ordering: the new session key, the grace pointer, and the consumed-family +-- marker are written BEFORE the old key is removed. Redis does not roll back a script's earlier +-- writes if a later command errors, so any failing SET aborts the script while the old token is +-- still intact — the old refresh token is never consumed without the new session being persisted +-- and the consumed marker planted (so a crash can never lose reuse detection). local old = redis.call('GET', KEYS[1]) if old then redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2]) @@ -26,6 +33,16 @@ if old then if tonumber(ARGV[3]) > 0 then redis.call('SET', KEYS[3], ARGV[1], 'EX', ARGV[3]) end + -- Plant the consumed-family marker (surviving the whole refresh lifetime, past the shorter + -- grace window) and move the family membership from the old hash to the new one, so a + -- post-grace replay is detected as a reuse and the whole lineage stays revocable. A legacy + -- session with no family ('') skips this bookkeeping. + if ARGV[4] ~= '' then + redis.call('SET', KEYS[4], ARGV[4], 'EX', ARGV[2]) + redis.call('SREM', KEYS[5], ARGV[5]) + redis.call('SADD', KEYS[5], ARGV[6]) + redis.call('EXPIRE', KEYS[5], ARGV[2]) + end redis.call('DEL', KEYS[1]) return old end @@ -33,4 +50,10 @@ local grace = redis.call('GET', KEYS[3]) if grace then return 'GRACE:' .. grace end +-- Post-grace reuse: the consumed-family marker outlives the grace pointer, so its presence here +-- means this token was validly issued and already rotated — a replay of a consumed token. +local family = redis.call('GET', KEYS[4]) +if family then + return 'REUSED:' .. family +end return false diff --git a/crates/bymax-auth-redis/src/lua/revoke_family.lua b/crates/bymax-auth-redis/src/lua/revoke_family.lua new file mode 100644 index 0000000..d5ea2d1 --- /dev/null +++ b/crates/bymax-auth-redis/src/lua/revoke_family.lua @@ -0,0 +1,40 @@ +-- revoke_family: revoke every live session in a refresh-token family in one transaction +-- (spec section 12.5.2). Called on reuse detection to lock out a stolen token's whole lineage: +-- every descendant of the compromised login is deleted, forcing each holder to re-authenticate. +-- +-- KEYS[1] = fam:{family} the family index SET of live session hashes (already namespaced) +-- ARGV[1] = namespace e.g. "auth" +-- ARGV[2] = refresh prefix "rt" (dashboard) or "prt" (platform) +-- ARGV[3] = detail prefix "sd" (dashboard) or "psd" (platform) +-- ARGV[4] = session prefix "sess" (dashboard) or "psess" (platform) +-- +-- Returns the number of family members that were removed. Idempotent: an unknown or empty +-- family removes nothing. +local members = redis.call('SMEMBERS', KEYS[1]) +if #members == 0 then + redis.call('DEL', KEYS[1]) + return 0 +end +local ns, rt, sd, sess = ARGV[1], ARGV[2], ARGV[3], ARGV[4] +-- Every member of one family belongs to the same user; resolve that user's `sess:` SET from the +-- first member whose record is still readable, so the deleted hashes can be pruned from it too. +local sess_key = nil +for _, hash in ipairs(members) do + local record = redis.call('GET', ns .. ':' .. rt .. ':' .. hash) + if record then + local ok, decoded = pcall(cjson.decode, record) + if ok and decoded.userId then + sess_key = ns .. ':' .. sess .. ':' .. decoded.userId + break + end + end +end +for _, hash in ipairs(members) do + redis.call('DEL', ns .. ':' .. rt .. ':' .. hash) + redis.call('DEL', ns .. ':' .. sd .. ':' .. hash) + if sess_key then + redis.call('SREM', sess_key, hash) + end +end +redis.call('DEL', KEYS[1]) +return #members diff --git a/crates/bymax-auth-redis/src/script.rs b/crates/bymax-auth-redis/src/script.rs index 55ad1ff..3f80651 100644 --- a/crates/bymax-auth-redis/src/script.rs +++ b/crates/bymax-auth-redis/src/script.rs @@ -45,6 +45,11 @@ pub static SESSION_REVOKE: LazyLock = pub static INVALIDATE_USER_SESSIONS: LazyLock = LazyLock::new(|| LuaScript::new(include_str!("lua/invalidate_user_sessions.lua"))); +/// `revoke_family` — revoke every live session in a refresh-token family on reuse detection +/// (section 12.5.2). +pub static REVOKE_FAMILY: LazyLock = + LazyLock::new(|| LuaScript::new(include_str!("lua/revoke_family.lua"))); + /// `brute_force_incr` — fixed-window failure counter (section 12.5.3). pub static BRUTE_FORCE_INCR: LazyLock = LazyLock::new(|| LuaScript::new(include_str!("lua/brute_force_incr.lua"))); diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 4ae499e..33be103 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -21,6 +21,11 @@ use crate::script; /// the literal in `lua/refresh_rotate.lua`. const GRACE_TAG: &str = "GRACE:"; +/// The tag the `refresh_rotate` script prepends to a reuse-detection reply (a replay of a +/// consumed token past its grace window), carrying the compromised family id. Matches the +/// literal in `lua/refresh_rotate.lua`. +const REUSED_TAG: &str = "REUSED:"; + /// The stored `sd:`/`psd:` per-session detail value. The `session_hash` lives in the key, so /// it is absent here; the field set is byte-identical to nest-auth. #[derive(Serialize, Deserialize)] @@ -51,28 +56,34 @@ impl SessionDetailValue { } } -/// The prefix quartet selected by a [`SessionKind`]: the refresh-session, grace-pointer, -/// session-index, and per-session-detail keyspaces. +/// The prefix sextet selected by a [`SessionKind`]: the refresh-session, grace-pointer, +/// consumed-family marker, family-index, session-index, and per-session-detail keyspaces. struct KindPrefixes { rt: Prefix, rp: Prefix, + cf: Prefix, + fam: Prefix, sess: Prefix, sd: Prefix, } -/// Map a [`SessionKind`] onto its prefix quartet (`rt`/`rp`/`sess`/`sd` for dashboard, -/// `prt`/`prp`/`psess`/`psd` for platform). +/// Map a [`SessionKind`] onto its prefix sextet (`rt`/`rp`/`cf`/`fam`/`sess`/`sd` for dashboard, +/// `prt`/`prp`/`pcf`/`pfam`/`psess`/`psd` for platform). fn kind_prefixes(kind: SessionKind) -> KindPrefixes { match kind { SessionKind::Dashboard => KindPrefixes { rt: Prefix::Rt, rp: Prefix::Rp, + cf: Prefix::Cf, + fam: Prefix::Fam, sess: Prefix::Sess, sd: Prefix::Sd, }, SessionKind::Platform => KindPrefixes { rt: Prefix::Prt, rp: Prefix::Prp, + cf: Prefix::Pcf, + fam: Prefix::Pfam, sess: Prefix::Psess, sd: Prefix::Psd, }, @@ -86,12 +97,16 @@ enum RotateParsed { Rotated(SessionRecord), /// The old token was inside the grace window; carries the recovered record. Grace(SessionRecord), - /// Neither the live token nor a grace pointer was present. + /// The old token was already consumed and its grace window has closed — a reuse; carries + /// the compromised family id. + Reused(String), + /// Neither the live token, a grace pointer, nor a consumed marker was present. Invalid, } /// Interpret the raw `refresh_rotate` reply: `nil` is an invalid refresh, a `"GRACE:"`-tagged -/// payload is a grace-window hit, and any other payload is the consumed old-session JSON. +/// payload is a grace-window hit, a `"REUSED:"`-tagged payload is a consumed-token reuse +/// carrying its family id, and any other payload is the consumed old-session JSON. fn interpret_rotate(raw: Option) -> Result { let Some(payload) = raw else { return Ok(RotateParsed::Invalid); @@ -99,6 +114,9 @@ fn interpret_rotate(raw: Option) -> Result(&mut conn) - .await?; + .ignore(); + // Register the session in its family index (skipped for a legacy record with no family), + // so the whole lineage can be revoked on reuse detection. The index carries the refresh + // TTL so it ages out with the sessions it tracks. + if !detail.family_id.is_empty() { + let fam_key = keys.key(prefixes.fam, &detail.family_id); + pipe.cmd("SADD") + .arg(&fam_key) + .arg(token_hash) + .ignore() + .cmd("EXPIRE") + .arg(&fam_key) + .arg(ttl_window) + .ignore(); + } + + let mut conn = self.connection().await?; + pipe.query_async::<()>(&mut conn).await?; Ok(()) } @@ -160,6 +192,12 @@ impl RedisStores { let rt_old = keys.key(prefixes.rt, &rotation.old_hash); let rt_new = keys.key(prefixes.rt, &rotation.new_hash); let rp_old = keys.key(prefixes.rp, &rotation.old_hash); + let cf_old = keys.key(prefixes.cf, &rotation.old_hash); + // The family index of the presented session's lineage. When the new record carries no + // family (a legacy rotation) the script's `ARGV[4] == ''` guard skips every family write, + // so this key is built but never touched. + let family = &rotation.new_record.family_id; + let fam_key = keys.key(prefixes.fam, family); let new_json = serde_json::to_string(&rotation.new_record)?; let mut conn = self.connection().await?; @@ -168,15 +206,21 @@ impl RedisStores { .key(&rt_old) .key(&rt_new) .key(&rp_old) + .key(&cf_old) + .key(&fam_key) .arg(&new_json) .arg(rotation.refresh_ttl) .arg(rotation.grace_ttl) + .arg(family) + .arg(&rotation.old_hash) + .arg(&rotation.new_hash) .invoke_async(&mut conn) .await?; match interpret_rotate(raw)? { RotateParsed::Invalid => Ok(RotateOutcome::Invalid), RotateParsed::Grace(record) => Ok(RotateOutcome::Grace(record)), + RotateParsed::Reused(family) => Ok(RotateOutcome::Reused(family)), RotateParsed::Rotated(old_record) => { self.move_session_member(&mut conn, &prefixes, rotation, &old_record.user_id) .await?; @@ -185,6 +229,34 @@ impl RedisStores { } } + /// Run the `revoke_family` transaction, deleting every live member's `rt:`/`sd:` key, pruning + /// each from its owner's `sess:` SET, and dropping the family index — the reuse-detection + /// lockout of a stolen token's whole lineage. + async fn revoke_family_inner( + &self, + kind: SessionKind, + family_id: &str, + ) -> Result<(), RedisStoreError> { + // An empty family id has no index key; nothing to revoke. + if family_id.is_empty() { + return Ok(()); + } + let prefixes = kind_prefixes(kind); + let keys = self.keys(); + let fam_key = keys.key(prefixes.fam, family_id); + let mut conn = self.connection().await?; + script::REVOKE_FAMILY + .prepare() + .key(&fam_key) + .arg(keys.namespace()) + .arg(prefixes.rt.as_str()) + .arg(prefixes.sd.as_str()) + .arg(prefixes.sess.as_str()) + .invoke_async::(&mut conn) + .await?; + Ok(()) + } + /// Move the session-index membership and detail from the old hash to the new hash after a /// live rotation — the non-atomic bookkeeping the rotation script leaves to the caller. async fn move_session_member( @@ -361,6 +433,56 @@ impl RedisStores { let present: bool = conn.exists(&key).await?; Ok(present) } + + /// Read the user's token epoch (`ep:`/`pep:`), defaulting to `0` when no key exists — a + /// plain `GET` that never creates the key, so only a user who has actually been bumped + /// carries one. + async fn current_epoch_inner( + &self, + kind: SessionKind, + user_id: &str, + ) -> Result { + let key = self.keys().key(epoch_prefix(kind), user_id); + let mut conn = self.connection().await?; + let value: Option = conn.get(&key).await?; + Ok(value.unwrap_or(0)) + } + + /// Atomically increment the user's token epoch (`INCR`, creating it at `1` when absent) and + /// (re)apply its TTL, returning the new value. The TTL is deliberately far longer than any + /// access token lives, so a bump stays effective for the whole window a pre-bump token could + /// still be presented, while still bounding growth to a small integer per reset-affected user. + async fn bump_epoch_inner( + &self, + kind: SessionKind, + user_id: &str, + ) -> Result { + let key = self.keys().key(epoch_prefix(kind), user_id); + let mut conn = self.connection().await?; + let (new_value, _): (u64, bool) = redis::pipe() + .atomic() + .cmd("INCR") + .arg(&key) + .cmd("EXPIRE") + .arg(&key) + .arg(EPOCH_TTL_SECS) + .query_async(&mut conn) + .await?; + Ok(new_value) + } +} + +/// TTL applied to a token-epoch key, in seconds (30 days). It must comfortably exceed the +/// longest an access token can live so an epoch bump stays in force for every pre-bump token's +/// remaining lifetime; a small fixed integer key per reset-affected user is negligible. +const EPOCH_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// The token-epoch key prefix for a session kind (`ep:` dashboard, `pep:` platform). +fn epoch_prefix(kind: SessionKind) -> Prefix { + match kind { + SessionKind::Dashboard => Prefix::Ep, + SessionKind::Platform => Prefix::Pep, + } } #[async_trait] @@ -440,6 +562,12 @@ impl SessionStore for RedisStores { .map_err(AuthError::from) } + async fn revoke_family(&self, kind: SessionKind, family_id: &str) -> Result<(), AuthError> { + self.revoke_family_inner(kind, family_id) + .await + .map_err(AuthError::from) + } + async fn blacklist_access( &self, jti_or_hash: &str, @@ -455,6 +583,18 @@ impl SessionStore for RedisStores { .await .map_err(AuthError::from) } + + async fn current_epoch(&self, kind: SessionKind, user_id: &str) -> Result { + self.current_epoch_inner(kind, user_id) + .await + .map_err(AuthError::from) + } + + async fn bump_epoch(&self, kind: SessionKind, user_id: &str) -> Result { + self.bump_epoch_inner(kind, user_id) + .await + .map_err(AuthError::from) + } } #[cfg(test)] @@ -469,6 +609,7 @@ mod tests { device: "Chrome".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + family_id: "fam-1".to_owned(), } } @@ -507,6 +648,11 @@ mod tests { interpret_rotate(Some(format!("GRACE:{json}"))), Ok(RotateParsed::Grace(_)) )); + // A `REUSED:`-tagged reply carries the compromised family id verbatim (never JSON). + assert!(matches!( + interpret_rotate(Some("REUSED:fam-1".to_owned())), + Ok(RotateParsed::Reused(family)) if family == "fam-1" + )); assert!(matches!( interpret_rotate(Some(json)), Ok(RotateParsed::Rotated(_)) diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index dec63cb..7eda2f0 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -31,7 +31,9 @@ use bymax_auth_types::{AuthError, LoginResult}; use secrecy::SecretString; use time::OffsetDateTime; -/// A dashboard/platform session record for the given user. +/// A dashboard/platform session record for the given user. All of a user's sessions share one +/// family here, so a rotation (whose `new_record` is `record(user)`) inherits it and the whole +/// lineage stays revocable together. fn record(user: &str) -> SessionRecord { SessionRecord { user_id: user.to_owned(), @@ -40,6 +42,7 @@ fn record(user: &str) -> SessionRecord { device: "Chrome on macOS".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + family_id: format!("fam-{user}"), } } @@ -190,8 +193,143 @@ async fn rotate_with_zero_grace_writes_no_grace_pointer() { assert_eq!(redis.ttl("auth:rp:z1").await, -2); assert_eq!(redis.ttl("auth:rp:z2").await, -2); - // A replay of the consumed token cannot recover: with no grace pointer it is invalid, - // never a second live rotation. + // A replay of the consumed token cannot recover — and with no grace window it is not even + // race-tolerated: the consumed-family marker (which is planted regardless of the grace TTL) + // makes the replay a REUSE carrying the family, never a second live rotation. + assert!(matches!( + stores.rotate(kind, &rot).await, + Ok(RotateOutcome::Reused(family)) if family == "fam-zu" + )); +} + +#[tokio::test] +async fn reuse_past_grace_is_detected_and_revoke_family_kills_the_lineage() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // A login under `r1`, then a rotation r1 -> r2 within family "fam-fu": the old token is + // consumed (a grace pointer planted) and the family index moves to the live descendant r2. + assert!( + stores + .create_session(kind, "r1", &record("fu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores.rotate(kind, &rotation("r1", "r2", "fu")).await, + Ok(RotateOutcome::Rotated(old)) if old.user_id == "fu" + )); + // The family index exists (a positive TTL) and, while r2 is live, r2 is listed for the user. + assert!(redis.ttl("auth:fam:fam-fu").await > 0); + assert!(matches!( + stores.list_sessions(kind, "fu").await, + Ok(v) if v.len() == 1 && v[0].session_hash == "r2" + )); + + // Simulate the grace window closing by dropping the grace pointer for the consumed token. + assert!(redis.del("auth:rp:r1").await); + // Replaying the consumed token is now caught as a REUSE carrying the compromised family. + assert!(matches!( + stores.rotate(kind, &rotation("r1", "rX", "fu")).await, + Ok(RotateOutcome::Reused(family)) if family == "fam-fu" + )); + // The failed reuse never minted a live token: `rX` was never persisted. + assert!(matches!(stores.find_session(kind, "rX").await, Ok(None))); + + // Revoking the family deletes the live descendant r2, prunes it from the owner's `sess:` set, + // and drops the family index. + assert!(stores.revoke_family(kind, "fam-fu").await.is_ok()); + assert!(matches!(stores.find_session(kind, "r2").await, Ok(None))); + assert!(matches!(stores.list_sessions(kind, "fu").await, Ok(v) if v.is_empty())); + assert_eq!(redis.ttl("auth:fam:fam-fu").await, -2); + + // revoke_family is idempotent: an empty and an unknown family are both no-ops. + assert!(stores.revoke_family(kind, "").await.is_ok()); + assert!(stores.revoke_family(kind, "unknown-family").await.is_ok()); +} + +#[tokio::test] +async fn token_epoch_defaults_to_zero_bumps_monotonically_and_is_keyspace_disjoint() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // An unbumped user reads epoch 0, and the read never creates a key (only a bump does). + assert!(matches!(stores.current_epoch(kind, "eu").await, Ok(0))); + assert_eq!(redis.ttl("auth:ep:eu").await, -2, "a read plants no key"); + + // A bump increments (creating the key at 1), returns the new value, and carries a TTL. + assert!(matches!(stores.bump_epoch(kind, "eu").await, Ok(1))); + assert!(matches!(stores.current_epoch(kind, "eu").await, Ok(1))); + assert!( + redis.ttl("auth:ep:eu").await > 0, + "the epoch key carries a TTL" + ); + assert!(matches!(stores.bump_epoch(kind, "eu").await, Ok(2))); + assert!(matches!(stores.current_epoch(kind, "eu").await, Ok(2))); + + // The platform keyspace (`pep:`) is disjoint from the dashboard one (`ep:`): the same user + // id carries an independent epoch under each kind. + assert!(matches!( + stores.current_epoch(SessionKind::Platform, "eu").await, + Ok(0) + )); + assert!(matches!( + stores.bump_epoch(SessionKind::Platform, "eu").await, + Ok(1) + )); + assert!(redis.ttl("auth:pep:eu").await > 0); + // The dashboard epoch is unaffected by the platform bump. + assert!(matches!(stores.current_epoch(kind, "eu").await, Ok(2))); +} + +#[tokio::test] +async fn a_legacy_session_without_a_family_plants_no_family_keys() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // A legacy record (written before families existed) carries no family id, so create/rotate + // skip every family write: no `fam:` index and no `cf:` consumed marker are ever planted. + let legacy = SessionRecord { + family_id: String::new(), + ..record("lu") + }; + assert!( + stores + .create_session(kind, "l1", &legacy, 3600) + .await + .is_ok() + ); + assert_eq!(redis.ttl("auth:fam:fam-lu").await, -2, "no family index"); + + let rot = SessionRotation { + new_record: SessionRecord { + family_id: String::new(), + ..record("lu") + }, + ..rotation("l1", "l2", "lu") + }; + assert!(matches!( + stores.rotate(kind, &rot).await, + Ok(RotateOutcome::Rotated(old)) if old.user_id == "lu" + )); + assert_eq!( + redis.ttl("auth:cf:l1").await, + -2, + "no consumed marker planted" + ); + + // With no consumed marker, a post-grace replay is a plain Invalid, never a reuse. Drop the + // grace pointer to close the window first. + assert!(redis.del("auth:rp:l1").await); assert!(matches!( stores.rotate(kind, &rot).await, Ok(RotateOutcome::Invalid) @@ -433,8 +571,8 @@ async fn keys_are_namespaced_no_pii_and_carry_a_ttl() { let keys = redis.all_keys().await; assert!(!keys.is_empty(), "operations should have written keys"); let allowed = [ - "rt", "rv", "rp", "sess", "sd", "lf", "otp", "resend", "wst", "pr", "prv", "inv", "prt", - "prp", "psess", "psd", + "rt", "rv", "ep", "pep", "rp", "cf", "fam", "sess", "sd", "lf", "otp", "resend", "wst", + "pr", "prv", "inv", "prt", "prp", "pcf", "pfam", "psess", "psd", ]; for key in &keys { // Namespaced under the configured prefix, applied in exactly one place. diff --git a/crates/bymax-auth-types/src/claims.rs b/crates/bymax-auth-types/src/claims.rs index 00278ab..47847e5 100644 --- a/crates/bymax-auth-types/src/claims.rs +++ b/crates/bymax-auth-types/src/claims.rs @@ -89,6 +89,14 @@ pub struct DashboardClaims { /// Expiry (seconds since the Unix epoch). #[cfg_attr(feature = "ts-export", ts(type = "number"))] pub exp: i64, + /// The user's token **epoch** at issuance — a per-user generation counter the server bumps + /// to invalidate every outstanding access token at once (a password reset or a full + /// sign-out-everywhere). Verification rejects the token when its epoch is below the user's + /// current stored epoch. Defaults to `0` on a legacy token that predates the field, which is + /// never rejected while the stored epoch is also `0` (the mechanism is inert until a bump). + #[serde(default)] + #[cfg_attr(feature = "ts-export", ts(type = "number"))] + pub epoch: u64, } /// Access token for platform admins — no `tenantId`. The TypeScript counterpart is @@ -120,6 +128,12 @@ pub struct PlatformClaims { /// Expiry (seconds since the Unix epoch). #[cfg_attr(feature = "ts-export", ts(type = "number"))] pub exp: i64, + /// The admin's token **epoch** at issuance — the platform-domain analogue of + /// [`DashboardClaims::epoch`]: a per-admin generation counter the server bumps to invalidate + /// every outstanding platform access token at once. Defaults to `0` on a legacy token. + #[serde(default)] + #[cfg_attr(feature = "ts-export", ts(type = "number"))] + pub epoch: u64, } /// Short-lived token bridging the password step and the MFA challenge. The TypeScript @@ -165,25 +179,41 @@ mod tests { mfa_verified: false, iat: 1_700_000_000, exp: 1_700_000_900, + epoch: 3, } } #[test] fn dashboard_claims_emit_the_exact_wire_field_names() { // The discriminator is `type`, the tenant/MFA fields are camelCase, and BOTH - // mfaEnabled and mfaVerified are present — the byte-level nest-auth contract. + // mfaEnabled and mfaVerified are present — the byte-level nest-auth contract. The + // per-user token epoch rides along as a plain `epoch` number. let json = serde_json::to_value(dashboard_claims()).unwrap_or_default(); assert_eq!(json["type"], "dashboard"); assert_eq!(json["tenantId"], "t_1"); assert_eq!(json["mfaEnabled"], true); assert_eq!(json["mfaVerified"], false); assert_eq!(json["status"], "ACTIVE"); + assert_eq!(json["epoch"], 3); assert!( json.get("token_type").is_none(), "raw field name must not leak" ); } + #[test] + fn a_missing_epoch_deserializes_to_zero() { + // A legacy token that predates the epoch field must still deserialize, defaulting the + // epoch to 0 so the mechanism stays inert for it rather than failing the parse. + let legacy = serde_json::json!({ + "sub": "u_1", "jti": "jti-1", "tenantId": "t_1", "role": "member", + "type": "dashboard", "status": "ACTIVE", "mfaEnabled": false, + "mfaVerified": false, "iat": 1, "exp": 2 + }); + let parsed: Result = serde_json::from_value(legacy); + assert!(matches!(parsed, Ok(claims) if claims.epoch == 0)); + } + #[test] fn platform_claims_have_no_tenant_id() { // Platform tokens never carry a tenant scope; the field is absent by type. @@ -196,6 +226,7 @@ mod tests { mfa_verified: false, iat: 1, exp: 2, + epoch: 0, }; let json = serde_json::to_value(claims).unwrap_or_default(); assert_eq!(json["type"], "platform"); diff --git a/packages/rust-auth/src/shared/jwt-payload.types.ts b/packages/rust-auth/src/shared/jwt-payload.types.ts index d3be6ba..75a4f3d 100644 --- a/packages/rust-auth/src/shared/jwt-payload.types.ts +++ b/packages/rust-auth/src/shared/jwt-payload.types.ts @@ -44,7 +44,15 @@ iat: number, /** * Expiry (seconds since the Unix epoch). */ -exp: number, }; +exp: number, +/** + * The user's token **epoch** at issuance — a per-user generation counter the server bumps + * to invalidate every outstanding access token at once (a password reset or a full + * sign-out-everywhere). Verification rejects the token when its epoch is below the user's + * current stored epoch. Defaults to `0` on a legacy token that predates the field, which is + * never rejected while the stored epoch is also `0` (the mechanism is inert until a bump). + */ +epoch: number, }; /** * Discriminator value for a dashboard access token. Serializes to `"dashboard"`. @@ -128,7 +136,13 @@ iat: number, /** * Expiry (seconds since the Unix epoch). */ -exp: number, }; +exp: number, +/** + * The admin's token **epoch** at issuance — the platform-domain analogue of + * [`DashboardClaims::epoch`]: a per-admin generation counter the server bumps to invalidate + * every outstanding platform access token at once. Defaults to `0` on a legacy token. + */ +epoch: number, }; /** * Discriminator value for a platform access token. Serializes to `"platform"`.