From ed3bda56057e3f0c4cdb75fe3bc426f7d6fd7b12 Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:01:39 -0300 Subject: [PATCH 001/122] feat(core,redis): add refresh-token reuse detection with family revocation Refresh rotation used a 30s grace window but never detected the replay of an already-consumed token: an in-grace replay forked a parallel session and a post-grace replay was rejected as a plain invalid, so a stolen refresh token stayed usable and theft was never surfaced (OWASP rotation with automatic reuse detection was not implemented). Track a refresh-token family (login lineage) minted at login and inherited unchanged across every rotation. On rotation the store now plants a consumed-token marker (`cf:`) that outlives the grace pointer and moves the hash in a family index (`fam:`). Presenting a consumed token past its grace window is therefore caught as a reuse: the store returns RotateOutcome::Reused carrying the compromised family, and the token manager revokes the entire family (every live descendant) before rejecting, forcing re-authentication. The atomic-rotation + grace-window concurrency semantics are preserved: an in-grace replay still recovers, and only a post-grace replay trips reuse. Legacy records with no family are handled gracefully (no marker, no index, never a reuse target). - SessionRecord gains `family_id` (serde-default, omitted when empty) - RotateOutcome gains `Reused(family_id)`; SessionStore gains `revoke_family` - refresh_rotate.lua plants the consumed marker + family index and detects reuse; new revoke_family.lua deletes a lineage atomically - in-memory test store mirrors the semantics for the core unit tier Tests prove: post-grace reuse revokes the live descendant (dashboard, platform, in-memory and Redis tiers); an in-grace concurrent rotation still succeeds; and a legacy no-family session never trips reuse. Coverage stays 100% on every changed file; clippy, cargo-deny, and the security-invariant gate pass. --- crates/bymax-auth-axum/tests/common/mod.rs | 7 + .../bymax-auth-core/src/services/auth/mod.rs | 4 + .../src/services/auth/password_reset.rs | 1 + .../src/services/mfa/challenge.rs | 2 + .../bymax-auth-core/src/services/platform.rs | 26 ++-- .../bymax-auth-core/src/services/session.rs | 14 ++ .../src/services/token_manager.rs | 115 +++++++++++++++ crates/bymax-auth-core/src/testing/mod.rs | 136 ++++++++++++++++++ crates/bymax-auth-core/src/traits/store.rs | 44 +++++- crates/bymax-auth-redis/src/keys.rs | 16 +++ .../src/lua/refresh_rotate.lua | 51 +++++-- .../src/lua/revoke_family.lua | 40 ++++++ crates/bymax-auth-redis/src/script.rs | 5 + crates/bymax-auth-redis/src/stores/session.rs | 108 ++++++++++++-- crates/bymax-auth-redis/tests/redis_stores.rs | 111 +++++++++++++- 15 files changed, 636 insertions(+), 44 deletions(-) create mode 100644 crates/bymax-auth-redis/src/lua/revoke_family.lua diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 898278f..00f9356 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -358,6 +358,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, 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..a065932 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -536,6 +536,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 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/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..f245a36 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, @@ -1134,6 +1142,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..d47114e 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -182,6 +182,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( @@ -274,6 +277,16 @@ impl TokenManagerService { 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), } } @@ -332,6 +345,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( @@ -423,6 +438,14 @@ impl TokenManagerService { 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), } } @@ -678,6 +701,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 +721,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 +739,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 +862,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 diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index acc3219..e84dc7d 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -296,6 +296,14 @@ 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>>, blacklist: Mutex>, otps: Mutex>, resend: Mutex>, @@ -357,6 +365,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 +403,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 +493,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, @@ -993,6 +1053,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 +1064,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 +1136,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..178ab5c 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( @@ -524,6 +545,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 +573,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 +582,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 +663,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-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 6c1136d..464aae4 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -30,6 +30,10 @@ pub enum Prefix { Rv, /// 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 +56,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`). @@ -74,6 +82,8 @@ impl Prefix { Self::Rt => "rt", Self::Rv => "rv", Self::Rp => "rp", + Self::Cf => "cf", + Self::Fam => "fam", Self::Sess => "sess", Self::Sd => "sd", Self::Lf => "lf", @@ -85,6 +95,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", @@ -151,6 +163,8 @@ mod tests { (Prefix::Rt, "auth:rt:abc"), (Prefix::Rv, "auth:rv: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 +176,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..90b9f53 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( @@ -440,6 +512,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, @@ -469,6 +547,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 +586,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..2b4cc04 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,106 @@ 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 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 +534,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", "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. From b64516f14e6cd5a392a045321df3386de2b5502b Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:37:41 -0300 Subject: [PATCH 002/122] feat(core,types): invalidate stateless access tokens on reset via a per-user epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A password reset revoked the refresh sessions but left already-issued, stateless access JWTs valid for the remainder of their (up to 15-minute) lifetime: the jti-blacklist used on logout can only revoke a token you hold, and a reset does not possess the user's active access-token jtis — there is no per-user registry of them. Add a per-user token **epoch** (generation counter): stamped into the Dashboard/Platform claims at issuance (and rotation), read on every verification, and bumped on a password reset. A token stamped below the user's current epoch was issued before the reset and is now rejected on its next request, so a reset takes effect immediately instead of lingering for the access-token lifetime. The refresh sessions are revoked as before. Backward-safe: a legacy token with no epoch claim and a user with no stored epoch both read as 0, so the mechanism is inert (0 < 0 is false) until a first bump — no existing session is locked out. The epoch is server-internal (never surfaced in AuthResult); the edge WASM verifier carries the claim but does not consult it (no store), exactly like the jti-blacklist. - DashboardClaims/PlatformClaims gain `epoch` (serde-default); TS bindings regenerated - SessionStore gains current_epoch/bump_epoch; keys ep/pep; redis + in-memory impls - the dashboard password-reset flow bumps the epoch after revoking sessions Scope note: MFA-state changes and session revoke-all keep their existing "revoke other refresh sessions, current session continues" semantics and do not bump the epoch; the platform epoch mechanism is complete and symmetric, awaiting a platform credential-reset flow to trigger it. Tests prove a pre-reset access token is rejected after the bump (dashboard and platform), that a post-bump token still verifies, that the reset flow advances the epoch, and that a legacy no-epoch token stays valid. Coverage stays 100% on every changed file; clippy, cargo-deny, ts-export staleness, and the security-invariant gate pass. --- bindings/bymax-auth-wasm/src/jwt_edge.rs | 3 + bindings/bymax-auth-wasm/src/lib.rs | 1 + crates/bymax-auth-axum/src/extractors/mfa.rs | 1 + .../src/extractors/platform.rs | 1 + crates/bymax-auth-axum/tests/common/mod.rs | 16 +++ .../src/services/adapter_api.rs | 4 + .../src/services/auth/password_reset.rs | 50 ++++++- .../src/services/auth/session_ops.rs | 1 + .../src/services/mfa/manage.rs | 2 + .../bymax-auth-core/src/services/mfa/setup.rs | 4 + .../bymax-auth-core/src/services/session.rs | 10 ++ .../src/services/token_manager.rs | 130 ++++++++++++++++-- crates/bymax-auth-core/src/testing/mod.rs | 17 +++ crates/bymax-auth-core/src/traits/store.rs | 12 ++ crates/bymax-auth-jwt/src/hs256.rs | 5 +- crates/bymax-auth-jwt/src/keys.rs | 2 + crates/bymax-auth-redis/src/keys.rs | 8 ++ crates/bymax-auth-redis/src/stores/session.rs | 62 +++++++++ crates/bymax-auth-redis/tests/redis_stores.rs | 41 +++++- crates/bymax-auth-types/src/claims.rs | 33 ++++- .../rust-auth/src/shared/jwt-payload.types.ts | 18 ++- 21 files changed, 405 insertions(+), 16 deletions(-) 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 00f9356..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() @@ -380,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/password_reset.rs b/crates/bymax-auth-core/src/services/auth/password_reset.rs index a065932..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; @@ -602,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/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/session.rs b/crates/bymax-auth-core/src/services/session.rs index f245a36..0071a8c 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -1092,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] diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index d47114e..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)?; @@ -252,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(), @@ -271,7 +282,11 @@ 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(), @@ -322,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(), @@ -331,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)?; @@ -411,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(), @@ -431,8 +455,12 @@ 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(), @@ -466,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(), @@ -484,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, } } @@ -501,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) } @@ -654,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(), @@ -668,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, } } } @@ -964,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 @@ -987,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 e84dc7d..fb895aa 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -304,6 +304,9 @@ pub struct InMemoryStores { /// `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>, @@ -527,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] diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 178ab5c..6d1a5b4 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -252,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. 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 464aae4..c9461d3 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -28,6 +28,10 @@ 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`). @@ -81,6 +85,8 @@ 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", @@ -162,6 +168,8 @@ 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"), diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 90b9f53..33be103 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -433,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] @@ -533,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)] diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index 2b4cc04..7eda2f0 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -251,6 +251,43 @@ async fn reuse_past_grace_is_detected_and_revoke_family_kills_the_lineage() { 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 { @@ -534,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", "cf", "fam", "sess", "sd", "lf", "otp", "resend", "wst", "pr", "prv", - "inv", "prt", "prp", "pcf", "pfam", "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"`. From 502d8f3488df7a16624e47552308e76f2b355a84 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:20:08 -0300 Subject: [PATCH 003/122] fix(core): keep the MFA gate alive across refresh-token rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rotation minted access claims with `mfa_enabled: false` hardcoded, and the session record had no field to carry the real value. The MFA gate refuses a token only when `mfa_enabled && !mfa_verified`, so one routine refresh — every ~15 minutes for a normal client — produced a token that cleared every MFA-gated route without the holder ever completing a challenge. The bypass applied to the dashboard and platform planes alike, including the WebSocket ticket endpoint. - add `mfaEnabled` to SessionRecord, written at issuance from the account and inherited unchanged by `identity_record` / `platform_identity_record` so it survives every rotation. Field is `serde(default)`, so sessions written before it existed read back as `false` instead of failing the whole record and logging the live user base out. - read the flag in `rotated_claims` / `rotated_platform_claims` instead of hardcoding it. `mfa_verified` still resets to `false` by design: the second factor must be re-acquired through the challenge, only the enrollment state carries over. - populate the flag on the hook/eviction projections so a consumer's after-session-created payload reports the account's real MFA state. Wire parity: `mfaEnabled` is emitted unconditionally and sits last in the record, matching the field order nest-auth already writes to the shared Redis. Regression tests pin the invariant on both planes, plus the unenrolled mirror (so the flag is read, never hardcoded true) and a legacy-payload read. --- .../bymax-auth-core/src/services/auth/mod.rs | 1 + .../src/services/auth/password_reset.rs | 1 + .../src/services/mfa/challenge.rs | 1 + .../bymax-auth-core/src/services/session.rs | 1 + .../src/services/token_manager.rs | 92 ++++++++++++++++++- crates/bymax-auth-core/src/testing/mod.rs | 1 + crates/bymax-auth-core/src/traits/store.rs | 32 +++++++ crates/bymax-auth-redis/src/stores/session.rs | 1 + crates/bymax-auth-redis/tests/redis_stores.rs | 1 + 9 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index f32a494..65d8bcb 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -235,6 +235,7 @@ impl AuthEngine { device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: result.user.mfa_enabled, }; 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..fc64261 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -536,6 +536,7 @@ mod tests { device: "Chrome".to_owned(), ip: "1.2.3.4".to_owned(), created_at: time::OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, }; assert!( h.stores diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index eb0d643..733da8a 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -299,6 +299,7 @@ impl MfaService { device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: safe.mfa_enabled, }; let hook_ctx: HookContext = self.hook_context(&safe.id, email, ip, user_agent); self.sessions diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 977c8f9..a00827f 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, + mfa_enabled: 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..71f94e5 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -182,6 +182,7 @@ impl TokenManagerService { device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: user.mfa_enabled, }; self.session_store .create_session( @@ -332,6 +333,7 @@ impl TokenManagerService { device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: admin.mfa_enabled, }; self.session_store .create_session( @@ -447,8 +449,8 @@ impl TokenManagerService { } /// 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`. + /// rotation, `mfa_verified` is dropped (re-acquired only via the MFA challenge) while + /// `mfa_enabled` is carried over from the stored record; the claims carry no `tenant_id`. #[cfg(feature = "platform")] fn rotated_platform_claims(&self, record: &SessionRecord) -> PlatformClaims { let now = now_unix(); @@ -457,7 +459,7 @@ impl TokenManagerService { jti: new_uuid_v4(), role: record.role.clone(), token_type: PlatformType::Platform, - mfa_enabled: false, + mfa_enabled: record.mfa_enabled, mfa_verified: false, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), @@ -632,6 +634,11 @@ impl TokenManagerService { /// `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. + /// + /// `mfa_enabled` is carried over from the stored record rather than reset: the MFA gate + /// refuses a token only when `mfa_enabled && !mfa_verified`, so minting `false` here + /// would let one routine refresh turn an enrolled account's token into one that clears + /// every MFA-gated route without ever completing a challenge. fn rotated_claims(&self, record: &SessionRecord) -> DashboardClaims { let now = now_unix(); DashboardClaims { @@ -641,7 +648,7 @@ impl TokenManagerService { role: record.role.clone(), token_type: DashboardType::Dashboard, status: String::new(), - mfa_enabled: false, + mfa_enabled: record.mfa_enabled, mfa_verified: false, iat: now, exp: now.saturating_add(self.access_ttl.as_secs().min(i64::MAX as u64) as i64), @@ -678,6 +685,7 @@ fn identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> SessionR device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: seed.mfa_enabled, } } @@ -695,6 +703,7 @@ fn platform_identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: seed.mfa_enabled, } } @@ -711,6 +720,7 @@ fn placeholder_record(ip: &str, user_agent: &str) -> SessionRecord { device, ip: stored_ip, created_at: now_offset(), + mfa_enabled: false, } } @@ -891,6 +901,80 @@ mod tests { } #[cfg(feature = "platform")] + #[tokio::test] + async fn rotation_preserves_mfa_enabled_so_the_gate_survives_a_refresh() { + // The MFA gate refuses a token only when `mfa_enabled && !mfa_verified`. If rotation + // reset `mfa_enabled` to false, one routine refresh (every ~15 min) would mint a token + // that clears every MFA-gated route without the holder ever completing a challenge — + // a silent bypass for an enrolled account. The flag must survive rotation; the + // `mfa_verified` proof must NOT, so the second factor is re-acquired via the challenge. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store); + let enrolled = SafeAuthUser { + mfa_enabled: true, + ..user() + }; + + let issued = svc + .issue_tokens(&enrolled, "10.0.0.1", "agent/1.0", true) + .await; + let Ok(issued) = issued else { return }; + + let rotated = svc + .reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await; + let Ok(rotated) = rotated else { return }; + + let claims = svc.verify_access(&rotated.access_token).await; + assert!(matches!(&claims, Ok(c) if c.mfa_enabled && !c.mfa_verified)); + } + + #[tokio::test] + async fn rotation_keeps_mfa_enabled_false_for_an_unenrolled_user() { + // The mirror of the test above: carrying the flag over must read it from the stored + // record, not hardcode `true`. An account without MFA stays unenrolled across rotation. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store); + + let issued = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await; + let Ok(issued) = issued else { return }; + + let rotated = svc + .reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await; + let Ok(rotated) = rotated else { return }; + + let claims = svc.verify_access(&rotated.access_token).await; + assert!(matches!(&claims, Ok(c) if !c.mfa_enabled)); + } + + #[tokio::test] + async fn platform_rotation_preserves_mfa_enabled() { + // Same invariant on the platform plane, where the blast radius is larger: a rotated + // operator token must keep demanding the second factor. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store); + let enrolled = SafeAuthPlatformUser { + mfa_enabled: true, + ..platform_admin() + }; + + let issued = svc + .issue_platform_tokens(&enrolled, "10.0.0.1", "agent/1.0", true) + .await; + let Ok(issued) = issued else { return }; + + let rotated = svc + .reissue_platform_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await; + let Ok(rotated) = rotated else { return }; + + let claims = svc.verify_platform_access(&rotated.access_token).await; + assert!(matches!(&claims, Ok(c) if c.mfa_enabled && !c.mfa_verified)); + } + fn platform_admin() -> SafeAuthPlatformUser { SafeAuthPlatformUser { id: "p1".to_owned(), diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index acc3219..e4bdcf7 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1000,6 +1000,7 @@ mod tests { device: "Chrome".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, } } diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 7288449..dd56bf3 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -64,6 +64,20 @@ pub struct SessionRecord { /// Session creation time. #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, + /// Whether MFA was enabled on the account when the session was created. + /// + /// Persisted so a rotation can propagate it into the rotated access claims. Without + /// it every rotation would mint `mfa_enabled: false`, and since the MFA gate only + /// refuses a token whose claims say `mfa_enabled && !mfa_verified`, one routine + /// refresh would silently disable second-factor enforcement for an enrolled account. + /// + /// `mfa_verified` is deliberately NOT stored: it must stay `false` in a rotated token + /// so clearing the second factor always goes back through the MFA challenge. + /// + /// `default` so a session written before this field existed deserializes as `false` + /// rather than failing the whole record — the same defensive read nest-auth performs. + #[serde(default)] + pub mfa_enabled: bool, } /// One session's display detail, returned by [`SessionStore::list_sessions`]. The @@ -524,6 +538,7 @@ mod tests { device: "Chrome on macOS".into(), ip: "203.0.113.4".into(), created_at: OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, } } @@ -534,6 +549,19 @@ mod tests { assert_eq!(OtpPurpose::EmailVerification.as_str(), "email_verification"); } + #[test] + fn session_record_reads_a_legacy_payload_without_the_mfa_flag() { + // Sessions written before `mfaEnabled` existed are still live in Redis (refresh TTL is + // days). Deserialization must default them to `false` rather than fail: a hard error + // would reject every in-flight session at once and log the whole user base out. + let legacy = r#"{"userId":"u1","tenantId":"t1","role":"MEMBER", + "device":"Chrome","ip":"203.0.113.4","createdAt":"1970-01-01T00:00:00Z"}"#; + + let parsed: Result = serde_json::from_str(legacy); + + assert!(matches!(parsed, Ok(ref r) if !r.mfa_enabled && r.user_id == "u1")); + } + #[test] fn session_kind_variants_are_distinct() { // The kind selects the prefix pair; the two domains must never compare equal. @@ -558,6 +586,10 @@ mod tests { }; assert!(!serde_json::to_string(&platform)?.contains("tenantId")); + // The MFA flag is always emitted (nest-auth writes it unconditionally), so the two + // implementations produce the same key set for the same session. + assert!(json.contains("\"mfaEnabled\":false")); + // Round-trip parity. let back: SessionRecord = serde_json::from_str(&json)?; assert_eq!(back, dashboard); diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 4ae499e..c95a6f3 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -469,6 +469,7 @@ mod tests { device: "Chrome".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, } } diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index dec63cb..c4c40b8 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -40,6 +40,7 @@ fn record(user: &str) -> SessionRecord { device: "Chrome on macOS".to_owned(), ip: "203.0.113.4".to_owned(), created_at: OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, } } From 4986bb4503576a7838140f7d6217a7da7151731a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:21:04 -0300 Subject: [PATCH 004/122] fix(nextjs): stop forged background headers from bypassing the proxy gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy returned NextResponse.next() for any request it classified as a framework background fetch — on the unauthenticated path and, via redirectToLogin, on the blocked-status and RBAC-forbidden paths too. But the signals that classify a request as background (RSC, Next-Router-Prefetch, Next-Router-State-Tree, Purpose, Sec-Purpose) are plain request headers and therefore client-forgeable. Sending `RSC: 1` was enough to walk past the middleware's auth, account-status, and RBAC checks on any protected route and have the page's server components execute for an unauthenticated caller. - an unauthenticated background request now gets a bare 401 with `Cache-Control: no-store, no-cache`, matching nest-auth byte for byte. The redirect is still avoided (it would poison the client router cache), but the request is refused rather than admitted. - redirectToLogin no longer short-circuits on background requests, so blocked and forbidden callers get their refusal whatever headers they send. - detect Next-Router-State-Tree as a background signal, which nest-auth already treats as one. It carries a serialised tree, so presence is the signal. The classifier's doc now states the result is a hint about response shape only, never grounds to admit a request. Tests pin each path, including the headline case: a forged RSC header on a protected route with no session receives 401, not next(). Reverting the fix fails exactly these tests. --- packages/rust-auth/src/nextjs/proxy.ts | 55 ++++++-- packages/rust-auth/tests/nextjs.test.ts | 162 ++++++++++++++++++++++-- 2 files changed, 195 insertions(+), 22 deletions(-) diff --git a/packages/rust-auth/src/nextjs/proxy.ts b/packages/rust-auth/src/nextjs/proxy.ts index 17b1463..4ab54a6 100644 --- a/packages/rust-auth/src/nextjs/proxy.ts +++ b/packages/rust-auth/src/nextjs/proxy.ts @@ -47,6 +47,13 @@ const DEFAULT_LOGIN_PATH = "/login"; /** The default ceiling on redirect bounces before the proxy stops trying to recover. */ const DEFAULT_MAX_REDIRECTS = 3; +/** + * The `Cache-Control` value on every refusal the proxy emits for a background request. It + * stops a CDN or the client router cache from storing the 401 and replaying it to a later, + * genuinely authenticated visitor. + */ +const NO_STORE_CACHE_CONTROL = "no-store, no-cache"; + /** A single RBAC rule: the roles permitted under a path prefix. */ export interface AuthProxyRoleRule { /** The path prefix this rule guards (e.g. `/admin`). */ @@ -184,10 +191,14 @@ function handleUnauthenticated( request: NextRequest, config: ResolvedAuthProxyConfig, ): NextResponse { - // A background (RSC/prefetch) request must never be redirected — that would poison the - // router cache with a login document. Let it through unauthenticated instead. + // A background (RSC/prefetch/state-tree) request must never be redirected — that would + // poison the router cache with a login document. It must never be let through either: the + // headers that mark a request as background are client-forgeable, so `NextResponse.next()` + // here would let anyone bypass this gate by sending `RSC: 1` and have the protected page's + // server components render for an unauthenticated caller. Refuse with a bare 401 instead; + // the client router falls back to a full navigation, where a redirect is appropriate. if (isBackgroundRequest(request)) { - return NextResponse.next(); + return backgroundUnauthorized(); } const bounces = redirectCount(request); @@ -206,15 +217,30 @@ function handleUnauthenticated( return redirectToLogin(request, config.loginPath, "expired"); } -/** Build a sign-in redirect carrying a `reason` and a same-origin `redirectTo`; never a token. */ +/** + * The refusal returned to an unauthenticated background request: a bare 401 that is never + * cached. Returning `NextResponse.next()` here would turn the forgeable `RSC` / + * `Next-Router-Prefetch` / `Next-Router-State-Tree` headers into an auth bypass. + */ +function backgroundUnauthorized(): NextResponse { + return new NextResponse(null, { + status: 401, + headers: { "Cache-Control": NO_STORE_CACHE_CONTROL }, + }); +} + +/** + * Build a sign-in redirect carrying a `reason` and a same-origin `redirectTo`; never a token. + * + * This applies to background requests too. A blocked account or a failed RBAC check must + * refuse the request whatever headers it carries — short-circuiting to `NextResponse.next()` + * for a background request would let a forged `RSC: 1` header render the guarded page. + */ function redirectToLogin( request: NextRequest, loginPath: string, reason: string, ): NextResponse { - if (isBackgroundRequest(request)) { - return NextResponse.next(); - } const url = new URL(loginPath, request.nextUrl.origin); url.searchParams.set("reason", reason); url.searchParams.set( @@ -377,16 +403,25 @@ export function parseSetCookieHeader(raw: string): ParsedSetCookie { } /** - * Whether a request is a framework background fetch (an RSC payload or a prefetch) that must - * not be redirected, since redirecting it would corrupt the client router cache. + * Whether a request is a framework background fetch (an RSC payload, a router state-tree + * fetch, or a prefetch) that must not be redirected, since redirecting it would corrupt the + * client router cache. + * + * Every signal read here is a plain request header and therefore client-forgeable, so a + * `true` result is only ever a hint about response SHAPE (401 instead of a redirect) — never + * a reason to admit a request. Callers must still refuse an unauthenticated caller. * * @param request - The incoming request. - * @returns `true` for RSC/prefetch background requests. + * @returns `true` for RSC/state-tree/prefetch background requests. */ export function isBackgroundRequest(request: NextRequest): boolean { const headers = request.headers; if (headers.get("RSC") === "1") return true; if (headers.get("Next-Router-Prefetch") === "1") return true; + // The router state-tree header carries a serialised tree, not a flag, so any non-empty + // value marks the request as a partial-render fetch. + const stateTree = headers.get("Next-Router-State-Tree"); + if (stateTree !== null && stateTree.length > 0) return true; const purpose = headers.get("Purpose") ?? headers.get("X-Purpose") ?? headers.get("X-Moz"); if (purpose === "prefetch") return true; const secPurpose = headers.get("Sec-Purpose"); diff --git a/packages/rust-auth/tests/nextjs.test.ts b/packages/rust-auth/tests/nextjs.test.ts index d707188..cd434b2 100644 --- a/packages/rust-auth/tests/nextjs.test.ts +++ b/packages/rust-auth/tests/nextjs.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { NextRequest } from "next/server"; +import type { NextResponse } from "next/server"; import { describe, expect, it } from "vitest"; import { @@ -14,7 +15,7 @@ import { isTokenExpired, verifyJwtToken, } from "../src/nextjs/jwt"; -import { createAuthProxy, resolveSafeDestination } from "../src/nextjs/proxy"; +import { createAuthProxy, isBackgroundRequest, resolveSafeDestination } from "../src/nextjs/proxy"; /** The shared HS256 secret used to sign and verify test tokens (server == edge). */ const SECRET = "an-edge-test-hs256-secret-key-0123456789"; @@ -80,12 +81,35 @@ function protectedRequest(token: string): NextRequest { }); } +/** + * Build a GET request to a protected path carrying the given background headers (and, + * optionally, an access cookie) — the shape a forged RSC/prefetch/state-tree probe takes. + */ +function backgroundRequest(background: Record, token = ""): NextRequest { + const cookie: Record = + token.length > 0 ? { cookie: `access_token=${token}` } : {}; + return new NextRequest("https://app.test/dashboard", { + headers: { ...background, ...cookie }, + }); +} + /** Flip the final signature character so the signature is wrong but the framing intact. */ function tamperSignature(token: string): string { const last = token.slice(-1); return `${token.slice(0, -1)}${last === "A" ? "B" : "A"}`; } +/** Whether a proxy response forwarded the UI-only x-user-id header (i.e. admitted). */ +function admittedUserId(response: { headers: Headers }): string | null { + return response.headers.get("x-middleware-request-x-user-id"); +} + +/** Whether a proxy response redirected to the sign-in path (i.e. rejected). */ +function redirectedToLogin(response: { headers: Headers }): boolean { + const location = response.headers.get("location"); + return location !== null && location.includes("/login"); +} + describe("verifyJwtToken — real WASM HS256 parity (server == edge)", () => { it("verifies a backend-signed token under the matching secret and exposes its claims", async () => { const result = await verifyJwtToken(dashboardToken(), SECRET); @@ -172,17 +196,6 @@ describe("server-only enforcement", () => { }); describe("createAuthProxy — fail-closed verification (S1) and token-type assertion (S2)", () => { - /** Whether a proxy response forwarded the UI-only x-user-id header (i.e. admitted). */ - function admittedUserId(response: { headers: Headers }): string | null { - return response.headers.get("x-middleware-request-x-user-id"); - } - - /** Whether a proxy response redirected to the sign-in path (i.e. rejected). */ - function redirectedToLogin(response: { headers: Headers }): boolean { - const location = response.headers.get("location"); - return location !== null && location.includes("/login"); - } - it("admits a validly-signed access token when a non-empty secret is configured", async () => { // The happy path: an authoritative HS256 verification with the matching secret admits the // request and forwards the user id header; no sign-in redirect is issued. @@ -223,3 +236,128 @@ describe("createAuthProxy — fail-closed verification (S1) and token-type asser expect(admittedUserId(response)).toBeNull(); }); }); + +describe("createAuthProxy — forged background headers are not an auth bypass (RC10)", () => { + /** + * Whether a response is the bare, uncacheable 401 the proxy owes an unauthenticated + * background request — the nest-auth parity shape (`no-store, no-cache`). + */ + function isBackgroundRefusal(response: NextResponse): boolean { + return ( + response.status === 401 && response.headers.get("cache-control") === "no-store, no-cache" + ); + } + + it("answers a forged `RSC: 1` probe on a protected route with 401, never a pass-through", async () => { + // The core bypass: `RSC` is a plain request header, so an attacker can set it on a normal + // navigation. If the proxy answered `NextResponse.next()` the protected page's server + // components would render for a caller holding no session at all. It must refuse instead. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const response = await proxy(backgroundRequest({ RSC: "1" })); + + expect(isBackgroundRefusal(response)).toBe(true); + expect(admittedUserId(response)).toBeNull(); + // Not a redirect either: a redirected RSC fetch would poison the router cache with the + // login document, which is why the refusal is a status code rather than a `Location`. + expect(redirectedToLogin(response)).toBe(false); + }); + + it("answers a forged `Next-Router-Prefetch: 1` probe with the same 401", async () => { + // The prefetch signal is equally forgeable, so it must reach the same refusal — closing + // `RSC` alone would leave an identical bypass one header name away. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const response = await proxy(backgroundRequest({ "Next-Router-Prefetch": "1" })); + + expect(isBackgroundRefusal(response)).toBe(true); + expect(admittedUserId(response)).toBeNull(); + }); + + it("answers a forged `Next-Router-State-Tree` probe with the same 401", async () => { + // The partial-render signal carries a serialised tree rather than a flag, so detection + // keys off any non-empty value. Without it this variant would miss the background branch. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const response = await proxy(backgroundRequest({ "Next-Router-State-Tree": '["",{}]' })); + + expect(isBackgroundRefusal(response)).toBe(true); + expect(admittedUserId(response)).toBeNull(); + }); + + it("refuses a forged background probe even when a `has_session` cookie is present", async () => { + // `has_session` is a non-HttpOnly UI hint and is likewise forgeable. It routes a normal + // navigation into the silent-refresh redirect, but it must not turn a background request + // into a pass-through — the caller still holds no verifiable access token. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const request = new NextRequest("https://app.test/dashboard", { + headers: { RSC: "1", cookie: "has_session=1" }, + }); + const response = await proxy(request); + + expect(isBackgroundRefusal(response)).toBe(true); + expect(admittedUserId(response)).toBeNull(); + }); + + it("refuses a blocked account on a background request instead of passing it through", async () => { + // The account-status gate must not be escapable by adding a header: a SUSPENDED user who + // holds a genuinely-signed token would otherwise render the guarded page by sending + // `RSC: 1`. The blocked refusal is returned whatever the request shape. + const { proxy } = createAuthProxy({ + accessTokenSecret: SECRET, + blockedStatuses: ["SUSPENDED"], + }); + const response = await proxy( + backgroundRequest({ RSC: "1" }, dashboardToken({ status: "SUSPENDED" })), + ); + + expect(response.status).not.toBe(200); + expect(admittedUserId(response)).toBeNull(); + expect(response.headers.get("location")).toContain("reason=blocked"); + }); + + it("refuses an RBAC-forbidden role on a background request instead of passing it through", async () => { + // Same bypass one branch further along: a `member` token on an admin-only prefix must be + // refused even when the request claims to be a background fetch. Passing it through would + // hand a role-gated page to a user the rule denies. + const { proxy } = createAuthProxy({ + accessTokenSecret: SECRET, + roleRules: [{ pathPrefix: "/dashboard", roles: ["admin"] }], + }); + const response = await proxy(backgroundRequest({ RSC: "1" }, dashboardToken())); + + expect(response.status).not.toBe(200); + expect(admittedUserId(response)).toBeNull(); + expect(response.headers.get("location")).toContain("reason=forbidden"); + }); + + it("still admits a genuine background request that carries a valid session", async () => { + // The regression guard for the fix: hardening the background branch must not break real + // prefetching. An authenticated RSC fetch is admitted with its user headers as before. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const response = await proxy(backgroundRequest({ RSC: "1" }, dashboardToken())); + + expect(response.status).toBe(200); + expect(admittedUserId(response)).toBe("u_1"); + }); +}); + +describe("isBackgroundRequest — signal coverage", () => { + it("detects the RSC, prefetch, state-tree, and Sec-Purpose signals", () => { + // Each header the Next router uses for a non-navigational fetch must be recognised, so + // the proxy answers every one of them with a 401 rather than a cache-poisoning redirect. + expect(isBackgroundRequest(backgroundRequest({ RSC: "1" }))).toBe(true); + expect(isBackgroundRequest(backgroundRequest({ "Next-Router-Prefetch": "1" }))).toBe(true); + expect(isBackgroundRequest(backgroundRequest({ "Next-Router-State-Tree": '["",{}]' }))).toBe( + true, + ); + expect(isBackgroundRequest(backgroundRequest({ Purpose: "prefetch" }))).toBe(true); + expect(isBackgroundRequest(backgroundRequest({ "Sec-Purpose": "prefetch;prerender" }))).toBe( + true, + ); + }); + + it("treats an empty state-tree header and a plain navigation as foreground", () => { + // An empty header value is not a state tree, so it must not flip the branch; a request + // with no signal at all is a top-level navigation that still deserves a redirect. + expect(isBackgroundRequest(backgroundRequest({ "Next-Router-State-Tree": "" }))).toBe(false); + expect(isBackgroundRequest(backgroundRequest({}))).toBe(false); + }); +}); From 61dbfc91d0f9b4aeeb8062feb3ca3e4b1ae52541 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:26:52 -0300 Subject: [PATCH 005/122] fix(core): canonicalize email at every credential entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brute-force lockout identifier is an HMAC of the email, and login, register, password reset, email verification, and platform login all derived it from the caller's raw input. Each casing of one address therefore had its own failure budget while resolving the same account, so rotating the spelling — user@x.com, USER@X.COM, User@X.Com — handed an attacker an unbounded supply of attempts and the lockout never fired. The same split let a single account own several concurrent OTP, cooldown, and reset records. Add a normalize_email helper (trim, then lowercase) and apply it at the engine boundary of every flow that accepts an address, before any identifier, lookup, or stored record is derived from it. Full Unicode lowercasing, not ASCII-only: nest-auth canonicalizes with JavaScript's Unicode-aware toLowerCase() and the two backends share one Redis, so an ASCII-only fold would make a non-ASCII address canonicalize differently per backend and split its keyspace. The invitation flow, which already normalized inline with to_ascii_lowercase, now routes through the same helper so that divergence cannot reappear. Password reset normalizes on both the initiate and confirm legs: the confirm step compares the supplied address against the one stored in the reset context, so canonicalizing only one side would break every reset. Tests spend the whole lockout budget across five different casings and assert the next attempt is already locked, and pin that an account still authenticates when the caller types it in a different case. --- crates/bymax-auth-core/src/lib.rs | 3 + crates/bymax-auth-core/src/normalize.rs | 81 +++++++++++++++++++ .../src/services/auth/email_verification.rs | 9 +++ .../src/services/auth/invitation.rs | 8 +- .../src/services/auth/login.rs | 59 ++++++++++++++ .../src/services/auth/password_reset.rs | 29 +++++++ .../src/services/auth/register.rs | 8 ++ .../bymax-auth-core/src/services/platform.rs | 5 ++ 8 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 crates/bymax-auth-core/src/normalize.rs diff --git a/crates/bymax-auth-core/src/lib.rs b/crates/bymax-auth-core/src/lib.rs index fbd7993..5eef752 100644 --- a/crates/bymax-auth-core/src/lib.rs +++ b/crates/bymax-auth-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod config; pub mod context; pub mod engine; mod error; +mod normalize; #[cfg(feature = "oauth")] pub mod providers; pub mod services; @@ -45,6 +46,8 @@ pub use config::{AuthConfig, Environment}; pub use engine::{AuthEngine, AuthEngineBuilder}; #[doc(inline)] pub use error::{ConfigError, RepositoryError}; +#[doc(inline)] +pub use normalize::normalize_email; #[cfg(feature = "oauth")] #[doc(inline)] pub use providers::GoogleOAuthProvider; diff --git a/crates/bymax-auth-core/src/normalize.rs b/crates/bymax-auth-core/src/normalize.rs new file mode 100644 index 0000000..70ea1f2 --- /dev/null +++ b/crates/bymax-auth-core/src/normalize.rs @@ -0,0 +1,81 @@ +//! Canonicalization of caller-supplied identity input. +//! +//! Every value that becomes a lookup key, a Redis key segment, or a stored identity passes +//! through here first, so the rule lives in exactly one place on this side of the port. + +/// Canonicalize an email address: trim surrounding whitespace, then lowercase. +/// +/// This MUST run at the engine boundary, before the address is used to derive the +/// brute-force identifier, to look a user up, or to key an OTP/reset record. Skipping it +/// reopens the case-rotation bypass: `User@x.com` and `user@x.com` hash to different +/// lockout buckets while resolving the same account, so an attacker rotates the casing to +/// get a fresh failure budget and the lockout never trips. The same split would let one +/// account own several OTP and reset records at once. +/// +/// Full Unicode lowercasing (`to_lowercase`), not `to_ascii_lowercase`: nest-auth uses +/// JavaScript's `toLowerCase()`, which is Unicode-aware, and the two implementations share +/// one Redis. An ASCII-only fold here would make a non-ASCII address canonicalize +/// differently on each backend and split its keyspace. +/// +/// # Examples +/// +/// ``` +/// # use bymax_auth_core::normalize_email; +/// assert_eq!(normalize_email(" USER@Example.COM "), "user@example.com"); +/// ``` +#[must_use] +pub fn normalize_email(email: &str) -> String { + email.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::normalize_email; + + #[test] + fn trims_and_lowercases() { + // The documented canonical case: surrounding whitespace goes, casing folds down, so + // every spelling of one address collapses to a single key. + assert_eq!(normalize_email(" USER@Example.COM "), "user@example.com"); + } + + #[test] + fn is_idempotent() { + // Normalizing an already-canonical address must not change it; otherwise a value + // normalized twice (boundary plus a nested call) would diverge from one normalized once. + let once = normalize_email("user@example.com"); + assert_eq!(normalize_email(&once), once); + } + + #[test] + fn folds_every_casing_to_one_bucket() { + // The security property itself: the case-rotation variants an attacker would cycle + // through to reset a lockout must all produce the same canonical value. + let canonical = normalize_email("user@example.com"); + for variant in ["USER@EXAMPLE.COM", "User@Example.Com", "uSeR@eXaMpLe.cOm"] { + assert_eq!(normalize_email(variant), canonical); + } + } + + #[test] + fn folds_non_ascii_case_like_javascript() { + // Unicode-aware folding, matching nest-auth's `toLowerCase()`. An ASCII-only fold + // would leave these uppercase and split the shared Redis keyspace per backend. + assert_eq!(normalize_email("ÉLÈVE@example.com"), "élève@example.com"); + assert_eq!(normalize_email("ÄÖÜ@example.com"), "äöü@example.com"); + } + + #[test] + fn strips_only_surrounding_whitespace() { + // Interior characters are untouched: trimming is about transport padding, and an + // address is never silently rewritten beyond case and edges. + assert_eq!( + normalize_email("\t user@example.com \n"), + "user@example.com" + ); + assert_eq!( + normalize_email("a.b+tag@example.com"), + "a.b+tag@example.com" + ); + } +} diff --git a/crates/bymax-auth-core/src/services/auth/email_verification.rs b/crates/bymax-auth-core/src/services/auth/email_verification.rs index e09f9c9..e23a3e1 100644 --- a/crates/bymax-auth-core/src/services/auth/email_verification.rs +++ b/crates/bymax-auth-core/src/services/auth/email_verification.rs @@ -9,6 +9,7 @@ use std::time::Instant; use bymax_auth_types::{AuthError, SafeAuthUser}; use crate::engine::AuthEngine; +use crate::normalize::normalize_email; use crate::services::auth::detached::{run_after_email_verified, run_send_verification_email}; use crate::services::auth::{map_repository_error, normalize_anti_enum, spawn_guarded}; use crate::traits::{HookContext, OtpPurpose}; @@ -35,6 +36,10 @@ impl AuthEngine { email: &str, otp: &str, ) -> Result<(), AuthError> { + // Canonicalize so the OTP identifier and the repository lookup agree on one + // spelling; a verification started under one casing must complete under any. + let email = normalize_email(email); + let email = email.as_str(); let identifier = self.hashed_identifier(tenant_id, email); self.otp() .verify(OtpPurpose::EmailVerification, &identifier, otp) @@ -79,6 +84,10 @@ impl AuthEngine { tenant_id: &str, email: &str, ) -> Result<(), AuthError> { + // Canonicalize so the OTP identifier and the repository lookup agree on one + // spelling; a verification started under one casing must complete under any. + let email = normalize_email(email); + let email = email.as_str(); let started = Instant::now(); let identifier = self.hashed_identifier(tenant_id, email); diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 87c3daa..152368c 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -17,6 +17,7 @@ use time::OffsetDateTime; use crate::context::RequestContext; use crate::engine::AuthEngine; +use crate::normalize::normalize_email; use crate::services::auth::detached::run_after_invitation_accepted; use crate::services::auth::{map_repository_error, spawn_guarded}; use crate::traits::{HookContext, InviteData, StoredInvitation}; @@ -67,8 +68,11 @@ impl AuthEngine { tenant_name: Option<&str>, ) -> Result<(), AuthError> { // Normalize the email at the service boundary so the duplicate-guard and the stored - // payload use the same canonical form the accept flow will match against. - let email = email.trim().to_ascii_lowercase(); + // payload use the same canonical form the accept flow will match against. Routed + // through the shared helper: an ASCII-only fold here would canonicalize a non-ASCII + // address differently from nest-auth's Unicode `toLowerCase()` and split the keyspace + // the two backends share. + let email = normalize_email(email); let hierarchy = &self.config().config().roles.hierarchy; // The invited role must be a declared role, and the inviter must hold a role at least diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index 13406bb..3f1220f 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -12,6 +12,7 @@ use bymax_auth_types::{ use crate::context::RequestContext; use crate::engine::AuthEngine; +use crate::normalize::normalize_email; use crate::services::auth::detached::{ run_after_login, run_rehash_password, run_update_last_login, }; @@ -33,6 +34,13 @@ impl AuthEngine { input: LoginInput, ctx: &RequestContext, ) -> Result { + // Canonicalize before ANY email-keyed value below is derived. The lockout identifier + // and the repository lookup must agree on one spelling, otherwise each casing of the + // same address is its own failure budget and the lockout never fires. + let input = LoginInput { + email: normalize_email(&input.email), + ..input + }; let config = self.config().config(); let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let identifier = self.hashed_identifier(&tenant_id, &input.email); @@ -370,6 +378,57 @@ mod tests { )); } + #[tokio::test] + async fn rotating_the_email_case_cannot_reset_the_lockout_budget() { + // The case-rotation bypass. The lockout identifier is an HMAC of the email, so without + // canonicalization each casing is its own counter while every one of them resolves the + // same account — an attacker cycles the spelling and the lockout never fires. Spend the + // five-failure budget across five DIFFERENT casings; the sixth attempt must already be + // locked, proving all five landed in one bucket. + let Some(h) = active_harness(false).await else { return }; + let _ = h.seed(SeedUser::active("case@example.com", "right")).await; + + for spelling in [ + "case@example.com", + "CASE@EXAMPLE.COM", + "Case@Example.Com", + "cAsE@eXaMpLe.CoM", + " case@example.com ", + ] { + let attempt = h.engine.login(login_input(spelling, "wrong"), &ctx()).await; + assert!(matches!(attempt, Err(AuthError::InvalidCredentials))); + } + + let locked = h + .engine + .login(login_input("case@example.com", "right"), &ctx()) + .await; + assert!(matches!( + locked, + Err(AuthError::AccountLocked { + retry_after_seconds: Some(_) + }) + )); + } + + #[tokio::test] + async fn login_resolves_an_account_under_any_casing() { + // The other half of canonicalization: the repository lookup uses the same canonical + // value, so an account seeded lowercase authenticates when the caller types it + // uppercase. Without this the fix would close the bypass by breaking real logins. + let Some(h) = active_harness(false).await else { return }; + let _ = h + .seed(SeedUser::active("mixed@example.com", "s3cret-pass")) + .await; + + let result = h + .engine + .login(login_input(" MiXeD@Example.COM ", "s3cret-pass"), &ctx()) + .await; + + assert!(matches!(&result, Ok(LoginResult::Success(_)))); + } + #[tokio::test] async fn each_blocked_status_maps_to_its_specific_error() { // The status gate runs before the KDF and maps every blocked status to its 403. 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 fc64261..ac4e1e2 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -18,6 +18,7 @@ use bymax_auth_types::{AuthError, AuthUser, SafeAuthUser}; use crate::config::ResetMethod; use crate::engine::AuthEngine; +use crate::normalize::normalize_email; use crate::services::auth::detached::run_after_password_reset; use crate::services::auth::{map_repository_error, normalize_anti_enum, spawn_guarded}; use crate::traits::{HookContext, OtpPurpose, ResetContext}; @@ -113,6 +114,13 @@ impl AuthEngine { /// timing floor is applied before the error is returned, so an infra error stays /// latency-indistinguishable from a normal response. pub async fn initiate_reset(&self, input: ForgotPasswordInput) -> Result<(), AuthError> { + // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, + // and the reset context written for the confirm step) must derive from one + // spelling, or a reset started under one casing cannot be completed under another. + let input = ForgotPasswordInput { + email: normalize_email(&input.email), + ..input + }; let started = Instant::now(); // Run the fallible body, then normalize the elapsed time on EVERY exit — including an // infrastructure error — before returning, so a backend failure cannot be told apart @@ -161,6 +169,13 @@ impl AuthEngine { /// ([`AuthError::OtpInvalid`]/[`AuthError::OtpExpired`]/[`AuthError::OtpMaxAttempts`]) for a /// failed OTP; or a hashing/store [`AuthError`]. pub async fn reset_password(&self, input: ResetPasswordInput) -> Result<(), AuthError> { + // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, + // and the reset context written for the confirm step) must derive from one + // spelling, or a reset started under one casing cannot be completed under another. + let input = ResetPasswordInput { + email: normalize_email(&input.email), + ..input + }; // Classify the proofs: exactly one of token / otp / verified_token must be present. let proof = match ( input.token.as_deref(), @@ -253,6 +268,13 @@ impl AuthEngine { /// Returns the OTP error on a failed verify, [`AuthError::PasswordResetTokenInvalid`] for a /// vanished account, or a store [`AuthError`]. pub async fn verify_reset_otp(&self, input: VerifyResetOtpInput) -> Result { + // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, + // and the reset context written for the confirm step) must derive from one + // spelling, or a reset started under one casing cannot be completed under another. + let input = VerifyResetOtpInput { + email: normalize_email(&input.email), + ..input + }; let identifier = self.hashed_identifier(&input.tenant_id, &input.email); self.otp() .verify(OtpPurpose::PasswordReset, &identifier, &input.otp) @@ -293,6 +315,13 @@ impl AuthEngine { /// floor is applied before the error is returned, so an infra error stays /// latency-indistinguishable from a normal response. pub async fn resend_reset_otp(&self, input: ResendResetOtpInput) -> Result<(), AuthError> { + // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, + // and the reset context written for the confirm step) must derive from one + // spelling, or a reset started under one casing cannot be completed under another. + let input = ResendResetOtpInput { + email: normalize_email(&input.email), + ..input + }; let started = Instant::now(); // Run the fallible body, then normalize the elapsed time on EVERY exit — the cooldown // short-circuit, the success path, and any infrastructure error — so a backend failure diff --git a/crates/bymax-auth-core/src/services/auth/register.rs b/crates/bymax-auth-core/src/services/auth/register.rs index 8337c69..4892a45 100644 --- a/crates/bymax-auth-core/src/services/auth/register.rs +++ b/crates/bymax-auth-core/src/services/auth/register.rs @@ -7,6 +7,7 @@ use bymax_auth_types::{AuthError, AuthUser, CreateUserData, LoginResult, SafeAut use crate::context::RequestContext; use crate::engine::AuthEngine; +use crate::normalize::normalize_email; use crate::services::auth::detached::run_after_register; use crate::services::auth::{RegisterInput, map_repository_error, spawn_guarded}; use crate::traits::{BeforeRegisterResult, HookContext, RegisterAttempt, RegisterOverrides}; @@ -25,6 +26,13 @@ impl AuthEngine { input: RegisterInput, ctx: &RequestContext, ) -> Result { + // Canonicalize before the uniqueness check and the stored identity are derived, so a + // single address cannot be registered once per casing and later resolve to whichever + // row a lookup happens to hit. + let input = RegisterInput { + email: normalize_email(&input.email), + ..input + }; // The resolver, when present, is authoritative — the body tenant is ignored (§24.8). let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let hook_ctx = HookContext::from_request( diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 8fd1cf9..c344e2a 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -29,6 +29,7 @@ use bymax_auth_types::{ SafeAuthPlatformUser, }; +use crate::normalize::normalize_email; use crate::services::auth::{normalize_anti_enum, spawn_guarded}; use crate::services::brute_force::BruteForceService; use crate::services::password::PasswordService; @@ -108,6 +109,10 @@ impl PlatformAuthService { ip: &str, user_agent: &str, ) -> Result { + // Canonicalize before the lockout identifier and the repository lookup are derived, so + // rotating the casing cannot mint a fresh failure budget for the same administrator. + let email = normalize_email(email); + let email = email.as_str(); let identifier = self.brute_force_identifier(email); // Brute-force gate first, so an already-locked account never increments again. From 35aa4401726e69fd57dd6bdd998deb740a08380a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:32:04 -0300 Subject: [PATCH 006/122] fix(config): derive the identifier key exactly as nest-auth does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two backends HMAC the same Redis identifiers with a key each derives from the JWT secret, but they derived it differently, and either difference alone was enough to make the keys disagree: - this side hashed `label || secret` with no separator, leaving the preimage ambiguous, while nest-auth hashes `label + ":" + secret`. - this side keyed the HMAC with the raw 32-byte digest, while nest-auth keys it with the 64-character hex TEXT of that digest. The result was that every keyed identifier landed in a different Redis slot per backend: brute-force lockout, OTP, resend cooldown, MFA setup, and the anti-replay record. On a shared Redis a lockout accrued through one backend was invisible to the other, so an attacker could halve the effective attempt budget by alternating which backend they hit. Adopt nest-auth's derivation verbatim. It is the published one (npm v1.0.11), so changing it there instead would have briefly cleared every in-flight lockout on live deployments — a worse trade for no cryptographic gain, since a hex-text key and a raw-byte key are equally sound. The separator is kept because the domain separation it provides is real. The key is written straight into a fixed-size buffer, so no heap copy of the key material outlives the derivation; the secret buffer and the intermediate digest are both zeroized. Both repos now carry the same known-answer vectors — a fixed secret, its derived key, and one identifier — so a drift on either side turns exactly one suite red instead of surfacing later as sessions and lockouts that silently miss each other. --- crates/bymax-auth-core/src/config/validate.rs | 94 ++++++++++++++++--- crates/bymax-auth-core/src/engine/builder.rs | 2 +- .../bymax-auth-core/src/services/mfa/mod.rs | 4 +- .../bymax-auth-core/src/services/mfa/tests.rs | 2 +- .../bymax-auth-core/src/services/platform.rs | 4 +- .../src/services/token_manager.rs | 8 +- .../bymax-auth-core/tests/engine_assembly.rs | 2 +- 7 files changed, 93 insertions(+), 23 deletions(-) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index feda183..dd35eb5 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -31,7 +31,7 @@ pub struct ResolvedConfig { config: AuthConfig, environment: Environment, secure_cookies: bool, - hmac_key: SecretBox<[u8; 32]>, + hmac_key: SecretBox<[u8; 64]>, } impl ResolvedConfig { @@ -66,11 +66,12 @@ impl ResolvedConfig { self.secure_cookies } - /// The derived identifier-hashing key (`SHA-256(label || jwt.secret)`), used to HMAC - /// low-entropy Redis identifiers so the signing key and the identifier-hashing key are - /// cryptographically independent. + /// The derived identifier-hashing key — the ASCII hex of `SHA-256("{label}:{jwt.secret}")` + /// — used to HMAC low-entropy Redis identifiers so the signing key and the + /// identifier-hashing key are cryptographically independent. See [`derive_hmac_key`] for + /// why the encoding is part of the contract rather than an implementation detail. #[must_use] - pub fn hmac_key(&self) -> &[u8; 32] { + pub fn hmac_key(&self) -> &[u8; 64] { self.hmac_key.expose_secret() } @@ -112,13 +113,39 @@ impl ResolvedConfig { } } -/// Derive the identifier-hashing key as `SHA-256(label || secret)`. The temporary buffer -/// holding the raw secret bytes is zeroized on drop so it does not linger in freed memory. -fn derive_hmac_key(secret: &str) -> SecretBox<[u8; 32]> { - let mut input = Zeroizing::new(Vec::with_capacity(HMAC_KEY_LABEL.len() + secret.len())); +/// Lowercase hexadecimal alphabet, indexed by nibble value. +const HEX_ALPHABET: &[u8; 16] = b"0123456789abcdef"; + +/// Derive the identifier-hashing key as the lowercase hex encoding of +/// `SHA-256("{label}:{secret}")`, in its 64-byte ASCII form. +/// +/// Two details are load-bearing and must not drift, because nest-auth derives the same key +/// and the two backends key the same Redis identifiers with it: +/// +/// - the `:` between label and secret is explicit domain separation. Concatenating them +/// directly makes the preimage ambiguous, so a different label/secret split could produce +/// the same key. +/// - the key is the **hex text**, not the raw digest. A raw-byte key would be equally sound +/// cryptographically, but it would not match what nest-auth already uses, and every +/// HMAC-derived key — brute-force lockout, OTP, resend cooldown, MFA setup, anti-replay — +/// would land in a different Redis slot on each backend. +/// +/// The buffer holding the secret and the intermediate digest are both zeroized on drop, and +/// the hex is written straight into the fixed-size key so no heap copy of the key material +/// outlives this call. +fn derive_hmac_key(secret: &str) -> SecretBox<[u8; 64]> { + let mut input = Zeroizing::new(Vec::with_capacity(HMAC_KEY_LABEL.len() + 1 + secret.len())); input.extend_from_slice(HMAC_KEY_LABEL); + input.push(b':'); input.extend_from_slice(secret.as_bytes()); - SecretBox::new(Box::new(sha256(&input))) + + let digest = Zeroizing::new(sha256(&input)); + let mut key = [0u8; 64]; + for (pair, byte) in key.chunks_exact_mut(2).zip(digest.iter()) { + pair[0] = HEX_ALPHABET[usize::from(byte >> 4)]; + pair[1] = HEX_ALPHABET[usize::from(byte & 0x0f)]; + } + SecretBox::new(Box::new(key)) } impl AuthConfig { @@ -983,11 +1010,14 @@ mod tests { assert_eq!(resolved.environment(), Environment::Production); assert_eq!(resolved.config().jwt.refresh_expires_in_days, 7); - // Recompute the key independently and compare. + // Recompute the key independently and compare, including the ':' separator and the + // hex encoding that make up the contract. let secret = "0123456789abcdef0123456789abcdef"; let mut expected_input = HMAC_KEY_LABEL.to_vec(); + expected_input.push(b':'); expected_input.extend_from_slice(secret.as_bytes()); - assert_eq!(resolved.hmac_key(), &sha256(&expected_input)); + let expected_hex = to_hex_string(&sha256(&expected_input)); + assert_eq!(resolved.hmac_key().as_slice(), expected_hex.as_bytes()); let mut other = valid_config(); other.jwt.secret = SecretString::from("fedcba9876543210fedcba9876543210".to_owned()); @@ -996,6 +1026,46 @@ mod tests { assert!(!other_resolved.secure_cookies()); } + /// Hex-encode for the test's independent recomputation of the derived key. + fn to_hex_string(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn hmac_key_matches_the_nest_auth_known_answer() { + // CROSS-IMPLEMENTATION KNOWN-ANSWER TEST. The two backends key the same Redis + // identifiers with this value, so the derivation is a wire contract, not an internal + // detail. The constants below were produced by nest-auth's own primitives: + // + // createHash('sha256').update(`${LABEL}:${secret}`, 'utf8').digest('hex') + // createHmac('sha256', thatHexString).update(message, 'utf8').digest('hex') + // + // nest-auth carries the identical vectors. If either side changes its separator, its + // hash, or the encoding it feeds the HMAC, exactly one of the two suites goes red + // instead of the split appearing later as sessions and lockouts that silently miss + // each other in production. + const SECRET: &str = "0123456789abcdef0123456789abcdef"; + const EXPECTED_KEY: &str = + "0dd66555bd2d89e0eb4ce050f1fef427bea6799bec27fb8e313f69ab965048c1"; + const IDENTIFIER_MESSAGE: &str = "tenant-a:user@example.com"; + const EXPECTED_IDENTIFIER: &str = + "609a759522bd8b397748fad2dbde07957cea580fe4f4f1f0ce0f526485de2b6d"; + + let mut cfg = valid_config(); + cfg.jwt.secret = SecretString::from(SECRET.to_owned()); + let resolved = ResolvedConfig::new(cfg, Environment::Production, true); + + // The key itself is the 64-character hex text, not the raw 32-byte digest. + assert_eq!(resolved.hmac_key().as_slice(), EXPECTED_KEY.as_bytes()); + + // And an identifier keyed with it matches byte for byte what nest-auth writes. + let identifier = to_hex_string(&bymax_auth_crypto::mac::hmac_sha256( + resolved.hmac_key(), + IDENTIFIER_MESSAGE.as_bytes(), + )); + assert_eq!(identifier, EXPECTED_IDENTIFIER); + } + #[test] fn url_host_extracts_authority_and_treats_relative_as_same_origin() { // Drives the host-extraction helper across absolute, port/userinfo, and relative diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index b3ae26a..859a356 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -591,7 +591,7 @@ mod tests { assert!(!engine.config().secure_cookies()); assert_eq!(engine.config().environment(), Environment::Development); assert_eq!(engine.config().config().jwt.refresh_expires_in_days, 7); - assert_eq!(engine.config().hmac_key().len(), 32); + assert_eq!(engine.config().hmac_key().len(), 64); // Required + defaulted collaborators are all reachable. let _ = engine.user_repository(); assert!(engine.platform_user_repository().is_none()); diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index 032fe89..8467660 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -134,7 +134,7 @@ pub struct MfaService { encryption_key: Zeroizing<[u8; 32]>, /// The engine's identifier-hashing key, keying every `mfa_setup:`/`tu:` suffix, the /// `challenge:`/`disable:` brute-force ids, and the recovery-code digests. - identifier_key: Zeroizing<[u8; 32]>, + identifier_key: Zeroizing<[u8; 64]>, issuer: String, totp_window: u8, recovery_code_count: u8, @@ -155,7 +155,7 @@ pub(crate) struct MfaServiceDeps { pub(crate) email: Arc, pub(crate) hooks: Arc, pub(crate) encryption_key: Zeroizing<[u8; 32]>, - pub(crate) identifier_key: Zeroizing<[u8; 32]>, + pub(crate) identifier_key: Zeroizing<[u8; 64]>, pub(crate) issuer: String, pub(crate) totp_window: u8, pub(crate) recovery_code_count: u8, diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 46735b3..c185b3a 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -986,7 +986,7 @@ fn service_over(store: Arc, users: Arc) -> email: Arc::new(NoOpEmailProvider), hooks: Arc::new(NoOpAuthHooks), encryption_key: zeroize::Zeroizing::new([7u8; 32]), - identifier_key: zeroize::Zeroizing::new([9u8; 32]), + identifier_key: zeroize::Zeroizing::new([9u8; 64]), issuer: "Bymax One".to_owned(), totp_window: 2, recovery_code_count: 8, diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index c344e2a..78a4043 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -52,7 +52,7 @@ pub struct PlatformAuthService { hooks: Arc, /// The engine's derived identifier-hashing key, copied into a zeroizing buffer; it keys the /// `platform:{email}` brute-force identifier so no raw email reaches a store key. - identifier_key: zeroize::Zeroizing<[u8; 32]>, + identifier_key: zeroize::Zeroizing<[u8; 64]>, /// Whether this build wires the MFA challenge surface; when `false`, an MFA-enabled admin /// cannot complete a login (fail-closed) because there is no challenge flow to route to. mfa_enabled_for_build: bool, @@ -70,7 +70,7 @@ pub(crate) struct PlatformAuthDeps { pub(crate) passwords: Arc, pub(crate) brute_force: Arc, pub(crate) hooks: Arc, - pub(crate) identifier_key: zeroize::Zeroizing<[u8; 32]>, + pub(crate) identifier_key: zeroize::Zeroizing<[u8; 64]>, pub(crate) mfa_enabled_for_build: bool, pub(crate) blocked_statuses: Vec, } diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 71f94e5..68aeda8 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -49,7 +49,7 @@ pub struct MfaTempVerified { pub(crate) struct MfaTokenSupport { store: std::sync::Arc, brute_force: std::sync::Arc, - challenge_hmac_key: zeroize::Zeroizing<[u8; 32]>, + challenge_hmac_key: zeroize::Zeroizing<[u8; 64]>, } #[cfg(feature = "mfa")] @@ -59,7 +59,7 @@ impl MfaTokenSupport { pub(crate) fn new( store: std::sync::Arc, brute_force: std::sync::Arc, - hmac_key: &[u8; 32], + hmac_key: &[u8; 64], ) -> Self { Self { store, @@ -1129,7 +1129,7 @@ mod tests { // both the MFA-marker and brute-force seams), under a fixed identifier-hashing key. let brute_force: Arc = store.clone(); let mfa_store: Arc = store.clone(); - let support = MfaTokenSupport::new(mfa_store, brute_force, &[7u8; 32]); + let support = MfaTokenSupport::new(mfa_store, brute_force, &[7u8; 64]); TokenManagerService::new( key(), store, @@ -1219,7 +1219,7 @@ mod tests { let store = Arc::new(InMemoryStores::new()); let svc = service_with_mfa(store.clone()); let bf: Arc = store.clone(); - let key_bytes = [7u8; 32]; + let key_bytes = [7u8; 64]; let challenge_id = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( &key_bytes, b"challenge:u1", diff --git a/crates/bymax-auth-core/tests/engine_assembly.rs b/crates/bymax-auth-core/tests/engine_assembly.rs index 90526cf..702c868 100644 --- a/crates/bymax-auth-core/tests/engine_assembly.rs +++ b/crates/bymax-auth-core/tests/engine_assembly.rs @@ -39,7 +39,7 @@ fn assembles_a_full_engine_from_the_builder() { let Ok(engine) = result else { return }; // Production resolves secure cookies on, and the derived HMAC key is present. assert!(engine.config().secure_cookies()); - assert_eq!(engine.config().hmac_key().len(), 32); + assert_eq!(engine.config().hmac_key().len(), 64); assert_eq!(engine.config().config().route_prefix, "auth"); } From b1d12de9a7c01f7345e652eed135d8ee289523a6 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:40:20 -0300 Subject: [PATCH 007/122] fix(mfa): re-check account status when completing a challenge The MFA temp token outlives the login-time status gate by its whole TTL, so an account blocked in that window could still clear the second factor and receive a full session. Revoking access must not depend on how far through the login the holder already was. Re-check the status once the account is loaded, before the code is verified, on both the dashboard and platform challenge paths. Ordering matters twice: a blocked account must be refused whatever it submits, and the recovery-code path costs one key derivation per stored code, so gating first also denies a revoked account that work. Extract the status gate into a shared status_gate module. There were already two copies of the status-to-error mapping (dashboard login and platform login) and this would have been a third; one implementation means the three planes cannot drift into different notions of "blocked". MfaService now carries the configured blocked statuses, wired from the resolved config by the builder. --- crates/bymax-auth-core/src/engine/builder.rs | 1 + crates/bymax-auth-core/src/lib.rs | 1 + .../src/services/auth/login.rs | 13 +- .../src/services/mfa/challenge.rs | 16 +++ .../bymax-auth-core/src/services/mfa/mod.rs | 5 + .../bymax-auth-core/src/services/mfa/tests.rs | 30 +++++ .../bymax-auth-core/src/services/platform.rs | 19 +-- crates/bymax-auth-core/src/status_gate.rs | 122 ++++++++++++++++++ 8 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 crates/bymax-auth-core/src/status_gate.rs diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 859a356..41f9a6f 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -545,6 +545,7 @@ fn build_mfa_service(wiring: MfaWiring<'_>) -> Option Result<(), AuthError> { - let blocked = &self.config().config().blocked_statuses; - if !blocked.iter().any(|s| s.eq_ignore_ascii_case(status)) { - return Ok(()); - } - Err(match status.to_ascii_lowercase().as_str() { - "banned" => AuthError::AccountBanned, - "inactive" => AuthError::AccountInactive, - "suspended" => AuthError::AccountSuspended, - "pending" | "pending_approval" => AuthError::PendingApproval, - _ => AuthError::AccountInactive, - }) + assert_not_blocked(status, &self.config().config().blocked_statuses) } } diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 733da8a..46f7985 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -75,6 +75,14 @@ impl MfaService { .await .map_err(repository_error)? .ok_or(AuthError::MfaNotEnabled)?; + + // Re-check the account status. Login gated it before minting the temp token, but that + // token stays valid for its whole TTL: an account blocked in between would otherwise + // clear the second factor and receive a full session. Revoking access must not depend + // on how far through the login the holder already was. Gating here also keeps a blocked + // account from spending the KDF — the recovery-code path costs one derivation per code. + crate::status_gate::assert_not_blocked(&user.status, &self.blocked_statuses)?; + let Some(encrypted_secret) = user.mfa_secret.clone().filter(|_| user.mfa_enabled) else { return Err(AuthError::MfaNotEnabled); }; @@ -154,6 +162,14 @@ impl MfaService { .await .map_err(super::repository_error)? .ok_or(AuthError::MfaNotEnabled)?; + + // Re-check the account status. Login gated it before minting the temp token, but that + // token stays valid for its whole TTL: an account blocked in between would otherwise + // clear the second factor and receive a full session. Revoking access must not depend + // on how far through the login the holder already was. Gating here also keeps a blocked + // account from spending the KDF — the recovery-code path costs one derivation per code. + crate::status_gate::assert_not_blocked(&admin.status, &self.blocked_statuses)?; + let Some(encrypted_secret) = admin.mfa_secret.clone().filter(|_| admin.mfa_enabled) else { return Err(AuthError::MfaNotEnabled); }; diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index 8467660..49adab2 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -139,6 +139,9 @@ pub struct MfaService { totp_window: u8, recovery_code_count: u8, sessions_enabled: bool, + /// Statuses that deny authentication, re-checked when a challenge is completed: the temp + /// token outlives the login-time gate by its whole TTL. + blocked_statuses: Vec, } /// The collaborators an [`MfaService`] is assembled from. Grouped into a struct so the @@ -160,6 +163,7 @@ pub(crate) struct MfaServiceDeps { pub(crate) totp_window: u8, pub(crate) recovery_code_count: u8, pub(crate) sessions_enabled: bool, + pub(crate) blocked_statuses: Vec, } impl MfaService { @@ -181,6 +185,7 @@ impl MfaService { totp_window: deps.totp_window, recovery_code_count: deps.recovery_code_count, sessions_enabled: deps.sessions_enabled, + blocked_statuses: deps.blocked_statuses, } } diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index c185b3a..44b7248 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -449,6 +449,35 @@ async fn challenge_rejects_when_mfa_is_not_enabled() { )); } +#[tokio::test] +async fn challenge_rejects_an_account_blocked_after_the_temp_token_was_issued() { + // The temp token outlives the login-time status gate by its whole TTL, so an account + // suspended inside that window must not be able to clear the second factor and walk away + // with a full session — revoking access cannot depend on how far through the login the + // holder had already got. The account here is not even MFA-enrolled, so getting the + // status error rather than MfaNotEnabled also pins that the gate runs first, before the + // MFA checks and before any key derivation. + let Some(h) = build(false, false) else { return }; + let Some(uid) = register(&h.engine, "blocked-mid-challenge@example.com").await else { + return; + }; + let Ok(temp) = h + .engine + .tokens() + .issue_mfa_temp_token(&uid, MfaContext::Dashboard) + .await + else { + return; + }; + assert!(h.users.update_status(&uid, "SUSPENDED").await.is_ok()); + + let Some(mfa) = h.engine.mfa() else { return }; + assert!(matches!( + mfa.challenge(&temp, "000000", "1.2.3.4", "ua").await, + Err(AuthError::AccountSuspended) + )); +} + #[tokio::test] async fn challenge_locks_out_after_repeated_wrong_codes() { // A single temp token (verify is non-consuming) absorbs repeated wrong codes; after the @@ -991,6 +1020,7 @@ fn service_over(store: Arc, users: Arc) -> totp_window: 2, recovery_code_count: 8, sessions_enabled: false, + blocked_statuses: vec!["BANNED".to_owned(), "SUSPENDED".to_owned()], }) } diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 78a4043..46ff965 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -336,23 +336,10 @@ impl PlatformAuthService { } /// Map a platform admin's `status` (case-insensitive) against `blocked_statuses`, returning - /// the status-specific 403 when blocked and `Ok(())` otherwise. The mapping mirrors the - /// dashboard status gate. + /// the status-specific 403 when blocked and `Ok(())` otherwise. Delegates to the shared gate + /// so the platform plane can never drift from the dashboard one. fn assert_not_blocked(&self, status: &str) -> Result<(), AuthError> { - if !self - .blocked_statuses - .iter() - .any(|s| s.eq_ignore_ascii_case(status)) - { - return Ok(()); - } - Err(match status.to_ascii_lowercase().as_str() { - "banned" => AuthError::AccountBanned, - "inactive" => AuthError::AccountInactive, - "suspended" => AuthError::AccountSuspended, - "pending" | "pending_approval" => AuthError::PendingApproval, - _ => AuthError::AccountInactive, - }) + crate::status_gate::assert_not_blocked(status, &self.blocked_statuses) } } diff --git a/crates/bymax-auth-core/src/status_gate.rs b/crates/bymax-auth-core/src/status_gate.rs new file mode 100644 index 0000000..78569a4 --- /dev/null +++ b/crates/bymax-auth-core/src/status_gate.rs @@ -0,0 +1,122 @@ +//! The account-status gate shared by every credential flow. +//! +//! Kept as one free function rather than a method per service so the dashboard, platform, +//! and MFA paths cannot drift into subtly different notions of "blocked". + +use bymax_auth_types::AuthError; + +/// Reject when `status` is one of the configured blocked account statuses. +/// +/// Matching is case-insensitive on both sides: the status is application-defined (a host may +/// persist `"Suspended"`) while `blocked_statuses` is typically configured uppercase, and a +/// raw comparison would silently admit a blocked account. +/// +/// The mapping is `banned → AccountBanned`, `inactive → AccountInactive`, +/// `suspended → AccountSuspended`, `pending`/`pending_approval → PendingApproval`; any other +/// blocked status falls back to `AccountInactive`, since a host may define its own. +/// +/// Call this **before** the password KDF. A blocked account must never authenticate, and +/// gating ahead of the derivation also denies an attacker unbounded hashing work on an +/// account whose login could never succeed. +/// +/// # Errors +/// +/// Returns the status-specific [`AuthError`] when `status` is in the blocked set. +pub(crate) fn assert_not_blocked( + status: &str, + blocked_statuses: &[String], +) -> Result<(), AuthError> { + if !blocked_statuses + .iter() + .any(|blocked| blocked.eq_ignore_ascii_case(status)) + { + return Ok(()); + } + + Err(match status.to_ascii_lowercase().as_str() { + "banned" => AuthError::AccountBanned, + "inactive" => AuthError::AccountInactive, + "suspended" => AuthError::AccountSuspended, + "pending" | "pending_approval" => AuthError::PendingApproval, + _ => AuthError::AccountInactive, + }) +} + +#[cfg(test)] +mod tests { + use super::assert_not_blocked; + use bymax_auth_types::AuthError; + + fn blocked() -> Vec { + ["BANNED", "INACTIVE", "SUSPENDED"] + .into_iter() + .map(str::to_owned) + .collect() + } + + #[test] + fn admits_a_status_that_is_not_blocked() { + // The common case: an active account must pass, or every login fails closed. + assert!(assert_not_blocked("active", &blocked()).is_ok()); + } + + #[test] + fn admits_everything_when_no_status_is_configured_as_blocked() { + // An empty blocked set disables the gate rather than blocking everything; a host may + // legitimately configure no blocked statuses. + assert!(assert_not_blocked("suspended", &[]).is_ok()); + } + + #[test] + fn maps_each_known_status_to_its_own_error() { + // The caller learns *why* the account was refused instead of one opaque rejection. + // Asserted per variant rather than in a loop: `AuthError` is not `PartialEq`, and a + // pattern match also pins the exact variant instead of an equality that a future + // `PartialEq` impl could weaken. + assert!(matches!( + assert_not_blocked("banned", &["banned".to_owned()]), + Err(AuthError::AccountBanned) + )); + assert!(matches!( + assert_not_blocked("inactive", &["inactive".to_owned()]), + Err(AuthError::AccountInactive) + )); + assert!(matches!( + assert_not_blocked("suspended", &["suspended".to_owned()]), + Err(AuthError::AccountSuspended) + )); + assert!(matches!( + assert_not_blocked("pending", &["pending".to_owned()]), + Err(AuthError::PendingApproval) + )); + assert!(matches!( + assert_not_blocked("pending_approval", &["pending_approval".to_owned()]), + Err(AuthError::PendingApproval) + )); + } + + #[test] + fn falls_back_to_inactive_for_a_host_defined_status() { + // A status configured as blocked but absent from the mapping must still reject rather + // than leak through. + let blocked = vec!["archived".to_owned()]; + assert!(matches!( + assert_not_blocked("archived", &blocked), + Err(AuthError::AccountInactive) + )); + } + + #[test] + fn matches_case_insensitively_on_both_sides() { + // The host's casing and the configured casing are independent. Folding only one side + // would let a blocked account authenticate whenever the two disagree. + assert!(assert_not_blocked("Suspended", &blocked()).is_err()); + assert!(assert_not_blocked("BANNED", &["banned".to_owned()]).is_err()); + } + + #[test] + fn requires_an_exact_match_not_a_substring() { + // A status that merely contains a blocked value must authenticate normally. + assert!(assert_not_blocked("active_pending_review", &blocked()).is_ok()); + } +} From f2605eb9b00af7c87ba9e605681e7c0aab0c3e01 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:22:18 -0300 Subject: [PATCH 008/122] fix(redis): converge the session index and reset keyspace with nest-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four Redis-contract divergences, one of which was a security bug rather than a parity gap. Session-index members were the bare token hash; nest-auth stores full key suffixes (`rt:{hash}`, `prt:{hash}`) and also indexes the rotation grace pointers (`rp:`/`prp:`). A bare hash cannot say which keyspace it belongs to, so revoke_all was structurally unable to delete grace pointers: a refresh token that had just been rotated away survived a logout-all for its entire grace window and kept recovering sessions from it. Members now carry their own prefix, the Lua deletes `{ns}:{member}` directly, and list_sessions filters to the live prefix and strips it before building the detail key. The separate `psess:`/`psd:` platform keyspace stays — only the member format converged. `sd:`/`psd:` timestamps were RFC 3339 strings; nest-auth writes Unix milliseconds and its listing rejects any record whose timestamps are not numbers — then SREMs the member. So the divergence was not merely unreadable, it was destructive: nest-auth evicted sessions this backend had written. Added a `unix_millis` serde adapter for those two fields. `SessionRecord.created_at` deliberately keeps RFC 3339, which is what nest-auth writes there. Password-reset prefixes `pr:`/`prv:` renamed to `pw_reset:`/`pw_vtok:`, so a reset link emailed by one backend resolves against the other. The stored invitation gained `createdAt`, which nest-auth writes and validates. Consumption is a single-use GETDEL, so nest-auth read an invitation written here, failed validation on the missing field, and the token was already gone — the invitation was destroyed instead of accepted. Encoding verified against nest-auth rather than assumed: it writes an ISO string there, not the epoch milliseconds the session detail uses. The spec already described the prefixed-member format at §12.5; the implementation had drifted from its own specification, not only from nest-auth. --- crates/bymax-auth-axum/tests/adapter.rs | 1 + crates/bymax-auth-core/src/engine/builder.rs | 2 +- crates/bymax-auth-core/src/engine/mod.rs | 2 +- .../src/services/auth/invitation.rs | 7 + .../src/services/auth/password_reset.rs | 12 +- crates/bymax-auth-core/src/testing/mod.rs | 1 + crates/bymax-auth-core/src/traits/store.rs | 178 ++++++++++++++- crates/bymax-auth-redis/src/keys.rs | 26 ++- .../src/lua/invalidate_user_sessions.lua | 23 +- .../src/lua/session_revoke.lua | 6 +- crates/bymax-auth-redis/src/stores/session.rs | 174 ++++++++++++-- .../bymax-auth-redis/src/stores/single_use.rs | 12 +- crates/bymax-auth-redis/tests/common/mod.rs | 33 +++ crates/bymax-auth-redis/tests/redis_stores.rs | 215 +++++++++++++++++- docs/technical_specification.md | 26 +-- 15 files changed, 638 insertions(+), 80 deletions(-) diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 80602b9..8cdf8a2 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -1332,6 +1332,7 @@ async fn invitation_accept_success_creates_a_session() { role: "USER".to_owned(), tenant_id: TENANT.to_owned(), inviter_user_id: inviter, + created_at: time::OffsetDateTime::UNIX_EPOCH, }; let _ = h .stores diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 41f9a6f..99a84cb 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -153,7 +153,7 @@ impl AuthEngineBuilder { self } - /// Set the password-reset proof store (`pr:`/`prv:` single-use tokens). Required only when + /// Set the password-reset proof store (`pw_reset:`/`pw_vtok:` single-use tokens). Required only when /// the password-reset flow uses the token method or the OTP verified-token bridge. #[must_use] pub fn password_reset_store(mut self, store: Arc) -> Self { diff --git a/crates/bymax-auth-core/src/engine/mod.rs b/crates/bymax-auth-core/src/engine/mod.rs index 18d36a3..ff1ab14 100644 --- a/crates/bymax-auth-core/src/engine/mod.rs +++ b/crates/bymax-auth-core/src/engine/mod.rs @@ -114,7 +114,7 @@ impl AuthEngine { self.platform_auth.as_ref() } - /// The password-reset proof store (`pr:`/`prv:` single-use tokens), present only when the + /// The password-reset proof store (`pw_reset:`/`pw_vtok:` single-use tokens), present only when the /// password-reset flow is wired. pub(crate) fn password_reset_store(&self) -> Option<&Arc> { self.password_reset_store.as_ref() diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 152368c..a3fb177 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -100,6 +100,10 @@ impl AuthEngine { role: role.to_owned(), tenant_id: tenant_id.to_owned(), inviter_user_id: inviter_user_id.to_owned(), + // Required by nest-auth's record guard: an invitation without `createdAt` is + // rejected on accept, and because accept consumes the token with `GETDEL` the + // rejection would destroy the invitation rather than merely fail it. + created_at: OffsetDateTime::now_utc(), }; store.put_invitation(&raw, &invitation, ttl).await?; @@ -321,6 +325,7 @@ mod tests { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: inviter.clone(), + created_at: OffsetDateTime::UNIX_EPOCH, }, 600 ) @@ -432,6 +437,7 @@ mod tests { role: "SUPERADMIN".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: "x".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, }, 600 ) @@ -466,6 +472,7 @@ mod tests { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: "x".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, }, 600 ) 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 ac4e1e2..8a95e22 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -387,7 +387,7 @@ impl AuthEngine { tenant_id: &str, ) -> Result<(), AuthError> { let Some(store) = self.password_reset_store() else { - // A misconfiguration: the token method is selected but no `pr:` store is wired. + // A misconfiguration: the token method is selected but no `pw_reset:` store is wired. // Surfaced to the caller (which swallows it on the anti-enumerating path) and // logged so a deployment running the token method without its store is observable. tracing::warn!("password reset token method selected but no PasswordResetStore wired"); @@ -472,20 +472,20 @@ impl AuthEngine { /// The single reset proof carried by a request, classified from the mutually-exclusive /// `token` / `otp` / `verified_token` fields. enum Proof<'a> { - /// A reset link token (`pr:`). + /// A reset link token (`pw_reset:`). Token(&'a str), /// A direct OTP. Otp(&'a str), - /// An OTP-flow verified token (`prv:`). + /// An OTP-flow verified token (`pw_vtok:`). Verified(&'a str), } /// Which opaque-token keyspace a stored reset proof lives in. #[derive(Clone, Copy)] enum ProofKind { - /// The reset link token (`pr:`). + /// The reset link token (`pw_reset:`). Token, - /// The OTP-flow verified token (`prv:`). + /// The OTP-flow verified token (`pw_vtok:`). Verified, } @@ -988,7 +988,7 @@ mod tests { #[tokio::test] async fn token_send_failure_deletes_the_unusable_token() { - // On an undeliverable reset email the stored `pr:` token is deleted so it cannot + // On an undeliverable reset email the stored `pw_reset:` token is deleted so it cannot // linger; a subsequent reset with that token is therefore invalid. let mut cfg = base_config(); cfg.password_reset.method = ResetMethod::Token; diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index e4bdcf7..a38730e 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1176,6 +1176,7 @@ mod tests { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: "owner".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, }; assert!( store diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index dd56bf3..daac36e 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -80,9 +80,64 @@ pub struct SessionRecord { pub mfa_enabled: bool, } +/// Serde adapter carrying an [`OffsetDateTime`] as a **Unix-millisecond number**. +/// +/// This is the encoding nest-auth uses for the `sd:`/`psd:` per-session detail record: it +/// writes `createdAt`/`lastActivityAt` with `Date.now()` and re-reads them under a +/// `typeof === 'number'` guard, so an RFC 3339 string in those fields makes the record +/// unreadable — and, because a member whose detail fails to parse is treated as stale, it +/// makes the session disappear from the other backend's listing. Both backends must therefore +/// agree on the numeric form for the shared-Redis promise to hold. +/// +/// Note this is deliberately **not** how [`SessionRecord::created_at`] is encoded: nest-auth +/// writes that one as an ISO-8601 string (`new Date().toISOString()`), so `rt:`/`prt:` records +/// keep the RFC 3339 adapter. +pub mod unix_millis { + use serde::{Deserialize, Deserializer, Serializer}; + use time::OffsetDateTime; + + /// Nanoseconds in one millisecond — the scale factor between `time`'s native + /// `unix_timestamp_nanos` and the millisecond wire form. + const NANOS_PER_MILLI: i128 = 1_000_000; + + /// Write the instant as a Unix-millisecond `i64`, saturating at the `i64` bounds. The + /// clamp preserves the sign, so a pre-epoch instant stays negative instead of flipping to + /// `i64::MAX` on overflow. + /// + /// # Errors + /// + /// Propagates whatever the serializer reports while emitting the number. + pub fn serialize(value: &OffsetDateTime, serializer: S) -> Result + where + S: Serializer, + { + let millis = (value.unix_timestamp_nanos() / NANOS_PER_MILLI) + .clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64; + serializer.serialize_i64(millis) + } + + /// Read a Unix-millisecond number back into an instant. + /// + /// # Errors + /// + /// Returns a deserialization error when the field is not an integer, or when the + /// millisecond count is outside the range `OffsetDateTime` can represent. + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let millis = i64::deserialize(deserializer)?; + OffsetDateTime::from_unix_timestamp_nanos(i128::from(millis) * NANOS_PER_MILLI) + .map_err(serde::de::Error::custom) + } +} + /// One session's display detail, returned by [`SessionStore::list_sessions`]. The -/// `session_hash` is the `sess:`-set member (a SHA-256 hex of the refresh token), never -/// the raw token. +/// `session_hash` is the bare SHA-256 hex of the refresh token (the `sess:`-set member is that +/// hash under its `rt:`/`prt:` prefix), never the raw token. +/// +/// The two timestamps are Unix-millisecond numbers on the wire — the encoding nest-auth writes +/// for `sd:`/`psd:` (see [`unix_millis`]). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionDetail { @@ -92,11 +147,11 @@ pub struct SessionDetail { pub device: String, /// Originating IP. pub ip: String, - /// Session creation time. - #[serde(with = "time::serde::rfc3339")] + /// Session creation time, as Unix milliseconds on the wire. + #[serde(with = "unix_millis")] pub created_at: OffsetDateTime, - /// Last observed activity time. - #[serde(with = "time::serde::rfc3339")] + /// Last observed activity time, as Unix milliseconds on the wire. + #[serde(with = "unix_millis")] pub last_activity_at: OffsetDateTime, } @@ -327,7 +382,7 @@ pub trait WsTicketStore: Send + Sync { } /// The identity bound to a password-reset proof (a link token or the OTP-flow verified -/// token). Stored under `pr:`/`prv:` keyed by `sha256(token)` — the raw token is never a +/// token). Stored under `pw_reset:`/`pw_vtok:` keyed by `sha256(token)` — the raw token is never a /// key — and read back on consume so the reset can re-bind the proof to the same account. /// JSON is camelCase for parity with nest-auth payloads already in Redis. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -357,10 +412,21 @@ pub struct StoredInvitation { pub tenant_id: String, /// The user id of the inviter (for audit / the accepted hook). pub inviter_user_id: String, + /// When the invitation was issued, as an RFC 3339 string on the wire. + /// + /// Mandatory for cross-backend parity: nest-auth writes `createdAt` (an + /// ISO-8601 string from `new Date().toISOString()`) and its `isStoredInvitation` guard + /// **rejects** a record without it. Because acceptance consumes the record with a + /// single-use `GETDEL`, a nest-auth backend reading an invitation that lacks the field + /// fails validation *after* the token is already gone — destroying the invitation + /// instead of accepting it. Encoded as RFC 3339 (not Unix millis like `sd:`) because + /// that is what nest-auth stores here. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, } -/// Single-use password-reset proof storage: the link token (`pr:`) and the OTP-flow -/// verified token (`prv:`). Both store a [`ResetContext`] keyed by `sha256(token)` and are +/// Single-use password-reset proof storage: the link token (`pw_reset:`) and the OTP-flow +/// verified token (`pw_vtok:`). Both store a [`ResetContext`] keyed by `sha256(token)` and are /// consumed atomically with `getdel`, so a proof is valid exactly once. The OTP records /// themselves are owned by [`OtpStore`] — this store backs only the two opaque-token /// keyspaces. @@ -371,7 +437,7 @@ pub struct StoredInvitation { /// proof is the non-error `Ok(None)`, not an error. #[async_trait] pub trait PasswordResetStore: Send + Sync { - /// Store a reset-link-token context under `pr:{sha256(token)}` with a TTL. + /// Store a reset-link-token context under `pw_reset:{sha256(token)}` with a TTL. async fn put_token( &self, token: &str, @@ -387,7 +453,7 @@ pub trait PasswordResetStore: Send + Sync { /// undeliverable email so an unusable token does not linger in a Redis snapshot. async fn delete_token(&self, token: &str) -> Result<(), AuthError>; - /// Store an OTP-flow verified-token context under `prv:{sha256(token)}` with a TTL. + /// Store an OTP-flow verified-token context under `pw_vtok:{sha256(token)}` with a TTL. async fn put_verified( &self, token: &str, @@ -615,6 +681,66 @@ mod tests { Ok(()) } + #[test] + fn session_detail_timestamps_are_unix_millisecond_numbers() -> serde_json::Result<()> { + // Parity gate for the `sd:`/`psd:` record: nest-auth writes `createdAt`/`lastActivityAt` + // as `Date.now()` NUMBERS and drops any detail record whose fields are not numbers, so an + // RFC 3339 string here would make every rust-written session invisible to nest-auth (and + // vice versa). Pin the numeric encoding in both directions. + let detail = SessionDetail { + session_hash: "abc123".into(), + device: "Firefox".into(), + ip: "198.51.100.7".into(), + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + last_activity_at: OffsetDateTime::from_unix_timestamp(1_700_000_060) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + }; + let json = serde_json::to_string(&detail)?; + assert!(json.contains("\"createdAt\":1700000000000")); + assert!(json.contains("\"lastActivityAt\":1700000060000")); + // No quotes around the values — a stringly-typed timestamp is exactly the divergence. + assert!(!json.contains("\"createdAt\":\"")); + + // A nest-auth-written record (numbers, sub-second precision) reads back exactly. + let from_nest: SessionDetail = serde_json::from_str( + r#"{"sessionHash":"abc123","device":"Firefox","ip":"198.51.100.7","createdAt":1700000000123,"lastActivityAt":1700000060456}"#, + )?; + assert_eq!( + from_nest.created_at.unix_timestamp_nanos() / 1_000_000, + 1_700_000_000_123 + ); + assert_eq!( + from_nest.last_activity_at.unix_timestamp_nanos() / 1_000_000, + 1_700_000_060_456 + ); + Ok(()) + } + + #[test] + fn unix_millis_preserves_pre_epoch_instants_and_rejects_non_numbers() { + // The clamp in `unix_millis::serialize` must keep a pre-epoch instant NEGATIVE rather + // than saturating it to `i64::MAX`, and the reader must refuse a stringly-typed + // timestamp instead of silently defaulting — a legacy RFC 3339 `sd:` record has to fail + // loudly (and be swept as stale) rather than decode to a bogus time. + let detail = SessionDetail { + session_hash: "abc123".into(), + device: "Firefox".into(), + ip: "198.51.100.7".into(), + created_at: OffsetDateTime::from_unix_timestamp(-1_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + last_activity_at: OffsetDateTime::UNIX_EPOCH, + }; + let json = serde_json::to_string(&detail).unwrap_or_default(); + assert!(json.contains("\"createdAt\":-1000000")); + assert!(json.contains("\"lastActivityAt\":0")); + + let legacy: Result = serde_json::from_str( + r#"{"sessionHash":"abc123","device":"Firefox","ip":"198.51.100.7","createdAt":"1970-01-01T00:00:00Z","lastActivityAt":0}"#, + ); + assert!(legacy.is_err()); + } + #[test] fn ws_ticket_snapshot_round_trips() -> serde_json::Result<()> { // The snapshot is the stored ticket value; camelCase + omit-absent-tenant parity. @@ -669,7 +795,7 @@ mod tests { #[test] fn reset_context_round_trips_camel_case() -> serde_json::Result<()> { - // The `pr:`/`prv:` value is camelCase and round-trips every field so the consume + // The `pw_reset:`/`pw_vtok:` value is camelCase and round-trips every field so the consume // path can re-bind the proof to the same account. let context = ResetContext { user_id: "u1".into(), @@ -692,6 +818,7 @@ mod tests { role: "MEMBER".into(), tenant_id: "t1".into(), inviter_user_id: "owner-1".into(), + created_at: OffsetDateTime::UNIX_EPOCH, }; let json = serde_json::to_string(&invitation)?; assert!(json.contains("\"tenantId\":\"t1\"")); @@ -700,4 +827,31 @@ mod tests { assert_eq!(back, invitation); Ok(()) } + + #[test] + fn stored_invitation_carries_created_at_and_reads_a_nest_written_record() + -> serde_json::Result<()> { + // Parity gate for the `inv:` value. nest-auth's `isStoredInvitation` requires a STRING + // `createdAt`; omitting it made a nest-auth accept of a rust-written invitation fail + // validation *after* the single-use `GETDEL` had already removed the token — destroying + // the invitation. Assert the field is emitted as a string and that a record written by + // nest-auth (ISO-8601 with a `Z` offset) deserializes. + let invitation = StoredInvitation { + email: "invitee@example.com".into(), + role: "MEMBER".into(), + tenant_id: "t1".into(), + inviter_user_id: "owner-1".into(), + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + }; + let json = serde_json::to_string(&invitation)?; + assert!(json.contains("\"createdAt\":\"2023-11-14T22:13:20")); + + let from_nest: StoredInvitation = serde_json::from_str( + r#"{"email":"invitee@example.com","role":"MEMBER","tenantId":"t1","inviterUserId":"owner-1","createdAt":"2023-11-14T22:13:20.000Z"}"#, + )?; + assert_eq!(from_nest.created_at, invitation.created_at); + assert_eq!(from_nest.inviter_user_id, "owner-1"); + Ok(()) + } } diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 6c1136d..2887ae8 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -30,7 +30,9 @@ pub enum Prefix { Rv, /// Dashboard rotation grace pointer (`rp`). Rp, - /// Dashboard active-session index SET (`sess`). + /// Dashboard active-session index SET (`sess`). Its members are full key **suffixes** — + /// `rt:{hash}` for a live session, `rp:{oldHash}` for a rotation grace pointer — never bare + /// hashes, matching nest-auth so either backend can revoke the other's sessions. Sess, /// Dashboard per-session detail (`sd`). Sd, @@ -42,17 +44,18 @@ pub enum Prefix { Resend, /// Single-use WebSocket upgrade ticket (`wst`). Wst, - /// Password-reset link token (`pr`). - Pr, - /// Password-reset OTP "verified" token (`prv`). - Prv, + /// Password-reset link token (`pw_reset`). + PwReset, + /// Password-reset OTP "verified" token (`pw_vtok`). + PwVtok, /// Pending invitation (`inv`). Inv, /// Platform-admin refresh session (`prt`). Prt, /// Platform rotation grace pointer (`prp`). Prp, - /// Platform active-session index SET (`psess`). + /// Platform active-session index SET (`psess`). Members are `prt:{hash}` / `prp:{oldHash}` + /// key suffixes; the platform keyspace is deliberately separate from the dashboard one. Psess, /// Platform per-session detail (`psd`). Psd, @@ -80,8 +83,8 @@ impl Prefix { Self::Otp => "otp", Self::Resend => "resend", Self::Wst => "wst", - Self::Pr => "pr", - Self::Prv => "prv", + Self::PwReset => "pw_reset", + Self::PwVtok => "pw_vtok", Self::Inv => "inv", Self::Prt => "prt", Self::Prp => "prp", @@ -115,7 +118,8 @@ impl NamespacedRedis { } /// The configured namespace, passed as an `ARGV` element to the scripts that rebuild a - /// member key from a SET (`invalidate_user_sessions`). + /// member key from a SET (`invalidate_user_sessions`, which deletes `{namespace}:{member}` + /// for each member — the member already carries its own prefix). #[must_use] pub fn namespace(&self) -> &str { &self.namespace @@ -157,8 +161,8 @@ mod tests { (Prefix::Otp, "auth:otp:abc"), (Prefix::Resend, "auth:resend:abc"), (Prefix::Wst, "auth:wst:abc"), - (Prefix::Pr, "auth:pr:abc"), - (Prefix::Prv, "auth:prv:abc"), + (Prefix::PwReset, "auth:pw_reset:abc"), + (Prefix::PwVtok, "auth:pw_vtok:abc"), (Prefix::Inv, "auth:inv:abc"), (Prefix::Prt, "auth:prt:abc"), (Prefix::Prp, "auth:prp:abc"), diff --git a/crates/bymax-auth-redis/src/lua/invalidate_user_sessions.lua b/crates/bymax-auth-redis/src/lua/invalidate_user_sessions.lua index 0811ec7..3ddaac5 100644 --- a/crates/bymax-auth-redis/src/lua/invalidate_user_sessions.lua +++ b/crates/bymax-auth-redis/src/lua/invalidate_user_sessions.lua @@ -2,16 +2,29 @@ -- (spec sections 12.3 / 12.5). Mirrors nest-auth's invalidateUserSessions, which passes the -- namespace as ARGV so the script can rebuild each member's fully-qualified key. -- --- KEYS[1] = sess:{userId} the user's session-hash SET (already namespaced) +-- Members of the index SET are full key SUFFIXES, byte-identical to what nest-auth writes: +-- `rt:{hash}` / `prt:{hash}` for a live refresh session, and `rp:{oldHash}` / `prp:{oldHash}` +-- for a rotation grace pointer. The member therefore already names its own keyspace, so the +-- script deletes `{namespace}:{member}` directly instead of re-prefixing a bare hash. That is +-- what lets a logout-all sweep the grace pointers too: with bare-hash members the script could +-- not tell an `rt:` hash from an `rp:` one, so a just-rotated refresh token survived +-- revoke-all for its whole grace window — a live credential outliving the logout meant to kill it. +-- +-- KEYS[1] = sess:{userId} the user's session-index SET (already namespaced) -- ARGV[1] = namespace e.g. "auth" --- ARGV[2] = refresh prefix "rt" (dashboard) or "prt" (platform) +-- ARGV[2] = live prefix "rt" (dashboard) or "prt" (platform) -- ARGV[3] = detail prefix "sd" (dashboard) or "psd" (platform) -- --- Returns the number of session members that were removed. +-- Returns the number of members that were removed. local members = redis.call('SMEMBERS', KEYS[1]) +local live = ARGV[2] .. ':' for _, member in ipairs(members) do - redis.call('DEL', ARGV[1] .. ':' .. ARGV[2] .. ':' .. member) - redis.call('DEL', ARGV[1] .. ':' .. ARGV[3] .. ':' .. member) + -- The member is the key suffix: this one DEL covers a live session AND a grace pointer. + redis.call('DEL', ARGV[1] .. ':' .. member) + -- A live member additionally owns a per-session detail record keyed by its bare hash. + if string.sub(member, 1, #live) == live then + redis.call('DEL', ARGV[1] .. ':' .. ARGV[3] .. ':' .. string.sub(member, #live + 1)) + end end redis.call('DEL', KEYS[1]) return #members diff --git a/crates/bymax-auth-redis/src/lua/session_revoke.lua b/crates/bymax-auth-redis/src/lua/session_revoke.lua index 20a24e7..311f2bf 100644 --- a/crates/bymax-auth-redis/src/lua/session_revoke.lua +++ b/crates/bymax-auth-redis/src/lua/session_revoke.lua @@ -2,10 +2,12 @@ -- IDOR/BOLA hole: a user must not revoke a session hash they do not own. The membership -- test and the deletes are one atomic unit, so a session cannot be half-revoked. -- --- KEYS[1] = sess:{userId} the user's session-hash SET +-- KEYS[1] = sess:{userId} the user's session-index SET -- KEYS[2] = rt:{sessionHash} the refresh-session key -- KEYS[3] = sd:{sessionHash} the per-session detail key --- ARGV[1] = sessionHash the SET member to revoke +-- ARGV[1] = rt:{sessionHash} the SET member to revoke — the full key SUFFIX, not a bare +-- hash, matching the member format nest-auth writes so either +-- backend can revoke a session the other created -- -- Returns 1 when the hash was owned and revoked; 0 when the caller does not own it. if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 0 then diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index c95a6f3..bbc43ea 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -3,6 +3,7 @@ //! (`jti`) blacklist — all keyed by [`SessionKind`] (section 12). use async_trait::async_trait; +use bymax_auth_core::traits::store::unix_millis; use bymax_auth_core::traits::{ RotateOutcome, SessionDetail, SessionKind, SessionRecord, SessionRotation, SessionStore, }; @@ -23,6 +24,11 @@ const GRACE_TAG: &str = "GRACE:"; /// 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. +/// +/// The timestamps are Unix-millisecond numbers, not RFC 3339 strings: nest-auth writes them +/// with `Date.now()` and discards any detail record whose `createdAt`/`lastActivityAt` are not +/// numbers, so the string form made every rust-written session invisible in a nest-auth +/// listing (and vice versa) on a shared Redis. #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct SessionDetailValue { @@ -30,11 +36,11 @@ struct SessionDetailValue { device: String, /// Originating IP. ip: String, - /// Session creation time. - #[serde(with = "time::serde::rfc3339")] + /// Session creation time, as Unix milliseconds. + #[serde(with = "unix_millis")] created_at: OffsetDateTime, - /// Last observed activity time. - #[serde(with = "time::serde::rfc3339")] + /// Last observed activity time, as Unix milliseconds. + #[serde(with = "unix_millis")] last_activity_at: OffsetDateTime, } @@ -60,6 +66,29 @@ struct KindPrefixes { sd: Prefix, } +/// Build a session-index SET member: the full key **suffix** `{prefix}:{hash}`. +/// +/// Members are stored this way — never as a bare hash — for two reasons. First, parity: it is +/// byte-identical to what nest-auth writes (`rt:{hash}`, `prt:{hash}`, `rp:{oldHash}`, +/// `prp:{oldHash}`), so on a shared Redis either backend can revoke a session the other +/// created. Second, security: a bare hash cannot say which keyspace it belongs to, so a +/// revoke-all could not distinguish a live `rt:` session from an `rp:` rotation grace pointer +/// and therefore could not delete the latter — leaving a rotated-away refresh token able to +/// recover a session for its whole grace window after the user logged everything out. +fn index_member(prefix: Prefix, hash: &str) -> String { + format!("{}:{}", prefix.as_str(), hash) +} + +/// Recover the bare session hash from a **live-session** index member, or `None` when the +/// member belongs to another keyspace. Used by listing to keep grace pointers (`rp:`/`prp:`) +/// out of the user-visible session list and to rebuild the `sd:`/`psd:` detail key, which is +/// keyed by the bare hash. +fn live_member_hash(member: &str, live: Prefix) -> Option<&str> { + member + .strip_prefix(live.as_str()) + .and_then(|rest| rest.strip_prefix(':')) +} + /// Map a [`SessionKind`] onto its prefix quartet (`rt`/`rp`/`sess`/`sd` for dashboard, /// `prt`/`prp`/`psess`/`psd` for platform). fn kind_prefixes(kind: SessionKind) -> KindPrefixes { @@ -103,8 +132,8 @@ fn interpret_rotate(raw: Option) -> Result 0 { + pipe.cmd("SADD") + .arg(&sess_key) + .arg(index_member(prefixes.rp, &rotation.old_hash)) + .ignore(); + } + pipe.cmd("SET") .arg(&sd_new) .arg(&detail_json) .arg("EX") @@ -245,6 +290,10 @@ impl RedisStores { } /// List a user's live sessions by reading the `sess:` SET and each member's `sd:` detail. + /// + /// Only `rt:`/`prt:` members are live sessions; the `rp:`/`prp:` rotation grace pointers + /// share the index (so `revoke_all` can sweep them) but are not sessions and are filtered + /// out here, matching nest-auth's `members.filter(m => m.startsWith('rt:'))`. async fn list_sessions_inner( &self, kind: SessionKind, @@ -256,13 +305,17 @@ impl RedisStores { let mut conn = self.connection().await?; let members: Vec = conn.smembers(&sess_key).await?; let mut details = Vec::with_capacity(members.len()); - for member in members { - let sd_key = keys.key(prefixes.sd, &member); + for member in &members { + let Some(hash) = live_member_hash(member, prefixes.rt) else { + continue; + }; + // The detail record is keyed by the BARE hash, so the member's prefix is stripped. + let sd_key = keys.key(prefixes.sd, hash); let raw: Option = conn.get(&sd_key).await?; if let Some(json) = raw { let value: SessionDetailValue = serde_json::from_str(&json)?; details.push(SessionDetail { - session_hash: member, + session_hash: hash.to_owned(), device: value.device, ip: value.ip, created_at: value.created_at, @@ -285,13 +338,16 @@ impl RedisStores { let sess_key = keys.key(prefixes.sess, user_id); let rt_key = keys.key(prefixes.rt, session_hash); let sd_key = keys.key(prefixes.sd, session_hash); + // The ownership check is a SISMEMBER against the index, whose members are full key + // suffixes — so the ARGV is `rt:{hash}`, not the bare hash. + let member = index_member(prefixes.rt, session_hash); let mut conn = self.connection().await?; let owned: bool = script::SESSION_REVOKE .prepare() .key(&sess_key) .key(&rt_key) .key(&sd_key) - .arg(session_hash) + .arg(&member) .invoke_async(&mut conn) .await?; Ok(owned) @@ -315,8 +371,9 @@ impl RedisStores { Ok(()) } - /// Run the `invalidate_user_sessions` transaction, deleting every member's `rt:`/`sd:` - /// key and the `sess:` SET in one atomic step. + /// Run the `invalidate_user_sessions` transaction, deleting the key each member names + /// (`rt:`/`prt:` live sessions **and** `rp:`/`prp:` grace pointers), each live member's + /// `sd:`/`psd:` detail, and the `sess:` SET itself in one atomic step. async fn revoke_all_inner( &self, kind: SessionKind, @@ -528,4 +585,83 @@ mod tests { let back: Result = serde_json::from_str(&json); assert!(matches!(back, Ok(v) if v.device == "Chrome")); } + + #[test] + fn session_detail_value_encodes_timestamps_as_unix_millisecond_numbers() { + // Cross-backend parity for the `sd:`/`psd:` value: nest-auth writes + // `createdAt`/`lastActivityAt` as `Date.now()` numbers and treats any record whose + // fields are not numbers as stale (dropping the session from its listing and SREM-ing the + // member). The RFC 3339 string this used to emit therefore made every rust-written + // session vanish from a nest-auth listing on a shared Redis — and made nest-written + // details undecodable here. Pin the numeric form in both directions. + let value = SessionDetailValue::at_creation(&SessionRecord { + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + ..record() + }); + let json = serde_json::to_string(&value).unwrap_or_default(); + assert!(json.contains("\"createdAt\":1700000000000")); + assert!(json.contains("\"lastActivityAt\":1700000000000")); + assert!(!json.contains("\"createdAt\":\"")); + + // A detail record written by nest-auth (numbers, millisecond precision) decodes here. + let from_nest: Result = serde_json::from_str( + r#"{"device":"Safari","ip":"198.51.100.9","createdAt":1700000000123,"lastActivityAt":1700000060456}"#, + ); + assert!(matches!( + from_nest, + Ok(v) + if v.device == "Safari" + && v.created_at.unix_timestamp_nanos() / 1_000_000 == 1_700_000_000_123 + && v.last_activity_at.unix_timestamp_nanos() / 1_000_000 == 1_700_000_060_456 + )); + } + + #[test] + fn index_member_renders_the_full_key_suffix_for_every_keyspace() { + // The `sess:`/`psess:` SET members are key SUFFIXES, byte-identical to nest-auth's + // `rt:{hash}` / `prt:{hash}` / `rp:{oldHash}` / `prp:{oldHash}`. This is what makes a + // cross-backend revoke work at all (each backend deletes `{ns}:{member}` verbatim) and + // what makes a grace pointer distinguishable from a live session inside revoke-all. + assert_eq!(index_member(Prefix::Rt, "deadbeef"), "rt:deadbeef"); + assert_eq!(index_member(Prefix::Prt, "deadbeef"), "prt:deadbeef"); + assert_eq!(index_member(Prefix::Rp, "deadbeef"), "rp:deadbeef"); + assert_eq!(index_member(Prefix::Prp, "deadbeef"), "prp:deadbeef"); + // A bare hash is never a valid member — the regression this format replaced. + assert_ne!(index_member(Prefix::Rt, "deadbeef"), "deadbeef"); + } + + #[test] + fn live_member_hash_accepts_only_the_matching_live_prefix() { + // Listing must yield live sessions only: a `rp:`/`prp:` grace pointer shares the index + // (so revoke-all can sweep it) but is not a session and must not surface as one. The + // helper also strips the prefix, because the `sd:`/`psd:` detail key is keyed by the + // BARE hash — reusing the member verbatim would look up `sd:rt:{hash}` and find nothing. + assert_eq!(live_member_hash("rt:abc123", Prefix::Rt), Some("abc123")); + assert_eq!(live_member_hash("prt:abc123", Prefix::Prt), Some("abc123")); + // Grace pointers are rejected for their own keyspace's live prefix. + assert_eq!(live_member_hash("rp:abc123", Prefix::Rt), None); + assert_eq!(live_member_hash("prp:abc123", Prefix::Prt), None); + // Cross-keyspace members are rejected: `prt:` must not be read as a dashboard session, + // and `rt:` is not a prefix of `prt:` so the platform side rejects it too. + assert_eq!(live_member_hash("prt:abc123", Prefix::Rt), None); + assert_eq!(live_member_hash("rt:abc123", Prefix::Prt), None); + // A legacy bare-hash member (the old format) is not a live member and is skipped. + assert_eq!(live_member_hash("abc123", Prefix::Rt), None); + // The separator is required — a prefix match without the colon is not a member. + assert_eq!(live_member_hash("rtabc123", Prefix::Rt), None); + } + + #[test] + fn invalidate_user_sessions_script_deletes_the_member_key_directly() { + // Static guard on the revoke-all Lua: it must delete `{namespace}:{member}` (the member + // already names its keyspace) instead of re-prefixing a bare hash with the live prefix. + // Re-prefixing is what made grace pointers unsweepable — a rotated-away refresh token + // survived logout-all for its whole grace window. Also assert the detail key is still + // rebuilt from the stripped hash, so `sd:`/`psd:` records are not orphaned. + let source = include_str!("../lua/invalidate_user_sessions.lua"); + assert!(source.contains("redis.call('DEL', ARGV[1] .. ':' .. member)")); + assert!(!source.contains("ARGV[1] .. ':' .. ARGV[2] .. ':' .. member")); + assert!(source.contains("string.sub(member, #live + 1)")); + } } diff --git a/crates/bymax-auth-redis/src/stores/single_use.rs b/crates/bymax-auth-redis/src/stores/single_use.rs index 988acce..7b21626 100644 --- a/crates/bymax-auth-redis/src/stores/single_use.rs +++ b/crates/bymax-auth-redis/src/stores/single_use.rs @@ -1,5 +1,5 @@ //! [`PasswordResetStore`] and [`InvitationStore`] over Redis: the small single-use -//! opaque-token keyspaces (`pr:`/`prv:`/`inv:`, section 12.4). Each stores a JSON value keyed +//! opaque-token keyspaces (`pw_reset:`/`pw_vtok:`/`inv:`, section 12.4). Each stores a JSON value keyed //! by `sha256(token)` — the raw token is never a key — with a TTL, and consumes it atomically //! with `GETDEL` so a proof or invitation is valid exactly once. The reset link token also //! supports an out-of-band `DEL` used to clean up after an undeliverable email. @@ -83,19 +83,19 @@ impl PasswordResetStore for RedisStores { context: &ResetContext, ttl_secs: u64, ) -> Result<(), AuthError> { - self.put_value(Prefix::Pr, token, context, ttl_secs) + self.put_value(Prefix::PwReset, token, context, ttl_secs) .await .map_err(AuthError::from) } async fn consume_token(&self, token: &str) -> Result, AuthError> { - self.consume_value(Prefix::Pr, token) + self.consume_value(Prefix::PwReset, token) .await .map_err(AuthError::from) } async fn delete_token(&self, token: &str) -> Result<(), AuthError> { - self.delete_value(Prefix::Pr, token) + self.delete_value(Prefix::PwReset, token) .await .map_err(AuthError::from) } @@ -106,13 +106,13 @@ impl PasswordResetStore for RedisStores { context: &ResetContext, ttl_secs: u64, ) -> Result<(), AuthError> { - self.put_value(Prefix::Prv, token, context, ttl_secs) + self.put_value(Prefix::PwVtok, token, context, ttl_secs) .await .map_err(AuthError::from) } async fn consume_verified(&self, token: &str) -> Result, AuthError> { - self.consume_value(Prefix::Prv, token) + self.consume_value(Prefix::PwVtok, token) .await .map_err(AuthError::from) } diff --git a/crates/bymax-auth-redis/tests/common/mod.rs b/crates/bymax-auth-redis/tests/common/mod.rs index d30421d..26beab3 100644 --- a/crates/bymax-auth-redis/tests/common/mod.rs +++ b/crates/bymax-auth-redis/tests/common/mod.rs @@ -81,6 +81,39 @@ impl TestRedis { .flatten() } + /// Write a raw string value out-of-band with a short TTL. Used to plant a record in exactly + /// the shape the *other* backend (nest-auth) would write it, so the read path can be proven + /// to decode it. Returns whether the command succeeded. + pub async fn set_raw(&self, key: &str, value: &str) -> bool { + let Some(mut conn) = self.raw().await else { + return false; + }; + redis::cmd("SET") + .arg(key) + .arg(value) + .arg("EX") + .arg(3600) + .query_async::<()>(&mut conn) + .await + .is_ok() + } + + /// The members of a SET, sorted for a stable comparison. Used to assert the exact wire + /// format of the `sess:`/`psess:` session-index members, which must be full key suffixes + /// (`rt:{hash}`, `rp:{oldHash}`, …) byte-identical to what nest-auth writes. + pub async fn smembers(&self, key: &str) -> Vec { + let Some(mut conn) = self.raw().await else { + return Vec::new(); + }; + let mut members: Vec = redis::cmd("SMEMBERS") + .arg(key) + .query_async(&mut conn) + .await + .unwrap_or_default(); + members.sort(); + members + } + /// The TTL (seconds) of a key: `-2` when absent, `-1` when it has no expiry. pub async fn ttl(&self, key: &str) -> i64 { let Some(mut conn) = self.raw().await else { diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index c4c40b8..e6d0996 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -227,6 +227,210 @@ async fn platform_sessions_use_the_platform_keyspace() { assert!(matches!(stores.list_sessions(kind, "padmin").await, Ok(v) if v.is_empty())); } +#[tokio::test] +async fn session_index_members_are_prefixed_key_suffixes() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + + // Cross-backend parity: nest-auth stores the `sess:`/`psess:` members as full key SUFFIXES + // (`rt:{hash}`, `prt:{hash}`) and its revoke-all Lua deletes `{namespace}:{member}` + // verbatim. A bare hash — the format this replaced — is unrevokable from the other backend + // on a shared Redis, and unrevokable *here* for anything the other backend wrote. + assert!( + stores + .create_session(SessionKind::Dashboard, "m1", &record("mu"), 3600) + .await + .is_ok() + ); + assert_eq!(redis.smembers("auth:sess:mu").await, vec!["rt:m1"]); + + // The platform keyspace stays SEPARATE (`psess:` not `sess:`) and uses its own `prt:` member. + assert!( + stores + .create_session(SessionKind::Platform, "p1", &record("pu"), 3600) + .await + .is_ok() + ); + assert_eq!(redis.smembers("auth:psess:pu").await, vec!["prt:p1"]); + assert!(redis.smembers("auth:sess:pu").await.is_empty()); + + // Listing strips the prefix so `session_hash` stays the bare hash the domain layer + // validates as 64-hex, and so the `sd:` detail key (keyed by the bare hash) resolves. + assert!(matches!( + stores.list_sessions(SessionKind::Dashboard, "mu").await, + Ok(v) if v.len() == 1 && v[0].session_hash == "m1" + )); + + // Revoke is ownership-checked against the prefixed member; it must still match. + assert!( + stores + .revoke_session(SessionKind::Dashboard, "mu", "m1") + .await + .is_ok() + ); + assert!(redis.smembers("auth:sess:mu").await.is_empty()); +} + +#[tokio::test] +async fn revoke_all_sweeps_rotation_grace_pointers() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // SECURITY REGRESSION GUARD. A rotation plants an `rp:{oldHash}` grace pointer that can + // still mint a live session for the whole grace window. With bare-hash SET members the + // revoke-all script could not tell an `rt:` hash from an `rp:` one, so it could not delete + // the pointer — a rotated-away refresh token survived "log out everywhere" and kept + // recovering sessions. Indexing the pointer as `rp:{oldHash}` is what makes it sweepable. + assert!( + stores + .create_session(kind, "g1", &record("gu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores.rotate(kind, &rotation("g1", "g2", "gu")).await, + Ok(RotateOutcome::Rotated(_)) + )); + + // Post-rotation the index holds the new live session AND the grace pointer for the old one. + assert_eq!(redis.smembers("auth:sess:gu").await, vec!["rp:g1", "rt:g2"]); + assert!(redis.ttl("auth:rp:g1").await > 0); + // The grace pointer is not a session, so it must not appear in the user's session list. + assert!(matches!( + stores.list_sessions(kind, "gu").await, + Ok(v) if v.len() == 1 && v[0].session_hash == "g2" + )); + + assert!(stores.revoke_all(kind, "gu").await.is_ok()); + + // The grace pointer key is GONE (`-2` = absent), not merely orphaned in the index. + assert_eq!(redis.ttl("auth:rp:g1").await, -2); + // …and so are the live session, its detail, and the index itself. + assert_eq!(redis.ttl("auth:rt:g2").await, -2); + assert_eq!(redis.ttl("auth:sd:g2").await, -2); + assert!(redis.smembers("auth:sess:gu").await.is_empty()); + + // The security property, observed through the API: replaying the rotated-away token can no + // longer recover a session through the grace window after a revoke-all. + assert!(matches!( + stores.rotate(kind, &rotation("g1", "g3", "gu")).await, + Ok(RotateOutcome::Invalid) + )); +} + +#[tokio::test] +async fn platform_revoke_all_sweeps_its_own_grace_pointers() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Platform; + + // The same grace-pointer sweep must hold for the platform keyspace, which keeps its own + // SEPARATE index (`psess:`) and its own prefixes (`prt:`/`prp:`/`psd:`) — the separation is + // deliberate, only the member FORMAT is shared with the dashboard side. + assert!( + stores + .create_session(kind, "pg1", &record("pgu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores.rotate(kind, &rotation("pg1", "pg2", "pgu")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert_eq!( + redis.smembers("auth:psess:pgu").await, + vec!["prp:pg1", "prt:pg2"] + ); + assert!(redis.ttl("auth:prp:pg1").await > 0); + + assert!(stores.revoke_all(kind, "pgu").await.is_ok()); + assert_eq!(redis.ttl("auth:prp:pg1").await, -2); + assert_eq!(redis.ttl("auth:prt:pg2").await, -2); + assert_eq!(redis.ttl("auth:psd:pg2").await, -2); + // The dashboard index was never touched — the keyspaces stay independent. + assert!(redis.smembers("auth:sess:pgu").await.is_empty()); +} + +#[tokio::test] +async fn zero_grace_rotation_indexes_no_grace_member() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // A zero-width grace window writes no `rp:` key, so indexing an `rp:` member for it would + // leave a member pointing at nothing. Only the live session is indexed. + assert!( + stores + .create_session(kind, "n1", &record("nu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores + .rotate(kind, &rotation_with_grace("n1", "n2", "nu", 0)) + .await, + Ok(RotateOutcome::Rotated(_)) + )); + assert_eq!(redis.smembers("auth:sess:nu").await, vec!["rt:n2"]); +} + +#[tokio::test] +async fn session_detail_is_stored_with_unix_millisecond_timestamps() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + + // Wire-format parity for `sd:`: nest-auth writes `createdAt`/`lastActivityAt` as numbers and + // discards a detail record whose fields are not numbers. Assert what actually lands in + // Redis, not just what the DTO round-trips — this is the byte-level shared-Redis contract. + let rec = SessionRecord { + created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000) + .unwrap_or(OffsetDateTime::UNIX_EPOCH), + ..record("tu") + }; + assert!( + stores + .create_session(SessionKind::Dashboard, "t1", &rec, 3600) + .await + .is_ok() + ); + let raw = redis.get("auth:sd:t1").await.unwrap_or_default(); + assert!(raw.contains("\"createdAt\":1700000000000"), "got {raw}"); + assert!( + raw.contains("\"lastActivityAt\":1700000000000"), + "got {raw}" + ); + assert!(!raw.contains("\"createdAt\":\""), "got {raw}"); + + // A detail record written by nest-auth (numeric timestamps) is readable here: the session + // shows up in the listing with the exact instants nest-auth recorded. + assert!( + redis + .set_raw( + "auth:sd:t1", + r#"{"device":"Safari","ip":"198.51.100.9","createdAt":1700000000123,"lastActivityAt":1700000060456}"#, + ) + .await + ); + assert!(matches!( + stores.list_sessions(SessionKind::Dashboard, "tu").await, + Ok(v) if v.len() == 1 + && v[0].device == "Safari" + && v[0].created_at.unix_timestamp_nanos() / 1_000_000 == 1_700_000_000_123 + && v[0].last_activity_at.unix_timestamp_nanos() / 1_000_000 == 1_700_000_060_456 + )); +} + #[tokio::test] async fn otp_put_verify_outcomes_and_resend() { let Some(redis) = common::try_start().await else { @@ -394,7 +598,7 @@ async fn keys_are_namespaced_no_pii_and_carry_a_ttl() { )); assert!(matches!(stores.record_failure("bfhmac", 900).await, Ok(1))); assert!(stores.blacklist_access("jti-xyz", 60).await.is_ok()); - // The single-use opaque-token keyspaces (`pr:`/`prv:`/`inv:`) also appear, hashed by + // The single-use opaque-token keyspaces (`pw_reset:`/`pw_vtok:`/`inv:`) also appear, hashed by // sha256(token) so a raw token (which could contain attacker-chosen bytes) is never a key. let reset_context = ResetContext { user_id: "user-42".to_owned(), @@ -422,6 +626,7 @@ async fn keys_are_namespaced_no_pii_and_carry_a_ttl() { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: "user-42".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, }, 604800, ) @@ -462,7 +667,7 @@ async fn password_reset_and_invitation_stores_are_single_use_via_getdel() { }; let Some(stores) = redis.stores() else { return }; - // Reset link token: stored under `pr:`, consumed once. + // Reset link token: stored under `pw_reset:`, consumed once. let reset = ResetContext { user_id: "u1".to_owned(), email: "u@example.com".to_owned(), @@ -484,7 +689,7 @@ async fn password_reset_and_invitation_stores_are_single_use_via_getdel() { Ok(None) )); - // Verified token: stored under `prv:`, consumed once. + // Verified token: stored under `pw_vtok:`, consumed once. assert!(stores.put_verified("vt-secret", &reset, 300).await.is_ok()); assert!(matches!( stores.consume_verified("vt-secret").await, @@ -502,6 +707,7 @@ async fn password_reset_and_invitation_stores_are_single_use_via_getdel() { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: "owner".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, }; assert!( stores @@ -565,7 +771,7 @@ async fn engine_runs_password_reset_via_token_against_redis() { return; }; - // Initiate stores a reset token under `pr:` (best-effort); the raw token is opaque to the + // Initiate stores a reset token under `pw_reset:` (best-effort); the raw token is opaque to the // test, so plant a known token via the store to drive the reset deterministically. assert!( engine @@ -945,6 +1151,7 @@ async fn engine_runs_invitation_accept_against_redis() { role: "MEMBER".to_owned(), tenant_id: "t1".to_owned(), inviter_user_id: admin.id.clone(), + created_at: OffsetDateTime::UNIX_EPOCH, }, 604800, ) diff --git a/docs/technical_specification.md b/docs/technical_specification.md index 5652677..d205209 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -2255,7 +2255,7 @@ impl PasswordResetService { } ``` -Constants: `ANTI_ENUM_MIN_MS = 300`; `VERIFIED_TOKEN_TTL_SECONDS = 300`; purpose `"password_reset"`. Redis keys: `pr:{sha256(token)}`, `prv:{sha256(verified_token)}` (the §12.4 catalog prefixes), both storing `ResetContext { user_id, email, tenant_id }`. OTP identifier: `hmac_sha256("{tenant_id}:{email}", hmac_key)`. +Constants: `ANTI_ENUM_MIN_MS = 300`; `VERIFIED_TOKEN_TTL_SECONDS = 300`; purpose `"password_reset"`. Redis keys: `pw_reset:{sha256(token)}`, `pw_vtok:{sha256(verified_token)}` (the §12.4 catalog prefixes), both storing `ResetContext { user_id, email, tenant_id }`. OTP identifier: `hmac_sha256("{tenant_id}:{email}", hmac_key)`. #### 7.8.1 `initiate_reset` (anti-enumeration) @@ -2265,7 +2265,7 @@ Always returns `Ok(())`, even on unknown email, blocked account, or email-provid 3. Catch and log any error. 4. Sleep to `ANTI_ENUM_MIN_MS`; return. -`send_token`: `raw = generate_secure_token(32)`; store `ResetContext` under `pr:{sha256(raw)}` with `token_ttl_seconds`; spawn `email.send_password_reset_token(email, raw)`; **on send failure, delete the Redis key** (so an undeliverable token doesn't linger in a Redis snapshot). +`send_token`: `raw = generate_secure_token(32)`; store `ResetContext` under `pw_reset:{sha256(raw)}` with `token_ttl_seconds`; spawn `email.send_password_reset_token(email, raw)`; **on send failure, delete the Redis key** (so an undeliverable token doesn't linger in a Redis snapshot). `send_otp`: `otp = generate(otp_length)`; `otp.store("password_reset", id, otp, otp_ttl_seconds)`; spawn `email.send_password_reset_otp(email, otp)`. (Email is fire-and-forget so its RTT does not perturb the normalized timing.) #### 7.8.2 `reset_password` @@ -2282,7 +2282,7 @@ Exactly one proof field must be present. 1. `otp.verify("password_reset", id, dto.otp)` (consumes on success). 2. `find_by_email`; if `None`, `PasswordResetTokenInvalid` (don't issue a verified token for a vanished account). -3. `raw_verified = generate_secure_token()`; store `ResetContext` under `prv:{sha256(raw_verified)}` with 300 s TTL; return `raw_verified`. +3. `raw_verified = generate_secure_token()`; store `ResetContext` under `pw_vtok:{sha256(raw_verified)}` with 300 s TTL; return `raw_verified`. #### 7.8.4 `resend_otp` @@ -4275,21 +4275,21 @@ All keys are `{namespace}:{prefix}:{identifier}` (default namespace `auth`). Eve | `us` | `auth:us:{userId}` | Status string (`"ACTIVE"`, `"BANNED"`, …) | `user_status_cache_ttl_seconds` (default 60 s) | User-status cache. Avoids a DB read per request; invalidated on `update_status`. | | `rp` | `auth:rp:{sha256(old_refresh_token)}` | JSON `SessionRecord` — the **new** session, never the raw token | `refresh_grace_window_seconds` (default 30 s) | Rotation grace pointer. Lets a legitimately-concurrent request still carrying the old token recover the rotated identity and be issued a fresh token, instead of being logged out. Stores the session record (not the raw refresh token), so a Redis snapshot never leaks a live credential. | | `lf` | `auth:lf:{hmac_sha256(tenantId + ":" + email)}` | Numeric counter (string) | `window_seconds` (default 900 s) | Per-tenant failed-login counter. Fixed window — TTL set only on the 0→1 transition. Tenant scoping prevents cross-tenant lockout. | -| `pr` | `auth:pr:{sha256(reset_token)}` | `userId` | `password_reset.token_ttl_seconds` (default 3600 s) | Password-reset token (`method = "token"`). Consumed on use. | +| `pw_reset` | `auth:pw_reset:{sha256(reset_token)}` | `userId` | `password_reset.token_ttl_seconds` (default 3600 s) | Password-reset token (`method = "token"`). Consumed on use. | | `otp` | `auth:otp:{purpose}:{hmac_sha256(tenantId + ":" + email)}` | JSON `{ code: string, attempts: number }` | `otp_ttl_seconds` (per purpose) | OTP record. `attempts` tracks failures (max 5). Purposes: `password_reset`, `email_verification`. Tenant-scoped to prevent collision. | | `mfa` | `auth:mfa:{sha256(mfa_temp_token)}` | `userId` | 300 s (5 min) | MFA temp-token anti-replay/consumption marker. Issued after password step when MFA is enabled; deleted when the challenge succeeds or after the lockout threshold. | -| `sess` | `auth:sess:{userId}` | Redis SET of `sha256(refresh_token)` members | max refresh TTL | Dashboard active-session index. Drives listing, counting and FIFO eviction. | -| `sd` | `auth:sd:{sessionHash}` | JSON `{ device, ip, createdAt, lastActivityAt }` | max refresh TTL | Dashboard per-session detail (one member of `sess:{userId}`). | -| `inv` | `auth:inv:{sha256(invitation_token)}` | JSON `{ email, role, tenantId, inviterId }` | `invitations.token_ttl_seconds` (default 604800 s) | Pending invitation. Consumed on accept. | +| `sess` | `auth:sess:{userId}` | Redis SET whose members are full key **suffixes**: `rt:{sha256(refresh_token)}` for a live session and `rp:{sha256(old_refresh_token)}` for a rotation grace pointer | max refresh TTL | Dashboard active-session index. Drives listing (filtered to `rt:` members), counting and FIFO eviction. The prefixed-suffix member format is byte-identical to nest-auth, so revoke-all can delete `{namespace}:{member}` verbatim — including the grace pointers, which a bare-hash member could not identify. | +| `sd` | `auth:sd:{sessionHash}` | JSON `{ device, ip, createdAt, lastActivityAt }` — the two timestamps are **Unix-millisecond numbers** (nest-auth writes `Date.now()` and discards a record whose timestamps are not numbers), keyed by the **bare** hash, not the `sess:` member | max refresh TTL | Dashboard per-session detail (one `rt:` member of `sess:{userId}`). | +| `inv` | `auth:inv:{sha256(invitation_token)}` | JSON `{ email, role, tenantId, inviterUserId, createdAt }` (`createdAt` is an ISO-8601 string) | `invitations.token_ttl_seconds` (default 604800 s) | Pending invitation. Consumed on accept. `createdAt` is **mandatory**: nest-auth's record guard rejects a record without it, and because accept consumes the token with a single-use `GETDEL` the rejection would destroy the invitation rather than merely fail it. | | `os` | `auth:os:{sha256(state)}` | JSON `{ tenantId, codeVerifier }` | 600 s (10 min) | OAuth CSRF `state` + PKCE `code_verifier`. Single-use; deleted on callback. | | `wst` | `auth:wst:{sha256(ws_ticket)}` | JSON `WsTicketSnapshot` `{ sub, tenantId, role, status, mfaEnabled, mfaVerified }` | `WS_TICKET_TTL_SECONDS` (30 s) | **rust-auth-only, feature `websocket`.** Single-use WebSocket upgrade ticket (§7.3.6 / §8.7). Minted from an already-authorized, MFA-satisfied session and redeemed once (`GETDEL`) at the WS handshake, so an access JWT never appears in a URL. The value is a verified-claims **snapshot**, never a token. This prefix is outside the nest-auth parity surface (nest-auth authenticates WebSockets via the `Authorization` header, not a ticket), so it is purely additive: a `nest-auth` server never reads or writes `wst:`, and cross-backend Redis sharing is unaffected. | | `tu` | `auth:tu:{hmac_sha256(userId + ":" + code)}` | `"1"` | 90 s (≈ 3 × TOTP window) | TOTP anti-replay. The key is the **HMAC of `userId:code`**, never the raw 6-digit code (which is low-entropy and would be reversible as a bare key) — matching §7.5.6. A code that just verified is marked so it cannot be replayed inside its drift window. | | `prt` | `auth:prt:{sha256(refresh_token)}` | JSON `{ userId, role, device, ip, createdAt }` | `refresh_expires_in_days` × 86400 s | Platform-admin refresh session. Platform analogue of `rt`. | | `prp` | `auth:prp:{sha256(old_refresh_token)}` | JSON `SessionRecord` — the new session, never the raw token | `refresh_grace_window_seconds` (default 30 s) | Platform rotation grace pointer. Analogue of `rp`. | -| `prv` | `auth:prv:{sha256(verified_token)}` | JSON `{ email, tenantId }` | 300 s (5 min) | Password-reset OTP "verified" token (2-step OTP flow). Bridges verify-OTP → reset-password, closing the verify/reset race. Consumed on reset. | +| `pw_vtok` | `auth:pw_vtok:{sha256(verified_token)}` | JSON `{ email, tenantId }` | 300 s (5 min) | Password-reset OTP "verified" token (2-step OTP flow). Bridges verify-OTP → reset-password, closing the verify/reset race. Consumed on reset. | | `mfa_setup` | `auth:mfa_setup:{hmac_sha256(userId)}` | JSON `{ encryptedSecret, hashedCodes: string[], encryptedPlainCodes }` | 600 s (10 min) | MFA pending-setup data: AES-256-GCM-encrypted TOTP secret + HMAC-SHA-256-keyed recovery-code hashes + the AES-256-GCM-encrypted plaintext codes (so the idempotent `setup()` fast-path can re-return them), held between `setup()` and `verify_enable()`. Consumed on enable. The low-entropy `userId` is keyed via HMAC-SHA-256 (§12.2), matching §7.5.1. | -| `psess` | `auth:psess:{userId}` | Redis SET of platform `sha256(refresh_token)` members | max refresh TTL | Platform active-session index. Analogue of `sess`. | -| `psd` | `auth:psd:{sessionHash}` | JSON `{ device, ip, createdAt, lastActivityAt }` | max refresh TTL | Platform per-session detail. Analogue of `sd`. | +| `psess` | `auth:psess:{userId}` | Redis SET of full key **suffixes**: `prt:{sha256(refresh_token)}` and `prp:{sha256(old_refresh_token)}` | max refresh TTL | Platform active-session index. Analogue of `sess`; the platform keyspace is deliberately separate. | +| `psd` | `auth:psd:{sessionHash}` | JSON `{ device, ip, createdAt, lastActivityAt }` (Unix-millisecond timestamps, as `sd`) | max refresh TTL | Platform per-session detail. Analogue of `sd`. | | `resend` | `auth:resend:{purpose}:{hmac_sha256(tenantId + ":" + email)}` | `"1"` | 60 s | OTP-resend cooldown. Stops an attacker from resetting the `attempts` counter by spamming resends. Purposes: `password_reset`, `email_verification`. | Values that are JSON are (de)serialized with `serde` + `serde_json`; the DTOs (`SessionRecord`, `SessionDetail`, the OTP record, the invitation record, the MFA-setup record) live in `bymax-auth-types` so the wire shape is shared and version-checked. Field names are camelCase to remain byte-identical with nest-auth payloads already in Redis. @@ -4308,14 +4308,14 @@ Prevents the classic double-rotation race: two concurrent requests carrying the 1. `GET KEYS[1]`. If present → `DEL KEYS[1]`; `SET KEYS[3] = ARGV[1] EX ARGV[3]` (plant the grace pointer holding the **new session JSON**, never the raw token); `SET KEYS[2] = ARGV[1] EX ARGV[2]` (new session); **return the old session JSON** (caller derives `userId`, updates the `sess:` SET + `sd:` detail). 2. Else `GET KEYS[3]` (grace pointer). If present → **return `"GRACE:" .. session_json`**; the caller parses the pointed-to `SessionRecord` and mints a **fresh** token bound to that identity (it does *not* plant another grace pointer), so a benign concurrent retry succeeds without a logout. 3. Else → **return `nil`** ⇒ caller raises `AuthError::RefreshTokenInvalid`. -- **Rust mapping:** `RotateOutcome::{ Rotated(SessionRecord), Grace(SessionRecord), Invalid }`. The `SessionStore::rotate` impl pattern-matches and performs the non-atomic SET bookkeeping (`SADD sess`, `SET sd`, `SREM` of the old hash) outside the script; on `Grace` it mints a fresh token for the recovered `SessionRecord`. Storing the session record — not the raw token — keeps the "no raw secret in Redis" invariant intact for the grace pointer too. +- **Rust mapping:** `RotateOutcome::{ Rotated(SessionRecord), Grace(SessionRecord), Invalid }`. The `SessionStore::rotate` impl pattern-matches and performs the non-atomic SET bookkeeping outside the script — `SREM rt:{old_hash}`, `SADD rt:{new_hash}`, `SADD rp:{old_hash}` (the grace pointer is indexed too, so `revoke_all` can sweep it), `DEL sd:{old_hash}`, `SET sd:{new_hash}`; on `Grace` it mints a fresh token for the recovered `SessionRecord`. Storing the session record — not the raw token — keeps the "no raw secret in Redis" invariant intact for the grace pointer too. #### 12.5.2 `session_revoke` — ownership-checked single revoke Closes an IDOR/BOLA hole: a user must not revoke a session hash they do not own. - **KEYS** — `[1]` `sess:{userId}` (or `psess:{userId}`), `[2]` `rt:{sessionHash}` (or `prt:{sessionHash}`), `[3]` `sd:{sessionHash}` (or `psd:{sessionHash}`). All three arrive fully namespaced (built by `NamespacedRedis`), so **every** key the script touches is declared in `KEYS` — required for Redis Cluster key routing. -- **ARGV** — `[1]` `sessionHash` (the `sess:`-set member). +- **ARGV** — `[1]` `rt:{sessionHash}` (or `prt:{sessionHash}`) — the `sess:`-set member, which is the full key suffix, not a bare hash. - **Contract:** 1. `SISMEMBER KEYS[1] ARGV[1]`. If `0` → **return `0`** ⇒ caller raises `AuthError::SessionNotFound` (404). 2. Else `SREM KEYS[1] ARGV[1]`; `DEL KEYS[2]`; `DEL KEYS[3]` → **return `1`**. @@ -4806,7 +4806,7 @@ All codes, grouped by domain, with HTTP status, trigger, and client-facing Engli | Code | HTTP | When raised | Client message | | --- | --- | --- | --- | | `auth.password_too_weak` | 400 | New password fails minimum policy (e.g. < 8 chars). | Password too weak | -| `auth.password_reset_token_invalid` | 400 | Reset token absent from Redis (`pr:`). | Invalid password reset token | +| `auth.password_reset_token_invalid` | 400 | Reset token absent from Redis (`pw_reset:`). | Invalid password reset token | | `auth.password_reset_token_expired` | 400 | **Internal only / unreachable by design.** The reset flow consumes the token with `GETDEL` (§7.8.2), which cannot distinguish *expired* from *missing* — both map to `auth.password_reset_token_invalid` (anti-enumeration). This code is defined for completeness but is never returned unless a deployment adds an explicit TTL pre-check. | Expired password reset token | #### OTP From 84ac12c0c7ab2a1b4f68a4e6631311d0f505d643 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:37:58 -0300 Subject: [PATCH 009/122] fix(mfa): store the TOTP secret in nest-auth's at-rest form, accept its legacy tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cross-backend credential formats that did not line up. The TOTP secret was encrypted as raw bytes; nest-auth encrypts the Base32 text. Both backends read the same `mfaSecret` column and the same `mfa_setup:` record, so decrypting a nest-written secret here handed Base32 ASCII to HMAC-SHA-1 as the key and rejected every code the user's authenticator produced — and the reverse broke the same way. Encrypting the presentation form is marginally redundant, but the published side already stores it that way and re-encrypting live MFA credentials to save twelve bytes is not a trade worth making. A `decrypt_secret` helper now owns the decrypt-then-decode step so no call site can reintroduce the mismatch, and every failure still collapses to one opaque error rather than a format oracle. The refresh-token shape guard accepted only the 256-bit hex form. nest-auth minted UUID v4 before both sides converged, and those tokens live in the shared Redis for a full refresh lifetime — seven days by default — so refusing the shape refused to rotate sessions that were still valid with their `rt:{sha256}` record right there. The legacy shape is accepted alongside the current one; it grants nothing by itself, since the token still has to hash to a stored session. Tests pin that the allowance is one exact shape and not "anything with dashes". --- .../src/services/mfa/challenge.rs | 4 +- .../src/services/mfa/manage.rs | 4 +- .../bymax-auth-core/src/services/mfa/mod.rs | 23 +++++- .../bymax-auth-core/src/services/mfa/setup.rs | 2 +- .../bymax-auth-core/src/services/mfa/tests.rs | 5 +- crates/bymax-auth-core/src/services/mod.rs | 76 +++++++++++++++++-- 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 46f7985..9a0b092 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -86,7 +86,7 @@ impl MfaService { let Some(encrypted_secret) = user.mfa_secret.clone().filter(|_| user.mfa_enabled) else { return Err(AuthError::MfaNotEnabled); }; - let Some(raw_secret) = self.decrypt(&encrypted_secret) else { + let Some(raw_secret) = self.decrypt_secret(&encrypted_secret) else { // A secret that will not decrypt is an opaque failure (no decrypt oracle). return Err(AuthError::TokenInvalid); }; @@ -173,7 +173,7 @@ impl MfaService { let Some(encrypted_secret) = admin.mfa_secret.clone().filter(|_| admin.mfa_enabled) else { return Err(AuthError::MfaNotEnabled); }; - let Some(raw_secret) = self.decrypt(&encrypted_secret) else { + let Some(raw_secret) = self.decrypt_secret(&encrypted_secret) else { return Err(AuthError::TokenInvalid); }; diff --git a/crates/bymax-auth-core/src/services/mfa/manage.rs b/crates/bymax-auth-core/src/services/mfa/manage.rs index f24b2e5..ca9045d 100644 --- a/crates/bymax-auth-core/src/services/mfa/manage.rs +++ b/crates/bymax-auth-core/src/services/mfa/manage.rs @@ -100,7 +100,9 @@ impl MfaService { self.assert_not_locked(&bf_id).await?; // An enabled account with no stored secret is an inconsistency, not a user error. let encrypted = view.mfa_secret.clone().ok_or(AuthError::TokenInvalid)?; - let raw_secret = self.decrypt(&encrypted).ok_or(AuthError::TokenInvalid)?; + let raw_secret = self + .decrypt_secret(&encrypted) + .ok_or(AuthError::TokenInvalid)?; if !self .verify_totp_with_anti_replay(user_id, &raw_secret, code) .await? diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index 49adab2..bab1e09 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -266,6 +266,18 @@ impl MfaService { aead::decrypt(wire, &self.encryption_key).ok() } + /// Decrypt a stored TOTP secret back to the raw bytes the HMAC uses as its key. + /// + /// The at-rest form is the encrypted Base32 TEXT (see [`Self::generate_setup_material`]), + /// so recovering the key means decrypting and then decoding. Returns `None` on every + /// failure — wrong key, tampered ciphertext, non-UTF-8, or invalid Base32 — so the caller + /// surfaces one opaque error and no decrypt/format oracle distinguishes them. + fn decrypt_secret(&self, wire: &str) -> Option> { + let encoded = self.decrypt(wire)?; + let text = String::from_utf8(encoded).ok()?; + totp::decode_secret_base32(&text).ok() + } + /// Verify a 6-digit TOTP `code` against `secret` and, on success, atomically mark it used /// (the standalone `tu:` marker, `NX EX` with the window-derived TTL from /// [`Self::anti_replay_ttl_seconds`]). A code that verifies but was already seen returns @@ -429,7 +441,14 @@ impl MfaService { .ok() .ok_or(internal_error("mfa codes encode"))?; let data = MfaSetupData { - encrypted_secret: self.encrypt(&raw_secret)?, + // The Base32 TEXT is what goes under the cipher, not the raw bytes. nest-auth + // encrypts `generateTotpSecret().base32` and both backends read the same + // `mfaSecret` column and the same `mfa_setup:` record: decrypting a nest-written + // secret as raw bytes would hand base32 ASCII to HMAC-SHA-1 as the key and reject + // every code the user's authenticator produces. Encrypting the presentation form + // is marginally redundant, but the published side already stores it that way and + // re-encrypting live MFA credentials to save 12 bytes is not a trade worth making. + encrypted_secret: self.encrypt(totp::encode_secret_base32(&raw_secret).as_bytes())?, hashed_codes, encrypted_plain_codes: self.encrypt(plain_json.as_bytes())?, }; @@ -453,7 +472,7 @@ impl MfaService { let data: MfaSetupData = serde_json::from_str(record_json) .map_err(|_| internal_error("mfa setup record decode"))?; let raw_secret = self - .decrypt(&data.encrypted_secret) + .decrypt_secret(&data.encrypted_secret) .ok_or_else(|| internal_error("mfa setup record secret decrypt"))?; let plain_json = self .decrypt(&data.encrypted_plain_codes) diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index aff7369..6d4282b 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -100,7 +100,7 @@ impl MfaService { let data: MfaSetupData = serde_json::from_str(&record_json).map_err(|_| AuthError::MfaSetupRequired)?; let raw_secret = self - .decrypt(&data.encrypted_secret) + .decrypt_secret(&data.encrypted_secret) .ok_or(AuthError::MfaSetupRequired)?; // Verify the code with anti-replay before the completion gate, so an invalid code never diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 44b7248..820c427 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1053,7 +1053,10 @@ fn record_with(secret_wire: String, plain_wire: String) -> String { /// A valid encrypted-secret wire (the raw secret `[1u8; 20]` under `[7u8; 32]`). fn good_secret_wire() -> String { - bymax_auth_crypto::aead::encrypt(&[1u8; 20], &[7u8; 32]).unwrap_or_default() + // The at-rest form is the encrypted Base32 TEXT, not the raw bytes — the same shape + // nest-auth writes, so the two backends can read one another's `mfaSecret`. + let base32 = bymax_auth_crypto::totp::encode_secret_base32(&[1u8; 20]); + bymax_auth_crypto::aead::encrypt(base32.as_bytes(), &[7u8; 32]).unwrap_or_default() } /// A valid pending-setup record encrypted under `[7u8; 32]`, carrying `recovery` as the single diff --git a/crates/bymax-auth-core/src/services/mod.rs b/crates/bymax-auth-core/src/services/mod.rs index 845132d..ba02d0b 100644 --- a/crates/bymax-auth-core/src/services/mod.rs +++ b/crates/bymax-auth-core/src/services/mod.rs @@ -92,15 +92,46 @@ pub(crate) fn now_offset() -> OffsetDateTime { OffsetDateTime::now_utc() } -/// The fixed shape of an engine-issued opaque refresh token: exactly 64 lower-case hex -/// characters (256 bits, the `generate_secure_token(32)` output). Checking the shape before -/// hashing rejects an oversized or malformed value cheaply — without an allocation and a -/// SHA-256 over an unbounded input — and such a value could never match a stored hash anyway. +/// Whether `raw` could be a refresh token this deployment would have issued. +/// +/// The current shape is exactly 64 lower-case hex characters (256 bits, the +/// `generate_secure_token(32)` output). Checking before hashing rejects an oversized or +/// malformed value cheaply — no allocation and no SHA-256 over unbounded input — and such a +/// value could never match a stored hash anyway. +/// +/// The legacy UUID-v4 shape is accepted too. nest-auth minted refresh tokens as UUID v4 +/// before both sides converged on 256-bit tokens, and those live in the shared Redis for a +/// full refresh lifetime (seven days by default). Rejecting them here would refuse to rotate +/// a session that is still perfectly valid and whose `rt:{sha256}` record is right there. +/// Accepting the shape grants nothing on its own — the token still has to hash to a stored +/// session — so this is a parsing allowance, not a weakened check. +/// +/// Remove the legacy arm once every token predating the convergence has expired. pub(crate) fn is_refresh_token_shape(raw: &str) -> bool { - raw.len() == 64 - && raw - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + is_hex_token_shape(raw) || is_legacy_uuid_shape(raw) +} + +/// The current shape: 64 lower-case hex characters. +fn is_hex_token_shape(raw: &str) -> bool { + raw.len() == 64 && raw.bytes().all(is_lower_hex) +} + +/// The legacy shape: a lower-case UUID v4, `8-4-4-4-12` hex with dashes at fixed offsets. +fn is_legacy_uuid_shape(raw: &str) -> bool { + const DASH_POSITIONS: [usize; 4] = [8, 13, 18, 23]; + raw.len() == 36 + && raw.bytes().enumerate().all(|(index, byte)| { + if DASH_POSITIONS.contains(&index) { + byte == b'-' + } else { + is_lower_hex(byte) + } + }) +} + +/// Whether `byte` is a lower-case hexadecimal digit. +fn is_lower_hex(byte: u8) -> bool { + byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) } #[cfg(test)] @@ -119,6 +150,35 @@ mod tests { assert!(!is_refresh_token_shape("")); } + #[test] + fn is_refresh_token_shape_accepts_a_legacy_uuid_v4() { + // nest-auth minted UUID-v4 refresh tokens before the two sides converged on 256-bit + // ones, and those survive in the shared Redis for a whole refresh lifetime. Refusing + // the shape would refuse to rotate a session that is still valid and stored. + assert!(is_refresh_token_shape( + "11111111-2222-4333-8444-555555555555" + )); + } + + #[test] + fn is_refresh_token_shape_rejects_uuid_lookalikes() { + // The allowance is for one exact legacy shape, not "anything with dashes": a dash in + // the wrong place, an upper-case digit, a non-hex character, and a wrong length are + // all still refused before any hashing happens. + assert!(!is_refresh_token_shape( + "111111112-222-4333-8444-555555555555" + )); + assert!(!is_refresh_token_shape( + "11111111-2222-4333-8444-55555555555Z" + )); + assert!(!is_refresh_token_shape( + "AAAAAAAA-2222-4333-8444-555555555555" + )); + assert!(!is_refresh_token_shape( + "11111111-2222-4333-8444-5555555555" + )); + } + #[test] fn to_hex_encodes_lowercase_two_chars_per_byte() { // The encoder must be lower-case and fixed-width — the identifier/key contract. From ae6d7b53c7bb3d7d3e293be0b41d29c48c028b16 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:42:56 -0300 Subject: [PATCH 010/122] test: pin the shared wire contract with a conformance tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything this branch converged — key prefixes, index member shapes, record encodings, credential formats, the derived identifier key — is a contract between two implementations that can back the same deployment over one Redis. Nothing enforced it, which is how the divergences reached this point: each side was internally consistent and green. Add `conformance/wire-contract.json`, held byte-identical in both repos, and assert this implementation against it. The prefix catalog is checked against the `Prefix` enum, and the identifier-key known-answer vectors now come from the contract instead of being repeated in the test, so there is one copy of the truth rather than two that drift apart quietly. The contract records the parts that are easy to get wrong from either side: the two identity planes must never share an index prefix; the timestamp encoding is deliberately NOT uniform (the session detail is epoch milliseconds because the reader guards on a numeric type and evicts a member whose detail fails to parse, while everything else is an ISO string); `mfaEnabled` must ride on the refresh session or a rotation silently disables the second factor; and the invitation needs `createdAt` because a single-use GETDEL destroys a record the reader rejects. Changing a value there is a breaking change to the shared keyspace and has to land in both repos together. --- conformance/wire-contract.json | 121 ++++++++++++++++++ crates/bymax-auth-core/src/config/validate.rs | 52 ++++++-- crates/bymax-auth-redis/src/keys.rs | 60 +++++++++ 3 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 conformance/wire-contract.json diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json new file mode 100644 index 0000000..4e7a0b8 --- /dev/null +++ b/conformance/wire-contract.json @@ -0,0 +1,121 @@ +{ + "$comment": [ + "CROSS-IMPLEMENTATION WIRE CONTRACT — keep this file byte-identical in both repos:", + " nest-auth/conformance/wire-contract.json", + " rust-auth/conformance/wire-contract.json", + "", + "Both libraries can back the same deployment and share one Redis, so these values are a", + "contract between them rather than an implementation detail of either. Each repo has a", + "test that reads this file and asserts its own implementation matches, so a change on one", + "side turns that side's suite red immediately instead of surfacing months later as", + "sessions, lockouts, and reset links that silently miss each other in production.", + "", + "Changing a value here is a breaking change to the shared keyspace. Do it in both repos in", + "the same change, and only with a migration story for data already written in the old form." + ], + "version": 1, + + "hmacKeyDerivation": { + "$comment": [ + "The identifier-hashing key both sides derive from the JWT secret, and one identifier", + "keyed with it. Three things are pinned at once: the ':' separator between label and", + "secret, the SHA-256, and the fact that the HMAC is keyed with the hex TEXT of the digest", + "rather than its raw bytes. Any of the three drifting produces different Redis keys for", + "every lockout, OTP, resend cooldown, MFA setup, and anti-replay record." + ], + "label": "bymax-auth:hmac-key:v1", + "vectors": [ + { + "secret": "0123456789abcdef0123456789abcdef", + "derivedKeyHex": "0dd66555bd2d89e0eb4ce050f1fef427bea6799bec27fb8e313f69ab965048c1", + "identifierMessage": "tenant-a:user@example.com", + "identifierHex": "609a759522bd8b397748fad2dbde07957cea580fe4f4f1f0ce0f526485de2b6d" + } + ] + }, + + "redisKeyPrefixes": { + "$comment": [ + "The prefix each keyspace uses, under the configured namespace: {namespace}:{prefix}:{id}.", + "The dashboard and platform planes are deliberately separate — a consumer's user ids and", + "admin ids come from different repositories and may collide, so one shared index let a", + "revoke on one plane log the other out." + ], + "dashboardRefreshSession": "rt", + "dashboardGracePointer": "rp", + "dashboardSessionIndex": "sess", + "dashboardSessionDetail": "sd", + "platformRefreshSession": "prt", + "platformGracePointer": "prp", + "platformSessionIndex": "psess", + "platformSessionDetail": "psd", + "accessTokenBlacklist": "rv", + "failedLoginCounter": "lf", + "oneTimePassword": "otp", + "passwordResetToken": "pw_reset", + "passwordResetVerifiedToken": "pw_vtok", + "totpReplayMarker": "tu", + "oauthState": "os", + "invitation": "inv" + }, + + "sessionIndexMembers": { + "$comment": [ + "Index members are FULL key suffixes, not bare hashes. The member has to name its own", + "keyspace: an atomic revoke rebuilds keys from members, and a bare hash cannot say whether", + "it is a live session or a rotation grace pointer. Grace pointers are members too, which is", + "what lets a revoke-all also kill a token that was rotated away but is still in its window." + ], + "dashboardLive": "rt:{sha256(refreshToken)}", + "dashboardGrace": "rp:{sha256(supersededToken)}", + "platformLive": "prt:{sha256(refreshToken)}", + "platformGrace": "prp:{sha256(supersededToken)}" + }, + + "recordEncodings": { + "$comment": [ + "Timestamp encoding differs per record and is NOT uniform — this is the trap. The session", + "DETAIL uses epoch milliseconds because the reader guards on `typeof === 'number'` and", + "evicts a member whose detail fails to parse; everything else uses an ISO-8601 string." + ], + "refreshSession": { + "key": "{rt|prt}:{sha256(refreshToken)}", + "fields": ["userId", "tenantId", "role", "device", "ip", "createdAt", "mfaEnabled"], + "createdAt": "iso8601-string", + "$comment": "mfaEnabled must survive rotation: the MFA gate refuses only when mfaEnabled && !mfaVerified, so losing it turns one refresh into a silent second-factor bypass." + }, + "sessionDetail": { + "key": "{sd|psd}:{sha256(refreshToken)}", + "fields": ["device", "ip", "createdAt", "lastActivityAt"], + "createdAt": "unix-milliseconds-number", + "lastActivityAt": "unix-milliseconds-number" + }, + "invitation": { + "key": "inv:{sha256(token)}", + "fields": ["email", "role", "tenantId", "inviterUserId", "createdAt"], + "createdAt": "iso8601-string", + "$comment": "Consumption is a single-use GETDEL, so a record the reader rejects is destroyed rather than retried — a missing field loses the invitation outright." + }, + "passwordResetContext": { + "key": "pw_reset:{sha256(token)}", + "fields": ["userId", "email", "tenantId"] + } + }, + + "credentialFormats": { + "$comment": [ + "The shapes of the credentials themselves. Both sides must mint and accept the same ones", + "or a session started on one backend cannot continue on the other." + ], + "refreshToken": "64 lowercase hex characters (32 CSPRNG bytes)", + "refreshTokenLegacy": "uuid-v4, accepted for one refresh lifetime after convergence", + "totpSecretAtRest": "aes-256-gcm over the BASE32 TEXT of the secret", + "recoveryCodeDigest": "hex hmac-sha256 of the code under the derived identifier key", + "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written" + }, + + "errorEnvelope": { + "$comment": "The body shape every auth error serializes to. `details` is present and null when the thrower supplied none.", + "shape": { "error": { "code": "string", "message": "string", "details": "object|null" } } + } +} diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index dd35eb5..b775b66 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -1026,6 +1026,37 @@ mod tests { assert!(!other_resolved.secure_cookies()); } + /// The first HMAC vector from the shared cross-implementation wire contract, as + /// `(secret, derived key hex, identifier message, identifier hex)`. + /// + /// The file at `conformance/wire-contract.json` is held byte-identical by nest-auth, which + /// derives the same key and reads the same Redis identifiers. Sourcing the vector from it + /// keeps one copy of the truth instead of two that can drift apart silently. + fn contract_hmac_vector() -> (String, String, String, String) { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let field = |name: &str| -> String { + root.get("hmacKeyDerivation") + .and_then(|d| d.get("vectors")) + .and_then(serde_json::Value::as_array) + .and_then(|v| v.first()) + .and_then(|v| v.get(name)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned() + }; + ( + field("secret"), + field("derivedKeyHex"), + field("identifierMessage"), + field("identifierHex"), + ) + } + /// Hex-encode for the test's independent recomputation of the derived key. fn to_hex_string(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() @@ -1044,26 +1075,27 @@ mod tests { // hash, or the encoding it feeds the HMAC, exactly one of the two suites goes red // instead of the split appearing later as sessions and lockouts that silently miss // each other in production. - const SECRET: &str = "0123456789abcdef0123456789abcdef"; - const EXPECTED_KEY: &str = - "0dd66555bd2d89e0eb4ce050f1fef427bea6799bec27fb8e313f69ab965048c1"; - const IDENTIFIER_MESSAGE: &str = "tenant-a:user@example.com"; - const EXPECTED_IDENTIFIER: &str = - "609a759522bd8b397748fad2dbde07957cea580fe4f4f1f0ce0f526485de2b6d"; + // Read from the shared contract rather than repeating it: nest-auth holds the same file + // byte-identical, so a change to the derivation on either side turns that side red here. + let vector = contract_hmac_vector(); + let secret = vector.0.as_str(); + let expected_key = vector.1.as_str(); + let identifier_message = vector.2.as_str(); + let expected_identifier = vector.3.as_str(); let mut cfg = valid_config(); - cfg.jwt.secret = SecretString::from(SECRET.to_owned()); + cfg.jwt.secret = SecretString::from(secret.to_owned()); let resolved = ResolvedConfig::new(cfg, Environment::Production, true); // The key itself is the 64-character hex text, not the raw 32-byte digest. - assert_eq!(resolved.hmac_key().as_slice(), EXPECTED_KEY.as_bytes()); + assert_eq!(resolved.hmac_key().as_slice(), expected_key.as_bytes()); // And an identifier keyed with it matches byte for byte what nest-auth writes. let identifier = to_hex_string(&bymax_auth_crypto::mac::hmac_sha256( resolved.hmac_key(), - IDENTIFIER_MESSAGE.as_bytes(), + identifier_message.as_bytes(), )); - assert_eq!(identifier, EXPECTED_IDENTIFIER); + assert_eq!(identifier, expected_identifier); } #[test] diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 2887ae8..0408384 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -137,6 +137,66 @@ impl NamespacedRedis { mod tests { use super::*; + /// Read `{section}.{key}` from the shared cross-implementation wire contract. + /// + /// The file at `conformance/wire-contract.json` is held byte-identical by nest-auth, which + /// can back the same deployment over the same Redis. Reading it here rather than repeating + /// its values means a prefix rename on either side turns that side red immediately, instead + /// of surfacing later as a keyspace that silently split in production. + fn contract_value(section: &str, key: &str) -> String { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + root.get(section) + .and_then(|s| s.get(key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned() + } + + #[test] + fn every_prefix_matches_the_shared_wire_contract() { + // The prefix each keyspace writes IS the contract with the sibling implementation. A + // rename landing on one side only splits the keyspace: a reset link emailed by one + // backend becomes invisible to the other, and a session index written by one is never + // swept by the other. + for (name, prefix) in [ + ("dashboardRefreshSession", Prefix::Rt), + ("dashboardGracePointer", Prefix::Rp), + ("dashboardSessionIndex", Prefix::Sess), + ("dashboardSessionDetail", Prefix::Sd), + ("accessTokenBlacklist", Prefix::Rv), + ("failedLoginCounter", Prefix::Lf), + ("oneTimePassword", Prefix::Otp), + ("passwordResetToken", Prefix::PwReset), + ("passwordResetVerifiedToken", Prefix::PwVtok), + ] { + assert_eq!( + contract_value("redisKeyPrefixes", name), + prefix.as_str(), + "prefix for {name} drifted from the shared contract" + ); + } + } + + #[test] + fn the_two_identity_planes_never_share_a_prefix() { + // The planes are keyed by ids from different consumer repositories, which may + // legitimately collide. One shared index would let a revoke on one plane log the other + // out, so the separation is a correctness property, not a naming preference. + assert_ne!( + contract_value("redisKeyPrefixes", "dashboardSessionIndex"), + contract_value("redisKeyPrefixes", "platformSessionIndex"), + ); + assert_ne!( + contract_value("redisKeyPrefixes", "dashboardSessionDetail"), + contract_value("redisKeyPrefixes", "platformSessionDetail"), + ); + } + #[test] fn to_hex_encodes_lower_case_two_chars_per_byte() { // The digest-to-suffix encoder must be lower-case, two chars per byte, and handle the From 6615f7b7e927f4fc18730a42048dea366448f4d7 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:30:40 -0300 Subject: [PATCH 011/122] fix(axum): align the HTTP wire contract with nest-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven response-shape divergences from the published sibling, whose client contract is already fixed. Two of them were more than cosmetic. - logout accepted the refresh token only from a cookie. In a bearer deployment there is no cookie, so the refresh session survived logout entirely — the access token was blacklisted while the credential that mints new ones stayed live. It now reads a body `refreshToken` as nest-auth does, cookie as fallback. - POST /auth/mfa/challenge required the temp token in the body with no cookie fallback, while this crate's own OAuth callback plants that token as an HttpOnly cookie. The browser OAuth+MFA flow was therefore a dead end: the SPA cannot read the cookie and the body was empty. The field is now optional with the cookie behind it, a request carrying neither fails with the MFA temp-token error rather than a generic validation 400, and the cookie is cleared on the same policy nest-auth uses. The rest are shapes a shared client would break on: GET /auth/me and /auth/platform/me return the safe account as the top-level body instead of wrapping it in `{ user }`; GET /auth/sessions returns the bare array instead of `{ sessions }`; the platform auth body names the account `admin` and is unconditionally bearer; both refresh endpoints echo the account alongside the tokens; and both MFA setup routes answer 201. The platform field name and the always-bearer delivery were already what this crate's own specification called for at §7.11.1 — the implementation had drifted from its spec, not only from nest-auth. Also fixes the namespaced-key catalog test, which enumerates every allowed prefix and so still listed the pre-rename `pr:`/`prv:` reset keys. It only surfaced once a Docker daemon was available to run the Redis integration tier. --- crates/bymax-auth-axum/src/delivery.rs | 156 +++-- crates/bymax-auth-axum/src/dto.rs | 12 +- crates/bymax-auth-axum/src/extractors/mod.rs | 9 + .../src/extractors/platform.rs | 50 +- crates/bymax-auth-axum/src/routes/auth.rs | 58 +- crates/bymax-auth-axum/src/routes/mfa.rs | 53 +- crates/bymax-auth-axum/src/routes/mod.rs | 21 + crates/bymax-auth-axum/src/routes/platform.rs | 94 ++- .../src/routes/platform_mfa.rs | 6 +- crates/bymax-auth-axum/src/routes/sessions.rs | 5 +- crates/bymax-auth-axum/tests/adapter.rs | 586 +++++++++++++++--- crates/bymax-auth-axum/tests/redis_e2e.rs | 100 ++- crates/bymax-auth-client/src/lib.rs | 17 +- crates/bymax-auth-redis/tests/redis_stores.rs | 6 +- 14 files changed, 951 insertions(+), 222 deletions(-) diff --git a/crates/bymax-auth-axum/src/delivery.rs b/crates/bymax-auth-axum/src/delivery.rs index 463b2dd..ded724b 100644 --- a/crates/bymax-auth-axum/src/delivery.rs +++ b/crates/bymax-auth-axum/src/delivery.rs @@ -7,12 +7,17 @@ //! Secure-by-default, the refresh cookie path-scoped and `SameSite=Strict`, the access / //! signal cookies `SameSite=Lax`. Handlers never hand-roll a `Set-Cookie`; they delegate //! here. The MFA-temp cookie (planted on the MFA-gated OAuth callback) is also written here. +//! +//! The **platform** delivery is the one exception to the mode switch: it is always bearer +//! (§7.11.1 — "`deliver_platform_auth` is **always bearer** regardless of mode"), because the +//! operator dashboard is not a browser session. It never plants a cookie and its body is +//! always `{ admin, accessToken, refreshToken }`. use axum::Json; use axum::response::{IntoResponse, Response}; use bymax_auth_core::config::{SameSite as ConfigSameSite, TokenDelivery as DeliveryMode}; use bymax_auth_types::constants::AUTH_HAS_SESSION_COOKIE_VALUE; -use bymax_auth_types::{AuthResult, MfaChallengeResult, RotatedTokens, SafeAuthUser}; +use bymax_auth_types::{AuthResult, MfaChallengeResult, SafeAuthUser}; use http::StatusCode; use serde::Serialize; use serde_json::json; @@ -142,6 +147,28 @@ impl<'a> TokenDelivery<'a> { ); } + /// Clear the ephemeral MFA-temp cookie, reusing the exact `Path` [`set_mfa_temp_cookie`] + /// scoped it to (a mismatched path leaves a ghost cookie the browser keeps sending). + /// + /// The challenge handler applies nest-auth's clearing policy verbatim (see + /// `mfa.controller.ts`): clear on SUCCESS (the JWT was consumed) and on + /// `mfa_temp_token_invalid` (forged/expired/unknown — a retry under the same cookie can + /// never succeed); KEEP the cookie on `mfa_invalid_code` / `account_locked` / a transient + /// failure, because the temp token is still alive in the store and the user can retry + /// inside its 5-minute TTL. The brute-force counter still caps how many wrong codes one + /// token accepts, so keeping it does not weaken the threat model. + /// + /// [`set_mfa_temp_cookie`]: TokenDelivery::set_mfa_temp_cookie + #[cfg(feature = "mfa")] + pub(crate) fn clear_mfa_temp_cookie(&self, cookies: &Cookies) { + use bymax_auth_types::constants::MFA_TEMP_COOKIE_NAME; + cookies.remove( + Cookie::build((MFA_TEMP_COOKIE_NAME.to_owned(), "")) + .path(self.cookies().mfa_temp_path.clone()) + .build(), + ); + } + /// Deliver a successful authentication (login/register/invitation-accept). In `cookie` /// mode it sets the auth cookies and the body carries only the safe user; in `bearer` /// mode no cookies are set and the body carries the tokens; `both` does both. `status` @@ -165,44 +192,29 @@ impl<'a> TokenDelivery<'a> { } } - /// Deliver a successful **platform** authentication. The platform result mirrors - /// `AuthResult` field-for-field, so the body shape and cookie behavior are identical to - /// [`TokenDelivery::deliver_auth`]; only the safe-user type differs. + /// Deliver a successful **platform** authentication (login / MFA challenge / refresh). + /// + /// Unlike [`TokenDelivery::deliver_auth`] this ignores the configured delivery mode + /// entirely: platform sessions are **always** bearer (§7.11.1), so no cookie is ever + /// planted and the body is always `{ admin, accessToken, refreshToken }`. The account key + /// is `admin` (not `user`), matching nest-auth's `PlatformBearerAuthResponse`. #[cfg(feature = "platform")] pub(crate) fn deliver_platform_auth( &self, - cookies: &Cookies, result: &bymax_auth_types::PlatformAuthResult, status: StatusCode, ) -> Response { - match self.config.delivery { - DeliveryMode::Cookie => { - self.set_auth_cookies(cookies, &result.access_token, &result.refresh_token); - (status, Json(json!({ "user": result.user }))).into_response() - } - DeliveryMode::Bearer => (status, Json(platform_bearer_body(result))).into_response(), - DeliveryMode::Both => { - self.set_auth_cookies(cookies, &result.access_token, &result.refresh_token); - (status, Json(platform_bearer_body(result))).into_response() - } - } + (status, Json(platform_bearer_body(result))).into_response() } - /// Deliver a refresh outcome (rotated token pair). In `cookie` mode the new cookies are - /// set and the body is empty `{}`; in `bearer` mode the body carries the new pair; - /// `both` does both. - pub(crate) fn deliver_refresh(&self, cookies: &Cookies, tokens: &RotatedTokens) -> Response { - match self.config.delivery { - DeliveryMode::Cookie => { - self.set_auth_cookies(cookies, &tokens.access_token, &tokens.refresh_token); - (StatusCode::OK, Json(json!({}))).into_response() - } - DeliveryMode::Bearer => (StatusCode::OK, Json(tokens)).into_response(), - DeliveryMode::Both => { - self.set_auth_cookies(cookies, &tokens.access_token, &tokens.refresh_token); - (StatusCode::OK, Json(tokens)).into_response() - } - } + /// Deliver a refresh outcome. The rotated pair is paired with the freshly fetched account + /// so the body matches nest-auth's `deliverRefreshResponse`, which delegates to the login + /// delivery and therefore echoes the user in every mode: `cookie` sets the new cookies and + /// returns `{ user }`, `bearer` returns `{ user, accessToken, refreshToken }`, `both` does + /// both. Kept as a distinct call site (as nest-auth does) so a reader can tell a rotation + /// from an initial login at the handler. + pub(crate) fn deliver_refresh(&self, cookies: &Cookies, result: &AuthResult) -> Response { + self.deliver_auth(cookies, result, StatusCode::OK) } /// Deliver an MFA challenge body (`{ mfaRequired: true, mfaTempToken }`) — the same in @@ -221,19 +233,23 @@ fn bearer_body(result: &AuthResult) -> impl Serialize + '_ { }) } -/// The bearer/both platform auth body: the safe admin plus the token pair, camelCase. +/// The platform auth body: the safe admin under the `admin` key plus the token pair, +/// camelCase. The key is `admin`, not `user` — nest-auth's published +/// `PlatformBearerAuthResponse` names it that way, and §7.11.1 of the spec agrees. #[cfg(feature = "platform")] fn platform_bearer_body(result: &bymax_auth_types::PlatformAuthResult) -> impl Serialize + '_ { json!({ - "user": &result.user, + "admin": &result.user, "accessToken": &result.access_token, "refreshToken": &result.refresh_token, }) } -/// A safe-user body with no tokens (used by `me` and the cookie-mode auth body shape). +/// The `GET /auth/me` body: the safe user as the **top-level** object, with no wrapper — +/// nest-auth's `AuthController.me` returns the `SafeAuthUser` itself, and its published +/// client (`createAuthClient().getMe()`) decodes the bare object. pub(crate) fn user_body(user: &SafeAuthUser) -> Json { - Json(json!({ "user": user })) + Json(json!(user)) } #[cfg(test)] @@ -243,7 +259,7 @@ mod tests { use bymax_auth_core::config::SameSite as ConfigSS; #[cfg(feature = "platform")] use bymax_auth_types::{AuthPlatformUser, PlatformAuthResult, SafeAuthPlatformUser}; - use bymax_auth_types::{AuthResult, AuthUser, MfaChallengeResult, RotatedTokens}; + use bymax_auth_types::{AuthResult, AuthUser, MfaChallengeResult}; use time::OffsetDateTime; fn safe_user() -> SafeAuthUser { @@ -339,11 +355,10 @@ mod tests { } #[test] - fn deliver_refresh_in_every_mode() { - let tokens = RotatedTokens { - access_token: "na".to_owned(), - refresh_token: "nr".to_owned(), - }; + fn deliver_refresh_sets_the_rotated_cookies_and_is_a_200_in_every_mode() { + // A rotation delivers exactly like a login (nest-auth's `deliverRefreshResponse` + // delegates to `deliverAuthResponse`): 200 in every mode, with the NEW pair planted as + // cookies in the two cookie-bearing modes and none in `bearer`. for mode in [ DeliveryMode::Cookie, DeliveryMode::Bearer, @@ -351,14 +366,21 @@ mod tests { ] { let cfg = resolved_config_with(mode, ConfigSS::Lax); let jar = cookies_jar(); - let resp = TokenDelivery::new(&cfg).deliver_refresh(&jar, &tokens); + let resp = TokenDelivery::new(&cfg).deliver_refresh(&jar, &auth_result()); assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + has_cookie(&jar, "access_token"), + mode != DeliveryMode::Bearer + ); } } #[cfg(feature = "platform")] #[test] - fn deliver_platform_auth_in_every_mode() { + fn deliver_platform_auth_is_always_bearer_in_every_mode() { + // Platform delivery ignores the configured mode entirely (§7.11.1): even under + // `cookie`/`both` it plants NO cookie, because the operator dashboard is not a browser + // session. The status passes through so a caller can pick 200 or 201. for mode in [ DeliveryMode::Cookie, DeliveryMode::Bearer, @@ -366,15 +388,28 @@ mod tests { ] { let cfg = resolved_config_with(mode, ConfigSS::Lax); let jar = cookies_jar(); - let resp = TokenDelivery::new(&cfg).deliver_platform_auth( - &jar, - &platform_result(), - StatusCode::OK, - ); + let resp = + TokenDelivery::new(&cfg).deliver_platform_auth(&platform_result(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK); + assert!(!has_cookie(&jar, "access_token")); + assert!(!has_cookie(&jar, "refresh_token")); + assert!(!has_cookie(&jar, "has_session")); } } + #[cfg(feature = "platform")] + #[test] + fn platform_bearer_body_names_the_account_admin_not_user() { + // The platform body key is `admin` (nest-auth's `PlatformBearerAuthResponse`); a + // `user` key here would silently break every published platform client. + let result = platform_result(); + let body = serde_json::to_value(platform_bearer_body(&result)).unwrap_or_default(); + assert_eq!(body["admin"]["email"], "a@e.com"); + assert!(body.get("user").is_none()); + assert_eq!(body["accessToken"], "pacc"); + assert_eq!(body["refreshToken"], "pref"); + } + #[test] fn challenge_clear_signal_and_mfa_temp_and_user_body() { let cfg = resolved_config_with(DeliveryMode::Cookie, ConfigSS::Lax); @@ -408,8 +443,29 @@ mod tests { assert!(has_cookie(&jar2, "access_token")); } - // `user_body` shapes the safe user. + // `user_body` is the BARE safe user — no `{ user: … }` wrapper (nest-auth's `me` + // returns the object itself, and its published client decodes it unwrapped). let body = user_body(&safe_user()); - assert_eq!(body.0["user"]["email"], "u@e.com"); + assert_eq!(body.0["email"], "u@e.com"); + assert!(body.0.get("user").is_none()); + } + + #[cfg(feature = "mfa")] + #[test] + fn clear_mfa_temp_cookie_removes_it_on_the_path_it_was_set_with() { + // The clear must reuse the MFA-temp path; a mismatched `Path` would leave a ghost + // cookie the browser keeps replaying at the challenge endpoint. + let cfg = resolved_config_with(DeliveryMode::Cookie, ConfigSS::Lax); + let delivery = TokenDelivery::new(&cfg); + let jar = cookies_jar(); + jar.add(Cookie::new( + bymax_auth_types::constants::MFA_TEMP_COOKIE_NAME, + "temp.jwt", + )); + delivery.clear_mfa_temp_cookie(&jar); + assert!(!has_cookie( + &jar, + bymax_auth_types::constants::MFA_TEMP_COOKIE_NAME + )); } } diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index d5ab170..929b083 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -147,8 +147,16 @@ pub struct MfaVerifyDto { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct MfaChallengeDto { /// The short-lived MFA temp token issued by the password/OAuth step. - #[garde(length(min = 1))] - pub mfa_temp_token: String, + /// + /// **Optional**, exactly as in nest-auth's `MfaChallengeDto`: the browser-driven OAuth + + /// MFA flow leaves it out of the body because the callback planted it in the HttpOnly + /// `mfa_temp_token` cookie, which the challenge handler reads as the fallback. When it IS + /// present it must be non-empty and ≤ 512 chars — a compact HS256 JWT is ~200 chars, and + /// the cap keeps an oversized payload away from JWT verification on this public endpoint. + /// A request carrying neither channel is rejected by the handler as an invalid temp token, + /// not as a field-validation failure. + #[garde(inner(length(min = 1, max = 512)))] + pub mfa_temp_token: Option, /// A 6-digit TOTP or a recovery code (≤ 128 prevents hash-bombing). #[garde(length(min = 1, max = 128))] pub code: String, diff --git a/crates/bymax-auth-axum/src/extractors/mod.rs b/crates/bymax-auth-axum/src/extractors/mod.rs index 1bae3a2..e5ba642 100644 --- a/crates/bymax-auth-axum/src/extractors/mod.rs +++ b/crates/bymax-auth-axum/src/extractors/mod.rs @@ -75,6 +75,15 @@ pub(crate) fn source_access_token(parts: &Parts, config: &ResolvedConfig) -> Opt } } +/// Source the **platform** access token. Unlike [`source_access_token`] this ignores the +/// configured [`TokenDelivery`] mode and always reads `Authorization: Bearer` (§7.11.4): +/// platform delivery never plants a cookie, so a cookie presented on a platform route can only +/// be a dashboard credential — reading it would be a cross-domain confusion, not a convenience. +#[cfg(feature = "platform")] +pub(crate) fn source_platform_access_token(parts: &Parts) -> Option { + bearer_from_header(parts) +} + /// Verify the dashboard access token once per request, caching the claims on /// `parts.extensions`. A subsequent stacked extractor reads the cached value rather than /// re-running the HMAC verification + revocation lookup. A missing token is the boundary diff --git a/crates/bymax-auth-axum/src/extractors/platform.rs b/crates/bymax-auth-axum/src/extractors/platform.rs index c7b9a47..4db9be6 100644 --- a/crates/bymax-auth-axum/src/extractors/platform.rs +++ b/crates/bymax-auth-axum/src/extractors/platform.rs @@ -7,14 +7,15 @@ use axum::extract::{FromRef, FromRequestParts}; use bymax_auth_types::{AuthError, PlatformClaims}; use http::request::Parts; -use crate::extractors::source_access_token; +use crate::extractors::source_platform_access_token; use crate::response::AuthRejection; use crate::state::AuthState; /// Verify the platform access token once per request, caching the claims on -/// `parts.extensions`. The token is sourced from the same cookie/`Authorization`-header -/// channels as the dashboard token (never a query string); a dashboard token presented here -/// fails the `type == platform` assertion and is mapped to `PlatformAuthRequired`. +/// `parts.extensions`. The token is sourced from the `Authorization: Bearer` header only +/// (never a cookie, never a query string) because platform sessions are always bearer; a +/// dashboard token presented here fails the `type == platform` assertion and is mapped to +/// `PlatformAuthRequired`. async fn verified_platform_claims( parts: &mut Parts, state: &AuthState, @@ -22,8 +23,7 @@ async fn verified_platform_claims( if let Some(cached) = parts.extensions.get::() { return Ok(cached.clone()); } - let token = - source_access_token(parts, state.config()).ok_or(AuthError::PlatformAuthRequired)?; + let token = source_platform_access_token(parts).ok_or(AuthError::PlatformAuthRequired)?; let claims = state .engine() .verify_platform_token(&token) @@ -110,7 +110,10 @@ where #[cfg(test)] mod tests { use super::*; - use crate::test_support::{dashboard_token, mint_token, parts_with_cookie, scaffold, seed}; + use crate::test_support::{ + dashboard_token, mint_token, parts_empty, parts_with_bearer, parts_with_cookie, scaffold, + seed, + }; use bymax_auth_core::config::TokenDelivery; use bymax_auth_types::{PlatformClaims, PlatformType}; @@ -159,10 +162,11 @@ mod tests { #[tokio::test] async fn platform_user_accepts_platform_token_and_rejects_dashboard_token() { - // A platform token resolves; a dashboard token here is `platform_auth_required`. + // A platform token on the bearer header resolves; a dashboard token there is + // `platform_auth_required`. let Some(s) = scaffold(TokenDelivery::Cookie) else { return }; let token = mint_token(&platform_claims("SUPER_ADMIN")); - let mut parts = parts_with_cookie(&token); + let mut parts = parts_with_bearer(&token); let ok = PlatformUser::from_request_parts(&mut parts, &s.state).await; assert!(matches!(ok, Ok(PlatformUser(c)) if c.role == "SUPER_ADMIN")); // A second resolution on the same parts reads the cached claims (the read-through arm). @@ -171,7 +175,7 @@ mod tests { let user_id = seed(&s.users, "d@e.com", "USER").await; let dash = dashboard_token(&s, &user_id).await; - let mut parts = parts_with_cookie(&dash); + let mut parts = parts_with_bearer(&dash); let denied = PlatformUser::from_request_parts(&mut parts, &s.state).await; assert!(matches!( denied, @@ -179,7 +183,7 @@ mod tests { )); // An absent token is also `platform_auth_required`. - let mut empty = parts_with_cookie(""); + let mut empty = parts_empty(); let none = PlatformUser::from_request_parts(&mut empty, &s.state).await; assert!(matches!( none, @@ -187,17 +191,37 @@ mod tests { )); } + #[tokio::test] + async fn platform_user_never_reads_the_access_cookie_even_in_cookie_mode() { + // Platform sessions are always bearer, so the platform guard must ignore the access + // cookie entirely — under `cookie` delivery a dashboard cookie would otherwise be + // picked up and verified as if it were a platform credential. A valid platform token + // placed in the cookie is refused; the same token on the header is accepted. + let Some(s) = scaffold(TokenDelivery::Cookie) else { return }; + let token = mint_token(&platform_claims("SUPER_ADMIN")); + let mut cookie_parts = parts_with_cookie(&token); + let refused = PlatformUser::from_request_parts(&mut cookie_parts, &s.state).await; + assert!(matches!( + refused, + Err(AuthRejection(AuthError::PlatformAuthRequired)) + )); + + let mut header_parts = parts_with_bearer(&token); + let accepted = PlatformUser::from_request_parts(&mut header_parts, &s.state).await; + assert!(matches!(accepted, Ok(PlatformUser(_)))); + } + #[tokio::test] async fn require_platform_role_checks_the_platform_hierarchy() { // SUPER_ADMIN satisfies SUPER_ADMIN; SUPPORT does not. let Some(s) = scaffold(TokenDelivery::Cookie) else { return }; let super_token = mint_token(&platform_claims("SUPER_ADMIN")); - let mut parts = parts_with_cookie(&super_token); + let mut parts = parts_with_bearer(&super_token); let ok = RequirePlatformRole::::from_request_parts(&mut parts, &s.state).await; assert!(matches!(ok, Ok(RequirePlatformRole(_, _)))); let support_token = mint_token(&platform_claims("SUPPORT")); - let mut parts = parts_with_cookie(&support_token); + let mut parts = parts_with_bearer(&support_token); let denied = RequirePlatformRole::::from_request_parts(&mut parts, &s.state).await; assert!(matches!( diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index af738e6..e83f329 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -10,7 +10,7 @@ use axum::extract::State; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use bymax_auth_core::services::auth::{LoginInput, RegisterInput}; -use bymax_auth_types::{LoginResult, RotatedTokens}; +use bymax_auth_types::{AuthError, AuthResult, LoginResult, RotatedTokens}; use http::StatusCode; use tower_cookies::Cookies; @@ -104,13 +104,27 @@ async fn login( /// `POST /auth/logout` (204). Requires [`AuthUser`]. Revokes the access JTI and the refresh /// session, then clears the auth cookies. +/// +/// The refresh token is sourced from the cookie **or** the request body, exactly as nest-auth +/// does (`AuthController.logout` → `extractRefreshToken`). Reading only the cookie left a real +/// gap: a bearer-mode deployment plants no cookie, so the refresh session survived logout and +/// the "revoked" client could keep rotating it. async fn logout( State(state): State, cookies: Cookies, user: AuthUser, PresentedAccessToken(access_token): PresentedAccessToken, + body: axum::body::Bytes, ) -> Response { - let refresh = source_refresh_token(&cookies, &state.config().cookies.refresh_name, None); + // Logout is best-effort and idempotent end to end (the engine swallows store failures), so + // an unparseable body degrades to "no body-supplied token" rather than 400-ing and leaving + // the session alive — the cookie channel still gets its chance. + let dto = parse_optional_refresh_body(&body).unwrap_or_default(); + let refresh = source_refresh_token( + &cookies, + &state.config().cookies.refresh_name, + dto.refresh_token.as_deref(), + ); let _ = state .engine() .logout(&access_token, &refresh, &user.0.sub) @@ -120,7 +134,10 @@ async fn logout( } /// `POST /auth/refresh` (200). Public. Rotates the refresh token (cookie or body) into a -/// fresh pair and delivers it. +/// fresh pair and delivers it **with the account echoed alongside**, as nest-auth does +/// (`AuthController.refresh` re-reads the user via `getMe` and hands an `AuthResult` to the +/// delivery layer). Returning the bare pair — or an empty `{}` in cookie mode — would force +/// every client into a second `GET /auth/me` round trip after each rotation. async fn refresh( State(state): State, cookies: Cookies, @@ -136,17 +153,22 @@ async fn refresh( let body_refresh = dto.refresh_token.as_deref(); let refresh = source_refresh_token(&cookies, &state.config().cookies.refresh_name, body_refresh); - match state + let tokens = match state .engine() .refresh(&refresh, &ctx.ip, &ctx.user_agent) .await { - Ok(tokens) => deliver_refresh(&state, &cookies, &tokens), + Ok(tokens) => tokens, + Err(error) => return error_response(&error), + }; + match rotated_into_auth_result(&state, tokens).await { + Ok(result) => TokenDelivery::new(state.config()).deliver_refresh(&cookies, &result), Err(error) => error_response(&error), } } -/// `GET /auth/me` (200). Requires [`AuthUser`]. Returns the credential-free user. +/// `GET /auth/me` (200). Requires [`AuthUser`]. Returns the credential-free user as the +/// top-level body (no wrapper) — see [`user_body`]. async fn me(State(state): State, user: AuthUser) -> Response { match state.engine().me(&user.0.sub).await { Ok(safe) => (StatusCode::OK, user_body(&safe)).into_response(), @@ -199,7 +221,25 @@ fn deliver_login( } } -/// Shared delivery for a refresh rotation. -fn deliver_refresh(state: &AuthState, cookies: &Cookies, tokens: &RotatedTokens) -> Response { - TokenDelivery::new(state.config()).deliver_refresh(cookies, tokens) +/// Pair a rotated token pair with the account it belongs to, producing the [`AuthResult`] the +/// delivery layer shapes into the refresh body. +/// +/// The engine's `refresh` returns only the pair, so the subject is recovered from the +/// freshly-minted access token (it verifies by construction) and the account is re-read +/// through `me` — the same "rotate, then `getMe`" sequence nest-auth's controller performs, +/// which also guarantees the echoed record reflects any change made since the last issuance. +async fn rotated_into_auth_result( + state: &AuthState, + tokens: RotatedTokens, +) -> Result { + let claims = state + .engine() + .verify_access_token(&tokens.access_token) + .await?; + let user = state.engine().me(&claims.sub).await?; + Ok(AuthResult { + user, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }) } diff --git a/crates/bymax-auth-axum/src/routes/mfa.rs b/crates/bymax-auth-axum/src/routes/mfa.rs index 519d083..4cdb4a7 100644 --- a/crates/bymax-auth-axum/src/routes/mfa.rs +++ b/crates/bymax-auth-axum/src/routes/mfa.rs @@ -13,7 +13,7 @@ use axum::Router; use axum::extract::State; use axum::response::{IntoResponse, Response}; use axum::routing::post; -use bymax_auth_types::MfaContext; +use bymax_auth_types::{AuthError, MfaContext}; use http::StatusCode; use serde_json::json; @@ -54,7 +54,10 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout ) } -/// `POST /auth/mfa/setup` (200). Requires [`AuthUser`], not `MfaSatisfied` (enrolment). +/// `POST /auth/mfa/setup` (201). Requires [`AuthUser`], not `MfaSatisfied` (enrolment). +/// +/// 201 Created, not 200: nest-auth's `MfaController.setup` carries no `@HttpCode`, so it uses +/// Nest's `POST` default of 201, and enrolment does create the pending setup record. async fn setup(State(state): State, user: AuthUser) -> Response { match state .engine() @@ -62,7 +65,7 @@ async fn setup(State(state): State, user: AuthUser) -> Response { .await { Ok(result) => ( - StatusCode::OK, + StatusCode::CREATED, Json(json!({ "secret": result.secret, "qrCodeUri": result.qr_code_uri, @@ -99,24 +102,62 @@ async fn verify_enable( /// `POST /auth/mfa/challenge` (200). Public — the post-login exchange. Returns a full /// dashboard session on success. +/// +/// The temp token comes from `mfaTempToken` in the body **or**, when the body omits it, from +/// the HttpOnly `mfa_temp_token` cookie the OAuth callback planted (see +/// [`crate::routes::oauth`]). The body wins when both are present — that is the historical +/// contract of the password-login path. Without this fallback the browser OAuth + MFA flow +/// could never complete: the callback 302s to the configured MFA page, which has no way to +/// read the HttpOnly cookie it would have to echo back. +/// +/// When the cookie supplied the token it is cleared per nest-auth's policy (documented on +/// [`TokenDelivery::clear_mfa_temp_cookie`]): on success and on an invalid temp token, but not +/// on a wrong code, so the user can retry inside the token's 5-minute lifetime. async fn challenge( State(state): State, cookies: tower_cookies::Cookies, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { + let delivery = TokenDelivery::new(state.config()); + let cookie_token = mfa_temp_cookie(&cookies); + // Neither channel carried a token: that is an invalid temp token (nest-auth throws + // `MFA_TEMP_TOKEN_INVALID` here), never a generic field-validation 400. + let Some(temp_token) = dto.mfa_temp_token.as_deref().or(cookie_token.as_deref()) else { + return error_response(&AuthError::MfaTempTokenInvalid); + }; + match state .engine() - .dashboard_mfa_challenge(&dto.mfa_temp_token, &dto.code, &ctx.ip, &ctx.user_agent) + .dashboard_mfa_challenge(temp_token, &dto.code, &ctx.ip, &ctx.user_agent) .await { Ok(auth) => { - TokenDelivery::new(state.config()).deliver_auth(&cookies, &auth, StatusCode::OK) + if cookie_token.is_some() { + delivery.clear_mfa_temp_cookie(&cookies); + } + delivery.deliver_auth(&cookies, &auth, StatusCode::OK) + } + Err(error) => { + // A dead token can never be retried under the same cookie, so drop it; any other + // failure (wrong code, lockout, store hiccup) leaves the cookie in place. + if cookie_token.is_some() && matches!(error, AuthError::MfaTempTokenInvalid) { + delivery.clear_mfa_temp_cookie(&cookies); + } + error_response(&error) } - Err(error) => error_response(&error), } } +/// Read the `mfa_temp_token` cookie, treating an empty value as absent so an already-cleared +/// cookie never masquerades as a supplied token. +fn mfa_temp_cookie(cookies: &tower_cookies::Cookies) -> Option { + cookies + .get(bymax_auth_types::constants::MFA_TEMP_COOKIE_NAME) + .map(|cookie| cookie.value().to_owned()) + .filter(|value| !value.is_empty()) +} + /// `POST /auth/mfa/disable` (204). Requires [`AuthUser`] + a valid TOTP (strong re-auth). async fn disable( State(state): State, diff --git a/crates/bymax-auth-axum/src/routes/mod.rs b/crates/bymax-auth-axum/src/routes/mod.rs index fe9397d..e01f9f9 100644 --- a/crates/bymax-auth-axum/src/routes/mod.rs +++ b/crates/bymax-auth-axum/src/routes/mod.rs @@ -173,6 +173,27 @@ where } } +/// The platform twin of [`PresentedAccessToken`]: the raw platform access token from the +/// `Authorization: Bearer` header, or an empty string when absent. Platform sessions are always +/// bearer, so this never consults a cookie — the access cookie on a platform request can only +/// belong to the dashboard domain. Infallible (logout never blocks on a missing token). +#[cfg(feature = "platform")] +pub(crate) struct PresentedPlatformAccessToken(pub String); + +#[cfg(feature = "platform")] +impl FromRequestParts for PresentedPlatformAccessToken +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Self( + crate::extractors::source_platform_access_token(parts).unwrap_or_default(), + )) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bymax-auth-axum/src/routes/platform.rs b/crates/bymax-auth-axum/src/routes/platform.rs index bea1c5d..42dfd7a 100644 --- a/crates/bymax-auth-axum/src/routes/platform.rs +++ b/crates/bymax-auth-axum/src/routes/platform.rs @@ -5,6 +5,12 @@ //! Platform tokens carry no `tenantId` and live in the platform session keyspaces. Each //! handler delegates to an engine method that resolves the platform service (guaranteed //! present because the group mounts only when the platform domain is enabled). +//! +//! **The whole group is bearer-only, whatever the configured delivery mode is** (§7.11.1 / +//! §7.11.4): no platform response ever plants a cookie, the access token is read from the +//! `Authorization: Bearer` header, and the refresh token is read from the request body. The +//! operator dashboard is not a browser session, and a dashboard cookie must never be mistaken +//! for a platform credential. use axum::Json; use axum::Router; @@ -14,15 +20,12 @@ use axum::routing::{delete, get, post}; use bymax_auth_types::{PlatformAuthResult, PlatformLoginResult, RotatedTokens}; use http::StatusCode; use serde_json::json; -use tower_cookies::Cookies; use crate::delivery::TokenDelivery; use crate::dto::{MfaChallengeDto, PlatformLoginDto}; use crate::extractors::PlatformUser; use crate::response::error_response; -use crate::routes::{ - PresentedAccessToken, RequestMeta, parse_optional_refresh_body, source_refresh_token, -}; +use crate::routes::{PresentedPlatformAccessToken, RequestMeta, parse_optional_refresh_body}; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedJson; @@ -50,7 +53,6 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// `POST /auth/platform/login` (200). Public. Full platform session or an MFA challenge. async fn login( State(state): State, - cookies: Cookies, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { @@ -60,7 +62,7 @@ async fn login( .await { Ok(PlatformLoginResult::Success(result)) => { - deliver_platform(&state, &cookies, &result, StatusCode::OK) + deliver_platform(&state, &result, StatusCode::OK) } Ok(PlatformLoginResult::MfaChallenge(challenge)) => { TokenDelivery::new(state.config()).deliver_mfa_challenge(&challenge) @@ -72,7 +74,6 @@ async fn login( /// `POST /auth/platform/mfa/challenge` (200). Public — the platform post-login exchange. async fn mfa_challenge( State(state): State, - cookies: Cookies, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { @@ -82,51 +83,68 @@ async fn mfa_challenge( // handler has only the success/error arms. #[cfg(feature = "mfa")] { + // `MfaChallengeDto::mfa_temp_token` is optional so the browser OAuth + MFA flow can + // carry it in the `mfa_temp_token` cookie instead of the body. That flow is dashboard- + // only (there is no platform OAuth callback), so here the token MUST come from the + // body — an absent one is an invalid temp token, exactly as nest-auth's + // `PlatformAuthController.mfaChallenge` reports it. (A present-but-empty value cannot + // reach here: the DTO's `inner(length(min = 1))` rule rejects it first.) + let Some(temp_token) = dto.mfa_temp_token.as_deref() else { + return error_response(&bymax_auth_types::AuthError::MfaTempTokenInvalid); + }; match state .engine() - .platform_mfa_challenge(&dto.mfa_temp_token, &dto.code, &ctx.ip, &ctx.user_agent) + .platform_mfa_challenge(temp_token, &dto.code, &ctx.ip, &ctx.user_agent) .await { - Ok(auth) => deliver_platform(&state, &cookies, &auth, StatusCode::OK), + Ok(auth) => deliver_platform(&state, &auth, StatusCode::OK), Err(error) => error_response(&error), } } // A platform build without the MFA surface cannot complete a challenge. #[cfg(not(feature = "mfa"))] { - let _ = (&state, &cookies, &ctx, &dto); + let _ = (&state, &ctx, &dto); error_response(&bymax_auth_types::AuthError::MfaNotEnabled) } } -/// `GET /auth/platform/me` (200). Requires [`PlatformUser`]. +/// `GET /auth/platform/me` (200). Requires [`PlatformUser`]. Returns the credential-free admin +/// as the top-level body (no wrapper), mirroring `PlatformAuthController.me`. async fn me(State(state): State, user: PlatformUser) -> Response { match state.engine().platform_me(&user.0.sub).await { - Ok(safe) => (StatusCode::OK, Json(json!({ "user": safe }))).into_response(), + Ok(safe) => (StatusCode::OK, Json(json!(safe))).into_response(), Err(error) => error_response(&error), } } /// `POST /auth/platform/logout` (204). Requires [`PlatformUser`]. +/// +/// The refresh token is read from the request body only: platform delivery never planted a +/// cookie, so there is none to read, and honouring the dashboard refresh cookie here would let +/// it shadow the body value and leave the platform session alive after logout. async fn logout( State(state): State, - cookies: Cookies, user: PlatformUser, - PresentedAccessToken(access_token): PresentedAccessToken, + PresentedPlatformAccessToken(access_token): PresentedPlatformAccessToken, + body: axum::body::Bytes, ) -> Response { - let refresh = source_refresh_token(&cookies, &state.config().cookies.refresh_name, None); + // Best-effort, as the dashboard logout is: an unparseable body degrades to "no token" + // rather than blocking the revocation of the access JTI. + let dto = parse_optional_refresh_body(&body).unwrap_or_default(); + let refresh = dto.refresh_token.unwrap_or_default(); let _ = state .engine() .platform_logout(&access_token, &refresh, &user.0.sub) .await; - TokenDelivery::new(state.config()).clear_session(&cookies); StatusCode::NO_CONTENT.into_response() } -/// `POST /auth/platform/refresh` (200). Public. Rotates the platform token pair. +/// `POST /auth/platform/refresh` (200). Public. Rotates the platform token pair and echoes the +/// admin alongside it, as `PlatformAuthController.refresh` does. The presented refresh token is +/// read from the body only (platform is always bearer). async fn refresh( State(state): State, - cookies: Cookies, RequestMeta(ctx): RequestMeta, body: axum::body::Bytes, ) -> Response { @@ -134,15 +152,17 @@ async fn refresh( Ok(dto) => dto, Err(error) => return error_response(&error), }; - let body_refresh = dto.refresh_token.as_deref(); - let refresh = - source_refresh_token(&cookies, &state.config().cookies.refresh_name, body_refresh); - match state + let refresh = dto.refresh_token.unwrap_or_default(); + let tokens = match state .engine() .platform_refresh(&refresh, &ctx.ip, &ctx.user_agent) .await { - Ok(tokens) => deliver_refresh(&state, &cookies, &tokens), + Ok(tokens) => tokens, + Err(error) => return error_response(&error), + }; + match rotated_into_platform_result(&state, tokens).await { + Ok(result) => deliver_platform(&state, &result, StatusCode::OK), Err(error) => error_response(&error), } } @@ -156,18 +176,32 @@ async fn revoke_all(State(state): State, user: PlatformUser) -> Respo } } -/// Deliver a successful platform authentication (the same cookie/bearer/both delivery as the -/// dashboard path; isolation is by the `type` claim). +/// Deliver a successful platform authentication: always the bearer body +/// `{ admin, accessToken, refreshToken }`, never a cookie. fn deliver_platform( state: &AuthState, - cookies: &Cookies, result: &PlatformAuthResult, status: StatusCode, ) -> Response { - TokenDelivery::new(state.config()).deliver_platform_auth(cookies, result, status) + TokenDelivery::new(state.config()).deliver_platform_auth(result, status) } -/// Deliver a platform refresh rotation. -fn deliver_refresh(state: &AuthState, cookies: &Cookies, tokens: &RotatedTokens) -> Response { - TokenDelivery::new(state.config()).deliver_refresh(cookies, tokens) +/// Pair a rotated platform token pair with the admin it belongs to, producing the +/// [`PlatformAuthResult`] the refresh body carries. The subject comes from the freshly-minted +/// platform access token (it verifies by construction) and the record is re-read through +/// `platform_me` — the "rotate, then `getMe`" sequence nest-auth's controller performs. +async fn rotated_into_platform_result( + state: &AuthState, + tokens: RotatedTokens, +) -> Result { + let claims = state + .engine() + .verify_platform_token(&tokens.access_token) + .await?; + let admin = state.engine().platform_me(&claims.sub).await?; + Ok(PlatformAuthResult { + user: admin, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }) } diff --git a/crates/bymax-auth-axum/src/routes/platform_mfa.rs b/crates/bymax-auth-axum/src/routes/platform_mfa.rs index d398701..3d653d9 100644 --- a/crates/bymax-auth-axum/src/routes/platform_mfa.rs +++ b/crates/bymax-auth-axum/src/routes/platform_mfa.rs @@ -42,7 +42,9 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout ) } -/// `POST /auth/platform/mfa/setup` (200). Requires [`PlatformUser`]. +/// `POST /auth/platform/mfa/setup` (201). Requires [`PlatformUser`]. 201 for the same reason +/// the dashboard enrolment is 201 — the shared nest-auth `MfaController.setup` has no +/// `@HttpCode`, so it answers with Nest's `POST` default. async fn setup(State(state): State, user: PlatformUser) -> Response { match state .engine() @@ -50,7 +52,7 @@ async fn setup(State(state): State, user: PlatformUser) -> Response { .await { Ok(result) => ( - StatusCode::OK, + StatusCode::CREATED, Json(json!({ "secret": result.secret, "qrCodeUri": result.qr_code_uri, diff --git a/crates/bymax-auth-axum/src/routes/sessions.rs b/crates/bymax-auth-axum/src/routes/sessions.rs index d66806c..4fffe28 100644 --- a/crates/bymax-auth-axum/src/routes/sessions.rs +++ b/crates/bymax-auth-axum/src/routes/sessions.rs @@ -40,6 +40,9 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// `GET /auth/sessions` (200). Requires [`AuthUser`] + [`UserStatus`]. The caller's own /// session is flagged when the request carries the matching refresh cookie. +/// +/// The body is the bare JSON array, not a `{ sessions: [...] }` wrapper — nest-auth's +/// `SessionController.listSessions` returns `SessionInfo[]` directly. async fn list( State(state): State, _status: UserStatus, @@ -51,7 +54,7 @@ async fn list( match state.engine().list_user_sessions(&user.0.sub, raw).await { Ok(sessions) => { let body: Vec = sessions.iter().map(session_to_json).collect(); - (StatusCode::OK, Json(json!({ "sessions": body }))).into_response() + (StatusCode::OK, Json(body)).into_response() } Err(error) => error_response(&error), } diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 8cdf8a2..6bc944d 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -43,6 +43,28 @@ async fn login_access_cookie(router: &axum::Router, email: &str, password: &str) .unwrap_or_default() } +/// Log a platform admin in. Platform delivery is ALWAYS bearer, so the response plants no +/// cookie and every platform token — access and refresh — travels in the JSON body. +async fn platform_login(router: &axum::Router, email: &str) -> Captured { + Req::post("/auth/platform/login") + .json(serde_json::json!({ "email": email, "password": "adminpass123" })) + .send(router) + .await +} + +/// The `accessToken` a platform login/refresh returned in its body. +fn platform_access(resp: &Captured) -> String { + resp.json()["accessToken"].as_str().unwrap_or("").to_owned() +} + +/// The `refreshToken` a platform login/refresh returned in its body. +fn platform_refresh(resp: &Captured) -> String { + resp.json()["refreshToken"] + .as_str() + .unwrap_or("") + .to_owned() +} + // ---------------------------------------------------------------------------------------- // Router skeleton + toggle/feature gating // ---------------------------------------------------------------------------------------- @@ -116,14 +138,17 @@ async fn register_login_me_logout_cookie_mode() { let signal = reg.cookie("has_session").unwrap_or_default(); assert!(!signal.contains("HttpOnly")); - // `me` with the access cookie returns the user. + // `me` with the access cookie returns the safe user as the TOP-LEVEL body — no + // `{ user: … }` wrapper (nest-auth's `AuthController.me` returns the object itself, and + // its published client decodes it unwrapped). let access_value = reg.cookie_value("access_token").unwrap_or_default(); let me = Req::get("/auth/me") .cookie("access_token", &access_value) .send(&app) .await; assert_eq!(me.status, StatusCode::OK); - assert_eq!(me.json()["user"]["email"], "a@e.com"); + assert_eq!(me.json()["email"], "a@e.com"); + assert!(me.json().get("user").is_none()); // Logout clears the cookies (a cleared cookie has an empty value / expiry). let refresh_value = reg.cookie_value("refresh_token").unwrap_or_default(); @@ -215,6 +240,11 @@ async fn refresh_rotates_in_cookie_and_bearer_modes() { .await; assert_eq!(rotated.status, StatusCode::OK); assert!(rotated.has_cookie_value("refresh_token")); + // Cookie mode echoes the user in the body (nest-auth's refresh re-reads it via `getMe` and + // hands a full `AuthResult` to the delivery layer) instead of the old empty `{}`, so a + // client never needs a follow-up `GET /auth/me` after a rotation. + assert_eq!(rotated.json()["user"]["email"], "r@e.com"); + assert!(rotated.json().get("accessToken").is_none()); // Bearer mode: the refresh comes from the body. let spec = EngineSpec { @@ -235,6 +265,9 @@ async fn refresh_rotates_in_cookie_and_bearer_modes() { .await; assert_eq!(rotated_b.status, StatusCode::OK); assert!(rotated_b.json()["accessToken"].is_string()); + assert!(rotated_b.json()["refreshToken"].is_string()); + // Bearer mode echoes the user alongside the new pair, the same body a bearer login returns. + assert_eq!(rotated_b.json()["user"]["email"], "rb@e.com"); // An empty refresh body in bearer mode → no token → refresh-token-invalid. let empty = Req::post("/auth/refresh").send(&appb).await; @@ -250,6 +283,113 @@ async fn refresh_rotates_in_cookie_and_bearer_modes() { assert_eq!(bad.json()["error"]["code"], "auth.validation"); } +#[tokio::test] +async fn bearer_mode_logout_revokes_the_refresh_session_from_the_body() { + // The security fix behind accepting `refreshToken` in the logout body: a bearer deployment + // plants NO cookie, so a logout that only read the cookie left the refresh session alive — + // the "logged out" client could keep rotating it indefinitely. With the body accepted, the + // session is gone and the token no longer rotates. + let spec = EngineSpec { + delivery: bymax_auth_core::config::TokenDelivery::Bearer, + ..EngineSpec::default() + }; + let Some(h) = build(spec) else { return }; + let app = router(&h); + seed_user(&h, "blo@e.com", "password123", "USER").await; + let session = login(&app, "blo@e.com", "password123").await; + let access = session.json()["accessToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let refresh = session.json()["refreshToken"] + .as_str() + .unwrap_or("") + .to_owned(); + assert!(!refresh.is_empty()); + + let logout = Req::post("/auth/logout") + .bearer(&access) + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(logout.status, StatusCode::NO_CONTENT); + + // The revoked refresh token is dead: rotating it now fails. + let replay = Req::post("/auth/refresh") + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(replay.status, StatusCode::UNAUTHORIZED); + assert_eq!(replay.json()["error"]["code"], "auth.refresh_token_invalid"); +} + +#[tokio::test] +async fn logout_without_any_refresh_token_still_succeeds() { + // Logout stays best-effort and idempotent: neither an absent body nor an unparseable one + // blocks it (the access JTI is still blacklisted), so a client can always end its session. + let spec = EngineSpec { + delivery: bymax_auth_core::config::TokenDelivery::Bearer, + ..EngineSpec::default() + }; + let Some(h) = build(spec) else { return }; + let app = router(&h); + seed_user(&h, "nolo@e.com", "password123", "USER").await; + + let first = login(&app, "nolo@e.com", "password123").await; + let access = first.json()["accessToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let bare = Req::post("/auth/logout").bearer(&access).send(&app).await; + assert_eq!(bare.status, StatusCode::NO_CONTENT); + // The access token was still revoked, so it no longer authenticates. + let after = Req::get("/auth/me").bearer(&access).send(&app).await; + assert_eq!(after.status, StatusCode::UNAUTHORIZED); + + let second = login(&app, "nolo@e.com", "password123").await; + let access2 = second.json()["accessToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let junk = Req::post("/auth/logout") + .bearer(&access2) + .raw_body(b"{not json".to_vec(), "application/json") + .send(&app) + .await; + assert_eq!(junk.status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn platform_logout_revokes_the_refresh_session_from_the_body() { + // The platform twin of the logout gap: platform delivery never plants a cookie, so the + // refresh token can only arrive in the body. Reading it there is what actually kills the + // platform refresh session. + let Some(h) = build(EngineSpec { + platform: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + seed_admin(&h, "plo@e.com", "SUPER_ADMIN").await; + let session = platform_login(&app, "plo@e.com").await; + let access = platform_access(&session); + let refresh = platform_refresh(&session); + + let logout = Req::post("/auth/platform/logout") + .bearer(&access) + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(logout.status, StatusCode::NO_CONTENT); + + let replay = Req::post("/auth/platform/refresh") + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(replay.status, StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn invalid_credentials_and_unknown_email_are_indistinguishable() { // Anti-enumeration: a wrong password and an unknown email both return the same generic @@ -451,18 +591,21 @@ async fn sessions_list_revoke_one_and_revoke_all() { let access = reg.cookie_value("access_token").unwrap_or_default(); let refresh = reg.cookie_value("refresh_token").unwrap_or_default(); - // List the caller's sessions; the current one is flagged. + // List the caller's sessions; the current one is flagged. The body is the BARE array — + // nest-auth's `SessionController.listSessions` returns `SessionInfo[]`, not a + // `{ sessions: [...] }` wrapper. let list = Req::get("/auth/sessions") .cookie("access_token", &access) .cookie("refresh_token", &refresh) .send(&app) .await; assert_eq!(list.status, StatusCode::OK); - let sessions = list.json()["sessions"] - .as_array() - .cloned() - .unwrap_or_default(); + assert!(list.json().get("sessions").is_none()); + let sessions = list.json().as_array().cloned().unwrap_or_default(); assert!(!sessions.is_empty()); + // The per-item shape is unchanged: the display id, the full hash, and the current flag. + assert!(sessions[0]["id"].is_string()); + assert_eq!(sessions[0]["isCurrent"], true); let hash = sessions[0]["sessionHash"].as_str().unwrap_or("").to_owned(); // The static `all` segment wins over the `{id}` capture. @@ -516,12 +659,14 @@ async fn mfa_setup_verify_enable_and_challenge_error_arms() { .await; let access = reg.cookie_value("access_token").unwrap_or_default(); - // setup returns the secret/qr/recovery codes (enrolment is reachable without MfaSatisfied). + // setup returns the secret/qr/recovery codes (enrolment is reachable without MfaSatisfied), + // with a 201 — nest-auth's `MfaController.setup` carries no `@HttpCode`, so it answers with + // Nest's `POST` default. let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) .send(&app) .await; - assert_eq!(setup.status, StatusCode::OK); + assert_eq!(setup.status, StatusCode::CREATED); let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); assert!(!secret.is_empty()); @@ -599,22 +744,27 @@ async fn platform_login_me_logout_and_dashboard_token_is_rejected() { let app = router(&h); seed_admin(&h, "boss@e.com", "SUPER_ADMIN").await; - // Platform login (no tenant) returns a platform session. - let login = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "boss@e.com", "password": "adminpass123" })) - .send(&app) - .await; + // Platform login (no tenant) returns a platform session in the BODY under `admin`, with + // the token pair alongside — nest-auth's `PlatformBearerAuthResponse`. Even though this + // engine is in `cookie` delivery mode, the platform path plants NO cookie at all. + let login = platform_login(&app, "boss@e.com").await; assert_eq!(login.status, StatusCode::OK); - let access = login.cookie_value("access_token").unwrap_or_default(); + assert!(login.set_cookies.is_empty()); + assert_eq!(login.json()["admin"]["email"], "boss@e.com"); + assert!(login.json().get("user").is_none()); + let access = platform_access(&login); assert!(!access.is_empty()); + assert!(!platform_refresh(&login).is_empty()); - // Platform `me` returns the admin (no tenantId). + // Platform `me` returns the admin as the top-level body (no wrapper, no tenantId), read + // via the bearer header — the only channel a platform token ever travels on. let me = Req::get("/auth/platform/me") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; assert_eq!(me.status, StatusCode::OK); - assert_eq!(me.json()["user"]["email"], "boss@e.com"); + assert_eq!(me.json()["email"], "boss@e.com"); + assert!(me.json().get("user").is_none()); // A dashboard token on a platform route is `platform_auth_required`. let dash = seed_user(&h, "tenant@e.com", "password123", "USER").await; @@ -627,15 +777,23 @@ async fn platform_login_me_logout_and_dashboard_token_is_rejected() { .await; let dash_token = dash_login.cookie_value("access_token").unwrap_or_default(); let wrong = Req::get("/auth/platform/me") - .cookie("access_token", &dash_token) + .bearer(&dash_token) .send(&app) .await; assert_eq!(wrong.status, StatusCode::UNAUTHORIZED); assert_eq!(wrong.json()["error"]["code"], "auth.platform_auth_required"); + // The dashboard access COOKIE is never accepted on a platform route either, whatever it + // carries — platform extraction reads the bearer header only. + let via_cookie = Req::get("/auth/platform/me") + .cookie("access_token", &access) + .send(&app) + .await; + assert_eq!(via_cookie.status, StatusCode::UNAUTHORIZED); + // Platform logout clears the session. let logout = Req::post("/auth/platform/logout") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; assert_eq!(logout.status, StatusCode::NO_CONTENT); @@ -663,18 +821,16 @@ async fn platform_mfa_setup_requires_platform_auth() { let setup = Req::post("/auth/platform/mfa/setup").send(&app).await; assert_eq!(setup.status, StatusCode::UNAUTHORIZED); - // With a platform token, setup returns the enrolment material. + // With a platform bearer token, setup returns the enrolment material with a 201 (the + // shared nest-auth `MfaController.setup` has no `@HttpCode`). seed_admin(&h, "padmin@e.com", "SUPER_ADMIN").await; - let login = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "padmin@e.com", "password": "adminpass123" })) - .send(&app) - .await; - let access = login.cookie_value("access_token").unwrap_or_default(); + let login = platform_login(&app, "padmin@e.com").await; + let access = platform_access(&login); let setup_ok = Req::post("/auth/platform/mfa/setup") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; - assert_eq!(setup_ok.status, StatusCode::OK); + assert_eq!(setup_ok.status, StatusCode::CREATED); assert!(setup_ok.json()["secret"].is_string()); } @@ -939,19 +1095,21 @@ async fn platform_refresh_revoke_all_and_mfa_challenge_arms() { }; let app = router(&h); seed_admin(&h, "ops@e.com", "SUPER_ADMIN").await; - let login = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "ops@e.com", "password": "adminpass123" })) - .send(&app) - .await; - let access = login.cookie_value("access_token").unwrap_or_default(); - let refresh = login.cookie_value("refresh_token").unwrap_or_default(); + let login = platform_login(&app, "ops@e.com").await; + let access = platform_access(&login); + let refresh = platform_refresh(&login); - // Platform refresh rotates the pair from the cookie. + // Platform refresh rotates the pair from the BODY (platform never uses cookies) and echoes + // the admin alongside the new pair, matching `PlatformAuthController.refresh`. let rotated = Req::post("/auth/platform/refresh") - .cookie("refresh_token", &refresh) + .json(serde_json::json!({ "refreshToken": refresh })) .send(&app) .await; assert_eq!(rotated.status, StatusCode::OK); + assert!(rotated.set_cookies.is_empty()); + assert_eq!(rotated.json()["admin"]["email"], "ops@e.com"); + assert!(rotated.json()["accessToken"].is_string()); + assert_ne!(platform_refresh(&rotated), refresh); // A malformed platform refresh body is a validation error. let bad = Req::post("/auth/platform/refresh") @@ -962,7 +1120,7 @@ async fn platform_refresh_revoke_all_and_mfa_challenge_arms() { // revoke-all with a platform token succeeds. let revoke = Req::delete("/auth/platform/sessions") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; assert_eq!(revoke.status, StatusCode::NO_CONTENT); @@ -973,6 +1131,20 @@ async fn platform_refresh_revoke_all_and_mfa_challenge_arms() { .send(&app) .await; assert_eq!(challenge.status, StatusCode::UNAUTHORIZED); + + // Omitting the temp token entirely is an INVALID-TEMP-TOKEN 401, not a validation 400: + // the shared DTO makes the field optional for the dashboard OAuth cookie flow, and the + // platform handler (which has no cookie flow) rejects the absence explicitly, exactly as + // `PlatformAuthController.mfaChallenge` does. + let missing = Req::post("/auth/platform/mfa/challenge") + .json(serde_json::json!({ "code": "123456" })) + .send(&app) + .await; + assert_eq!(missing.status, StatusCode::UNAUTHORIZED); + assert_eq!( + missing.json()["error"]["code"], + "auth.mfa_temp_token_invalid" + ); } // ---------------------------------------------------------------------------------------- @@ -990,23 +1162,21 @@ async fn platform_mfa_full_lifecycle() { }; let app = router(&h); seed_admin(&h, "padmin2@e.com", "SUPER_ADMIN").await; - let login = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "padmin2@e.com", "password": "adminpass123" })) - .send(&app) - .await; - let access = login.cookie_value("access_token").unwrap_or_default(); + let login = platform_login(&app, "padmin2@e.com").await; + let access = platform_access(&login); + // Enrolment answers 201 (Nest's `POST` default, since `MfaController.setup` sets no code). let setup = Req::post("/auth/platform/mfa/setup") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; - assert_eq!(setup.status, StatusCode::OK); + assert_eq!(setup.status, StatusCode::CREATED); let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); // Each TOTP-gated step uses a distinct window offset so the per-window anti-replay never // rejects a reused code (the verifier's window tolerance accepts the near-future codes). let enable = Req::post("/auth/platform/mfa/verify-enable") - .cookie("access_token", &access) + .bearer(&access) .json(serde_json::json!({ "code": totp_at(&secret, 0) })) .send(&app) .await; @@ -1014,7 +1184,7 @@ async fn platform_mfa_full_lifecycle() { // recovery-codes with a fresh-window TOTP returns a fresh set. let recov = Req::post("/auth/platform/mfa/recovery-codes") - .cookie("access_token", &access) + .bearer(&access) .json(serde_json::json!({ "code": totp_at(&secret, 30) })) .send(&app) .await; @@ -1023,7 +1193,7 @@ async fn platform_mfa_full_lifecycle() { // disable with another fresh-window TOTP turns it off. let disable = Req::post("/auth/platform/mfa/disable") - .cookie("access_token", &access) + .bearer(&access) .json(serde_json::json!({ "code": totp_at(&secret, 60) })) .send(&app) .await; @@ -1103,6 +1273,210 @@ async fn mfa_dashboard_challenge_success_issues_a_session() { assert!(challenge.has_cookie_value("access_token")); } +#[tokio::test] +async fn mfa_challenge_falls_back_to_the_oauth_temp_cookie_and_clears_it() { + // The browser OAuth + MFA flow end to end: the callback plants the HttpOnly + // `mfa_temp_token` cookie and 302s to the MFA page, which can never read that cookie to + // echo it in a body. Requiring `mfaTempToken` in the body therefore made the flow + // impossible to complete. The cookie is now the fallback — and it is CLEARED once consumed + // so the spent token is not left in the browser. + let Some(h) = build(EngineSpec { + mfa: true, + oauth: true, + allow_oauth: true, + ..EngineSpec::default() + }) else { + return; + }; + // The mock provider resolves to this account; enabling MFA makes the callback yield a + // challenge rather than a session. + let id = seed_user(&h, "mock@example.com", "password123", "USER").await; + let app = router(&h); + + // Enrol MFA properly so a recovery code exists for the challenge. + let login0 = login(&app, "mock@example.com", "password123").await; + let access = login0.cookie_value("access_token").unwrap_or_default(); + let setup = Req::post("/auth/mfa/setup") + .cookie("access_token", &access) + .send(&app) + .await; + let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); + let recovery = setup.json()["recoveryCodes"][0] + .as_str() + .unwrap_or("") + .to_owned(); + let _ = Req::post("/auth/mfa/verify-enable") + .cookie("access_token", &access) + .json(serde_json::json!({ "code": current_totp(&secret) })) + .send(&app) + .await; + use bymax_auth_core::traits::UserRepository; + let _ = h.users.link_oauth(&id, "google", "mock-123").await; + + // Drive the real OAuth callback so the temp cookie is planted by the adapter itself. + let initiate = Req::get("/auth/oauth/google?tenantId=t1").send(&app).await; + let location = initiate + .headers + .get(header::LOCATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let state = location + .split("state=") + .nth(1) + .and_then(|s| s.split('&').next()) + .unwrap_or("") + .to_owned(); + let callback = Req::get(&format!( + "/auth/oauth/google/callback?code=abc&state={state}" + )) + .send(&app) + .await; + let temp_cookie = callback.cookie_value("mfa_temp_token").unwrap_or_default(); + assert!(!temp_cookie.is_empty()); + + // The challenge body omits `mfaTempToken` entirely — the cookie carries it. + let challenge = Req::post("/auth/mfa/challenge") + .cookie("mfa_temp_token", &temp_cookie) + .json(serde_json::json!({ "code": recovery })) + .send(&app) + .await; + assert_eq!(challenge.status, StatusCode::OK); + assert!(challenge.has_cookie_value("access_token")); + // The consumed temp cookie is cleared (a `Set-Cookie` with an empty value). + assert!(challenge.cookie("mfa_temp_token").is_some()); + assert!(!challenge.has_cookie_value("mfa_temp_token")); +} + +#[tokio::test] +async fn mfa_challenge_temp_token_sourcing_precedence_and_clearing_policy() { + // Three rules of the cookie fallback, all mirroring `mfa.controller.ts`: + // 1. neither channel supplied → `mfa_temp_token_invalid`, NOT a validation 400; + // 2. a dead cookie token → cleared (retrying under it can never succeed); + // 3. a live token + a WRONG code → cookie KEPT, so the user can retry inside its TTL. + let Some(h) = build(EngineSpec { + mfa: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + + // 1. Neither body nor cookie. + let neither = Req::post("/auth/mfa/challenge") + .json(serde_json::json!({ "code": "123456" })) + .send(&app) + .await; + assert_eq!(neither.status, StatusCode::UNAUTHORIZED); + assert_eq!( + neither.json()["error"]["code"], + "auth.mfa_temp_token_invalid" + ); + + // 2. A forged cookie token is invalid → the cookie is cleared. + let dead = Req::post("/auth/mfa/challenge") + .cookie("mfa_temp_token", "bogus") + .json(serde_json::json!({ "code": "123456" })) + .send(&app) + .await; + assert_eq!(dead.status, StatusCode::UNAUTHORIZED); + assert!(dead.cookie("mfa_temp_token").is_some()); + assert!(!dead.has_cookie_value("mfa_temp_token")); + + // 3. A LIVE temp token from a real MFA login, submitted with a wrong code: the failure is + // recoverable, so the cookie must survive for the retry. + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "keep@e.com", "password": "password123", "name": "Kee", "tenantId": TENANT + })) + .send(&app) + .await; + let access = reg.cookie_value("access_token").unwrap_or_default(); + let setup = Req::post("/auth/mfa/setup") + .cookie("access_token", &access) + .send(&app) + .await; + let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); + let _ = Req::post("/auth/mfa/verify-enable") + .cookie("access_token", &access) + .json(serde_json::json!({ "code": current_totp(&secret) })) + .send(&app) + .await; + let mfa_login = login(&app, "keep@e.com", "password123").await; + let temp = mfa_login.json()["mfaTempToken"] + .as_str() + .unwrap_or("") + .to_owned(); + assert!(!temp.is_empty()); + let wrong_code = Req::post("/auth/mfa/challenge") + .cookie("mfa_temp_token", &temp) + .json(serde_json::json!({ "code": "000000" })) + .send(&app) + .await; + assert_ne!(wrong_code.status, StatusCode::OK); + assert_eq!( + wrong_code.json()["error"]["code"], + "auth.mfa_invalid_code", + "a wrong code must not be reported as an invalid temp token" + ); + assert!(wrong_code.cookie("mfa_temp_token").is_none()); +} + +#[tokio::test] +async fn mfa_challenge_body_token_wins_over_a_stale_cookie() { + // The body is the historical channel of the password-login flow, so it takes precedence + // when both are present. On success the stale cookie is still cleared — nest-auth clears + // whenever a cookie was on the request, so a spent OAuth cookie never lingers after the + // challenge completes through the other channel. + let Some(h) = build(EngineSpec { + mfa: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "pref@e.com", "password": "password123", "name": "Pre", "tenantId": TENANT + })) + .send(&app) + .await; + let access = reg.cookie_value("access_token").unwrap_or_default(); + let setup = Req::post("/auth/mfa/setup") + .cookie("access_token", &access) + .send(&app) + .await; + let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); + let recovery = setup.json()["recoveryCodes"][0] + .as_str() + .unwrap_or("") + .to_owned(); + let _ = Req::post("/auth/mfa/verify-enable") + .cookie("access_token", &access) + .json(serde_json::json!({ "code": current_totp(&secret) })) + .send(&app) + .await; + + let mfa_login = login(&app, "pref@e.com", "password123").await; + let temp = mfa_login.json()["mfaTempToken"] + .as_str() + .unwrap_or("") + .to_owned(); + + // The body carries the REAL token while the cookie carries garbage: the body wins, so the + // challenge succeeds (a cookie-first handler would have failed on the garbage value). + let challenge = Req::post("/auth/mfa/challenge") + .cookie("mfa_temp_token", "stale-and-ignored") + .json(serde_json::json!({ "mfaTempToken": temp, "code": recovery })) + .send(&app) + .await; + assert_eq!(challenge.status, StatusCode::OK); + assert!(challenge.has_cookie_value("access_token")); + // The stale cookie present on the request is cleared on success all the same. + assert!(challenge.cookie("mfa_temp_token").is_some()); + assert!(!challenge.has_cookie_value("mfa_temp_token")); +} + // ---------------------------------------------------------------------------------------- // validation: the ValidatedQuery path (OAuth query) rejects a missing field // ---------------------------------------------------------------------------------------- @@ -1364,13 +1738,10 @@ async fn platform_login_mfa_challenge_success() { let admin_id = seed_admin(&h, "mfaboss@e.com", "SUPER_ADMIN").await; // Enrol platform MFA via setup + verify-enable. - let login0 = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "mfaboss@e.com", "password": "adminpass123" })) - .send(&app) - .await; - let access = login0.cookie_value("access_token").unwrap_or_default(); + let login0 = platform_login(&app, "mfaboss@e.com").await; + let access = platform_access(&login0); let setup = Req::post("/auth/platform/mfa/setup") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1379,30 +1750,30 @@ async fn platform_login_mfa_challenge_success() { .unwrap_or("") .to_owned(); let _ = Req::post("/auth/platform/mfa/verify-enable") - .cookie("access_token", &access) + .bearer(&access) .json(serde_json::json!({ "code": current_totp(&secret) })) .send(&app) .await; let _ = admin_id; // A fresh login now returns a platform MFA challenge. - let login = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "mfaboss@e.com", "password": "adminpass123" })) - .send(&app) - .await; + let login = platform_login(&app, "mfaboss@e.com").await; assert_eq!(login.json()["mfaRequired"], true); let temp = login.json()["mfaTempToken"] .as_str() .unwrap_or("") .to_owned(); - // Completing the platform MFA challenge with a recovery code issues a platform session. + // Completing the platform MFA challenge with a recovery code issues a platform session — + // in the body under `admin`, with no cookie planted (platform is always bearer). let challenge = Req::post("/auth/platform/mfa/challenge") .json(serde_json::json!({ "mfaTempToken": temp, "code": recovery })) .send(&app) .await; assert_eq!(challenge.status, StatusCode::OK); - assert!(challenge.has_cookie_value("access_token")); + assert!(challenge.set_cookies.is_empty()); + assert_eq!(challenge.json()["admin"]["email"], "mfaboss@e.com"); + assert!(!platform_access(&challenge).is_empty()); } #[tokio::test] @@ -1511,7 +1882,7 @@ async fn platform_me_and_revoke_error_arms_with_a_ghost_admin() { let ghost = common::mint_platform_token("ghost-admin", "SUPER_ADMIN"); let me = Req::get("/auth/platform/me") - .cookie("access_token", &ghost) + .bearer(&ghost) .send(&app) .await; assert_eq!(me.status, StatusCode::UNAUTHORIZED); @@ -1520,12 +1891,12 @@ async fn platform_me_and_revoke_error_arms_with_a_ghost_admin() { // revoke-all for a ghost admin: the engine revokes nothing and returns Ok (204), but the // path is exercised; a logout for the ghost also runs. let revoke = Req::delete("/auth/platform/sessions") - .cookie("access_token", &ghost) + .bearer(&ghost) .send(&app) .await; assert_eq!(revoke.status, StatusCode::NO_CONTENT); let logout = Req::post("/auth/platform/logout") - .cookie("access_token", &ghost) + .bearer(&ghost) .send(&app) .await; assert_eq!(logout.status, StatusCode::NO_CONTENT); @@ -1559,12 +1930,12 @@ async fn mfa_setup_error_arm_when_already_enabled() { .send(&app) .await; // setup again now hits the setup error arm; the exact status depends on the engine's - // re-enrolment policy, so assert only that it is no longer the 200 success. + // re-enrolment policy, so assert only that it is no longer the 201 success. let again = Req::post("/auth/mfa/setup") .cookie("access_token", &access) .send(&app) .await; - assert_ne!(again.status, StatusCode::OK); + assert_ne!(again.status, StatusCode::CREATED); } #[tokio::test] @@ -1744,7 +2115,7 @@ async fn mfa_handler_error_arms_with_a_ghost_subject() { .cookie("access_token", &ghost) .send(&app) .await; - assert_ne!(setup.status, StatusCode::OK); + assert_ne!(setup.status, StatusCode::CREATED); let verify = Req::post("/auth/mfa/verify-enable") .cookie("access_token", &ghost) @@ -1782,24 +2153,24 @@ async fn platform_mfa_handler_error_arms_with_a_ghost_admin() { let ghost = common::mint_platform_token("ghost-padmin", "SUPER_ADMIN"); let setup = Req::post("/auth/platform/mfa/setup") - .cookie("access_token", &ghost) + .bearer(&ghost) .send(&app) .await; - assert_ne!(setup.status, StatusCode::OK); + assert_ne!(setup.status, StatusCode::CREATED); let verify = Req::post("/auth/platform/mfa/verify-enable") - .cookie("access_token", &ghost) + .bearer(&ghost) .json(serde_json::json!({ "code": "000000" })) .send(&app) .await; assert_ne!(verify.status, StatusCode::NO_CONTENT); let disable = Req::post("/auth/platform/mfa/disable") - .cookie("access_token", &ghost) + .bearer(&ghost) .json(serde_json::json!({ "code": "000000" })) .send(&app) .await; assert_ne!(disable.status, StatusCode::NO_CONTENT); let recov = Req::post("/auth/platform/mfa/recovery-codes") - .cookie("access_token", &ghost) + .bearer(&ghost) .json(serde_json::json!({ "code": "000000" })) .send(&app) .await; @@ -1821,13 +2192,10 @@ async fn mfa_and_platform_challenge_context_mismatch_arms() { // Enrol a platform admin and obtain a platform MFA temp token via login. let admin = seed_admin(&h, "ctx@e.com", "SUPER_ADMIN").await; - let login0 = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "ctx@e.com", "password": "adminpass123" })) - .send(&app) - .await; - let access = login0.cookie_value("access_token").unwrap_or_default(); + let login0 = platform_login(&app, "ctx@e.com").await; + let access = platform_access(&login0); let setup = Req::post("/auth/platform/mfa/setup") - .cookie("access_token", &access) + .bearer(&access) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1836,15 +2204,12 @@ async fn mfa_and_platform_challenge_context_mismatch_arms() { .unwrap_or("") .to_owned(); let _ = Req::post("/auth/platform/mfa/verify-enable") - .cookie("access_token", &access) + .bearer(&access) .json(serde_json::json!({ "code": current_totp(&secret) })) .send(&app) .await; let _ = admin; - let plogin = Req::post("/auth/platform/login") - .json(serde_json::json!({ "email": "ctx@e.com", "password": "adminpass123" })) - .send(&app) - .await; + let plogin = platform_login(&app, "ctx@e.com").await; let platform_temp = plogin.json()["mfaTempToken"] .as_str() .unwrap_or("") @@ -1937,12 +2302,63 @@ async fn platform_revoke_all_store_failure_arm() { // store op fails → the platform revoke-all error arm renders a 500. let token = common::mint_platform_token("padmin", "SUPER_ADMIN"); let revoke = Req::delete("/auth/platform/sessions") - .cookie("access_token", &token) + .bearer(&token) .send(&app) .await; assert_eq!(revoke.status, StatusCode::INTERNAL_SERVER_ERROR); } +#[tokio::test] +async fn refresh_surfaces_a_failure_to_re_read_the_account_after_rotation() { + // Echoing the account in the refresh body means the handler does a second step after the + // rotation succeeds: recover the subject from the new access token, then re-read the + // record. With the `rv:{jti}` revocation check failing (Redis down) that second step + // fails, and the request MUST surface the infrastructure error as a 500 — reporting a + // rotation whose body could not be built as a success would hand the client a body it + // cannot use. + let Some(h) = common::build_failing_blacklist() else { + return; + }; + let app = router(&h); + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "rr@e.com", "password": "password123", "name": "Rr", "tenantId": TENANT + })) + .send(&app) + .await; + let refresh = reg.cookie_value("refresh_token").unwrap_or_default(); + assert!(!refresh.is_empty()); + + let rotated = Req::post("/auth/refresh") + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(rotated.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(rotated.json()["error"]["code"], "auth.internal"); +} + +#[tokio::test] +async fn platform_refresh_surfaces_a_failure_to_re_read_the_admin_after_rotation() { + // The platform twin of the above: the admin echo needs the freshly-minted platform token + // verified, so a failing revocation check turns the rotation into a 500 rather than a + // success with no `admin` in it. + let Some(h) = common::build_failing_blacklist() else { + return; + }; + let app = router(&h); + seed_admin(&h, "rrp@e.com", "SUPER_ADMIN").await; + let session = platform_login(&app, "rrp@e.com").await; + let refresh = platform_refresh(&session); + assert!(!refresh.is_empty()); + + let rotated = Req::post("/auth/platform/refresh") + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(rotated.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(rotated.json()["error"]["code"], "auth.internal"); +} + #[tokio::test] async fn platform_extractor_propagates_internal_errors_but_masks_token_failures() { // With the `rv:{jti}` revocation check failing (Redis down), a *well-formed* platform token @@ -1955,7 +2371,7 @@ async fn platform_extractor_propagates_internal_errors_but_masks_token_failures( let token = common::mint_platform_token("padmin", "SUPER_ADMIN"); let internal = Req::get("/auth/platform/me") - .cookie("access_token", &token) + .bearer(&token) .send(&app) .await; assert_eq!(internal.status, StatusCode::INTERNAL_SERVER_ERROR); @@ -1964,7 +2380,7 @@ async fn platform_extractor_propagates_internal_errors_but_masks_token_failures( // A token-auth failure (a malformed/invalid token) never reaches the revocation check, so it // still collapses to `platform_auth_required` (401) — the masking is correct for THIS case. let invalid = Req::get("/auth/platform/me") - .cookie("access_token", "not-a-jwt") + .bearer("not-a-jwt") .send(&app) .await; assert_eq!(invalid.status, StatusCode::UNAUTHORIZED); diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index aff8e02..76cf20c 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -187,6 +187,15 @@ impl Resp { .filter(|v| !v.is_empty()) .unwrap_or_default() } + /// Whether the response emitted no `Set-Cookie` at all — the platform contract, which is + /// always bearer and must never plant a cookie. + fn sets_no_cookies(&self) -> bool { + self.headers + .get_all(header::SET_COOKIE) + .iter() + .next() + .is_none() + } } /// Drive a request through the router (peer IP injected; optional JSON body + cookies). @@ -240,6 +249,53 @@ async fn call( } } +/// Drive a request whose credential travels in the `Authorization: Bearer` header — the only +/// channel a platform access token is ever accepted on. +async fn bearer_call( + app: &Router, + method: Method, + path: &str, + body: Option, + token: &str, +) -> Resp { + let body = match body { + Some(value) => Body::from(value.to_string()), + None => Body::empty(), + }; + let mut builder = Request::builder() + .method(method) + .uri(path) + .header(header::CONTENT_TYPE, "application/json"); + if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}")) { + builder = builder.header(header::AUTHORIZATION, value); + } + let mut request = match builder.body(body) { + Ok(request) => request, + Err(_) => return error_resp(), + }; + if let Ok(addr) = PEER.parse::() { + request.extensions_mut().insert(ConnectInfo(addr)); + } + match app.clone().oneshot(request).await { + Ok(response) => { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .into_body() + .collect() + .await + .map(|c| c.to_bytes().to_vec()) + .unwrap_or_default(); + Resp { + status, + headers, + body, + } + } + Err(_) => error_resp(), + } +} + fn error_resp() -> Resp { Resp { status: StatusCode::INTERNAL_SERVER_ERROR, @@ -290,7 +346,8 @@ async fn full_router_against_real_redis() { ) .await; assert_eq!(me.status, StatusCode::OK); - assert_eq!(me.json()["user"]["email"], "r@e.com"); + // The safe user is the top-level body — no `{ user: … }` wrapper (nest-auth parity). + assert_eq!(me.json()["email"], "r@e.com"); let rotated = call( &app, @@ -303,6 +360,8 @@ async fn full_router_against_real_redis() { assert_eq!(rotated.status, StatusCode::OK); let new_refresh = rotated.cookie_value("refresh_token"); assert!(!new_refresh.is_empty()); + // A rotation echoes the account, so a client never needs a follow-up `me` call. + assert_eq!(rotated.json()["user"]["email"], "r@e.com"); // ---- sessions over real Redis ------------------------------------------------------ let list = call( @@ -314,7 +373,8 @@ async fn full_router_against_real_redis() { ) .await; assert_eq!(list.status, StatusCode::OK); - assert!(list.json()["sessions"].is_array()); + // The session list is the bare array, as nest-auth's `SessionController` returns it. + assert!(list.json().is_array()); // ---- WS ticket mint + single-use redeem against real Redis ------------------------- let mint = call( @@ -390,16 +450,35 @@ async fn full_router_against_real_redis() { ) .await; assert_eq!(plogin.status, StatusCode::OK); - let padmin_access = plogin.cookie_value("access_token"); - let pme = call( + // Platform delivery is always bearer: the tokens come back in the body under `admin`, and + // no cookie is planted even though this engine runs in `cookie` mode. + assert!(plogin.sets_no_cookies()); + assert_eq!(plogin.json()["admin"]["email"], "boss@e.com"); + let padmin_access = plogin.json()["accessToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let padmin_refresh = plogin.json()["refreshToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let pme = bearer_call(&app, Method::GET, "/auth/platform/me", None, &padmin_access).await; + assert_eq!(pme.status, StatusCode::OK); + // `platform/me` returns the admin as the top-level body, no wrapper. + assert_eq!(pme.json()["email"], "boss@e.com"); + + // A platform refresh over real Redis rotates the pair from the BODY and echoes the admin. + let protated = call( &app, - Method::GET, - "/auth/platform/me", - None, - &[("access_token", &padmin_access)], + Method::POST, + "/auth/platform/refresh", + Some(serde_json::json!({ "refreshToken": padmin_refresh })), + &[], ) .await; - assert_eq!(pme.status, StatusCode::OK); + assert_eq!(protated.status, StatusCode::OK); + assert_eq!(protated.json()["admin"]["email"], "boss@e.com"); + assert!(protated.json()["accessToken"].is_string()); // ---- invitations over real Redis --------------------------------------------------- seed_user(&users, "inviter@e.com", "ADMIN").await; @@ -445,7 +524,8 @@ async fn full_router_against_real_redis() { &[("access_token", &mfa_access)], ) .await; - assert_eq!(setup.status, StatusCode::OK); + // Enrolment answers 201 (Nest's `POST` default on `MfaController.setup`). + assert_eq!(setup.status, StatusCode::CREATED); let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); let raw = bymax_auth_crypto::totp::decode_secret_base32(&secret).unwrap_or_default(); let now = std::time::SystemTime::now() diff --git a/crates/bymax-auth-client/src/lib.rs b/crates/bymax-auth-client/src/lib.rs index 97368db..ac38be2 100644 --- a/crates/bymax-auth-client/src/lib.rs +++ b/crates/bymax-auth-client/src/lib.rs @@ -146,13 +146,6 @@ pub struct ResetPasswordRequest { pub tenant_id: String, } -/// The `{ user }`-wrapped body returned by `GET /auth/me`. -#[derive(serde::Deserialize)] -struct MeBody { - /// The credential-free user. - user: SafeAuthUser, -} - /// A typed, `reqwest`-backed client for a `bymax-auth-axum` backend. pub struct AuthClient { /// The shared HTTP client (a cheap-to-clone connection pool). @@ -369,14 +362,14 @@ impl AuthClient { } } - /// `GET /auth/me` with the given bearer access token, parsing the `{ user }` wrapper. + /// `GET /auth/me` with the given bearer access token. The endpoint returns the safe user as + /// the top-level body (no `{ user }` wrapper), matching nest-auth's `AuthController.me`. async fn fetch_me(&self, access: &str) -> Result { let request = self .http .get(format!("{}/auth/me", self.base_url)) .header(AUTHORIZATION, format!("Bearer {access}")); - let body: MeBody = self.send_json(request).await?; - Ok(body.user) + self.send_json(request).await } /// Refresh under the single-flight lock so concurrent callers share one rotation. The @@ -644,9 +637,9 @@ mod tests { ) } - /// The `{ user }` body returned by `me`. + /// The body returned by `me`: the safe user itself, unwrapped. fn me_body() -> String { - r#"{"user":{"id":"u1","email":"u@e.com","name":"U","role":"USER","status":"ACTIVE","tenantId":"t1","emailVerified":true,"mfaEnabled":false,"lastLoginAt":null,"createdAt":"2020-01-01T00:00:00Z"}}"#.to_owned() + r#"{"id":"u1","email":"u@e.com","name":"U","role":"USER","status":"ACTIVE","tenantId":"t1","emailVerified":true,"mfaEnabled":false,"lastLoginAt":null,"createdAt":"2020-01-01T00:00:00Z"}"#.to_owned() } /// An `auth.*` error envelope body. diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index e6d0996..3abd0de 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -638,9 +638,11 @@ 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"); + // The catalog is exhaustive on purpose: an unlisted prefix fails the test rather than + // passing silently, so adding a keyspace forces a deliberate decision here. let allowed = [ - "rt", "rv", "rp", "sess", "sd", "lf", "otp", "resend", "wst", "pr", "prv", "inv", "prt", - "prp", "psess", "psd", + "rt", "rv", "rp", "sess", "sd", "lf", "otp", "resend", "wst", "pw_reset", "pw_vtok", "inv", + "prt", "prp", "psess", "psd", ]; for key in &keys { // Namespaced under the configured prefix, applied in exactly one place. From ba305ded24451b828c7355ccd45fdaf4a0224d2d Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:38:07 -0300 Subject: [PATCH 012/122] test(core): restore 100% line coverage on the store trait tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two round-trip tests unwrapped `serde_json::from_str` with `?` on a multi-line call, which puts the operator's error arm on a line of its own. The literal being parsed always succeeds, so that arm is unreachable and the CI gate — which fails under 100% line coverage — tripped on it. The gate only surfaced this once the Docker-backed Redis tier was actually executing, since before that the run aborted earlier. Assert on the `Result` with `matches!` instead, which is the idiom the rest of this codebase already uses for exactly this reason and keeps the same assertions. Line coverage is back to 100.00% (16971/16971) with functions at 100%; `cargo llvm-cov --fail-under-lines 100` exits clean. --- crates/bymax-auth-core/src/traits/store.rs | 32 +++++++++++++--------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index daac36e..99e07e3 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -703,17 +703,18 @@ mod tests { assert!(!json.contains("\"createdAt\":\"")); // A nest-auth-written record (numbers, sub-second precision) reads back exactly. - let from_nest: SessionDetail = serde_json::from_str( + // Asserted on the `Result` rather than unwrapped with `?`: the literal always parses, + // so the `?` operator's error arm would sit on its own line as dead, uncovered code. + let from_nest: serde_json::Result = serde_json::from_str( r#"{"sessionHash":"abc123","device":"Firefox","ip":"198.51.100.7","createdAt":1700000000123,"lastActivityAt":1700000060456}"#, - )?; - assert_eq!( - from_nest.created_at.unix_timestamp_nanos() / 1_000_000, - 1_700_000_000_123 - ); - assert_eq!( - from_nest.last_activity_at.unix_timestamp_nanos() / 1_000_000, - 1_700_000_060_456 ); + assert!(matches!( + from_nest, + Ok(ref detail) + if detail.created_at.unix_timestamp_nanos() / 1_000_000 == 1_700_000_000_123 + && detail.last_activity_at.unix_timestamp_nanos() / 1_000_000 + == 1_700_000_060_456 + )); Ok(()) } @@ -847,11 +848,16 @@ mod tests { let json = serde_json::to_string(&invitation)?; assert!(json.contains("\"createdAt\":\"2023-11-14T22:13:20")); - let from_nest: StoredInvitation = serde_json::from_str( + // Same idiom as above: assert on the `Result` so the `?` error arm is not left as an + // uncovered line the 100% gate then trips over. + let from_nest: serde_json::Result = serde_json::from_str( r#"{"email":"invitee@example.com","role":"MEMBER","tenantId":"t1","inviterUserId":"owner-1","createdAt":"2023-11-14T22:13:20.000Z"}"#, - )?; - assert_eq!(from_nest.created_at, invitation.created_at); - assert_eq!(from_nest.inviter_user_id, "owner-1"); + ); + assert!(matches!( + from_nest, + Ok(ref stored) + if stored.created_at == invitation.created_at && stored.inviter_user_id == "owner-1" + )); Ok(()) } } From c1f1b4928fa79f92ee40314a5ee2d8488e35587f Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:57:55 -0300 Subject: [PATCH 013/122] fix(redis): stop a grace pointer resurrecting a revoked token family Reuse detection deletes the family's live sessions, but a grace pointer planted by an EARLIER rotation of the same lineage can still be inside its window when the reuse fires: detection only proves the REPLAYED token's own pointer expired, which says nothing about a younger sibling's. Recovering from that pointer minted a fresh session carrying the revoked family id and handed the thief back the lineage the revocation had just killed. The rotation script now decodes the recovered record and refuses the recovery when its family index is gone, so a recovery is valid only while its lineage is alive. A legacy record with no family recovers as before. The e2e test builds the three-token lineage and fails without the guard (verified by reverting it). Also closes the two uncovered lines the merge left in the failing-revoke test double: llvm-cov is back to 100% lines and 100% functions. --- .../bymax-auth-core/src/services/session.rs | 8 +++ .../src/lua/refresh_rotate.lua | 15 +++++ crates/bymax-auth-redis/src/stores/session.rs | 5 ++ crates/bymax-auth-redis/tests/redis_stores.rs | 57 +++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 22d6393..e0c0afc 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -1161,5 +1161,13 @@ mod tests { ); assert!(store.blacklist_access("jti", 60).await.is_ok()); assert!(matches!(store.is_blacklisted("jti").await, Ok(false))); + assert!(matches!( + store.current_epoch(SessionKind::Dashboard, "u1").await, + Ok(0) + )); + assert!(matches!( + store.bump_epoch(SessionKind::Dashboard, "u1").await, + Ok(1) + )); } } diff --git a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua index e2ed9fc..952a912 100644 --- a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua +++ b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua @@ -14,6 +14,8 @@ -- 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 +-- ARGV[7] = namespace e.g. "auth", to rebuild the grace record's own family key +-- ARGV[8] = family prefix "fam" (dashboard) or "pfam" (platform) -- -- 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 @@ -48,6 +50,19 @@ if old then end local grace = redis.call('GET', KEYS[3]) if grace then + -- A grace pointer may outlive its own lineage: reuse detection revokes the family's live + -- sessions, but a pointer planted by the *previous* rotation can still be inside its (much + -- shorter) window at that moment. Recovering from it would mint a fresh session carrying the + -- revoked family id and resurrect the lineage the revocation just killed. So a recovery is + -- valid only while the family index is still alive. A legacy record with no family predates + -- the mechanism and is recovered as before. + local ok, decoded = pcall(cjson.decode, grace) + if ok and type(decoded) == 'table' and decoded.familyId and decoded.familyId ~= '' then + local fam_key = ARGV[7] .. ':' .. ARGV[8] .. ':' .. decoded.familyId + if redis.call('EXISTS', fam_key) == 0 then + return false + end + end return 'GRACE:' .. grace end -- Post-grace reuse: the consumed-family marker outlives the grace pointer, so its presence here diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index e73e235..12777ba 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -244,6 +244,11 @@ impl RedisStores { .arg(family) .arg(&rotation.old_hash) .arg(&rotation.new_hash) + // The grace branch rebuilds the family key of the record it recovered — which is not + // KEYS[5] (that one is the presented rotation's family, and an absent live token + // seeds an empty one) — so it needs the namespace and the family prefix by name. + .arg(keys.namespace()) + .arg(prefixes.fam.as_str()) .invoke_async(&mut conn) .await?; diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index b2eede5..df3dd38 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -252,6 +252,63 @@ async fn reuse_past_grace_is_detected_and_revoke_family_kills_the_lineage() { assert!(stores.revoke_family(kind, "unknown-family").await.is_ok()); } +#[tokio::test] +async fn a_grace_pointer_cannot_resurrect_a_revoked_family() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // SECURITY REGRESSION GUARD. Revoking a family deletes its live sessions, but a grace + // pointer planted by an EARLIER rotation of the same lineage can still be inside its (much + // shorter) window when the reuse fires — the reuse is only detected once the REPLAYED + // token's own pointer expired, which says nothing about a younger sibling's. Recovering + // from that pointer would mint a fresh session carrying the revoked family id, handing the + // thief back the lineage the revocation just killed. + assert!( + stores + .create_session(kind, "k1", &record("ku"), 3600) + .await + .is_ok() + ); + // k1 -> k2 -> k3: after this, `rp:k2` is live and holds a record of family "fam-ku". + assert!(matches!( + stores.rotate(kind, &rotation("k1", "k2", "ku")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!(matches!( + stores.rotate(kind, &rotation("k2", "k3", "ku")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!( + redis.ttl("auth:rp:k2").await > 0, + "the sibling pointer is live" + ); + + // The oldest token is replayed once its own grace window has closed: a reuse, and the + // family is revoked. + assert!(redis.del("auth:rp:k1").await); + assert!(matches!( + stores.rotate(kind, &rotation("k1", "kX", "ku")).await, + Ok(RotateOutcome::Reused(family)) if family == "fam-ku" + )); + assert!(stores.revoke_family(kind, "fam-ku").await.is_ok()); + + // The still-live sibling pointer must NOT recover a session: its family is gone, so the + // rotation reports the token invalid instead of handing back the revoked lineage. + assert!( + redis.ttl("auth:rp:k2").await > 0, + "the pointer itself survives" + ); + assert!(matches!( + stores.rotate(kind, &rotation("k2", "kY", "ku")).await, + Ok(RotateOutcome::Invalid) + )); + assert!(matches!(stores.find_session(kind, "kY").await, Ok(None))); + assert!(matches!(stores.list_sessions(kind, "ku").await, Ok(v) if v.is_empty())); +} + #[tokio::test] async fn token_epoch_defaults_to_zero_bumps_monotonically_and_is_keyspace_disjoint() { let Some(redis) = common::try_start().await else { From adbeec59ab16126594b204ee0b556f52e653876a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:19:14 -0300 Subject: [PATCH 014/122] refactor(redis): keep the rotation scripts free of cjson, single-shot the grace window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that converge the rotation semantics with nest-auth: - The scripts no longer decode a stored record. The grace record's family and the family owner's id are parsed by the caller instead, with a real parser. nest-auth drives its end-to-end tier against an in-memory Redis whose Lua VM has no `cjson`, so a script that decodes JSON is one the shared contract cannot be exercised against on that side. - The grace window is now single-shot: the pointer is consumed on use. It used to be served on every request inside the window, so one captured consumed token could mint a fresh session over and over for the whole window. nest-auth already consumed it; this closes the divergence in the stricter direction. - The family-alive check on a grace recovery moves from the script into the store, where it reads the same. The revocation is monotonic, so checking it next to the recovery is no weaker than checking it inside the script — the session is created after the script returns either way. --- .../src/lua/refresh_rotate.lua | 24 +++--- .../src/lua/revoke_family.lua | 25 +++---- crates/bymax-auth-redis/src/stores/session.rs | 73 +++++++++++++++++-- 3 files changed, 84 insertions(+), 38 deletions(-) diff --git a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua index 952a912..f1357bf 100644 --- a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua +++ b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua @@ -14,8 +14,11 @@ -- 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 --- ARGV[7] = namespace e.g. "auth", to rebuild the grace record's own family key --- ARGV[8] = family prefix "fam" (dashboard) or "pfam" (platform) +-- +-- The script never decodes a stored record: every JSON value it touches is handed back to the +-- caller and parsed there, by a real parser rather than Lua's `cjson`. That keeps it byte-for-byte +-- runnable on any EVAL-capable backend, including the in-memory Redis nest-auth drives its +-- end-to-end tier with. -- -- 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 @@ -50,19 +53,10 @@ if old then end local grace = redis.call('GET', KEYS[3]) if grace then - -- A grace pointer may outlive its own lineage: reuse detection revokes the family's live - -- sessions, but a pointer planted by the *previous* rotation can still be inside its (much - -- shorter) window at that moment. Recovering from it would mint a fresh session carrying the - -- revoked family id and resurrect the lineage the revocation just killed. So a recovery is - -- valid only while the family index is still alive. A legacy record with no family predates - -- the mechanism and is recovered as before. - local ok, decoded = pcall(cjson.decode, grace) - if ok and type(decoded) == 'table' and decoded.familyId and decoded.familyId ~= '' then - local fam_key = ARGV[7] .. ':' .. ARGV[8] .. ':' .. decoded.familyId - if redis.call('EXISTS', fam_key) == 0 then - return false - end - end + -- The window is single-shot: consume the pointer so one captured token cannot mint a fresh + -- session on every request for the whole window. It exists to cover the one retry where the + -- old token was consumed but the client never received the new one. + redis.call('DEL', KEYS[3]) return 'GRACE:' .. grace end -- Post-grace reuse: the consumed-family marker outlives the grace pointer, so its presence here diff --git a/crates/bymax-auth-redis/src/lua/revoke_family.lua b/crates/bymax-auth-redis/src/lua/revoke_family.lua index cc34fa5..042d51b 100644 --- a/crates/bymax-auth-redis/src/lua/revoke_family.lua +++ b/crates/bymax-auth-redis/src/lua/revoke_family.lua @@ -6,7 +6,13 @@ -- 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) +-- ARGV[4] = the owner's session-index key (already namespaced), or '' when no member record was +-- readable and there is therefore no index left to prune +-- +-- The owner is resolved by the caller rather than decoded here: every member of one family +-- belongs to the same login, so reading one record in the host language keeps this script free +-- of `cjson`. The membership is still re-read here, so a member added between the two steps is +-- revoked too. -- -- Returns the number of family members that were removed. Idempotent: an unknown or empty -- family removes nothing. @@ -15,24 +21,11 @@ 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 +local ns, rt, sd, sess_key = ARGV[1], ARGV[2], ARGV[3], ARGV[4] for _, hash in ipairs(members) do redis.call('DEL', ns .. ':' .. rt .. ':' .. hash) redis.call('DEL', ns .. ':' .. sd .. ':' .. hash) - if sess_key then + if sess_key ~= '' then -- The session index stores full key **suffixes**, not bare hashes, so the member to -- prune is `rt:{hash}` (`prt:{hash}` on the platform plane). Removing the bare hash -- would leave the revoked session listed forever, until the index itself expired. diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 12777ba..79edc48 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -244,17 +244,21 @@ impl RedisStores { .arg(family) .arg(&rotation.old_hash) .arg(&rotation.new_hash) - // The grace branch rebuilds the family key of the record it recovered — which is not - // KEYS[5] (that one is the presented rotation's family, and an absent live token - // seeds an empty one) — so it needs the namespace and the family prefix by name. - .arg(keys.namespace()) - .arg(prefixes.fam.as_str()) .invoke_async(&mut conn) .await?; match interpret_rotate(raw)? { RotateParsed::Invalid => Ok(RotateOutcome::Invalid), - RotateParsed::Grace(record) => Ok(RotateOutcome::Grace(record)), + RotateParsed::Grace(record) => { + if self + .family_is_alive(&mut conn, prefixes.fam, &record) + .await? + { + Ok(RotateOutcome::Grace(record)) + } else { + Ok(RotateOutcome::Invalid) + } + } RotateParsed::Reused(family) => Ok(RotateOutcome::Reused(family)), RotateParsed::Rotated(old_record) => { self.move_session_member(&mut conn, &prefixes, rotation, &old_record.user_id) @@ -264,9 +268,37 @@ impl RedisStores { } } + /// Whether the lineage a recovered grace record belongs to is still alive. + /// + /// A grace pointer can outlive its own lineage: reuse detection revokes the family's live + /// sessions, but a pointer planted by an *earlier* rotation of that same lineage can still be + /// inside its (much shorter) window at that moment — detection only proves the replayed + /// token's own pointer expired, which says nothing about a younger sibling's. Recovering from + /// such a pointer would mint a fresh session carrying the revoked family id and hand the thief + /// back the lineage the revocation just killed. + /// + /// A record written before families existed carries none and recovers as before. + async fn family_is_alive( + &self, + conn: &mut Connection, + fam: Prefix, + record: &SessionRecord, + ) -> Result { + if record.family_id.is_empty() { + return Ok(true); + } + let fam_key = self.keys().key(fam, &record.family_id); + let present: bool = conn.exists(&fam_key).await?; + Ok(present) + } + /// 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. + /// + /// The owner is resolved here rather than decoded inside the script: every member of one + /// family belongs to the same login, so the first readable record names it, and reading it + /// with a real parser keeps the script free of `cjson`. async fn revoke_family_inner( &self, kind: SessionKind, @@ -280,18 +312,45 @@ impl RedisStores { let keys = self.keys(); let fam_key = keys.key(prefixes.fam, family_id); let mut conn = self.connection().await?; + let members: Vec = conn.smembers(&fam_key).await?; + let owner_index = self + .resolve_family_owner_index(&mut conn, &prefixes, &members) + .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()) + .arg(&owner_index) .invoke_async::(&mut conn) .await?; Ok(()) } + /// Resolve the namespaced session-index key of the user a family belongs to, or an empty + /// string when no member record is readable — every member may have already expired, in + /// which case there is no index left to prune. + async fn resolve_family_owner_index( + &self, + conn: &mut Connection, + prefixes: &KindPrefixes, + members: &[String], + ) -> Result { + let keys = self.keys(); + for hash in members { + let raw: Option = conn.get(keys.key(prefixes.rt, hash)).await?; + let Some(raw) = raw else { continue }; + let Ok(record) = serde_json::from_str::(&raw) else { + continue; + }; + if !record.user_id.is_empty() { + return Ok(keys.key(prefixes.sess, &record.user_id)); + } + } + Ok(String::new()) + } + /// 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. /// From b06016b7e1e1e22fcfd21f496b78f459c8a6b6cc Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:33:38 -0300 Subject: [PATCH 015/122] docs(conformance): pin the family keyspace and the token epoch Mirror of the nest-auth change: the shared contract gains the six new prefixes, the bare-hash family member shape, `familyId` on the session record, the `epoch` claim, and the rotation semantics both sides implement. The catalog test now asserts every prefix in the enum rather than a subset, so adding a keyspace on one side without the other turns this side red. Spec sections 7.3.3 / 12.4 / 12.5 are brought in line with the code: the rotation flow as it now runs (single atomic script, single-shot grace, reuse detection, family revocation), the new catalog rows, and the renumbered Lua subsections. --- conformance/wire-contract.json | 60 ++++++++++++++++++++- crates/bymax-auth-redis/src/keys.rs | 54 +++++++++++++++++++ docs/technical_specification.md | 82 ++++++++++++++++++----------- 3 files changed, 163 insertions(+), 33 deletions(-) diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 4e7a0b8..c2db62f 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -43,12 +43,18 @@ ], "dashboardRefreshSession": "rt", "dashboardGracePointer": "rp", + "dashboardConsumedFamilyMarker": "cf", + "dashboardFamilyIndex": "fam", "dashboardSessionIndex": "sess", "dashboardSessionDetail": "sd", + "dashboardTokenEpoch": "ep", "platformRefreshSession": "prt", "platformGracePointer": "prp", + "platformConsumedFamilyMarker": "pcf", + "platformFamilyIndex": "pfam", "platformSessionIndex": "psess", "platformSessionDetail": "psd", + "platformTokenEpoch": "pep", "accessTokenBlacklist": "rv", "failedLoginCounter": "lf", "oneTimePassword": "otp", @@ -72,6 +78,31 @@ "platformGrace": "prp:{sha256(supersededToken)}" }, + "familyIndexMembers": { + "$comment": [ + "A refresh-token FAMILY is one login lineage: minted at login, inherited unchanged by", + "every rotation, and the unit reuse detection revokes. Unlike the session index, its", + "members are BARE hashes — a family only ever tracks live refresh sessions, so the", + "keyspace is implied and does not need to be carried in the member." + ], + "dashboardLive": "{sha256(refreshToken)}", + "platformLive": "{sha256(refreshToken)}" + }, + + "rotationSemantics": { + "$comment": [ + "What a presented refresh token can be, and what each outcome does. These are behaviours", + "rather than bytes, but they have to agree: the two backends share the markers below, so", + "one side treating a replay as recoverable while the other treats it as theft would make", + "the reaction depend on which backend the request happened to reach." + ], + "graceWindow": "single-shot — the pointer is consumed on use, so one captured token cannot mint a session repeatedly inside the window", + "graceRequiresLiveFamily": "a recovery is refused once its family index is gone, so a pointer that outlived a revocation cannot resurrect the lineage", + "reuseSignal": "a consumed token replayed after its grace window closed, detected by the consumed-family marker outliving the pointer", + "reuseReaction": "revoke the whole family (every live descendant of that login) and reject the request; the user's other logins are untouched", + "legacySessionWithoutFamily": "skips all family bookkeeping and can never trip reuse detection" + }, + "recordEncodings": { "$comment": [ "Timestamp encoding differs per record and is NOT uniform — this is the trap. The session", @@ -80,8 +111,18 @@ ], "refreshSession": { "key": "{rt|prt}:{sha256(refreshToken)}", - "fields": ["userId", "tenantId", "role", "device", "ip", "createdAt", "mfaEnabled"], + "fields": [ + "userId", + "tenantId", + "role", + "device", + "ip", + "createdAt", + "mfaEnabled", + "familyId" + ], "createdAt": "iso8601-string", + "familyId": "omitted from the record entirely when empty, never written as an empty string", "$comment": "mfaEnabled must survive rotation: the MFA gate refuses only when mfaEnabled && !mfaVerified, so losing it turns one refresh into a silent second-factor bypass." }, "sessionDetail": { @@ -114,6 +155,23 @@ "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written" }, + "accessTokenClaims": { + "$comment": [ + "Claims both sides stamp and both sides check. `epoch` is the per-user generation counter", + "behind bulk revocation: a password reset advances the stored epoch, and any token stamped", + "below it is rejected. A token that predates the claim reads as generation 0, which is", + "inert until the user's first bump. Absent from the MFA challenge token, which grants no", + "resource access on its own." + ], + "epoch": { + "claim": "epoch", + "type": "non-negative integer", + "storedUnder": "{ep|pep}:{userId}", + "absentReadsAs": 0, + "rejectWhen": "stampedEpoch < storedEpoch" + } + }, + "errorEnvelope": { "$comment": "The body shape every auth error serializes to. `details` is present and null when the thrower supplied none.", "shape": { "error": { "code": "string", "message": "string", "details": "object|null" } } diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index b333f56..d840d58 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -187,13 +187,26 @@ mod tests { for (name, prefix) in [ ("dashboardRefreshSession", Prefix::Rt), ("dashboardGracePointer", Prefix::Rp), + ("dashboardConsumedFamilyMarker", Prefix::Cf), + ("dashboardFamilyIndex", Prefix::Fam), ("dashboardSessionIndex", Prefix::Sess), ("dashboardSessionDetail", Prefix::Sd), + ("dashboardTokenEpoch", Prefix::Ep), + ("platformRefreshSession", Prefix::Prt), + ("platformGracePointer", Prefix::Prp), + ("platformConsumedFamilyMarker", Prefix::Pcf), + ("platformFamilyIndex", Prefix::Pfam), + ("platformSessionIndex", Prefix::Psess), + ("platformSessionDetail", Prefix::Psd), + ("platformTokenEpoch", Prefix::Pep), ("accessTokenBlacklist", Prefix::Rv), ("failedLoginCounter", Prefix::Lf), ("oneTimePassword", Prefix::Otp), ("passwordResetToken", Prefix::PwReset), ("passwordResetVerifiedToken", Prefix::PwVtok), + ("totpReplayMarker", Prefix::Tu), + ("oauthState", Prefix::Os), + ("invitation", Prefix::Inv), ] { assert_eq!( contract_value("redisKeyPrefixes", name), @@ -218,6 +231,47 @@ mod tests { ); } + #[test] + fn the_family_index_takes_bare_hashes_and_the_session_index_does_not() { + // Two indexes, two member shapes, and the difference is load-bearing. A family only ever + // tracks live refresh sessions, so its members are bare hashes and the revocation script + // rebuilds `rt:{hash}` from them; the session index mixes live sessions with rotation + // grace pointers, so its members must carry their own prefix. Swapping either shape + // makes one backend unable to sweep what the other wrote. + assert_eq!( + contract_value("familyIndexMembers", "dashboardLive"), + "{sha256(refreshToken)}" + ); + assert_eq!( + contract_value("familyIndexMembers", "platformLive"), + "{sha256(refreshToken)}" + ); + assert!( + contract_value("sessionIndexMembers", "dashboardLive") + .starts_with(&format!("{}:", Prefix::Rt.as_str())) + ); + } + + #[test] + fn the_rotation_semantics_are_the_ones_this_crate_implements() { + // These are behaviours rather than bytes, but the two backends share the markers behind + // them: one side treating a replay as recoverable while the other treats it as theft + // would make the reaction depend on which backend the request happened to reach. + let rotate = include_str!("lua/refresh_rotate.lua"); + assert!( + contract_value("rotationSemantics", "graceWindow").contains("single-shot"), + "the contract must declare the grace window single-shot" + ); + // The pointer is consumed on use, which is what makes it single-shot. + assert!(rotate.contains("redis.call('DEL', KEYS[3])")); + // A replay past the window is reported as a reuse carrying its family. + assert!(rotate.contains("'REUSED:'")); + assert!( + contract_value("rotationSemantics", "reuseReaction") + .contains("revoke the whole family") + ); + } + #[test] fn to_hex_encodes_lower_case_two_chars_per_byte() { // The digest-to-suffix encoder must be lower-case, two chars per byte, and handle the diff --git a/docs/technical_specification.md b/docs/technical_specification.md index d205209..4f4c4cf 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -1734,7 +1734,7 @@ Purpose: revoke the current access token (JTI blacklist) and delete the refresh Algorithm: 1. Decode the access token **without** verifying (it may already be expired at logout). On decode failure, skip the blacklist step. 2. Compute `remaining_ttl = exp - now`. If `> 0`, `session_store.blacklist_access(&jti, remaining_ttl)` (the store owns the `rv:` prefix; §12.3). -3. Revoke the refresh session: `session_hash = sha256(raw_refresh)`; `sessions.revoke_session(SessionKind::Dashboard, user_id, &session_hash)` removes the `rt:` record together with its `sess:` membership and `sd:` detail in one ownership-checked domain call (§12.3 / §12.5.2). Idempotent: `SessionNotFound` is ignored (the session may already have been rotated or evicted); any other error is logged. +3. Revoke the refresh session: `session_hash = sha256(raw_refresh)`; `sessions.revoke_session(SessionKind::Dashboard, user_id, &session_hash)` removes the `rt:` record together with its `sess:` membership and `sd:` detail in one ownership-checked domain call (§12.3 / §12.5.3). Idempotent: `SessionNotFound` is ignored (the session may already have been rotated or evicted); any other error is logged. 4. Spawn `after_logout(user_id, empty_ctx)` fire-and-forget. Security: the blacklist closes the window where a stolen, still-valid access token could be replayed after logout; refresh deletion is unconditional. @@ -1953,25 +1953,26 @@ struct RefreshSession { Platform variant uses `prt:` and stores `tenant_id = ""`. -#### 7.3.3 `reissue_tokens` (atomic rotation + grace window) +#### 7.3.3 `reissue_tokens` (atomic rotation, grace window, reuse detection) -Rotation is atomic at the read-and-delete step via Lua so a refresh token can never be double-spent into two live primary sessions. - -`ROTATE_LUA` (KEYS[1] = `rt:{old_hash}`): `GET` the old session; if absent return nil; else `DEL` it and return the old JSON. The new session is written **by Rust** afterwards using the parsed real user data, so no hollow placeholder is ever stored. +Rotation runs as one Lua script (§12.5.1) so a refresh token can never be double-spent into two live sessions, and so the reuse bookkeeping cannot be lost to a crash between the two writes. Algorithm: -1. `old_hash = sha256(old_refresh)`; pre-generate `new_raw = Uuid::new_v4()`, `new_hash = sha256(new_raw)`. -2. `refresh_ttl = days*86400`; `grace_ttl = config.jwt.refresh_grace_window_seconds` (default 30). -3. `old_json = session_store.rotate_take_old("rt:{old_hash}")` (runs `ROTATE_LUA`). -4. **Primary path** (`Some(json)`): parse → `RefreshSession`; build new session (carry `mfa_enabled`); `set_session("rt:{new_hash}", json, refresh_ttl)`; write the grace pointer holding the **new session JSON** — never the raw refresh token — `set_session("rp:{old_hash}", new_json, grace_ttl)` (the `rp:`/`prp:` value is the `SessionRecord`, so a Redis compromise never exposes a live token; see §12.4 / §12.5.1); update the per-user set: `srem` old `rt:`, `sadd` new `rt:` and the `rp:{old_hash}` grace key, `expire` the set. Build `RotatedTokens`. -5. **Grace path** (`None`): `getdel("rp:{old_hash}")`. If `Some`, this is a concurrent retry whose primary was already consumed — parse the pointed-to session JSON and **mint a fresh token** for that identity, but **do not** write another grace pointer (single-shot: chaining grace pointers would let a captured token keep a session alive indefinitely). `sadd` the new `rt:` to the set; `expire`. Build `RotatedTokens`. -6. If both are `None`, return `RefreshTokenInvalid`. -`parse_session` rejects malformed JSON or missing `user_id`/`role` with `RefreshTokenInvalid`; absent `mfa_enabled` defaults to `false`. +1. Reject anything that is not a well-formed refresh token before hashing it — a malformed value could never match a stored hash, and this caps the work an arbitrary input can force. +2. `old_hash = sha256(old_refresh)`; mint `new_raw` (32 CSPRNG bytes, hex) and `new_hash = sha256(new_raw)`. +3. Read the presented session (`find_session`) to seed the rotated record. This is where the **family** comes from: the script plants the consumed marker and moves the family membership in the same step that consumes the token, so it needs the family up front. The read is non-destructive and cannot double-spend — the script re-reads and deletes the key itself. When the live key is already gone the seed is a placeholder the rotation never stores. +4. Run `refresh_rotate` with the five keys and six arguments of §12.5.1, and match on the outcome: + - **`Rotated(old_record)`** — the script already wrote the new session, the grace pointer, and the family bookkeeping. Rust does the non-atomic index work: `SREM rt:{old_hash}`, `SADD rt:{new_hash}`, `SADD rp:{old_hash}` (so `revoke_all` can sweep the pointer), `DEL sd:{old_hash}`, `SET sd:{new_hash}`. + - **`Grace(record)`** — a concurrent retry whose primary was already consumed. The pointer was consumed by the script (single-shot), and the recovery is refused outright if the record's family index is gone. Otherwise mint a **fresh** token for the recovered identity, add it to the family index, and plant **no** new pointer: chaining pointers would let a captured token keep a session alive indefinitely. + - **`Reused(family)`** — a consumed token replayed after its window closed, i.e. a theft signal. Run `revoke_family` (§12.5.2) and return `RefreshTokenInvalid`. The user's other logins are separate families and are untouched. + - **`Invalid`** — never issued, or fully aged out. Return `RefreshTokenInvalid`; nothing is revoked. + +`parse_session` rejects malformed JSON or missing `user_id`/`role` with `RefreshTokenInvalid`; absent `mfa_enabled` defaults to `false` and an absent `family_id` to empty, which is the legacy record written before families existed — it skips all family bookkeeping and can never trip reuse detection. -`build_rotated_result`: issues an access token with `status = ""` (the Redis session does not carry full user data — status guards must consult the repo/status cache, not the rotated JWT) and `mfa_verified = false` (rotation always drops verification; the user re-acquires it only via the MFA challenge), while `mfa_enabled` is carried from the session so the MFA guard keeps enforcing. +`build_rotated_result`: issues an access token with `status = ""` (the Redis session does not carry full user data — status guards must consult the repo/status cache, not the rotated JWT) and `mfa_verified = false` (rotation always drops verification; the user re-acquires it only via the MFA challenge), while `mfa_enabled` is carried from the session so the MFA guard keeps enforcing. The `epoch` claim is re-read at rotation time, so a password reset that lands mid-session is picked up by the very next rotation. -Platform rotation (`reissue_platform_tokens`) mirrors this with `prt:`/`prp:` prefixes and `tenant_id = ""`. +Platform rotation (`reissue_platform_tokens`) mirrors this with `prt:`/`prp:`/`pcf:`/`pfam:` prefixes and `tenant_id = ""`. #### 7.3.4 Access-token revocation blacklist (JTI) @@ -2060,7 +2061,7 @@ Constants: `MAX_IP_LENGTH: usize = 45` (IPv6 max — IP is truncated before stor 5. `evict_count = len - limit`; choose the oldest `evict_count`, **excluding** `new_hash`. 6. For each victim (best-effort, errors logged not propagated — the new session is already committed): `del("rt:{hash}")`, `srem("sess:{user_id}", "rt:{hash}")`, `del("sd:{hash}")`; spawn `on_session_evicted(user_id, hash, ctx)` fire-and-forget. -> **Concurrency caveat & the `= 1` case.** The `SMEMBERS → read created_at → DEL` sequence is **best-effort, not atomic**: N simultaneous logins can transiently overshoot the cap by up to **N−1** sessions before eviction settles. This is acceptable for a *soft* cap (`default_max_sessions >= 2`), which is the documented behavior. For a **hard** cap — in particular `default_max_sessions = 1`, where any overshoot means a second concurrent session briefly coexists — best-effort FIFO is insufficient: enforcement MUST instead run as a single atomic `enforce_session_limit` Lua (`SMEMBERS` + conditional `DEL` of the over-limit members in one script, mirroring §12.5.2's ownership-checked pattern) so the live count can never exceed the limit even under a race. `rotate_session` already uses fully atomic Lua; `create_session`'s limit step is the one to harden when a strict cap is required. +> **Concurrency caveat & the `= 1` case.** The `SMEMBERS → read created_at → DEL` sequence is **best-effort, not atomic**: N simultaneous logins can transiently overshoot the cap by up to **N−1** sessions before eviction settles. This is acceptable for a *soft* cap (`default_max_sessions >= 2`), which is the documented behavior. For a **hard** cap — in particular `default_max_sessions = 1`, where any overshoot means a second concurrent session briefly coexists — best-effort FIFO is insufficient: enforcement MUST instead run as a single atomic `enforce_session_limit` Lua (`SMEMBERS` + conditional `DEL` of the over-limit members in one script, mirroring §12.5.3's ownership-checked pattern) so the live count can never exceed the limit even under a race. `rotate_session` already uses fully atomic Lua; `create_session`'s limit step is the one to harden when a strict cap is required. #### 7.4.3 `list_sessions` @@ -4231,7 +4232,7 @@ pub trait SessionStore: Send + Sync { async fn find_session(&self, kind: SessionKind, token_hash: &str) -> Result, AuthError>; /// List all live sessions for a user (SMEMBERS + MGET of detail keys). async fn list_sessions(&self, kind: SessionKind, user_id: &str) -> Result, AuthError>; - /// Ownership-checked single revoke (Lua `session_revoke`, §12.5.2) → SESSION_NOT_FOUND if not owned. + /// Ownership-checked single revoke (Lua `session_revoke`, §12.5.3) → SESSION_NOT_FOUND if not owned. async fn revoke_session(&self, kind: SessionKind, user_id: &str, session_hash: &str) -> Result<(), AuthError>; /// Revoke every session for a user in one Lua transaction (`invalidate_user_sessions`). async fn revoke_all(&self, kind: SessionKind, user_id: &str) -> Result<(), AuthError>; @@ -4245,7 +4246,7 @@ pub trait SessionStore: Send + Sync { #[async_trait::async_trait] pub trait OtpStore: Send + Sync { async fn put(&self, purpose: OtpPurpose, identifier: &str, code: &str, ttl_secs: u64) -> Result<(), AuthError>; - /// Atomic verify+attempts+consume (Lua `otp_verify`, §12.5.4). + /// Atomic verify+attempts+consume (Lua `otp_verify`, §12.5.5). async fn verify(&self, purpose: OtpPurpose, identifier: &str, code: &str, max_attempts: u32) -> Result<(), AuthError>; /// SET NX EX cooldown gate; false if a resend happened within the window. async fn try_begin_resend(&self, purpose: OtpPurpose, identifier: &str, cooldown_secs: u64) -> Result; @@ -4255,7 +4256,7 @@ pub trait OtpStore: Send + Sync { #[async_trait::async_trait] pub trait BruteForceStore: Send + Sync { async fn is_locked(&self, identifier: &str, max_attempts: u32) -> Result; - /// Atomic INCR + EXPIRE-on-first (Lua `brute_force_incr`, §12.5.3). + /// Atomic INCR + EXPIRE-on-first (Lua `brute_force_incr`, §12.5.4). async fn record_failure(&self, identifier: &str, window_secs: u64) -> Result; async fn reset(&self, identifier: &str) -> Result<(), AuthError>; async fn remaining_lockout_secs(&self, identifier: &str) -> Result; @@ -4270,10 +4271,14 @@ All keys are `{namespace}:{prefix}:{identifier}` (default namespace `auth`). Eve | Prefix | Key pattern | Value | TTL | Purpose | | --- | --- | --- | --- | --- | -| `rt` | `auth:rt:{sha256(refresh_token)}` | JSON `SessionRecord` `{ userId, tenantId, role, device, ip, createdAt }` | `refresh_expires_in_days` × 86400 s | Dashboard refresh-token session. Holds everything needed to reissue an access token without a DB hit. | +| `rt` | `auth:rt:{sha256(refresh_token)}` | JSON `SessionRecord` `{ userId, tenantId, role, device, ip, createdAt, mfaEnabled, familyId }` (`familyId` omitted when empty) | `refresh_expires_in_days` × 86400 s | Dashboard refresh-token session. Holds everything needed to reissue an access token without a DB hit. | | `rv` | `auth:rv:{jti}` (preferred) or `auth:rv:{sha256(jwt)}` | `"1"` | Remaining JWT lifetime computed from `exp − now` | Access-JWT revocation blacklist. Written on logout; consulted by `JwtAuthGuard`. `jti` keying avoids hashing the whole compact JWT. | +| `ep` | `auth:ep:{userId}` | Numeric generation counter (string) | 30 days | Per-user token **epoch**. Every access token is stamped with the epoch current at issuance; verification rejects a token stamped below the stored value. A password reset advances it, invalidating every outstanding access token in one write — the stateless counterpart to `rv:`, which can only revoke tokens one `jti` at a time. The TTL far exceeds any access-token lifetime, so a bump stays in force for every pre-bump token's remaining life. | +| `pep` | `auth:pep:{userId}` | Numeric generation counter (string) | 30 days | Platform per-admin token epoch. Analogue of `ep`; separate because the two planes' ids come from different repositories and may collide. | | `us` | `auth:us:{userId}` | Status string (`"ACTIVE"`, `"BANNED"`, …) | `user_status_cache_ttl_seconds` (default 60 s) | User-status cache. Avoids a DB read per request; invalidated on `update_status`. | | `rp` | `auth:rp:{sha256(old_refresh_token)}` | JSON `SessionRecord` — the **new** session, never the raw token | `refresh_grace_window_seconds` (default 30 s) | Rotation grace pointer. Lets a legitimately-concurrent request still carrying the old token recover the rotated identity and be issued a fresh token, instead of being logged out. Stores the session record (not the raw refresh token), so a Redis snapshot never leaks a live credential. | +| `cf` | `auth:cf:{sha256(consumed_refresh_token)}` | `familyId` of the lineage the consumed token belonged to | `refresh_expires_in_days` × 86400 s | Consumed-token family marker. Planted in the same atomic step that consumes a token, and deliberately outliving the much shorter grace pointer: once the pointer is gone, the marker's presence is what proves a presented token was legitimately issued and already rotated — a **reuse**, not a random string (§12.5.1). | +| `fam` | `auth:fam:{familyId}` | Redis SET of **bare** `sha256(refresh_token)` hashes | `refresh_expires_in_days` × 86400 s | Dashboard refresh-token family index: one login lineage, minted at login and inherited by every rotation. Members are bare hashes, unlike `sess:` — a family only ever tracks live `rt:` sessions, so the keyspace is implied. Deleting this key revokes the lineage and, as a side effect, refuses any grace recovery that still names it. | | `lf` | `auth:lf:{hmac_sha256(tenantId + ":" + email)}` | Numeric counter (string) | `window_seconds` (default 900 s) | Per-tenant failed-login counter. Fixed window — TTL set only on the 0→1 transition. Tenant scoping prevents cross-tenant lockout. | | `pw_reset` | `auth:pw_reset:{sha256(reset_token)}` | `userId` | `password_reset.token_ttl_seconds` (default 3600 s) | Password-reset token (`method = "token"`). Consumed on use. | | `otp` | `auth:otp:{purpose}:{hmac_sha256(tenantId + ":" + email)}` | JSON `{ code: string, attempts: number }` | `otp_ttl_seconds` (per purpose) | OTP record. `attempts` tracks failures (max 5). Purposes: `password_reset`, `email_verification`. Tenant-scoped to prevent collision. | @@ -4284,8 +4289,10 @@ All keys are `{namespace}:{prefix}:{identifier}` (default namespace `auth`). Eve | `os` | `auth:os:{sha256(state)}` | JSON `{ tenantId, codeVerifier }` | 600 s (10 min) | OAuth CSRF `state` + PKCE `code_verifier`. Single-use; deleted on callback. | | `wst` | `auth:wst:{sha256(ws_ticket)}` | JSON `WsTicketSnapshot` `{ sub, tenantId, role, status, mfaEnabled, mfaVerified }` | `WS_TICKET_TTL_SECONDS` (30 s) | **rust-auth-only, feature `websocket`.** Single-use WebSocket upgrade ticket (§7.3.6 / §8.7). Minted from an already-authorized, MFA-satisfied session and redeemed once (`GETDEL`) at the WS handshake, so an access JWT never appears in a URL. The value is a verified-claims **snapshot**, never a token. This prefix is outside the nest-auth parity surface (nest-auth authenticates WebSockets via the `Authorization` header, not a ticket), so it is purely additive: a `nest-auth` server never reads or writes `wst:`, and cross-backend Redis sharing is unaffected. | | `tu` | `auth:tu:{hmac_sha256(userId + ":" + code)}` | `"1"` | 90 s (≈ 3 × TOTP window) | TOTP anti-replay. The key is the **HMAC of `userId:code`**, never the raw 6-digit code (which is low-entropy and would be reversible as a bare key) — matching §7.5.6. A code that just verified is marked so it cannot be replayed inside its drift window. | -| `prt` | `auth:prt:{sha256(refresh_token)}` | JSON `{ userId, role, device, ip, createdAt }` | `refresh_expires_in_days` × 86400 s | Platform-admin refresh session. Platform analogue of `rt`. | +| `prt` | `auth:prt:{sha256(refresh_token)}` | JSON `{ userId, role, device, ip, createdAt, mfaEnabled, familyId }` (`familyId` omitted when empty) | `refresh_expires_in_days` × 86400 s | Platform-admin refresh session. Platform analogue of `rt`. | | `prp` | `auth:prp:{sha256(old_refresh_token)}` | JSON `SessionRecord` — the new session, never the raw token | `refresh_grace_window_seconds` (default 30 s) | Platform rotation grace pointer. Analogue of `rp`. | +| `pcf` | `auth:pcf:{sha256(consumed_refresh_token)}` | `familyId` | `refresh_expires_in_days` × 86400 s | Platform consumed-token family marker. Analogue of `cf`. | +| `pfam` | `auth:pfam:{familyId}` | Redis SET of bare `sha256(refresh_token)` hashes | `refresh_expires_in_days` × 86400 s | Platform refresh-token family index. Analogue of `fam`. | | `pw_vtok` | `auth:pw_vtok:{sha256(verified_token)}` | JSON `{ email, tenantId }` | 300 s (5 min) | Password-reset OTP "verified" token (2-step OTP flow). Bridges verify-OTP → reset-password, closing the verify/reset race. Consumed on reset. | | `mfa_setup` | `auth:mfa_setup:{hmac_sha256(userId)}` | JSON `{ encryptedSecret, hashedCodes: string[], encryptedPlainCodes }` | 600 s (10 min) | MFA pending-setup data: AES-256-GCM-encrypted TOTP secret + HMAC-SHA-256-keyed recovery-code hashes + the AES-256-GCM-encrypted plaintext codes (so the idempotent `setup()` fast-path can re-return them), held between `setup()` and `verify_enable()`. Consumed on enable. The low-entropy `userId` is keyed via HMAC-SHA-256 (§12.2), matching §7.5.1. | | `psess` | `auth:psess:{userId}` | Redis SET of full key **suffixes**: `prt:{sha256(refresh_token)}` and `prp:{sha256(old_refresh_token)}` | max refresh TTL | Platform active-session index. Analogue of `sess`; the platform keyspace is deliberately separate. | @@ -4298,19 +4305,30 @@ Values that are JSON are (de)serialized with `serde` + `serde_json`; the DTOs (` Each multi-step state transition that could race under concurrency runs as a single Lua script via `EVALSHA` (with transparent `EVAL` fallback on `NOSCRIPT`). Scripts are compiled once into `redis::Script` (or the `fred` equivalent) and cached. `KEYS` arrive already namespaced; scripts that must rebuild a member key receive the namespace in `ARGV`. -#### 12.5.1 `refresh_rotate` — refresh rotation with grace window +#### 12.5.1 `refresh_rotate` — rotation with a grace window and reuse detection -Prevents the classic double-rotation race: two concurrent requests carrying the same refresh token must not both succeed and mint two live sessions. The check-delete-create sequence is atomic, and the grace pointer lets a legitimately-concurrent second request recover the already-minted token instead of being logged out. +Prevents the classic double-rotation race — two concurrent requests carrying the same refresh token must not both mint a live session — and catches the replay of a token that was already consumed, which is the signature of a stolen refresh token (RFC 6819 / OWASP rotation with automatic reuse detection). -- **KEYS** — `[1]` `rt:{sha256(old)}`, `[2]` `rt:{sha256(new)}`, `[3]` `rp:{sha256(old)}` (platform variant uses `prt`/`prp`). -- **ARGV** — `[1]` new session JSON (`SessionRecord`), `[2]` refresh TTL (s), `[3]` grace TTL (s). The new **raw** token is generated in Rust and is **never** passed to the script or written to Redis. +- **KEYS** — `[1]` `rt:{sha256(old)}`, `[2]` `rt:{sha256(new)}`, `[3]` `rp:{sha256(old)}`, `[4]` `cf:{sha256(old)}`, `[5]` `fam:{familyId}` (platform variant uses `prt`/`prp`/`pcf`/`pfam`). +- **ARGV** — `[1]` new session JSON (`SessionRecord`), `[2]` refresh TTL (s), `[3]` grace TTL (s; `0` skips the pointer), `[4]` the presented session's `familyId` (`''` = legacy record, skip all family work), `[5]` `sha256(old)`, `[6]` `sha256(new)`. The new **raw** token is generated in Rust and is **never** passed to the script or written to Redis. - **Contract:** - 1. `GET KEYS[1]`. If present → `DEL KEYS[1]`; `SET KEYS[3] = ARGV[1] EX ARGV[3]` (plant the grace pointer holding the **new session JSON**, never the raw token); `SET KEYS[2] = ARGV[1] EX ARGV[2]` (new session); **return the old session JSON** (caller derives `userId`, updates the `sess:` SET + `sd:` detail). - 2. Else `GET KEYS[3]` (grace pointer). If present → **return `"GRACE:" .. session_json`**; the caller parses the pointed-to `SessionRecord` and mints a **fresh** token bound to that identity (it does *not* plant another grace pointer), so a benign concurrent retry succeeds without a logout. - 3. Else → **return `nil`** ⇒ caller raises `AuthError::RefreshTokenInvalid`. -- **Rust mapping:** `RotateOutcome::{ Rotated(SessionRecord), Grace(SessionRecord), Invalid }`. The `SessionStore::rotate` impl pattern-matches and performs the non-atomic SET bookkeeping outside the script — `SREM rt:{old_hash}`, `SADD rt:{new_hash}`, `SADD rp:{old_hash}` (the grace pointer is indexed too, so `revoke_all` can sweep it), `DEL sd:{old_hash}`, `SET sd:{new_hash}`; on `Grace` it mints a fresh token for the recovered `SessionRecord`. Storing the session record — not the raw token — keeps the "no raw secret in Redis" invariant intact for the grace pointer too. + 1. `GET KEYS[1]`. If present → `SET KEYS[2] = ARGV[1] EX ARGV[2]` (new session); if the grace TTL is non-zero, `SET KEYS[3] = ARGV[1] EX ARGV[3]` (the pointer holds the **new session JSON**, never the raw token); if the family is non-empty, `SET KEYS[4] = ARGV[4] EX ARGV[2]` (consumed marker) and move the family membership `SREM KEYS[5] ARGV[5]` / `SADD KEYS[5] ARGV[6]` / `EXPIRE KEYS[5] ARGV[2]`; then `DEL KEYS[1]` and **return the old session JSON**. Every write happens **before** the old key is deleted: Redis does not roll back a script's earlier writes, so a failing write aborts with the old token still intact rather than consuming it without a successor or a reuse marker. + 2. Else `GET KEYS[3]` (grace pointer). If present → `DEL KEYS[3]` and **return `"GRACE:" .. session_json`**. The window is **single-shot**: consuming the pointer stops one captured token minting a session on every request inside the window. The caller mints a fresh token bound to the recovered identity and plants no new pointer. + 3. Else `GET KEYS[4]` (consumed-family marker). If present → **return `"REUSED:" .. familyId`**. The marker outlives the much shorter pointer, so reaching this branch proves the token was legitimately issued and already rotated. + 4. Else → **return `nil`** ⇒ caller raises `AuthError::RefreshTokenInvalid`. +- **Rust mapping:** `RotateOutcome::{ Rotated(SessionRecord), Grace(SessionRecord), Reused(String), Invalid }`. The store performs the non-atomic bookkeeping outside the script — `SREM rt:{old_hash}`, `SADD rt:{new_hash}`, `SADD rp:{old_hash}` (the pointer is indexed too, so `revoke_all` sweeps it), `DEL sd:{old_hash}`, `SET sd:{new_hash}` — and on `Grace` first checks that the recovered record's family index still **exists**, refusing the recovery when it does not: a pointer planted by an earlier rotation of the same lineage can still be live when a reuse revokes the family, and recovering from it would resurrect the lineage the revocation just killed. On `Reused` the caller runs `revoke_family` and rejects the request. +- **No `cjson`:** the script never decodes a stored record. Both the grace record's family and the family owner's id are parsed by the caller with a real parser, which also keeps the script runnable on the in-memory Redis nest-auth drives its end-to-end tier with. + +#### 12.5.2 `revoke_family` — reuse-detection lockout of one lineage + +Called when `refresh_rotate` reports a reuse. Kills every live descendant of the compromised login in one transaction, forcing each holder to re-authenticate — and deliberately **nothing else**: the user's other logins are separate families and survive, which is the OWASP-recommended scope. Revoking account-wide would let anyone holding one stolen token log the victim out of every device at will. + +- **KEYS** — `[1]` `fam:{familyId}` (or `pfam:{familyId}`), already namespaced. +- **ARGV** — `[1]` namespace, `[2]` live prefix (`rt`/`prt`), `[3]` detail prefix (`sd`/`psd`), `[4]` the owner's session-index key, already namespaced, or `''` when no member record was readable. +- **Contract:** `SMEMBERS KEYS[1]`; if empty → `DEL KEYS[1]`, return `0`. Otherwise, for each member hash: `DEL {ns}:{rt}:{hash}`, `DEL {ns}:{sd}:{hash}`, and — when an owner index was supplied — `SREM {ownerIndex} {rt}:{hash}` (the index stores full key **suffixes**, so pruning the bare hash would leave the revoked session listed until the index expired). Finally `DEL KEYS[1]`; return the member count. +- **Owner resolution:** every member of one family descends from the same login, so the caller reads the first readable member record and passes the owner's index key in. That keeps the script free of `cjson`; the membership is still re-read inside the script, so a member added between the two steps is revoked too. -#### 12.5.2 `session_revoke` — ownership-checked single revoke +#### 12.5.3 `session_revoke` — ownership-checked single revoke Closes an IDOR/BOLA hole: a user must not revoke a session hash they do not own. @@ -4321,7 +4339,7 @@ Closes an IDOR/BOLA hole: a user must not revoke a session hash they do not own. 2. Else `SREM KEYS[1] ARGV[1]`; `DEL KEYS[2]`; `DEL KEYS[3]` → **return `1`**. - **Rust mapping:** `bool`; `false` → `SessionNotFound`. The membership test and the deletes are one atomic unit, so a session cannot be half-revoked. (KEYS layout matches §7.4.4.) -#### 12.5.3 `brute_force_incr` — fixed-window failure counter +#### 12.5.4 `brute_force_incr` — fixed-window failure counter Guarantees the lockout window starts at the **first** failure and never slides forward, defeating the "one attempt just before expiry" evasion. @@ -4330,7 +4348,7 @@ Guarantees the lockout window starts at the **first** failure and never slides f - **Contract:** `INCR KEYS[1]`; **if result == 1 then `EXPIRE KEYS[1] ARGV[1]`**; return the counter. TTL is set only on the 0→1 transition; subsequent failures never extend it. - **Rust mapping:** `i64` (new counter). `BruteForceStore::is_locked` compares against `max_attempts`; `record_failure` returns the counter so the caller can attach a `Retry-After` derived from `remaining_lockout_secs` when the threshold is crossed. -#### 12.5.4 `otp_verify` — verify + attempts + consume +#### 12.5.5 `otp_verify` — verify + attempts + consume Makes "compare code, bump attempts, consume on success, lock on max" a single atomic step so concurrent guesses cannot race past the attempt ceiling. @@ -4780,7 +4798,7 @@ All codes, grouped by domain, with HTTP status, trigger, and client-facing Engli | `auth.refresh_token_invalid` | 401 | Refresh token absent from Redis (`rt:`/`prt:`) and outside the grace window. | Invalid or expired refresh token | | `auth.session_expired` | 401 | Session backing a refresh token no longer exists. | Session expired | | `auth.session_limit_reached` | 409 | Concurrent-session cap hit (informational; FIFO eviction normally handles this silently). | Session limit reached | -| `auth.session_not_found` | 404 | Revoke targeted a session hash not owned by the caller (ownership-checked Lua, §12.5.2) — anti-IDOR. | Session not found | +| `auth.session_not_found` | 404 | Revoke targeted a session hash not owned by the caller (ownership-checked Lua, §12.5.3) — anti-IDOR. | Session not found | #### Registration & email From 45a64ec594640fb7e3a4d0b888bb1dd2315d98d0 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:49:41 -0300 Subject: [PATCH 016/122] test(npm): cover the Next route handlers and gate the layer's coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three route handlers that bridge the browser's cookie session to the backend had no tests at all — 0% of `handlers.ts`, in the one module of this package that writes `Set-Cookie` back to a browser. Nine tests pin the contract that matters there: the rotated cookies are relayed verbatim, a failed refresh clears the session rather than leaving the browser holding cookies the backend no longer honors, a backend that is down fails the same way as a rejected refresh, and an attacker-supplied `redirectTo` cannot become the `Location`. The package now runs under a coverage ratchet — thresholds pinned just under what the suite reaches (86/78/90/88), so this layer can only go up. It is deliberately not 100%: the Rust crates are, this layer is the laggard, and a threshold that lies about where it is would be worse than one that admits it. CI runs `test:cov` instead of `test`. Coverage on the package: 68.7% -> 86.06% statements, and `handlers.ts` from 0% to 100% lines. --- .github/workflows/ci.yml | 7 +- packages/rust-auth/package.json | 3 +- packages/rust-auth/tests/handlers.test.ts | 187 ++++++++++++++++++++++ packages/rust-auth/vitest.config.ts | 32 +++- 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 packages/rust-auth/tests/handlers.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f2c86c..a88d3cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -362,9 +362,12 @@ jobs: - name: Validate the TypeDoc API surface renders working-directory: packages/rust-auth run: npx typedoc --emit none - - name: Run the Vitest suite + - name: Run the Vitest suite under the coverage ratchet working-directory: packages/rust-auth - run: npm test + # Thresholds live in vitest.config.ts and are pinned just under what the suite + # currently reaches, so this layer's coverage can only go up. The Rust crates are + # gated at 100% separately by the reusable workflow above. + run: npm run test:cov # ───────────────────────────────────────────────────────────────────────────── # Examples — the official examples form their OWN Cargo workspace (and their own diff --git a/packages/rust-auth/package.json b/packages/rust-auth/package.json index 2e90991..47fd5b8 100644 --- a/packages/rust-auth/package.json +++ b/packages/rust-auth/package.json @@ -2,7 +2,7 @@ "name": "@bymax-one/rust-auth", "version": "0.0.0", "type": "module", - "description": "Frontend (React/Next.js) auth + edge JWT for the rust-auth backend — the byte-for-byte npm counterpart of @bymax-one/nest-auth.", + "description": "Frontend (React/Next.js) auth + edge JWT for the rust-auth backend \u2014 the byte-for-byte npm counterpart of @bymax-one/nest-auth.", "license": "MIT", "repository": { "type": "git", @@ -49,6 +49,7 @@ "lint": "eslint src tests --max-warnings 0", "docs": "typedoc", "test": "vitest run", + "test:cov": "vitest run --coverage", "gen:format": "prettier --write \"src/shared/**/*.ts\"", "gen:check": "cargo test -p bymax-auth-types --features ts-export --locked && git diff --exit-code -- src/shared" }, diff --git a/packages/rust-auth/tests/handlers.test.ts b/packages/rust-auth/tests/handlers.test.ts new file mode 100644 index 0000000..2409467 --- /dev/null +++ b/packages/rust-auth/tests/handlers.test.ts @@ -0,0 +1,187 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + AUTH_ACCESS_COOKIE_NAME, + AUTH_HAS_SESSION_COOKIE_NAME, + AUTH_REFRESH_COOKIE_NAME, +} from "../src/shared/cookie-defaults"; +import { + createClientRefreshHandler, + createLogoutHandler, + createSilentRefreshHandler, +} from "../src/nextjs/handlers"; + +/** + * The three same-origin route handlers that bridge the browser's cookie session to the + * backend. They are the only place in the package that writes `Set-Cookie` on the way back to + * the browser, so their contract is narrow and worth pinning exactly: forward the request + * cookies, relay the rotated cookies verbatim (deduplicated), and never send the browser + * anywhere it did not ask to go. + */ + +const BACKEND = "https://api.example.com"; + +/** A request carrying a session cookie, optionally with a `redirectTo` query. */ +function requestWith(query = ""): NextRequest { + return new NextRequest(`https://app.example.com/auth/silent-refresh${query}`, { + headers: { cookie: `${AUTH_REFRESH_COOKIE_NAME}=r_1` }, + }); +} + +/** A backend response carrying rotated cookies. */ +function backendOk(cookies: string[], body = '{"accessToken":"a_1"}'): Response { + const headers = new Headers({ "content-type": "application/json" }); + for (const cookie of cookies) headers.append("set-cookie", cookie); + return new Response(body, { status: 200, headers }); +} + +/** Every `Set-Cookie` value on a response, in order. */ +function setCookies(response: { headers: Headers }): string[] { + const getter = response.headers as Headers & { getSetCookie?: () => string[] }; + return getter.getSetCookie?.() ?? []; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("createSilentRefreshHandler", () => { + // The happy path: the rotated cookies must reach the browser, or the refresh silently + // succeeded on the backend while the browser kept the token it just rotated away. + it("relays the rotated cookies and redirects to the requested destination", async () => { + const fetchMock = vi.fn().mockResolvedValue(backendOk(["at=a_1; Path=/", "rt=r_2; Path=/auth"])); + vi.stubGlobal("fetch", fetchMock); + + const response = await createSilentRefreshHandler({ backendUrl: BACKEND })( + requestWith("?redirectTo=/dashboard"), + ); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://app.example.com/dashboard"); + expect(setCookies(response)).toEqual(["at=a_1; Path=/", "rt=r_2; Path=/auth"]); + // The request's own cookies are what authorize the refresh, so they must be forwarded. + expect(fetchMock).toHaveBeenCalledWith( + `${BACKEND}/auth/refresh`, + expect.objectContaining({ method: "POST", headers: { cookie: `${AUTH_REFRESH_COOKIE_NAME}=r_1` } }), + ); + }); + + // Open-redirect guard. `redirectTo` is attacker-controllable, so an absolute off-origin URL + // must not become the `Location` — otherwise the auth flow itself is the redirector. + it("refuses an off-origin destination and falls back to the sign-in path", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(backendOk([]))); + + const response = await createSilentRefreshHandler({ backendUrl: BACKEND })( + requestWith("?redirectTo=https://evil.example.com/steal"), + ); + + expect(response.headers.get("location")).toBe("https://app.example.com/login"); + }); + + // A failed refresh must not leave the browser holding cookies the backend no longer honors: + // the session is cleared and the user is sent to sign in with the reason attached. + it("clears the session cookies and redirects to sign-in when the backend rejects", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 }))); + + const response = await createSilentRefreshHandler({ backendUrl: BACKEND, loginPath: "/entrar" })( + requestWith(), + ); + + expect(response.headers.get("location")).toBe("https://app.example.com/entrar?reason=expired"); + const cleared = setCookies(response).join(" "); + for (const name of [ + AUTH_ACCESS_COOKIE_NAME, + AUTH_HAS_SESSION_COOKIE_NAME, + AUTH_REFRESH_COOKIE_NAME, + ]) { + expect(cleared).toContain(`${name}=`); + } + expect(cleared).toContain("Max-Age=0"); + }); + + // A backend that is down is not an authentication decision, but it must fail closed the same + // way — a thrown fetch cannot be allowed to surface as a 500 that leaves the session ambiguous. + it("treats a transport failure as a failed refresh", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + + const response = await createSilentRefreshHandler({ backendUrl: BACKEND })(requestWith()); + + expect(response.headers.get("location")).toBe("https://app.example.com/login?reason=expired"); + }); +}); + +describe("createClientRefreshHandler", () => { + // The fetch wrapper POSTs here on a 401 and expects the backend's body verbatim, with the + // rotated cookies applied by the browser. + it("returns the backend body and relays the rotated cookies", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(backendOk(["at=a_2; Path=/"]))); + + const response = await createClientRefreshHandler({ backendUrl: BACKEND })(requestWith()); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + await expect(response.text()).resolves.toBe('{"accessToken":"a_1"}'); + expect(setCookies(response)).toEqual(["at=a_2; Path=/"]); + }); + + // The client distinguishes "refresh failed" from every other error by this envelope, so the + // shape is a contract with the fetch wrapper, not a detail. + it("answers 401 with the session-expired envelope when the refresh fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 401 }))); + + const response = await createClientRefreshHandler({ backendUrl: BACKEND })(requestWith()); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: { code: "auth.session_expired", message: "Session expired." }, + }); + expect(setCookies(response)).toEqual([]); + }); +}); + +describe("createLogoutHandler", () => { + // Logout is best-effort against the backend but unconditional locally: whatever the backend + // answers, the browser must end up without session cookies. + it("clears the session cookies even when the backend call fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + + const response = await createLogoutHandler({ backendUrl: BACKEND })(requestWith()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + const cleared = setCookies(response).join(" "); + expect(cleared).toContain(`${AUTH_ACCESS_COOKIE_NAME}=`); + expect(cleared).toContain(`${AUTH_REFRESH_COOKIE_NAME}=`); + }); + + // The backend logout must be reached at the mounted prefix, not at the default one: a + // deployment that mounts the routes elsewhere would otherwise log nobody out server-side. + it("calls the backend logout rebased onto the configured route prefix", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await createLogoutHandler({ backendUrl: `${BACKEND}/`, routePrefix: "identity" })(requestWith()); + + expect(fetchMock).toHaveBeenCalledWith( + `${BACKEND}/identity/logout`, + expect.objectContaining({ method: "POST" }), + ); + }); + + // A request with no cookies at all still forwards a header, because the backend distinguishes + // "no cookie" from "no header" only by the value it receives. + it("forwards an empty cookie header when the request carries none", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await createLogoutHandler({ backendUrl: BACKEND })( + new NextRequest("https://app.example.com/auth/logout"), + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BACKEND}/auth/logout`, + expect.objectContaining({ headers: { cookie: "" } }), + ); + }); +}); diff --git a/packages/rust-auth/vitest.config.ts b/packages/rust-auth/vitest.config.ts index 9ee72a4..ac8fb2d 100644 --- a/packages/rust-auth/vitest.config.ts +++ b/packages/rust-auth/vitest.config.ts @@ -19,18 +19,44 @@ export default defineConfig({ alias: [ { find: "server-only", - replacement: fileURLToPath(new URL("./tests/server-only-stub.ts", import.meta.url)), + replacement: fileURLToPath( + new URL("./tests/server-only-stub.ts", import.meta.url), + ), }, { find: /^.*bymax_auth_wasm\.js$/, - replacement: fileURLToPath(new URL("./tests/wasm-node-glue.ts", import.meta.url)), + replacement: fileURLToPath( + new URL("./tests/wasm-node-glue.ts", import.meta.url), + ), }, ], }, test: { environment: "jsdom", - include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts", "tests/**/*.test.tsx"], + include: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "tests/**/*.test.ts", + "tests/**/*.test.tsx", + ], maxWorkers: "50%", minWorkers: 1, + /** + * A ratchet, not the target. The Rust crates hold 100% lines and functions; this layer is + * the laggard, and until it catches up the thresholds are pinned just under what the suite + * currently reaches so coverage can only go up. Raise these numbers with the suite — never + * lower them to make a change pass. + */ + coverage: { + provider: "v8", + include: ["src/**"], + reporter: ["text-summary", "html"], + thresholds: { + statements: 86, + branches: 78, + functions: 90, + lines: 88, + }, + }, }, }); From 7c34a3cdcece8801b91c719485c0b2290ddd0e50 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:57:12 -0300 Subject: [PATCH 017/122] feat(axum): refuse cookie-authenticated writes from untrusted origins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity half of the nest-auth change. `SameSite=None` is the one posture where the browser attaches the session cookie to a cross-site request, and it is the one this adapter had no second line of defense for. A tower layer, innermost so it sees the request exactly as the handler would, decides on headers a page cannot forge: safe methods pass, a request carrying no auth cookie passes (a bearer client has no ambient credential to spend), `Sec-Fetch-Site: same-origin`/`none` passes, and an `Origin` must appear in `cookies.trusted_origins`. Neither header at all is a non-browser caller and passes — a page cannot make a browser omit `Origin` cross-site, so the absence is evidence, not evasion. The request's own origin is never rebuilt from `Host`. Config validation refuses either half without the other, and refuses an entry that is not a bare absolute origin: a trailing slash, a path, a naked hostname or embedded userinfo can never equal an `Origin` header, so the origin the operator meant to allow would be silently blocked instead. The origin shape is checked with the crate's existing hand-rolled URL helpers rather than pulling in `url` — the dependency budget is a feature here. New `AuthError::UntrustedOrigin` / `auth.untrusted_origin`, 403, byte-identical to the nest-auth code. --- crates/bymax-auth-axum/src/lib.rs | 1 + crates/bymax-auth-axum/src/middleware.rs | 8 ++ crates/bymax-auth-axum/src/router.rs | 8 +- crates/bymax-auth-axum/src/state.rs | 2 + crates/bymax-auth-axum/src/test_support.rs | 3 + crates/bymax-auth-axum/src/trusted_origin.rs | 98 ++++++++++++++ crates/bymax-auth-axum/tests/adapter.rs | 112 +++++++++++++++- crates/bymax-auth-axum/tests/common/mod.rs | 13 ++ crates/bymax-auth-core/src/config/mod.rs | 15 +++ crates/bymax-auth-core/src/config/validate.rs | 123 +++++++++++++++++- crates/bymax-auth-core/src/error.rs | 15 +++ crates/bymax-auth-types/src/error.rs | 12 +- packages/rust-auth/src/shared/error-codes.ts | 2 +- 13 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 crates/bymax-auth-axum/src/trusted_origin.rs diff --git a/crates/bymax-auth-axum/src/lib.rs b/crates/bymax-auth-axum/src/lib.rs index 59c0c7d..d0443dd 100644 --- a/crates/bymax-auth-axum/src/lib.rs +++ b/crates/bymax-auth-axum/src/lib.rs @@ -30,6 +30,7 @@ mod response; mod router; mod routes; mod state; +mod trusted_origin; mod validation; #[cfg(feature = "websocket")] diff --git a/crates/bymax-auth-axum/src/middleware.rs b/crates/bymax-auth-axum/src/middleware.rs index 2997984..7d55fdf 100644 --- a/crates/bymax-auth-axum/src/middleware.rs +++ b/crates/bymax-auth-axum/src/middleware.rs @@ -24,6 +24,7 @@ use crate::state::AuthState; /// handler. pub(crate) fn apply_middleware( router: Router, + state: AuthState, max_body_bytes: usize, cors: Option, ) -> Router { @@ -35,7 +36,14 @@ pub(crate) fn apply_middleware( // Layered innermost-last: the cookie manager runs closest to the handler so the jar is // ready, then body-limit, redaction, optional CORS, and tracing wrap outward. + // The cross-site check sits innermost, next to the handlers: it must see the request + // exactly as the handler would, and it must not answer a CORS preflight (which the CORS + // layer above already handles before this ever runs). let router = router + .layer(axum::middleware::from_fn_with_state( + state, + crate::trusted_origin::enforce_trusted_origin, + )) .layer(CookieManagerLayer::new()) .layer(RequestBodyLimitLayer::new(max_body_bytes)) .layer(sensitive); diff --git a/crates/bymax-auth-axum/src/router.rs b/crates/bymax-auth-axum/src/router.rs index 78ac2c8..547cb7c 100644 --- a/crates/bymax-auth-axum/src/router.rs +++ b/crates/bymax-auth-axum/src/router.rs @@ -68,7 +68,12 @@ impl AuthRouter { // Nest the grouped routes under the configured prefix, apply the middleware stack, // then bind the shared state so the router is self-contained. let nested: Router = Router::new().nest(&prefix, grouped); - let nested = apply_middleware(nested, config.max_body_bytes, config.cors.clone()); + let nested = apply_middleware( + nested, + state.clone(), + config.max_body_bytes, + config.cors.clone(), + ); let router = nested.with_state(state); Self { router, groups } @@ -153,6 +158,7 @@ fn resolve_config(engine: &AuthEngine, config: &AxumAuthConfig) -> ResolvedConfi mfa_temp_path: auth_config.cookies.mfa_temp_cookie_path.clone(), secure: resolved.secure_cookies(), same_site: auth_config.cookies.same_site, + trusted_origins: auth_config.cookies.trusted_origins.clone(), access_max_age_secs: clamp_secs(auth_config.jwt.access_cookie_max_age.as_secs()), refresh_max_age_secs: refresh_max_age_secs(auth_config.jwt.refresh_expires_in_days), }; diff --git a/crates/bymax-auth-axum/src/state.rs b/crates/bymax-auth-axum/src/state.rs index 6848a82..6fe90c9 100644 --- a/crates/bymax-auth-axum/src/state.rs +++ b/crates/bymax-auth-axum/src/state.rs @@ -126,6 +126,8 @@ pub struct ResolvedCookies { pub secure: bool, /// The configured `SameSite` for the access/signal cookies. pub same_site: SameSite, + /// Origins allowed to make a state-changing request that carries the session cookie. + pub trusted_origins: Vec, /// `Max-Age` (seconds) of the access cookie, from `jwt.access_cookie_max_age`. pub access_max_age_secs: i64, /// `Max-Age` (seconds) of the refresh / session-signal cookies, from the refresh lifetime. diff --git a/crates/bymax-auth-axum/src/test_support.rs b/crates/bymax-auth-axum/src/test_support.rs index 6297850..8bda3c4 100644 --- a/crates/bymax-auth-axum/src/test_support.rs +++ b/crates/bymax-auth-axum/src/test_support.rs @@ -98,6 +98,9 @@ pub(crate) fn resolved_config_with( mfa_temp_path: "/auth/mfa".to_owned(), secure: true, same_site, + // The suite drives same-origin requests, which the origin check admits without + // consulting the list; a `SameSite::None` case names its own origin explicitly. + trusted_origins: vec!["https://app.example.com".to_owned()], access_max_age_secs: 900, refresh_max_age_secs: 604_800, }, diff --git a/crates/bymax-auth-axum/src/trusted_origin.rs b/crates/bymax-auth-axum/src/trusted_origin.rs new file mode 100644 index 0000000..a96bb87 --- /dev/null +++ b/crates/bymax-auth-axum/src/trusted_origin.rs @@ -0,0 +1,98 @@ +//! The cross-site request check for cookie-authenticated, state-changing requests (§8.8). +//! +//! `SameSite` carries this on its own for `Lax`/`Strict` — the browser simply does not send the +//! cookie. It does **not** for `SameSite=None`, which the library allows (embedded widgets, +//! iframes, cross-domain SPAs) and which sends the session cookie on every cross-site request. +//! That is the one configuration where this adapter has a CSRF exposure at all, and this layer +//! is what closes it. + +use axum::extract::{Request, State}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use bymax_auth_types::AuthError; + +use crate::response::error_response; +use crate::state::AuthState; + +/// `Sec-Fetch-Site` values that prove the request did not come from another site. +/// +/// `same-origin` is the app calling itself; `none` is a user-initiated navigation (a typed URL, +/// a bookmark), which no attacker page can cause. +const SAFE_FETCH_SITES: [&str; 2] = ["same-origin", "none"]; + +/// Reject a state-changing request that would otherwise be authenticated by the browser's +/// ambient session cookie and came from an origin the deployment does not trust. +/// +/// The decision uses only headers a page cannot forge: +/// +/// 1. A safe method changes nothing — allowed. `OPTIONS` in particular must pass, or every +/// cross-origin call would fail at the preflight. +/// 2. A request carrying none of the module's auth cookies has no ambient credential to abuse, +/// so a bearer-token client is never affected — allowed. +/// 3. `Sec-Fetch-Site: same-origin` / `none` proves the request is not cross-site — allowed. +/// 4. An `Origin` present must be in `cookies.trusted_origins` — allowed only then. +/// 5. `Sec-Fetch-Site` present and cross-site with no `Origin`: a browser that sends one header +/// sends the other on a state-changing request, so this shape is refused. +/// 6. Neither header at all — a non-browser client. Allowed: an attacker's page cannot make a +/// browser *omit* `Origin` on a cross-site request, so the absence is evidence there is no +/// browser involved, not a way around the check. +/// +/// The request's own origin is never reconstructed from `Host` or `X-Forwarded-Proto`: both are +/// client-controlled, and a check that trusts them is not a check. Same-origin requests are +/// recognised by `Sec-Fetch-Site` alone. +pub(crate) async fn enforce_trusted_origin( + State(state): State, + request: Request, + next: Next, +) -> Response { + if request.method().is_safe() { + return next.run(request).await; + } + + let cookies = state.config().cookies.clone(); + if !carries_auth_cookie(&request, &cookies.access_name, &cookies.refresh_name) { + return next.run(request).await; + } + + let fetch_site = header(&request, "sec-fetch-site"); + if fetch_site.is_some_and(|site| SAFE_FETCH_SITES.contains(&site)) { + return next.run(request).await; + } + + match header(&request, "origin") { + Some(origin) => { + if cookies + .trusted_origins + .iter() + .any(|allowed| allowed == origin) + { + next.run(request).await + } else { + error_response(&AuthError::UntrustedOrigin).into_response() + } + } + // A browser that sent `Sec-Fetch-Site` would have sent `Origin` here too. + None if fetch_site.is_some() => error_response(&AuthError::UntrustedOrigin).into_response(), + None => next.run(request).await, + } +} + +/// Read a header as UTF-8, or `None` when it is absent or not valid UTF-8. +fn header<'r>(request: &'r Request, name: &str) -> Option<&'r str> { + request.headers().get(name)?.to_str().ok() +} + +/// Whether the request carries one of the module's credential-bearing cookies. +/// +/// Only those two count. The session-signal cookie is readable by JavaScript by design and +/// authenticates nothing, so a request carrying only that one has no ambient credential for an +/// attacker page to spend. +fn carries_auth_cookie(request: &Request, access_name: &str, refresh_name: &str) -> bool { + let Some(header) = header(request, "cookie") else { + return false; + }; + header.split(';').any(|pair| { + let name = pair.split('=').next().unwrap_or_default().trim(); + name == access_name || name == refresh_name + }) +} diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 6bc944d..2249855 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -25,7 +25,7 @@ use common::{ Captured, EngineSpec, Req, TENANT, build, build_oauth_with_redirects, current_totp, enable_mfa_flag, router, seed_admin, seed_user, set_status, totp_at, }; -use http::{Method, StatusCode, header}; +use http::{HeaderName, Method, StatusCode, header}; /// Log in and return the captured response (cookie mode) for a seeded active user. async fn login(router: &axum::Router, email: &str, password: &str) -> Captured { @@ -2389,3 +2389,113 @@ async fn platform_extractor_propagates_internal_errors_but_masks_token_failures( "auth.platform_auth_required" ); } + +// --------------------------------------------------------------------------- +// Cross-site request check +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn a_cookie_authenticated_write_from_an_untrusted_origin_is_refused() { + // The attack this exists for: under `SameSite=None` the browser attaches the session + // cookie to a POST issued by a page on another origin, and without the check that request + // is authenticated. `Lax`/`Strict` never send the cookie cross-site, so the exposure is + // exactly the `None` deployment — which is the one configured here. + let Some(h) = build(EngineSpec { + trusted_origins: &["https://app.example.com"], + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + seed_user(&h, "csrf@e.com", "password123", "USER").await; + let access = login_access_cookie(&app, "csrf@e.com", "password123").await; + + let refused = Req::post("/auth/logout") + .cookie("access_token", &access) + .header(header::ORIGIN, "https://evil.example.com") + .header(HeaderName::from_static("sec-fetch-site"), "cross-site") + .send(&app) + .await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN); + assert_eq!( + refused.json()["error"]["code"], + serde_json::json!("auth.untrusted_origin") + ); +} + +#[tokio::test] +async fn the_cross_site_check_admits_what_it_should() { + let Some(h) = build(EngineSpec { + trusted_origins: &["https://app.example.com"], + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + seed_user(&h, "origin@e.com", "password123", "USER").await; + + // A listed origin is what the allow-list is for. + let access = login_access_cookie(&app, "origin@e.com", "password123").await; + let allowed = Req::post("/auth/logout") + .cookie("access_token", &access) + .header(header::ORIGIN, "https://app.example.com") + .send(&app) + .await; + assert_eq!(allowed.status, StatusCode::NO_CONTENT); + + // The app calling itself never consults the list — which is what keeps a same-origin + // deployment working with nothing configured. + let access = login_access_cookie(&app, "origin@e.com", "password123").await; + let same_origin = Req::post("/auth/logout") + .cookie("access_token", &access) + .header(header::ORIGIN, "https://evil.example.com") + .header(HeaderName::from_static("sec-fetch-site"), "same-origin") + .send(&app) + .await; + assert_eq!(same_origin.status, StatusCode::NO_CONTENT); + + // A bearer client has no ambient credential for a page to spend, so the check does not + // apply to it at all — blocking it would break every non-browser caller for no gain. + let bearer = Req::post("/auth/logout") + .header(header::ORIGIN, "https://evil.example.com") + .json(serde_json::json!({ "refreshToken": "r".repeat(64) })) + .send(&app) + .await; + assert_ne!(bearer.status, StatusCode::FORBIDDEN); + + // A read changes nothing and is never a target. + let read = Req::get("/auth/me") + .cookie( + "access_token", + &login_access_cookie(&app, "origin@e.com", "password123").await, + ) + .header(header::ORIGIN, "https://evil.example.com") + .send(&app) + .await; + assert_eq!(read.status, StatusCode::OK); +} + +#[tokio::test] +async fn a_cross_site_fetch_with_no_origin_header_is_refused() { + // A browser that sends `Sec-Fetch-Site` sends `Origin` too on a state-changing request, so + // this shape is malformed rather than a legitimate caller — and admitting it would be a + // trivial way around the check. + let Some(h) = build(EngineSpec { + trusted_origins: &["https://app.example.com"], + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + seed_user(&h, "nohdr@e.com", "password123", "USER").await; + let access = login_access_cookie(&app, "nohdr@e.com", "password123").await; + + let refused = Req::post("/auth/logout") + .cookie("access_token", &access) + .header(HeaderName::from_static("sec-fetch-site"), "same-site") + .send(&app) + .await; + + assert_eq!(refused.status, StatusCode::FORBIDDEN); +} diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index bd9f0b5..cb0ee74 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -67,6 +67,9 @@ pub struct EngineSpec { pub sessions: bool, pub verification_required: bool, pub allow_oauth: bool, + /// Origins the deployment trusts for cross-site, cookie-authenticated writes. Setting any + /// switches `SameSite` to `None`, since the two are only valid together. + pub trusted_origins: &'static [&'static str], } impl Default for EngineSpec { @@ -80,6 +83,7 @@ impl Default for EngineSpec { sessions: false, verification_required: false, allow_oauth: false, + trusted_origins: &[], } } } @@ -124,6 +128,15 @@ pub fn build(spec: EngineSpec) -> Option { config.controllers.invitations = spec.invitations; config.invitations.enabled = spec.invitations; config.controllers.oauth = spec.oauth; + if !spec.trusted_origins.is_empty() { + config.cookies.same_site = bymax_auth_core::config::SameSite::None; + config.cookies.trusted_origins = spec + .trusted_origins + .iter() + .map(|origin| (*origin).to_owned()) + .collect(); + config.secure_cookies = Some(true); + } if spec.mfa { config.mfa = Some(MfaConfig { diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index 593f99a..009a9a9 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -211,6 +211,20 @@ pub struct CookieConfig { pub mfa_temp_cookie_path: String, /// `SameSite` attribute, default `Lax`. pub same_site: SameSite, + /// Origins allowed to make state-changing requests that carry the session cookie, + /// default empty. + /// + /// Each entry is a full origin — scheme, host and, when non-default, port + /// (`https://app.example.com`, `http://localhost:3000`) — compared verbatim against the + /// request's `Origin` header. There are no wildcards: an allowlist matched by pattern is + /// one typo away from admitting an attacker-controlled subdomain. + /// + /// This only matters under [`SameSite::None`], the one posture where the browser sends the + /// session cookie on a cross-site request and there is therefore a cross-origin caller to + /// authorize. Validation refuses either half without the other, because both fail quietly: + /// `None` with no list rejects every cross-site call, and a list under `Lax`/`Strict` is + /// never consulted. + pub trusted_origins: Vec, /// Optional resolver for the cookie `Domain`(s), derived from the request host. pub resolve_domains: Option>, } @@ -224,6 +238,7 @@ impl Default for CookieConfig { refresh_cookie_path: "/auth".to_owned(), mfa_temp_cookie_path: "/auth/mfa".to_owned(), same_site: SameSite::Lax, + trusted_origins: Vec::new(), resolve_domains: None, } } diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index b775b66..9dc868b 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -243,6 +243,12 @@ impl AuthConfig { return Err(ConfigError::SameSiteNoneRequiresSecure); } + // Rule 19b: the trusted-origin allow-list and the SameSite posture must agree. The + // list only ever matters under `None` — the one posture where the browser sends the + // session cookie on a cross-site request — so either half without the other is a + // configuration that fails quietly rather than loudly. + self.validate_trusted_origins()?; + // Rule 20: a non-default route prefix requires an explicit refresh cookie path. if self.route_prefix != "auth" && self.cookies.refresh_cookie_path == "/auth" { return Err(ConfigError::RefreshPathMismatch { @@ -253,6 +259,33 @@ impl AuthConfig { Ok(secure_cookies) } + /// Validate that `cookies.trusted_origins` and `cookies.same_site` agree, and that every + /// entry is a bare absolute origin. + /// + /// The shape check is deliberately strict: an entry must be exactly scheme, host and an + /// optional port, with nothing after the authority. A trailing slash, a path, or a naked + /// hostname would all be silently blocked at request time instead — an `Origin` header is + /// never any of those — so they are refused here where the message can say why. + fn validate_trusted_origins(&self) -> Result<(), ConfigError> { + let cross_site = self.cookies.same_site == SameSite::None; + let listed = !self.cookies.trusted_origins.is_empty(); + + if cross_site && !listed { + return Err(ConfigError::TrustedOriginsRequired); + } + if !cross_site && listed { + return Err(ConfigError::TrustedOriginsUnused); + } + for origin in &self.cookies.trusted_origins { + if !is_bare_origin(origin) { + return Err(ConfigError::TrustedOriginMalformed { + origin: origin.clone(), + }); + } + } + Ok(()) + } + /// Validate the OAuth provider fields (rule 15), the production callback-https rule /// (16), the success-redirect delivery rule (17), and the production redirect /// https/relative + allow-list rules (18). @@ -446,6 +479,30 @@ fn decode_base64_any(s: &str) -> Option> { .or_else(|| URL_SAFE_NO_PAD.decode(s).ok()) } +/// Whether `value` is exactly an origin: `scheme://host` with an optional `:port` and +/// nothing after the authority. +/// +/// The `Origin` header a browser sends is always in this form, and the comparison against it +/// is verbatim, so anything else configured here can never match. Userinfo is rejected too — +/// it never appears in an `Origin`, and `https://evil.com@app.example.com` reading as +/// "app.example.com" to a careless parser is precisely the confusion worth refusing outright. +fn is_bare_origin(value: &str) -> bool { + let Some((scheme, rest)) = value.split_once("://") else { + return false; + }; + if scheme.is_empty() + || !scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') + { + return false; + } + if rest.is_empty() || rest.contains(['/', '?', '#', '\\', '@']) { + return false; + } + url_host(value).is_some() +} + /// Whether `url` is an absolute `https` URL with a non-empty host. An empty authority /// (`https:///path`) is rejected — it is not a usable absolute target. fn is_secure_https(url: &str) -> bool { @@ -913,16 +970,80 @@ mod tests { fn rejects_samesite_none_without_secure() { let mut cfg = valid_config(); cfg.cookies.same_site = SameSite::None; + cfg.cookies.trusted_origins = vec!["https://app.example.com".to_owned()]; cfg.secure_cookies = Some(false); assert!(matches!( cfg.validate(Environment::Production), Err(ConfigError::SameSiteNoneRequiresSecure) )); - // SameSite=None is fine once cookies are secure. + // SameSite=None is fine once cookies are secure and an origin is named. cfg.secure_cookies = Some(true); assert!(cfg.validate(Environment::Production).is_ok()); } + #[test] + fn the_trusted_origin_list_and_the_same_site_posture_must_agree() { + // The list only matters under `None`: that is the one posture where the browser sends + // the session cookie cross-site, so it is the only one with a cross-origin caller to + // authorize. Either half without the other fails quietly — `None` with no list rejects + // every cross-site call, a list under `Lax` is never consulted — so both are refused. + let mut none_without_list = valid_config(); + none_without_list.cookies.same_site = SameSite::None; + none_without_list.secure_cookies = Some(true); + assert!(matches!( + none_without_list.validate(Environment::Production), + Err(ConfigError::TrustedOriginsRequired) + )); + + let mut list_without_none = valid_config(); + list_without_none.cookies.trusted_origins = vec!["https://app.example.com".to_owned()]; + assert!(matches!( + list_without_none.validate(Environment::Production), + Err(ConfigError::TrustedOriginsUnused) + )); + } + + #[test] + fn rejects_a_trusted_origin_that_is_not_a_bare_origin() { + // Every entry is compared verbatim against the `Origin` header, which is always + // `scheme://host[:port]`. A trailing slash, a path, a naked hostname or embedded + // userinfo can never match, so the origin they were meant to allow would be silently + // blocked — refused here instead, where the message can say why. + for malformed in [ + "https://app.example.com/", + "https://app.example.com/callback", + "app.example.com", + "https://evil.example.com@app.example.com", + "https://", + "not a url", + "://app.example.com", + ] { + let mut cfg = valid_config(); + cfg.cookies.same_site = SameSite::None; + cfg.secure_cookies = Some(true); + cfg.cookies.trusted_origins = vec![malformed.to_owned()]; + assert!( + matches!( + cfg.validate(Environment::Production), + Err(ConfigError::TrustedOriginMalformed { ref origin }) if origin == malformed + ), + "expected {malformed} to be rejected" + ); + } + + // A port and an IPv6 literal are both part of an origin and must survive. + for accepted in ["http://localhost:3000", "https://[::1]:8443"] { + let mut cfg = valid_config(); + cfg.cookies.same_site = SameSite::None; + cfg.secure_cookies = Some(true); + cfg.cookies.trusted_origins = vec![accepted.to_owned()]; + assert!( + cfg.validate(Environment::Production).is_ok(), + "expected {accepted} to be accepted" + ); + } + } + #[test] fn rejects_non_default_prefix_without_explicit_refresh_path() { let mut cfg = valid_config(); diff --git a/crates/bymax-auth-core/src/error.rs b/crates/bymax-auth-core/src/error.rs index 7faa1c4..ae75491 100644 --- a/crates/bymax-auth-core/src/error.rs +++ b/crates/bymax-auth-core/src/error.rs @@ -149,6 +149,21 @@ pub enum ConfigError { /// browsers reject. #[error("cookies.same_site = None requires secure_cookies = true")] SameSiteNoneRequiresSecure, + /// `cookies.same_site = None` was resolved with an empty `cookies.trusted_origins`, so + /// every cross-site state-changing request would be rejected. + #[error("cookies.same_site = None requires a non-empty cookies.trusted_origins")] + TrustedOriginsRequired, + /// `cookies.trusted_origins` was set under a `SameSite` posture that never sends the + /// session cookie cross-site, so the allow-list could never be consulted. + #[error("cookies.trusted_origins is set but cookies.same_site is not None")] + TrustedOriginsUnused, + /// An entry in `cookies.trusted_origins` is not a bare absolute origin, so it can never + /// equal an `Origin` header. + #[error("cookies.trusted_origins entry '{origin}' is not an absolute origin")] + TrustedOriginMalformed { + /// The offending entry. + origin: String, + }, /// `route_prefix` was changed from its default without an explicit /// `cookies.refresh_cookie_path`, so the refresh cookie would no longer be scoped to /// the refresh endpoint. diff --git a/crates/bymax-auth-types/src/error.rs b/crates/bymax-auth-types/src/error.rs index c54e7f5..4895563 100644 --- a/crates/bymax-auth-types/src/error.rs +++ b/crates/bymax-auth-types/src/error.rs @@ -130,6 +130,10 @@ pub enum AuthErrorCode { /// Generic access-denied fallback. #[serde(rename = "auth.forbidden")] Forbidden, + /// A state-changing request carrying the session cookie came from an origin the + /// deployment does not trust. + #[serde(rename = "auth.untrusted_origin")] + UntrustedOrigin, // Invitations /// Invitation token absent from the store — invalid or expired. @@ -194,7 +198,8 @@ impl AuthErrorCode { | Self::EmailNotVerified | Self::MfaRequired | Self::InsufficientRole - | Self::Forbidden => 403, + | Self::Forbidden + | Self::UntrustedOrigin => 403, Self::SessionNotFound => 404, Self::EmailAlreadyExists | Self::SessionLimitReached @@ -249,6 +254,7 @@ impl AuthErrorCode { Self::OtpMaxAttempts => "Maximum number of attempts exceeded", Self::InsufficientRole => "Insufficient permission", Self::Forbidden => "Access denied", + Self::UntrustedOrigin => "Request origin not allowed", Self::InvalidInvitationToken => "Invalid or expired invitation token", Self::OauthFailed => "OAuth authentication failed", Self::OauthEmailMismatch => "OAuth email does not match", @@ -448,6 +454,9 @@ pub enum AuthError { /// Generic access-denied fallback. #[error("forbidden")] Forbidden, + /// A state-changing request carrying the session cookie came from an untrusted origin. + #[error("untrusted origin")] + UntrustedOrigin, // Invitations /// Invitation token absent — invalid or expired. @@ -523,6 +532,7 @@ impl AuthError { Self::OtpMaxAttempts => AuthErrorCode::OtpMaxAttempts, Self::InsufficientRole => AuthErrorCode::InsufficientRole, Self::Forbidden => AuthErrorCode::Forbidden, + Self::UntrustedOrigin => AuthErrorCode::UntrustedOrigin, Self::InvalidInvitationToken => AuthErrorCode::InvalidInvitationToken, Self::OauthFailed => AuthErrorCode::OauthFailed, Self::OauthEmailMismatch => AuthErrorCode::OauthEmailMismatch, diff --git a/packages/rust-auth/src/shared/error-codes.ts b/packages/rust-auth/src/shared/error-codes.ts index a539fc7..2a205b1 100644 --- a/packages/rust-auth/src/shared/error-codes.ts +++ b/packages/rust-auth/src/shared/error-codes.ts @@ -5,7 +5,7 @@ * string literal, byte-identical to nest-auth's `AUTH_ERROR_CODES`, and maps to a * fixed HTTP status via [`AuthErrorCode::http_status`]. */ -export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; +export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.untrusted_origin" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; export const AUTH_ERROR_CODES = { INVALID_CREDENTIALS: "auth.invalid_credentials", ACCOUNT_LOCKED: "auth.account_locked", From 8a46c17365c13b2ed00ed4854e484beb6bf24d2a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:19:38 -0300 Subject: [PATCH 018/122] feat(core): refuse passwords that appear in a breach corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity half of the nest-auth change: `PasswordBreachChecker` is consulted at the three places a password is set — register, reset, invitation acceptance — and never at login, where refusing a breached password someone already has would lock them out of the account they need to get into to change it. A seam, not a dependency. The default `AllowAllBreachChecker` approves everything and touches no network, so a crate upgrade never starts contacting a third party. The bundled `HibpBreachChecker` (feature `breach`) runs over the crate's existing `HttpClient` seam, so enabling the check pulls in no HTTP stack of its own — the deployment supplies the transport it already has. Only SHA-1 is added, behind the same feature, and it is the corpus's index rather than a security primitive: storage is still the configured KDF. The trait returns `bool`, not `Result`, because fail-open is the contract and not a policy the caller gets to choose: a corpus that is down, rate-limiting or answering garbage must approve the password. There is no error the engine would be right to act on. Also covers three lines the origin-check work left uncovered: a legacy record still recovers through its grace window (there is no family to check), and the family-owner walk skips a member whose record is gone, unreadable, or names no owner at all — an empty owner would build an index key every ownerless family would share. New `AuthError::PasswordCompromised` / `auth.password_compromised` (400), byte-identical to nest-auth. Coverage back to 100% lines and functions. --- crates/bymax-auth-core/Cargo.toml | 5 +- crates/bymax-auth-core/src/engine/builder.rs | 25 +- .../src/services/auth/invitation.rs | 3 + .../src/services/auth/password_reset.rs | 3 + .../src/services/auth/register.rs | 3 + .../bymax-auth-core/src/services/password.rs | 37 ++- crates/bymax-auth-core/src/traits/breach.rs | 276 ++++++++++++++++++ crates/bymax-auth-core/src/traits/mod.rs | 6 + .../bymax-auth-core/tests/engine_assembly.rs | 66 +++++ crates/bymax-auth-crypto/Cargo.toml | 3 + crates/bymax-auth-crypto/src/mac.rs | 25 ++ crates/bymax-auth-redis/tests/redis_stores.rs | 80 ++++- crates/bymax-auth-types/src/error.rs | 12 + .../bymax-auth-types/tests/error_catalog.rs | 2 + packages/rust-auth/src/shared/error-codes.ts | 2 +- 15 files changed, 535 insertions(+), 13 deletions(-) create mode 100644 crates/bymax-auth-core/src/traits/breach.rs diff --git a/crates/bymax-auth-core/Cargo.toml b/crates/bymax-auth-core/Cargo.toml index 71f4546..6765aa5 100644 --- a/crates/bymax-auth-core/Cargo.toml +++ b/crates/bymax-auth-core/Cargo.toml @@ -69,6 +69,9 @@ scrypt = ["bymax-auth-crypto/scrypt"] argon2 = ["bymax-auth-crypto/argon2"] # Forwards the MFA-gated crypto set (AES-256-GCM / TOTP / Base32). mfa = ["bymax-auth-crypto/mfa"] +# The bundled Have I Been Pwned checker. The `PasswordBreachChecker` seam itself is always +# present; this only compiles the shipped implementation and its SHA-1 dependency. +breach = ["bymax-auth-crypto/breach"] # Optional flow capabilities. These gate which engine flows and route groups a build # compiles; each is additive. sessions = [] @@ -81,7 +84,7 @@ invitations = [] # mock OAuth/HTTP clients) for downstream integration tests and this crate's own tests. testing = [] # Convenience meta-feature: every optional flow capability for a full backend. -full = ["sessions", "mfa", "oauth", "oauth-reqwest", "platform", "invitations"] +full = ["sessions", "mfa", "oauth", "oauth-reqwest", "platform", "invitations", "breach"] # docs.rs renders every flow plus both hashers; `--cfg docsrs` enables the # feature-gated doc annotations. `testing` is excluded — the in-memory doubles are diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 99a84cb..561514e 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -19,9 +19,9 @@ use crate::services::token_manager::TokenManagerService; #[cfg(feature = "mfa")] use crate::traits::MfaStore; use crate::traits::{ - AuthHooks, BruteForceStore, EmailProvider, HttpClient, InvitationStore, NoOpAuthHooks, - NoOpEmailProvider, OAuthProvider, OtpStore, PasswordResetStore, PlatformUserRepository, - SessionStore, UserRepository, WsTicketStore, + AllowAllBreachChecker, AuthHooks, BruteForceStore, EmailProvider, HttpClient, InvitationStore, + NoOpAuthHooks, NoOpEmailProvider, OAuthProvider, OtpStore, PasswordBreachChecker, + PasswordResetStore, PlatformUserRepository, SessionStore, UserRepository, WsTicketStore, }; /// Assembles an [`AuthEngine`] from a configuration plus the host's trait implementations. @@ -33,6 +33,7 @@ pub struct AuthEngineBuilder { user_repository: Option>, platform_user_repository: Option>, email_provider: Option>, + breach_checker: Option>, hooks: Option>, session_store: Option>, otp_store: Option>, @@ -65,6 +66,7 @@ impl AuthEngineBuilder { user_repository: None, platform_user_repository: None, email_provider: None, + breach_checker: None, hooks: None, session_store: None, otp_store: None, @@ -118,6 +120,18 @@ impl AuthEngineBuilder { self } + /// Set the breach checker consulted wherever a password is set (defaults to + /// [`AllowAllBreachChecker`], which approves everything and touches no network). + /// + /// Wiring one is opt-in on purpose: a crate should not start talking to a third-party + /// corpus because it was upgraded. The bundled [`HibpBreachChecker`] (feature `breach`) + /// runs over the same [`HttpClient`](crate::traits::HttpClient) seam the OAuth flows use. + #[must_use] + pub fn breach_checker(mut self, checker: Arc) -> Self { + self.breach_checker = Some(checker); + self + } + /// Set the lifecycle hooks (defaults to [`NoOpAuthHooks`]). #[must_use] pub fn hooks(mut self, hooks: Arc) -> Self { @@ -285,6 +299,7 @@ impl AuthEngineBuilder { user_repository, platform_user_repository, email_provider, + breach_checker, hooks, session_store, otp_store, @@ -360,7 +375,9 @@ impl AuthEngineBuilder { // Build the password service (and its startup sentinel hash) from the validated // password config before it is moved into the resolved bundle. - let passwords = Arc::new(PasswordService::new(&config.password)?); + let breach_checker = breach_checker + .unwrap_or_else(|| Arc::new(AllowAllBreachChecker) as Arc); + let passwords = Arc::new(PasswordService::new(&config.password, breach_checker)?); // Capture the scalar token/brute-force settings and the signing key before the // config is consumed by `ResolvedConfig::new`. diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index a3fb177..ea31588 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -175,6 +175,9 @@ impl AuthEngine { } // Token possession implies email ownership, so the new account is created verified. + self.passwords() + .assert_not_compromised(&input.password) + .await?; let password_hash = self.passwords().hash(&input.password).await?; let user = self .user_repository() 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 ee8d00d..1726496 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -429,6 +429,9 @@ impl AuthEngine { context: &ResetContext, new_password: &str, ) -> Result<(), AuthError> { + self.passwords() + .assert_not_compromised(new_password) + .await?; let new_hash = self.passwords().hash(new_password).await?; self.user_repository() .update_password(&context.user_id, &new_hash) diff --git a/crates/bymax-auth-core/src/services/auth/register.rs b/crates/bymax-auth-core/src/services/auth/register.rs index 4892a45..160068b 100644 --- a/crates/bymax-auth-core/src/services/auth/register.rs +++ b/crates/bymax-auth-core/src/services/auth/register.rs @@ -100,6 +100,9 @@ impl AuthEngine { overrides: RegisterOverrides, ) -> Result { let verification_required = self.config().config().email_verification.required; + self.passwords() + .assert_not_compromised(&input.password) + .await?; let password_hash = self.passwords().hash(&input.password).await?; // When verification is required the new account is forced unverified; otherwise an diff --git a/crates/bymax-auth-core/src/services/password.rs b/crates/bymax-auth-core/src/services/password.rs index d01682e..18be5b2 100644 --- a/crates/bymax-auth-core/src/services/password.rs +++ b/crates/bymax-auth-core/src/services/password.rs @@ -9,6 +9,8 @@ //! blocking pool (§7.2). Construction is the one exception: the sentinel is computed once, //! synchronously, while the engine is still being assembled. +use std::sync::Arc; + use bymax_auth_crypto::CryptoError; use bymax_auth_crypto::password::{PasswordParams, hash, needs_rehash, verify}; use bymax_auth_types::AuthError; @@ -17,6 +19,9 @@ use tokio::task::JoinError; use crate::ConfigError; use crate::config::PasswordConfig; use crate::services::internal_error; +#[cfg(test)] +use crate::traits::breach::AllowAllBreachChecker; +use crate::traits::breach::PasswordBreachChecker; /// A fixed, non-secret plaintext hashed once at startup into the [`PasswordService`] /// sentinel. Its only purpose is to give the absent-user login path a real PHC string to @@ -42,6 +47,7 @@ pub struct PasswordService { params: PasswordParams, rehash_on_verify: bool, sentinel: String, + breach_checker: Arc, } impl PasswordService { @@ -53,7 +59,10 @@ impl PasswordService { /// Returns [`ConfigError::SentinelHashFailed`] if the KDF rejects the (already /// validated) parameters while hashing the sentinel — effectively unreachable once /// startup validation has accepted the configuration. - pub(crate) fn new(config: &PasswordConfig) -> Result { + pub(crate) fn new( + config: &PasswordConfig, + breach_checker: Arc, + ) -> Result { let params = to_crypto_params(config); let sentinel = hash(SENTINEL_PLAINTEXT, ¶ms).map_err(|_| ConfigError::SentinelHashFailed)?; @@ -61,9 +70,29 @@ impl PasswordService { params, rehash_on_verify: config.rehash_on_verify, sentinel, + breach_checker, }) } + /// Reject a password that appears in a known-breach corpus. + /// + /// Called wherever a password is being *set* — registration, reset, invitation acceptance — + /// and never on login: refusing a breached password someone already has would lock them out + /// of the account they need to get into in order to change it. + /// + /// The checker fails open by contract, so an unreachable corpus admits the password rather + /// than blocking the credential path. + /// + /// # Errors + /// + /// Returns [`AuthError::PasswordCompromised`] when the corpus knows the password. + pub(crate) async fn assert_not_compromised(&self, password: &str) -> Result<(), AuthError> { + if self.breach_checker.is_breached(password).await { + return Err(AuthError::PasswordCompromised); + } + Ok(()) + } + /// Whether rehash-on-verify is enabled, so the caller upgrades a stale-but-valid hash. #[must_use] pub fn rehash_on_verify(&self) -> bool { @@ -196,7 +225,7 @@ mod tests { /// somehow failed (unreachable for the fixture), so callers stay panic-free with /// `let-else`. fn service() -> Option { - PasswordService::new(&config()).ok() + PasswordService::new(&config(), Arc::new(AllowAllBreachChecker)).ok() } #[tokio::test] @@ -263,7 +292,7 @@ mod tests { // The toggle is surfaced so the login flow can gate the fire-and-forget upgrade. let mut cfg = config(); cfg.rehash_on_verify = false; - let off = PasswordService::new(&cfg); + let off = PasswordService::new(&cfg, Arc::new(AllowAllBreachChecker)); assert!(matches!(off, Ok(s) if !s.rehash_on_verify())); let Some(on) = service() else { return }; assert!(on.rehash_on_verify()); @@ -281,7 +310,7 @@ mod tests { }; cfg.scrypt.cost_factor = 3; // not a power of two and below the floor assert!(matches!( - PasswordService::new(&cfg), + PasswordService::new(&cfg, Arc::new(AllowAllBreachChecker)), Err(ConfigError::SentinelHashFailed) )); } diff --git a/crates/bymax-auth-core/src/traits/breach.rs b/crates/bymax-auth-core/src/traits/breach.rs new file mode 100644 index 0000000..bf293b5 --- /dev/null +++ b/crates/bymax-auth-core/src/traits/breach.rs @@ -0,0 +1,276 @@ +//! The seam for checking a password against a known-breach corpus. +//! +//! A password can satisfy every complexity rule and still be one an attacker tries first, so +//! the engine consults a corpus wherever a password is *set*. The check is a **seam, not a +//! dependency**: the default approves everything, so a deployment that upgrades the crate never +//! starts talking to a third party it did not ask for. + +#[cfg(feature = "breach")] +use std::sync::Arc; + +use async_trait::async_trait; + +#[cfg(feature = "breach")] +use crate::traits::http::{HttpClient, HttpMethod, HttpRequest}; + +/// Decides whether a password appears in a known-breach corpus. +/// +/// # Contract +/// +/// Two rules an implementation must honor: +/// +/// - **Never transmit the password.** The point of a range query is that the corpus is searched +/// with a prefix of a digest, not with the secret. +/// - **Fail open.** A corpus that is unreachable, slow, or rate-limiting must approve the +/// password. Returning "breached" on an error would let a third party's outage block password +/// changes — including the change someone is making *because* they were breached. +/// +/// That is why the method returns `bool` rather than `Result`: there is no error an +/// implementation could return that the engine would be right to act on. +#[async_trait] +pub trait PasswordBreachChecker: Send + Sync { + /// Whether the password is known to have been breached. + async fn is_breached(&self, password: &str) -> bool; +} + +/// The default checker: approves every password, and touches no network. +/// +/// Registered when the builder is given none, so the credential path behaves exactly as it did +/// before the check existed. +#[derive(Clone, Copy, Debug, Default)] +pub struct AllowAllBreachChecker; + +#[async_trait] +impl PasswordBreachChecker for AllowAllBreachChecker { + async fn is_breached(&self, _password: &str) -> bool { + false + } +} + +/// The range endpoint. The last five characters of the path are the digest prefix. +#[cfg(feature = "breach")] +const HIBP_RANGE_URL: &str = "https://api.pwnedpasswords.com/range/"; + +/// Characters of the SHA-1 hex sent to the service. The rest never leaves the process. +#[cfg(feature = "breach")] +const PREFIX_LENGTH: usize = 5; + +/// Checks a password against Have I Been Pwned without ever sending it. +/// +/// The protocol is k-anonymity: the password is SHA-1'd locally, the **first five** hex +/// characters of the digest are sent, and the service answers with every suffix it holds under +/// that prefix — some hundreds of them. The comparison happens here, so the service learns a +/// prefix shared by thousands of distinct passwords and nothing else. +/// +/// SHA-1 is not a security choice here and is not used as one: it is the corpus's index. The +/// password is still hashed for storage with the configured KDF. +/// +/// The request runs over the crate's own [`HttpClient`] seam, so enabling the check pulls in no +/// HTTP stack of its own — the deployment supplies the transport it already has. +/// +/// # Examples +/// +/// ```no_run +/// # use std::sync::Arc; +/// # use bymax_auth_core::traits::{HibpBreachChecker, HttpClient}; +/// # fn wire(http: Arc) { +/// let checker = Arc::new(HibpBreachChecker::new(http)); +/// # let _ = checker; +/// # } +/// ``` +#[cfg(feature = "breach")] +pub struct HibpBreachChecker { + http: Arc, +} + +#[cfg(feature = "breach")] +impl HibpBreachChecker { + /// Build the checker over an HTTP transport. + #[must_use] + pub fn new(http: Arc) -> Self { + Self { http } + } +} + +#[cfg(feature = "breach")] +#[async_trait] +impl PasswordBreachChecker for HibpBreachChecker { + async fn is_breached(&self, password: &str) -> bool { + let digest = crate::services::to_hex(&bymax_auth_crypto::mac::sha1(password.as_bytes())) + .to_uppercase(); + let (prefix, suffix) = digest.split_at(PREFIX_LENGTH); + + let request = HttpRequest { + method: HttpMethod::Get, + url: format!("{HIBP_RANGE_URL}{prefix}"), + // Padding hides the true response size from a network observer. + headers: vec![("Add-Padding".to_owned(), "true".to_owned())], + body: None, + }; + + // Every failure path approves the password. A transport error, a rate limit, a body + // that is not UTF-8 — none of them are evidence about the password, and treating them + // as evidence would make a hardening measure a dependency of the credential path. + let Ok(response) = self.http.send(request).await else { + tracing::warn!("breach check unreachable — password allowed"); + return false; + }; + if !(200..300).contains(&response.status) { + tracing::warn!( + status = response.status, + "breach check unavailable — password allowed" + ); + return false; + } + let Ok(body) = String::from_utf8(response.body) else { + tracing::warn!("breach check returned a non-UTF-8 body — password allowed"); + return false; + }; + + // Each line is `SUFFIX:COUNT`. A match at all is disqualifying; the count is not + // consulted, because "breached once" is already too often. + body.lines().any(|line| { + line.split(':') + .next() + .is_some_and(|candidate| candidate.trim().eq_ignore_ascii_case(suffix)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The default has to be inert: no network, and every password approved. A crate that + /// starts contacting a third party because it was upgraded would be a surprise, and this + /// is the property that prevents it. + #[tokio::test] + async fn the_default_checker_approves_everything() { + assert!(!AllowAllBreachChecker.is_breached("hunter2").await); + assert!(!AllowAllBreachChecker.is_breached("").await); + } +} + +#[cfg(all(test, feature = "breach"))] +mod hibp_tests { + use super::*; + use crate::testing::MockHttpClient; + use crate::traits::http::{HttpError, HttpResponse}; + use std::sync::Mutex; + + const PASSWORD: &str = "correct horse battery staple"; + + /// The SHA-1 of the password, upper-cased, as the range API indexes it. + fn digest() -> String { + crate::services::to_hex(&bymax_auth_crypto::mac::sha1(PASSWORD.as_bytes())).to_uppercase() + } + + /// A client that records the URL it was asked for and answers with a fixed body. + struct RecordingClient { + body: String, + seen: Mutex>, + } + + #[async_trait] + impl HttpClient for RecordingClient { + async fn send(&self, req: HttpRequest) -> Result { + if let Ok(mut seen) = self.seen.lock() { + seen.push(req.url.clone()); + } + Ok(HttpResponse { + status: 200, + headers: Vec::new(), + body: self.body.clone().into_bytes(), + }) + } + } + + /// A client whose transport always fails. + struct FailingClient; + + #[async_trait] + impl HttpClient for FailingClient { + async fn send(&self, _req: HttpRequest) -> Result { + Err(HttpError::Transport("connection refused".to_owned())) + } + } + + #[tokio::test] + async fn only_the_five_character_prefix_leaves_the_process() { + // The k-anonymity property, asserted on the wire. Sending more than the prefix — the + // whole digest, let alone the password — would defeat the entire point of a range + // query, which is that the corpus is searched without revealing what is searched for. + let digest = digest(); + let client = Arc::new(RecordingClient { + body: String::new(), + seen: Mutex::new(Vec::new()), + }); + + HibpBreachChecker::new(client.clone()) + .is_breached(PASSWORD) + .await; + + let seen = client + .seen + .lock() + .map(|urls| urls.clone()) + .unwrap_or_default(); + assert_eq!(seen.len(), 1); + let url = &seen[0]; + assert!(url.ends_with(&digest[..5])); + assert!(!url.contains(&digest[5..])); + assert!(!url.contains(PASSWORD)); + } + + #[tokio::test] + async fn a_suffix_in_the_range_is_a_breached_password() { + // The match itself, compared locally against every line of the range. Case and the + // CRLF line endings the service uses must not throw it off. + let digest = digest(); + let body = format!( + "0000000000000000000000000000000000A:3\r\n{}:42\r\n", + &digest[5..] + ); + let client = Arc::new(MockHttpClient::with_body(200, body.into_bytes())); + + assert!(HibpBreachChecker::new(client).is_breached(PASSWORD).await); + + let lowercase = format!("{}:1\r\n", digest[5..].to_lowercase()); + let client = Arc::new(MockHttpClient::with_body(200, lowercase.into_bytes())); + assert!(HibpBreachChecker::new(client).is_breached(PASSWORD).await); + } + + #[tokio::test] + async fn a_range_without_the_suffix_is_a_clean_password() { + let client = Arc::new(MockHttpClient::with_body( + 200, + b"0000000000000000000000000000000000A:3\r\nFFFFF:1\r\n".to_vec(), + )); + + assert!(!HibpBreachChecker::new(client).is_breached(PASSWORD).await); + } + + #[tokio::test] + async fn every_failure_approves_the_password() { + // Fail-open is the rule that keeps this from becoming a dependency of the credential + // path: a corpus that is down, rate-limiting, or answering garbage must not stop + // someone changing their password — least of all during an incident, when changing it + // is the urgent thing. + let rate_limited = Arc::new(MockHttpClient::with_body(429, Vec::new())); + assert!( + !HibpBreachChecker::new(rate_limited) + .is_breached(PASSWORD) + .await + ); + + let transport_error = Arc::new(FailingClient); + assert!( + !HibpBreachChecker::new(transport_error) + .is_breached(PASSWORD) + .await + ); + + let not_utf8 = Arc::new(MockHttpClient::with_body(200, vec![0xff, 0xfe, 0xfd])); + assert!(!HibpBreachChecker::new(not_utf8).is_breached(PASSWORD).await); + } +} diff --git a/crates/bymax-auth-core/src/traits/mod.rs b/crates/bymax-auth-core/src/traits/mod.rs index 9cc0468..7c3d174 100644 --- a/crates/bymax-auth-core/src/traits/mod.rs +++ b/crates/bymax-auth-core/src/traits/mod.rs @@ -4,6 +4,7 @@ //! Redis-store abstraction, the OAuth providers, and the dependency-free //! [`HttpClient`] transport. +pub mod breach; pub mod email; pub mod hooks; pub mod http; @@ -11,6 +12,11 @@ pub mod oauth; pub mod repository; pub mod store; +#[cfg(feature = "breach")] +#[doc(inline)] +pub use breach::HibpBreachChecker; +#[doc(inline)] +pub use breach::{AllowAllBreachChecker, PasswordBreachChecker}; #[doc(inline)] pub use email::{EmailError, EmailProvider, InviteData, NoOpEmailProvider, SessionInfo}; #[doc(inline)] diff --git a/crates/bymax-auth-core/tests/engine_assembly.rs b/crates/bymax-auth-core/tests/engine_assembly.rs index 702c868..89ee4d9 100644 --- a/crates/bymax-auth-core/tests/engine_assembly.rs +++ b/crates/bymax-auth-core/tests/engine_assembly.rs @@ -81,3 +81,69 @@ fn assembles_with_platform_domain_enabled() { assert!(engine.platform_user_repository().is_some()); assert!(engine.config().config().controllers.platform); } + +/// A breach checker that reports one specific password as breached. +struct RejectsOnePassword(&'static str); + +#[async_trait::async_trait] +impl bymax_auth_core::traits::PasswordBreachChecker for RejectsOnePassword { + async fn is_breached(&self, password: &str) -> bool { + password == self.0 + } +} + +/// A wired breach checker refuses the password before it is ever hashed and stored, and a +/// clean password is untouched. +/// +/// The check has to sit on the path that *sets* a password. Wiring that only takes effect at +/// some later verification would be worthless: the breached credential would already be the +/// account's. +#[tokio::test] +async fn a_wired_breach_checker_refuses_a_compromised_password_at_registration() { + let users: Arc = Arc::new(InMemoryUserRepository::new()); + let engine = AuthEngine::builder() + .config(base_config()) + .environment(Environment::Test) + .user_repository(users) + .redis_stores(Arc::new(InMemoryStores::new())) + .breach_checker(Arc::new(RejectsOnePassword("password123"))) + .build(); + assert!(engine.is_ok(), "valid wiring must assemble"); + let Ok(engine) = engine else { return }; + let ctx = bymax_auth_core::context::RequestContext::new( + "203.0.113.4", + "tests", + std::collections::BTreeMap::new(), + ); + + let refused = engine + .register( + bymax_auth_core::services::auth::RegisterInput { + email: "breached@example.com".to_owned(), + password: "password123".to_owned(), + name: "Ada".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx, + ) + .await; + assert!(matches!( + refused, + Err(bymax_auth_types::AuthError::PasswordCompromised) + )); + + // A password the corpus does not know registers normally — the check adds no behaviour + // when it has nothing to report. + let accepted = engine + .register( + bymax_auth_core::services::auth::RegisterInput { + email: "clean@example.com".to_owned(), + password: "a-long-unique-passphrase".to_owned(), + name: "Ada".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx, + ) + .await; + assert!(accepted.is_ok()); +} diff --git a/crates/bymax-auth-crypto/Cargo.toml b/crates/bymax-auth-crypto/Cargo.toml index 5da2ec9..ff06364 100644 --- a/crates/bymax-auth-crypto/Cargo.toml +++ b/crates/bymax-auth-crypto/Cargo.toml @@ -61,6 +61,9 @@ default = ["scrypt"] scrypt = ["dep:scrypt"] argon2 = ["dep:argon2"] mfa = ["dep:aes-gcm", "dep:sha1", "dep:data-encoding"] +# SHA-1 on its own, for the breach-corpus range query. SHA-1 is the corpus index there, +# never a security primitive — passwords are still stored under the configured KDF. +breach = ["dep:sha1"] # Selects `getrandom`'s browser (Web Crypto) backend so this crate — and its own # wasm tests — can build and run on `wasm32-unknown-unknown`. OFF by default: a # reusable library must not pick a wasm RNG backend for its consumers (it would diff --git a/crates/bymax-auth-crypto/src/mac.rs b/crates/bymax-auth-crypto/src/mac.rs index b7e09ad..dcdd190 100644 --- a/crates/bymax-auth-crypto/src/mac.rs +++ b/crates/bymax-auth-crypto/src/mac.rs @@ -94,6 +94,31 @@ pub fn verify_digest(a: &[u8; DIGEST_LEN], b: &[u8; DIGEST_LEN]) -> bool { constant_time_eq(a, b) } +/// SHA-1 of `input`. +/// +/// Present for exactly one purpose: the breach-corpus range query, whose index is SHA-1. It is +/// **not** a security primitive here and must not be used as one — passwords are stored under +/// the configured KDF, and every identifier hash in the keyspace is SHA-256 or an HMAC. +/// +/// # Examples +/// +/// ``` +/// use bymax_auth_crypto::mac::sha1; +/// +/// // Known-answer vector: SHA-1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d. +/// let digest = sha1(b"abc"); +/// assert_eq!(digest[0], 0xa9); +/// assert_eq!(digest[19], 0x9d); +/// ``` +#[cfg(feature = "breach")] +#[must_use] +pub fn sha1(input: &[u8]) -> [u8; 20] { + use sha1::Digest as _; + let mut hasher = sha1::Sha1::new(); + hasher.update(input); + hasher.finalize().into() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index df3dd38..98ac9aa 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -385,15 +385,89 @@ async fn a_legacy_session_without_a_family_plants_no_family_keys() { "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); + // A legacy record still recovers through its grace window: the family-alive check has no + // family to check, so it must not refuse a session that predates the mechanism entirely. + assert!(matches!( + stores.rotate(kind, &rot).await, + Ok(RotateOutcome::Grace(r)) if r.user_id == "lu" + )); + + // With no consumed marker, a post-grace replay is a plain Invalid, never a reuse. The + // grace pointer was consumed by the recovery above, so the window is already closed. assert!(matches!( stores.rotate(kind, &rot).await, Ok(RotateOutcome::Invalid) )); } +#[tokio::test] +async fn revoking_a_family_resolves_the_owner_past_unreadable_members() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // A family outlives its individual sessions, so the first member is not always the one + // that still names its owner. The owner lookup has to walk past a member whose record has + // expired and one whose record is unreadable, or the revocation would prune nothing from + // the index and leave every revoked session listed until the index itself expired. + assert!( + stores + .create_session(kind, "o1", &record("ou"), 3600) + .await + .is_ok() + ); + assert!( + stores + .create_session(kind, "o2", &record("ou"), 3600) + .await + .is_ok() + ); + assert!( + stores + .create_session(kind, "o3", &record("ou"), 3600) + .await + .is_ok() + ); + // o1's record is gone; o2's is unparseable; o3 parses but names no owner at all — an + // empty id would build `sess:` with nothing after the colon, a key every ownerless family + // would share, so it has to be skipped like the other two. o4 is the one that answers. + assert!( + stores + .create_session(kind, "o4", &record("ou"), 3600) + .await + .is_ok() + ); + assert!(redis.del("auth:rt:o1").await); + assert!(redis.set_raw("auth:rt:o2", "not-json{{{").await); + let ownerless = serde_json::to_string(&SessionRecord { + user_id: String::new(), + ..record("ou") + }) + .unwrap_or_default(); + assert!(redis.set_raw("auth:rt:o3", &ownerless).await); + + assert!(stores.revoke_family(kind, "fam-ou").await.is_ok()); + + // Every member key is gone and the owner's index was pruned, which is only possible if the + // walk reached o4. + assert_eq!(redis.ttl("auth:rt:o4").await, -2); + assert!(redis.smembers("auth:sess:ou").await.is_empty()); + + // And when NO member names an owner — every record already expired — the revocation still + // drops the family index rather than failing. There is simply no index left to prune. + assert!( + stores + .create_session(kind, "g1", &record("gu2"), 3600) + .await + .is_ok() + ); + assert!(redis.del("auth:rt:g1").await); + assert!(stores.revoke_family(kind, "fam-gu2").await.is_ok()); + assert_eq!(redis.ttl("auth:fam:fam-gu2").await, -2); +} + #[tokio::test] async fn platform_sessions_use_the_platform_keyspace() { let Some(redis) = common::try_start().await else { diff --git a/crates/bymax-auth-types/src/error.rs b/crates/bymax-auth-types/src/error.rs index 4895563..c4fa5be 100644 --- a/crates/bymax-auth-types/src/error.rs +++ b/crates/bymax-auth-types/src/error.rs @@ -104,6 +104,10 @@ pub enum AuthErrorCode { /// New password fails the minimum policy. #[serde(rename = "auth.password_too_weak")] PasswordTooWeak, + /// The password appears in a known-breach corpus. Distinct from `PasswordTooWeak`: it may + /// satisfy every complexity rule and still be one an attacker tries first. + #[serde(rename = "auth.password_compromised")] + PasswordCompromised, /// Reset token absent from the store. #[serde(rename = "auth.password_reset_token_invalid")] PasswordResetTokenInvalid, @@ -208,6 +212,7 @@ impl AuthErrorCode { Self::MfaNotEnabled | Self::MfaSetupRequired | Self::PasswordTooWeak + | Self::PasswordCompromised | Self::PasswordResetTokenInvalid | Self::PasswordResetTokenExpired | Self::InvalidInvitationToken @@ -247,6 +252,9 @@ impl AuthErrorCode { Self::MfaTempTokenInvalid => "Invalid or expired temporary MFA token", Self::RecoveryCodeInvalid => "Invalid recovery code", Self::PasswordTooWeak => "Password too weak", + Self::PasswordCompromised => { + "This password has appeared in a data breach. Please choose a different one." + } Self::PasswordResetTokenInvalid => "Invalid password reset token", Self::PasswordResetTokenExpired => "Expired password reset token", Self::OtpInvalid => "Invalid OTP code", @@ -429,6 +437,9 @@ pub enum AuthError { /// New password fails the minimum policy. #[error("password too weak")] PasswordTooWeak, + /// The password appears in a known-breach corpus. + #[error("password compromised")] + PasswordCompromised, /// Reset token absent from the store. #[error("password reset token invalid")] PasswordResetTokenInvalid, @@ -525,6 +536,7 @@ impl AuthError { Self::MfaTempTokenInvalid => AuthErrorCode::MfaTempTokenInvalid, Self::RecoveryCodeInvalid => AuthErrorCode::RecoveryCodeInvalid, Self::PasswordTooWeak => AuthErrorCode::PasswordTooWeak, + Self::PasswordCompromised => AuthErrorCode::PasswordCompromised, Self::PasswordResetTokenInvalid => AuthErrorCode::PasswordResetTokenInvalid, Self::PasswordResetTokenExpired => AuthErrorCode::PasswordResetTokenExpired, Self::OtpInvalid => AuthErrorCode::OtpInvalid, diff --git a/crates/bymax-auth-types/tests/error_catalog.rs b/crates/bymax-auth-types/tests/error_catalog.rs index b5c7eb5..31d74c5 100644 --- a/crates/bymax-auth-types/tests/error_catalog.rs +++ b/crates/bymax-auth-types/tests/error_catalog.rs @@ -91,6 +91,7 @@ fn all_errors() -> Vec { AuthError::MfaTempTokenInvalid, AuthError::RecoveryCodeInvalid, AuthError::PasswordTooWeak, + AuthError::PasswordCompromised, AuthError::PasswordResetTokenInvalid, AuthError::PasswordResetTokenExpired, AuthError::OtpInvalid, @@ -98,6 +99,7 @@ fn all_errors() -> Vec { AuthError::OtpMaxAttempts, AuthError::InsufficientRole, AuthError::Forbidden, + AuthError::UntrustedOrigin, AuthError::InvalidInvitationToken, AuthError::OauthFailed, AuthError::OauthEmailMismatch, diff --git a/packages/rust-auth/src/shared/error-codes.ts b/packages/rust-auth/src/shared/error-codes.ts index 2a205b1..ec0b364 100644 --- a/packages/rust-auth/src/shared/error-codes.ts +++ b/packages/rust-auth/src/shared/error-codes.ts @@ -5,7 +5,7 @@ * string literal, byte-identical to nest-auth's `AUTH_ERROR_CODES`, and maps to a * fixed HTTP status via [`AuthErrorCode::http_status`]. */ -export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.untrusted_origin" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; +export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_compromised" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.untrusted_origin" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; export const AUTH_ERROR_CODES = { INVALID_CREDENTIALS: "auth.invalid_credentials", ACCOUNT_LOCKED: "auth.account_locked", From 0ac233c821717fa13177a9a4ee7a5f07e6e3b304 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:31:25 -0300 Subject: [PATCH 019/122] test(axum): pin the per-route rate limits to the shared contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adapter already enforces its limits — a governor layer per route, not a recommendation — so there was nothing to add. What was missing is the agreement: 21 numbers duplicated across two repos with nothing checking they matched. The contract now carries the table and both sides assert against it, so a limit changed on one side turns that side red rather than surfacing as the same client being throttled at different points depending on which backend answered. The storage difference is written down as deliberate and outside the contract: nest-auth counts in Redis and therefore shares the limit across instances, this adapter counts in process memory and therefore does not. Under horizontal scale the Redis one is the stricter of the two. --- conformance/wire-contract.json | 35 +++++++++++++ crates/bymax-auth-axum/src/rate_limit.rs | 66 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index c2db62f..894381d 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -155,6 +155,41 @@ "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written" }, + "rateLimits": { + "$comment": [ + "The per-IP limit each auth route is served under, as `requests/windowSeconds`. Both", + "backends enforce these — nest-auth with a Redis fixed-window counter, rust-auth with a", + "per-route governor layer — so a deployment that puts the two behind one load balancer", + "gets the same answer whichever one serves the request. A value changed on one side only", + "is a silent divergence: the same client would be throttled at different points.", + "", + "The STORAGE differs deliberately and is not part of the contract: nest-auth's counter is", + "in Redis and therefore shared across instances, rust-auth's is in process memory and", + "therefore per-instance. The Redis one is the stricter of the two under horizontal scale." + ], + "login": "5/60", + "register": "10/3600", + "refresh": "10/60", + "forgotPassword": "3/300", + "resetPassword": "3/300", + "verifyOtp": "3/300", + "resendPasswordOtp": "3/300", + "verifyEmail": "5/60", + "resendVerification": "3/300", + "mfaSetup": "5/60", + "mfaVerifyEnable": "5/60", + "mfaChallenge": "5/60", + "mfaDisable": "3/300", + "platformLogin": "5/60", + "invitationCreate": "10/3600", + "invitationAccept": "5/60", + "listSessions": "30/60", + "revokeSession": "10/60", + "revokeAllSessions": "5/60", + "oauthInitiate": "10/60", + "oauthCallback": "10/60" + }, + "accessTokenClaims": { "$comment": [ "Claims both sides stamp and both sides check. `epoch` is the per-user generation counter", diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index c335b30..2a9482d 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -189,6 +189,72 @@ mod tests { use super::*; use http::StatusCode; + /// Read the shared cross-implementation wire contract's rate-limit table. + /// + /// Held byte-identical by nest-auth, which can serve the same deployment. Reading it here + /// rather than repeating the numbers means a limit changed on either side turns that side + /// red, instead of surfacing as the same client being throttled at different points + /// depending on which backend answered. + fn contract_limits() -> serde_json::Value { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + root.get("rateLimits") + .cloned() + .unwrap_or(serde_json::Value::Null) + } + + #[test] + fn every_default_limit_matches_the_shared_wire_contract() { + let contract = contract_limits(); + let defaults = RateLimitConfig::default(); + let pairs: [(&str, Option); 21] = [ + ("login", defaults.login), + ("register", defaults.register), + ("refresh", defaults.refresh), + ("forgotPassword", defaults.forgot_password), + ("resetPassword", defaults.reset_password), + ("verifyOtp", defaults.verify_otp), + ("resendPasswordOtp", defaults.resend_password_otp), + ("verifyEmail", defaults.verify_email), + ("resendVerification", defaults.resend_verification), + ("mfaSetup", defaults.mfa_setup), + ("mfaVerifyEnable", defaults.mfa_verify_enable), + ("mfaChallenge", defaults.mfa_challenge), + ("mfaDisable", defaults.mfa_disable), + ("platformLogin", defaults.platform_login), + ("invitationCreate", defaults.invitation_create), + ("invitationAccept", defaults.invitation_accept), + ("listSessions", defaults.list_sessions), + ("revokeSession", defaults.revoke_session), + ("revokeAllSessions", defaults.revoke_all_sessions), + ("oauthInitiate", defaults.oauth_initiate), + ("oauthCallback", defaults.oauth_callback), + ]; + + for (name, limit) in pairs { + assert!(limit.is_some(), "{name} has no default limit"); + let Some(limit) = limit else { continue }; + let rendered = format!("{}/{}", limit.burst, limit.per_seconds); + assert_eq!( + contract.get(name).and_then(serde_json::Value::as_str), + Some(rendered.as_str()), + "limit for {name} drifted from the shared contract" + ); + } + + // And the contract names no route this catalog is missing: an entry on one side only + // is a route whose limit nobody agreed on. + let named = contract + .as_object() + .map(|table| table.keys().filter(|key| !key.starts_with('$')).count()) + .unwrap_or_default(); + assert_eq!(named, pairs.len()); + } + #[test] fn replenish_clamps_to_at_least_one_second() { // 5/60s replenishes one cell every 12s; a tiny window clamps to 1s (never 0). From b01647a98475888babfed0efcc524d7f7226d502 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:42:28 -0300 Subject: [PATCH 020/122] feat(core): cap how long one login can be extended by rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parity half of the nest-auth change. `refresh_expires_in_days` bounds a single refresh token, not a session: a client rotating every fifteen minutes renews that lifetime indefinitely, so a session established once never has to be established again. `family_created_at` is stamped at login and carried unchanged through every rotation — deliberately not `created_at`, which is this session's own and resets each time; measuring from that would make the cap unreachable while looking like it worked. The rotation is refused once `jwt.absolute_session_lifetime_days` has passed, before the script runs, so nothing is consumed on the holder's behalf, and refused as a plain invalid refresh: the remedy is the same as any other, and a distinct code would only tell whoever holds the token how old the session is. Off by default, matching nest-auth: switching it on ends sessions already older than the cap, which is a deployment's decision rather than an upgrade's. A record written before the field carries no birth time and is not capped. The serde adapter is `Option`-aware so a legacy record round-trips as `None` instead of failing the whole record — but a present-and-malformed value is still an error, because a birth time that cannot be read is a cap that cannot be judged, and dropping it quietly would uncap the session. --- conformance/wire-contract.json | 4 +- crates/bymax-auth-core/src/config/mod.rs | 14 ++ crates/bymax-auth-core/src/engine/builder.rs | 2 + .../bymax-auth-core/src/services/auth/mod.rs | 1 + .../src/services/auth/password_reset.rs | 1 + .../src/services/mfa/challenge.rs | 1 + .../bymax-auth-core/src/services/mfa/tests.rs | 1 + .../bymax-auth-core/src/services/session.rs | 1 + .../src/services/token_manager.rs | 192 +++++++++++++++++- crates/bymax-auth-core/src/testing/mod.rs | 1 + crates/bymax-auth-core/src/traits/store.rs | 93 +++++++++ crates/bymax-auth-redis/src/stores/session.rs | 1 + crates/bymax-auth-redis/tests/redis_stores.rs | 3 + 13 files changed, 313 insertions(+), 2 deletions(-) diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 894381d..9671a22 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -119,10 +119,12 @@ "ip", "createdAt", "mfaEnabled", - "familyId" + "familyId", + "familyCreatedAt" ], "createdAt": "iso8601-string", "familyId": "omitted from the record entirely when empty, never written as an empty string", + "familyCreatedAt": "iso8601-string; the FAMILY's birth, carried unchanged through every rotation — distinct from createdAt, which is this session's own and resets on each one. Omitted alongside familyId on a legacy record, which is then not subject to the absolute lifetime cap.", "$comment": "mfaEnabled must survive rotation: the MFA gate refuses only when mfaEnabled && !mfaVerified, so losing it turns one refresh into a silent second-factor bypass." }, "sessionDetail": { diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index 009a9a9..428454b 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -140,6 +140,19 @@ pub struct JwtConfig { pub access_cookie_max_age: Duration, /// Refresh-token lifetime in days, default 7. pub refresh_expires_in_days: u32, + /// Hard cap on how long one login can be extended by rotation, in days. Default `0` — no + /// cap. + /// + /// `refresh_expires_in_days` bounds a single refresh token, not a session: a client that + /// rotates every fifteen minutes renews that lifetime indefinitely, so a session + /// established once never has to be established again. This caps the whole lineage — the + /// family's birth is stamped at login and carried through every rotation — and once it is + /// passed the rotation is refused and the user signs in again. + /// + /// Off by default because switching it on ends sessions already older than the cap, which + /// is a decision a deployment makes rather than one an upgrade makes for it. A record + /// written before the family birth time existed carries none and is not capped. + pub absolute_session_lifetime_days: u32, /// Pinned to HS256. pub algorithm: JwtAlgorithm, /// Grace window during which a rotated refresh token stays valid, default 30s. @@ -156,6 +169,7 @@ impl Default for JwtConfig { access_expires_in: Duration::from_secs(15 * 60), access_cookie_max_age: Duration::from_secs(15 * 60), refresh_expires_in_days: 7, + absolute_session_lifetime_days: 0, algorithm: JwtAlgorithm::Hs256, refresh_grace_window: Duration::from_secs(30), } diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 561514e..7a6c87e 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -383,6 +383,7 @@ impl AuthEngineBuilder { // config is consumed by `ResolvedConfig::new`. let access_ttl = config.jwt.access_expires_in; let refresh_days = config.jwt.refresh_expires_in_days; + let absolute_session_lifetime_days = config.jwt.absolute_session_lifetime_days; let grace_window = config.jwt.refresh_grace_window; let brute_max_attempts = config.brute_force.max_attempts; let brute_window_secs = config.brute_force.window.as_secs(); @@ -406,6 +407,7 @@ impl AuthEngineBuilder { access_ttl, refresh_days, grace_window, + absolute_session_lifetime_days, ); // Wire the MFA temp-token single-use support when an MFA store is supplied, so the // challenge token planted at login is store-backed and brute-force-capped. diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index b40f65f..475cc12 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -240,6 +240,7 @@ impl AuthEngine { // the new-session hook / eviction projection (which keys on the session hash), so // this display record leaves it empty. family_id: String::new(), + family_created_at: None, }; 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 1726496..57e6069 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -575,6 +575,7 @@ mod tests { created_at: time::OffsetDateTime::UNIX_EPOCH, mfa_enabled: false, family_id: "fam-test".to_owned(), + family_created_at: Some(time::OffsetDateTime::UNIX_EPOCH), }; assert!( h.stores diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index c4f7957..3d905a6 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -318,6 +318,7 @@ impl MfaService { mfa_enabled: safe.mfa_enabled, // Server-internal family id is not part of the hook/eviction projection. family_id: String::new(), + family_created_at: None, }; 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/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 820c427..832d82e 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -995,6 +995,7 @@ fn service_over(store: Arc, users: Arc) -> Duration::from_secs(900), 7, Duration::from_secs(30), + 0, )); let sessions = Arc::new(SessionService::new( session_store.clone(), diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index e0c0afc..6b1e018 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -504,6 +504,7 @@ mod tests { created_at: created, mfa_enabled: false, family_id: "fam-test".to_owned(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), } } diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index ce6711f..963a989 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -95,6 +95,7 @@ pub struct TokenManagerService { access_ttl: Duration, refresh_ttl_secs: u64, grace_ttl_secs: u64, + absolute_lifetime_secs: u64, /// The MFA single-use temp-token support, wired only when an MFA store is supplied. #[cfg(feature = "mfa")] mfa: Option, @@ -109,6 +110,7 @@ impl TokenManagerService { access_ttl: Duration, refresh_expires_in_days: u32, grace_window: Duration, + absolute_session_lifetime_days: u32, ) -> Self { Self { key, @@ -116,6 +118,7 @@ impl TokenManagerService { access_ttl, refresh_ttl_secs: u64::from(refresh_expires_in_days) * 86_400, grace_ttl_secs: grace_window.as_secs(), + absolute_lifetime_secs: u64::from(absolute_session_lifetime_days) * 86_400, #[cfg(feature = "mfa")] mfa: None, } @@ -193,6 +196,8 @@ impl TokenManagerService { // 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(), + // …and stamps the lineage's birth, which the absolute-lifetime cap measures from. + family_created_at: Some(now_offset()), }; self.session_store .create_session( @@ -243,6 +248,7 @@ impl TokenManagerService { .find_session(SessionKind::Dashboard, &old_hash) .await?; let seed = live.unwrap_or_else(|| placeholder_record(ip, user_agent)); + self.assert_within_absolute_lifetime(&seed)?; let new_record = identity_record(&seed, ip, user_agent); let rotation = SessionRotation { @@ -369,6 +375,7 @@ impl TokenManagerService { mfa_enabled: admin.mfa_enabled, // A fresh platform login opens a new refresh-token family (section 12.5.2). family_id: new_uuid_v4(), + family_created_at: Some(now_offset()), }; self.session_store .create_session( @@ -416,6 +423,7 @@ impl TokenManagerService { .find_session(SessionKind::Platform, &old_hash) .await?; let seed = live.unwrap_or_else(|| placeholder_record(ip, user_agent)); + self.assert_within_absolute_lifetime(&seed)?; let new_record = platform_identity_record(&seed, ip, user_agent); let rotation = SessionRotation { @@ -703,6 +711,36 @@ impl TokenManagerService { support.store.del_temp(&jti_hash(jti)).await } + /// Refuse a rotation once the login it descends from has outlived the absolute cap. + /// + /// `refresh_expires_in_days` bounds a single refresh token, not a session: a client + /// rotating every fifteen minutes renews that lifetime forever, so without this a session + /// established once never has to be established again. The cap measures from the + /// **family's** birth, which is carried unchanged through the lineage. + /// + /// A session with no birth time predates the field and is not capped — it ages out under + /// the refresh lifetime like any other. A cap of `0` disables the check. + /// + /// # Errors + /// + /// Returns [`AuthError::RefreshTokenInvalid`] once the cap is passed. The caller cannot + /// distinguish it from any other invalid refresh, which is deliberate: the remedy is the + /// same, and a distinct code would tell whoever holds the token how old the session is. + fn assert_within_absolute_lifetime(&self, record: &SessionRecord) -> Result<(), AuthError> { + if self.absolute_lifetime_secs == 0 { + return Ok(()); + } + let Some(born_at) = record.family_created_at else { + return Ok(()); + }; + let age = now_offset() - born_at; + if age.whole_seconds().unsigned_abs() > self.absolute_lifetime_secs && age.is_positive() { + tracing::warn!("rotation refused: session outlived the absolute lifetime cap"); + return Err(AuthError::RefreshTokenInvalid); + } + Ok(()) + } + /// 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 @@ -764,6 +802,9 @@ fn identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> SessionR // 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(), + // The birth time is inherited too — measuring from this record's own `created_at` + // would reset the clock on every rotation and make the cap unreachable. + family_created_at: seed.family_created_at, } } @@ -784,6 +825,7 @@ fn platform_identity_record(seed: &SessionRecord, ip: &str, user_agent: &str) -> mfa_enabled: seed.mfa_enabled, // The platform rotation inherits the seed's family unchanged (section 12.5.2). family_id: seed.family_id.clone(), + family_created_at: seed.family_created_at, } } @@ -802,8 +844,9 @@ fn placeholder_record(ip: &str, user_agent: &str) -> SessionRecord { created_at: now_offset(), mfa_enabled: false, // The placeholder is never stored (an absent live token yields only Grace/Reused/Invalid), - // so it carries no family. + // so it carries no family and no birth time. family_id: String::new(), + family_created_at: None, } } @@ -824,6 +867,8 @@ mod tests { Duration::from_secs(900), 7, Duration::from_secs(30), + // No absolute cap in the default fixture; the cap has its own tests. + 0, ) } @@ -1364,6 +1409,7 @@ mod tests { Duration::from_secs(900), 7, Duration::from_secs(30), + 0, ) .with_mfa_support(support) } @@ -1473,3 +1519,147 @@ mod tests { assert!(matches!(bf.is_locked(&disable_id, 5).await, Ok(true))); } } + +#[cfg(test)] +mod absolute_lifetime_tests { + use super::*; + use crate::testing::InMemoryStores; + use time::Duration as TimeDuration; + + /// A manager with a 30-day absolute cap. + fn capped(store: Arc) -> TokenManagerService { + TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + store, + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 30, + ) + } + + /// A session record born `days_ago`. + fn record_born(days_ago: i64) -> SessionRecord { + SessionRecord { + user_id: "u1".to_owned(), + tenant_id: Some("t1".to_owned()), + role: "MEMBER".to_owned(), + device: "Chrome".to_owned(), + ip: "203.0.113.4".to_owned(), + created_at: now_offset(), + mfa_enabled: false, + family_id: "fam-1".to_owned(), + family_created_at: Some(now_offset() - TimeDuration::days(days_ago)), + } + } + + #[tokio::test] + async fn a_rotation_is_refused_once_the_family_outlives_the_cap() { + // `refresh_expires_in_days` bounds a single token, not a session: a client rotating + // every fifteen minutes renews that lifetime forever. The cap is what ends the lineage. + let store = Arc::new(InMemoryStores::new()); + let manager = capped(store.clone()); + let old = RawRefreshToken::generate(); + assert!( + store + .create_session( + SessionKind::Dashboard, + &old.redis_hash(), + &record_born(31), + 3600 + ) + .await + .is_ok() + ); + + let refused = manager + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await; + + assert!(matches!(refused, Err(AuthError::RefreshTokenInvalid))); + // Refused BEFORE the rotation ran, so nothing was consumed on the holder's behalf. + assert!(matches!( + store + .find_session(SessionKind::Dashboard, &old.redis_hash()) + .await, + Ok(Some(_)) + )); + } + + #[tokio::test] + async fn a_family_inside_the_cap_still_rotates() { + // The boundary matters: an off-by-one here signs every user out a day early. + let store = Arc::new(InMemoryStores::new()); + let manager = capped(store.clone()); + let old = RawRefreshToken::generate(); + assert!( + store + .create_session( + SessionKind::Dashboard, + &old.redis_hash(), + &record_born(29), + 3600 + ) + .await + .is_ok() + ); + + assert!( + manager + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await + .is_ok() + ); + } + + #[tokio::test] + async fn a_record_with_no_birth_time_and_a_zero_cap_both_rotate() { + // A record written before the field predates the mechanism and must not be ended by + // it; a zero cap disables the check outright. Both are the "not capped" answer. + let store = Arc::new(InMemoryStores::new()); + let legacy = SessionRecord { + family_created_at: None, + ..record_born(365) + }; + let old = RawRefreshToken::generate(); + assert!( + store + .create_session(SessionKind::Dashboard, &old.redis_hash(), &legacy, 3600) + .await + .is_ok() + ); + assert!( + capped(store.clone()) + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await + .is_ok() + ); + + let uncapped = TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + store.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ); + let ancient = RawRefreshToken::generate(); + assert!( + store + .create_session( + SessionKind::Dashboard, + &ancient.redis_hash(), + &record_born(365), + 3600 + ) + .await + .is_ok() + ); + assert!( + uncapped + .reissue_tokens(ancient.expose_secret(), "203.0.113.4", "Chrome") + .await + .is_ok() + ); + } +} diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index c1dc11f..39b2e61 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1083,6 +1083,7 @@ mod tests { created_at: OffsetDateTime::UNIX_EPOCH, mfa_enabled: false, family_id: family.to_owned(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), } } diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 8472fae..85a1b79 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -86,6 +86,61 @@ pub struct SessionRecord { /// 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, + /// When the **family** was born — the moment of the login this session descends from. + /// + /// Distinct from [`SessionRecord::created_at`], which is this session's own creation and is + /// reset on every rotation. Carried unchanged through the lineage so the absolute-lifetime + /// cap has something to measure: without it, a client rotating every fifteen minutes renews + /// its lifetime forever and a session established once never has to be established again. + /// + /// Serialized as an ISO-8601 string alongside `family_id`, and omitted with it on a legacy + /// record — such a session is simply not capped. + #[serde( + default, + with = "optional_rfc3339", + skip_serializing_if = "Option::is_none" + )] + pub family_created_at: Option, +} + +/// Serde adapter for an optional RFC 3339 instant, so a legacy record with no family birth +/// time round-trips as `None` rather than failing the whole record. +pub mod optional_rfc3339 { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use time::OffsetDateTime; + use time::format_description::well_known::Rfc3339; + + /// Write the instant as an RFC 3339 string, or nothing when absent. + /// + /// # Errors + /// + /// Propagates whatever the serializer reports, or a formatting failure. + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(instant) => instant + .format(&Rfc3339) + .map_err(serde::ser::Error::custom)? + .serialize(serializer), + None => serializer.serialize_none(), + } + } + + /// Read an RFC 3339 string back, treating an absent field as `None`. + /// + /// # Errors + /// + /// Returns a deserialization error when the field is present but not RFC 3339. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let raw = Option::::deserialize(deserializer)?; + raw.map(|value| OffsetDateTime::parse(&value, &Rfc3339).map_err(serde::de::Error::custom)) + .transpose() + } } /// Serde adapter carrying an [`OffsetDateTime`] as a **Unix-millisecond number**. @@ -639,7 +694,44 @@ mod tests { created_at: OffsetDateTime::UNIX_EPOCH, mfa_enabled: false, family_id: "fam-1".into(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), + } + } + + #[test] + fn the_optional_birth_time_adapter_round_trips_both_arms() { + // On a `SessionRecord` the field is skipped when absent, so the `None` arm of the + // serializer is unreachable there. It is still the adapter's contract, and a caller + // that uses it without `skip_serializing_if` must get `null` rather than a panic — + // this pins both directions independently of how the record happens to use it. + #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] + struct Wrapper { + #[serde(with = "optional_rfc3339")] + at: Option, } + + let absent = Wrapper { at: None }; + let json = serde_json::to_string(&absent).unwrap_or_default(); + assert_eq!(json, r#"{"at":null}"#); + assert!(matches!( + serde_json::from_str::(&json), + Ok(back) if back == absent + )); + + let present = Wrapper { + at: Some(OffsetDateTime::UNIX_EPOCH), + }; + let json = serde_json::to_string(&present).unwrap_or_default(); + assert_eq!(json, r#"{"at":"1970-01-01T00:00:00Z"}"#); + assert!(matches!( + serde_json::from_str::(&json), + Ok(back) if back == present + )); + + // A present-but-malformed value is an error, not a silent `None`: a record whose birth + // time cannot be read is a record whose cap cannot be judged, and quietly dropping it + // would uncap the session. + assert!(serde_json::from_str::(r#"{"at":"not-a-date"}"#).is_err()); } #[test] @@ -696,6 +788,7 @@ mod tests { // record with no `familyId` key deserializes back to an empty family. let legacy = SessionRecord { family_id: String::new(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), ..session_record() }; let legacy_json = serde_json::to_string(&legacy)?; diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 79edc48..0329a53 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -732,6 +732,7 @@ mod tests { created_at: OffsetDateTime::UNIX_EPOCH, mfa_enabled: false, family_id: "fam-1".to_owned(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), } } diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index 98ac9aa..d78c58c 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -44,6 +44,7 @@ fn record(user: &str) -> SessionRecord { created_at: OffsetDateTime::UNIX_EPOCH, mfa_enabled: false, family_id: format!("fam-{user}"), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), } } @@ -358,6 +359,7 @@ async fn a_legacy_session_without_a_family_plants_no_family_keys() { // skip every family write: no `fam:` index and no `cf:` consumed marker are ever planted. let legacy = SessionRecord { family_id: String::new(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), ..record("lu") }; assert!( @@ -371,6 +373,7 @@ async fn a_legacy_session_without_a_family_plants_no_family_keys() { let rot = SessionRotation { new_record: SessionRecord { family_id: String::new(), + family_created_at: Some(OffsetDateTime::UNIX_EPOCH), ..record("lu") }, ..rotation("l1", "l2", "lu") From 49dfa9821aeb71a52a15cdee527ed9d4dfc3fc5e Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:49:47 -0300 Subject: [PATCH 021/122] feat(core): carry the provider's email-verification fact on the OAuth profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_with_oauth` was called with `email_verified: Some(true)` unconditionally, and `OAuthProfile` had no field for a provider to say otherwise. No bug today — the only bundled provider is Google's, which refuses an unverified profile before building one — but the first third-party provider written against that contract would have created a verified account from an address nobody proved they owned. That account belongs to whoever controls the OAuth account, not to whoever controls the mailbox, and marking it verified makes the consumer's "this email is proven" invariant false from the first login — which is how an account is taken over by registering with someone else's address at a provider that does not check. The profile now carries `email_verified` and the engine passes it through. Google's provider reports `true` unconditionally, which is honest precisely because its own guard already refused everything else. `MockOAuthProvider` gains an `unverified` constructor so the path can be driven end to end — the bundled provider structurally cannot produce it. Parity with the nest-auth change; the field cannot be defaulted to `true` without reintroducing exactly the assumption this removes. --- .../bymax-auth-core/src/providers/google.rs | 2 + crates/bymax-auth-core/src/services/oauth.rs | 46 ++++++++++++++++++- crates/bymax-auth-core/src/testing/mod.rs | 19 +++++++- crates/bymax-auth-core/src/traits/hooks.rs | 1 + crates/bymax-auth-core/src/traits/oauth.rs | 13 +++++- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/bymax-auth-core/src/providers/google.rs b/crates/bymax-auth-core/src/providers/google.rs index 6b412bb..03e0583 100644 --- a/crates/bymax-auth-core/src/providers/google.rs +++ b/crates/bymax-auth-core/src/providers/google.rs @@ -165,6 +165,8 @@ impl OAuthProvider for GoogleOAuthProvider { provider: "google".to_owned(), provider_id: parsed.id, email: parsed.email, + // Unconditionally true only because the guard above already refused everything else. + email_verified: true, name: parsed.name, avatar: parsed.picture, }) diff --git a/crates/bymax-auth-core/src/services/oauth.rs b/crates/bymax-auth-core/src/services/oauth.rs index 1b09c18..9bad293 100644 --- a/crates/bymax-auth-core/src/services/oauth.rs +++ b/crates/bymax-auth-core/src/services/oauth.rs @@ -273,7 +273,10 @@ impl AuthEngine { role: None, status: None, tenant_id: tenant_id.to_owned(), - email_verified: Some(true), + // What the provider actually asserted, not a convenient constant. An + // account created from an unverified address belongs to whoever controls + // the OAuth account, not to whoever controls the mailbox. + email_verified: Some(profile.email_verified), oauth_provider: provider_name.to_owned(), oauth_provider_id: profile.provider_id.clone(), }; @@ -1221,6 +1224,46 @@ mod tests { )); } + #[tokio::test] + async fn a_provider_that_did_not_verify_the_email_creates_an_unverified_account() { + // The account created from an unverified address belongs to whoever controls the OAuth + // account, not to whoever controls the mailbox. Marking it verified would make the + // consumer's "this email is proven" invariant false from the first login — which is how + // an account is taken over by registering with someone else's address at a provider + // that does not check. The bundled Google provider refuses such a profile outright, so + // this is driven through a provider that reports the address as unverified. + let users = Arc::new(InMemoryUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let mut cfg = base_config(); + cfg.controllers.oauth = true; + let engine = AuthEngine::builder() + .config(cfg) + .environment(Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores.clone()) + .hooks(Arc::new(DecisionHook(OAuthLoginResult::Create))) + .oauth_provider(Arc::new(crate::testing::MockOAuthProvider::unverified( + "google", + ))) + .oauth_state_store(stores.clone()) + .build(); + let Ok(engine) = engine else { return }; + + let url = engine.oauth_initiate("google", "t1").await; + let Ok(url) = url else { return }; + let state = extract_query_param(&url, "state").unwrap_or_default(); + let outcome = engine + .oauth_callback("google", "auth-code", &state, &ctx()) + .await; + + assert!(matches!(&outcome, Ok(OAuthOutcome::Authenticated(_)))); + let stored = users.find_by_email("mock@example.com", "t1").await; + assert!( + matches!(stored, Ok(Some(ref user)) if !user.email_verified), + "an unverified provider email must not create a verified account" + ); + } + #[test] fn name_or_local_part_prefers_the_profile_name() { // The profile name wins when present; otherwise the email local-part is used. @@ -1228,6 +1271,7 @@ mod tests { provider: "google".to_owned(), provider_id: "1".to_owned(), email: "x@example.com".to_owned(), + email_verified: true, name: Some("Real Name".to_owned()), avatar: None, }; diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 39b2e61..67b37d7 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -838,13 +838,27 @@ impl HttpClient for MockHttpClient { #[derive(Debug, Clone)] pub struct MockOAuthProvider { name: String, + email_verified: bool, } impl MockOAuthProvider { - /// A provider registered under `name`. + /// A provider registered under `name`, reporting a verified email. #[must_use] pub fn new(name: impl Into) -> Self { - Self { name: name.into() } + Self { + name: name.into(), + email_verified: true, + } + } + + /// The same provider, but reporting an email it has **not** verified — the shape of a + /// provider like GitHub, which hands back unverified addresses. + #[must_use] + pub fn unverified(name: impl Into) -> Self { + Self { + name: name.into(), + email_verified: false, + } } } @@ -883,6 +897,7 @@ impl OAuthProvider for MockOAuthProvider { provider: self.name.clone(), provider_id: "mock-123".to_owned(), email: "mock@example.com".to_owned(), + email_verified: self.email_verified, name: Some("Mock User".to_owned()), avatar: None, }) diff --git a/crates/bymax-auth-core/src/traits/hooks.rs b/crates/bymax-auth-core/src/traits/hooks.rs index 55f6c3a..7c3d157 100644 --- a/crates/bymax-auth-core/src/traits/hooks.rs +++ b/crates/bymax-auth-core/src/traits/hooks.rs @@ -342,6 +342,7 @@ mod tests { provider: "google".into(), provider_id: "google-1".into(), email: "e@x.io".into(), + email_verified: true, name: Some("E".into()), avatar: None, } diff --git a/crates/bymax-auth-core/src/traits/oauth.rs b/crates/bymax-auth-core/src/traits/oauth.rs index f088729..6436e9c 100644 --- a/crates/bymax-auth-core/src/traits/oauth.rs +++ b/crates/bymax-auth-core/src/traits/oauth.rs @@ -85,8 +85,19 @@ pub struct OAuthProfile { pub provider: String, /// The opaque, stable user id within the provider. pub provider_id: String, - /// The verified email address. + /// The email address the provider returned. pub email: String, + /// Whether the provider asserts it has verified that address. + /// + /// A provider MUST report what it actually said, not what would be convenient. The account + /// created from an unverified address belongs to whoever controls the OAuth account, not to + /// whoever controls the mailbox — and if the engine marks it verified anyway, the consumer's + /// "this email is proven" invariant is false from the first login. + /// + /// The bundled Google provider refuses an unverified profile outright, so it always reports + /// `true`. Providers that hand back unverified addresses (GitHub, among others) must report + /// `false` and leave the consumer's own verification flow to do its job. + pub email_verified: bool, /// Display name; providers that omit it leave `None`, and the engine derives a /// fallback from the email local-part on account creation. pub name: Option, From a990af0bbc7bc6333a2e6920095540be2874723b Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:51:30 -0300 Subject: [PATCH 022/122] docs(readme): record the security work shipped in this cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse detection, the token epoch, the absolute session lifetime, the cross-site check, the breach checker and the contract-pinned rate limits — all of which have a nest-auth counterpart, which is the point. --- README.md | 6 ++++++ crates/bymax-auth-axum/src/extractors/mod.rs | 6 +----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 29fe8da..b3a7922 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,12 @@ pnpm add @bymax-one/rust-auth - ✅ **Constant-Time Comparisons** — Every secret/token/OTP/recovery-code compare goes through `subtle` — never `==` on secret bytes - ✅ **JWT Revocation** — Instant access-token revocation via a Redis `jti` blacklist - ✅ **Anti-Enumeration** — Identical status, body, and timing for known vs. unknown accounts, with an always-run sentinel hash +- ✅ **Refresh-Token Reuse Detection** — Replaying a consumed token revokes that login's whole lineage, and only that lineage +- ✅ **Bulk Access-Token Revocation** — A password reset advances a per-user token epoch, invalidating every outstanding access token in one write +- ✅ **Absolute Session Lifetime** — Optional hard cap on how long one login can be extended by rotation +- ✅ **Cross-Site Request Refusal** — Cookie-authenticated writes from an untrusted origin are rejected (matters under `SameSite=None`) +- ✅ **Breached-Password Refusal** — Optional Have I Been Pwned check by k-anonymity range, over the crate's own `HttpClient` seam +- ✅ **Per-Route Rate Limiting** — A governor layer per route, values pinned to the shared cross-implementation contract ### 🏢 Multi-Tenant & Platform diff --git a/crates/bymax-auth-axum/src/extractors/mod.rs b/crates/bymax-auth-axum/src/extractors/mod.rs index e5ba642..f9aa243 100644 --- a/crates/bymax-auth-axum/src/extractors/mod.rs +++ b/crates/bymax-auth-axum/src/extractors/mod.rs @@ -68,11 +68,7 @@ fn access_cookie(parts: &Parts, config: &ResolvedConfig) -> Option { /// (`Cookie`), `Authorization: Bearer` only (`Bearer`), or cookie-first-else-header (`Both`). /// Returns `None` when no token is present on the accepted channel(s). pub(crate) fn source_access_token(parts: &Parts, config: &ResolvedConfig) -> Option { - match config.delivery { - TokenDelivery::Cookie => access_cookie(parts, config), - TokenDelivery::Bearer => bearer_from_header(parts), - TokenDelivery::Both => access_cookie(parts, config).or_else(|| bearer_from_header(parts)), - } + None /* ~ changed by cargo-mutants ~ */ } /// Source the **platform** access token. Unlike [`source_access_token`] this ignores the From c6c9e7da8023996456e049f0285c65a0c09ce646 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:52:54 -0300 Subject: [PATCH 023/122] fix(axum): restore source_access_token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit swept in a live `cargo mutants --in-place` mutation: the token source had been replaced with `None`, which would have made every cookie-and-bearer read return nothing. Restores the real match. Lesson recorded in the run itself — `--in-place` mutates the working tree, so nothing may be staged while it runs. --- crates/bymax-auth-axum/src/extractors/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/bymax-auth-axum/src/extractors/mod.rs b/crates/bymax-auth-axum/src/extractors/mod.rs index f9aa243..e5ba642 100644 --- a/crates/bymax-auth-axum/src/extractors/mod.rs +++ b/crates/bymax-auth-axum/src/extractors/mod.rs @@ -68,7 +68,11 @@ fn access_cookie(parts: &Parts, config: &ResolvedConfig) -> Option { /// (`Cookie`), `Authorization: Bearer` only (`Bearer`), or cookie-first-else-header (`Both`). /// Returns `None` when no token is present on the accepted channel(s). pub(crate) fn source_access_token(parts: &Parts, config: &ResolvedConfig) -> Option { - None /* ~ changed by cargo-mutants ~ */ + match config.delivery { + TokenDelivery::Cookie => access_cookie(parts, config), + TokenDelivery::Bearer => bearer_from_header(parts), + TokenDelivery::Both => access_cookie(parts, config).or_else(|| bearer_from_header(parts)), + } } /// Source the **platform** access token. Unlike [`source_access_token`] this ignores the From a905c0cbb775480313b999c9cefe2602a3a19123 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:21:00 -0300 Subject: [PATCH 024/122] test(axum): close the mutants the adapter tier could not kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six survivors from `cargo mutants`, each a real gap rather than an equivalent: - `clear_session` could be replaced by a no-op: the logout test asserted no cookie carried a value, which an absent `Set-Cookie` satisfies just as well. It now asserts each cookie is expired back on the Path it was set with — a clearing header on the wrong path leaves a ghost cookie the browser keeps sending. - `clamp_secs` and `refresh_max_age_secs` could return any constant: no test read a cookie's Max-Age. A session cookie instead would log every user out when they closed the tab. - The refresh-name comparison in `carries_auth_cookie` could be inverted unnoticed: every cross-site test carried the access cookie, so the second clause was never decisive. Covered now from both sides — the refresh cookie alone is a credential, the session-signal cookie alone is not. - `forgot_password` and `resend_otp` could be replaced by an empty 200: the anti- enumeration test read only the status, and the two-step flow *skipped* itself when no OTP was minted. The uniform body is asserted, and a missing OTP now fails. `DEFAULT_MAX_BODY_BYTES` was asserted against itself, which accepts whatever the expression produces; it is pinned to the literal. --- crates/bymax-auth-axum/src/state.rs | 4 ++ crates/bymax-auth-axum/tests/adapter.rs | 63 +++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/crates/bymax-auth-axum/src/state.rs b/crates/bymax-auth-axum/src/state.rs index 6fe90c9..febb295 100644 --- a/crates/bymax-auth-axum/src/state.rs +++ b/crates/bymax-auth-axum/src/state.rs @@ -212,7 +212,11 @@ mod tests { // The config default carries the canonical prefix/body-limit and the safe IP source. let config = AxumAuthConfig::default(); assert_eq!(config.route_prefix, "auth"); + // Pinned to the literal, not to the constant: a body cap is a security bound, and + // asserting it against itself would accept any value the expression happened to + // produce. assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES); + assert_eq!(DEFAULT_MAX_BODY_BYTES, 1_048_576); assert_eq!(config.client_ip_source, ClientIpSource::PeerAddr); assert!(config.cors.is_none()); assert_eq!(ClientIpSource::default(), ClientIpSource::PeerAddr); diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 2249855..96c3ff9 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -138,6 +138,14 @@ async fn register_login_me_logout_cookie_mode() { let signal = reg.cookie("has_session").unwrap_or_default(); assert!(!signal.contains("HttpOnly")); + // Max-Age is what makes each cookie outlive the response: the access cookie tracks the + // access-token lifetime (15 min), the refresh and signal cookies the refresh lifetime + // (7 days). A session cookie instead — or one capped at a second — would log every user + // out when they closed the tab. + assert!(access.contains("Max-Age=900"), "access: {access}"); + assert!(refresh.contains("Max-Age=604800"), "refresh: {refresh}"); + assert!(signal.contains("Max-Age=604800"), "signal: {signal}"); + // `me` with the access cookie returns the safe user as the TOP-LEVEL body — no // `{ user: … }` wrapper (nest-auth's `AuthController.me` returns the object itself, and // its published client decodes it unwrapped). @@ -152,13 +160,34 @@ async fn register_login_me_logout_cookie_mode() { // Logout clears the cookies (a cleared cookie has an empty value / expiry). let refresh_value = reg.cookie_value("refresh_token").unwrap_or_default(); + // The browser sends all three cookies back (the signal one is Path=/ and merely + // JS-readable, not omitted), so the logout is exercised with the jar it actually sees. let logout = Req::post("/auth/logout") .cookie("access_token", &access_value) .cookie("refresh_token", &refresh_value) + .cookie("has_session", "1") .send(&app) .await; assert_eq!(logout.status, StatusCode::NO_CONTENT); - assert!(!logout.has_cookie_value("access_token")); + // Every cookie the login planted is expired back, each on the Path it was set with — a + // clearing header on the wrong path leaves a ghost cookie the browser keeps sending. The + // absence of a `Set-Cookie` is not a logout: the browser would keep the credential. + for (name, path) in [ + ("access_token", "Path=/"), + ("refresh_token", "Path=/auth"), + ("has_session", "Path=/"), + ] { + let cleared = logout.cookie(name).unwrap_or_default(); + assert!( + cleared.contains("Max-Age=0"), + "{name} not expired: {cleared}" + ); + assert!( + cleared.contains(path), + "{name} cleared on the wrong path: {cleared}" + ); + assert!(!logout.has_cookie_value(name)); + } } #[tokio::test] @@ -509,6 +538,9 @@ async fn password_reset_endpoints_are_anti_enumerating() { .send(&app) .await; assert_eq!(forgot.status, StatusCode::OK); + // The uniform body is half the anti-enumeration contract: an empty response would be as + // distinguishable to a prober as a 404. + assert_eq!(forgot.json(), serde_json::json!({})); // resend-otp likewise. let resend = Req::post("/auth/password/resend-otp") @@ -516,6 +548,7 @@ async fn password_reset_endpoints_are_anti_enumerating() { .send(&app) .await; assert_eq!(resend.status, StatusCode::OK); + assert_eq!(resend.json(), serde_json::json!({})); // verify-otp with a bogus code is an OTP error (the record is absent). let verify = Req::post("/auth/password/verify-otp") @@ -1640,10 +1673,11 @@ async fn password_reset_otp_two_step_success_flow() { .await; // Recover the OTP from the in-memory store (the engine derives the identifier internally). - let Some(otp) = common::peek_otp(&h, OtpPurpose::PasswordReset, "pw@e.com") else { - // The reset flow may use a link token rather than an OTP depending on config; skip. - return; - }; + // Asserted rather than skipped: the OTP existing is what proves the route reached the + // engine at all, and a skip here would pass just as happily against a handler that did + // nothing but answer 200. + let otp = common::peek_otp(&h, OtpPurpose::PasswordReset, "pw@e.com").unwrap_or_default(); + assert!(!otp.is_empty(), "forgot-password minted no reset OTP"); let verify = Req::post("/auth/password/verify-otp") .json(serde_json::json!({ "email": "pw@e.com", "otp": otp, "tenantId": TENANT })) @@ -2422,6 +2456,15 @@ async fn a_cookie_authenticated_write_from_an_untrusted_origin_is_refused() { refused.json()["error"]["code"], serde_json::json!("auth.untrusted_origin") ); + + // The refresh cookie is a credential in its own right — it mints access tokens — so a + // request carrying only that one is as much a target as one carrying the access cookie. + let refresh_only = Req::post("/auth/logout") + .cookie("refresh_token", &"r".repeat(64)) + .header(header::ORIGIN, "https://evil.example.com") + .send(&app) + .await; + assert_eq!(refresh_only.status, StatusCode::FORBIDDEN); } #[tokio::test] @@ -2464,6 +2507,16 @@ async fn the_cross_site_check_admits_what_it_should() { .await; assert_ne!(bearer.status, StatusCode::FORBIDDEN); + // The session-signal cookie is readable by JavaScript by design and authenticates + // nothing, so a request carrying only that one has no ambient credential for an attacker + // page to spend — counting it would reject cross-site calls that cannot do any harm. + let signal_only = Req::post("/auth/logout") + .cookie("has_session", "1") + .header(header::ORIGIN, "https://evil.example.com") + .send(&app) + .await; + assert_ne!(signal_only.status, StatusCode::FORBIDDEN); + // A read changes nothing and is never a target. let read = Req::get("/auth/me") .cookie( From 104c889060c2102fc91d56a6d6fdc39277703d39 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:25:51 -0300 Subject: [PATCH 025/122] test(core): kill the uuid and platform-service mutants, record the equivalents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v4 layout test drew a single UUID, so a broken mask left the version and variant nibbles correct often enough to pass by luck — two of the four bit-twiddling mutants survived on a lucky draw. It draws 64 now, which makes the assertion deterministic. `AuthEngine::platform_auth` could return `None` unnoticed: every platform test recovers the service with `let Some(..) else { return }`, so the whole tier skipped itself instead of failing. It is asserted from an engine built the same way the disabled-domain test builds one, where nothing can skip. Three mutants are genuinely equivalent and are recorded in `mutants.toml` with the reason each cannot be killed — the XOR/OR pair on already-masked bits, the builder whose `Default` calls the body being replaced, and the `"inactive"` arm the wildcard already answers identically. The patterns stay narrow so the killable mutants of the same functions are still generated. --- crates/bymax-auth-core/src/services/mod.rs | 37 +++++++++++-------- .../bymax-auth-core/src/services/platform.rs | 17 +++++++++ mutants.toml | 22 +++++++++-- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/crates/bymax-auth-core/src/services/mod.rs b/crates/bymax-auth-core/src/services/mod.rs index ba02d0b..565b643 100644 --- a/crates/bymax-auth-core/src/services/mod.rs +++ b/crates/bymax-auth-core/src/services/mod.rs @@ -190,22 +190,27 @@ mod tests { fn new_uuid_v4_has_the_canonical_version_4_layout() { // 8-4-4-4-12 hyphenation, the version nibble pinned to '4', and the variant nibble // in {8,9,a,b} — the structural proof a minted value is a v4 UUID (§24 invariant 2). - let id = new_uuid_v4(); - assert_eq!(id.len(), 36); - let bytes = id.as_bytes(); - assert_eq!(bytes[8], b'-'); - assert_eq!(bytes[13], b'-'); - assert_eq!(bytes[18], b'-'); - assert_eq!(bytes[23], b'-'); - assert_eq!(bytes[14], b'4', "version nibble must be 4"); - assert!( - matches!(bytes[19], b'8' | b'9' | b'a' | b'b'), - "variant nibble" - ); - assert!( - id.bytes() - .all(|c| c == b'-' || (c.is_ascii_hexdigit() && !c.is_ascii_uppercase())) - ); + // Drawn repeatedly rather than once: the version and variant nibbles are forced on + // top of CSPRNG bytes, so a masking bug leaves them correct for a good fraction of + // draws. A single sample would pass by luck. + for _ in 0..64 { + let id = new_uuid_v4(); + assert_eq!(id.len(), 36); + let bytes = id.as_bytes(); + assert_eq!(bytes[8], b'-'); + assert_eq!(bytes[13], b'-'); + assert_eq!(bytes[18], b'-'); + assert_eq!(bytes[23], b'-'); + assert_eq!(bytes[14], b'4', "version nibble must be 4: {id}"); + assert!( + matches!(bytes[19], b'8' | b'9' | b'a' | b'b'), + "variant nibble: {id}" + ); + assert!( + id.bytes() + .all(|c| c == b'-' || (c.is_ascii_hexdigit() && !c.is_ascii_uppercase())) + ); + } // Two successive draws differ (CSPRNG). assert_ne!(new_uuid_v4(), new_uuid_v4()); } diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index e7d86c8..1e382c1 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -527,6 +527,23 @@ mod tests { assert!(matches!(&built, Ok(engine) if engine.platform_auth().is_none())); } + #[test] + fn engine_exposes_the_platform_service_when_the_domain_is_enabled() { + // The inverse, built the same way so nothing can skip: every platform test below + // recovers the service with `let Some(..) else { return }`, so an engine that stopped + // handing it back would quietly skip the whole platform tier rather than fail it. + let admins = Arc::new(InMemoryPlatformUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let built = AuthEngine::builder() + .config(platform_config()) + .environment(Environment::Test) + .user_repository(Arc::new(crate::testing::InMemoryUserRepository::new())) + .platform_user_repository(admins) + .redis_stores(stores) + .build(); + assert!(matches!(&built, Ok(engine) if engine.platform_auth().is_some())); + } + #[tokio::test] async fn login_issues_a_platform_session_with_no_tenant() { // A correct password for an active admin returns a full platform session; me/refresh diff --git a/mutants.toml b/mutants.toml index 1d26f79..9482602 100644 --- a/mutants.toml +++ b/mutants.toml @@ -32,6 +32,22 @@ exclude_globs = [ # against exercises the same paths the mutated code lives on. additional_cargo_args = ["--all-features"] -# Documented equivalent mutants (mutants that cannot be killed because the change is -# behaviour-preserving) are listed under [[skip]] entries with a `reason`. None are -# recorded yet — every surviving mutant must first be triaged before being skipped. +# Documented equivalent mutants: mutants no test can kill because the change is +# behaviour-preserving. Each entry names the reason it is equivalent, and the pattern is +# kept narrow enough that other mutants of the same function are still generated — a +# blanket `#[mutants::skip]` on the function would hide the killable ones alongside it. +# +# 1. `replace | with ^ in new_uuid_v4` — the version and variant bits are set on a byte +# whose target bits were just cleared (`b[6] & 0x0f`, `b[8] & 0x3f`), so XOR and OR +# write the same byte. The neighbouring `&` mutants are NOT equivalent and are killed +# by drawing the UUID repeatedly in the layout test. +# 2. `replace AuthEngine::builder -> AuthEngineBuilder with Default::default()` — the +# `Default` impl for the builder calls `Self::new()`, which is the body being replaced. +# 3. `delete match arm "inactive" in assert_not_blocked` — the wildcard arm returns +# `AccountInactive`, the same error the deleted arm returns, so a host-defined status +# and `"inactive"` are indistinguishable by design. +exclude_re = [ + 'replace \| with \^ in new_uuid_v4', + 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', + 'delete match arm "inactive" in assert_not_blocked', +] From ec5ab537bead7539255a58a70d38d6e5114d124d Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:26:54 -0300 Subject: [PATCH 026/122] test(core): read back the in-memory platform repository's updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake answered `Ok(())` and the test asserted only that — so a repository that stored nothing would have let every platform test built on it pass while asserting nothing. Each update is now read back through `find_by_id`. --- crates/bymax-auth-core/src/testing/mod.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 67b37d7..ddaa95c 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1066,6 +1066,15 @@ mod tests { ); assert!(repo.update_password("p1", "$scrypt$y").await.is_ok()); assert!(repo.update_status("p1", "SUSPENDED").await.is_ok()); + // Read back rather than trusting the `Ok`: a fake that answers `Ok(())` and stores + // nothing lets every test built on it pass while asserting nothing. + let stored = repo.find_by_id("p1").await; + assert!( + matches!(&stored, Ok(Some(u)) if u.last_login_at.is_some() + && u.status == "SUSPENDED" + && u.password_hash == "$scrypt$y" + && u.mfa_enabled) + ); // Absent-id no-ops. assert!(repo.update_last_login("missing").await.is_ok()); assert!( From 938665034803f84869cdd48d378a394fe089da76 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:29:46 -0300 Subject: [PATCH 027/122] test(core): pin the in-memory doubles' keying and drop a dead guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `token_key` could be replaced by a constant without failing anything: every test stored and consumed one token at a time, so a colliding key round-trips perfectly. Two live tokens are now asserted not to collide. The fake's `remaining_lockout_secs` guarded on a counter being positive, which it always is — `record_failure` inserts and increments under one lock and `reset` removes the entry — so the entry's existence was already the whole condition. --- crates/bymax-auth-core/src/testing/mod.rs | 30 ++++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index ddaa95c..7e011d9 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -618,11 +618,13 @@ impl BruteForceStore for InMemoryStores { } /// Returns the stored window while a counter exists (mirroring the real store, whose - /// counter key carries the window TTL from the first failure), else `0`. + /// counter key carries the window TTL from the first failure), else `0`. A stored counter + /// is always at least 1 — `record_failure` inserts and increments under one lock, and + /// `reset` removes the entry outright — so the entry's existence is the whole condition. async fn remaining_lockout_secs(&self, identifier: &str) -> Result { Ok(lock(&self.brute_force) .get(identifier) - .map_or(0, |(count, window)| if *count > 0 { *window } else { 0 })) + .map_or(0, |(_, window)| *window)) } } @@ -1069,12 +1071,10 @@ mod tests { // Read back rather than trusting the `Ok`: a fake that answers `Ok(())` and stores // nothing lets every test built on it pass while asserting nothing. let stored = repo.find_by_id("p1").await; - assert!( - matches!(&stored, Ok(Some(u)) if u.last_login_at.is_some() + assert!(matches!(&stored, Ok(Some(u)) if u.last_login_at.is_some() && u.status == "SUSPENDED" && u.password_hash == "$scrypt$y" - && u.mfa_enabled) - ); + && u.mfa_enabled)); // Absent-id no-ops. assert!(repo.update_last_login("missing").await.is_ok()); assert!( @@ -1343,6 +1343,24 @@ mod tests { Ok(Some(c)) if c.email == "u@example.com" )); assert!(matches!(store.consume_verified("vtok").await, Ok(None))); + + // Two live tokens do not collide: the key is derived from the token, so consuming one + // leaves the other intact. A double that keyed everything the same way would round-trip + // a single token perfectly and quietly lose the second. + let other = ResetContext { + user_id: "u2".to_owned(), + ..context.clone() + }; + assert!(store.put_token("first", &context, 600).await.is_ok()); + assert!(store.put_token("second", &other, 600).await.is_ok()); + assert!(matches!( + store.consume_token("first").await, + Ok(Some(c)) if c.user_id == "u1" + )); + assert!(matches!( + store.consume_token("second").await, + Ok(Some(c)) if c.user_id == "u2" + )); } #[tokio::test] From 9ee9581eacd312f1f663d31f1637bc95ac2011f7 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:31:49 -0300 Subject: [PATCH 028/122] test(core): exercise the MFA store double's SET NX / GETDEL semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enrolment gate is built on exactly those two operations — the first writer wins the setup slot, the completion reads it away so only one caller can finish — and neither was asserted against the double. A double that swallowed the value or always reported "already there" would make that gate untestable while every test stayed green. --- crates/bymax-auth-core/src/testing/mod.rs | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 7e011d9..f7514cb 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1409,6 +1409,36 @@ mod tests { assert!(matches!(store.redeem(&ticket).await, Ok(None))); } + #[cfg(feature = "mfa")] + #[tokio::test] + async fn mfa_store_reproduces_set_nx_and_getdel() { + // The double stands in for `SET NX` and `GETDEL`, and the enrolment gate is built on + // exactly those two: the first writer wins the setup slot, and the completion reads it + // away so only one caller can finish. A double that swallowed the value or always + // reported "already there" would make that gate untestable. + use crate::traits::MfaStore; + let store = InMemoryStores::new(); + assert!(matches!(store.get_setup("uh").await, Ok(None))); + assert!(matches!( + store.put_setup_nx("uh", "enc-secret", 300).await, + Ok(true) + )); + assert!(matches!(store.get_setup("uh").await, Ok(Some(v)) if v == "enc-secret")); + // Second writer loses and does not overwrite. + assert!(matches!( + store.put_setup_nx("uh", "other", 300).await, + Ok(false) + )); + assert!(matches!(store.get_setup("uh").await, Ok(Some(v)) if v == "enc-secret")); + // GETDEL: the value comes back once, and the slot is free again. + assert!(matches!(store.take_setup("uh").await, Ok(Some(v)) if v == "enc-secret")); + assert!(matches!(store.take_setup("uh").await, Ok(None))); + assert!(matches!( + store.put_setup_nx("uh", "third", 300).await, + Ok(true) + )); + } + #[cfg(feature = "oauth")] #[tokio::test] async fn oauth_state_store_consumes_state_single_use() { From 779d1c3994e5ad51ecfd9b4e706ef9373e5d2263 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:44:06 -0300 Subject: [PATCH 029/122] test(core): pin the entropy floor and the resolved MFA key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secret-entropy rule was only exercised well away from its floor, so tightening the comparison to reject a secret *at* 3.5 bits/char would have gone unnoticed — the boundary secret is built to land on the constant exactly, with no rounding. `mfa_encryption_key` could return a fixed or zeroed key without failing anything: the MFA tests only ever round-trip through the same resolver, so every deployment's TOTP secrets sealed under one shared key would still decrypt. It is asserted byte for byte, and absent when no MFA is configured. --- crates/bymax-auth-core/src/config/validate.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 9dc868b..f99db53 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -636,6 +636,19 @@ mod tests { )); } + #[test] + fn admits_a_secret_whose_entropy_sits_exactly_on_the_floor() { + // The floor is inclusive: 3.5 bits/char is *acceptable*, not rejected. Built to land + // on the constant exactly — eight symbols twice (4 bits each) and four symbols four + // times (3 bits each) sum to 3.5 with no rounding — because only a secret at the + // boundary can tell `<` from `<=`, and rejecting one at the floor would refuse a + // configuration the documented rule admits. + let mut cfg = valid_config(); + cfg.jwt.secret = SecretString::from("aabbccddeeffgghhiiiijjjjkkkkllll".to_owned()); + assert!((shannon_entropy("aabbccddeeffgghhiiiijjjjkkkkllll") - 3.5).abs() < f64::EPSILON); + assert!(cfg.validate(Environment::Production).is_ok()); + } + #[test] fn rejects_low_entropy_secret() { let mut cfg = valid_config(); @@ -769,6 +782,25 @@ mod tests { } } + #[cfg(feature = "mfa")] + #[test] + fn resolves_the_configured_mfa_key_and_none_without_mfa() { + // The key that comes back is the configured one, byte for byte — the stored TOTP + // secrets are sealed with it, so a resolver that answered with a fixed or zeroed key + // would seal every deployment's secrets under the same one and none of the MFA tests + // would notice: they only ever round-trip through the same resolver. + let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); + let mut cfg = valid_config(); + cfg.mfa = Some(mfa_with_key(&key)); + let resolved = ResolvedConfig::new(cfg, Environment::Test, false); + let got = resolved.mfa_encryption_key(); + assert!(matches!(&got, Some(k) if **k == [7u8; 32])); + + // No MFA configured: no key at all, rather than a default one. + let plain = ResolvedConfig::new(valid_config(), Environment::Test, false); + assert!(plain.mfa_encryption_key().is_none()); + } + #[test] fn rejects_bad_mfa_key_and_empty_issuer() { // A 32-byte key, base64-encoded, is the accepted case. From 3da9b20fca4b7c3e318b2a3d4213f0c1f20fd92d Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:45:47 -0300 Subject: [PATCH 030/122] test(core): pin the hasher parameter floors at their boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scrypt cost factor and both argon2 floors were only exercised well below the limit, so tightening any of the three to reject a configuration sitting *exactly* on the documented minimum would have gone unnoticed — and that value is the one the error message tells an operator to use. The `active_algorithm == Scrypt` arm is recorded as untestable under the gate rather than killed: it lives behind `cfg(not(feature = "scrypt"))` and the sweep builds with --all-features, so the line is never compiled into the suite it is measured against. --- crates/bymax-auth-core/src/config/validate.rs | 14 ++++++++++++++ mutants.toml | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index f99db53..6d6c03d 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -737,6 +737,13 @@ mod tests { too_small.validate(Environment::Production), Err(ConfigError::ScryptCostFactor { got: 8_192 }) )); + + // The floor is inclusive: 16384 is the documented minimum, not the first rejected + // value. Only a config sitting exactly on it can tell the two apart, and refusing it + // would reject the very parameters the error message tells an operator to use. + let mut at_floor = valid_config(); + at_floor.password.scrypt.cost_factor = 16_384; + assert!(at_floor.validate(Environment::Production).is_ok()); } #[cfg(feature = "argon2")] @@ -756,6 +763,13 @@ mod tests { low_iter.validate(Environment::Production), Err(ConfigError::Argon2Iterations { got: 1 }) )); + + // Both floors are inclusive — a deployment configured at exactly the OWASP minimum + // is compliant, and rejecting it would contradict the error the rule raises. + let mut at_floor = valid_config(); + at_floor.password.argon2.memory_kib = 19_456; + at_floor.password.argon2.iterations = 2; + assert!(at_floor.validate(Environment::Production).is_ok()); } #[cfg(not(feature = "scrypt"))] diff --git a/mutants.toml b/mutants.toml index 9482602..9eb286e 100644 --- a/mutants.toml +++ b/mutants.toml @@ -46,8 +46,14 @@ additional_cargo_args = ["--all-features"] # 3. `delete match arm "inactive" in assert_not_blocked` — the wildcard arm returns # `AccountInactive`, the same error the deleted arm returns, so a host-defined status # and `"inactive"` are indistinguishable by design. +# 4. `replace == with != in validate_password` (the `active_algorithm == Scrypt` arm) — that +# arm lives behind `#[cfg(not(feature = "scrypt"))]`, and the gate builds with +# `--all-features`, so the mutated line is never compiled into the suite it is measured +# against. It is covered by the `not(feature = "scrypt")` test in the same module, which a +# default-feature run exercises. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', 'delete match arm "inactive" in assert_not_blocked', + 'replace == with != in validate_password', ] From 98732e519329118b7bd698a8245c6961b1e7e981 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:47:34 -0300 Subject: [PATCH 031/122] test(core): cover the Origin scheme grammar from both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheme grammar admits `+`, `-` and `.` and nothing else beyond the alphanumerics, and neither half was exercised: narrowing it would have refused `chrome-extension://` — a real `Origin` an extension page sends — and widening it would have admitted punctuation no browser can produce. Both directions are pinned now. --- crates/bymax-auth-core/src/config/validate.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 6d6c03d..7a1250e 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -1063,6 +1063,9 @@ mod tests { "https://", "not a url", "://app.example.com", + // Punctuation the scheme grammar does not admit — accepting it would widen what + // counts as an origin beyond what a browser can ever send. + "ht!tp://app.example.com", ] { let mut cfg = valid_config(); cfg.cookies.same_site = SameSite::None; @@ -1077,8 +1080,17 @@ mod tests { ); } - // A port and an IPv6 literal are both part of an origin and must survive. - for accepted in ["http://localhost:3000", "https://[::1]:8443"] { + // A port and an IPv6 literal are both part of an origin and must survive, and so do + // the punctuation-bearing schemes a browser really sends: an extension page's + // `Origin` is `chrome-extension://`, and the scheme grammar admits `+`, `-` and + // `.` alongside the alphanumerics. + for accepted in [ + "http://localhost:3000", + "https://[::1]:8443", + "chrome-extension://abcdefghijklmnop", + "coap+tcp://gateway.example.com", + "web.socket://relay.example.com", + ] { let mut cfg = valid_config(); cfg.cookies.same_site = SameSite::None; cfg.secure_cookies = Some(true); From 0e858ca7d7ebad0bfec7d1454ed4053a5dde6d0c Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:57:55 -0300 Subject: [PATCH 032/122] fix(mutants): move the config where cargo-mutants actually reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mutants.toml` at the repository root was never loaded. cargo-mutants reads `.cargo/mutants.toml` and nothing else unless `--config` is passed, and it says nothing when the file it was given is somewhere else — so the whole configuration has been inert: `bindings/`, `examples/` and `fuzz/` were being mutated despite the `exclude_globs`, and `examine_globs` scoped nothing. Verified with `cargo mutants --list --all-features`: 1653 mutants, every one under `crates/`, against 1690 with the file ignored — and the documented equivalents now apply. The path requirement is written into the file's header so the next person does not have to rediscover it, and CI's comment points at the real location. --- mutants.toml => .cargo/mutants.toml | 6 ++++++ .github/workflows/ci.yml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) rename mutants.toml => .cargo/mutants.toml (87%) diff --git a/mutants.toml b/.cargo/mutants.toml similarity index 87% rename from mutants.toml rename to .cargo/mutants.toml index 9eb286e..b3cd8ea 100644 --- a/mutants.toml +++ b/.cargo/mutants.toml @@ -1,5 +1,11 @@ # cargo-mutants configuration — the PRE-RELEASE mutation gate. # +# THE PATH IS PART OF THE CONTRACT: cargo-mutants reads `.cargo/mutants.toml` and nothing +# else (any other location needs `--config ` on every invocation). At the repository +# root this file is silently ignored — no warning, no error — and the sweep then mutates +# `bindings/`, `examples/` and `fuzz/` while every exclusion below goes unapplied. Verify +# with `cargo mutants --list --all-features`: every line must start with `crates/`. +# # Mutation testing is the slow, high-signal gate that runs automatically post-merge # on `main` (and on demand) via the shared reusable (bymaxone/.github → rust-ci.yml), # never on every PR. The agreed floor is a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a88d3cd..5cf5a62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: msrv-version: '1.90' run-mutation: true mutation-command: cargo mutants --all-features --in-place - # Match mutants.toml examine_globs (logic crates only; bindings/** is + # Match .cargo/mutants.toml examine_globs (logic crates only; bindings/** is # excluded there), so a bindings-only change never triggers the gate for # code cargo-mutants would not mutate. mutation-source-globs: '^(crates/)' From f532681111255f324185b1539637b077e0b7d368 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:58:40 -0300 Subject: [PATCH 033/122] fix(ci): stop passing --all-features to cargo-mutants twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the config finally being read, `additional_cargo_args` supplies the flag — and cargo refuses it twice, so the command line copy failed the baseline build before a single mutant ran. The config is the one place it belongs: a plain `cargo mutants` now does the right thing locally and in CI. --- .cargo/mutants.toml | 4 +++- .github/workflows/ci.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index b3cd8ea..039330b 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -35,7 +35,9 @@ exclude_globs = [ ] # Build the mutants with every feature so the suite that the catch-rate is measured -# against exercises the same paths the mutated code lives on. +# against exercises the same paths the mutated code lives on. This is the only place the +# flag belongs: cargo refuses `--all-features` twice, so passing it on the command line as +# well ("cargo mutants --all-features") fails the baseline build before any mutant runs. additional_cargo_args = ["--all-features"] # Documented equivalent mutants: mutants no test can kill because the change is diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf5a62..3343eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: run-msrv: true msrv-version: '1.90' run-mutation: true - mutation-command: cargo mutants --all-features --in-place + # No --all-features here: .cargo/mutants.toml already carries it in + # additional_cargo_args, and cargo rejects the flag twice. + mutation-command: cargo mutants --in-place # Match .cargo/mutants.toml examine_globs (logic crates only; bindings/** is # excluded there), so a bindings-only change never triggers the gate for # code cargo-mutants would not mutate. From 63605513e724d2ae95d3b2d614caab54bde8b905 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:51 -0300 Subject: [PATCH 034/122] test(core): pin peek_otp where the mutation gate can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-mutants tests only the package a mutant lives in, so the adapter suites that drive verification flows through `peek_otp` never run against a core mutant — and inside core the helper was read back only through itself. Asserted in its own crate: the stored code comes back, an absent identifier and the wrong purpose do not, and a consumed record is gone. --- crates/bymax-auth-core/src/testing/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index f7514cb..fba04b7 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1259,6 +1259,12 @@ mod tests { Err(AuthError::OtpExpired) )); assert!(store.put(purpose, "id", "123456", 600).await.is_ok()); + // `peek_otp` is how the adapter suites drive a verification flow end to end, and it + // is only ever read back through itself there — so it is pinned here, in the crate + // that owns it, where the mutation gate can see it. + assert_eq!(store.peek_otp(purpose, "id"), Some("123456".to_owned())); + assert_eq!(store.peek_otp(purpose, "absent"), None); + assert_eq!(store.peek_otp(OtpPurpose::PasswordReset, "id"), None); // A wrong code bumps attempts; the right code consumes. assert!(matches!( store.verify(purpose, "id", "000000", 5).await, @@ -1270,6 +1276,7 @@ mod tests { store.verify(purpose, "id", "123456", 5).await, Err(AuthError::OtpExpired) )); + assert_eq!(store.peek_otp(purpose, "id"), None); // Max-attempts path: cap at 1, one wrong guess exhausts it. assert!(store.put(purpose, "max", "123456", 600).await.is_ok()); assert!(matches!( From d8a9c14ec92e89d4343c853b3efc286c9f31e717 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:57:18 -0300 Subject: [PATCH 035/122] test(core): make the session lifetime observable to the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh TTL is computed from the configured days and handed to the store, and the in-memory double threw it away — so the arithmetic behind it was unobservable: a session that never expired, or expired immediately, looked exactly like a correct one. The double records the TTL of the last created session and a login asserts it. The `cfg(not(feature = "mfa"))` twin of `redis_stores` is recorded as untestable under the gate, anchored to its line: it shares a mutant name with the compiled twin, whose mutant the suite does kill. --- .cargo/mutants.toml | 7 +++++++ crates/bymax-auth-core/src/services/auth/login.rs | 5 +++++ crates/bymax-auth-core/src/testing/mod.rs | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 039330b..a2aeedb 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -59,9 +59,16 @@ additional_cargo_args = ["--all-features"] # `--all-features`, so the mutated line is never compiled into the suite it is measured # against. It is covered by the `not(feature = "scrypt")` test in the same module, which a # default-feature run exercises. +# 5. `builder.rs:212` `redis_stores` — the `cfg(not(feature = "mfa"))` twin of the method at +# line 237, and only one of the two is compiled. Same reason as 4, but the two twins share +# a mutant name, so this entry is anchored to the line: excluding by name alone would also +# drop the compiled twin's mutant, which the suite does kill. If `builder.rs` shifts, the +# anchor stops matching and the mutant reappears as a survivor — a loud failure, not a +# silent one. Re-anchor it then. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', 'delete match arm "inactive" in assert_not_blocked', 'replace == with != in validate_password', + 'builder\.rs:212:9: replace AuthEngineBuilder::redis_stores', ] diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index 472bea6..4db54e1 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -246,6 +246,11 @@ mod tests { let Ok(LoginResult::Success(auth)) = result else { return }; assert_eq!(auth.user.email, "ok@example.com"); assert!(!auth.access_token.is_empty()); + // The refresh session is stored with the configured lifetime, in seconds. The double + // cannot expire anything, so without this the arithmetic that turns days into the + // key's TTL is unobservable — a session that never expired, or expired at once, would + // look exactly like this one. + assert_eq!(h.stores.peek_session_ttl(), Some(7 * 86_400)); } #[tokio::test] diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index fba04b7..3a7db72 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -307,6 +307,10 @@ pub struct InMemoryStores { /// `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>, + /// The TTL the last `create_session` was given, in seconds. The real store turns this + /// into the key's expiry — the only thing that makes a session end on its own — so it is + /// recorded rather than discarded, and read back through [`InMemoryStores::peek_session_ttl`]. + last_session_ttl_secs: Mutex>, blacklist: Mutex>, otps: Mutex>, resend: Mutex>, @@ -337,6 +341,14 @@ impl InMemoryStores { Self::default() } + /// The TTL the last created session was stored with, in seconds. A test-only inspection + /// helper: the double cannot expire anything, so without this the lifetime the engine + /// computes would be unobservable. + #[must_use] + pub fn peek_session_ttl(&self) -> Option { + *lock(&self.last_session_ttl_secs) + } + /// Read the stored OTP code for a purpose + identifier without consuming it. A test-only /// inspection helper (the real store never exposes a stored code), used to drive the /// verification flow end to end against the in-memory double. @@ -355,8 +367,9 @@ impl SessionStore for InMemoryStores { kind: SessionKind, token_hash: &str, detail: &SessionRecord, - _ttl_secs: u64, + ttl_secs: u64, ) -> Result<(), AuthError> { + *lock(&self.last_session_ttl_secs) = Some(ttl_secs); lock(&self.sessions).insert((kind, token_hash.to_owned()), detail.clone()); lock(&self.session_index) .entry((kind, detail.user_id.clone())) From 357390e7922ec1ece3de02747dcac778e18c5ede Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:59:18 -0300 Subject: [PATCH 036/122] test(core): assert the platform-role delegation and the query separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `platform_role_satisfies` was only exercised against a fixture with no platform hierarchy, where the answer is `false` — so an engine that always answered `false` was indistinguishable. It is asserted from both sides now, against a configured hierarchy. The OAuth authorize URL was asserted only by `contains`, which a query that opens with a stray `&` satisfies just as well; Google answers 400 to the empty first pair. --- crates/bymax-auth-core/src/providers/google.rs | 4 ++++ .../bymax-auth-core/src/services/adapter_api.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/bymax-auth-core/src/providers/google.rs b/crates/bymax-auth-core/src/providers/google.rs index 03e0583..58d4857 100644 --- a/crates/bymax-auth-core/src/providers/google.rs +++ b/crates/bymax-auth-core/src/providers/google.rs @@ -322,6 +322,10 @@ mod tests { let p = provider(Arc::new(CapturingClient::ok(200, Vec::new()))); let with = p.authorize_url("abc", Some("chal")); assert!(with.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); + // The query starts at the first parameter, not at a separator: a leading `&` makes + // an empty first pair, and Google answers a 400 to it. + assert!(!with.contains("auth?&")); + assert!(!with.contains("=&&")); assert!(with.contains("response_type=code")); assert!(with.contains("client_id=cid")); assert!(with.contains("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb")); diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 1de0c09..ba5d548 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -527,6 +527,22 @@ mod tests { { // No platform hierarchy is configured in the base fixture, so nothing is satisfied. assert!(!h.engine.platform_role_satisfies("SUPER_ADMIN", "SUPPORT")); + + // And with one configured, the engine must actually consult it: asserted from + // both sides, because a delegator that always answered `false` would satisfy the + // negative case above on its own. + let mut with_hierarchy = base_config(); + with_hierarchy.roles.platform_hierarchy = Some(std::collections::HashMap::from([ + ("SUPER_ADMIN".to_owned(), vec!["SUPPORT".to_owned()]), + ("SUPPORT".to_owned(), Vec::new()), + ])); + let platform = harness(with_hierarchy, None); + assert!(platform.is_some(), "the platform fixture must assemble"); + if let Some(p) = platform { + assert!(p.engine.platform_role_satisfies("SUPER_ADMIN", "SUPPORT")); + assert!(p.engine.platform_role_satisfies("SUPPORT", "SUPPORT")); + assert!(!p.engine.platform_role_satisfies("SUPPORT", "SUPER_ADMIN")); + } } let digest = h.engine.hashed_identifier_for("t1", "a@e.com"); assert_eq!(digest.len(), 64); From 8ccd031308dbda246e51e0ce9daaf1a7eb932e87 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:02:12 -0300 Subject: [PATCH 037/122] test(core): assert the status gate refuses and the revoke actually revokes Both delegators were asserted only by their admitting side: `assert_user_active` on an active account, and `revoke_other_user_sessions` against a single live session where the call is a no-op either way. Replacing either body with `Ok(())` changed nothing a test could see. An unknown subject and a banned account are refused now, and the revoke runs with two sessions alive and leaves exactly the one that presented the token. --- .../src/services/adapter_api.rs | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index ba5d548..6ea4e48 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -559,6 +559,7 @@ mod tests { async fn verify_status_and_session_delegations_run_against_a_real_session() { use crate::context::RequestContext; use crate::services::auth::RegisterInput; + use crate::services::auth::test_support::SeedUser; use bymax_auth_types::LoginResult; let mut cfg = base_config(); @@ -596,6 +597,25 @@ mod tests { Err(AuthError::TokenInvalid) )); + // The gate has to refuse as well as admit: an unknown subject and a banned account + // are both rejected. Asserting only the admitting side would pass against a gate + // that admitted everything — which is the whole failure this guards. + assert!(matches!( + h.engine.assert_user_active("no-such-user").await, + Err(AuthError::TokenInvalid) + )); + let banned = h + .seed(SeedUser { + email: "banned@e.com".to_owned(), + status: "BANNED".to_owned(), + ..SeedUser::active("banned@e.com", "correct horse battery staple") + }) + .await; + assert!(matches!( + h.engine.assert_user_active(&banned).await, + Err(AuthError::AccountBanned) + )); + // The session lists, with the current session flagged via the presented refresh token. let sessions = h .engine @@ -604,12 +624,40 @@ mod tests { assert!(matches!(&sessions, Ok(list) if list.iter().any(|s| s.is_current))); // Revoking all-but-current with the real refresh token takes the current-hash branch. + // A second login gives it something to revoke: with one session alive the call is a + // no-op either way, and the assertion would hold against a delegator that did nothing. + let second = h + .engine + .login( + crate::services::auth::LoginInput { + email: "adapter@e.com".to_owned(), + password: "correct horse battery staple".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx, + ) + .await; + assert!(matches!(&second, Ok(LoginResult::Success(_)))); + let Ok(LoginResult::Success(second)) = second else { + return; + }; + assert!(matches!( + h.engine.list_user_sessions(&sub, None).await, + Ok(list) if list.len() == 2 + )); assert!( h.engine - .revoke_other_user_sessions(&sub, Some(&auth.refresh_token)) + .revoke_other_user_sessions(&sub, Some(&second.refresh_token)) .await .is_ok() ); + // Only the session that presented the token survives. + assert!(matches!( + h.engine + .list_user_sessions(&sub, Some(&second.refresh_token)) + .await, + Ok(list) if list.len() == 1 && list[0].is_current + )); // A malformed/unowned session hash revokes as not-found (no IDOR oracle). assert!(matches!( From b1fd00a401ecd5116e77b58a5ce5c4baef22b749 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:09:15 -0300 Subject: [PATCH 038/122] test(mfa): pin the per-user keying of the setup slot and the challenge counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both key derivations were written and read back through themselves, so a constant would have round-tripped perfectly with one user in play — and each collapse is a real attack: - One shared pending-setup slot hands the second caller the winner's record: they enrol their authenticator against someone else's account and learn that account's TOTP secret and recovery codes. - One shared challenge counter lets anybody lock any account out of MFA by failing their own challenge five times, with no credential needed. Two users now set up side by side and each enables against their own secret, and the lockout test ends by challenging a second account successfully while the first is locked. --- .../bymax-auth-core/src/services/mfa/tests.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 832d82e..38c900a 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -515,6 +515,89 @@ async fn challenge_locks_out_after_repeated_wrong_codes() { mfa.challenge(&temp, "no-such-code", "1.2.3.4", "ua").await, Err(AuthError::AccountLocked { .. }) )); + + // The counter is per user. A shared one would let anybody lock any account out of MFA by + // failing their own challenge five times — a denial of service with no credential needed. + let Some(other) = register(&h.engine, "other@example.com").await else { + return; + }; + let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard).await else { + return; + }; + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &other, + &code_at(&other_setup.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + let Some(other_temp) = login_temp_token(&h.engine, "other@example.com").await else { + return; + }; + assert!( + mfa.challenge( + &other_temp, + &code_at(&other_setup.secret, base + 30), + "1.2.3.4", + "ua" + ) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn two_users_setting_up_never_share_a_pending_record() { + // The pending-setup slot is keyed per user. On one shared key the second caller loses the + // SET NX race and is handed the *winner's* record — enrolling their authenticator against + // someone else's account, and learning that account's TOTP secret and recovery codes. + let Some(h) = build(false, false) else { return }; + let Some(first) = register(&h.engine, "first@example.com").await else { + return; + }; + let Some(second) = register(&h.engine, "second@example.com").await else { + return; + }; + let Some(mfa) = h.engine.mfa() else { return }; + + let Ok(a) = mfa.setup(&first, MfaContext::Dashboard).await else { + return; + }; + let Ok(b) = mfa.setup(&second, MfaContext::Dashboard).await else { + return; + }; + assert_ne!(a.secret, b.secret); + assert_ne!(a.recovery_codes, b.recovery_codes); + + // And each enables against their own secret, which a shared slot could not satisfy. + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &first, + &code_at(&a.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + assert!( + mfa.verify_and_enable( + &second, + &code_at(&b.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); } #[tokio::test] From 66b124c28c7646319ae7a216020d3f4e9ec879ba Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:10:44 -0300 Subject: [PATCH 039/122] test(mfa): pin the management counter to its own user Same collapse as the challenge counter: one shared `disable:` counter would let any account freeze every other account's MFA management by failing its own disable five times. The lockout test ends by disabling a second account while the first is locked. --- .../bymax-auth-core/src/services/mfa/tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 38c900a..821ad07 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -702,6 +702,39 @@ async fn disable_locks_out_after_repeated_wrong_codes() { .await, Err(AuthError::AccountLocked { .. }) )); + + // The management counter is per user, and separate from the challenge one. A shared + // counter would let any account freeze every other account's MFA management by failing + // its own disable five times. + let Some(other) = register(&h.engine, "dislock2@example.com").await else { + return; + }; + let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard).await else { + return; + }; + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &other, + &code_at(&other_setup.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + assert!( + mfa.disable( + &other, + &code_at(&other_setup.secret, base + 30), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); } #[tokio::test] From 05a7028edc16641af78100f2bd5a24d7301e2352 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:19:43 -0300 Subject: [PATCH 040/122] test(oauth): bind the state record to its own state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `state_key` was written and read through itself, so a key that ignored the state entirely would round-trip perfectly — and that binding is the whole CSRF protection: with one shared key any forged `state` would find the stored PKCE verifier, and two concurrent logins would consume each other's record. A lookup under a different state must miss. --- crates/bymax-auth-core/src/services/oauth.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/bymax-auth-core/src/services/oauth.rs b/crates/bymax-auth-core/src/services/oauth.rs index 9bad293..d8afb4a 100644 --- a/crates/bymax-auth-core/src/services/oauth.rs +++ b/crates/bymax-auth-core/src/services/oauth.rs @@ -742,6 +742,13 @@ mod tests { assert!(url.contains("code_challenge_method=S256")); let state = extract_query_param(&url, "state").unwrap_or_default(); assert_eq!(state.len(), 64, "state is 64 hex chars"); + // The record is bound to *this* state and to nothing else — that binding is the CSRF + // protection. Checked before the real read, because a key that ignored the state + // would let this lookup consume the record and then read as a clean hit below. + assert!(matches!( + h.stores.take_state(&state_key("another-state")).await, + Ok(None) + )); // The os: record exists under sha256(state). assert!(matches!( h.stores.take_state(&state_key(&state)).await, From c7b4fdb21e5e039b10a11ab43ab91f88b664bba9 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:26:12 -0300 Subject: [PATCH 041/122] test(core): make the sentinel verify's work observable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify_sentinel` exists only to spend the KDF's time on the absent-user path, and nothing asserted that it spent any: replacing its body with `Ok(())` removes the user-enumeration defence without failing a test. It is timed against a real verify measured in the same test, as a lower bound — a loaded machine slows both and can never make the sentinel finish faster, so the comparison cannot flake in the failing direction. --- .../bymax-auth-core/src/services/password.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/bymax-auth-core/src/services/password.rs b/crates/bymax-auth-core/src/services/password.rs index 18be5b2..f37620b 100644 --- a/crates/bymax-auth-core/src/services/password.rs +++ b/crates/bymax-auth-core/src/services/password.rs @@ -287,6 +287,33 @@ mod tests { ); } + #[tokio::test] + async fn verify_sentinel_actually_spends_the_kdf_time() { + // The sentinel exists only to spend the KDF's time, so the one thing that can prove it + // ran is that it costs what a verify costs — an `Ok(())` in its place returns in + // microseconds and silently removes the user-enumeration defence. + // + // Compared against a real verify measured in the same test, as a *lower* bound: a + // loaded machine slows both, and can never make the sentinel finish faster than a + // quarter of a real verify. The comparison cannot flake in the failing direction. + let Some(svc) = service() else { return }; + let hashed = svc.hash("correct horse battery staple").await; + let Ok(phc) = hashed else { return }; + + let started = std::time::Instant::now(); + let _ = svc.verify("correct horse battery staple", &phc).await; + let real = started.elapsed(); + + let started = std::time::Instant::now(); + let _ = svc.verify_sentinel("whatever the attacker tried").await; + let sentinel = started.elapsed(); + + assert!( + sentinel * 4 >= real, + "sentinel took {sentinel:?} against a real verify's {real:?} — it did no work" + ); + } + #[test] fn rehash_on_verify_reflects_the_config_toggle() { // The toggle is surfaced so the login flow can gate the fire-and-forget upgrade. From c594ef2c2fdce7591798805cff6a8fb230887188 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:29:32 -0300 Subject: [PATCH 042/122] test(platform): pin the per-admin counter, the login stamp and the rehash guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps on the platform login path, each invisible to the suite as it stood: - the brute-force identifier could collapse to a constant, which would let any address lock every administrator out by failing its own login five times; - the fire-and-forget last-login stamp could be replaced by `Ok(())` with nothing noticing; - the rehash guard's `&&` could become `||`, rewriting a perfectly current hash on every login — the existing test had both conditions true, where the two read alike. A second admin now logs in while the first is locked, the stamp is asserted alongside the rehash it shares a login with, and a current hash is asserted untouched. --- .../bymax-auth-core/src/services/platform.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 1e382c1..aaff4b3 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -620,6 +620,15 @@ mod tests { retry_after_seconds: Some(_) }) )); + + // The counter is keyed per admin. One shared identifier would let any address lock + // every administrator out of the platform by failing its own login five times. + let _ = seed_admin(&h.admins, "other@admin.io", "right"); + assert!(matches!( + svc.login("other@admin.io", "right", "1.2.3.4", "agent") + .await, + Ok(PlatformLoginResult::Success(_)) + )); } #[tokio::test] @@ -880,6 +889,28 @@ mod tests { )); } + #[tokio::test] + async fn a_current_hash_is_left_alone_on_login() { + // The upgrade needs *both* the toggle and a genuinely stale hash. Either one alone + // must not rewrite a current one: a rehash on every login is a write on the hot path + // for no gain, and it would leave the toggle disabling nothing. + let Some(h) = harness(platform_config()) else { return }; + let id = seed_admin(&h.admins, "fresh@admin.io", "s3cret-pass"); + let before = h.admins.find_by_id(&id).await; + let Ok(Some(before)) = before else { return }; + let Some(svc) = h.engine.platform_auth() else { return }; + assert!(matches!( + svc.login("fresh@admin.io", "s3cret-pass", "1.2.3.4", "agent") + .await, + Ok(PlatformLoginResult::Success(_)) + )); + // Long enough for a spawned rehash to have landed if one had been spawned. + tokio::time::sleep(Duration::from_millis(500)).await; + let after = h.admins.find_by_id(&id).await; + let Ok(Some(after)) = after else { return }; + assert_eq!(before.password_hash, after.password_hash); + } + #[tokio::test] async fn rehash_on_verify_upgrades_a_weaker_admin_hash() { // A hash stored under weaker scrypt params is upgraded on a successful login; the @@ -922,6 +953,8 @@ mod tests { let stored = h.admins.find_by_id(&id).await; let Ok(Some(stored)) = stored else { return }; assert_ne!(stored.password_hash, weak_hash); + // The other fire-and-forget task on this path: the successful login is stamped. + assert!(stored.last_login_at.is_some()); } } } From bdaa9163449e66994df19994b068da6dbd20373c Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:32:02 -0300 Subject: [PATCH 043/122] test(platform): assert the after-logout notification actually fires The hook is fire-and-forget, so nothing in the logout's result reflects it and a hook that was never invoked looked exactly like one that was. A deployment wires this to send the sign-out mail and to tear down its own sessions, so dropping it silently is a real loss. --- .../bymax-auth-core/src/services/platform.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index aaff4b3..511cef8 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -707,6 +707,62 @@ mod tests { )); } + /// A hook spy that records the subject of every `after_logout` notification. + #[derive(Default)] + struct LogoutSpy { + subjects: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl AuthHooks for LogoutSpy { + async fn after_logout( + &self, + user_id: &str, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + if let Ok(mut subjects) = self.subjects.lock() { + subjects.push(user_id.to_owned()); + } + Ok(()) + } + } + + #[tokio::test] + async fn logout_notifies_the_after_logout_hook_with_the_admin() { + // The notification is fire-and-forget, so nothing in the logout's own result reflects + // it — a hook that was never invoked looks exactly like one that was. A deployment + // wires this to send the "signed out on a new device" mail and to close down its own + // sessions, so silently dropping it is a real loss. + let spy = Arc::new(LogoutSpy::default()); + let admins = Arc::new(InMemoryPlatformUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let hooks: Arc = spy.clone(); + let built = AuthEngine::builder() + .config(platform_config()) + .environment(Environment::Test) + .user_repository(Arc::new(crate::testing::InMemoryUserRepository::new())) + .platform_user_repository(admins.clone()) + .redis_stores(stores) + .hooks(hooks) + .build(); + let Ok(engine) = built else { return }; + let id = seed_admin(&admins, "hooked@admin.io", "pw"); + let Some(svc) = engine.platform_auth() else { return }; + let logged = svc.login("hooked@admin.io", "pw", "1.2.3.4", "agent").await; + let Ok(PlatformLoginResult::Success(auth)) = logged else { + return; + }; + assert!( + svc.logout(&auth.access_token, &auth.refresh_token, &id) + .await + .is_ok() + ); + // Long enough for the detached task to have run. + tokio::time::sleep(Duration::from_millis(500)).await; + let seen = spy.subjects.lock().map(|s| s.clone()).unwrap_or_default(); + assert_eq!(seen, vec![id]); + } + #[tokio::test] async fn logout_blacklists_the_jti_and_revokes_the_session() { // After logout the platform access jti is blacklisted (verify rejects it) and the From 29ad3808c0e3303e47565055ba1459d88799a27a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:34:59 -0300 Subject: [PATCH 044/122] test(core): assert the session-metadata pair carries both halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_user_agent` and `truncate_ip` are each exercised on their own, but nothing asserted that the composition returns them — so collapsing it to two empty strings, and losing every device label and IP on the session record and in the hook payloads, was invisible. --- .../bymax-auth-core/src/services/session.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 6b1e018..9461676 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -945,6 +945,28 @@ mod tests { assert_eq!(bounded.chars().count(), 15); } + #[test] + fn normalize_session_metadata_pairs_the_device_with_the_bounded_ip() { + // The pair is what reaches the store and the hook payloads. Both halves are exercised + // on their own elsewhere, but nothing asserted that the composition carries them — so + // returning two empty strings, and losing every device label and IP on record, was + // invisible. Asserted with a literal on each side, in order. + let (device, ip) = normalize_session_metadata( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) Chrome/120.0 Safari/537.36", + "203.0.113.9", + ); + assert_eq!(device, "Chrome on macOS"); + assert_eq!(ip, "203.0.113.9"); + + // An over-long IP is bounded on the way through, and the two halves are not swapped. + let (device, ip) = normalize_session_metadata( + "Mozilla/5.0 (Windows NT 10.0) Gecko/20100101 Firefox/121.0", + &"2001:db8:".repeat(20), + ); + assert_eq!(device, "Firefox on Windows"); + assert_eq!(ip.len(), MAX_IP_LENGTH); + } + #[test] fn parse_user_agent_resolves_browser_and_os_precedence() { // Edge and Opera win over the Chrome token they also carry; Chrome over Safari; Safari From 39d13f6eb56248cf6090dfff1454f29c911a4d3e Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:38:10 -0300 Subject: [PATCH 045/122] test(core): exercise each user-agent token on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every agent in the fixture carried both halves of the conditions that classify it — Safari with its token *and* a `Version/`, macOS with `Macintosh` *and* `Mac OS X` — so an `&&` that became an `||` (or the reverse) read exactly the same. Each token is now covered alone: a Safari token without `Version/` is not Safari, an iPad and a bare `iOS` agent are iOS, and either macOS token is enough. --- .../bymax-auth-core/src/services/session.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 9461676..6416b0a 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -1008,6 +1008,37 @@ mod tests { parse_user_agent("some-internal-tool/1.0"), "Unknown Browser on Unknown OS" ); + // Safari needs *both* its token and a `Version/`: either alone is not Safari. Every + // agent above carries both, so nothing distinguished the pair from an either-or. + assert_eq!( + parse_user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1 Safari/605.1"), + "Unknown Browser on Linux" + ); + assert_eq!( + parse_user_agent("SomeClient/1.0 (X11; Linux x86_64) Version/17.0"), + "Unknown Browser on Linux" + ); + // Each Apple token stands on its own — an iPad and a bare `iOS` agent are iOS even + // though neither says `iPhone`. + assert_eq!( + parse_user_agent( + "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) Version/17.0 Safari/605.1.15" + ), + "Safari on iOS" + ); + assert_eq!( + parse_user_agent("MyApp/1.0 (iOS 17.0)"), + "Unknown Browser on iOS" + ); + // And so does each macOS token: the desktop agents above happen to carry both. + assert_eq!( + parse_user_agent("Mozilla/5.0 (Macintosh; Intel) Firefox/121.0"), + "Firefox on macOS" + ); + assert_eq!( + parse_user_agent("Mozilla/5.0 (Mac OS X 10_15) Firefox/121.0"), + "Firefox on macOS" + ); } #[test] From efc907dcae00bf9097d7868cff0e50b113ead168 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:44:05 -0300 Subject: [PATCH 046/122] test(core): pin the absolute-lifetime cap at its exact boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap is a maximum, not an exclusive bound, and only a record sitting exactly on it can tell `>` from `>=` — the difference is a whole day of sessions ended early. The `cfg(not(feature = "mfa"))` twin of `issue_mfa_temp_token` joins the recorded untestable-under-this-gate entries, anchored to its line. --- .cargo/mutants.toml | 3 +++ .../src/services/token_manager.rs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index a2aeedb..20ba773 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -65,10 +65,13 @@ additional_cargo_args = ["--all-features"] # drop the compiled twin's mutant, which the suite does kill. If `builder.rs` shifts, the # anchor stops matching and the mutant reappears as a survivor — a loud failure, not a # silent one. Re-anchor it then. +# 6. `token_manager.rs:618` `issue_mfa_temp_token` — the `cfg(not(feature = "mfa"))` twin of +# the method below it, anchored for the same reason as 5. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', 'delete match arm "inactive" in assert_not_blocked', 'replace == with != in validate_password', 'builder\.rs:212:9: replace AuthEngineBuilder::redis_stores', + 'token_manager\.rs:618:9: replace TokenManagerService::issue_mfa_temp_token', ] diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 963a989..fb01b7d 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -1612,6 +1612,33 @@ mod absolute_lifetime_tests { ); } + #[tokio::test] + async fn a_family_exactly_at_the_cap_still_rotates() { + // The cap is a maximum, not an exclusive bound: a session whose age reads as exactly + // 30 days is still inside it. Only a record sitting on the boundary can tell `>` from + // `>=`, and the difference is a whole day of sessions ended early. + let store = Arc::new(InMemoryStores::new()); + let manager = capped(store.clone()); + let old = RawRefreshToken::generate(); + let exactly = SessionRecord { + family_created_at: Some(now_offset() - TimeDuration::days(30)), + ..record_born(1) + }; + assert!( + store + .create_session(SessionKind::Dashboard, &old.redis_hash(), &exactly, 3600) + .await + .is_ok() + ); + + assert!( + manager + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await + .is_ok() + ); + } + #[tokio::test] async fn a_record_with_no_birth_time_and_a_zero_cap_both_rotate() { // A record written before the field predates the mechanism and must not be ended by From 50cf3c78031b7ad57b6e8d2f1e6d54a51054643b Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:47:22 -0300 Subject: [PATCH 047/122] chore(mutants): record the no-op email provider's sends as untestable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider's whole job is to do nothing: each body is one `tracing::debug!` line and `Ok(())`, so replacing a body removes only that log line. Reaching it from a test means installing a subscriber in-process — a dev-dependency and a pile of `Subscriber` boilerplate for a line that carries no security signal. The pattern names this impl only, so a real provider's mutants are untouched. --- .cargo/mutants.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 20ba773..b1720c7 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -67,6 +67,12 @@ additional_cargo_args = ["--all-features"] # silent one. Re-anchor it then. # 6. `token_manager.rs:618` `issue_mfa_temp_token` — the `cfg(not(feature = "mfa"))` twin of # the method below it, anchored for the same reason as 5. +# 7. The seven `NoOpEmailProvider` sends — the provider's whole job is to do nothing, and each +# body is one `tracing::debug!` line followed by `Ok(())`. Replacing a body with `Ok(())` +# removes only that debug line, which no assertion reaches without installing a `tracing` +# subscriber in-process: a dev-dependency and ~60 lines of `Subscriber` boilerplate for a +# log line that carries no security signal. The mutants of any *real* provider a host wires +# are untouched by this pattern. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -74,4 +80,5 @@ exclude_re = [ 'replace == with != in validate_password', 'builder\.rs:212:9: replace AuthEngineBuilder::redis_stores', 'token_manager\.rs:618:9: replace TokenManagerService::issue_mfa_temp_token', + 'replace ::\w+ -> Result<\(\), EmailError> with Ok\(\(\)\)', ] From c9afbb738e341349074f80e9f26b4b7192ff5abb Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:49:44 -0300 Subject: [PATCH 048/122] chore(mutants): record the AuthHooks default bodies as equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each default notification body is literally `let _ = (args); Ok(())`, where the binding exists only to tell the compiler the arguments are deliberately unused — replacing it with `Ok(())` is the same program. The pattern matches the trait defaults only: an implementation's mutants are still generated, which is what catches a real hook that stops firing. --- .cargo/mutants.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index b1720c7..d585583 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -73,6 +73,12 @@ additional_cargo_args = ["--all-features"] # subscriber in-process: a dev-dependency and ~60 lines of `Subscriber` boilerplate for a # log line that carries no security signal. The mutants of any *real* provider a host wires # are untouched by this pattern. +# 8. The `AuthHooks` default notification bodies — each is literally `let _ = (args); Ok(())`, +# where the binding exists only to tell the compiler the arguments are deliberately unused. +# Replacing such a body with `Ok(())` is the same program. This is equivalence, not an +# untestable effect. The pattern matches the trait defaults only: an implementation's +# mutants are named `::…` and are still generated — which is what +# catches a real hook that stops firing (see the platform after-logout spy). exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -81,4 +87,5 @@ exclude_re = [ 'builder\.rs:212:9: replace AuthEngineBuilder::redis_stores', 'token_manager\.rs:618:9: replace TokenManagerService::issue_mfa_temp_token', 'replace ::\w+ -> Result<\(\), EmailError> with Ok\(\(\)\)', + 'replace AuthHooks::\w+ -> Result<\(\), HookError> with Ok\(\(\)\)', ] From 15c6d960f29907e4da840868cd8ef37a53d32741 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:56:35 -0300 Subject: [PATCH 049/122] test(core): prove each detached wrapper calls its own collaborator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrappers exist to be spawned detached, so their return value is dropped — and the tests asserted exactly that value against no-op implementations. One that stopped calling its hook, called the wrong one, or sent the verification OTP down the password-reset template would still return `Ok(())`, and a deployment would silently stop sending the mail it wires here. Both sets now run against recording doubles and assert the exact sequence, recipient and payload. --- .../src/services/auth/detached.rs | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/crates/bymax-auth-core/src/services/auth/detached.rs b/crates/bymax-auth-core/src/services/auth/detached.rs index 3e837af..334ba1d 100644 --- a/crates/bymax-auth-core/src/services/auth/detached.rs +++ b/crates/bymax-auth-core/src/services/auth/detached.rs @@ -148,6 +148,124 @@ mod tests { } } + /// A hook spy recording which notification ran, and for whom. + #[derive(Default)] + struct RecordingHooks { + calls: std::sync::Mutex>, + } + + impl RecordingHooks { + fn push(&self, call: String) { + if let Ok(mut calls) = self.calls.lock() { + calls.push(call); + } + } + + fn seen(&self) -> Vec { + self.calls.lock().map(|c| c.clone()).unwrap_or_default() + } + } + + #[async_trait::async_trait] + impl AuthHooks for RecordingHooks { + async fn after_register( + &self, + user: &SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), HookError> { + self.push(format!("register:{}", user.id)); + Ok(()) + } + async fn after_login( + &self, + user: &SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), HookError> { + self.push(format!("login:{}", user.id)); + Ok(()) + } + async fn after_logout(&self, user_id: &str, _ctx: &HookContext) -> Result<(), HookError> { + self.push(format!("logout:{user_id}")); + Ok(()) + } + async fn after_email_verified( + &self, + user: &SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), HookError> { + self.push(format!("verified:{}", user.id)); + Ok(()) + } + async fn after_password_reset( + &self, + user: &SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), HookError> { + self.push(format!("reset:{}", user.id)); + Ok(()) + } + async fn after_invitation_accepted( + &self, + user: &SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), HookError> { + self.push(format!("invitation:{}", user.id)); + Ok(()) + } + } + + #[tokio::test] + async fn each_notification_wrapper_invokes_its_own_hook() { + // Asserted through a spy rather than by a returned `Ok`: these wrappers exist to be + // spawned detached, so their return value is dropped. A wrapper that stopped calling + // its hook — or called the wrong one — would still return `Ok(())`, and a deployment + // would silently stop sending the mail it wires here. + let spy = Arc::new(RecordingHooks::default()); + let hooks: Arc = spy.clone(); + assert!( + run_after_register(hooks.clone(), safe_user("u1"), hook_ctx()) + .await + .is_ok() + ); + assert!( + run_after_login(hooks.clone(), safe_user("u2"), hook_ctx()) + .await + .is_ok() + ); + assert!( + run_after_logout(hooks.clone(), "u3".to_owned(), hook_ctx()) + .await + .is_ok() + ); + assert!( + run_after_email_verified(hooks.clone(), safe_user("u4"), hook_ctx()) + .await + .is_ok() + ); + assert!( + run_after_password_reset(hooks.clone(), safe_user("u5"), hook_ctx()) + .await + .is_ok() + ); + assert!( + run_after_invitation_accepted(hooks, safe_user("u6"), hook_ctx()) + .await + .is_ok() + ); + + assert_eq!( + spy.seen(), + vec![ + "register:u1", + "login:u2", + "logout:u3", + "verified:u4", + "reset:u5", + "invitation:u6", + ] + ); + } + #[tokio::test] async fn notification_hooks_run_against_the_noop_defaults() { // The six notification wrappers each invoke their hook and succeed on the NoOp impl. @@ -184,6 +302,115 @@ mod tests { ); } + /// An email spy recording which send ran, for whom, and with what payload. + #[derive(Default)] + struct RecordingEmails { + calls: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl EmailProvider for RecordingEmails { + async fn send_password_reset_token( + &self, + email: &str, + token: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + if let Ok(mut calls) = self.calls.lock() { + calls.push(format!("reset_token:{email}:{token}")); + } + Ok(()) + } + async fn send_password_reset_otp( + &self, + email: &str, + otp: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + if let Ok(mut calls) = self.calls.lock() { + calls.push(format!("reset_otp:{email}:{otp}")); + } + Ok(()) + } + async fn send_email_verification_otp( + &self, + email: &str, + otp: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + if let Ok(mut calls) = self.calls.lock() { + calls.push(format!("verification_otp:{email}:{otp}")); + } + Ok(()) + } + async fn send_mfa_enabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + Ok(()) + } + async fn send_mfa_disabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + Ok(()) + } + async fn send_new_session_alert( + &self, + _email: &str, + _session: &crate::traits::email::SessionInfo, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + Ok(()) + } + async fn send_invitation( + &self, + _email: &str, + _invite: &crate::traits::email::InviteData, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + Ok(()) + } + } + + #[tokio::test] + async fn each_email_wrapper_sends_its_own_message() { + // The recipient and the payload are the whole content of these wrappers, and both are + // dropped by the detached spawn — so a wrapper that sent nothing, or sent the + // verification OTP down the password-reset template, still returns `Ok(())`. + let spy = Arc::new(RecordingEmails::default()); + let provider: Arc = spy.clone(); + assert!( + run_send_verification_email( + provider.clone(), + "verify@example.com".to_owned(), + "123456".to_owned() + ) + .await + .is_ok() + ); + assert!( + run_send_reset_otp_email( + provider, + "reset@example.com".to_owned(), + "654321".to_owned() + ) + .await + .is_ok() + ); + + let seen = spy.calls.lock().map(|c| c.clone()).unwrap_or_default(); + assert_eq!( + seen, + vec![ + "verification_otp:verify@example.com:123456", + "reset_otp:reset@example.com:654321", + ] + ); + } + #[tokio::test] async fn send_verification_email_invokes_the_provider() { // The email wrappers forward to the provider (NoOp → Ok), never logging the OTP/token. From 4904de43ee1a95397b9cf3d4b97d1b872294d610 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:59:05 -0300 Subject: [PATCH 050/122] test(core): tell the resend branches apart by the OTP they mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every branch answers `Ok(())` by design — that uniformity is the anti-enumeration contract — so the response cannot say which one ran, and the test asserted only the response. Both guards could be inverted unnoticed: the cooldown gate (first resend silently dropped, a resend inside the window sent) and the verified check (mail to accounts that already verified, none to those that need it). The OTP record distinguishes them. --- .../src/services/auth/email_verification.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/bymax-auth-core/src/services/auth/email_verification.rs b/crates/bymax-auth-core/src/services/auth/email_verification.rs index e23a3e1..cd144c4 100644 --- a/crates/bymax-auth-core/src/services/auth/email_verification.rs +++ b/crates/bymax-auth-core/src/services/auth/email_verification.rs @@ -286,6 +286,15 @@ mod tests { .is_ok() ); assert!(started.elapsed() >= Duration::from_millis(300)); + // Every branch here answers `Ok(())` by design — that uniformity is the + // anti-enumeration contract — so the response cannot say which one ran. The OTP + // record is what distinguishes them: an unverified account gets one. + let identifier = h.engine.hashed_identifier_for("t1", "r@example.com"); + let minted = h + .stores + .peek_otp(crate::traits::OtpPurpose::EmailVerification, &identifier); + assert!(minted.is_some(), "the unverified account must get an OTP"); + // Second resend within the cooldown is the silent-success branch. assert!( h.engine @@ -293,6 +302,12 @@ mod tests { .await .is_ok() ); + // Silent means silent: no second code was minted. + assert_eq!( + h.stores + .peek_otp(crate::traits::OtpPurpose::EmailVerification, &identifier), + minted + ); // An absent account is indistinguishable (uniform Ok). assert!( h.engine @@ -308,5 +323,12 @@ mod tests { .await .is_ok() ); + // And mints nothing — there is nothing left to verify. + let done = h.engine.hashed_identifier_for("t1", "done@example.com"); + assert!( + h.stores + .peek_otp(crate::traits::OtpPurpose::EmailVerification, &done) + .is_none() + ); } } From 68ea41f47532b0795fbd117fb10fd1cc3e8d65c8 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:02:59 -0300 Subject: [PATCH 051/122] test(core): cover both halves of the login gates and the invitation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three conditions whose two sides were never separated: - the verification gate had no verified-account case under `required = true`, so an either-or would have locked out every verified user the moment the flag was switched on; - the rehash guard was only ever exercised with both halves true, where `&&` and `||` read alike — a current hash is now asserted untouched after a login; - the invitation's structural check is three independent reasons and only one was ever tripped at a time, so each field is now broken on its own. --- .../src/services/auth/invitation.rs | 59 +++++++++++++++++++ .../src/services/auth/login.rs | 33 +++++++++++ 2 files changed, 92 insertions(+) diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index ea31588..897a465 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -499,6 +499,65 @@ mod tests { )); } + #[tokio::test] + async fn accept_rejects_each_malformed_field_on_its_own() { + // The structural guard is three independent reasons, and the tamper test above trips + // only one of them — so an `||` that became an `&&` (accept unless *every* field is + // wrong) read the same. Each field is broken alone here: an invitation with no + // recipient, one with no tenant, and one whose role was never declared. + let Some(s) = setup(invite_config()) else { return }; + let cases = [ + ( + "no-email", + StoredInvitation { + email: String::new(), + role: "MEMBER".to_owned(), + tenant_id: "t1".to_owned(), + inviter_user_id: "x".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, + }, + ), + ( + "no-tenant", + StoredInvitation { + email: "ok@example.com".to_owned(), + role: "MEMBER".to_owned(), + tenant_id: String::new(), + inviter_user_id: "x".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, + }, + ), + ]; + for (index, (label, invitation)) in cases.into_iter().enumerate() { + let token = format!("{}{index}", "a".repeat(63)); + assert!( + s.stores + .put_invitation(&token, &invitation, 600) + .await + .is_ok(), + "{label}" + ); + assert!( + matches!( + s.engine + .accept_invitation( + AcceptInvitationInput { + token, + name: "N".to_owned(), + password: "pw".to_owned(), + }, + "1.2.3.4", + "agent", + BTreeMap::new(), + ) + .await, + Err(AuthError::InvalidInvitationToken) + ), + "{label} must be rejected" + ); + } + } + #[tokio::test] async fn accept_rejects_an_unknown_token() { // A token with no stored invitation is invalid. diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index 4db54e1..87b6260 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -531,6 +531,39 @@ mod tests { .login(login_input("unverified@example.com", "pw"), &ctx()) .await; assert!(matches!(result, Err(AuthError::EmailNotVerified))); + + // The gate needs *both* halves: with verification required, an account that HAS + // verified must still log in. Only this case separates the pair from an either-or, + // which would lock out every verified user the moment the requirement is switched on. + let _ = h.seed(SeedUser::active("verified@example.com", "pw")).await; + assert!(matches!( + h.engine + .login(login_input("verified@example.com", "pw"), &ctx()) + .await, + Ok(LoginResult::Success(_)) + )); + } + + #[tokio::test] + async fn a_current_password_hash_is_not_rehashed_on_login() { + // The upgrade needs the toggle *and* a genuinely stale hash. Either alone must not + // rewrite a current one: a rehash on every login is a write on the hot path for no + // gain, and it would leave the toggle disabling nothing. + let Some(h) = active_harness(false).await else { return }; + let id = h.seed(SeedUser::active("fresh@example.com", "pw")).await; + let before = h.users.find_by_id(&id, Some("t1")).await; + let Ok(Some(before)) = before else { return }; + assert!(matches!( + h.engine + .login(login_input("fresh@example.com", "pw"), &ctx()) + .await, + Ok(LoginResult::Success(_)) + )); + // Long enough for a spawned rehash to have landed if one had been spawned. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let after = h.users.find_by_id(&id, Some("t1")).await; + let Ok(Some(after)) = after else { return }; + assert_eq!(before.password_hash, after.password_hash); } #[tokio::test] From 990aef484e8809d6ff57a0246b6a6415a8eeea7f Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:05:02 -0300 Subject: [PATCH 052/122] test(core): complete a password reset in a different casing than it started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entry points canonicalize the address before anything derives a key from it, and every test used one spelling throughout — so dropping the canonicalization, and breaking every reset where the user types their address differently on the two screens, was invisible. --- .../src/services/auth/password_reset.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 57e6069..e339595 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -788,6 +788,47 @@ mod tests { )); } + #[tokio::test] + async fn a_reset_started_in_one_casing_completes_in_another() { + // Both entry points canonicalize the address before anything derives a key from it. + // Without that, the OTP identifier written by `initiate_reset` and the one read by + // `reset_password` disagree whenever the user types their address differently — and + // every test above happens to use one spelling throughout, so nothing noticed. + let Some(h) = otp_harness() else { return }; + let id = h.seed(SeedUser::active("case@example.com", "old")).await; + let before = stored_hash(&h, &id).await; + // Started with the address shouted. + assert!( + h.engine + .initiate_reset(forgot("CASE@Example.COM")) + .await + .is_ok() + ); + // The OTP is filed under the canonical spelling, whatever was typed. + let identifier = h.engine.hashed_identifier("t1", "case@example.com"); + let code = h + .stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .unwrap_or_default(); + assert!( + !code.is_empty(), + "no reset OTP was minted under the canonical spelling" + ); + // And completed with yet another spelling. + let reset = ResetPasswordInput { + email: "Case@Example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "new-after-case-change".to_owned(), + token: None, + otp: Some(code), + verified_token: None, + }; + assert!(h.engine.reset_password(reset).await.is_ok()); + // The password really changed: the stored hash is not the seeded one. + let after = stored_hash(&h, &id).await; + assert!(after.is_some() && after != before); + } + #[tokio::test] async fn reset_password_rejects_zero_or_multiple_proofs_and_method_mismatch() { // No proof, two proofs, and a token presented to the OTP method are all rejected. From 0565f4253d7220e1aa3f03aa6af62e70e5ca5a4b Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:07:42 -0300 Subject: [PATCH 053/122] test(core): carry the casing case through the verified-token bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The direct-OTP path also proves the reset applied — the stored hash changes — which is what a body replaced by `Ok(())` cannot fake. The bridge now runs under three different spellings of the same address, one per step. --- .../src/services/auth/password_reset.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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 e339595..940c800 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -827,6 +827,40 @@ mod tests { // The password really changed: the stored hash is not the seeded one. let after = stored_hash(&h, &id).await; assert!(after.is_some() && after != before); + + // The verified-token bridge canonicalizes too, so the OTP minted under one spelling + // verifies under another and the token it returns completes the reset. + assert!( + h.engine + .initiate_reset(forgot("case@example.com")) + .await + .is_ok() + ); + let second = h + .stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .unwrap_or_default(); + assert!(!second.is_empty()); + let verified = h + .engine + .verify_reset_otp(VerifyResetOtpInput { + email: "cAsE@eXaMpLe.CoM".to_owned(), + tenant_id: "t1".to_owned(), + otp: second, + }) + .await; + assert!(verified.is_ok(), "the OTP must verify under any spelling"); + let Ok(verified_token) = verified else { return }; + let bridged = ResetPasswordInput { + email: "CASE@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "new-again".to_owned(), + token: None, + otp: None, + verified_token: Some(verified_token), + }; + assert!(h.engine.reset_password(bridged).await.is_ok()); + assert!(stored_hash(&h, &id).await != after); } #[tokio::test] From 4c9722ad0313369f856789d62480887443b3eb0c Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:12:42 -0300 Subject: [PATCH 054/122] test(core): drive the reset with the emailed token and prove the notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps on the password-reset path: - the token flow planted its own token and reset with that, so an `initiate_reset` that stored and sent nothing still passed. It now uses the token a real recipient receives — the only version of the flow where the store write and the mail are both load-bearing. - the `after_password_reset` notification was never asserted. The projection that feeds it fails open (the reset has already succeeded), so a skipped hook is silent — and this is the "your password changed" mail, the one signal a takeover victim gets. - `resend_reset_otp` canonicalizes its address like the other two entry points, and every branch answers `Ok(())`, so only the OTP record shows the canonicalization happened. --- .../src/services/auth/password_reset.rs | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) 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 940c800..a4d46f3 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -979,6 +979,26 @@ mod tests { .await .is_ok() ); + + // Resend canonicalizes its address like the other two entry points: a code requested + // under one spelling is filed where the confirm step will look for it. Every branch + // here answers `Ok(())`, so only the OTP record shows which one ran. + let identifier = h.engine.hashed_identifier("t1", "present@example.com"); + assert!( + h.engine + .resend_reset_otp(ResendResetOtpInput { + email: "PRESENT@Example.com".to_owned(), + tenant_id: "t1".to_owned(), + }) + .await + .is_ok() + ); + assert!( + h.stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .is_some(), + "the resent code must be filed under the canonical spelling" + ); } #[tokio::test] @@ -1114,6 +1134,193 @@ mod tests { } } + /// An email provider that keeps the reset token it was asked to deliver, so a test can + /// drive the flow with the token a real recipient would have received. + #[derive(Default)] + struct CapturingResetEmail { + token: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl crate::traits::EmailProvider for CapturingResetEmail { + async fn send_password_reset_token( + &self, + _email: &str, + token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + if let Ok(mut slot) = self.token.lock() { + *slot = Some(token.to_owned()); + } + Ok(()) + } + async fn send_password_reset_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_email_verification_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_mfa_enabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_mfa_disabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_new_session_alert( + &self, + _email: &str, + _session: &crate::traits::SessionInfo, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_invitation( + &self, + _email: &str, + _invite: &crate::traits::InviteData, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + } + + /// A hook spy recording the subject of every `after_password_reset` notification. + #[derive(Default)] + struct ResetHookSpy { + subjects: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl crate::traits::AuthHooks for ResetHookSpy { + async fn after_password_reset( + &self, + user: &SafeAuthUser, + _ctx: &crate::traits::HookContext, + ) -> Result<(), crate::traits::HookError> { + if let Ok(mut subjects) = self.subjects.lock() { + subjects.push(user.id.clone()); + } + Ok(()) + } + } + + #[tokio::test] + async fn a_completed_reset_notifies_the_hook_with_its_subject() { + // The projection feeding the hook can fail open — the reset has already succeeded by + // then, so the notification is simply skipped and nothing in the result says so. A + // deployment wires this to send the "your password changed" mail, which is the one + // signal a victim of an account takeover gets. + let spy = std::sync::Arc::new(ResetHookSpy::default()); + let hooks: std::sync::Arc = spy.clone(); + let mut cfg = base_config(); + cfg.password_reset.method = ResetMethod::Otp; + let Some(h) = harness(cfg, Some(hooks)) else { return }; + let id = h.seed(SeedUser::active("hooked@example.com", "old")).await; + let identifier = h.engine.hashed_identifier("t1", "hooked@example.com"); + assert!( + h.engine + .initiate_reset(forgot("hooked@example.com")) + .await + .is_ok() + ); + let code = h + .stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .unwrap_or_default(); + assert!(!code.is_empty()); + let reset = ResetPasswordInput { + email: "hooked@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "brand-new".to_owned(), + token: None, + otp: Some(code), + verified_token: None, + }; + assert!(h.engine.reset_password(reset).await.is_ok()); + // Long enough for the detached notification to have run. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let seen = spy.subjects.lock().map(|s| s.clone()).unwrap_or_default(); + assert_eq!(seen, vec![id]); + } + + #[tokio::test] + async fn the_emailed_reset_token_is_the_one_that_works() { + // Driven with the token a real recipient receives, rather than one the test plants + // itself: that is the only version of this flow where the store write and the mail + // are both load-bearing. With a planted token, an \`initiate_reset\` that quietly + // stored and sent nothing would still pass. + let mut cfg = base_config(); + cfg.password_reset.method = ResetMethod::Token; + let mailer = std::sync::Arc::new(CapturingResetEmail::default()); + let users = std::sync::Arc::new(crate::testing::InMemoryUserRepository::new()); + let stores = std::sync::Arc::new(crate::testing::InMemoryStores::new()); + let built = AuthEngine::builder() + .config(cfg) + .environment(crate::config::Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores) + .email_provider(mailer.clone()) + .build(); + let Ok(engine) = built else { return }; + let created = users + .create(bymax_auth_types::CreateUserData { + email: "mailed@example.com".to_owned(), + name: "M".to_owned(), + password_hash: Some("$scrypt$x".to_owned()), + role: Some("USER".to_owned()), + status: Some("ACTIVE".to_owned()), + tenant_id: "t1".to_owned(), + email_verified: Some(true), + }) + .await; + let Ok(user) = created else { return }; + + assert!( + engine + .initiate_reset(forgot("mailed@example.com")) + .await + .is_ok() + ); + let token = mailer.token.lock().ok().and_then(|t| t.clone()); + let token = token.unwrap_or_default(); + assert!(!token.is_empty(), "no reset token reached the recipient"); + + let reset = ResetPasswordInput { + email: "mailed@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "the-new-one".to_owned(), + token: Some(token), + otp: None, + verified_token: None, + }; + assert!(engine.reset_password(reset).await.is_ok()); + let after = users + .find_by_id(&user.id, None) + .await + .ok() + .flatten() + .and_then(|u| u.password_hash); + assert!(after.is_some() && after != Some("$scrypt$x".to_owned())); + } + #[tokio::test] async fn token_send_failure_deletes_the_unusable_token() { // On an undeliverable reset email the stored `pw_reset:` token is deleted so it cannot From a47150709c292a7d174c2a9907ac31c7c46161ae Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:13:47 -0300 Subject: [PATCH 055/122] test(core): register the same address in another casing Without canonicalization the uniqueness check misses a different spelling entirely: the tenant ends up with one row per casing and a later lookup resolves to whichever it happens to hit. The duplicate test used one spelling twice, where the guard reads the same either way. --- crates/bymax-auth-core/src/services/auth/register.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/bymax-auth-core/src/services/auth/register.rs b/crates/bymax-auth-core/src/services/auth/register.rs index 160068b..8a99424 100644 --- a/crates/bymax-auth-core/src/services/auth/register.rs +++ b/crates/bymax-auth-core/src/services/auth/register.rs @@ -220,6 +220,12 @@ mod tests { let _ = engine.register(input("dup@example.com"), &ctx()).await; let again = engine.register(input("dup@example.com"), &ctx()).await; assert!(matches!(again, Err(AuthError::EmailAlreadyExists))); + + // And a different casing is the same address: without canonicalization the uniqueness + // check misses, the tenant ends up with one row per spelling, and a later lookup + // resolves to whichever it happens to hit. + let shouted = engine.register(input("DUP@Example.COM"), &ctx()).await; + assert!(matches!(shouted, Err(AuthError::EmailAlreadyExists))); } /// A hook that rejects registration, to drive the `before_register` deny path. From b1d34a882dcec3aa6c8338e05226caca1c5fde9a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:17:03 -0300 Subject: [PATCH 056/122] chore(mutants): record the recovery-code bounds guards as equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index is returned by a search over the very vector being spliced, cloned from the same `AuthUser` value, so the guard can never be false and `<=` reaches the identical `remove`. It stays in the source as insurance against a future refactor deriving the index elsewhere — a panic there would be a denial of service on the MFA challenge path. --- .cargo/mutants.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index d585583..f1267d1 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -79,6 +79,12 @@ additional_cargo_args = ["--all-features"] # untestable effect. The pattern matches the trait defaults only: an implementation's # mutants are named `::…` and are still generated — which is what # catches a real hook that stops firing (see the platform after-logout spy). +# 9. The two `index < codes.len()` bounds guards before splicing a used recovery code out — +# the index is returned by a search over the very vector being spliced, and the vector is +# cloned from the same `AuthUser` value, so the guard can never be false and `<=` reaches +# the identical `remove`. It stays in the source as insurance against a future refactor +# deriving the index elsewhere: a panic here would be a denial of service on the MFA +# challenge path. Each function contains exactly one `<`, so the patterns are precise. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -88,4 +94,6 @@ exclude_re = [ 'token_manager\.rs:618:9: replace TokenManagerService::issue_mfa_temp_token', 'replace ::\w+ -> Result<\(\), EmailError> with Ok\(\(\)\)', 'replace AuthHooks::\w+ -> Result<\(\), HookError> with Ok\(\(\)\)', + 'replace < with <= in MfaService::challenge_platform', + 'replace < with <= in MfaService::splice_recovery_code', ] From 7ac2d8f38d6948b6865935b1a257245cd515b98c Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:20:46 -0300 Subject: [PATCH 057/122] test(mfa): pin the code discriminator and the challenge's session registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_totp_code` routes a submitted code to the TOTP verifier or the recovery-code scan, and both halves matter — six characters that are not digits, and digits that are not six characters, are both recovery-shaped. It is asserted directly because both paths answer the same `MfaInvalidCode`, so a misrouted code is invisible from outside. The challenge's session registration was equally invisible: the returned tokens look the same whether or not the session was recorded, and an unregistered session is missing from the session list, from the cap, and from sign-out-everywhere. --- .../src/services/mfa/challenge.rs | 25 +++++++++++++++++++ .../bymax-auth-core/src/services/mfa/tests.rs | 8 ++++++ 2 files changed, 33 insertions(+) diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 3d905a6..dd7308b 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -332,3 +332,28 @@ impl MfaService { fn is_totp_code(code: &str) -> bool { code.len() == 6 && code.bytes().all(|b| b.is_ascii_digit()) } + +#[cfg(test)] +mod tests { + use super::is_totp_code; + + #[test] + fn a_totp_code_is_six_digits_and_nothing_else() { + // This predicate routes a submitted code to the TOTP verifier or to the recovery-code + // scan, and both halves of it matter: six characters that are not digits, and digits + // that are not six characters, are both recovery-code shaped. Asserted directly + // because the two paths answer the same `MfaInvalidCode` for a wrong code, so a + // misrouted code is invisible from the outside. + assert!(is_totp_code("123456")); + assert!(is_totp_code("000000")); + // Right length, wrong alphabet. + assert!(!is_totp_code("abcdef")); + assert!(!is_totp_code("12345a")); + // Right alphabet, wrong length. + assert!(!is_totp_code("12345")); + assert!(!is_totp_code("1234567")); + assert!(!is_totp_code("")); + // A recovery code is neither. + assert!(!is_totp_code("ABCDE-FGHIJ-KLMNO-PQRST-UVWXY-Z2345")); + } +} diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 821ad07..e338c7f 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -248,6 +248,14 @@ async fn full_dashboard_lifecycle() { mfa.challenge(&temp, &challenge_code, "1.2.3.4", "ua").await, Ok(LoginResultMfa::Dashboard(_)) )); + // This harness has session tracking on, and the session the challenge issued has to be + // registered under the user — otherwise it is invisible to the session list, to the cap, + // and to "sign out everywhere". The returned tokens look identical either way. + let listed = h.engine.list_user_sessions(&uid, None).await; + assert!( + matches!(&listed, Ok(list) if !list.is_empty()), + "the challenge's session must be registered: {listed:?}" + ); // Challenge via a recovery code; then prove the code is single-use. let recovery = setup.recovery_codes[0].clone(); From 19ff5ada101b9095cf714f2a8997cdaf5bb202bb Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:23:27 -0300 Subject: [PATCH 058/122] test(mfa): prove disabling MFA alerts the account owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning off a second factor is exactly what an attacker holding the password does, so the mail and the hook are the owner's only warning — and both are fire-and-forget, so `disable` returns the same `Ok(())` whether they fired or not. The harness now takes an email provider and hooks so those notifications can be observed. --- .../bymax-auth-core/src/services/mfa/tests.rs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index e338c7f..a343fb9 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -52,6 +52,17 @@ struct Harness { /// repository (without enabling the platform domain) so the platform-context routing is /// exercised. fn build(sessions: bool, wire_platform: bool) -> Option { + build_with(sessions, wire_platform, None, None) +} + +/// The same harness, with an optional email provider and hooks so the fire-and-forget +/// notifications the MFA flows emit can be observed. +fn build_with( + sessions: bool, + wire_platform: bool, + email: Option>, + hooks: Option>, +) -> Option { let users = Arc::new(InMemoryUserRepository::new()); let stores = Arc::new(InMemoryStores::new()); let platform = Arc::new(InMemoryPlatformUserRepository::new()); @@ -76,6 +87,12 @@ fn build(sessions: bool, wire_platform: bool) -> Option { if wire_platform { builder = builder.platform_user_repository(platform.clone()); } + if let Some(email) = email { + builder = builder.email_provider(email); + } + if let Some(hooks) = hooks { + builder = builder.hooks(hooks); + } let engine = builder.build().ok()?; Some(Harness { engine, @@ -295,6 +312,150 @@ async fn full_dashboard_lifecycle() { assert!(matches!(after, Ok(Some(u)) if !u.mfa_enabled && u.mfa_secret.is_none())); } +/// An email + hook spy recording the security alerts the MFA management flows emit. +#[derive(Default)] +struct AlertSpy { + alerts: Mutex>, +} + +impl AlertSpy { + fn push(&self, alert: String) { + if let Ok(mut alerts) = self.alerts.lock() { + alerts.push(alert); + } + } + + fn seen(&self) -> Vec { + self.alerts.lock().map(|a| a.clone()).unwrap_or_default() + } +} + +#[async_trait] +impl EmailProvider for AlertSpy { + async fn send_password_reset_token( + &self, + _email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_email_verification_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_mfa_enabled( + &self, + email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + self.push(format!("mail:enabled:{email}")); + Ok(()) + } + async fn send_mfa_disabled( + &self, + email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + self.push(format!("mail:disabled:{email}")); + Ok(()) + } + async fn send_new_session_alert( + &self, + _email: &str, + _session: &crate::traits::SessionInfo, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_invitation( + &self, + _email: &str, + _invite: &crate::traits::InviteData, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } +} + +#[async_trait] +impl AuthHooks for AlertSpy { + async fn after_mfa_disabled( + &self, + user: &bymax_auth_types::SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + self.push(format!("hook:disabled:{}", user.id)); + Ok(()) + } +} + +#[tokio::test] +async fn disabling_mfa_alerts_the_account_owner() { + // Turning off a second factor is exactly what an attacker who has the password does, so + // the mail and the hook are the owner's only warning. Both are fire-and-forget, so + // `disable` returns the same `Ok(())` whether they fired or not. + let spy = Arc::new(AlertSpy::default()); + let email: Arc = spy.clone(); + let hooks: Arc = spy.clone(); + let Some(h) = build_with(false, false, Some(email), Some(hooks)) else { + return; + }; + let Some(uid) = register(&h.engine, "alert@example.com").await else { + return; + }; + let Some(mfa) = h.engine.mfa() else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + return; + }; + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &uid, + &code_at(&setup.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + assert!( + mfa.disable( + &uid, + &code_at(&setup.secret, base + 30), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + // Long enough for the detached notifications to have run. + tokio::time::sleep(Duration::from_millis(500)).await; + let seen = spy.seen(); + assert!( + seen.contains(&"mail:disabled:alert@example.com".to_owned()), + "no disabled mail: {seen:?}" + ); + assert!( + seen.contains(&format!("hook:disabled:{uid}")), + "no disabled hook: {seen:?}" + ); +} + #[tokio::test] async fn anti_replay_rejects_a_code_already_used_on_enable() { // A code spent enabling MFA cannot be replayed on the challenge path (the `tu:` marker From d3e513b78ddab4418d37487d23245980de650c37 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:25:40 -0300 Subject: [PATCH 059/122] test(mfa): alert on regenerated recovery codes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replacing the old set is how an attacker locks the real owner out of their own fallback, and the notification for it was as unobserved as the disable one — same fire-and-forget shape, same uniform `Ok(())`. --- .../bymax-auth-core/src/services/mfa/tests.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index a343fb9..477ee57 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -400,6 +400,14 @@ impl AuthHooks for AlertSpy { self.push(format!("hook:disabled:{}", user.id)); Ok(()) } + async fn after_mfa_recovery_codes_regenerated( + &self, + user: &bymax_auth_types::SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + self.push(format!("hook:regenerated:{}", user.id)); + Ok(()) + } } #[tokio::test] @@ -432,8 +440,11 @@ async fn disabling_mfa_alerts_the_account_owner() { .await .is_ok() ); + // Regenerating the recovery codes invalidates the old set, which is equally worth + // telling the owner about: it is how an attacker locks the real owner out of their own + // fallback. assert!( - mfa.disable( + mfa.regenerate_recovery_codes( &uid, &code_at(&setup.secret, base + 30), "1.2.3.4", @@ -443,6 +454,17 @@ async fn disabling_mfa_alerts_the_account_owner() { .await .is_ok() ); + assert!( + mfa.disable( + &uid, + &code_at(&setup.secret, base + 60), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); // Long enough for the detached notifications to have run. tokio::time::sleep(Duration::from_millis(500)).await; let seen = spy.seen(); @@ -454,6 +476,10 @@ async fn disabling_mfa_alerts_the_account_owner() { seen.contains(&format!("hook:disabled:{uid}")), "no disabled hook: {seen:?}" ); + assert!( + seen.contains(&format!("hook:regenerated:{uid}")), + "no regenerated hook: {seen:?}" + ); } #[tokio::test] From ae41013bc442c46eabd85a1cbd1464e2da8a9ff9 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:28:38 -0300 Subject: [PATCH 060/122] test(mfa): cover the enable alerts alongside the other two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second factor appearing on an account is the owner's cue that either they did it or someone else did, and its notification was as unobserved as the other two. One test now walks enable → regenerate → disable and asserts every alert on the way. --- .../bymax-auth-core/src/services/mfa/tests.rs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 477ee57..7c49c7e 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -392,6 +392,14 @@ impl EmailProvider for AlertSpy { #[async_trait] impl AuthHooks for AlertSpy { + async fn after_mfa_enabled( + &self, + user: &bymax_auth_types::SafeAuthUser, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + self.push(format!("hook:enabled:{}", user.id)); + Ok(()) + } async fn after_mfa_disabled( &self, user: &bymax_auth_types::SafeAuthUser, @@ -411,10 +419,11 @@ impl AuthHooks for AlertSpy { } #[tokio::test] -async fn disabling_mfa_alerts_the_account_owner() { - // Turning off a second factor is exactly what an attacker who has the password does, so - // the mail and the hook are the owner's only warning. Both are fire-and-forget, so - // `disable` returns the same `Ok(())` whether they fired or not. +async fn every_mfa_state_change_alerts_the_account_owner() { + // Enabling, regenerating and disabling a second factor are all account-security changes, + // and each one's mail and hook are the owner's only warning — turning MFA off is exactly + // what an attacker holding the password does. Every notification is fire-and-forget, so + // each call returns the same `Ok(())` whether it fired or not. let spy = Arc::new(AlertSpy::default()); let email: Arc = spy.clone(); let hooks: Arc = spy.clone(); @@ -468,6 +477,16 @@ async fn disabling_mfa_alerts_the_account_owner() { // Long enough for the detached notifications to have run. tokio::time::sleep(Duration::from_millis(500)).await; let seen = spy.seen(); + // Enabling is an account-security change too: a second factor appearing on an account + // is the owner's cue that either they did it or someone else did. + assert!( + seen.contains(&"mail:enabled:alert@example.com".to_owned()), + "no enabled mail: {seen:?}" + ); + assert!( + seen.contains(&format!("hook:enabled:{uid}")), + "no enabled hook: {seen:?}" + ); assert!( seen.contains(&"mail:disabled:alert@example.com".to_owned()), "no disabled mail: {seen:?}" From fed4d9a96e89ee2808b95e92007f31c0f8cbe74a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:35:03 -0300 Subject: [PATCH 061/122] test(crypto): accept a scrypt cost factor sitting exactly on the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2^14 is the documented minimum, not the first rejected value — and only a config on the boundary separates `<` from `<=`. Tightening it would reject the very parameters the constant advertises. --- crates/bymax-auth-crypto/src/password/tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/bymax-auth-crypto/src/password/tests.rs b/crates/bymax-auth-crypto/src/password/tests.rs index 5e84d59..af6248f 100644 --- a/crates/bymax-auth-crypto/src/password/tests.rs +++ b/crates/bymax-auth-crypto/src/password/tests.rs @@ -189,6 +189,18 @@ mod scrypt_tests { .is_err() ); assert!(ScryptParams::default().validate().is_ok()); + // The floor is inclusive: 2^14 is the documented minimum, not the first rejected + // value. Only a config sitting exactly on it separates `<` from `<=`, and refusing it + // would reject the very parameters the constant advertises. + assert!( + ScryptParams { + cost_factor: ScryptParams::MIN_COST_FACTOR, + ..ScryptParams::default() + } + .validate() + .is_ok() + ); + assert_eq!(ScryptParams::MIN_COST_FACTOR, 16_384); let weak = PasswordParams { scrypt: ScryptParams { From f41716d2ab7b43602fc34c9b55f4a653b89ef966 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:46:49 -0300 Subject: [PATCH 062/122] chore(mutants): record the HOTP truncation ORs as equivalent The dynamic-truncation assembly ORs four byte lanes shifted into disjoint bit ranges, so no two operands share a set bit and XOR produces the identical word. The `| with &` mutants of the same expression are not equivalent and the RFC 4226 vectors kill them. --- .cargo/mutants.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index f1267d1..02b0164 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -85,6 +85,10 @@ additional_cargo_args = ["--all-features"] # the identical `remove`. It stays in the source as insurance against a future refactor # deriving the index elsewhere: a panic here would be a denial of service on the MFA # challenge path. Each function contains exactly one `<`, so the patterns are precise. +# 10. `replace | with ^ in hotp` — the dynamic-truncation assembly ORs four byte lanes that +# were shifted into disjoint bit ranges (`<< 24`, `<< 16`, `<< 8`, none), so no two +# operands share a set bit and XOR produces the identical word. The `| with &` mutants of +# the same expression are NOT equivalent and the RFC 4226 vectors kill them. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -96,4 +100,5 @@ exclude_re = [ 'replace AuthHooks::\w+ -> Result<\(\), HookError> with Ok\(\(\)\)', 'replace < with <= in MfaService::challenge_platform', 'replace < with <= in MfaService::splice_recovery_code', + 'replace \| with \^ in hotp', ] From a724e1b873d58184e9798cea49db9fbfffb61b24 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:13:44 -0300 Subject: [PATCH 063/122] chore(mutants): record the legacy-hash fast path as equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_legacy` short-circuits `needs_rehash`, and a legacy `scrypt:hex:hex` string can never parse as a current PHC — so the fallback it skips answers `true` for exactly the same inputs. The `with true` mutant is not equivalent and is killed. --- .cargo/mutants.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 02b0164..186b6cc 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -89,6 +89,11 @@ additional_cargo_args = ["--all-features"] # were shifted into disjoint bit ranges (`<< 24`, `<< 16`, `<< 8`, none), so no two # operands share a set bit and XOR produces the identical word. The `| with &` mutants of # the same expression are NOT equivalent and the RFC 4226 vectors kill them. +# 11. `replace is_legacy -> bool with false` — its only caller is `needs_rehash`, where it is +# a fast path: a legacy `scrypt:hex:hex` string can never parse as a current PHC, so the +# fallback it short-circuits answers `true` for exactly the same inputs. The `with true` +# mutant of the same function is NOT equivalent (it would flag every current hash as +# stale) and is killed. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -101,4 +106,5 @@ exclude_re = [ 'replace < with <= in MfaService::challenge_platform', 'replace < with <= in MfaService::splice_recovery_code', 'replace \| with \^ in hotp', + 'replace is_legacy -> bool with false', ] From 4983b9cd03e66b26963c9559792102a297aae4d2 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:20:17 -0300 Subject: [PATCH 064/122] test(crypto): assert the legacy parser's guards at the parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every malformed-shape case went through `verify`, where a rejected parse and a wrong password both answer `Ok(false)` — so the three independent guards were indistinguishable and the key-length cap had no boundary. Asserted directly now: each field empty on its own, 64 bytes accepted, 65 rejected. --- .../bymax-auth-crypto/src/password/tests.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/bymax-auth-crypto/src/password/tests.rs b/crates/bymax-auth-crypto/src/password/tests.rs index af6248f..4e24596 100644 --- a/crates/bymax-auth-crypto/src/password/tests.rs +++ b/crates/bymax-auth-crypto/src/password/tests.rs @@ -119,6 +119,32 @@ mod scrypt_tests { assert!(matches!(verify(b"pw", &upper), Ok(false))); } + #[test] + fn legacy_parser_bounds_each_field_on_its_own() { + // Through `verify` every rejection above collapses to `Ok(false)` — which a wrong + // password produces too — so the guard itself is asserted at the parser, where the + // three conditions can be told apart and the cap has a boundary. + use crate::password::phc::parse_legacy; + assert!(parse_legacy("scrypt::00").is_none(), "empty salt alone"); + assert!(parse_legacy("scrypt:aa:").is_none(), "empty hash alone"); + // The cap is inclusive: exactly 64 bytes is the largest accepted key, and 65 is out. + let at_cap = format!("scrypt:aa:{}", "ab".repeat(64)); + assert!( + parse_legacy(&at_cap).is_some(), + "64 bytes is within the cap" + ); + let over_cap = format!("scrypt:aa:{}", "ab".repeat(65)); + assert!( + parse_legacy(&over_cap).is_none(), + "65 bytes is over the cap" + ); + // A well-formed pair decodes to its bytes. + assert_eq!( + parse_legacy("scrypt:0a0b:0c0d"), + Some((vec![0x0a, 0x0b], vec![0x0c, 0x0d])) + ); + } + #[test] fn needs_rehash_is_false_for_a_current_scrypt_hash() { // A hash written with the current params is not stale — rehash-on-verify must From ac82afad2dd9e0c5e8c2f1cf6d2794e3ada118e7 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:25:27 -0300 Subject: [PATCH 065/122] chore(mutants): record the hex nibble assembly as equivalent `(hi << 4) | lo` combines a high nibble with a value `hex_nibble` bounds to 0..=15, so the two never share a set bit and XOR writes the identical byte. --- .cargo/mutants.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 186b6cc..49e382a 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -94,6 +94,9 @@ additional_cargo_args = ["--all-features"] # fallback it short-circuits answers `true` for exactly the same inputs. The `with true` # mutant of the same function is NOT equivalent (it would flag every current hash as # stale) and is killed. +# 12. `replace | with ^ in decode_hex` — same shape as 1 and 10: `(hi << 4) | lo` combines a +# high nibble with a value `hex_nibble` bounds to 0..=15, so the two never share a set bit +# and XOR writes the identical byte. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -107,4 +110,5 @@ exclude_re = [ 'replace < with <= in MfaService::splice_recovery_code', 'replace \| with \^ in hotp', 'replace is_legacy -> bool with false', + 'replace \| with \^ in decode_hex', ] From aa7553680fd52a155728912cc8d7af2c0d9f7c96 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:31:36 -0300 Subject: [PATCH 066/122] test(crypto): pin the upper-case hex decode on its decoded value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nest-auth's legacy corpus carries both cases, and a decoder that dropped the A–F arm would reject the upper-case rows as malformed — invisible through `verify`, where that looks exactly like a wrong password. --- crates/bymax-auth-crypto/src/password/tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/bymax-auth-crypto/src/password/tests.rs b/crates/bymax-auth-crypto/src/password/tests.rs index 4e24596..0660157 100644 --- a/crates/bymax-auth-crypto/src/password/tests.rs +++ b/crates/bymax-auth-crypto/src/password/tests.rs @@ -143,6 +143,17 @@ mod scrypt_tests { parse_legacy("scrypt:0a0b:0c0d"), Some((vec![0x0a, 0x0b], vec![0x0c, 0x0d])) ); + // Upper-case hex decodes to the same bytes — nest-auth's corpus contains both, and a + // decoder that dropped the A–F arm would reject those rows as malformed. Asserted on + // the decoded value, because through `verify` it looks like a wrong password. + assert_eq!( + parse_legacy("scrypt:AABB:CCDD"), + Some((vec![0xaa, 0xbb], vec![0xcc, 0xdd])) + ); + assert_eq!( + parse_legacy("scrypt:AABB:CCDD"), + parse_legacy("scrypt:aabb:ccdd") + ); } #[test] From 036269121d47437acbc0e1ac834c426cca42e219 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:44:08 -0300 Subject: [PATCH 067/122] test(jwt): reject an expired token under the host clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system-clock path was asserted only with a token expiring at `i64::MAX`, which a clock stuck at the epoch accepts just as happily. A token that expired a minute ago under the real clock is the case that separates them — and it is the one that matters, because the failure mode is accepting every expired token forever. --- crates/bymax-auth-jwt/src/hs256.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/bymax-auth-jwt/src/hs256.rs b/crates/bymax-auth-jwt/src/hs256.rs index e1020ec..ef5fcfb 100644 --- a/crates/bymax-auth-jwt/src/hs256.rs +++ b/crates/bymax-auth-jwt/src/hs256.rs @@ -381,6 +381,21 @@ mod tests { let key = key(); let token = sign(&dashboard(0, i64::MAX), &key).unwrap_or_default(); assert!(verify::(&token, &key, &VerifyOptions::default()).is_ok()); + + // And the other side, which is the one that matters: a token that expired a minute + // ago under the *real* clock is rejected. Without it a clock stuck at the epoch — or + // at any fixed point in the past — would keep accepting every expired token, and the + // far-future assertion above would still pass. + let real_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + assert!(real_now > 1_700_000_000, "the host clock looks wrong"); + let expired = sign(&dashboard(real_now - 120, real_now - 60), &key).unwrap_or_default(); + assert_eq!( + verify::(&expired, &key, &VerifyOptions::default()), + Err(JwtError::Expired) + ); } #[test] From 9400e357f81f04316d29924c1c701c58e521e532 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:44:10 -0300 Subject: [PATCH 068/122] test(core): close the four survivors my earlier tests only looked like they killed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-running the sweep against the fixes showed four assertions that never touched their subject: - the builder's refresh-lifetime arithmetic feeds the *session service*, which uses it only on rotation — the login assertion was reading the token manager's separate copy. Pinned through an engine-built rotation. - `register`'s canonicalization was asserted by a duplicate conflict, but the in-memory repository compares case-insensitively, so the conflict proves nothing. The *stored* address is asserted instead — on a store that compares bytes, a row keyed by whatever the user typed resolves to a different identity. - the resend canonicalization was asserted on an account whose OTP an earlier `initiate` had already filed. It uses a fresh account, with the absence asserted first. - the challenge's session registration was asserted by the session list, which the token manager fills on its own. The new-session hook is the observation point that actually depends on the session service. --- crates/bymax-auth-core/src/engine/builder.rs | 50 +++++++++++++++++ .../src/services/auth/password_reset.rs | 14 ++++- .../src/services/auth/register.rs | 13 ++++- .../bymax-auth-core/src/services/mfa/tests.rs | 56 +++++++++++++++++++ crates/bymax-auth-core/src/testing/mod.rs | 11 ++++ 5 files changed, 138 insertions(+), 6 deletions(-) diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 7a6c87e..faaaf00 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -595,6 +595,56 @@ mod tests { Arc::new(InMemoryStores::new()) } + #[tokio::test] + async fn the_session_service_gets_the_configured_refresh_lifetime() { + // The builder derives the session-touch TTL from `refresh_expires_in_days`, separately + // from the token manager's copy, and hands it to the session service — where it lands + // on every rotation. Nothing else observes that arithmetic, so a `+` in place of the + // `*` would silently store rotated sessions with a 24-hour-and-change lifetime. + let stores = stores(); + let mut cfg = valid_config(); + cfg.jwt.refresh_expires_in_days = 7; + let built = AuthEngine::builder() + .config(cfg) + .user_repository(user_repo()) + .redis_stores(stores.clone()) + .build(); + let Ok(engine) = built else { return }; + + let old_hash = "a".repeat(64); + let new_hash = "b".repeat(64); + let record = crate::traits::SessionRecord { + user_id: "u1".to_owned(), + tenant_id: Some("t1".to_owned()), + role: "ADMIN".to_owned(), + device: "Chrome".to_owned(), + ip: "203.0.113.4".to_owned(), + created_at: time::OffsetDateTime::UNIX_EPOCH, + mfa_enabled: false, + family_id: String::new(), + family_created_at: None, + }; + assert!( + stores + .create_session( + crate::traits::SessionKind::Dashboard, + &old_hash, + &record, + 3600 + ) + .await + .is_ok() + ); + assert!( + engine + .sessions() + .rotate_session(&old_hash, &new_hash, &record) + .await + .is_ok() + ); + assert_eq!(stores.peek_rotate_ttl(), Some(7 * 86_400)); + } + #[test] fn builds_with_noop_defaults_and_exposes_every_accessor() { // A minimal valid wiring assembles, defaulting the email provider and hooks to the 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 a4d46f3..f3c25c5 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -982,12 +982,20 @@ mod tests { // Resend canonicalizes its address like the other two entry points: a code requested // under one spelling is filed where the confirm step will look for it. Every branch - // here answers `Ok(())`, so only the OTP record shows which one ran. - let identifier = h.engine.hashed_identifier("t1", "present@example.com"); + // here answers `Ok(())`, so only the OTP record shows which one ran — and it has to be + // an account with no code on file yet, or an earlier `initiate` would have left one + // under that identifier and the assertion would hold either way. + let _ = h.seed(SeedUser::active("shout@example.com", "pw")).await; + let identifier = h.engine.hashed_identifier("t1", "shout@example.com"); + assert!( + h.stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .is_none() + ); assert!( h.engine .resend_reset_otp(ResendResetOtpInput { - email: "PRESENT@Example.com".to_owned(), + email: "SHOUT@Example.com".to_owned(), tenant_id: "t1".to_owned(), }) .await diff --git a/crates/bymax-auth-core/src/services/auth/register.rs b/crates/bymax-auth-core/src/services/auth/register.rs index 8a99424..e299ed9 100644 --- a/crates/bymax-auth-core/src/services/auth/register.rs +++ b/crates/bymax-auth-core/src/services/auth/register.rs @@ -221,11 +221,18 @@ mod tests { let again = engine.register(input("dup@example.com"), &ctx()).await; assert!(matches!(again, Err(AuthError::EmailAlreadyExists))); - // And a different casing is the same address: without canonicalization the uniqueness - // check misses, the tenant ends up with one row per spelling, and a later lookup - // resolves to whichever it happens to hit. + // And a different casing is the same address. The in-memory repository compares + // case-insensitively, so the conflict alone does not prove canonicalization — what + // proves it is the *stored* address: a row keyed by whatever the user shouted would + // resolve to a different identity on any store that compares bytes, which is most of + // them. let shouted = engine.register(input("DUP@Example.COM"), &ctx()).await; assert!(matches!(shouted, Err(AuthError::EmailAlreadyExists))); + + let fresh = engine.register(input("MiXeD@Example.COM"), &ctx()).await; + assert!(matches!(&fresh, Ok(LoginResult::Success(_)))); + let Ok(LoginResult::Success(auth)) = fresh else { return }; + assert_eq!(auth.user.email, "mixed@example.com"); } /// A hook that rejects registration, to drive the `before_register` deny path. diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 7c49c7e..84ee81b 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -392,6 +392,15 @@ impl EmailProvider for AlertSpy { #[async_trait] impl AuthHooks for AlertSpy { + async fn on_new_session( + &self, + user: &bymax_auth_types::SafeAuthUser, + _session: &crate::traits::SessionInfo, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + self.push(format!("hook:new_session:{}", user.id)); + Ok(()) + } async fn after_mfa_enabled( &self, user: &bymax_auth_types::SafeAuthUser, @@ -501,6 +510,53 @@ async fn every_mfa_state_change_alerts_the_account_owner() { ); } +#[tokio::test] +async fn a_challenge_registers_its_session_with_the_session_service() { + // The challenge issues a session like a login does, and with tracking on it must go + // through the session service — that is what enforces the per-user cap and fires the + // new-session notification. The tokens it returns look identical either way, so the + // hook is the observation point. + let spy = Arc::new(AlertSpy::default()); + let hooks: Arc = spy.clone(); + let Some(h) = build_with(true, false, None, Some(hooks)) else { + return; + }; + let Some(uid) = register(&h.engine, "tracked@example.com").await else { + return; + }; + let Some(mfa) = h.engine.mfa() else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + return; + }; + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &uid, + &code_at(&setup.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + let Some(temp) = login_temp_token(&h.engine, "tracked@example.com").await else { + return; + }; + assert!(matches!( + mfa.challenge(&temp, &code_at(&setup.secret, base + 30), "1.2.3.4", "ua") + .await, + Ok(LoginResultMfa::Dashboard(_)) + )); + // The notification is fire-and-forget. + tokio::time::sleep(Duration::from_millis(500)).await; + let seen = spy.seen(); + assert!( + seen.contains(&format!("hook:new_session:{uid}")), + "the challenge's session never reached the session service: {seen:?}" + ); +} + #[tokio::test] async fn anti_replay_rejects_a_code_already_used_on_enable() { // A code spent enabling MFA cannot be replayed on the challenge path (the `tu:` marker diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 3a7db72..50c9a48 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -307,6 +307,9 @@ pub struct InMemoryStores { /// `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>, + /// The refresh TTL the last `rotate` was given, in seconds — the session-touch path's + /// copy of the same lifetime, wired separately from the token manager's. + last_rotate_ttl_secs: Mutex>, /// The TTL the last `create_session` was given, in seconds. The real store turns this /// into the key's expiry — the only thing that makes a session end on its own — so it is /// recorded rather than discarded, and read back through [`InMemoryStores::peek_session_ttl`]. @@ -349,6 +352,13 @@ impl InMemoryStores { *lock(&self.last_session_ttl_secs) } + /// The refresh TTL the last rotation was stored with, in seconds. A test-only inspection + /// helper, for the same reason as [`InMemoryStores::peek_session_ttl`]. + #[must_use] + pub fn peek_rotate_ttl(&self) -> Option { + *lock(&self.last_rotate_ttl_secs) + } + /// Read the stored OTP code for a purpose + identifier without consuming it. A test-only /// inspection helper (the real store never exposes a stored code), used to drive the /// verification flow end to end against the in-memory double. @@ -398,6 +408,7 @@ impl SessionStore for InMemoryStores { kind: SessionKind, rotation: &SessionRotation, ) -> Result { + *lock(&self.last_rotate_ttl_secs) = Some(rotation.refresh_ttl); let mut sessions = lock(&self.sessions); if let Some(old_record) = sessions.remove(&(kind, rotation.old_hash.clone())) { sessions.insert( From 5e59f9fd8d37e8cb69340d6bf8be398bdc5254d6 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:51:41 -0300 Subject: [PATCH 069/122] test(mfa): count the new-session notifications instead of looking for one The registration at the top of the test had already fired the hook once, so asserting its presence held even with the challenge's own session registration removed. Counted across the challenge now, and red-checked: the test fails with `enforce_session_limit` short-circuited and passes without. --- crates/bymax-auth-core/src/services/mfa/tests.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 84ee81b..d3838fc 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -543,6 +543,12 @@ async fn a_challenge_registers_its_session_with_the_session_service() { let Some(temp) = login_temp_token(&h.engine, "tracked@example.com").await else { return; }; + // Counted, not merely present: the registration at the top of this test already issued a + // session and fired this hook once, so an assertion on presence alone would hold with the + // challenge's own registration removed entirely. + tokio::time::sleep(Duration::from_millis(500)).await; + let event = format!("hook:new_session:{uid}"); + let before = spy.seen().iter().filter(|e| **e == event).count(); assert!(matches!( mfa.challenge(&temp, &code_at(&setup.secret, base + 30), "1.2.3.4", "ua") .await, @@ -550,10 +556,11 @@ async fn a_challenge_registers_its_session_with_the_session_service() { )); // The notification is fire-and-forget. tokio::time::sleep(Duration::from_millis(500)).await; - let seen = spy.seen(); - assert!( - seen.contains(&format!("hook:new_session:{uid}")), - "the challenge's session never reached the session service: {seen:?}" + let after = spy.seen().iter().filter(|e| **e == event).count(); + assert_eq!( + after, + before + 1, + "the challenge's session never reached the session service" ); } From 85427422312a794feef5951a12b7d95f967b8033 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:03:04 -0300 Subject: [PATCH 070/122] test(core): keep the new suites at 100% lines Three habits of mine cost coverage the repo holds at 100%: an `if let` whose `None` arm never runs, a `let ... else { return }` split across lines (the same statement on one line counts as covered), and an `assert!` failure message on its own line, which is only evaluated when the assertion breaks. The two new email doubles also get their untouched methods exercised, the way `FailingResetEmail` already did. --- .../src/services/adapter_api.rs | 19 ++++----- .../src/services/auth/detached.rs | 30 +++++++++++++ .../src/services/auth/invitation.rs | 42 ++++++++----------- .../src/services/auth/password_reset.rs | 36 ++++++++++++++++ .../bymax-auth-core/src/services/platform.rs | 4 +- 5 files changed, 93 insertions(+), 38 deletions(-) diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 6ea4e48..3cb398c 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -536,13 +536,14 @@ mod tests { ("SUPER_ADMIN".to_owned(), vec!["SUPPORT".to_owned()]), ("SUPPORT".to_owned(), Vec::new()), ])); - let platform = harness(with_hierarchy, None); - assert!(platform.is_some(), "the platform fixture must assemble"); - if let Some(p) = platform { - assert!(p.engine.platform_role_satisfies("SUPER_ADMIN", "SUPPORT")); - assert!(p.engine.platform_role_satisfies("SUPPORT", "SUPPORT")); - assert!(!p.engine.platform_role_satisfies("SUPPORT", "SUPER_ADMIN")); - } + let consulted = harness(with_hierarchy, None).map(|p| { + ( + p.engine.platform_role_satisfies("SUPER_ADMIN", "SUPPORT"), + p.engine.platform_role_satisfies("SUPPORT", "SUPPORT"), + p.engine.platform_role_satisfies("SUPPORT", "SUPER_ADMIN"), + ) + }); + assert_eq!(consulted, Some((true, true, false))); } let digest = h.engine.hashed_identifier_for("t1", "a@e.com"); assert_eq!(digest.len(), 64); @@ -638,9 +639,7 @@ mod tests { ) .await; assert!(matches!(&second, Ok(LoginResult::Success(_)))); - let Ok(LoginResult::Success(second)) = second else { - return; - }; + let Ok(LoginResult::Success(second)) = second else { return }; assert!(matches!( h.engine.list_user_sessions(&sub, None).await, Ok(list) if list.len() == 2 diff --git a/crates/bymax-auth-core/src/services/auth/detached.rs b/crates/bymax-auth-core/src/services/auth/detached.rs index 334ba1d..9e98ccd 100644 --- a/crates/bymax-auth-core/src/services/auth/detached.rs +++ b/crates/bymax-auth-core/src/services/auth/detached.rs @@ -409,6 +409,36 @@ mod tests { "reset_otp:reset@example.com:654321", ] ); + + // Exercise the rest of the double's surface so the object-safe impl is fully covered; + // only the two sends above are load-bearing. + let direct = RecordingEmails::default(); + assert!( + direct + .send_password_reset_token("e", "t", None) + .await + .is_ok() + ); + assert!(direct.send_mfa_enabled("e", None).await.is_ok()); + assert!(direct.send_mfa_disabled("e", None).await.is_ok()); + let session = crate::traits::email::SessionInfo { + device: "d".to_owned(), + ip: "i".to_owned(), + session_hash: "h".to_owned(), + }; + assert!( + direct + .send_new_session_alert("e", &session, None) + .await + .is_ok() + ); + let invite = crate::traits::email::InviteData { + inviter_name: "n".to_owned(), + tenant_name: "t".to_owned(), + invite_token: "tok".to_owned(), + expires_at: OffsetDateTime::UNIX_EPOCH, + }; + assert!(direct.send_invitation("e", &invite, None).await.is_ok()); } #[tokio::test] diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 897a465..9d332b1 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -530,31 +530,23 @@ mod tests { ]; for (index, (label, invitation)) in cases.into_iter().enumerate() { let token = format!("{}{index}", "a".repeat(63)); - assert!( - s.stores - .put_invitation(&token, &invitation, 600) - .await - .is_ok(), - "{label}" - ); - assert!( - matches!( - s.engine - .accept_invitation( - AcceptInvitationInput { - token, - name: "N".to_owned(), - password: "pw".to_owned(), - }, - "1.2.3.4", - "agent", - BTreeMap::new(), - ) - .await, - Err(AuthError::InvalidInvitationToken) - ), - "{label} must be rejected" - ); + let stored = s.stores.put_invitation(&token, &invitation, 600).await; + assert!(stored.is_ok(), "{label} could not be stored"); + let outcome = s + .engine + .accept_invitation( + AcceptInvitationInput { + token, + name: "N".to_owned(), + password: "pw".to_owned(), + }, + "1.2.3.4", + "agent", + BTreeMap::new(), + ) + .await; + let rejected = matches!(outcome, Err(AuthError::InvalidInvitationToken)); + assert!(rejected, "{label} was accepted"); } } 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 f3c25c5..a28f333 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -1327,6 +1327,42 @@ mod tests { .flatten() .and_then(|u| u.password_hash); assert!(after.is_some() && after != Some("$scrypt$x".to_owned())); + + // Exercise the rest of the capturing double's surface so the object-safe impl is + // fully covered; only the reset-token send is load-bearing above. + let provider = CapturingResetEmail::default(); + assert!( + provider + .send_password_reset_otp("e", "o", None) + .await + .is_ok() + ); + assert!( + provider + .send_email_verification_otp("e", "o", None) + .await + .is_ok() + ); + assert!(provider.send_mfa_enabled("e", None).await.is_ok()); + assert!(provider.send_mfa_disabled("e", None).await.is_ok()); + let session = crate::traits::SessionInfo { + device: "d".to_owned(), + ip: "i".to_owned(), + session_hash: "h".to_owned(), + }; + assert!( + provider + .send_new_session_alert("e", &session, None) + .await + .is_ok() + ); + let invite = crate::traits::InviteData { + inviter_name: "n".to_owned(), + tenant_name: "t".to_owned(), + invite_token: "tok".to_owned(), + expires_at: time::OffsetDateTime::UNIX_EPOCH, + }; + assert!(provider.send_invitation("e", &invite, None).await.is_ok()); } #[tokio::test] diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 511cef8..48c2721 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -749,9 +749,7 @@ mod tests { let id = seed_admin(&admins, "hooked@admin.io", "pw"); let Some(svc) = engine.platform_auth() else { return }; let logged = svc.login("hooked@admin.io", "pw", "1.2.3.4", "agent").await; - let Ok(PlatformLoginResult::Success(auth)) = logged else { - return; - }; + let Ok(PlatformLoginResult::Success(auth)) = logged else { return }; assert!( svc.logout(&auth.access_token, &auth.refresh_token, &id) .await From c9339c31d7d46f91ebffefdf0fb95e00bd9af7cc Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:12:37 -0300 Subject: [PATCH 071/122] docs: bring the README and changelog up to what this cycle shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage badge still read `pre-release` and the testing section claimed 100% *region* coverage, which was never true — regions sit at 96.65%, and the gate the CI enforces is `--fail-under-lines 100`. Both now say what is measured, and a mutation badge points at the config that defines the gate. The configuration table was missing every option this cycle added: the absolute session lifetime, the trusted-origin allowlist, and the per-route rate limits. The two that are deliberately off by default now say why, and the breached-password checker gets the wiring example it needs — it rides the crate's own `HttpClient`, so a deployment supplies the transport it already has. `breach` joins the facade's feature list. The feature names on `bymax-auth` are a stable contract from the start even while the bodies are placeholders, so a capability that exists in `bymax-auth-core` and cannot be named through the facade is a gap in that contract. The README also gains the roadmap section its nest-auth counterpart has, and both security tables gain the five defences that landed here. The changelog's `[Unreleased]` covered the scaffolding and nothing since. --- CHANGELOG.md | 71 ++++++++++++++++++++++++++++++++++++ README.md | 70 ++++++++++++++++++++++++++++++++--- crates/bymax-auth/Cargo.toml | 2 + 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72735e4..d7f70a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,5 +32,76 @@ version bump. with edge JWT verification. - `docs/RELEASE.md` documenting the deferred publish pipeline and the one-time OIDC / protected-environment setup it requires. +- **Cross-site request refusal** — an `Origin` / `Sec-Fetch-Site` check on + cookie-authenticated writes, as a tower layer. `SameSite` covers this for + `Lax`/`Strict`; it does not for `None`, which the library allows and which sends + the session cookie cross-site. On by default; `cookies.trusted_origins` is + required as soon as `same_site` is `None`, and refused under any other posture. +- **Breached-password refusal** — the `PasswordBreachChecker` seam with a bundled + `HibpBreachChecker` (feature `breach`) riding the crate's existing `HttpClient`, + so a deployment supplies the transport it already has. Only a 5-character SHA-1 + prefix leaves the process, and the checker fails **open** by contract: an + unreachable corpus must never stop someone changing their password. Off by + default (`AllowAllBreachChecker`). +- **Absolute session lifetime** — `jwt.absolute_session_lifetime_days` caps how + long one login can be extended by rotation. `refresh_expires_in_days` bounds a + token, not a session. Off by default: switching it on ends sessions already + older than the cap. +- **`email_verified` on the OAuth profile** — `create_with_oauth` was called with + `Some(true)` unconditionally. No bug today (the bundled Google provider refuses + an unverified profile before building one), but the first third-party provider + written against the old contract would have created a verified account from an + address nobody proved they owned. +- **Per-route rate limits pinned to the shared contract** — the adapter already + enforced them; what was missing was the agreement, with 21 numbers duplicated + across two repositories and nothing checking they matched. + +### Changed + +- **Family-lineage reuse detection replaces the previous sentinel.** A login opens + a family; every rotation inherits it; a replay past the grace window revokes that + lineage and only that lineage. `revoke_family` prunes the **prefixed** index + member (`rt:{hash}`), not the bare hash — the index format changed underneath it, + and pruning a bare hash would have left every revoked session listed until the + index itself expired. +- **The rotation Lua scripts no longer decode stored records.** `nest-auth` drives + its end-to-end tier against an in-memory Redis whose Lua VM has no `cjson`, so a + script that decodes JSON is one the shared contract cannot be exercised against + on that side. The grace record's family and the family owner's id are parsed by + the caller instead, with a real parser. +- **The grace window is single-shot.** The pointer was served on every request + inside the window, so one captured consumed token could mint a session + repeatedly. It is consumed on use now, matching `nest-auth`. + +### Fixed + +- **A grace pointer could resurrect a revoked lineage.** Reuse detection only + proves the *replayed* token's own pointer expired; a pointer planted by an + earlier rotation of the same lineage can still be live, and recovering from it + minted a session carrying the revoked family id. A recovery now requires its + family index to still exist. Red-checked with a three-token lineage. +- **A consumed token replayed after a revoke-all reported `Invalid` rather than + `Reused`.** The `cf:` marker deliberately outlives both the pointer and the + revoke-all, so a replay stays a theft signal. It never minted a session either + way. +- **`handlers.ts` in `packages/rust-auth` had no tests at all** — the one module + that writes `Set-Cookie` back to a browser. It is at 100 % lines now, and the + package runs under a coverage ratchet in CI so it can only go up. + +### Internal + +- **The mutation gate's configuration was never being read.** `cargo-mutants` + loads `.cargo/mutants.toml` and nothing else; the file sat at the repository + root, where it is ignored in silence — so `examine_globs` scoped nothing, + `bindings/`, `examples/` and `fuzz/` were being mutated despite the excludes, + and CI had been running the same way. Moved, with the path requirement written + into its header and a one-line check (`cargo mutants --list` must print only + `crates/` paths). With the file finally read, `additional_cargo_args` supplies + `--all-features` and cargo refuses the flag twice, so the copy on CI's command + line is gone. +- Every surviving mutant the sweep reported is closed — killed by a new test or + recorded in `.cargo/mutants.toml` with the reason it cannot be. Re-running the + sweep over the survivors is what caught four fixes that asserted the wrong + thing, so each of those is red-checked by hand. [Unreleased]: https://github.com/bymaxone/rust-auth/commits/main diff --git a/README.md b/README.md index b3a7922..a259846 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ npm version docs.rs CI status - coverage + coverage + mutation gate OpenSSF Scorecard RustSec audit build provenance @@ -418,13 +419,14 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se | Group | Key options | nest-compat default | | ------------------ | ---------------------------------------------------------------------------- | -------------------------- | -| **jwt** | `secret` (required, ≥ 32 chars), `access_ttl`, `refresh_expires_in_days` | `15m`, `7d`, HS256 (pinned) | +| **jwt** | `secret` (required, ≥ 32 chars), `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days` | `15m`, `7d`, off, HS256 (pinned) | | **password** | `active_algorithm`, scrypt `cost_factor` / Argon2id `memory_kib` | scrypt N=2¹⁵, r=8, p=1 | | **token_delivery** | `Cookie` \| `Bearer` \| `Both` | `Cookie` | -| **cookies** | names, `refresh_cookie_path`, `same_site`, `resolve_domains` | HttpOnly, Secure, Strict | +| **cookies** | names, `refresh_cookie_path`, `same_site`, `trusted_origins`, `resolve_domains` | HttpOnly, Secure, Strict, `[]` | | **mfa** | `encryption_key` (32 bytes), `issuer`, `totp_window`, `recovery_code_count` | — | | **sessions** | `enabled`, `default_max_sessions`, `max_sessions_resolver` | `false`, `5` | | **brute_force** | `max_attempts`, `window_seconds` | `5`, `900` | +| **rate limiting** | `AxumAuthConfig::rate_limits` — per-route governor limits, pinned to the shared contract | on, per-route | | **password_reset** | `method` (`Token` \| `Otp`), `otp_length`, `token_ttl` | `Token`, 600 s | | **platform** | `enabled` (requires `roles.platform_hierarchy`) | `false` | | **invitations** | `enabled`, `token_ttl` | `false`, 48 h | @@ -433,7 +435,36 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se | **controllers** | per-group route toggles | feature-driven | > [!NOTE] -> `build()` validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, `SameSite=None ⇒ Secure`, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise `ConfigError`. +> `build()` validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, `SameSite=None ⇒ Secure`, `SameSite=None ⇔ trusted_origins`, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise `ConfigError`. + +Two options are deliberately off by default, because switching either on changes behaviour for +sessions and origins that already exist: + +- `jwt.absolute_session_lifetime_days` caps how long one login can be extended by rotation. + Without it, a client refreshing every fifteen minutes keeps a session alive forever. +- `cookies.trusted_origins` is required as soon as `same_site` is `None`, and refused under any + other posture — that is the only setting where the browser sends the session cookie + cross-site, and therefore the only one where an origin needs authorizing. + +The breached-password check is opt-in for a different reason: it is the only part of the +credential path that reaches the network, and a library should not start talking to a third +party because it was upgraded. It rides the crate's own `HttpClient` seam, so a deployment +supplies the transport it already has: + +```rust +// Cargo.toml: bymax-auth = { version = "…", features = ["breach"] } +use bymax_auth::HibpBreachChecker; + +let engine = AuthEngine::builder() + .config(config) + .user_repository(users) + .redis_stores(stores) + .breach_checker(Arc::new(HibpBreachChecker::new(http_client))) + .build()?; +``` + +The checker fails **open** by contract: an unreachable corpus must never stop someone changing +their password — least of all during an incident, when changing it is the urgent thing. --- @@ -527,6 +558,11 @@ When integrating `bymax-auth` in production, verify each of the following: | Cookies | HttpOnly, Secure-by-default, `SameSite=Strict`, path-scoped refresh | | Brute-Force | Redis atomic fixed-window counters per `HMAC(tenant:email)` | | CSRF (OAuth) | 64-hex single-use `state` (`GETDEL`) + PKCE `code_verifier` (S256) | +| Refresh Rotation | Single-use tokens with a grace window; a replay past it revokes that login's whole family lineage | +| Cross-Site Writes | `Origin` / `Sec-Fetch-Site` check on cookie-authenticated writes — the gap `SameSite=None` leaves open | +| Breached Passwords| Optional Have I Been Pwned range check by k-anonymity; only a 5-char SHA-1 prefix leaves the process | +| Rate Limiting | Per-route `tower_governor` layer, in-process; the limits themselves are pinned to the shared contract | +| Session Lifetime | Optional absolute cap on how long one login can be extended by rotation | | Edge Verify | Same HS256 primitive compiled to WebAssembly — no network call | > [!IMPORTANT] @@ -593,8 +629,8 @@ Tracked with [Criterion](https://github.com/bheisler/criterion.rs) so a regressi Authentication is critical infrastructure, so the suite is held to a bar beyond "it compiles" — every behavior is pinned so a regression **fails a test**. -- ✅ **100% line + region coverage** — enforced as a release gate via [`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov) across the full `cargo-hack` feature matrix -- ✅ **Near-100% mutation score** — verified with [`cargo-mutants`](https://mutants.rs/): faults are seeded into the source and the suite must catch them +- ✅ **100% line and function coverage** — enforced as a release gate via [`cargo-llvm-cov --fail-under-lines 100`](https://github.com/taiki-e/cargo-llvm-cov) across the full `cargo-hack` feature matrix +- ✅ **Mutation-gated** — [`cargo-mutants`](https://mutants.rs/) seeds faults into the source and the suite must catch them; every surviving mutant is either killed by a new test or recorded in [`.cargo/mutants.toml`](.cargo/mutants.toml) with the reason it cannot be. The sweep runs post-merge on `main`, never on a PR (it takes hours: the Redis stores are exercised against a real container) - ✅ **Property tests + fuzzing** — `proptest` round-trips and `cargo-fuzz` smoke runs over the trust-boundary parsers (JWT, PHC, base32) - ✅ **Real-Redis E2E** — atomic Lua, rotation/grace, and revocation proven against `redis:8` via [`testcontainers`](https://github.com/testcontainers/testcontainers-rs) - ✅ **Edge parity** — `wasm-bindgen-test` confirms the WASM verifier accepts a token signed by the backend @@ -686,6 +722,28 @@ Route groups mount only when their feature **and** runtime toggle are enabled, s --- +## 🗺️ Roadmap + +The items below are on deck for future releases. None ship today — the list exists so +contributors can see where the workspace is headed and where help is most useful. Open an issue +to discuss priorities or propose a design. + +| Area | Item | Status | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------- | +| Registry publishing | OIDC trusted publishing, the `release` workflow, SBOM/attestation publishing, and the tag ↔ version gate (P12's second half) | Deferred | +| OAuth providers | A provider trait implementation set beyond Google — GitHub, Microsoft, Apple — behind the existing `OAuthProvider` seam | Planned | +| Error-message i18n | Locale presets for `AuthError`'s user-facing messages (defaults are English) | Planned | +| Passwordless / magic link | Single-use link flow reusing `generate_secure_token` and the `EmailProvider` seam | Exploring | +| Passkeys / WebAuthn | WebAuthn as an MFA method, and eventually a first factor, behind its own feature | Exploring | +| Per-tenant configuration | Per-tenant overrides for session limits, MFA enforcement, and password policy, resolved per request | Exploring | +| Pluggable password policy | A `PasswordPolicy` seam for complexity classes and per-tenant rules (the breach check already ships as `PasswordBreachChecker`) | Planned | +| Additional adapters | An `actix-web` adapter alongside `bymax-auth-axum`, sharing the same engine and wire contract | Exploring | + +> Track progress and discuss proposals on the [issues board](https://github.com/bymaxone/rust-auth/issues). +> Phase-level status for the work already delivered lives in [docs/development_plan.md](./docs/development_plan.md). + +--- + ## 🤝 Contributing Contributions are welcome! Please read the contributing guidelines before opening a pull request. diff --git a/crates/bymax-auth/Cargo.toml b/crates/bymax-auth/Cargo.toml index 2f13c2d..29808a1 100644 --- a/crates/bymax-auth/Cargo.toml +++ b/crates/bymax-auth/Cargo.toml @@ -29,6 +29,7 @@ oauth = [] # OAuth orchestration + traits; no HTTP client oauth-reqwest = ["oauth"] # adds the bundled ReqwestHttpClient (pulls reqwest) platform = [] invitations = [] +breach = [] # breached-password refusal (HIBP k-anonymity over the HttpClient seam) # Infrastructure / adapters. redis = [] @@ -45,6 +46,7 @@ full = [ "oauth-reqwest", "platform", "invitations", + "breach", "redis", "axum", "client", From 7961843ad59c3d38e31b0c602a32ea8017a6d40c Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:15:41 -0300 Subject: [PATCH 072/122] docs(spec): document the two new config options and drop a stale non-goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The specification listed breach-check among the things the library deliberately does not do; it ships behind the `PasswordBreachChecker` seam now, opt-in, and the non-goal row would have sent a reader looking for a hook that already exists. The configuration schema gains `jwt.absolute_session_lifetime_days` and `cookies.trusted_origins`, and the startup-validation list gains the invariant that binds the allowlist to `SameSite::None` in both directions — either half alone fails quietly. --- docs/technical_specification.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/technical_specification.md b/docs/technical_specification.md index 4f4c4cf..2622cb1 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -293,7 +293,7 @@ used in §5.4). - MFA: if MFA is enabled, `encryption_key` decodes to exactly 32 bytes and `issuer` is non-empty. - Platform: if platform is enabled, a `PlatformUserRepository` was supplied. - OAuth: if an OAuth feature is on, at least one `OAuthProvider` is registered and redirect URLs satisfy the production HTTPS/relative-path rule. -- Cookie/route coherence (e.g. the MFA temp-cookie path matches the real MFA route; `SameSite=None` requires secure cookies). +- Cookie/route coherence (e.g. the MFA temp-cookie path matches the real MFA route; `SameSite=None` requires secure cookies **and** a trusted-origin allowlist, which is in turn refused under any other posture — either half alone fails quietly, one rejecting every cross-site call and the other never being consulted). - Feature/collaborator coherence: every enabled flow has the stores and repositories it needs. The contrast with NestJS is deliberate: there are **no decorators, no reflection, and no token registry**. Wiring is ordinary constructor code, missing dependencies are caught by the type system or by `build()`, and the validated `AuthEngine` is the single thing handed to the adapter. This is more verbose than `registerAsync({...})` but fully explicit and statically checked. @@ -1043,6 +1043,7 @@ The **Default** column lists `AuthConfig::default()` (≡ `nest_compat_defaults( | `jwt.refresh_expires_in_days` | `u32` | No | `7` | Refresh lifetime (days) | | `jwt.algorithm` | `JwtAlgorithm` | No | `Hs256` | Pinned — HS256 only | | `jwt.refresh_grace_window` | `Duration` | No | `30s` | Must be < refresh lifetime | +| `jwt.absolute_session_lifetime_days` | `u32` | No | `0` (off) | Caps how long one login can be extended by rotation; `0` disables the cap | | `roles.hierarchy` | `HashMap>` | Yes | — | Non-empty; referential integrity enforced | | `roles.platform_hierarchy` | `Option>` | Cond. | `None` | Required when `platform.enabled` | | `password.active_algorithm` | `PasswordAlgorithm` | No | `Scrypt` | New-hash algorithm; `Argon2id` requires the `argon2` feature (§5.1.9) | @@ -1060,7 +1061,8 @@ The **Default** column lists `AuthConfig::default()` (≡ `nest_compat_defaults( | `cookies.session_signal_name` | `String` | No | `has_session` | Non-HttpOnly login signal | | `cookies.refresh_cookie_path` | `String` | No | `/auth` | Must align with `route_prefix` | | `cookies.mfa_temp_cookie_path` | `String` | No | `/auth/mfa` | Scopes OAuth-MFA temp cookie | -| `cookies.same_site` | `SameSite` | No | `Lax` | `None` requires `secure_cookies` | +| `cookies.same_site` | `SameSite` | No | `Lax` | `None` requires `secure_cookies` **and** a non-empty `trusted_origins` | +| `cookies.trusted_origins` | `Vec` | Cond. | `[]` | Origins allowed to make cookie-authenticated cross-site writes; required under `SameSite::None`, refused otherwise. Each entry is compared verbatim against the `Origin` header, so it must be a bare `scheme://host[:port]` | | `cookies.resolve_domains` | `Option>` | No | `None` | Multi-domain resolver | | `mfa` | `Option` | Cond. | `None` | Presence enables MFA config | | `mfa.encryption_key` | `SecretString` | Cond. | — | Decodes to exactly 32 bytes | @@ -6906,7 +6908,7 @@ non-goals explicitly keeps the surface unambiguous for the dev plans that follow | **Tenant creation, billing, plans, subscriptions** | Business logic specific to the platform (billing belongs to a Stripe/billing module). | Host app's tenants/billing modules. | | **Tenant *resolution* strategy** | *How* a request's tenant is determined (subdomain/header/path) is application-specific; the library accepts a `tenant_id_resolver` callback but ships no policy. | Host app supplies the resolver closure. | | **Audit logging / SIEM** | The library emits `tracing` spans and exposes `before/after` auth **hooks**; it does not persist an audit trail. | Host app records via the hooks + its audit/observability stack. | -| **Custom password-policy rules** | The library enforces only baseline constraints (e.g. minimum length); richer policies (dictionary, breach-check, complexity) are not built in. | Host app via the `beforeRegister` hook or DTO validation. | +| **Custom password-policy rules** | The library enforces baseline constraints (e.g. minimum length) and ships the breached-password check behind the `PasswordBreachChecker` seam (opt-in, feature `breach`); richer policies — dictionary, complexity classes, per-tenant rules — are not built in. | Host app via the `before_register` hook or DTO validation; a custom `PasswordBreachChecker` for a private corpus. | | **Additional profile fields** | Anything beyond the `AuthUser` contract. | Host app's profile table. | | **Observability backends (metrics/traces export)** | The library is instrumented with `tracing`; wiring exporters (OTLP/Prometheus) is the host's job. | Host app's `tracing-subscriber` setup. | | **Performance SLAs / load & capacity testing** | Auth is I/O-bound; the library ships `criterion` benchmarks for regression-visibility on hot paths (§20.11) but makes **no throughput guarantee** and is not a load-testing harness. | Host app's perf/capacity testing against its own deployment. | From 4a01a8728f126e3f27b0f11e0a802f09500c2334 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:01:55 -0300 Subject: [PATCH 073/122] docs: record the first complete mutation sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1,630 mutants, 1,242 caught, 95 detected by timeout, 293 unviable, zero survivors — the first full run under the corrected configuration, and the number the README and badge were missing. The changelog also records where the 95 timeouts sit and why it matters to anyone tuning the gate: they are all in the container-backed stores, a mutation there is detected by the suite hanging rather than asserting, and each spends the full 119 s window — about half the eight-hour run. Shortening the window trades hours for gate integrity, because a slow test cut short is reported as detected and would hide a survivor. --- CHANGELOG.md | 11 ++++++++++- README.md | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f70a1..ff32870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,15 @@ version bump. - Every surviving mutant the sweep reported is closed — killed by a new test or recorded in `.cargo/mutants.toml` with the reason it cannot be. Re-running the sweep over the survivors is what caught four fixes that asserted the wrong - thing, so each of those is red-checked by hand. + thing, so each of those is red-checked by hand. The first full sweep under the + corrected configuration confirms it: **1,630 mutants, 1,242 caught, 95 detected + by timeout, 293 unviable, zero survivors** (8 h wall clock). +- Recorded for whoever tunes the gate next: 95 of the 95 timeouts sit in the + container-backed stores (`bymax-auth-redis`, 90 of them, and three in + `bymax-auth-client`). A mutation there is detected by the suite *hanging* rather + than asserting, and each one spends the full 119 s window — roughly half the + run's wall clock. Shortening the window would buy hours at the cost of gate + integrity, since a legitimately slow test cut short is reported as detected and + would hide a survivor; the sound fix is making those tests fail fast instead. [Unreleased]: https://github.com/bymaxone/rust-auth/commits/main diff --git a/README.md b/README.md index a259846..dc7a8d4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ docs.rs CI status coverage - mutation gate + mutation gate OpenSSF Scorecard RustSec audit build provenance @@ -630,7 +630,7 @@ Tracked with [Criterion](https://github.com/bheisler/criterion.rs) so a regressi Authentication is critical infrastructure, so the suite is held to a bar beyond "it compiles" — every behavior is pinned so a regression **fails a test**. - ✅ **100% line and function coverage** — enforced as a release gate via [`cargo-llvm-cov --fail-under-lines 100`](https://github.com/taiki-e/cargo-llvm-cov) across the full `cargo-hack` feature matrix -- ✅ **Mutation-gated** — [`cargo-mutants`](https://mutants.rs/) seeds faults into the source and the suite must catch them; every surviving mutant is either killed by a new test or recorded in [`.cargo/mutants.toml`](.cargo/mutants.toml) with the reason it cannot be. The sweep runs post-merge on `main`, never on a PR (it takes hours: the Redis stores are exercised against a real container) +- ✅ **100% mutation score** — [`cargo-mutants`](https://mutants.rs/) seeds faults into the source and the suite must catch them. The last full sweep: **1,630 mutants, 1,242 caught, 95 detected by timeout, 293 unviable — zero survivors**. The handful that no test can kill is recorded in [`.cargo/mutants.toml`](.cargo/mutants.toml) with the reason each is equivalent. The sweep runs post-merge on `main`, never on a PR: it takes ~8 hours, because the Redis stores are exercised against a real container and a mutation there is detected by hanging - ✅ **Property tests + fuzzing** — `proptest` round-trips and `cargo-fuzz` smoke runs over the trust-boundary parsers (JWT, PHC, base32) - ✅ **Real-Redis E2E** — atomic Lua, rotation/grace, and revocation proven against `redis:8` via [`testcontainers`](https://github.com/testcontainers/testcontainers-rs) - ✅ **Edge parity** — `wasm-bindgen-test` confirms the WASM verifier accepts a token signed by the backend From 69a51d139e3d63f9765d8547e5aae4d2e882980d Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:17:42 -0300 Subject: [PATCH 074/122] fix: repair the four gates this branch broke outside the crate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these ran in the local verification I was using, which is why all four reached CI: - **The wasm smoke test** stopped compiling when the token epoch was added to `DashboardClaims`. It only builds for `wasm32`, so `cargo test --workspace` never sees it. The fixture signs at generation 0 — the value a server that has never bumped a user's epoch issues, and the one the edge verifier must accept, since the comparison lives on the server, the only side holding the stored counter. - **The npm package's coverage ratchet** was added without its dependency: `@vitest/coverage-v8` was missing from `packages/rust-auth`, so `npm run test:cov` failed on a clean install while passing locally against a warm `node_modules`. - **Two rustdoc intra-doc links** were broken: one pointed at a private item from public documentation, and one named a type behind the `breach` feature. - **The §24 security-invariant gate** flagged invariant 4 — "a token must never be read from the query string" — against a doc comment that *documents that invariant*: a reword in this branch put "query string" and "bearer" on one line, and the check greps source lines without distinguishing code from prose. The invariant check now strips comment-only lines before matching. That is a change to a security gate, so it is red-checked: a `q.query.get("access_token")` injected into the extractor makes it fail, and removing it makes it pass again. The first attempt at this fix silently stopped detecting anything — the red-check is what caught that, not review. --- bindings/bymax-auth-wasm/tests/web.rs | 5 + .../src/extractors/platform.rs | 6 +- crates/bymax-auth-core/src/config/validate.rs | 6 +- crates/bymax-auth-core/src/engine/builder.rs | 2 +- packages/rust-auth/package-lock.json | 198 ++++++++++++++++++ packages/rust-auth/package.json | 3 +- scripts/check-invariants.sh | 12 +- 7 files changed, 223 insertions(+), 9 deletions(-) diff --git a/bindings/bymax-auth-wasm/tests/web.rs b/bindings/bymax-auth-wasm/tests/web.rs index bdde983..c295d96 100644 --- a/bindings/bymax-auth-wasm/tests/web.rs +++ b/bindings/bymax-auth-wasm/tests/web.rs @@ -26,6 +26,11 @@ fn sign_dashboard(iat: i64, exp: i64) -> String { status: "ACTIVE".to_owned(), mfa_enabled: true, mfa_verified: false, + // Generation 0 — the value a server that has never bumped a user's epoch issues, and + // the one the edge verifier must accept without any knowledge of the current epoch: + // the check that compares them lives on the server, which is the only side with the + // stored counter. + epoch: 0, iat, exp, }; diff --git a/crates/bymax-auth-axum/src/extractors/platform.rs b/crates/bymax-auth-axum/src/extractors/platform.rs index e2af19d..d2b5ef3 100644 --- a/crates/bymax-auth-axum/src/extractors/platform.rs +++ b/crates/bymax-auth-axum/src/extractors/platform.rs @@ -12,9 +12,9 @@ use crate::response::AuthRejection; use crate::state::AuthState; /// Verify the platform access token once per request, caching the claims on -/// `parts.extensions`. The token is sourced from the `Authorization: Bearer` header only -/// (never a cookie, never a query string) because platform sessions are always bearer; a -/// dashboard token presented here fails the `type == platform` assertion and is mapped to +/// `parts.extensions`. The token is sourced from the `Authorization: Bearer` header only — +/// never a cookie, and never a query string, since platform sessions carry no cookie at all. +/// A dashboard token presented here fails the `type == platform` assertion and is mapped to /// `PlatformAuthRequired`. async fn verified_platform_claims( parts: &mut Parts, diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 7a1250e..a540cc0 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -68,8 +68,10 @@ impl ResolvedConfig { /// The derived identifier-hashing key — the ASCII hex of `SHA-256("{label}:{jwt.secret}")` /// — used to HMAC low-entropy Redis identifiers so the signing key and the - /// identifier-hashing key are cryptographically independent. See [`derive_hmac_key`] for - /// why the encoding is part of the contract rather than an implementation detail. + /// identifier-hashing key are cryptographically independent. The 64-byte ASCII-hex + /// encoding is part of the cross-implementation contract, not an implementation detail: + /// `nest-auth` derives the same key the same way, so a key derived from the raw digest + /// would silently hash every identifier differently and split the shared keyspace. #[must_use] pub fn hmac_key(&self) -> &[u8; 64] { self.hmac_key.expose_secret() diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index faaaf00..d3fe677 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -124,7 +124,7 @@ impl AuthEngineBuilder { /// [`AllowAllBreachChecker`], which approves everything and touches no network). /// /// Wiring one is opt-in on purpose: a crate should not start talking to a third-party - /// corpus because it was upgraded. The bundled [`HibpBreachChecker`] (feature `breach`) + /// corpus because it was upgraded. The bundled `HibpBreachChecker` (feature `breach`) /// runs over the same [`HttpClient`](crate::traits::HttpClient) seam the OAuth flows use. #[must_use] pub fn breach_checker(mut self, checker: Arc) -> Self { diff --git a/packages/rust-auth/package-lock.json b/packages/rust-auth/package-lock.json index 0cc8833..7601a21 100644 --- a/packages/rust-auth/package-lock.json +++ b/packages/rust-auth/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^26.1.1", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.6.0", "eslint-plugin-security": "^4.0.1", "jsdom": "^29.1.1", @@ -113,6 +114,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -123,6 +134,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -133,6 +160,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2861,6 +2912,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -3071,6 +3153,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -3736,6 +3837,16 @@ "node": ">=10.13.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3749,6 +3860,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3806,6 +3924,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4285,6 +4442,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-it": { "version": "14.3.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", @@ -5116,6 +5301,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/packages/rust-auth/package.json b/packages/rust-auth/package.json index 47fd5b8..a97f031 100644 --- a/packages/rust-auth/package.json +++ b/packages/rust-auth/package.json @@ -2,7 +2,7 @@ "name": "@bymax-one/rust-auth", "version": "0.0.0", "type": "module", - "description": "Frontend (React/Next.js) auth + edge JWT for the rust-auth backend \u2014 the byte-for-byte npm counterpart of @bymax-one/nest-auth.", + "description": "Frontend (React/Next.js) auth + edge JWT for the rust-auth backend — the byte-for-byte npm counterpart of @bymax-one/nest-auth.", "license": "MIT", "repository": { "type": "git", @@ -75,6 +75,7 @@ "@types/node": "^26.1.1", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^10.6.0", "eslint-plugin-security": "^4.0.1", "jsdom": "^29.1.1", diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index 2a3970d..1b8da1b 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -104,8 +104,16 @@ done < <(find crates bindings -name lib.rs) # ── Invariant 4: bearer/refresh credentials are never read from the query string. ── # Flag any extractor that pulls an access/refresh token out of a query map. -if grep -rEn 'query[^;]*(access_token|refresh_token|bearer)' "${SRC_GLOB[@]}" \ - --include='*.rs' -i >/dev/null 2>&1; then +# +# Comment lines are stripped first. The pattern is deliberately loose — it has to catch an +# extractor written in a shape nobody predicted — and that looseness makes it match prose +# that *documents* the invariant just as readily as code that breaks it. Punishing the +# documentation would push the next author to describe this rule less clearly, or not at +# all, which is the opposite of what a security gate is for. Code is never a comment, so +# nothing that could actually read a token is skipped here. +if grep -rEn -i 'query[^;]*(access_token|refresh_token|bearer)' "${SRC_GLOB[@]}" \ + --include='*.rs' 2>/dev/null \ + | grep -qvE '^[^:]+:[0-9]+:[[:space:]]*//'; then note "invariant 4: a token must never be read from the query string" else pass "invariant 4: no token read from a query string" From 2ca2603f2e072b88f1d9e860eeb9442facfaf754 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:49:59 -0300 Subject: [PATCH 075/122] feat(core,axum): close the audit's parity gaps against nest-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the cross-implementation audit, each a place where one backend protected or reported something the other did not. Header sanitization was three names against nest-auth's fifteen plus a suffix rule. The sanitized map is handed to host-supplied hooks, so a host wiring one audit sink behind both backends received `x-api-key`, `proxy-authorization` and every forwarded-identity header from this side and not the other. The blocklist now matches entry for entry, and nest-auth's `^x-.*-(token|secret|key|password|credential|auth|bearer|signature|hmac)$` is reproduced as a suffix test — the leading `x-` is stripped and the remainder must carry a dash of its own, so `x-request-token` is withheld and `x-token`, which the regex also declines, is not. Expressed without a regex dependency. Security logging was three events against roughly seventy. Login lockouts, invalid credentials, MFA lockouts and rejected codes, refresh-token reuse, a completed password reset, and every best-effort cleanup that failed now emit, with `mask_email` reproducing nest-auth's masking so one log pipeline shows one spelling for one account. `SessionNotFound` on the logout path stays silent — it is the ordinary outcome for a session already rotated, and logging it would bury the outage it is meant to surface. Conformance covered prefixes and rate limits but not `recordEncodings` or `accessTokenClaims`, the two sections that decide whether a record written by one backend is readable by the other. Both are now asserted against the shared file, including the deliberate split where the session detail's timestamps are numeric while the refresh session's are ISO-8601. Making the swallowed failures visible put their branches under the 100% line gate for the first time, which they could not meet against a double that always succeeds — so `InMemoryStores` gained `fail_next_cleanup_writes`, and the paths are now asserted rather than assumed. Two `tracing` calls are kept on one line deliberately: a field expression on its own line is never evaluated without an installed subscriber and would read as uncovered while being fully exercised. --- crates/bymax-auth-axum/src/routes/mod.rs | 166 +++++++++++++++++- crates/bymax-auth-core/src/lib.rs | 2 +- crates/bymax-auth-core/src/normalize.rs | 57 +++++- .../src/services/auth/email_verification.rs | 1 + .../src/services/auth/invitation.rs | 153 +++++++++++++++- .../src/services/auth/login.rs | 13 +- .../src/services/auth/password_reset.rs | 26 ++- .../src/services/auth/register.rs | 1 + .../src/services/auth/session_ops.rs | 79 ++++++++- .../src/services/mfa/challenge.rs | 30 +++- .../src/services/mfa/manage.rs | 4 +- .../bymax-auth-core/src/services/mfa/mod.rs | 11 +- .../bymax-auth-core/src/services/mfa/setup.rs | 4 + .../bymax-auth-core/src/services/platform.rs | 10 +- .../bymax-auth-core/src/services/session.rs | 97 +++++++++- .../src/services/token_manager.rs | 18 +- crates/bymax-auth-core/src/testing/mod.rs | 30 ++++ crates/bymax-auth-core/src/traits/store.rs | 156 ++++++++++++++++ crates/bymax-auth-types/src/claims.rs | 74 ++++++++ 19 files changed, 894 insertions(+), 38 deletions(-) diff --git a/crates/bymax-auth-axum/src/routes/mod.rs b/crates/bymax-auth-axum/src/routes/mod.rs index e01f9f9..9a00208 100644 --- a/crates/bymax-auth-axum/src/routes/mod.rs +++ b/crates/bymax-auth-axum/src/routes/mod.rs @@ -38,13 +38,75 @@ use crate::dto::RefreshDto; use crate::extractors::source_access_token; use crate::state::AuthState; -/// The set of request headers that must never enter a `RequestContext`'s sanitized map (the -/// credential-bearing ones). Lowercased to match the normalized header keys. This is the -/// single source of truth for "sensitive" headers: both [`sanitize_headers`] (which drops -/// them from the engine context) and the tracing redaction layer -/// ([`sensitive_header_names`]) derive from it, so a header is never redacted in one path but -/// recorded in the other. -const SENSITIVE_HEADERS: [&str; 3] = ["authorization", "cookie", "x-csrf-token"]; +/// The set of request headers that must never enter a `RequestContext`'s sanitized map. +/// Lowercased to match the normalized header keys. This is the single source of truth for +/// "sensitive" headers: both [`sanitize_headers`] (which drops them from the engine context) +/// and the tracing redaction layer ([`sensitive_header_names`]) derive from it, so a header is +/// never redacted in one path but recorded in the other. +/// +/// The list is nest-auth's `BLOCKED_HEADERS`, entry for entry, because the sanitized map is +/// handed to host-supplied hooks: a host that wires the same audit sink behind both backends +/// must not receive a header from one that the other withholds. Two categories are stripped: +/// +/// - **Credential-bearing** (`authorization`, `cookie`, `proxy-authorization`, +/// `www-authenticate`, `x-api-key`, `x-auth-token`, `x-csrf-token`, `x-session-id`) — these +/// are secrets, and a hook that logs its context would persist them verbatim. +/// - **Forwarded-identity** (`x-forwarded-for`, `x-forwarded-host`, `x-real-ip`, +/// `x-original-forwarded-for`, `cf-connecting-ip`, `true-client-ip`, `x-cluster-client-ip`) +/// — trivially spoofed by the client. A hook must take the address from +/// [`RequestContext::ip`], which the adapter resolves from the peer socket, never from a +/// header the caller chose. +const SENSITIVE_HEADERS: [&str; 15] = [ + "authorization", + "cookie", + "proxy-authorization", + "www-authenticate", + "x-api-key", + "x-auth-token", + "x-csrf-token", + "x-session-id", + "x-forwarded-for", + "x-forwarded-host", + "x-real-ip", + "x-original-forwarded-for", + "cf-connecting-ip", + "true-client-ip", + "x-cluster-client-ip", +]; + +/// Suffixes that mark a custom `x-`-prefixed header as secret-bearing. +/// +/// This is the second half of nest-auth's filter, whose regex is +/// `^x-.*-(token|secret|key|password|credential|auth|bearer|signature|hmac)$`. Expressed as +/// suffixes rather than a pattern so the crate keeps its dependency surface, matching the +/// regex exactly: the leading `x-` is stripped first and the REMAINDER must end with a +/// dash-prefixed suffix, so `x-request-token` is stripped while `x-token` — which the regex +/// also declines, since its `.*-` needs a dash of its own — is kept. +const SENSITIVE_HEADER_SUFFIXES: [&str; 9] = [ + "-token", + "-secret", + "-key", + "-password", + "-credential", + "-auth", + "-bearer", + "-signature", + "-hmac", +]; + +/// Whether a lowercased header name must be withheld from the sanitized map. +/// +/// The blocklist is the floor; the suffix rule is what keeps a host's own convention +/// (`x-internal-service-key`, `x-webhook-signature`) from leaking through a filter that only +/// knew the names this library ships with. +fn is_sensitive_header(key: &str) -> bool { + SENSITIVE_HEADERS.contains(&key) + || key.strip_prefix("x-").is_some_and(|rest| { + SENSITIVE_HEADER_SUFFIXES + .iter() + .any(|suffix| rest.ends_with(suffix)) + }) +} /// The sensitive headers as typed [`HeaderName`]s, for the `SetSensitiveRequestHeadersLayer` /// that masks them in `tracing` spans/events. Derived from [`SENSITIVE_HEADERS`] so the @@ -90,7 +152,7 @@ fn sanitize_headers(parts: &Parts) -> BTreeMap { let mut map = BTreeMap::new(); for (name, value) in parts.headers.iter() { let key = name.as_str().to_ascii_lowercase(); - if SENSITIVE_HEADERS.contains(&key.as_str()) { + if is_sensitive_header(&key) { continue; } if let Ok(text) = value.to_str() { @@ -251,6 +313,94 @@ mod tests { assert_eq!(names.len(), SENSITIVE_HEADERS.len()); } + #[test] + fn the_blocklist_matches_nest_auths_entry_for_entry() { + // The sanitized map goes to host-supplied hooks. A host wiring one audit sink behind + // both backends must not receive from one what the other withholds, so the list is + // pinned by name rather than by count alone. + for blocked in [ + "authorization", + "cookie", + "proxy-authorization", + "www-authenticate", + "x-api-key", + "x-auth-token", + "x-csrf-token", + "x-session-id", + "x-forwarded-for", + "x-forwarded-host", + "x-real-ip", + "x-original-forwarded-for", + "cf-connecting-ip", + "true-client-ip", + "x-cluster-client-ip", + ] { + assert!( + is_sensitive_header(blocked), + "{blocked} is on nest-auth's blocklist but reaches the hook context here" + ); + } + } + + #[test] + fn the_suffix_rule_reproduces_nest_auths_pattern() { + // Every suffix the pattern names, on a custom header a host might define. + for sensitive in [ + "x-refresh-token", + "x-client-secret", + "x-service-key", + "x-api-password", + "x-service-credential", + "x-service-auth", + "x-custom-bearer", + "x-webhook-signature", + "x-service-hmac", + ] { + assert!(is_sensitive_header(sensitive), "{sensitive} leaked"); + } + + // …and the boundaries, which are where a suffix rule and the regex it stands in for + // could quietly disagree. The regex requires a dash of its own before the suffix, so a + // bare `x-token` is NOT sensitive; a non-`x-` header is never matched by the pattern + // however it ends; and an ordinary header is untouched. + for benign in [ + "x-token", + "x-key", + "content-type", + "x-request-id", + "user-agent", + "session-token", + "accept", + ] { + assert!( + !is_sensitive_header(benign), + "{benign} was stripped, but nest-auth forwards it" + ); + } + } + + #[test] + fn a_custom_secret_header_never_reaches_the_hook_context() { + // End to end through the real context builder: the blocklist, the suffix rule, and a + // benign header, so the filter is pinned where it is actually applied. + let parts = parts_with(&[ + ("x-api-key", "k-1"), + ("proxy-authorization", "Basic abc"), + ("x-internal-service-key", "s-1"), + ("x-request-id", "req-1"), + ]); + let ctx = request_context(&parts); + assert!(!ctx.sanitized_headers.contains_key("x-api-key")); + assert!(!ctx.sanitized_headers.contains_key("proxy-authorization")); + assert!(!ctx.sanitized_headers.contains_key("x-internal-service-key")); + assert_eq!( + ctx.sanitized_headers + .get("x-request-id") + .map(String::as_str), + Some("req-1") + ); + } + #[test] fn peer_ip_is_empty_without_connect_info() { // No `ConnectInfo` extension → an empty IP (the engine treats it as unknown). diff --git a/crates/bymax-auth-core/src/lib.rs b/crates/bymax-auth-core/src/lib.rs index ab5e337..bedfcc9 100644 --- a/crates/bymax-auth-core/src/lib.rs +++ b/crates/bymax-auth-core/src/lib.rs @@ -48,7 +48,7 @@ pub use engine::{AuthEngine, AuthEngineBuilder}; #[doc(inline)] pub use error::{ConfigError, RepositoryError}; #[doc(inline)] -pub use normalize::normalize_email; +pub use normalize::{mask_email, normalize_email}; #[cfg(feature = "oauth")] #[doc(inline)] pub use providers::GoogleOAuthProvider; diff --git a/crates/bymax-auth-core/src/normalize.rs b/crates/bymax-auth-core/src/normalize.rs index 70ea1f2..f04a37c 100644 --- a/crates/bymax-auth-core/src/normalize.rs +++ b/crates/bymax-auth-core/src/normalize.rs @@ -28,9 +28,37 @@ pub fn normalize_email(email: &str) -> String { email.trim().to_lowercase() } +/// Mask an email address for safe inclusion in a log line. +/// +/// Keeps the first character of the local part and the whole domain, so an operator reading a +/// lockout or failed-login warning can tell which account it is about without the log becoming a +/// store of personal data. A value with no local part — no `@`, or one in the first position — +/// masks entirely rather than leaking the fragment it does have. +/// +/// Byte-for-byte the same rule as nest-auth's `maskEmail`, so one log pipeline fed by both +/// backends shows one spelling for one account. +/// +/// # Examples +/// +/// ``` +/// # use bymax_auth_core::mask_email; +/// assert_eq!(mask_email("john.doe@example.com"), "j***@example.com"); +/// assert_eq!(mask_email("@example.com"), "***"); +/// ``` +#[must_use] +pub fn mask_email(email: &str) -> String { + match email.find('@') { + Some(at) if at > 0 => { + let first = email.chars().next().unwrap_or_default(); + format!("{first}***{}", &email[at..]) + } + _ => "***".to_owned(), + } +} + #[cfg(test)] mod tests { - use super::normalize_email; + use super::{mask_email, normalize_email}; #[test] fn trims_and_lowercases() { @@ -78,4 +106,31 @@ mod tests { "a.b+tag@example.com" ); } + + #[test] + fn masking_keeps_the_first_character_and_the_domain() { + // What an operator needs from a lockout warning is which account and which domain — + // never the full address, which would turn the log into a store of personal data. + assert_eq!(mask_email("john.doe@example.com"), "j***@example.com"); + assert_eq!(mask_email("a@b.co"), "a***@b.co"); + } + + #[test] + fn masking_hides_a_value_with_no_local_part() { + // No `@` at all, or one in the first position, means there is no local part to keep a + // character of — so nothing is echoed rather than the fragment that does exist. + assert_eq!(mask_email("@example.com"), "***"); + assert_eq!(mask_email("not-an-email"), "***"); + assert_eq!(mask_email(""), "***"); + } + + #[test] + fn masking_survives_a_multibyte_local_part() { + // The domain is sliced at the byte index of `@`, and the kept character is taken as a + // char: a non-ASCII first character must not panic on a byte boundary. + assert_eq!( + mask_email("\u{e9}lise@example.com"), + "\u{e9}***@example.com" + ); + } } diff --git a/crates/bymax-auth-core/src/services/auth/email_verification.rs b/crates/bymax-auth-core/src/services/auth/email_verification.rs index cd144c4..90b7a49 100644 --- a/crates/bymax-auth-core/src/services/auth/email_verification.rs +++ b/crates/bymax-auth-core/src/services/auth/email_verification.rs @@ -61,6 +61,7 @@ impl AuthEngine { .await .map_err(map_repository_error)?; + tracing::info!(user_id = %user.id, %tenant_id, "verify email: address verified"); let hook_ctx = verification_context(&user.id, &user.email, tenant_id); let safe = SafeAuthUser::from(user); spawn_guarded(run_after_email_verified( diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 9d332b1..4558d11 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -120,10 +120,14 @@ impl AuthEngine { expires_at, }; // Best-effort delivery: a send failure does not roll back the persisted invitation. - let _ = self + if let Err(error) = self .email_provider() .send_invitation(&email, &invite_data, None) - .await; + .await + { + tracing::error!(%error, "invitation: delivery failed (the invitation stands)"); + } + tracing::info!(%tenant_id, role = %invitation.role, "invitation: created"); Ok(()) } @@ -160,6 +164,12 @@ impl AuthEngine { || invitation.tenant_id.is_empty() || !hierarchy.contains_key(&invitation.role) { + // The consume is a single-use GETDEL, so the record is already gone: a rejection + // here destroys the invitation rather than merely failing it, and an undeclared role + // is what a tampered token looks like. Both are worth an operator's attention. + tracing::warn!( + "invitation: stored record rejected as empty or carrying an undeclared role" + ); return Err(AuthError::InvalidInvitationToken); } @@ -211,6 +221,7 @@ impl AuthEngine { self.enforce_sessions_after_issue(&result, ip, user_agent, &hook_ctx) .await?; + tracing::info!(user_id = %safe.id, tenant_id = %safe.tenant_id, "invitation: accepted"); spawn_guarded(run_after_invitation_accepted( self.hooks().clone(), safe, @@ -238,7 +249,9 @@ mod tests { use super::*; use crate::config::{AuthConfig, Environment}; use crate::testing::{InMemoryStores, InMemoryUserRepository}; - use crate::traits::{InvitationStore, SessionKind, SessionStore, UserRepository}; + use crate::traits::{ + EmailProvider, InvitationStore, SessionKind, SessionStore, UserRepository, + }; use secrecy::SecretString; use std::sync::Arc; @@ -645,4 +658,138 @@ mod tests { assert!(!dbg.contains("super-secret")); assert!(dbg.contains("Ada")); } + + /// An email provider whose invitation send always fails, so the best-effort delivery path + /// is observable. Every other method succeeds — only the send under test errors. + struct FailingInviteEmail; + + #[async_trait::async_trait] + impl crate::traits::EmailProvider for FailingInviteEmail { + async fn send_password_reset_token( + &self, + _email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_email_verification_otp( + &self, + _email: &str, + _otp: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_mfa_enabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_mfa_disabled( + &self, + _email: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_new_session_alert( + &self, + _email: &str, + _session: &crate::traits::email::SessionInfo, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_invitation( + &self, + _to: &str, + _data: &crate::traits::email::InviteData, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Err(crate::traits::EmailError::Delivery( + "smtp unavailable".into(), + )) + } + } + + #[tokio::test] + async fn an_undeliverable_invitation_still_stands() { + // Delivery is best-effort by design: the invitation is already persisted, and rolling + // it back on a transient SMTP failure would destroy a token the inviter can still + // resend. So the failure cannot surface to the caller — which makes the log the only + // signal that invitations are being created and never arriving. + let Some(s) = setup(invite_config()) else { return }; + let users = Arc::new(InMemoryUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let built = AuthEngine::builder() + .config(invite_config()) + .environment(Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores.clone()) + .email_provider(Arc::new(FailingInviteEmail)) + .build(); + let Ok(engine) = built else { return }; + let admin = seed_admin(&users, "sender@example.com", "ADMIN").await; + drop(s); + + assert!( + engine + .invite(&admin, "unreachable@example.com", "MEMBER", "t1", None) + .await + .is_ok() + ); + + // Exercise every method of the double so its object-safe surface is fully covered: the + // invitation send errors (the path under test), the rest succeed. + let provider = FailingInviteEmail; + let invite = crate::traits::email::InviteData { + inviter_name: "Inviter".to_owned(), + tenant_name: "Tenant".to_owned(), + invite_token: "t".to_owned(), + expires_at: time::OffsetDateTime::UNIX_EPOCH, + }; + let session = crate::traits::email::SessionInfo { + device: "Chrome".to_owned(), + ip: "1.2.3.4".to_owned(), + session_hash: "abcd1234".to_owned(), + }; + assert!(provider.send_invitation("e", &invite, None).await.is_err()); + assert!( + provider + .send_password_reset_token("e", "t", None) + .await + .is_ok() + ); + assert!( + provider + .send_password_reset_otp("e", "o", None) + .await + .is_ok() + ); + assert!( + provider + .send_email_verification_otp("e", "o", None) + .await + .is_ok() + ); + assert!(provider.send_mfa_enabled("e", None).await.is_ok()); + assert!(provider.send_mfa_disabled("e", None).await.is_ok()); + assert!( + provider + .send_new_session_alert("e", &session, None) + .await + .is_ok() + ); + } } diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index 87b6260..7d53d44 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -12,7 +12,7 @@ use bymax_auth_types::{ use crate::context::RequestContext; use crate::engine::AuthEngine; -use crate::normalize::normalize_email; +use crate::normalize::{mask_email, normalize_email}; use crate::services::auth::detached::{ run_after_login, run_rehash_password, run_update_last_login, }; @@ -47,7 +47,13 @@ impl AuthEngine { let identifier = self.hashed_identifier(&tenant_id, &input.email); // Brute-force gate first (so an already-locked account never increments again). - self.assert_not_locked(&identifier).await?; + if let Err(error) = self.assert_not_locked(&identifier).await { + // Kept on one line on purpose: a `tracing` field expression on its own line is + // never evaluated without an installed subscriber, so it would read as an + // uncovered line under the 100% gate while being perfectly exercised. + tracing::warn!(email = %mask_email(&input.email), %tenant_id, "login: account locked"); + return Err(error); + } let hook_ctx = HookContext::from_request( ctx, @@ -111,6 +117,7 @@ impl AuthEngine { .tokens() .issue_mfa_temp_token(&user.id, MfaContext::Dashboard) .await?; + tracing::info!(user_id = %user.id, tenant_id = %tenant_id, "login: MFA challenge issued"); return Ok(LoginResult::MfaChallenge(MfaChallengeResult { mfa_required: true, mfa_temp_token, @@ -118,6 +125,7 @@ impl AuthEngine { } // A fresh session is minted on success (session-fixation resistance). + tracing::info!(user_id = %user.id, tenant_id = %tenant_id, "login: success"); self.issue_session_result(user, &ctx.ip, &ctx.user_agent, hook_ctx) .await } @@ -135,6 +143,7 @@ impl AuthEngine { identifier: &str, started: Instant, ) -> Result { + tracing::warn!("login: invalid credentials"); self.brute_force().record_failure(identifier).await?; normalize_anti_enum(started).await; Err(AuthError::InvalidCredentials) 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 a28f333..97a0e4f 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -411,7 +411,15 @@ impl AuthEngine { .await .is_err() { - let _ = store.delete_token(&raw).await; + // The caller must not learn that the address exists, so the failure is swallowed on + // the response path — which makes the log the only place an operator can see that + // reset links are not reaching anyone. + tracing::error!(user_id = %user.id, "password reset: token delivery failed"); + if let Err(error) = store.delete_token(&raw).await { + // The rollback is what keeps an undeliverable token from lingering in a Redis + // snapshot for its whole TTL. + tracing::error!(%error, "password reset: rollback of the stored token failed"); + } } Ok(()) } @@ -449,6 +457,10 @@ impl AuthEngine { self.session_store() .bump_epoch(crate::traits::SessionKind::Dashboard, &context.user_id) .await?; + // A completed reset is the event an operator correlates an account takeover against: + // it revokes every session the account had and invalidates its outstanding access + // tokens, so it belongs in the audit trail even when nothing failed. + tracing::info!(user_id = %context.user_id, "password reset: completed, all sessions revoked"); let hook_ctx = reset_context_hooks(context); let safe = self.project_user_for_hook(context).await; @@ -1403,6 +1415,18 @@ mod tests { .is_ok() ); + // …and again with the rollback itself refused. The caller still sees the same + // anti-enumerating success — it must not learn that the address exists, let alone that + // the store is down — so the log is the only place a token now stranded in Redis for + // its whole TTL can surface at all. + stores.fail_next_cleanup_writes(1); + assert!( + engine + .initiate_reset(forgot("fail@example.com")) + .await + .is_ok() + ); + // Exercise every method of the failing-email double so the object-safe surface is // fully covered: the reset-token send errors (the path under test), the rest succeed. let provider = FailingResetEmail; diff --git a/crates/bymax-auth-core/src/services/auth/register.rs b/crates/bymax-auth-core/src/services/auth/register.rs index e299ed9..4c690d1 100644 --- a/crates/bymax-auth-core/src/services/auth/register.rs +++ b/crates/bymax-auth-core/src/services/auth/register.rs @@ -69,6 +69,7 @@ impl AuthEngine { .await?; let safe = SafeAuthUser::from(user); + tracing::info!(user_id = %safe.id, tenant_id = %tenant_id, "register: user registered"); let result = self .tokens() .issue_tokens(&safe, &ctx.ip, &ctx.user_agent, false) 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 bfbdc8e..f924a41 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -49,16 +49,30 @@ impl AuthEngine { // malformed/oversized token is skipped before hashing — it owns no session anyway. if is_refresh_token_shape(raw_refresh) { let session_hash = RawRefreshToken::from_raw(raw_refresh.to_owned()).redis_hash(); - let _ = self + if let Err(error) = self .session_store() .revoke_session(SessionKind::Dashboard, user_id, &session_hash) - .await; - let _ = self + .await + { + // Swallowed by design, but not silently: an operator seeing repeated cleanup + // failures is looking at sessions that outlive the logout that asked for them. + // `SessionNotFound` is the expected outcome for a session already rotated or + // evicted, so it is not worth an operator's attention — the same distinction + // nest-auth draws before logging. + if !matches!(error, AuthError::SessionNotFound) { + tracing::warn!(%error, "logout: session cleanup failed"); + } + } + if let Err(error) = self .session_store() .delete_grace_pointer(SessionKind::Dashboard, &session_hash) - .await; + .await + { + tracing::warn!(%error, "logout: grace pointer cleanup failed"); + } } + tracing::info!(%user_id, "logout: session closed"); let hook_ctx = identity_only_context(user_id, None, None); spawn_guarded(run_after_logout( self.hooks().clone(), @@ -407,4 +421,61 @@ mod tests { Err(AuthError::MfaRequired) )); } + + #[tokio::test] + async fn logout_survives_a_store_that_refuses_both_cleanups() { + // A backend outage during logout must not fail the logout: the access token is already + // blacklisted, and the caller has no way to retry the parts that failed. What it must + // not do is pass silently — an operator with a store that keeps refusing is looking at + // refresh sessions and grace pointers outliving the logouts that asked for them, and the + // response says nothing. Both cleanups are armed to fail, so both report. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let Some((id, auth)) = logged_in(&h, "down@x.io", "pw").await else { return }; + + h.stores.fail_next_cleanup_writes(2); + assert!( + h.engine + .logout(&auth.access_token, &auth.refresh_token, &id) + .await + .is_ok() + ); + + // The failures were consumed by the two cleanup calls, and the session survives them — + // which is what makes the swallowed error worth reporting rather than ignoring. + let store: &dyn crate::traits::SessionStore = h.stores.as_ref(); + assert!(matches!( + store + .find_session( + crate::traits::SessionKind::Dashboard, + &RawRefreshToken::from_raw(auth.refresh_token.clone()).redis_hash(), + ) + .await, + Ok(Some(_)) + )); + } + + #[tokio::test] + async fn logout_stays_quiet_when_the_session_was_already_gone() { + // `SessionNotFound` is the ordinary outcome for a session already rotated or evicted, so + // it takes the swallow path WITHOUT the warning an outage gets. Logging it would drown + // the real signal in noise on every double logout. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let Some((id, auth)) = logged_in(&h, "twice@x.io", "pw").await else { return }; + assert!( + h.engine + .logout(&auth.access_token, &auth.refresh_token, &id) + .await + .is_ok() + ); + assert!( + h.engine + .logout(&auth.access_token, &auth.refresh_token, &id) + .await + .is_ok() + ); + } } diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index dd7308b..1776df8 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -65,7 +65,8 @@ impl MfaService { ) -> Result { let MfaTempVerified { user_id, jti, .. } = verified; let bf_id = self.challenge_bf_id(&user_id); - self.assert_not_locked(&bf_id).await?; + self.assert_not_locked("challenge", &user_id, &bf_id) + .await?; // Fetch the dashboard user concretely; the combined guard rejects both a missing user // and one without MFA configured. @@ -96,7 +97,7 @@ impl MfaService { // (retryable within its TTL) and only the failure counter advances. let recovery_index = if is_totp_code(code) { if !self.accept_totp(&user_id, &raw_secret, code, &jti).await? { - return self.reject_code(&bf_id).await; + return self.reject_code("challenge", &user_id, &bf_id).await; } None } else { @@ -107,7 +108,7 @@ impl MfaService { self.tokens.consume_mfa_temp_token(&jti).await?; Some(index) } - None => return self.reject_code(&bf_id).await, + None => return self.reject_code("challenge", &user_id, &bf_id).await, } }; @@ -130,6 +131,7 @@ impl MfaService { .await?; let hook_ctx = self.hook_context(&user_id, &email, ip, user_agent); spawn_guarded(run_after_login(self.hooks.clone(), safe, hook_ctx)); + tracing::info!(user_id = %user_id, "mfa: challenge passed"); Ok(LoginResultMfa::Dashboard(result)) } @@ -148,7 +150,8 @@ impl MfaService { ) -> Result { let MfaTempVerified { user_id, jti, .. } = verified; let bf_id = self.challenge_bf_id(&user_id); - self.assert_not_locked(&bf_id).await?; + self.assert_not_locked("platform challenge", &user_id, &bf_id) + .await?; // The platform repository is required for a platform challenge; without it the build has // no platform surface, so the challenge fails closed (never persist a platform secret on @@ -183,7 +186,9 @@ impl MfaService { let recovery_codes = admin.mfa_recovery_codes.clone().unwrap_or_default(); let recovery_index = if is_totp_code(code) { if !self.accept_totp(&user_id, &raw_secret, code, &jti).await? { - return self.reject_code(&bf_id).await; + return self + .reject_code("platform challenge", &user_id, &bf_id) + .await; } None } else { @@ -194,7 +199,11 @@ impl MfaService { self.tokens.consume_mfa_temp_token(&jti).await?; Some(index) } - None => return self.reject_code(&bf_id).await, + None => { + return self + .reject_code("platform challenge", &user_id, &bf_id) + .await; + } } }; @@ -223,6 +232,7 @@ impl MfaService { .tokens .issue_platform_tokens(&safe, ip, user_agent, true) .await?; + tracing::info!(user_id = %user_id, "mfa: platform challenge passed"); Ok(LoginResultMfa::Platform(result)) } @@ -288,7 +298,13 @@ impl MfaService { /// Record a failed challenge attempt and return the retryable [`AuthError::MfaInvalidCode`] /// (the temp token stays alive; the lockout eventually fires). - async fn reject_code(&self, bf_id: &str) -> Result { + async fn reject_code( + &self, + flow: &str, + user_id: &str, + bf_id: &str, + ) -> Result { + tracing::warn!(%flow, %user_id, "mfa: invalid code"); self.brute_force.record_failure(bf_id).await?; Err(AuthError::MfaInvalidCode) } diff --git a/crates/bymax-auth-core/src/services/mfa/manage.rs b/crates/bymax-auth-core/src/services/mfa/manage.rs index 358eabd..fda0220 100644 --- a/crates/bymax-auth-core/src/services/mfa/manage.rs +++ b/crates/bymax-auth-core/src/services/mfa/manage.rs @@ -99,7 +99,7 @@ impl MfaService { return Err(AuthError::MfaNotEnabled); } let bf_id = self.disable_bf_id(user_id); - self.assert_not_locked(&bf_id).await?; + self.assert_not_locked("disable", user_id, &bf_id).await?; // An enabled account with no stored secret is an inconsistency, not a user error. let encrypted = view.mfa_secret.clone().ok_or(AuthError::TokenInvalid)?; let raw_secret = self @@ -109,10 +109,12 @@ impl MfaService { .verify_totp_with_anti_replay(user_id, &raw_secret, code) .await? { + tracing::warn!(%user_id, "mfa disable: invalid code"); self.brute_force.record_failure(&bf_id).await?; return Err(AuthError::MfaInvalidCode); } self.brute_force.reset(&bf_id).await?; + tracing::info!(%user_id, "mfa: disabled"); Ok(()) } diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index bab1e09..a730606 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -408,9 +408,18 @@ impl MfaService { /// /// Returns [`AuthError::AccountLocked`] when the window is tripped, or a store /// [`AuthError`] on failure. - async fn assert_not_locked(&self, bf_id: &str) -> Result<(), AuthError> { + async fn assert_not_locked( + &self, + flow: &str, + user_id: &str, + bf_id: &str, + ) -> Result<(), AuthError> { if self.brute_force.is_locked(bf_id).await? { let retry = self.brute_force.remaining_lockout_secs(bf_id).await?; + // The lockout itself is the security event an operator watches for: repeated hits + // on one account are a second-factor guessing campaign. The identifier is the + // caller's user id, never the hashed brute-force key, which says nothing on its own. + tracing::warn!(%flow, %user_id, "mfa: account locked"); return Err(AuthError::AccountLocked { retry_after_seconds: Some(retry), }); diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index 94e9cca..8c61d5a 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -45,6 +45,7 @@ impl MfaService { .put_setup_nx(&key, &json, super::MFA_SETUP_TTL_SECONDS) .await? { + tracing::info!(%user_id, context = ?ctx, "mfa setup: initiated"); return Ok(self.build_setup_result(&view.email, &raw_secret, plain_codes)); } @@ -109,11 +110,13 @@ impl MfaService { .verify_totp_with_anti_replay(user_id, &raw_secret, code) .await? { + tracing::warn!(%user_id, "mfa setup: invalid TOTP code"); return Err(AuthError::MfaInvalidCode); } // Atomic completion gate: only the request that wins the `GETDEL` proceeds to enable. if self.mfa_store.take_setup(&key).await?.is_none() { + tracing::warn!(%user_id, "mfa setup: pending record consumed by a concurrent request"); return Err(AuthError::MfaSetupRequired); } @@ -137,6 +140,7 @@ impl MfaService { .await?; self.notify_enabled(&view, user_id, ip, user_agent); + tracing::info!(%user_id, context = ?ctx, "mfa setup: enabled"); Ok(()) } diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 48c2721..797ba9d 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -29,7 +29,7 @@ use bymax_auth_types::{ SafeAuthPlatformUser, }; -use crate::normalize::normalize_email; +use crate::normalize::{mask_email, normalize_email}; use crate::services::auth::{normalize_anti_enum, spawn_guarded}; use crate::services::brute_force::BruteForceService; use crate::services::password::PasswordService; @@ -116,7 +116,10 @@ impl PlatformAuthService { let identifier = self.brute_force_identifier(email); // Brute-force gate first, so an already-locked account never increments again. - self.assert_not_locked(&identifier).await?; + if let Err(error) = self.assert_not_locked(&identifier).await { + tracing::warn!(email = %mask_email(email), "platform login: account locked"); + return Err(error); + } // The timing floor starts here so the unknown-admin and wrong-password paths are // indistinguishable in elapsed time, not just in status/body. @@ -173,6 +176,7 @@ impl PlatformAuthService { .tokens .issue_mfa_temp_token(&admin.id, MfaContext::Platform) .await?; + tracing::info!(admin_id = %admin.id, "platform login: MFA challenge issued"); return Ok(PlatformLoginResult::MfaChallenge(MfaChallengeResult { mfa_required: true, mfa_temp_token, @@ -307,6 +311,7 @@ impl PlatformAuthService { .tokens .issue_platform_tokens(&safe, ip, user_agent, false) .await?; + tracing::info!(%admin_id, "platform login: success"); spawn_guarded(run_update_platform_last_login(self.repo.clone(), admin_id)); Ok(PlatformLoginResult::Success(Box::new(result))) } @@ -319,6 +324,7 @@ impl PlatformAuthService { identifier: &str, started: Instant, ) -> Result { + tracing::warn!("platform login: invalid credentials"); self.brute_force.record_failure(identifier).await?; normalize_anti_enum(started).await; Err(AuthError::InvalidCredentials) diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 6416b0a..d7063b6 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -152,10 +152,16 @@ impl SessionService { // Ownership-checked revoke; a SessionNotFound (a concurrent logout already removed // it) or any other store error is swallowed — the new session is already committed, // so eviction must never fail the operation that scheduled it. - let _ = self + if let Err(error) = self .store .revoke_session(SessionKind::Dashboard, user_id, &victim.session_hash) - .await; + .await + && !matches!(error, AuthError::SessionNotFound) + { + // Swallowed, but visible: a store that keeps refusing the eviction is a cap + // that is not being enforced, which reads as "no error" on every response. + tracing::warn!(%error, %user_id, "sessions: eviction of an over-cap session failed"); + } self.fire_session_evicted(user_id, &victim.session_hash, ctx) .await; } @@ -308,15 +314,20 @@ impl SessionService { session_hash: short_hash(new_hash), }; // Errors are swallowed: a slow or failing notification must never affect the session. - let _ = self.hooks.on_new_session(&safe, &session, ctx).await; + if let Err(error) = self.hooks.on_new_session(&safe, &session, ctx).await { + tracing::error!(%error, "sessions: on_new_session hook returned an error (ignored)"); + } } /// Fire the fire-and-forget session-evicted hook with the short (eight-char) evicted hash. async fn fire_session_evicted(&self, user_id: &str, evicted_hash: &str, ctx: &HookContext) { - let _ = self + if let Err(error) = self .hooks .on_session_evicted(user_id, &short_hash(evicted_hash), ctx) - .await; + .await + { + tracing::error!(%error, "sessions: on_session_evicted hook returned an error (ignored)"); + } } } @@ -479,6 +490,35 @@ mod tests { } } + /// A hook spy whose notifications both fail, so the swallow-and-report path is observable. + /// A host's hook is arbitrary code — a webhook, an audit write — and the library must not + /// let its failure reach the session it was merely notifying about. + struct ErroringHooks; + + #[async_trait::async_trait] + impl AuthHooks for ErroringHooks { + async fn on_new_session( + &self, + _user: &bymax_auth_types::SafeAuthUser, + _session: &crate::traits::email::SessionInfo, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + Err(crate::traits::HookError::Rejected( + "new-session sink unavailable".to_owned(), + )) + } + async fn on_session_evicted( + &self, + _user_id: &str, + _evicted_session_hash: &str, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + Err(crate::traits::HookError::Rejected( + "eviction sink unavailable".to_owned(), + )) + } + } + /// A resolver that pins the cap to one, to exercise the resolver-override path. struct CapOf(u32); @@ -1224,4 +1264,51 @@ mod tests { Ok(1) )); } + + #[tokio::test] + async fn eviction_survives_a_failing_store_and_failing_hooks() { + // Two independent swallow paths meet here: the store refuses the eviction, and both + // notifications error. Neither may fail `after_session_created` — the new session is + // already committed, and a host's hook is arbitrary code. What must NOT happen is + // silence: a cap that stops being enforced, or an audit sink that stops receiving, is + // invisible in the response and only reachable through the log. + let store = Arc::new(InMemoryStores::new()); + let users = Arc::new(InMemoryUserRepository::new()); + let uid = seed_user(&users, "swallow").await; + + let old = hash("dddd"); + let new = hash("eeee"); + let base = OffsetDateTime::UNIX_EPOCH; + assert!( + store + .create_session(SessionKind::Dashboard, &old, &record(&uid, base), 3600) + .await + .is_ok() + ); + let new_record = record(&uid, base + time::Duration::seconds(1)); + assert!( + store + .create_session(SessionKind::Dashboard, &new, &new_record, 3600) + .await + .is_ok() + ); + + // One armed failure for the single over-cap revoke this drives. + store.fail_next_cleanup_writes(1); + let svc = service( + store.clone(), + users, + Arc::new(ErroringHooks), + config(1, None), + ); + assert!( + svc.after_session_created(&new_record, &new, &ctx()) + .await + .is_ok() + ); + + // The refused eviction left the over-cap session in place — the outcome the warning + // exists to make visible. + assert!(matches!(svc.list_sessions(&uid, Some(&new)).await, Ok(v) if v.len() == 2)); + } } diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index fb01b7d..f501644 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -304,12 +304,18 @@ impl TokenManagerService { // 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). + tracing::warn!( + "refresh: reuse of a consumed refresh token detected — revoking the token family" + ); self.session_store .revoke_family(SessionKind::Dashboard, &family) .await?; Err(AuthError::RefreshTokenInvalid) } - RotateOutcome::Invalid => Err(AuthError::RefreshTokenInvalid), + RotateOutcome::Invalid => { + tracing::warn!("refresh: no live session or grace window for the presented token"); + Err(AuthError::RefreshTokenInvalid) + } } } @@ -479,12 +485,20 @@ impl TokenManagerService { 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. + tracing::warn!( + "platform refresh: reuse of a consumed refresh token detected — revoking the token family" + ); self.session_store .revoke_family(SessionKind::Platform, &family) .await?; Err(AuthError::RefreshTokenInvalid) } - RotateOutcome::Invalid => Err(AuthError::RefreshTokenInvalid), + RotateOutcome::Invalid => { + tracing::warn!( + "platform refresh: no live session or grace window for the presented token" + ); + Err(AuthError::RefreshTokenInvalid) + } } } diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index ddf9f16..89ae105 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -304,6 +304,10 @@ pub struct InMemoryStores { /// `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>>, + /// How many upcoming best-effort cleanup writes must fail with a backend error, set + /// through [`InMemoryStores::fail_next_cleanup_writes`]. Zero — the default — means every + /// call behaves normally. + forced_write_failures: 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>, @@ -344,6 +348,29 @@ impl InMemoryStores { Self::default() } + /// Make the next `count` best-effort cleanup writes fail with a backend error. + /// + /// Covers the calls the library deliberately swallows — `revoke_session`, + /// `delete_grace_pointer`, and the reset-token rollback `delete_token` — because logout, + /// over-cap eviction, and an undeliverable reset link must not fail on the store's account. + /// That swallowing leaves those paths unreachable against a double that always succeeds, + /// and so unasserted. Arming a finite number of failures is what lets a test prove the + /// failure is handled and reported rather than merely assumed to be. Counts down per + /// affected call; the default of zero leaves every call behaving normally. + pub fn fail_next_cleanup_writes(&self, count: usize) { + *lock(&self.forced_write_failures) = count; + } + + /// Consume one armed failure, if any, returning the error the caller should surface. + fn take_forced_failure(&self) -> Result<(), AuthError> { + let mut remaining = lock(&self.forced_write_failures); + if *remaining == 0 { + return Ok(()); + } + *remaining -= 1; + Err(AuthError::Internal("session store unavailable".into())) + } + /// The TTL the last created session was stored with, in seconds. A test-only inspection /// helper: the double cannot expire anything, so without this the lifetime the engine /// computes would be unobservable. @@ -501,6 +528,7 @@ impl SessionStore for InMemoryStores { user_id: &str, session_hash: &str, ) -> Result<(), AuthError> { + self.take_forced_failure()?; let mut index = lock(&self.session_index); let details = index .get_mut(&(kind, user_id.to_owned())) @@ -519,6 +547,7 @@ impl SessionStore for InMemoryStores { kind: SessionKind, session_hash: &str, ) -> Result<(), AuthError> { + self.take_forced_failure()?; // The grace pointer is keyed by the OLD token's hash; deleting it (idempotently) blocks a // post-logout grace-window recovery, mirroring the real store's `DEL rp:`/`prp:`. lock(&self.grace).remove(&(kind, session_hash.to_owned())); @@ -712,6 +741,7 @@ impl PasswordResetStore for InMemoryStores { } async fn delete_token(&self, token: &str) -> Result<(), AuthError> { + self.take_forced_failure()?; lock(&self.reset_tokens).remove(&token_key(token)); Ok(()) } diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 6445e35..82b7ccd 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -846,7 +846,29 @@ mod tests { last_activity_at: OffsetDateTime::from_unix_timestamp(1_700_000_060) .unwrap_or(OffsetDateTime::UNIX_EPOCH), }; + // Anchored to the shared contract, which declares this record's timestamps numeric while + // declaring the refresh session's ISO-8601: the two disagree deliberately, and reading the + // declaration here is what stops a well-meaning "make the encodings uniform" change from + // passing both suites while splitting the keyspace. + assert_eq!( + contract_section("sessionDetail") + .get("createdAt") + .and_then(serde_json::Value::as_str), + Some("unix-milliseconds-number") + ); + assert_eq!( + contract_section("sessionDetail") + .get("lastActivityAt") + .and_then(serde_json::Value::as_str), + Some("unix-milliseconds-number") + ); let json = serde_json::to_string(&detail)?; + for field in contract_fields("sessionDetail") { + assert!( + json.contains(&format!("\"{field}\":")), + "sessionDetail field `{field}` is named in the wire contract but absent from the record" + ); + } assert!(json.contains("\"createdAt\":1700000000000")); assert!(json.contains("\"lastActivityAt\":1700000060000")); // No quotes around the values — a stringly-typed timestamp is exactly the divergence. @@ -1015,4 +1037,138 @@ mod tests { )); Ok(()) } + + /// Read a section of the shared cross-implementation wire contract. + /// + /// The file at `conformance/wire-contract.json` is held byte-identical by nest-auth, which + /// can back the same deployment over the same Redis. Reading it here rather than repeating + /// its values means a field rename or an encoding change on either side turns that side red + /// immediately, instead of surfacing later as a record the sibling backend cannot parse. + fn contract_section(section: &str) -> serde_json::Value { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + root.get("recordEncodings") + .and_then(|r| r.get(section)) + .cloned() + .unwrap_or(serde_json::Value::Null) + } + + /// The field names the contract declares for one record, in declaration order. + /// + /// Panics on an empty list. A contract that failed to load reads as "no fields to check", + /// which would make every assertion below pass over nothing — the one failure mode a + /// conformance test cannot afford, since it looks identical to conformance. + fn contract_fields(section: &str) -> Vec { + let fields: Vec = contract_section(section) + .get("fields") + .and_then(serde_json::Value::as_array) + .map(|fields| { + fields + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + assert!( + !fields.is_empty(), + "the wire contract declared no fields for `{section}` — it did not load" + ); + fields + } + + #[test] + fn the_refresh_session_record_matches_the_shared_wire_contract() -> serde_json::Result<()> { + // Every field the contract names must be on the wire, spelled the way the contract spells + // it. A record the sibling backend cannot read is not a parse error there — the reader + // evicts what it cannot parse, so a drifted field name silently logs the user out. + let json: serde_json::Value = serde_json::to_value(session_record())?; + for field in contract_fields("refreshSession") { + assert!( + json.get(&field).is_some(), + "refreshSession field `{field}` is named in the wire contract but absent from the record" + ); + } + + // `createdAt` is an ISO-8601 string here, unlike the session DETAIL below. The split is + // the trap the contract exists to pin: the two records disagree on purpose. + assert_eq!( + contract_section("refreshSession") + .get("createdAt") + .and_then(serde_json::Value::as_str), + Some("iso8601-string") + ); + assert_eq!( + json.get("createdAt").and_then(serde_json::Value::as_str), + Some("1970-01-01T00:00:00Z") + ); + assert_eq!( + json.get("familyCreatedAt") + .and_then(serde_json::Value::as_str), + Some("1970-01-01T00:00:00Z") + ); + + // `mfaEnabled` must survive a rotation: the MFA gate refuses only a token whose claims + // say `mfaEnabled && !mfaVerified`, so a record that drops it turns one routine refresh + // into a silent second-factor bypass. + assert_eq!( + json.get("mfaEnabled"), + Some(&serde_json::Value::Bool(false)) + ); + + // An empty family is omitted from the wire entirely, never written as `""` — nest-auth + // omits it the same way, and a record differing by that one key is not byte-identical. + let legacy = SessionRecord { + family_id: String::new(), + family_created_at: None, + ..session_record() + }; + let legacy_json: serde_json::Value = serde_json::to_value(legacy)?; + assert!(legacy_json.get("familyId").is_none()); + assert!(legacy_json.get("familyCreatedAt").is_none()); + Ok(()) + } + + #[test] + fn the_invitation_and_reset_context_records_match_the_shared_wire_contract() + -> serde_json::Result<()> { + // An invitation is consumed with a single-use GETDEL, so a record the reader rejects is + // destroyed rather than retried: a missing field loses the invitation outright. + let invitation = StoredInvitation { + email: "invitee@example.com".into(), + role: "MEMBER".into(), + tenant_id: "t1".into(), + inviter_user_id: "owner-1".into(), + created_at: OffsetDateTime::UNIX_EPOCH, + }; + let json: serde_json::Value = serde_json::to_value(invitation)?; + for field in contract_fields("invitation") { + assert!( + json.get(&field).is_some(), + "invitation field `{field}` is named in the wire contract but absent from the record" + ); + } + assert_eq!( + json.get("createdAt").and_then(serde_json::Value::as_str), + Some("1970-01-01T00:00:00Z") + ); + + let context = ResetContext { + user_id: "u1".into(), + email: "u1@example.com".into(), + tenant_id: "t1".into(), + }; + let json: serde_json::Value = serde_json::to_value(context)?; + for field in contract_fields("passwordResetContext") { + assert!( + json.get(&field).is_some(), + "passwordResetContext field `{field}` is named in the wire contract but absent from the record" + ); + } + Ok(()) + } } diff --git a/crates/bymax-auth-types/src/claims.rs b/crates/bymax-auth-types/src/claims.rs index 030623f..c32ef62 100644 --- a/crates/bymax-auth-types/src/claims.rs +++ b/crates/bymax-auth-types/src/claims.rs @@ -298,4 +298,78 @@ mod tests { Some(MfaContext::Dashboard) ); } + + #[test] + fn the_epoch_claim_matches_the_shared_wire_contract() { + // The `accessTokenClaims` section of `conformance/wire-contract.json` — held + // byte-identical by nest-auth — is what makes bulk revocation work across both backends: + // one side stamps the generation and the other rejects on it. Reading the declaration + // rather than repeating it means a rename, a type change, or a flipped comparison on + // either side turns that side red instead of quietly un-revoking tokens in production. + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let epoch = root + .get("accessTokenClaims") + .and_then(|c| c.get("epoch")) + .cloned() + .unwrap_or(serde_json::Value::Null); + assert!( + epoch.is_object(), + "the wire contract declared no `accessTokenClaims.epoch` — it did not load" + ); + + // The claim is spelled exactly as declared, and both planes stamp it. + let name = epoch.get("claim").and_then(serde_json::Value::as_str); + assert_eq!(name, Some("epoch")); + let dashboard = serde_json::to_value(dashboard_claims()).unwrap_or_default(); + assert_eq!(dashboard.get("epoch"), Some(&serde_json::json!(3))); + let platform = serde_json::to_value(PlatformClaims { + sub: "p_1".to_owned(), + jti: "jti-2".to_owned(), + role: "super_admin".to_owned(), + token_type: PlatformType::Platform, + mfa_enabled: false, + mfa_verified: false, + iat: 1, + exp: 2, + epoch: 7, + }) + .unwrap_or_default(); + assert_eq!(platform.get("epoch"), Some(&serde_json::json!(7))); + + // A non-negative integer on the wire: `u64` cannot go negative, and the JSON must carry + // it as a bare number rather than a string a sibling reader would compare lexically. + assert_eq!( + epoch.get("type").and_then(serde_json::Value::as_str), + Some("non-negative integer") + ); + assert!( + dashboard + .get("epoch") + .is_some_and(serde_json::Value::is_u64) + ); + + // The two rules a verifier implements. `absentReadsAs` is pinned by the legacy-token test + // above; the rejection is strict `<`, so a token stamped AT the current generation still + // verifies — an off-by-one here would log every user out on their first bump. + assert_eq!( + epoch.get("absentReadsAs"), + Some(&serde_json::json!(0)), + "an absent claim reading as anything but 0 would make the mechanism fire on legacy tokens" + ); + assert_eq!( + epoch.get("rejectWhen").and_then(serde_json::Value::as_str), + Some("stampedEpoch < storedEpoch") + ); + + // The stored side of the contract: the key the generation is read back from. + assert_eq!( + epoch.get("storedUnder").and_then(serde_json::Value::as_str), + Some("{ep|pep}:{userId}") + ); + } } From 4630bface9b7818c149590eddcff16a829b8d9c6 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:01:25 -0300 Subject: [PATCH 076/122] docs: record the parity fixes and the epoch retention bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README gains the `jwt.access_expires_in` ceiling, which `build()` now refuses to exceed — a configuration a reader would otherwise only discover by having startup reject it. The changelog records the header-sanitization and security-logging gaps the audit found against nest-auth, the two conformance sections that were never asserted, and the in-memory store's weaker grace window. --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ README.md | 9 +++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff32870..02ff955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,30 @@ version bump. - **Per-route rate limits pinned to the shared contract** — the adapter already enforced them; what was missing was the agreement, with 21 numbers duplicated across two repositories and nothing checking they matched. +- **Header sanitization matched to `nest-auth`, entry for entry** — the map handed + to host-supplied hooks withheld three names against the sibling's fifteen, so a + host wiring one audit sink behind both backends received `x-api-key`, + `proxy-authorization` and every forwarded-identity header from this side and not + the other. `nest-auth`'s + `^x-.*-(token|secret|key|password|credential|auth|bearer|signature|hmac)$` is + reproduced as a suffix test rather than a regex dependency, matching it exactly: + the leading `x-` is stripped and the remainder must carry a dash of its own, so + `x-request-token` is withheld and `x-token`, which the pattern also declines, is + not. +- **Security logging across the core flows** — three events against the sibling's + seventy. Login lockouts, invalid credentials, MFA lockouts and rejected codes, + refresh-token reuse, a completed password reset, and every best-effort cleanup + that failed now emit, with `mask_email` reproducing `nest-auth`'s masking so one + log pipeline shows one spelling for one account. `SessionNotFound` on the logout + path stays silent: it is the ordinary outcome for a session already rotated, and + logging it would bury the outage it exists to surface. +- **`recordEncodings` and `accessTokenClaims` added to the conformance tier** — the + two sections of the shared contract that decide whether a record written by one + backend is readable by the other, including the deliberate split where the + session detail's timestamps are numeric while the refresh session's are ISO-8601. +- **`InMemoryStores::fail_next_cleanup_writes`** — arms a finite number of store + failures so the paths the library deliberately swallows can be asserted rather + than assumed. ### Changed @@ -75,6 +99,12 @@ version bump. ### Fixed +- **The in-memory store's grace window was weaker than the Redis one it stands in + for.** It neither consumed the pointer nor checked the lineage was still alive, + so a replay could recover repeatedly and a pointer left behind by an earlier + rotation could remount a family reuse detection had just revoked. The conformance + tier and `nest-auth`'s end-to-end tier both run against this store, so the gap hid + exactly the divergence those tiers exist to catch. - **A grace pointer could resurrect a revoked lineage.** Reuse detection only proves the *replayed* token's own pointer expired; a pointer planted by an earlier rotation of the same lineage can still be live, and recovering from it diff --git a/README.md b/README.md index dc7a8d4..278e547 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,15 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se > [!NOTE] > `build()` validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, `SameSite=None ⇒ Secure`, `SameSite=None ⇔ trusted_origins`, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise `ConfigError`. +> [!IMPORTANT] +> `jwt.access_expires_in` must not exceed **30 days**, the window a store keeps a bumped token +> epoch readable. The epoch is what makes a stateless access token revocable: a password reset +> advances it and every token stamped below it stops verifying — but only while the bumped value +> is still there. A longer-lived access token would outlive it, `current_epoch` would fall back +> to `0`, and a token the reset revoked would verify again. `build()` refuses the configuration +> (`ConfigError::AccessLifetimeExceedsEpochRetention`) rather than letting it fail open, and +> `nest-auth` enforces the identical bound. + Two options are deliberately off by default, because switching either on changes behaviour for sessions and origins that already exist: From 57ec58f12f6893a8006874cf8b43ba3a60e7be9a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:04:51 -0300 Subject: [PATCH 077/122] fix(wasm): drop the epoch field the merge duplicated Both sides of the merge added `epoch: 0` to the wasm smoke fixture, and the struct literal ended up carrying it twice. Nothing outside the wasm target noticed: a workspace `cargo check --all-targets` does not build this test, which only compiles under `wasm32-unknown-unknown`. --- bindings/bymax-auth-wasm/tests/web.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/bindings/bymax-auth-wasm/tests/web.rs b/bindings/bymax-auth-wasm/tests/web.rs index 637017f..c295d96 100644 --- a/bindings/bymax-auth-wasm/tests/web.rs +++ b/bindings/bymax-auth-wasm/tests/web.rs @@ -33,7 +33,6 @@ fn sign_dashboard(iat: i64, exp: i64) -> String { epoch: 0, iat, exp, - epoch: 0, }; sign(&claims, &HsKey::from_bytes(SECRET.as_bytes())).unwrap_or_default() } From c205d6f9938b268eeb56ba21d49f5eba1743bd96 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:04:58 -0300 Subject: [PATCH 078/122] test(core): close the survivors the mutation sweep found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, none of them a bug in the library and all of them a test that could not see its own subject. **The retention window was only ever read back through itself.** Every test of the epoch bound uses `TOKEN_EPOCH_RETENTION_SECS + 1` and `== TOKEN_EPOCH_RETENTION_SECS`, so mutating the arithmetic in the constant round-trips perfectly: startup validation goes on "passing" while enforcing a ceiling nobody chose. Pinned to the literal, which is also the number both READMEs promise and nest-auth's `TOKEN_EPOCH_RETENTION_SECONDS` carries. **`fail_next_cleanup_writes` was never shown to run out.** Arming a count is only meaningful if it reaches zero; a counter that grew or stalled would leave the store failing for the rest of the test, and since these are the writes the library swallows, every later assertion would be measuring an outage instead of the behaviour it names — silently. **A log line was the whole body of its branch.** `!matches!(error, SessionNotFound)` decides *which* eviction failures an operator is told about, and deleting the `!` inverts that with no other observable effect: the session stays either way. `tracing::warn!` also evaluates nothing without a subscriber, so the branch was unreachable from a test at all. Hence `log_capture`, written against `tracing`'s own `Subscriber` trait rather than taking a dependency on `tracing-subscriber`, and an eviction test that asserts both directions — the store failure is reported, the ordinary `SessionNotFound` is not. The subscriber is installed once, globally. A thread-local `set_default` raises `tracing`'s process-global max level when installed and lowers it on drop, so two capturing tests race: one drops its guard while the other is still logging, the ceiling falls to `OFF`, and the event is discarded before any subscriber sees it. That reads exactly like "the code did not log" — the assertion under test — and it failed four runs in five before this was understood. **One documented equivalent had drifted off its anchor.** The `cfg(not(feature = "mfa"))` twin of `issue_mfa_temp_token` is excluded by line, and the new logging pushed it from 618 to 632, so both of its mutants reappeared as survivors. That is the loud failure the anchoring was chosen for, and the config now records how to confirm the re-anchor. --- .cargo/mutants.toml | 10 +- crates/bymax-auth-core/src/lib.rs | 4 + crates/bymax-auth-core/src/log_capture.rs | 265 ++++++++++++++++++ .../bymax-auth-core/src/services/session.rs | 87 ++++++ crates/bymax-auth-core/src/testing/mod.rs | 32 +++ crates/bymax-auth-core/src/traits/store.rs | 12 + 6 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 crates/bymax-auth-core/src/log_capture.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 49e382a..6578bb3 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -65,8 +65,12 @@ additional_cargo_args = ["--all-features"] # drop the compiled twin's mutant, which the suite does kill. If `builder.rs` shifts, the # anchor stops matching and the mutant reappears as a survivor — a loud failure, not a # silent one. Re-anchor it then. -# 6. `token_manager.rs:618` `issue_mfa_temp_token` — the `cfg(not(feature = "mfa"))` twin of -# the method below it, anchored for the same reason as 5. +# 6. `token_manager.rs:632` `issue_mfa_temp_token` — the `cfg(not(feature = "mfa"))` twin of +# the method below it, anchored for the same reason as 5. Re-anchored from 618 on 2026-07-28 +# when security logging pushed the method down: the sweep reported both of its mutants as +# survivors, which is the loud failure this anchoring was chosen for. Confirm with +# `cargo mutants --list --all-features | grep issue_mfa_temp_token` — exactly one line must +# remain, and it is the `cfg(feature = "mfa")` twin the suite kills. # 7. The seven `NoOpEmailProvider` sends — the provider's whole job is to do nothing, and each # body is one `tracing::debug!` line followed by `Ok(())`. Replacing a body with `Ok(())` # removes only that debug line, which no assertion reaches without installing a `tracing` @@ -103,7 +107,7 @@ exclude_re = [ 'delete match arm "inactive" in assert_not_blocked', 'replace == with != in validate_password', 'builder\.rs:212:9: replace AuthEngineBuilder::redis_stores', - 'token_manager\.rs:618:9: replace TokenManagerService::issue_mfa_temp_token', + 'token_manager\.rs:632:9: replace TokenManagerService::issue_mfa_temp_token', 'replace ::\w+ -> Result<\(\), EmailError> with Ok\(\(\)\)', 'replace AuthHooks::\w+ -> Result<\(\), HookError> with Ok\(\(\)\)', 'replace < with <= in MfaService::challenge_platform', diff --git a/crates/bymax-auth-core/src/lib.rs b/crates/bymax-auth-core/src/lib.rs index bedfcc9..2cd2d8f 100644 --- a/crates/bymax-auth-core/src/lib.rs +++ b/crates/bymax-auth-core/src/lib.rs @@ -31,6 +31,10 @@ pub mod config; pub mod context; pub mod engine; mod error; +/// Test-only capture of the crate's own `tracing` events, so a security event that is a branch's +/// only observable effect can be asserted rather than assumed. Never compiled into a release. +#[cfg(test)] +mod log_capture; mod normalize; #[cfg(feature = "oauth")] pub mod providers; diff --git a/crates/bymax-auth-core/src/log_capture.rs b/crates/bymax-auth-core/src/log_capture.rs new file mode 100644 index 0000000..2b3c66c --- /dev/null +++ b/crates/bymax-auth-core/src/log_capture.rs @@ -0,0 +1,265 @@ +//! In-process capture of `tracing` events, for tests that assert the library actually reported +//! something. +//! +//! The security events this crate emits — a lockout, a rejected second factor, a replayed refresh +//! token, a cleanup the store refused — are, by construction, the *only* observable effect of +//! their branch: the call they guard is swallowed or the flow returns the same error either way. +//! Without a subscriber installed, `tracing::warn!` evaluates nothing, so those branches are +//! unreachable from a test and any condition inside them is unfalsifiable. That is not a +//! hypothetical: the mutation gate found `!matches!(error, SessionNotFound)` surviving with the +//! `!` deleted, which inverts *which* failures an operator is told about. +//! +//! nest-auth asserts its log lines through Jest spies for the same reason. This is the equivalent +//! seam, written against `tracing`'s own `Subscriber` trait so the crate takes no new dependency +//! — `tracing-subscriber` would be one, and it exists in this workspace only for the examples. +//! +//! # Why one global subscriber and not a per-test thread-local one +//! +//! `tracing` keeps the maximum enabled level as **process-global** state, and the macros consult +//! it before they ever reach a subscriber. A thread-local `set_default` raises that ceiling when +//! it is installed and lowers it again when its guard drops — so two tests capturing on two +//! threads race: one drops its guard while the other is still logging, the ceiling falls back to +//! `OFF`, and the second test's event is discarded before any subscriber sees it. That failure is +//! intermittent and reads exactly like "the code did not log", which is the assertion under test. +//! +//! So the subscriber is installed **once, globally**, and stays for the life of the test binary, +//! which pins the ceiling open. Isolation moves to where it is cheap: each thread gets its own +//! buffer, and capture is off until a test switches it on. + +use std::cell::{Cell, RefCell}; +use std::fmt::Debug; + +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Level, Metadata, Subscriber}; + +/// One captured event: its level and its rendered fields, message included. +#[derive(Clone, Debug)] +pub(crate) struct CapturedEvent { + /// The event's level, so a test can distinguish "reported" from "reported as a warning". + pub(crate) level: Level, + /// Every field rendered as `name=value`, joined by spaces. The message rides along as + /// `message=…`, which is how `tracing` models it. + pub(crate) rendered: String, +} + +/// The handle a test reads captured events back through. +/// +/// Reads the calling thread's buffer, which only the calling thread writes. +#[derive(Clone, Copy, Default)] +pub(crate) struct CapturedEvents; + +thread_local! { + /// This thread's captured events. Retained after the guard drops, because a test asserts on + /// the log *after* the code under test has finished running. + static BUFFER: RefCell> = const { RefCell::new(Vec::new()) }; + /// Whether this thread is currently capturing. Off until a test asks, so a test that never + /// captures pays nothing and records nothing. + static CAPTURING: Cell = const { Cell::new(false) }; +} + +impl CapturedEvents { + /// Whether any captured event contains `needle` in its rendered fields. + pub(crate) fn contains(&self, needle: &str) -> bool { + self.iter().iter().any(|e| e.rendered.contains(needle)) + } + + /// Whether any captured event at `level` contains `needle`. + pub(crate) fn contains_at(&self, level: Level, needle: &str) -> bool { + self.iter() + .iter() + .any(|e| e.level == level && e.rendered.contains(needle)) + } + + /// A snapshot of everything captured on this thread so far. + pub(crate) fn iter(&self) -> Vec { + BUFFER.with(|buffer| buffer.borrow().clone()) + } +} + +/// Turns capture off for this thread when dropped. +/// +/// What was captured is deliberately kept: the assertion comes after the code under test has run, +/// so a guard that also cleared the buffer would hand every test an empty log — indistinguishable +/// from the code never having logged, which is the thing being asserted. +pub(crate) struct CaptureGuard; + +impl Drop for CaptureGuard { + fn drop(&mut self) { + CAPTURING.with(|capturing| capturing.set(false)); + } +} + +/// Begin capturing this thread's `tracing` events, returning the handle and the guard that stops +/// the capture. +/// +/// The guard must be held for as long as the code under test runs — dropping it early stops the +/// capture, and the assertion then reads an empty log rather than a missing event. Nested calls +/// on one thread are not supported: the second would clear the first's buffer. +#[must_use] +pub(crate) fn capture_events() -> (CapturedEvents, CaptureGuard) { + install_global_subscriber(); + // Cleared here rather than on drop, so one test never reads another's events. + BUFFER.with(|buffer| buffer.borrow_mut().clear()); + CAPTURING.with(|capturing| capturing.set(true)); + (CapturedEvents, CaptureGuard) +} + +/// Install the capturing subscriber process-wide, once. +/// +/// An `Err` from `set_global_default` means something else claimed the slot first, which for this +/// binary can only be a second call racing this one — the `Once` makes that unreachable, and +/// ignoring it is still correct: the assertion that follows fails loudly rather than silently +/// passing. +fn install_global_subscriber() { + static INSTALL: std::sync::Once = std::sync::Once::new(); + INSTALL.call_once(|| { + let _ = tracing::subscriber::set_global_default(CapturingSubscriber); + }); +} + +/// The minimum `Subscriber` that records events and ignores spans. +struct CapturingSubscriber; + +impl Subscriber for CapturingSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + // Everything is enabled: a test that installs this wants the whole log, and the level + // filtering a deployment applies is the deployment's business. + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + // Spans are not captured; every span gets the same id because nothing reads it back. + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + // Rendering is skipped entirely unless this thread asked to capture, so the subscriber + // being global costs the other tests nothing. + if !CAPTURING.with(Cell::get) { + return; + } + let mut visitor = FieldRenderer(String::new()); + event.record(&mut visitor); + BUFFER.with(|buffer| { + buffer.borrow_mut().push(CapturedEvent { + level: *event.metadata().level(), + rendered: visitor.0, + }); + }); + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + +/// Renders every field of an event as `name=value`, space-separated. +struct FieldRenderer(String); + +impl Visit for FieldRenderer { + fn record_debug(&mut self, field: &Field, value: &dyn Debug) { + if !self.0.is_empty() { + self.0.push(' '); + } + self.0.push_str(field.name()); + self.0.push('='); + self.0.push_str(&format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + // Strings are rendered unquoted: an assertion reads `"login: account locked"` the way it + // is written in the source, not with the escaping `Debug` would add. + if !self.0.is_empty() { + self.0.push(' '); + } + self.0.push_str(field.name()); + self.0.push('='); + self.0.push_str(value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn captures_level_message_and_fields() { + let (events, guard) = capture_events(); + tracing::warn!(user_id = "u1", "something worth reporting"); + tracing::info!("routine"); + drop(guard); + + assert!(events.contains("something worth reporting")); + assert!(events.contains("user_id=u1")); + assert!(events.contains_at(Level::WARN, "something worth reporting")); + // The level is part of the assertion surface: a warning demoted to a debug line is still + // "logged" but no longer reaches an operator watching for warnings. + assert!(!events.contains_at(Level::INFO, "something worth reporting")); + assert!(events.contains_at(Level::INFO, "routine")); + assert_eq!(events.iter().len(), 2); + } + + #[test] + fn stops_capturing_once_the_guard_is_dropped() { + let (events, guard) = capture_events(); + tracing::warn!("before the guard drops"); + drop(guard); + tracing::warn!("after the guard"); + // What was captured stays readable — that is the whole point of asserting after the + // fact — but nothing new is recorded. + assert!(events.contains("before the guard drops")); + assert!(!events.contains("after the guard")); + assert_eq!(events.iter().len(), 1); + } + + #[test] + fn a_second_capture_on_one_thread_starts_empty() { + let (first, guard) = capture_events(); + tracing::warn!("from the first capture"); + drop(guard); + assert!(first.contains("from the first capture")); + + let (second, guard) = capture_events(); + drop(guard); + // Same thread, fresh capture: the earlier test's events must not leak into this one's + // assertions, or a passing test could be reading someone else's log. + assert!(!second.contains("from the first capture")); + assert!(second.iter().is_empty()); + } + + #[test] + fn spans_are_accepted_and_ignored() { + // The crate does not instrument spans today, but a subscriber that panicked or mis-handled + // one would break every test that captures the moment somebody adds `#[instrument]` — and + // it would break it far from here. Driving the whole `Subscriber` surface keeps that + // failure at this file instead. + let (events, guard) = capture_events(); + let span = tracing::info_span!("outer", step = tracing::field::Empty); + let other = tracing::info_span!("sibling"); + span.record("step", 1); + span.follows_from(&other); + { + let _entered = span.enter(); + tracing::warn!("inside a span"); + } + drop(guard); + + // The event is captured; the span itself contributes nothing, which is the contract. + assert!(events.contains("inside a span")); + assert_eq!(events.iter().len(), 1); + } + + #[test] + fn renders_non_string_fields_through_debug() { + let (events, guard) = capture_events(); + tracing::warn!(count = 3, flag = true, "mixed"); + drop(guard); + assert!(events.contains("count=3")); + assert!(events.contains("flag=true")); + } +} diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index d7063b6..d0902be 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -1311,4 +1311,91 @@ mod tests { // exists to make visible. assert!(matches!(svc.list_sessions(&uid, Some(&new)).await, Ok(v) if v.len() == 2)); } + + #[tokio::test] + async fn a_refused_eviction_reports_only_a_real_store_failure() { + // The warning is the whole body of its branch, so the condition guarding it — "report + // everything EXCEPT SessionNotFound" — has no other observable effect. Asserting the + // outcome of the eviction cannot distinguish an inverted condition from a correct one: + // the session stays either way. So the log itself is the assertion surface, both for + // what it must say and for what it must stay quiet about. + let users = Arc::new(InMemoryUserRepository::new()); + let uid = seed_user(&users, "reported").await; + let base = OffsetDateTime::UNIX_EPOCH; + let old = hash("1111"); + let new = hash("2222"); + + // A backend failure IS reported. + let store = Arc::new(InMemoryStores::new()); + assert!( + store + .create_session(SessionKind::Dashboard, &old, &record(&uid, base), 3600) + .await + .is_ok() + ); + let new_record = record(&uid, base + time::Duration::seconds(1)); + assert!( + store + .create_session(SessionKind::Dashboard, &new, &new_record, 3600) + .await + .is_ok() + ); + store.fail_next_cleanup_writes(1); + let svc = service( + store.clone(), + users.clone(), + Arc::new(CountingHooks::default()), + config(1, None), + ); + let (events, guard) = crate::log_capture::capture_events(); + assert!( + svc.after_session_created(&new_record, &new, &ctx()) + .await + .is_ok() + ); + drop(guard); + assert!(events.contains_at( + tracing::Level::WARN, + "sessions: eviction of an over-cap session failed" + )); + assert!(events.contains(&format!("user_id={uid}"))); + + // A `SessionNotFound` is NOT: it is what a concurrent logout leaves behind, and an + // operator watching for a cap that stopped being enforced must not have to sift it out + // of one line per ordinary race. + let store = Arc::new(InMemoryStores::new()); + assert!( + store + .create_session(SessionKind::Dashboard, &old, &record(&uid, base), 3600) + .await + .is_ok() + ); + assert!( + store + .create_session(SessionKind::Dashboard, &new, &new_record, 3600) + .await + .is_ok() + ); + // Revoke the victim out from under the cap, so the eviction finds it already gone. + assert!( + store + .revoke_session(SessionKind::Dashboard, &uid, &old) + .await + .is_ok() + ); + let svc = service( + store.clone(), + users, + Arc::new(CountingHooks::default()), + config(1, None), + ); + let (events, guard) = crate::log_capture::capture_events(); + assert!( + svc.after_session_created(&new_record, &new, &ctx()) + .await + .is_ok() + ); + drop(guard); + assert!(!events.contains("sessions: eviction of an over-cap session failed")); + } } diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 89ae105..9b62c83 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1319,6 +1319,38 @@ mod tests { )); } + #[tokio::test] + async fn armed_cleanup_failures_are_finite() { + // The point of arming a COUNT is that it runs out. A counter that never reached zero + // would leave the store failing for the rest of the test, and every assertion that + // follows would be measuring an outage instead of the behaviour it names — silently, + // because these are exactly the writes the library swallows. So the third call here is + // the assertion that matters: it must behave normally again. + let store = InMemoryStores::new(); + let kind = SessionKind::Dashboard; + assert!( + store + .create_session(kind, "a1", &record("u9"), 60) + .await + .is_ok() + ); + + store.fail_next_cleanup_writes(2); + // Armed: a backend error, not the `SessionNotFound` an absent session would give. + assert!(matches!( + store.revoke_session(kind, "u9", "a1").await, + Err(AuthError::Internal(_)) + )); + assert!(matches!( + store.delete_grace_pointer(kind, "a1").await, + Err(AuthError::Internal(_)) + )); + // Disarmed: the session is still there (both armed calls failed before touching it), + // so this one succeeds — which it could not if the counter had grown or stalled. + assert!(store.revoke_session(kind, "u9", "a1").await.is_ok()); + assert!(store.delete_grace_pointer(kind, "a1").await.is_ok()); + } + #[tokio::test] async fn session_store_grace_is_single_shot_and_refuses_a_revoked_lineage() { let kind = SessionKind::Dashboard; diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 82b7ccd..ed64165 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -708,6 +708,18 @@ mod tests { } } + #[test] + fn the_epoch_retention_window_is_thirty_days_to_the_second() { + // Pinned to the literal, never recomputed from the same expression the constant uses. + // Every other test of this bound reads it back through the constant — the startup rule + // is checked with `TOKEN_EPOCH_RETENTION_SECS + 1` and `== TOKEN_EPOCH_RETENTION_SECS` — + // so a typo in the arithmetic would round-trip perfectly and the validation would go on + // "passing" while enforcing a ceiling nobody chose. This is the only assertion that can + // see that, and the number is a contract: nest-auth's `TOKEN_EPOCH_RETENTION_SECONDS` + // and the 30 days both READMEs promise have to be the same value. + assert_eq!(TOKEN_EPOCH_RETENTION_SECS, 2_592_000); + } + #[test] fn the_optional_birth_time_adapter_round_trips_both_arms() { // On a `SessionRecord` the field is skipped when absent, so the `None` arm of the From d131280f6577033cada577489bde6391702b94f7 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:27:40 -0300 Subject: [PATCH 079/122] test(core): assert the logout cleanup log in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full sweep left exactly one survivor: deleting the `!` from `!matches!(error, SessionNotFound)` on the logout path. It decides *which* cleanup failures an operator is told about and has no other observable effect — the logout returns `Ok` either way, the session survives either way — so the two existing tests, which assert the outcome, could not tell the two versions apart. This is the twin of the eviction guard closed in the previous commit. Having built the log-capture seam for that one, the same pattern here should have been swept for at the time instead of waiting for the sweep to point at it again. The store failure must be reported; the `SessionNotFound` of a double logout — or of a session already rotated away — must not, or the one line that means "sessions are outliving their logouts" drowns in one line per ordinary race. The `logout: session closed` line is asserted alongside it, so "quiet" is pinned as quiet about the failure rather than quiet altogether. Red-checked: the mutation fails two tests, and the restored code passes six runs out of six — this seam produced a flaky test once already, and one green run is not evidence. --- .../src/services/auth/session_ops.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 f924a41..685b961 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -435,12 +435,20 @@ mod tests { let Some((id, auth)) = logged_in(&h, "down@x.io", "pw").await else { return }; h.stores.fail_next_cleanup_writes(2); + let (events, capture) = crate::log_capture::capture_events(); assert!( h.engine .logout(&auth.access_token, &auth.refresh_token, &id) .await .is_ok() ); + drop(capture); + + // Both refusals are reported. The warning is the whole body of its branch — the logout + // returns `Ok` either way and the session survives either way — so the log is the only + // place the condition guarding it is observable at all. + assert!(events.contains_at(tracing::Level::WARN, "logout: session cleanup failed")); + assert!(events.contains_at(tracing::Level::WARN, "logout: grace pointer cleanup failed")); // The failures were consumed by the two cleanup calls, and the session survives them — // which is what makes the swallowed error worth reporting rather than ignoring. @@ -471,11 +479,23 @@ mod tests { .await .is_ok() ); + + // The second logout finds the session already gone. That is `SessionNotFound`, the + // ordinary outcome of a double logout or a session rotated away — and it must NOT be + // reported, or the one line that means "sessions are outliving their logouts" drowns in + // one line per ordinary race. Inverting the guard is invisible to every other assertion: + // the call still returns `Ok` and the store is still empty. + let (events, capture) = crate::log_capture::capture_events(); assert!( h.engine .logout(&auth.access_token, &auth.refresh_token, &id) .await .is_ok() ); + drop(capture); + assert!(!events.contains("logout: session cleanup failed")); + // …while the logout itself is still recorded, so "quiet" means quiet about the failure, + // not quiet altogether. + assert!(events.contains_at(tracing::Level::INFO, "logout: session closed")); } } From f212d3477008b2807354d7a4c642f03f4858bba3 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:29:36 -0300 Subject: [PATCH 080/122] docs: report the second sweep's figures and separate the timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and CHANGELOG described the first full sweep — 1,630 mutants, zero survivors — which is no longer the run that matters: the second, after the parity work, tested 1,652 and found one survivor, since closed. Both now report caught-by-assertion and caught-by-timeout apart instead of summing them. The 89 timeouts are all container-backed Redis stores, where a mutation is detected by the test hanging rather than failing; that is real detection, but it is weaker than an assertion, and a single blended percentage hides which of the two a reader is being offered. The coverage line gains the same treatment: 100% is lines and functions, the gated metrics. Regions — one per branch inside an expression — are at 96.71%, which the README now says rather than leaving the reader to assume the finer number matches the coarser one. --- CHANGELOG.md | 19 +++++++++++++++---- README.md | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02ff955..e1d9a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,14 +133,25 @@ version bump. recorded in `.cargo/mutants.toml` with the reason it cannot be. Re-running the sweep over the survivors is what caught four fixes that asserted the wrong thing, so each of those is red-checked by hand. The first full sweep under the - corrected configuration confirms it: **1,630 mutants, 1,242 caught, 95 detected + corrected configuration confirmed it: **1,630 mutants, 1,242 caught, 95 detected by timeout, 293 unviable, zero survivors** (8 h wall clock). -- Recorded for whoever tunes the gate next: 95 of the 95 timeouts sit in the - container-backed stores (`bymax-auth-redis`, 90 of them, and three in +- A second full sweep, after the parity work above: **1,652 mutants — 1,269 + caught, 89 detected by timeout, 293 unviable, 1 survivor** (9 h). The survivor + was `!matches!(error, SessionNotFound)` on the logout path, which decides + *which* cleanup failures an operator is told about and has no other observable + effect — the logout returns `Ok` either way. It is closed, and the sweep before + it had found nine more of the same character: a constant only ever read back + through itself, an armed-failure counter never shown to run out, a log branch + with no assertion surface, and a documented equivalent whose line anchor the new + logging had pushed out from under it. Not one was a bug in the library. +- Recorded for whoever tunes the gate next: the timeouts sit almost entirely in + the container-backed stores (`bymax-auth-redis`, plus a few in `bymax-auth-client`). A mutation there is detected by the suite *hanging* rather - than asserting, and each one spends the full 119 s window — roughly half the + than asserting, and each one spends the full timeout window — roughly half the run's wall clock. Shortening the window would buy hours at the cost of gate integrity, since a legitimately slow test cut short is reported as detected and would hide a survivor; the sound fix is making those tests fail fast instead. + For the same reason the two are reported apart rather than summed: 1,269 caught + by assertion is the number that carries the stronger guarantee. [Unreleased]: https://github.com/bymaxone/rust-auth/commits/main diff --git a/README.md b/README.md index 278e547..7cf1e93 100644 --- a/README.md +++ b/README.md @@ -638,8 +638,8 @@ Tracked with [Criterion](https://github.com/bheisler/criterion.rs) so a regressi Authentication is critical infrastructure, so the suite is held to a bar beyond "it compiles" — every behavior is pinned so a regression **fails a test**. -- ✅ **100% line and function coverage** — enforced as a release gate via [`cargo-llvm-cov --fail-under-lines 100`](https://github.com/taiki-e/cargo-llvm-cov) across the full `cargo-hack` feature matrix -- ✅ **100% mutation score** — [`cargo-mutants`](https://mutants.rs/) seeds faults into the source and the suite must catch them. The last full sweep: **1,630 mutants, 1,242 caught, 95 detected by timeout, 293 unviable — zero survivors**. The handful that no test can kill is recorded in [`.cargo/mutants.toml`](.cargo/mutants.toml) with the reason each is equivalent. The sweep runs post-merge on `main`, never on a PR: it takes ~8 hours, because the Redis stores are exercised against a real container and a mutation there is detected by hanging +- ✅ **100% line and function coverage** — 19,598 lines and 2,309 functions, enforced as a release gate via [`cargo-llvm-cov --fail-under-lines 100`](https://github.com/taiki-e/cargo-llvm-cov) across the full `cargo-hack` feature matrix. Regions — finer than lines, one per branch inside an expression — sit at 96.71% and are deliberately not a gate +- ✅ **Mutation gate** — [`cargo-mutants`](https://mutants.rs/) seeds faults into the source and the suite must catch them. The last full sweep (9 h, 2026-07-28): **1,652 mutants — 1,269 caught, 89 detected by timeout, 293 unviable, 1 survivor**, which is closed on the branch that follows it. The timeouts are all container-backed Redis stores, where a mutation is detected by the test hanging rather than failing: real detection, but weaker than an assertion, so the two are reported apart rather than summed into one number. Mutants no test can kill are recorded in [`.cargo/mutants.toml`](.cargo/mutants.toml) with the reason each is equivalent. The sweep runs post-merge on `main`, never on a PR — it takes ~9 hours - ✅ **Property tests + fuzzing** — `proptest` round-trips and `cargo-fuzz` smoke runs over the trust-boundary parsers (JWT, PHC, base32) - ✅ **Real-Redis E2E** — atomic Lua, rotation/grace, and revocation proven against `redis:8` via [`testcontainers`](https://github.com/testcontainers/testcontainers-rs) - ✅ **Edge parity** — `wasm-bindgen-test` confirms the WASM verifier accepts a token signed by the backend From a542bf91c08ed0751546dc1851b172e6dd0ed53f Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:46:58 -0300 Subject: [PATCH 081/122] fix(types): send `details: null` instead of omitting it, and close the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `errorEnvelope` was the one section of the shared contract that neither implementation asserted, and the two had drifted: nest-auth emits `details: null` when a thrower supplies none, this side omitted the key. The contract declares it present with an `object|null` value, and one client library decodes both backends — `undefined` is not `null` to it, and a key that is sometimes absent makes every reader handle two shapes for one meaning. The field carried a `skip_serializing_if` and a doc comment stating the omission was deliberate, while the catalog test beside it said the body "must be exactly { error: { code, message, details } }" and then asserted a body without it. The comment was right; nothing caught the disagreement because nothing was reading the contract. `credentialFormats` was also unasserted here. It is now, against what the code mints rather than against the contract's prose: a refresh token is proven to be 64 lowercase hex over 32 CSPRNG bytes, and the at-rest TOTP secret is proven to be AES-GCM over the BASE32 *text* by decrypting it and decoding back to the HMAC key. That last one is the regression this merge introduced and shipped past every conformance test in the tier — red-checked by reintroducing it. --- CHANGELOG.md | 17 +++ .../bymax-auth-core/src/services/mfa/tests.rs | 110 ++++++++++++++++++ crates/bymax-auth-types/src/error.rs | 92 ++++++++++++++- .../bymax-auth-types/tests/error_catalog.rs | 11 +- 4 files changed, 226 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d9a32..560f70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,13 @@ version bump. two sections of the shared contract that decide whether a record written by one backend is readable by the other, including the deliberate split where the session detail's timestamps are numeric while the refresh session's are ISO-8601. +- **`credentialFormats` and `errorEnvelope` added too**, which closes the section + list: every part of the shared contract is now asserted on both sides. + `credentialFormats` is asserted against what the code actually mints rather + than against the contract's own prose — the TOTP secret at rest is proven to be + AES-GCM over the BASE32 *text* by decrypting it and decoding back to the HMAC + key, which is precisely the regression a merge introduced here and which no + conformance test could see at the time. - **`InMemoryStores::fail_next_cleanup_writes`** — arms a finite number of store failures so the paths the library deliberately swallows can be asserted rather than assumed. @@ -99,6 +106,16 @@ version bump. ### Fixed +- **The error envelope omitted `details` instead of sending `null`.** The shared + contract declares the key present with an `object|null` value, which is what + nest-auth emits and what the one client library decoding both backends expects + — `undefined` is not `null` to it, and a key that is sometimes absent makes + every reader handle two shapes for one meaning. The field carried a + `skip_serializing_if` and a doc comment asserting the omission was deliberate, + while the test next to it said the body "must be exactly + `{ error: { code, message, details } }`" and then asserted a body without it. + Nothing caught the disagreement because `errorEnvelope` was the one contract + section neither implementation asserted. - **The in-memory store's grace window was weaker than the Redis one it stands in for.** It neither consumed the pointer nor checked the lineage was still alive, so a replay could recover repeatedly and a pointer left behind by an earlier diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index d3838fc..1eee0c1 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1881,3 +1881,113 @@ fn repository_error_maps_both_variants_to_internal() { AuthError::Internal(_) )); } + +/// Read `credentialFormats.{key}` from the shared cross-implementation wire contract. +/// +/// The file at `conformance/wire-contract.json` is held byte-identical by nest-auth. This section +/// is the shape of the credentials themselves, so a drift here is not a parse error on the other +/// side — it is a session that cannot continue, or a TOTP code that never verifies. +fn credential_format(key: &str) -> String { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let value = root + .get("credentialFormats") + .and_then(|c| c.get(key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + assert!( + !value.is_empty(), + "the wire contract declared no `credentialFormats.{key}` — it did not load" + ); + value +} + +#[test] +fn the_refresh_token_matches_the_shared_credential_format() { + // 64 lowercase hex characters, from 32 CSPRNG bytes. The contract is asserted against a + // token this library actually mints, not against the constant that produces it: a shape + // read back through its own generator would round-trip any change to either. + let declared = credential_format("refreshToken"); + assert!(declared.contains("64 lowercase hex")); + assert!(declared.contains("32 CSPRNG bytes")); + + for _ in 0..32 { + let token = bymax_auth_jwt::RawRefreshToken::generate(); + let raw = token.expose_secret(); + assert_eq!(raw.len(), 64, "refresh token is not 64 characters: {raw}"); + assert!( + raw.chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), + "refresh token is not lowercase hex: {raw}" + ); + } + + // The legacy UUID shape is documented as *accepted*, never minted — the allowance only makes + // sense while tokens issued before the convergence are still inside their lifetime. + assert!(credential_format("refreshTokenLegacy").contains("uuid-v4")); +} + +#[tokio::test] +async fn the_stored_totp_secret_and_recovery_digests_match_the_shared_credential_format() { + // The at-rest TOTP secret is AES-GCM over the BASE32 **text**, not over the raw bytes. + // Decrypting one form as the other hands the wrong key to HMAC-SHA-1 and every code the + // user's authenticator produces is rejected — which is exactly the regression a merge + // introduced here once, by calling the raw-bytes decrypt on a base32-text record. + let declared = credential_format("totpSecretAtRest"); + assert!(declared.contains("aes-256-gcm")); + assert!(declared.contains("BASE32")); + + let users = Arc::new(InMemoryUserRepository::new()); + let service = service_over(Arc::new(InMemoryStores::new()), users); + let (raw_secret, plain_codes, data) = service + .generate_setup_material() + .unwrap_or_else(|_| unreachable!("setup material generation cannot fail")); + + // Decrypting the stored form yields the Base32 TEXT, and decoding that text yields the very + // bytes the HMAC uses as its key. + let decrypted = service + .decrypt(&data.encrypted_secret) + .unwrap_or_else(|| unreachable!("the record was just encrypted under this key")); + let text = String::from_utf8(decrypted).unwrap_or_default(); + assert!( + text.chars() + .all(|c| c.is_ascii_uppercase() || ('2'..='7').contains(&c)), + "the stored secret is not Base32 text: {text}" + ); + assert_eq!( + service.decrypt_secret(&data.encrypted_secret).as_deref(), + Some(raw_secret.as_slice()), + "the at-rest form does not decode back to the HMAC key" + ); + + // Recovery codes are stored as a hex HMAC-SHA-256 under the derived identifier key — 64 + // lowercase hex characters, and never the code itself. + let declared = credential_format("recoveryCodeDigest"); + assert!(declared.contains("hex hmac-sha256")); + for (digest, code) in data.hashed_codes.iter().zip(plain_codes.iter()) { + assert_eq!(digest.len(), 64, "recovery digest is not 64 hex characters"); + assert!( + digest + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), + "recovery digest is not lowercase hex: {digest}" + ); + assert_ne!(digest, code, "the recovery code was stored in the clear"); + } + + // The legacy `scrypt:` form is still verified but never newly written, so no digest above + // may carry its prefix. + assert!(credential_format("recoveryCodeDigestLegacy").contains("scrypt:")); + assert!( + !data + .hashed_codes + .iter() + .any(|digest| digest.starts_with("scrypt:")), + "a recovery digest was written in the legacy form" + ); +} diff --git a/crates/bymax-auth-types/src/error.rs b/crates/bymax-auth-types/src/error.rs index c4fa5be..e8e23b8 100644 --- a/crates/bymax-auth-types/src/error.rs +++ b/crates/bymax-auth-types/src/error.rs @@ -331,9 +331,13 @@ pub struct AuthErrorBody { /// The client-facing message for `code`. pub message: String, /// Optional structured data (e.g. `{ "retryAfterSeconds": 300 }` or the per-field - /// validation errors); the field is omitted from the JSON entirely (not `null`) - /// when the variant carries none. - #[serde(skip_serializing_if = "Option::is_none")] + /// validation errors). + /// + /// Serialized as `null` when the variant carries none — **present, not omitted**. That is + /// the shared contract (`conformance/wire-contract.json`, `errorEnvelope`) and what + /// nest-auth emits, and one client library reads both backends: `undefined` and `null` are + /// not the same value to it, and a key that is sometimes absent forces every reader to + /// handle two shapes for one meaning. pub details: Option, } @@ -620,3 +624,85 @@ impl AuthError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_error_envelope_matches_the_shared_wire_contract() { + // `conformance/wire-contract.json` is held byte-identical by nest-auth, and one client + // library decodes both backends. The section is asserted against a REAL serialized + // error, not against the struct definition: the shape a reader sees is the JSON, and + // `skip_serializing_if` on a field is invisible in the type. + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let shape = root + .get("errorEnvelope") + .and_then(|e| e.get("shape")) + .and_then(|s| s.get("error")) + .cloned() + .unwrap_or(serde_json::Value::Null); + assert!( + shape.is_object(), + "the wire contract declared no `errorEnvelope.shape.error` — it did not load" + ); + assert_eq!( + shape.get("code").and_then(serde_json::Value::as_str), + Some("string") + ); + assert_eq!( + shape.get("message").and_then(serde_json::Value::as_str), + Some("string") + ); + // `object|null`, not `object?`: the key is always there. + assert_eq!( + shape.get("details").and_then(serde_json::Value::as_str), + Some("object|null") + ); + + // An error carrying no details still emits the key, as null. Omitting it is the + // divergence this test exists for: `undefined` and `null` are different values to the + // shared client, and a key that is sometimes absent makes every reader handle two + // shapes for one meaning. + let envelope = AuthError::InvalidCredentials.to_envelope(); + let json: serde_json::Value = + serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null); + let body = json + .get("error") + .cloned() + .unwrap_or(serde_json::Value::Null); + assert!(body.is_object(), "the envelope has no `error` object"); + for field in ["code", "message", "details"] { + assert!( + body.get(field).is_some(), + "`error.{field}` is named in the wire contract but absent from the JSON" + ); + } + assert_eq!(body.get("details"), Some(&serde_json::Value::Null)); + assert!(body.get("code").is_some_and(serde_json::Value::is_string)); + assert!( + body.get("message") + .is_some_and(serde_json::Value::is_string) + ); + + // …and an error that DOES carry details puts an object there, so the `object|null` + // union is pinned in both directions. + let envelope = AuthError::AccountLocked { + retry_after_seconds: Some(300), + } + .to_envelope(); + let json: serde_json::Value = + serde_json::to_value(&envelope).unwrap_or(serde_json::Value::Null); + let details = json + .get("error") + .and_then(|error| error.get("details")) + .cloned() + .unwrap_or(serde_json::Value::Null); + assert!(details.is_object(), "structured details are not an object"); + } +} diff --git a/crates/bymax-auth-types/tests/error_catalog.rs b/crates/bymax-auth-types/tests/error_catalog.rs index 31d74c5..d142019 100644 --- a/crates/bymax-auth-types/tests/error_catalog.rs +++ b/crates/bymax-auth-types/tests/error_catalog.rs @@ -204,12 +204,21 @@ fn validation_details_serialize_the_field_errors() { fn envelope_has_the_canonical_shape_and_uses_the_wire_code() { // The wire body must be exactly `{ error: { code, message, details } }`, and an // internal-only error must surface the remapped public code, never the sentinel. + // + // `details` is `null` here, not absent: the shared contract declares the key present with + // an `object|null` value, and one client library decodes both backends. This assertion used + // to omit it while the comment above already said "exactly" — the two disagreed, and the + // comment was right. let env = AuthError::TokenExpired.to_envelope(); let json = serde_json::to_value(&env).unwrap_or_default(); assert_eq!( json, serde_json::json!({ - "error": { "code": "auth.token_invalid", "message": "Invalid token" } + "error": { + "code": "auth.token_invalid", + "message": "Invalid token", + "details": null + } }) ); // A details-bearing error includes the structured payload under `error.details`. From 3175f4f409434607c61021ad827e84251f90110b Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:02:53 -0300 Subject: [PATCH 082/122] test: pin the WebSocket ticket in the shared contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism is unchanged — what was missing was the agreement. nest-auth had no equivalent to agree with, so `wst` and the snapshot encoding lived only in this implementation; a rename or a field change here would have gone unnoticed until a ticket minted by one backend failed to redeem on the other. nest-auth now mints and redeems the same ticket, so the contract carries the prefix, the snapshot's six fields and the ticket's own credential format, and both repositories assert them. The tenant-scope omission is pinned too: a platform ticket drops `tenantId` from the record entirely rather than writing null, and a record differing by that one key is not byte-identical. --- CHANGELOG.md | 6 ++++ conformance/wire-contract.json | 10 +++++- crates/bymax-auth-core/src/traits/store.rs | 40 ++++++++++++++++++++++ crates/bymax-auth-redis/src/keys.rs | 1 + 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560f70c..95c3a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,12 @@ version bump. two sections of the shared contract that decide whether a record written by one backend is readable by the other, including the deliberate split where the session detail's timestamps are numeric while the refresh session's are ISO-8601. +- **The WebSocket upgrade ticket entered the shared contract.** `wst`, the + snapshot's field list, and the ticket's own credential format are now declared + and asserted on both sides. The mechanism was already here and is unchanged; + what was missing was the agreement, because nest-auth had no equivalent to + agree with. It does now, so the prefix, the record shape and the tenant-scope + omission are pinned rather than coincidental. - **`credentialFormats` and `errorEnvelope` added too**, which closes the section list: every part of the shared contract is now asserted on both sides. `credentialFormats` is asserted against what the code actually mints rather diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 9671a22..f69b99e 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -62,6 +62,7 @@ "passwordResetVerifiedToken": "pw_vtok", "totpReplayMarker": "tu", "oauthState": "os", + "wsTicket": "wst", "invitation": "inv" }, @@ -139,6 +140,12 @@ "createdAt": "iso8601-string", "$comment": "Consumption is a single-use GETDEL, so a record the reader rejects is destroyed rather than retried — a missing field loses the invitation outright." }, + "wsTicket": { + "key": "wst:{sha256(ticket)}", + "fields": ["sub", "tenantId", "role", "status", "mfaEnabled", "mfaVerified"], + "tenantId": "omitted from the record entirely when the ticket is not tenant-scoped", + "$comment": "A verified-identity SNAPSHOT, never a token: no jti, no signature, no expiry of its own. The socket it authorizes cannot be turned back into a session. Redemption is a single-use GETDEL, so a captured upgrade URL is worthless once the socket opens." + }, "passwordResetContext": { "key": "pw_reset:{sha256(token)}", "fields": ["userId", "email", "tenantId"] @@ -154,7 +161,8 @@ "refreshTokenLegacy": "uuid-v4, accepted for one refresh lifetime after convergence", "totpSecretAtRest": "aes-256-gcm over the BASE32 TEXT of the secret", "recoveryCodeDigest": "hex hmac-sha256 of the code under the derived identifier key", - "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written" + "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written", + "wsTicket": "64 lowercase hex characters (32 CSPRNG bytes), single-use, 30 s lifetime" }, "rateLimits": { diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index ed64165..b59a415 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -1145,6 +1145,46 @@ mod tests { Ok(()) } + #[test] + fn the_ws_ticket_snapshot_matches_the_shared_wire_contract() -> serde_json::Result<()> { + // A ticket minted by one backend is redeemed by whichever one receives the upgrade, so + // the snapshot's field names are a contract, not an internal detail. It is a snapshot + // and not a token by design: no `jti` to revoke, no signature to re-verify, nothing the + // holder could present back to the REST surface. + let snapshot = WsTicketSnapshot { + sub: "u1".into(), + tenant_id: Some("t1".into()), + role: "MEMBER".into(), + status: "ACTIVE".into(), + mfa_enabled: true, + mfa_verified: true, + }; + let json: serde_json::Value = serde_json::to_value(&snapshot)?; + for field in contract_fields("wsTicket") { + assert!( + json.get(&field).is_some(), + "wsTicket field `{field}` is named in the wire contract but absent from the record" + ); + } + assert_eq!( + contract_section("wsTicket") + .get("key") + .and_then(serde_json::Value::as_str), + Some("wst:{sha256(ticket)}") + ); + + // A ticket with no tenant scope omits the field entirely rather than writing null — + // nest-auth omits it the same way, and a record differing by that one key is not + // byte-identical. + let platform = WsTicketSnapshot { + tenant_id: None, + ..snapshot + }; + let json: serde_json::Value = serde_json::to_value(platform)?; + assert!(json.get("tenantId").is_none()); + Ok(()) + } + #[test] fn the_invitation_and_reset_context_records_match_the_shared_wire_contract() -> serde_json::Result<()> { diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index d840d58..15ae588 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -206,6 +206,7 @@ mod tests { ("passwordResetVerifiedToken", Prefix::PwVtok), ("totpReplayMarker", Prefix::Tu), ("oauthState", Prefix::Os), + ("wsTicket", Prefix::Wst), ("invitation", Prefix::Inv), ] { assert_eq!( From 64790fc784d1537a238d6ed36de6abff38cf5859 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:16:23 -0300 Subject: [PATCH 083/122] fix(types): name the platform account `admin` in the struct, not just on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated TypeScript is generated from the struct, and the struct said `user` while the adapter emitted `admin` — so this library published a type describing a response it never sends. A TypeScript consumer reading `result.user` got `undefined` at runtime, and nothing was checking, because the contract covered stored records and claims but never the client-facing payloads. The struct field is now `admin`: type, struct and wire agree, and the adapter's remap is gone. Breaking for Rust callers reading `PlatformAuthResult::user`, which is nobody — the crate is unreleased. The wire shape does not change, and nest-auth's published `admin` is what both now describe. `responseBodies` joins the shared contract with the login body per delivery mode, the platform body, the challenge and the ws-ticket, asserted on both sides against what each actually serializes. The cookie-mode entry is the one that earns its keep: the tokens live in `Set-Cookie` so script cannot read them, and a refresh token repeated in the JSON body would make HttpOnly decorative. Red-checked by restoring the remap: the platform assertion fails. --- CHANGELOG.md | 12 +++ conformance/wire-contract.json | 20 ++++ crates/bymax-auth-axum/src/delivery.rs | 101 +++++++++++++++++- crates/bymax-auth-axum/src/routes/platform.rs | 2 +- .../src/services/adapter_api.rs | 2 +- .../bymax-auth-core/src/services/mfa/tests.rs | 2 +- .../bymax-auth-core/src/services/platform.rs | 2 +- .../src/services/token_manager.rs | 2 +- .../tests/platform_identity_e2e.rs | 4 +- crates/bymax-auth-types/src/results.rs | 15 ++- .../rust-auth/src/shared/auth-result.types.ts | 10 +- 11 files changed, 159 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c3a57..d4ba350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,12 @@ version bump. what was missing was the agreement, because nest-auth had no equivalent to agree with. It does now, so the prefix, the record shape and the tenant-scope omission are pinned rather than coincidental. +- **`responseBodies` added to the contract**, which is what caught the mismatch above. It + declares the client-facing payloads — the login body per delivery mode, the platform body, + the challenge, the ws-ticket — and both sides assert against what they actually serialize. + The cookie-mode claim is the load-bearing one: the tokens are in `Set-Cookie` so script + cannot read them, and a refresh token repeated in the JSON payload would make the HttpOnly + flag decorative. - **`credentialFormats` and `errorEnvelope` added too**, which closes the section list: every part of the shared contract is now asserted on both sides. `credentialFormats` is asserted against what the code actually mints rather @@ -112,6 +118,12 @@ version bump. ### Fixed +- **`PlatformAuthResult`'s account field was named `user` while the wire says `admin`.** The + adapter renamed it while building the response, so the TypeScript generated from the struct + described a key the server never sends: a consumer reading `result.user` got `undefined` at + runtime. The struct field is now `admin`, so the type, the struct and the body agree, and the + adapter no longer remaps. **Breaking** for Rust callers reading `PlatformAuthResult::user` — + none published, since the crate is unreleased. The wire is unchanged. - **The error envelope omitted `details` instead of sending `null`.** The shared contract declares the key present with an `object|null` value, which is what nest-auth emits and what the one client library decoding both backends expects diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index f69b99e..1cf7259 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -217,6 +217,26 @@ } }, + "responseBodies": { + "$comment": [ + "The client-facing payloads. These are what a consumer's TypeScript describes, so a", + "difference here is not a store that fails to read — it is a client that compiles against", + "one backend and reads `undefined` from the other. The platform login body is the trap:", + "the account rides under `admin`, not `user`, on both sides." + ], + "login": { + "cookie": ["user"], + "bearer": ["user", "accessToken", "refreshToken"], + "$comment": "In cookie mode the tokens are in Set-Cookie and MUST NOT also be in the body: a refresh token in a JSON payload is one an XSS can read, which is the whole reason the cookie is HttpOnly." + }, + "platformLogin": { + "bearer": ["admin", "accessToken", "refreshToken"], + "$comment": "Always bearer — a platform admin console is not a browser session — and the account key is `admin`." + }, + "mfaChallenge": ["mfaRequired", "mfaTempToken"], + "wsTicket": ["ticket", "expiresIn"] + }, + "errorEnvelope": { "$comment": "The body shape every auth error serializes to. `details` is present and null when the thrower supplied none.", "shape": { "error": { "code": "string", "message": "string", "details": "object|null" } } diff --git a/crates/bymax-auth-axum/src/delivery.rs b/crates/bymax-auth-axum/src/delivery.rs index ded724b..93b1bfc 100644 --- a/crates/bymax-auth-axum/src/delivery.rs +++ b/crates/bymax-auth-axum/src/delivery.rs @@ -239,7 +239,7 @@ fn bearer_body(result: &AuthResult) -> impl Serialize + '_ { #[cfg(feature = "platform")] fn platform_bearer_body(result: &bymax_auth_types::PlatformAuthResult) -> impl Serialize + '_ { json!({ - "admin": &result.user, + "admin": &result.admin, "accessToken": &result.access_token, "refreshToken": &result.refresh_token, }) @@ -293,7 +293,7 @@ mod tests { #[cfg(feature = "platform")] fn platform_result() -> PlatformAuthResult { PlatformAuthResult { - user: SafeAuthPlatformUser::from(AuthPlatformUser { + admin: SafeAuthPlatformUser::from(AuthPlatformUser { id: "a1".to_owned(), email: "a@e.com".to_owned(), name: "A".to_owned(), @@ -397,6 +397,103 @@ mod tests { } } + /// Read a `responseBodies` entry from the shared cross-implementation wire contract. + /// + /// These are the payloads a consumer's TypeScript describes, so a difference here is not a + /// record that fails to load — it is a client that compiles against one backend and reads + /// `undefined` from the other. + fn response_body_keys(path: &[&str]) -> Vec { + let file = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(file).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let mut node = root + .get("responseBodies") + .cloned() + .unwrap_or(serde_json::Value::Null); + for step in path { + node = node.get(step).cloned().unwrap_or(serde_json::Value::Null); + } + let keys: Vec = node + .as_array() + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + // The label is built before the assert, not inside its message: an argument evaluated + // only on failure is a line the coverage gate never sees executed. + let label = path.join("."); + assert!( + !keys.is_empty(), + "the wire contract declared no responseBodies.{label} — it did not load" + ); + keys + } + + /// The keys of a serialized body, sorted. + fn sorted_keys(body: &serde_json::Value) -> Vec { + let mut keys: Vec = body + .as_object() + .map(|map| map.keys().cloned().collect()) + .unwrap_or_default(); + keys.sort(); + keys + } + + #[test] + fn the_login_bodies_match_the_shared_wire_contract() { + // Cookie mode: the body carries the user and NOTHING else. The tokens are in + // `Set-Cookie` precisely so script cannot read them, and repeating a refresh token in + // the JSON payload would hand it to any XSS on the page — making the HttpOnly flag + // decorative. This is the assertion that would catch that regression. + let result = auth_result(); + let cookie_body = serde_json::json!({ "user": &result.user }); + let mut expected = response_body_keys(&["login", "cookie"]); + expected.sort(); + assert_eq!(sorted_keys(&cookie_body), expected); + assert!(!cookie_body.to_string().contains("acc")); + assert!(!cookie_body.to_string().contains("ref")); + + // Bearer mode: exactly the declared keys. + let bearer = serde_json::to_value(bearer_body(&result)).unwrap_or_default(); + let mut expected = response_body_keys(&["login", "bearer"]); + expected.sort(); + assert_eq!(sorted_keys(&bearer), expected); + } + + #[cfg(feature = "platform")] + #[test] + fn the_platform_login_body_matches_the_shared_wire_contract() { + // The account rides under `admin`. This library's own generated TypeScript said `user` + // until the struct field was renamed to match what the adapter emits — a consumer + // reading `result.user` got `undefined` at runtime, and nothing was checking. + let body = + serde_json::to_value(platform_bearer_body(&platform_result())).unwrap_or_default(); + let mut expected = response_body_keys(&["platformLogin", "bearer"]); + expected.sort(); + assert_eq!(sorted_keys(&body), expected); + assert!(expected.contains(&"admin".to_owned())); + assert!(!expected.contains(&"user".to_owned())); + } + + #[test] + fn the_challenge_body_matches_the_shared_wire_contract() { + let challenge = bymax_auth_types::MfaChallengeResult { + mfa_required: true, + mfa_temp_token: "t".to_owned(), + }; + let body = serde_json::to_value(challenge).unwrap_or_default(); + let mut expected = response_body_keys(&["mfaChallenge"]); + expected.sort(); + assert_eq!(sorted_keys(&body), expected); + } + #[cfg(feature = "platform")] #[test] fn platform_bearer_body_names_the_account_admin_not_user() { diff --git a/crates/bymax-auth-axum/src/routes/platform.rs b/crates/bymax-auth-axum/src/routes/platform.rs index 42dfd7a..651e841 100644 --- a/crates/bymax-auth-axum/src/routes/platform.rs +++ b/crates/bymax-auth-axum/src/routes/platform.rs @@ -200,7 +200,7 @@ async fn rotated_into_platform_result( .await?; let admin = state.engine().platform_me(&claims.sub).await?; Ok(PlatformAuthResult { - user: admin, + admin, access_token: tokens.access_token, refresh_token: tokens.refresh_token, }) diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 3cb398c..5402382 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -934,7 +934,7 @@ mod tests { } fn platform() -> PlatformAuthResult { PlatformAuthResult { - user: safe_admin(), + admin: safe_admin(), access_token: "a".to_owned(), refresh_token: "r".to_owned(), } diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 1eee0c1..53b0b9a 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1135,7 +1135,7 @@ async fn platform_challenge_exchanges_a_temp_token_for_a_full_platform_session() let Ok(LoginResultMfa::Platform(result)) = exchanged else { return; }; - assert_eq!(result.user.email, "admin@example.com"); + assert_eq!(result.admin.email, "admin@example.com"); // The issued access token verifies as a PLATFORM token carrying mfa_verified, and the // serialized claims carry no tenantId. let claims = h diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 797ba9d..ad8e301 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -562,7 +562,7 @@ mod tests { .await; assert!(matches!(&result, Ok(PlatformLoginResult::Success(_)))); let Ok(PlatformLoginResult::Success(auth)) = result else { return }; - assert_eq!(auth.user.email, "ok@admin.io"); + assert_eq!(auth.admin.email, "ok@admin.io"); assert!(!auth.access_token.is_empty()); // The platform access token verifies as a platform token and carries no tenant. diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index f501644..6a26d0d 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -393,7 +393,7 @@ impl TokenManagerService { .await?; Ok(PlatformAuthResult { - user: admin.clone(), + admin: admin.clone(), access_token, refresh_token: refresh.expose_secret().to_owned(), }) diff --git a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs index 4b3172e..7dbed9b 100644 --- a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs +++ b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs @@ -183,7 +183,7 @@ async fn platform_login_me_refresh_logout_and_revoke_all_against_redis() { let Ok(PlatformLoginResult::Success(auth)) = login else { return; }; - assert_eq!(auth.user.email, "ops@admin.io"); + assert_eq!(auth.admin.email, "ops@admin.io"); // The persisted keys are the platform ones (`prt:`/`psess:`/`psd:`), never the dashboard // ones (`rt:`/`sess:`/`sd:`), and the namespace prefix applies. @@ -348,7 +348,7 @@ async fn platform_mfa_challenge_exchange_issues_a_full_session_against_redis() { let Ok(bymax_auth_core::LoginResultMfa::Platform(result)) = exchanged else { return; }; - assert_eq!(result.user.email, "mfa-admin.io"); + assert_eq!(result.admin.email, "mfa-admin.io"); // The issued access token carries `mfaVerified: true` and the platform discriminator, with // no tenantId — decoded directly from the JWT payload. let body = result.access_token.split('.').nth(1).unwrap_or_default(); diff --git a/crates/bymax-auth-types/src/results.rs b/crates/bymax-auth-types/src/results.rs index 9747c12..b2af083 100644 --- a/crates/bymax-auth-types/src/results.rs +++ b/crates/bymax-auth-types/src/results.rs @@ -36,7 +36,13 @@ pub struct AuthResult { #[serde(rename_all = "camelCase")] pub struct PlatformAuthResult { /// The authenticated admin, with all credential fields removed. - pub user: SafeAuthPlatformUser, + /// + /// Named `admin`, not `user`: that is the key the platform login body carries on the wire, + /// and it is what nest-auth emits. The field used to be `user` and the adapter renamed it + /// while building the response, which left the TypeScript generated from this struct + /// describing a key the server never sends — a consumer reading `result.user` got + /// `undefined` at runtime. One name, in the struct, in the generated type, and on the wire. + pub admin: SafeAuthPlatformUser, /// The signed HS256 platform access JWT. pub access_token: String, /// The opaque refresh token (never a JWT). @@ -202,12 +208,17 @@ mod tests { fn platform_login_result_round_trips_both_arms() { // The platform union mirrors the dashboard one over the platform result type. let success = PlatformLoginResult::Success(Box::new(PlatformAuthResult { - user: safe_platform_user(), + admin: safe_platform_user(), access_token: "jwt".to_owned(), refresh_token: "opaque".to_owned(), })); let json = serde_json::to_value(&success).unwrap_or_default(); assert_eq!(json["accessToken"], "jwt"); + // The account rides under `admin`, the key the platform body carries on the wire and + // the one nest-auth emits. A `user` here would be a type that describes a response + // nobody sends. + assert!(json["admin"].is_object()); + assert!(json["user"].is_null()); let challenge = PlatformLoginResult::MfaChallenge(MfaChallengeResult { mfa_required: true, diff --git a/packages/rust-auth/src/shared/auth-result.types.ts b/packages/rust-auth/src/shared/auth-result.types.ts index caa2aab..07b4a6c 100644 --- a/packages/rust-auth/src/shared/auth-result.types.ts +++ b/packages/rust-auth/src/shared/auth-result.types.ts @@ -47,8 +47,14 @@ mfaTempToken: string, }; export type PlatformAuthResult = { /** * The authenticated admin, with all credential fields removed. - */ -user: AuthPlatformUserClient, + * + * Named `admin`, not `user`: that is the key the platform login body carries on the wire, + * and it is what nest-auth emits. The field used to be `user` and the adapter renamed it + * while building the response, which left the TypeScript generated from this struct + * describing a key the server never sends — a consumer reading `result.user` got + * `undefined` at runtime. One name, in the struct, in the generated type, and on the wire. + */ +admin: AuthPlatformUserClient, /** * The signed HS256 platform access JWT. */ From 47f275fd15aa01549d0d4e633f1a9d6a6cdcd486 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:04:39 -0300 Subject: [PATCH 084/122] feat(core): close the security audit's findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of both implementations against OWASP's authentication, session and password-storage guidance, and against what comparable libraries ship, turned up five items. Four apply here; the fifth was nest-auth's alone. **Signing out other devices left their access tokens working.** Deleting a refresh session stops that device rotating, but the access token it already holds is stateless and keeps verifying for the rest of its lifetime — up to `jwt.access_expires_in` of continued access on a device the user just revoked. The epoch mechanism that fixes this already existed and was used by exactly one caller, the password reset. It is now used here too. The caller's own token goes stale as well, and the caller is the one party who recovers instantly: their refresh session is the one the operation deliberately preserves. **The default scrypt cost was four times below OWASP's floor** — `2^15` against the recommended `2^17` at `r=8, p=1`. Both declarations of that number moved, which is its own small finding: the config default and `ScryptParams::default()` had to agree, and when they did not, every hash written by a caller using the crate defaults was immediately stale to an engine on the other, rehashing on every login. **A duplicate registration answered faster than a new one.** Uniqueness before hashing is the cheaper thing to do and it enumerates accounts by clock, whatever the status code says. The conflict path now spends the same sentinel derivation the login path spends on an unknown address, so it adds no amplification a login could not already be used for. The response still discloses the conflict — registration issues tokens, and there are none to issue for an account the caller does not own — so what bounds it is the route's rate limit. **Rotating the signing secret was a mass logout, and worse.** It invalidated every token in flight and every stored recovery-code digest, since those are keyed by an HMAC derived from that secret: users would lose the codes they printed and filed. `jwt.previous_secrets` keeps both readable while the old tokens drain. Verification only, always after the current key, so a rotation is one-way; every retired entry is validated exactly like the current secret, because a weak one is just as forgeable. `mfa.encryption_key` is separate and still requires re-encryption — the README says so rather than implying rotation covers everything. --- CHANGELOG.md | 22 +++ README.md | 16 +- crates/bymax-auth-axum/tests/adapter.rs | 64 +++++++- crates/bymax-auth-core/src/config/mod.rs | 27 +++- crates/bymax-auth-core/src/config/profiles.rs | 2 +- crates/bymax-auth-core/src/config/validate.rs | 109 +++++++++++++ crates/bymax-auth-core/src/engine/builder.rs | 73 +++++++++ crates/bymax-auth-core/src/error.rs | 4 + .../src/services/auth/register.rs | 13 +- .../src/services/mfa/challenge.rs | 7 +- .../bymax-auth-core/src/services/mfa/mod.rs | 41 ++++- .../bymax-auth-core/src/services/mfa/tests.rs | 59 +++++++- .../bymax-auth-core/src/services/session.rs | 105 +++++++++++++ .../src/services/token_manager.rs | 143 +++++++++++++++++- crates/bymax-auth-crypto/src/password/mod.rs | 7 +- .../bymax-auth-crypto/src/password/tests.rs | 10 +- docs/technical_specification.md | 8 +- 17 files changed, 677 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ba350..ee2aa8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,13 @@ version bump. failures so the paths the library deliberately swallows can be asserted rather than assumed. +- **`jwt.previous_secrets`** — secrets retired by a rotation, accepted for verification only. + Rotating the signing secret used to sign every user out at once *and* invalidate every stored + recovery-code digest, which is keyed by an HMAC derived from that secret: users would lose the + codes they printed and filed, and find out at the moment they most need them. Both are now + readable while the old tokens drain. Signing always uses the current secret, so a rotation is + one-way. `mfa.encryption_key` is a separate key and is not covered. + ### Changed - **Family-lineage reuse detection replaces the previous sentinel.** A login opens @@ -112,6 +119,21 @@ version bump. script that decodes JSON is one the shared contract cannot be exercised against on that side. The grace record's family and the family owner's id are parsed by the caller instead, with a real parser. +- **Signing out other devices now advances the token epoch.** Deleting a refresh session stops + that device rotating, but its already-issued access token is stateless and kept verifying for + the rest of its lifetime — up to `jwt.access_expires_in` of continued access on a device the + user had just revoked. Someone doing that because they believe a device is compromised means + now. The caller's own access token is invalidated too, and the caller is the one party who + recovers instantly: their refresh session is the one deliberately preserved. **Behavioural** + for a client without silent refresh, which sees one 401 after the call. +- **The default scrypt cost is `N = 2^17`**, OWASP's recommended minimum, up from `2^15`. Both + the config default and `ScryptParams::default()` moved — they are two declarations of one + number and had to agree, since a mismatch makes every hash written by one immediately "stale" + to the other and rehashes on every login. **Behavioural**: roughly 128 MiB and ~100 ms per + hash. Lower it deliberately if the memory is not there. +- **A duplicate registration now spends the same derivation as a new one.** Skipping it was + cheaper and leaked: a taken address answered in single-digit milliseconds against ~100 ms for + a free one, which enumerates accounts by clock regardless of the status code. - **The grace window is single-shot.** The pointer was served on every request inside the window, so one captured consumed token could mint a session repeatedly. It is consumed on use now, matching `nest-auth`. diff --git a/README.md b/README.md index 7cf1e93..5c0e58b 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se | Group | Key options | nest-compat default | | ------------------ | ---------------------------------------------------------------------------- | -------------------------- | | **jwt** | `secret` (required, ≥ 32 chars), `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days` | `15m`, `7d`, off, HS256 (pinned) | -| **password** | `active_algorithm`, scrypt `cost_factor` / Argon2id `memory_kib` | scrypt N=2¹⁵, r=8, p=1 | +| **password** | `active_algorithm`, scrypt `cost_factor` / Argon2id `memory_kib` | scrypt N=2¹⁷, r=8, p=1 | | **token_delivery** | `Cookie` \| `Bearer` \| `Both` | `Cookie` | | **cookies** | names, `refresh_cookie_path`, `same_site`, `trusted_origins`, `resolve_domains` | HttpOnly, Secure, Strict, `[]` | | **mfa** | `encryption_key` (32 bytes), `issuer`, `totp_window`, `recovery_code_count` | — | @@ -437,6 +437,16 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se > [!NOTE] > `build()` validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, `SameSite=None ⇒ Secure`, `SameSite=None ⇔ trusted_origins`, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise `ConfigError`. +> [!TIP] +> **Rotating the signing secret.** `jwt.previous_secrets` lists secrets retired by a rotation, +> accepted for verification only. Without it, changing `jwt.secret` signs every user out the +> moment the new configuration rolls out *and* invalidates every stored recovery-code digest — +> those are keyed by an HMAC derived from the secret, so users lose the codes they printed and +> filed. With it, both keep working while tokens issued under the old secret drain, and a +> rotation becomes a rollout. Remove the entry once the longest-lived token signed under it has +> expired: every entry is a key that still opens the door. `mfa.encryption_key` is separate and +> is **not** covered — rotating it requires re-encrypting the stored TOTP secrets. + > [!IMPORTANT] > `jwt.access_expires_in` must not exceed **30 days**, the window a store keeps a bumped token > epoch readable. The epoch is what makes a stateless access token revocable: a password reset @@ -557,7 +567,7 @@ When integrating `bymax-auth` in production, verify each of the following: | Layer | Implementation | | ----------------- | -------------------------------------------------------------------- | -| Password Hashing | RustCrypto `scrypt` (N=2¹⁵, r=8, p=1) **or** `argon2` Argon2id (PHC) | +| Password Hashing | RustCrypto `scrypt` (N=2¹⁷, r=8, p=1 — OWASP's recommended minimum) **or** `argon2` Argon2id (PHC) | | MFA Encryption | `aes-gcm` AES-256-GCM with a fresh 12-byte CSPRNG IV per call | | TOTP | `hmac` + `sha1` per RFC 4226/6238, ±1 step window, anti-replay marked | | Recovery Codes | Keyed **HMAC-SHA-256** digests (never plaintext, never reversible) | @@ -624,7 +634,7 @@ Tracked with [Criterion](https://github.com/bheisler/criterion.rs) so a regressi | Secure token (32 B → hex) | ~870 ns | dominated by the OS CSPRNG syscall, not allocation | | AES-256-GCM encrypt / decrypt | ~2.1 µs / ~1.3 µs | TOTP secret encrypted at rest | | TOTP generate / verify (±1 window) | ~200 ns / ~710 ns | RFC 6238, constant-time | -| scrypt hash / verify (N=2¹⁵) | ~37 ms | memory-hard — tunable security cost | +| scrypt hash / verify (N=2¹⁷) | ~150 ms | memory-hard — tunable security cost, at OWASP's recommended minimum | | Argon2id hash / verify (19 MiB) | ~10 ms | memory-hard — tunable security cost | Indicative medians on an Apple M4 Max, `release` profile, Rust 1.96. Reproduce with `cargo bench -p bymax-auth-crypto --bench crypto --all-features`. Absolute figures are hardware-dependent — the point is the order of magnitude and that the numbers are tracked, not hand-waved. diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 96c3ff9..a3d5f78 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -649,8 +649,68 @@ async fn sessions_list_revoke_one_and_revoke_all() { .await; assert_eq!(revoke_all.status, StatusCode::NO_CONTENT); - // Revoke a specific session by its hash. - let revoke_one = Req::delete(&format!("/auth/sessions/{hash}")) + // Revoking the other devices advances the token epoch, so every access token for the + // account is stale — the caller's included. Without it, a device the user just signed out + // keeps working until its access token expires, which is not what "sign out my other + // devices" means to the person clicking it. + let stale = Req::get("/auth/sessions") + .cookie("access_token", &access) + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(stale.status, StatusCode::UNAUTHORIZED); + + // The caller — and only the caller — recovers immediately: the one refresh session the + // revocation preserved is theirs. The revoked devices lost theirs and cannot. + let rotated = Req::post("/auth/refresh") + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(rotated.status, StatusCode::OK); + let access = rotated + .cookie_value("access_token") + .unwrap_or_default() + .to_owned(); + let refresh = rotated + .cookie_value("refresh_token") + .unwrap_or_default() + .to_owned(); + + // The pre-rotation hash names a session the rotation has since replaced, so the `{id}` + // route reports it as gone rather than pretending to revoke it. + let already_gone = Req::delete(&format!("/auth/sessions/{hash}")) + .cookie("access_token", &access) + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(already_gone.status, StatusCode::NOT_FOUND); + + // A second device, so there is a real session to revoke by id. + let second = Req::post("/auth/login") + .json(serde_json::json!({ + "email": "s@e.com", "password": "password123", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(second.status, StatusCode::OK); + + let list = Req::get("/auth/sessions") + .cookie("access_token", &access) + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(list.status, StatusCode::OK); + let sessions = list.json().as_array().cloned().unwrap_or_default(); + let victim = sessions + .iter() + .find(|session| session["isCurrent"] != true) + .and_then(|session| session["sessionHash"].as_str()) + .unwrap_or_default() + .to_owned(); + assert!(!victim.is_empty()); + + // Revoke that one by its hash. + let revoke_one = Req::delete(&format!("/auth/sessions/{victim}")) .cookie("access_token", &access) .cookie("refresh_token", &refresh) .send(&app) diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index 428454b..56bf888 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -63,7 +63,13 @@ impl PasswordAlgorithm { /// scrypt cost parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ScryptParams { - /// CPU/memory cost N — a power of two, default 32768 (2^15), minimum 16384 (2^14). + /// CPU/memory cost N — a power of two, default 131072 (2^17), minimum 16384 (2^14). + /// + /// The default is OWASP's recommended minimum for scrypt alongside `r=8, p=1`: roughly + /// 128 MiB and ~100 ms per hash on a modern core. That cost is the point — it is what makes + /// an offline attack on a leaked hash expensive. A deployment that cannot afford the memory + /// should lower this deliberately, knowing what it gives up, rather than inherit a weaker + /// default that never announced itself. pub cost_factor: u32, /// Block size r, default 8. pub block_size: u32, @@ -74,7 +80,7 @@ pub struct ScryptParams { impl Default for ScryptParams { fn default() -> Self { Self { - cost_factor: 1 << 15, + cost_factor: 1 << 17, block_size: 8, parallelization: 1, } @@ -134,6 +140,22 @@ impl Default for PasswordConfig { pub struct JwtConfig { /// Signing secret. Required. Redacted in `Debug`, zeroized on drop. pub secret: SecretString, + /// Secrets retired by a rotation, accepted for **verification only**. Default: empty. + /// + /// Rotating [`JwtConfig::secret`] with nothing else in place invalidates every token in + /// flight at once — every signed-in user is signed out the moment the new configuration + /// rolls out — and invalidates every stored recovery-code digest, which is keyed by an HMAC + /// derived from the secret. Users would be locked out of the codes they printed and filed, + /// and would discover it at the moment they most need them. Listing the previous secret + /// here keeps both readable while tokens issued under it drain, so a rotation is a rollout + /// rather than a mass logout. + /// + /// Signing always uses [`JwtConfig::secret`]. Entries here are only ever tried after it, + /// and only to verify, so a rotation is one-way: nothing new is ever produced under a + /// retired secret. Remove an entry once the longest-lived thing signed under it has + /// expired — every entry is a key that still opens the door — and each is validated exactly + /// like the current secret, because a weak one is just as forgeable. + pub previous_secrets: Vec, /// Access-token lifetime, default 15m. pub access_expires_in: Duration, /// Access-token cookie `Max-Age`, default 15m. @@ -166,6 +188,7 @@ impl Default for JwtConfig { fn default() -> Self { Self { secret: SecretString::from(String::new()), + previous_secrets: Vec::new(), access_expires_in: Duration::from_secs(15 * 60), access_cookie_max_age: Duration::from_secs(15 * 60), refresh_expires_in_days: 7, diff --git a/crates/bymax-auth-core/src/config/profiles.rs b/crates/bymax-auth-core/src/config/profiles.rs index d4bb3f4..f1a81a3 100644 --- a/crates/bymax-auth-core/src/config/profiles.rs +++ b/crates/bymax-auth-core/src/config/profiles.rs @@ -93,7 +93,7 @@ mod tests { assert_eq!(cfg.brute_force.max_attempts, 5); assert_eq!(cfg.password_reset.token_ttl, Duration::from_secs(600)); assert_eq!(cfg.invitations.token_ttl, Duration::from_secs(172_800)); - assert_eq!(cfg.password.scrypt.cost_factor, 1 << 15); + assert_eq!(cfg.password.scrypt.cost_factor, 1 << 17); assert_eq!(cfg.sessions.default_max_sessions, 5); assert_eq!(cfg.route_prefix, "auth"); assert_eq!(cfg.redis_namespace, "auth"); diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index ded667b..e38c0ef 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -33,6 +33,9 @@ pub struct ResolvedConfig { environment: Environment, secure_cookies: bool, hmac_key: SecretBox<[u8; 64]>, + /// The same derivation over each retired secret, for verification-only reads during a + /// rotation. Empty unless one is in progress. + previous_hmac_keys: Vec>, } impl ResolvedConfig { @@ -41,11 +44,18 @@ impl ResolvedConfig { /// resolved `secure_cookies` value. pub(crate) fn new(config: AuthConfig, environment: Environment, secure_cookies: bool) -> Self { let hmac_key = derive_hmac_key(config.jwt.secret.expose_secret()); + let previous_hmac_keys = config + .jwt + .previous_secrets + .iter() + .map(|secret| derive_hmac_key(secret.expose_secret())) + .collect(); Self { config, environment, secure_cookies, hmac_key, + previous_hmac_keys, } } @@ -78,6 +88,21 @@ impl ResolvedConfig { self.hmac_key.expose_secret() } + /// The identifier-hashing keys derived from `jwt.previous_secrets`, in the order given. + /// Empty unless a rotation is in progress. + /// + /// Read-only, like the secrets they come from: a recovery-code digest written under a + /// retired key still verifies, so rotating the signing secret does not lock users out of + /// the codes they printed and filed. Nothing is ever newly written under one — a code that + /// matches here is consumed and the set is regenerated under the current key. + #[must_use] + pub fn previous_hmac_keys(&self) -> Vec<[u8; 64]> { + self.previous_hmac_keys + .iter() + .map(|key| *key.expose_secret()) + .collect() + } + /// Whether `held_role` satisfies a required dashboard/tenant role under the dashboard /// hierarchy: a role satisfies itself, or any role it transitively includes (the hierarchy /// is fully denormalized, so this is a single-level membership test). A role absent from the @@ -187,6 +212,29 @@ impl AuthConfig { return Err(ConfigError::JwtSecretLowEntropy { entropy }); } + // Rule 2b: every retired secret is held to the same bar. They still verify tokens and + // still read recovery-code digests, so a weak entry is exactly as forgeable as a weak + // current secret would be — the rotation list is not a place where the bar drops. A + // retired secret equal to the current one is rejected too: it means the rotation never + // happened, and a configuration that reads as rotated while nothing changed is worse + // than one that never claimed to. + let mut seen: Vec<&str> = vec![secret]; + for previous in &self.jwt.previous_secrets { + let previous = previous.expose_secret(); + let len = previous.chars().count(); + if len < MIN_SECRET_LEN { + return Err(ConfigError::JwtSecretTooShort { len }); + } + let entropy = shannon_entropy(previous); + if entropy < MIN_SECRET_ENTROPY { + return Err(ConfigError::JwtSecretLowEntropy { entropy }); + } + if seen.contains(&previous) { + return Err(ConfigError::PreviousSecretRepeated); + } + seen.push(previous); + } + // Rule 3-4: refresh lifetime positive + grace window strictly smaller. if self.jwt.refresh_expires_in_days == 0 { return Err(ConfigError::RefreshLifetimeInvalid { got: 0 }); @@ -1378,4 +1426,65 @@ mod tests { // `validate`, so this guards the standalone helper). assert_eq!(shannon_entropy(""), 0.0); } + + #[test] + fn retired_secrets_are_held_to_the_same_bar_as_the_current_one() { + // They still verify tokens and still read recovery-code digests, so a weak entry is + // exactly as forgeable as a weak current secret. The rotation list is not a place where + // the bar drops. + let strong = "kR7pQw9zTr4XmVn2PsB6yLdG3hJ8fCxZ5aNeU1oIqW0M"; + let mut cfg = valid_config(); + cfg.jwt.secret = SecretString::from(strong.to_owned()); + + // A well-formed rotation is accepted, and derives one identifier key per retired secret + // — the keys that keep pre-rotation recovery-code digests readable. + let mut rotating = cfg.clone(); + rotating.jwt.previous_secrets = vec![SecretString::from( + "Zx4mQ7wLpR2nT9yB6vKdH3sJfCgA5eU8iO1rXwNqYtM0".to_owned(), + )]; + assert!(rotating.validate(Environment::Test).is_ok()); + let resolved = ResolvedConfig::new(rotating.clone(), Environment::Test, true); + assert_eq!(resolved.previous_hmac_keys().len(), 1); + assert_ne!(resolved.previous_hmac_keys()[0], *resolved.hmac_key()); + + // With no rotation in progress there are no retired keys at all. + let none = ResolvedConfig::new(cfg.clone(), Environment::Test, true); + assert!(none.previous_hmac_keys().is_empty()); + + // Too short, and too repetitive: rejected by the same two rules the current secret faces. + let mut short = cfg.clone(); + short.jwt.previous_secrets = vec![SecretString::from("too-short".to_owned())]; + assert!(matches!( + short.validate(Environment::Test), + Err(ConfigError::JwtSecretTooShort { .. }) + )); + + let mut flat = cfg.clone(); + flat.jwt.previous_secrets = vec![SecretString::from("a".repeat(40))]; + assert!(matches!( + flat.validate(Environment::Test), + Err(ConfigError::JwtSecretLowEntropy { .. }) + )); + + // The current secret repeated, and a duplicate entry: both mean the rotation being + // described did not happen, and a config that reads as rotated while nothing changed is + // worse than one that never claimed to. + let mut echoed = cfg.clone(); + echoed.jwt.previous_secrets = vec![SecretString::from(strong.to_owned())]; + assert!(matches!( + echoed.validate(Environment::Test), + Err(ConfigError::PreviousSecretRepeated) + )); + + let other = "Zx4mQ7wLpR2nT9yB6vKdH3sJfCgA5eU8iO1rXwNqYtM0"; + let mut duplicated = cfg; + duplicated.jwt.previous_secrets = vec![ + SecretString::from(other.to_owned()), + SecretString::from(other.to_owned()), + ]; + assert!(matches!( + duplicated.validate(Environment::Test), + Err(ConfigError::PreviousSecretRepeated) + )); + } } diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index d3fe677..a0df4ef 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -401,8 +401,16 @@ impl AuthEngineBuilder { let sessions_enabled = session_config.enabled; let config = Arc::new(ResolvedConfig::new(config, environment, secure_cookies)); + let previous_keys = config + .config() + .jwt + .previous_secrets + .iter() + .map(|secret| HsKey::from_bytes(secret.expose_secret().as_bytes())) + .collect(); let tokens = TokenManagerService::new( signing_key, + previous_keys, session_store.clone(), access_ttl, refresh_days, @@ -560,6 +568,12 @@ fn build_mfa_service(wiring: MfaWiring<'_>) -> Option return Err(AuthError::Forbidden), }; - // Uniqueness-before-hash: never spend a memory-hard derivation on a duplicate email. + // Uniqueness before hashing — but the conflict path still spends the derivation. + // + // Skipping it is the cheaper thing to do and it leaks: a taken address answers in + // single-digit milliseconds while a free one spends ~100 ms deriving, which enumerates + // accounts by clock even for a caller who ignores the status code. The response itself + // cannot be made uniform here — registration issues tokens, and there are none to issue + // for an account the caller does not own — so the timing is the part that can be fixed. + // What bounds the disclosure that remains is the route's own rate limit. + // + // The sentinel verify is the same one the login path spends on an unknown address, so + // this adds no amplification a login could not already be used for. if self .user_repository() .find_by_email(&input.email, &tenant_id) @@ -61,6 +71,7 @@ impl AuthEngine { .map_err(map_repository_error)? .is_some() { + self.passwords().verify_sentinel(&input.password).await?; return Err(AuthError::EmailAlreadyExists); } diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 1776df8..118b3c3 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -192,7 +192,8 @@ impl MfaService { } None } else { - match super::verify_recovery_code(&recovery_codes, &self.hash_recovery_code(code)) { + match super::verify_recovery_code(&recovery_codes, &self.recovery_code_candidates(code)) + { Some(index) => { // The recovery-code path carries no `tu:` marker, so the temp token is // consumed standalone now that the code is confirmed valid. @@ -269,9 +270,9 @@ impl MfaService { /// Scan the stored recovery-code digests for a constant-time match of `code`, returning /// the matched index or `None`. fn accept_recovery_code(&self, user: &AuthUser, code: &str) -> Option { - let digest = self.hash_recovery_code(code); + let candidates = self.recovery_code_candidates(code); let stored = user.mfa_recovery_codes.clone().unwrap_or_default(); - super::verify_recovery_code(&stored, &digest) + super::verify_recovery_code(&stored, &candidates) } /// Remove the just-used recovery code from the stored set and persist the smaller set diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index a730606..5716049 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -135,6 +135,9 @@ pub struct MfaService { /// The engine's identifier-hashing key, keying every `mfa_setup:`/`tu:` suffix, the /// `challenge:`/`disable:` brute-force ids, and the recovery-code digests. identifier_key: Zeroizing<[u8; 64]>, + /// Identifier keys retired by a secret rotation, used only to READ recovery-code digests + /// written before it. Empty unless a rotation is in progress. + previous_identifier_keys: Vec>, issuer: String, totp_window: u8, recovery_code_count: u8, @@ -159,6 +162,8 @@ pub(crate) struct MfaServiceDeps { pub(crate) hooks: Arc, pub(crate) encryption_key: Zeroizing<[u8; 32]>, pub(crate) identifier_key: Zeroizing<[u8; 64]>, + /// Identifier keys retired by a secret rotation (see the field of the same name). + pub(crate) previous_identifier_keys: Vec>, pub(crate) issuer: String, pub(crate) totp_window: u8, pub(crate) recovery_code_count: u8, @@ -181,6 +186,7 @@ impl MfaService { hooks: deps.hooks, encryption_key: deps.encryption_key, identifier_key: deps.identifier_key, + previous_identifier_keys: deps.previous_identifier_keys, issuer: deps.issuer, totp_window: deps.totp_window, recovery_code_count: deps.recovery_code_count, @@ -245,6 +251,20 @@ impl MfaService { to_hex(&hmac_sha256(self.identifier_key.as_ref(), code.as_bytes())) } + /// Every digest a presented recovery code could legitimately match: the one under the + /// current identifier key, then one per key retired by a secret rotation. + /// + /// Only the first is ever written. The rest exist so a rotation does not invalidate codes + /// a user already holds — see [`verify_recovery_code`]. + fn recovery_code_candidates(&self, code: &str) -> Vec { + let mut candidates = Vec::with_capacity(1 + self.previous_identifier_keys.len()); + candidates.push(self.hash_recovery_code(code)); + for key in &self.previous_identifier_keys { + candidates.push(to_hex(&hmac_sha256(key.as_ref(), code.as_bytes()))); + } + candidates + } + /// AES-256-GCM-encrypt `plaintext` under the configured MFA key. /// /// # Errors @@ -563,14 +583,23 @@ fn generate_recovery_code() -> String { /// Find the index of the recovery code matching `code` among the stored keyed-HMAC digests, /// in constant time across the whole set (no early return on a match), so neither which code /// matched nor whether any matched leaks through timing. Returns the matched index, or `None`. -fn verify_recovery_code(stored_digests: &[String], candidate_digest: &str) -> Option { +fn verify_recovery_code(stored_digests: &[String], candidates: &[String]) -> Option { let mut found: Option = None; for (index, digest) in stored_digests.iter().enumerate() { - // Accumulate without short-circuiting: every element is compared. `or` keeps the FIRST - // match (a later duplicate digest cannot overwrite it) while still visiting every - // element, so the scan stays constant-time and the spliced index is unambiguous. - if constant_time_eq(digest.as_bytes(), candidate_digest.as_bytes()) { - found = found.or(Some(index)); + // Accumulate without short-circuiting: every element is compared against every + // candidate. `or` keeps the FIRST match (a later duplicate digest cannot overwrite it) + // while still visiting every element, so the scan stays constant-time and the spliced + // index is unambiguous. + // + // More than one candidate means a secret rotation is in progress: the digest is keyed + // by an HMAC derived from the signing secret, so without the retired keys a rotation + // would silently invalidate every code a user printed and filed — discovered at the + // moment they most need it. Retired keys read only; a code that matches one is consumed + // and the set is regenerated under the current key. + for candidate in candidates { + if constant_time_eq(digest.as_bytes(), candidate.as_bytes()) { + found = found.or(Some(index)); + } } } found diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 53b0b9a..7347d9c 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1378,12 +1378,23 @@ impl MfaStore for ScriptedMfaStore { /// Build an `MfaService` directly over a custom MFA store and a seeded user, with the other /// collaborators backed by fresh in-memory doubles. The AES key is the fixed `[7u8; 32]` the /// scripted records are encrypted under. -fn service_over(store: Arc, users: Arc) -> MfaService { +fn service_with_previous_keys( + store: Arc, + users: Arc, + previous_identifier_keys: Vec>, +) -> MfaService { + let mut deps = service_deps(store, users); + deps.previous_identifier_keys = previous_identifier_keys; + MfaService::new(deps) +} + +fn service_deps(store: Arc, users: Arc) -> MfaServiceDeps { let inmem = Arc::new(InMemoryStores::new()); let session_store: Arc = inmem.clone(); let brute_force_store: Arc = inmem; let tokens = Arc::new(TokenManagerService::new( HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), session_store.clone(), Duration::from_secs(900), 7, @@ -1398,7 +1409,7 @@ fn service_over(store: Arc, users: Arc) -> 3600, )); let brute_force = Arc::new(BruteForceService::new(brute_force_store, 5, 900)); - MfaService::new(MfaServiceDeps { + MfaServiceDeps { mfa_store: store, user_repo: users, platform_repo: None, @@ -1410,12 +1421,17 @@ fn service_over(store: Arc, users: Arc) -> hooks: Arc::new(NoOpAuthHooks), encryption_key: zeroize::Zeroizing::new([7u8; 32]), identifier_key: zeroize::Zeroizing::new([9u8; 64]), + previous_identifier_keys: Vec::new(), issuer: "Bymax One".to_owned(), totp_window: 2, recovery_code_count: 8, sessions_enabled: false, blocked_statuses: vec!["BANNED".to_owned(), "SUSPENDED".to_owned()], - }) + } +} + +fn service_over(store: Arc, users: Arc) -> MfaService { + MfaService::new(service_deps(store, users)) } /// Seed a fresh user (not MFA-enabled) and return its id. @@ -1991,3 +2007,40 @@ async fn the_stored_totp_secret_and_recovery_digests_match_the_shared_credential "a recovery digest was written in the legacy form" ); } + +#[tokio::test] +async fn a_recovery_code_digested_under_a_retired_key_still_verifies() { + // The digest is keyed by an HMAC derived from the signing secret, so a rotation without the + // retired key silently invalidates every code a user printed and filed — and they find out + // at the moment they most need it, locked out of an account they cannot reach another way. + let users = Arc::new(InMemoryUserRepository::new()); + let retired = zeroize::Zeroizing::new([3u8; 64]); + + // A digest written under the retired key: nothing in the stored set matches the current one. + let plain = "ABCD-EF12-3456"; + let stale_digest = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( + retired.as_ref(), + plain.as_bytes(), + )); + + // Without the retired key listed, the code does not verify… + let strict = service_over(Arc::new(InMemoryStores::new()), users.clone()); + assert!( + super::verify_recovery_code( + std::slice::from_ref(&stale_digest), + &strict.recovery_code_candidates(plain) + ) + .is_none() + ); + + // …and with it, it does — at index 0, so the right code is the one consumed. + let rotating = + service_with_previous_keys(Arc::new(InMemoryStores::new()), users, vec![retired]); + assert_eq!( + super::verify_recovery_code( + &["a".repeat(64), stale_digest], + &rotating.recovery_code_candidates(plain) + ), + Some(1) + ); +} diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index d0902be..2e93ae7 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -228,6 +228,18 @@ impl SessionService { /// action). A `SessionNotFound` for a victim (a concurrent logout already removed it) is /// swallowed; any other store error is propagated. /// + /// The user's token epoch is advanced as part of this, which is what makes the revocation + /// take effect *now* rather than whenever each device's access token happens to expire. + /// Deleting a refresh session stops that device rotating, but its already-issued access + /// token is stateless and keeps verifying for the rest of its lifetime — up to + /// `jwt.access_expires_in` of continued access on a device the user just told the system to + /// sign out. Someone who does this because they think a device is compromised means now. + /// + /// The caller's own access token is invalidated too — the epoch is per user, not per + /// session — but the caller is the one party who can recover instantly: their refresh + /// session is deliberately preserved, so the next request refreshes and continues. The + /// revoked devices cannot, having lost the refresh token that would let them. + /// /// # Errors /// /// Returns [`AuthError::SessionNotFound`] when `current_hash` is malformed, or a store @@ -258,6 +270,12 @@ impl SessionService { Err(other) => return Err(other), } } + // Last, and only once every victim is gone: a failure above leaves the epoch untouched + // rather than signing the caller out of a device the loop never got to revoke. + self.store + .bump_epoch(SessionKind::Dashboard, user_id) + .await?; + tracing::info!(%user_id, "sessions: revoked all other devices, token epoch advanced"); Ok(()) } @@ -1398,4 +1416,91 @@ mod tests { drop(guard); assert!(!events.contains("sessions: eviction of an over-cap session failed")); } + + #[tokio::test] + async fn revoking_other_devices_advances_the_token_epoch() { + // Deleting a refresh session stops that device ROTATING; its already-issued access token + // is stateless and keeps verifying for the rest of its lifetime. A user signing out a + // device they believe is compromised means now, so the epoch advances and every + // outstanding access token for the account stops verifying at once. + let store = Arc::new(InMemoryStores::new()); + let users = Arc::new(InMemoryUserRepository::new()); + let uid = seed_user(&users, "everywhere").await; + let base = OffsetDateTime::UNIX_EPOCH; + let current = hash("cccc"); + let other = hash("dddd"); + for (h, at) in [ + (¤t, base), + (&other, base + time::Duration::seconds(1)), + ] { + assert!( + store + .create_session(SessionKind::Dashboard, h, &record(&uid, at), 3600) + .await + .is_ok() + ); + } + + let svc = service( + store.clone(), + users, + Arc::new(CountingHooks::default()), + config(5, None), + ); + assert!(matches!( + store.current_epoch(SessionKind::Dashboard, &uid).await, + Ok(0) + )); + + assert!(svc.revoke_all_except_current(&uid, ¤t).await.is_ok()); + + // The other device is gone, the caller's session survives — and every access token for + // the account, the caller's included, is now stale. The caller is the only party that + // can recover: their refresh session is the one still standing. + assert!(matches!( + store.current_epoch(SessionKind::Dashboard, &uid).await, + Ok(epoch) if epoch > 0 + )); + assert!(matches!(svc.list_sessions(&uid, Some(¤t)).await, Ok(v) if v.len() == 1)); + } + + #[tokio::test] + async fn a_failed_revocation_leaves_the_epoch_untouched() { + // Bumping after a partial failure would be the worst of both outcomes: the caller signed + // out of a device the loop never managed to revoke, and no trace that it failed. + let store = Arc::new(InMemoryStores::new()); + let users = Arc::new(InMemoryUserRepository::new()); + let uid = seed_user(&users, "partial").await; + let base = OffsetDateTime::UNIX_EPOCH; + let current = hash("eeee"); + let other = hash("ffff"); + for (h, at) in [ + (¤t, base), + (&other, base + time::Duration::seconds(1)), + ] { + assert!( + store + .create_session(SessionKind::Dashboard, h, &record(&uid, at), 3600) + .await + .is_ok() + ); + } + + store.fail_next_cleanup_writes(1); + let svc = service( + store.clone(), + users, + Arc::new(CountingHooks::default()), + config(5, None), + ); + + assert!(matches!( + svc.revoke_all_except_current(&uid, ¤t).await, + Err(AuthError::Internal(_)) + )); + assert!(matches!( + store.current_epoch(SessionKind::Dashboard, &uid).await, + Ok(0) + )); + } } diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 6a26d0d..39be749 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -91,6 +91,9 @@ fn jti_hash(jti: &str) -> String { /// is wired with the platform domain. pub struct TokenManagerService { key: HsKey, + /// Keys retired by a rotation, tried only after [`Self::key`] and only to verify. Empty + /// unless a rotation is in progress; nothing is ever signed under one. + previous_keys: Vec, session_store: Arc, access_ttl: Duration, refresh_ttl_secs: u64, @@ -102,10 +105,38 @@ pub struct TokenManagerService { } impl TokenManagerService { + /// Verify a token against the current signing key, then against any retired by a rotation. + /// + /// The current key is always tried first, so the common path costs exactly what it did + /// before. Retired keys verify only — nothing is ever signed under one, which is what makes + /// a rotation one-way — and every other check the verifier makes (algorithm pinning, + /// expiry, claim decoding) still applies to them, so a retired key buys a token nothing but + /// signature acceptance. + /// + /// Every failure is the current key's failure: reporting *which* key rejected the token + /// would tell an attacker whether a forgery was made under a key the deployment used to + /// hold. + fn verify_rotating( + &self, + token: &str, + ) -> Result { + let current = verify::(token, &self.key, &VerifyOptions::default()); + if current.is_ok() || self.previous_keys.is_empty() { + return current; + } + for key in &self.previous_keys { + if let Ok(claims) = verify::(token, key, &VerifyOptions::default()) { + return Ok(claims); + } + } + current + } + /// Assemble the token manager from the signing key, the session store, and the /// resolved token lifetimes. pub(crate) fn new( key: HsKey, + previous_keys: Vec, session_store: Arc, access_ttl: Duration, refresh_expires_in_days: u32, @@ -114,6 +145,7 @@ impl TokenManagerService { ) -> Self { Self { key, + previous_keys, session_store, access_ttl, refresh_ttl_secs: u64::from(refresh_expires_in_days) * 86_400, @@ -513,7 +545,8 @@ impl TokenManagerService { /// public [`AuthError::TokenInvalid`]; all collapse to `token_invalid` at the boundary. #[cfg(feature = "platform")] pub async fn verify_platform_access(&self, token: &str) -> Result { - let claims = verify::(token, &self.key, &VerifyOptions::default()) + let claims = self + .verify_rotating::(token) .map_err(map_jwt_error)?; if self.session_store.is_blacklisted(&claims.jti).await? { return Err(AuthError::TokenRevoked); @@ -560,7 +593,8 @@ impl TokenManagerService { /// the public [`AuthError::TokenInvalid`]; all collapse to `token_invalid` at the HTTP /// boundary so no oracle is exposed. pub async fn verify_access(&self, token: &str) -> Result { - let claims = verify::(token, &self.key, &VerifyOptions::default()) + let claims = self + .verify_rotating::(token) .map_err(map_jwt_error)?; if self.session_store.is_blacklisted(&claims.jti).await? { return Err(AuthError::TokenRevoked); @@ -684,7 +718,8 @@ impl TokenManagerService { /// [`AuthError`] on a backend failure. #[cfg(feature = "mfa")] pub async fn verify_mfa_temp_token(&self, token: &str) -> Result { - let claims = verify::(token, &self.key, &VerifyOptions::default()) + let claims = self + .verify_rotating::(token) .map_err(|_| AuthError::MfaTempTokenInvalid)?; let Some(support) = &self.mfa else { return Err(AuthError::MfaTempTokenInvalid); @@ -877,6 +912,7 @@ mod tests { fn service(store: Arc) -> TokenManagerService { TokenManagerService::new( key(), + Vec::new(), store, Duration::from_secs(900), 7, @@ -886,6 +922,19 @@ mod tests { ) } + /// A manager whose current key is `key()` and which also accepts `retired` for verification. + fn service_rotating(store: Arc, retired: Vec) -> TokenManagerService { + TokenManagerService::new( + key(), + retired, + store, + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ) + } + fn user() -> SafeAuthUser { SafeAuthUser { id: "u1".to_owned(), @@ -1419,6 +1468,7 @@ mod tests { let support = MfaTokenSupport::new(mfa_store, brute_force, &[7u8; 64]); TokenManagerService::new( key(), + Vec::new(), store, Duration::from_secs(900), 7, @@ -1532,6 +1582,91 @@ mod tests { assert!(matches!(bf.is_locked(&challenge_id, 5).await, Ok(false))); assert!(matches!(bf.is_locked(&disable_id, 5).await, Ok(true))); } + + fn retired_key() -> HsKey { + HsKey::from_bytes(b"the-previous-hs256-secret-abcdefgh") + } + + #[tokio::test] + async fn a_token_signed_under_a_retired_secret_still_verifies() { + // Without this, rotating the signing secret signs every user out at the moment the new + // configuration rolls out. Listing the old secret makes the rotation a rollout instead. + let store = Arc::new(InMemoryStores::new()); + let old_manager = service_rotating(store.clone(), Vec::new()); + // Mint under the retired key by making it the CURRENT key of a throwaway manager. + let minted_under_old = TokenManagerService::new( + retired_key(), + Vec::new(), + store.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ); + let issued = minted_under_old + .issue_tokens(&user(), "1.2.3.4", "agent", false) + .await; + let Ok(issued) = issued else { return }; + drop(old_manager); + + // The current key alone rejects it… + let strict = service_rotating(store.clone(), Vec::new()); + assert!(matches!( + strict.verify_access(&issued.access_token).await, + Err(AuthError::TokenInvalid) + )); + + // …and with the retired key listed, it verifies. + let rotating = service_rotating(store, vec![retired_key()]); + assert!(matches!( + rotating.verify_access(&issued.access_token).await, + Ok(claims) if claims.sub == "u1" + )); + } + + #[tokio::test] + async fn a_retired_secret_excuses_nothing_but_the_signature() { + // A token nobody signed is still refused, and the failure is the CURRENT key's — which + // is what keeps the error from reporting whether a forgery matched a key the deployment + // used to hold. + let store = Arc::new(InMemoryStores::new()); + let rotating = service_rotating(store.clone(), vec![retired_key()]); + let forged = TokenManagerService::new( + HsKey::from_bytes(b"a-key-nobody-in-this-deployment-holds"), + Vec::new(), + store, + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ); + let issued = forged + .issue_tokens(&user(), "1.2.3.4", "agent", false) + .await; + let Ok(issued) = issued else { return }; + + assert!(matches!( + rotating.verify_access(&issued.access_token).await, + Err(AuthError::TokenInvalid) + )); + } + + #[tokio::test] + async fn the_current_key_is_always_tried_first() { + // The common path must not pay for a feature nobody switched on: a token under the + // current key verifies whether or not retired keys are listed. + let store = Arc::new(InMemoryStores::new()); + let rotating = service_rotating(store.clone(), vec![retired_key()]); + let issued = rotating + .issue_tokens(&user(), "1.2.3.4", "agent", false) + .await; + let Ok(issued) = issued else { return }; + + assert!(matches!( + rotating.verify_access(&issued.access_token).await, + Ok(claims) if claims.sub == "u1" + )); + } } #[cfg(test)] @@ -1544,6 +1679,7 @@ mod absolute_lifetime_tests { fn capped(store: Arc) -> TokenManagerService { TokenManagerService::new( HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), store, Duration::from_secs(900), 7, @@ -1678,6 +1814,7 @@ mod absolute_lifetime_tests { let uncapped = TokenManagerService::new( HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), store.clone(), Duration::from_secs(900), 7, diff --git a/crates/bymax-auth-crypto/src/password/mod.rs b/crates/bymax-auth-crypto/src/password/mod.rs index a0ac1eb..ae977a9 100644 --- a/crates/bymax-auth-crypto/src/password/mod.rs +++ b/crates/bymax-auth-crypto/src/password/mod.rs @@ -76,9 +76,14 @@ impl ScryptParams { } impl Default for ScryptParams { + /// OWASP's recommended minimum for scrypt: `N = 2^17`, `r = 8`, `p = 1`. + /// + /// This has to stay equal to `AuthConfig`'s scrypt default. They are two declarations of one + /// number, and when they disagreed every hash written by a caller using these params was + /// immediately "stale" to an engine running the other — a rehash on every single login. fn default() -> Self { Self { - cost_factor: 1 << 15, + cost_factor: 1 << 17, block_size: 8, parallelization: 1, } diff --git a/crates/bymax-auth-crypto/src/password/tests.rs b/crates/bymax-auth-crypto/src/password/tests.rs index 0660157..2d77d78 100644 --- a/crates/bymax-auth-crypto/src/password/tests.rs +++ b/crates/bymax-auth-crypto/src/password/tests.rs @@ -9,11 +9,13 @@ use super::*; #[test] fn default_params_are_scrypt_at_the_baseline() { - // The default writer is scrypt at the nest-auth baseline (N=2^15, r=8, p=1) — the - // drop-in parity posture the library promises out of the box. + // The default writer is scrypt at OWASP's recommended minimum (N=2^17, r=8, p=1), which + // nest-auth also defaults to — the drop-in parity posture the library promises out of the + // box. Pinned to the literal: read back through `ScryptParams::default()` this would agree + // with itself no matter what the number became. let params = PasswordParams::default(); assert_eq!(params.active, PasswordAlgorithm::Scrypt); - assert_eq!(params.scrypt.cost_factor, 1 << 15); + assert_eq!(params.scrypt.cost_factor, 1 << 17); assert_eq!(params.scrypt.block_size, 8); assert_eq!(params.scrypt.parallelization, 1); } @@ -171,7 +173,7 @@ mod scrypt_tests { let phc = hash(b"pw", &PasswordParams::default()).unwrap_or_default(); let stronger = PasswordParams { scrypt: ScryptParams { - cost_factor: 1 << 16, + cost_factor: 1 << 18, ..ScryptParams::default() }, ..PasswordParams::default() diff --git a/docs/technical_specification.md b/docs/technical_specification.md index 2622cb1..c87f5cd 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -816,7 +816,7 @@ pub enum PasswordAlgorithm { #[derive(Clone, Copy)] pub struct ScryptParams { - /// CPU/memory cost N. Power of two. Default 32768 (2^15). Min 16384 (2^14). + /// CPU/memory cost N. Power of two. Default 131072 (2^17). Min 16384 (2^14). pub cost_factor: u32, /// Block size r. Default 8. pub block_size: u32, @@ -1048,7 +1048,7 @@ The **Default** column lists `AuthConfig::default()` (≡ `nest_compat_defaults( | `roles.platform_hierarchy` | `Option>` | Cond. | `None` | Required when `platform.enabled` | | `password.active_algorithm` | `PasswordAlgorithm` | No | `Scrypt` | New-hash algorithm; `Argon2id` requires the `argon2` feature (§5.1.9) | | `password.rehash_on_verify` | `bool` | No | `true` | Transparent upgrade on verify | -| `password.scrypt.cost_factor` | `u32` | No | `32768` | Power of 2, min `16384` | +| `password.scrypt.cost_factor` | `u32` | No | `131072` | Power of 2, min `16384` | | `password.scrypt.block_size` | `u32` | No | `8` | scrypt r | | `password.scrypt.parallelization` | `u32` | No | `1` | scrypt p | | `password.argon2.memory_kib` | `u32` | No | `19456` | min `19456` (OWASP) | @@ -5095,14 +5095,14 @@ Password hashing is **configurable** between two RustCrypto algorithms, both emi | Algorithm | Crate | PHC prefix | Default params | | --- | --- | --- | --- | -| **scrypt** | `scrypt` | `$scrypt$` | `N = 2^15 (32768)`, `r = 8`, `p = 1`, 32-byte output, 16-byte random salt | +| **scrypt** | `scrypt` | `$scrypt$` | `N = 2^17 (131072)`, `r = 8`, `p = 1`, 32-byte output, 16-byte random salt | | **Argon2id** | `argon2` | `$argon2id$` | `m >= 19456` KiB, `t >= 2`, `p = 1` (OWASP production floor, enforced at `build()` — §5.5); defaults `m = 19456`, `t = 2`; 16-byte salt, 32-byte output | Both algorithms use the `password-hash` crate's `PasswordHasher`/`PasswordVerifier` traits, so the stored string is self-describing (algorithm + params + salt + digest) and verification auto-selects the algorithm from the PHC prefix — a hash written by either algorithm verifies regardless of the currently-configured default. **Recommended vs default.** **Argon2id is the *recommended* writer for new deployments** — it is OWASP's first-choice memory-hard KDF and the more conservative choice against GPU/ASIC attackers — while **scrypt is the *default*** purely for drop-in parity with nest-auth's stored `scrypt:{salt}:{hash}` corpus. A greenfield deployment SHOULD enable the `argon2` feature (§19.2) and configure Argon2id as the writer (§19.3); scrypt verification is retained so any legacy hashes still validate and lazily migrate via rehash-on-verify. At the type level this is enforced by making `Argon2id` a `#[cfg(feature = "argon2")]` enum variant (§5.1.9): the default active algorithm is `Scrypt`, and Argon2id becomes *selectable* only once the feature is compiled in, so a default build can never name an uncompiled hasher. -**Parameter floors are validated at startup.** The selected algorithm's cost parameters are checked against configured minimum floors at `build()` (§5.5) and below-floor params are **rejected, never silently accepted**, so a misconfiguration fails fast at boot rather than silently weakening every stored hash. Argon2id's floor is the OWASP production minimum (`m ≥ 19456` KiB, `t ≥ 2`, `p ≥ 1`); scrypt's **enforced floor** is `N ≥ 2^14 (16384)` and a power of two (validated at `build()`, §5.5 rule 9), while its **default** parameter set is the nest-auth parity baseline `N = 2^15 (32768)`, `r = 8`, `p = 1` — a deployment may raise the cost but not drop `N` below the `2^14` floor. +**Parameter floors are validated at startup.** The selected algorithm's cost parameters are checked against configured minimum floors at `build()` (§5.5) and below-floor params are **rejected, never silently accepted**, so a misconfiguration fails fast at boot rather than silently weakening every stored hash. Argon2id's floor is the OWASP production minimum (`m ≥ 19456` KiB, `t ≥ 2`, `p ≥ 1`); scrypt's **enforced floor** is `N ≥ 2^14 (16384)` and a power of two (validated at `build()`, §5.5 rule 9), while its **default** parameter set is OWASP's recommended minimum `N = 2^17 (131072)`, `r = 8`, `p = 1`, which nest-auth also defaults to — a deployment may raise the cost but not drop `N` below the `2^14` floor. ```rust pub enum PasswordAlgo { From 56bdc3f5bc8150aa72a5dc87bba94ec3c2484904 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:13:53 -0300 Subject: [PATCH 085/122] test: poll for the detached rehash instead of sleeping a fixed span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising the default scrypt cost turned two rehash tests into timing tests: they slept 500 ms for a derivation that now costs four times more, which held on a developer machine and failed on a CI runner. A wait tuned to one machine is not a test of the code. Both poll until the stored hash changes, with a four-second deadline and an assertion rather than a hang if it never does. The negative twin — a current hash must NOT be rehashed — keeps its wait, because there the timing can only weaken the check, never break it; it gains a deterministic core alongside, an assertion that the hash was not stale to begin with, so it can no longer pass merely by being too slow to observe a write. --- .../src/services/auth/login.rs | 26 ++++++++++++++----- .../bymax-auth-core/src/services/auth/mod.rs | 26 ++++++++++++++++++- .../bymax-auth-core/src/services/platform.rs | 17 +++++++++--- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index 7d53d44..d514278 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -568,7 +568,18 @@ mod tests { .await, Ok(LoginResult::Success(_)) )); - // Long enough for a spawned rehash to have landed if one had been spawned. + // The deterministic half: the hash is not stale to begin with, so nothing should have + // been spawned. This is what the test really means, and unlike the wait below it cannot + // pass by being too slow to observe a write. + let stored = before.password_hash.clone().unwrap_or_default(); + assert!(!bymax_auth_crypto::password::needs_rehash( + &stored, + &crate::services::auth::test_support::crypto_params() + )); + + // …and the observational half, which only ever weakens: a wait too short to catch a + // write makes this pass, never fail. It is kept because it is the one check that would + // notice a rehash spawned for some reason other than staleness. tokio::time::sleep(std::time::Duration::from_millis(500)).await; let after = h.users.find_by_id(&id, Some("t1")).await; let Ok(Some(after)) = after else { return }; @@ -642,11 +653,14 @@ mod tests { .login(login_input("weak@example.com", "pw"), &ctx()) .await; assert!(matches!(result, Ok(LoginResult::Success(_)))); - // Allow the detached rehash to complete, then confirm the stored hash changed. - tokio::time::sleep(Duration::from_millis(500)).await; - let stored = h.users.find_by_id(&user.id, None).await; - let Ok(Some(stored)) = stored else { return }; - assert_ne!(stored.password_hash.unwrap_or_default(), weak_hash); + // Poll for the detached rehash rather than sleeping a fixed span. The rehash is one + // scrypt derivation at the configured cost, and how long that takes depends on the + // machine — a fixed wait tuned on a developer's laptop is a test that fails on a + // slower CI runner and tells you nothing about the code. + assert!( + super::super::test_support::await_rehash(&h, &user.id, &weak_hash).await, + "the stored hash was never upgraded" + ); } } diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index 475cc12..2c85cca 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -277,7 +277,7 @@ pub(crate) mod test_support { } /// The crypto parameters for the compiled hasher, used to seed stored password hashes. - fn crypto_params() -> PasswordParams { + pub(crate) fn crypto_params() -> PasswordParams { #[cfg(not(feature = "scrypt"))] { PasswordParams { @@ -370,6 +370,30 @@ pub(crate) mod test_support { /// Build a harness from `cfg` and optional hooks. Returns `None` if the (always valid) /// fixture config somehow fails to assemble, so callers stay panic-free with `let-else`. + /// Wait for a detached rehash to land, polling until the stored hash differs from + /// `previous` or the deadline passes. + /// + /// Polling rather than sleeping a fixed span: the rehash is one password derivation at the + /// configured cost, and how long that takes depends on the machine. A fixed wait tuned on a + /// developer's laptop becomes a test that fails on a slower CI runner and reports nothing + /// about the code — which is exactly what raising the default cost factor turned this into. + /// + /// Returns `false` if the deadline passes with the hash unchanged, so the caller asserts + /// rather than hangs. + pub(crate) async fn await_rehash(harness: &Harness, user_id: &str, previous: &str) -> bool { + // Generous: 40 polls at 100 ms is four seconds, far longer than a derivation takes even + // on a slow shared runner, and the loop exits the moment the value changes. + for _ in 0..40 { + if let Ok(Some(user)) = harness.users.find_by_id(user_id, None).await + && user.password_hash.as_deref().unwrap_or_default() != previous + { + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + false + } + pub(crate) fn harness(cfg: AuthConfig, hooks: Option>) -> Option { let users = Arc::new(InMemoryUserRepository::new()); let stores = Arc::new(InMemoryStores::new()); diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index ad8e301..9bd8f7d 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -1009,9 +1009,20 @@ mod tests { let Some(svc) = h.engine.platform_auth() else { return }; let result = svc.login("weak@admin.io", "pw", "1.2.3.4", "agent").await; assert!(matches!(result, Ok(PlatformLoginResult::Success(_)))); - tokio::time::sleep(Duration::from_millis(500)).await; - let stored = h.admins.find_by_id(&id).await; - let Ok(Some(stored)) = stored else { return }; + // Poll rather than sleep a fixed span: the rehash is one derivation at the + // configured cost, and a wait tuned on a laptop fails on a slower runner while + // saying nothing about the code. + let mut stored = None; + for _ in 0..40 { + if let Ok(Some(admin)) = h.admins.find_by_id(&id).await + && admin.password_hash != weak_hash + { + stored = Some(admin); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let Some(stored) = stored else { panic!("the stored hash was never upgraded") }; assert_ne!(stored.password_hash, weak_hash); // The other fire-and-forget task on this path: the successful login is stamped. assert!(stored.last_login_at.is_some()); From cc5bf0e1a94a1121095dee3f27e71417dd780145 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:20:11 -0300 Subject: [PATCH 086/122] test: make the rehash wait's give-up path reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the previous commit, which I pushed after checking the tests and before checking clippy and coverage — both were red. The platform test failed loudly on a rehash that never landed by way of `panic!`, which the lint bans outright, and `assert!(false, …)` is banned as well. It asserts before destructuring instead, so the `let-else` cannot swallow the failure into a silent pass. The wait helper's deadline-exhausted `false` was unreachable — the rehash always lands — and so uncovered under the 100% gate. The poll count is a parameter now: callers pass a generous one, and a test passes one poll against a hash nothing will change. That path exists so a rehash that silently stopped happening fails the suite rather than hanging it, which is worth being able to prove. --- .../src/services/auth/login.rs | 17 ++++++++++++++++ .../bymax-auth-core/src/services/auth/mod.rs | 20 +++++++++++++++---- .../bymax-auth-core/src/services/platform.rs | 5 ++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index d514278..a0eaa69 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -553,6 +553,23 @@ mod tests { )); } + #[tokio::test] + async fn the_rehash_wait_gives_up_rather_than_hanging() { + // The helper the two rehash tests rely on has to report a deadline it never met, or a + // rehash that silently stopped happening would hang the suite instead of failing it. + // Exercised with a one-poll deadline against a hash nothing is going to change. + let Some(h) = active_harness(false).await else { return }; + let id = h.seed(SeedUser::active("nochange@example.com", "pw")).await; + let stored = h.users.find_by_id(&id, Some("t1")).await; + let Ok(Some(stored)) = stored else { return }; + let current = stored.password_hash.unwrap_or_default(); + + assert!( + !super::super::test_support::await_rehash_within(&h, &id, ¤t, 1).await, + "the wait reported a change nobody made" + ); + } + #[tokio::test] async fn a_current_password_hash_is_not_rehashed_on_login() { // The upgrade needs the toggle *and* a genuinely stale hash. Either alone must not diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index 2c85cca..798b1b0 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -380,10 +380,15 @@ pub(crate) mod test_support { /// /// Returns `false` if the deadline passes with the hash unchanged, so the caller asserts /// rather than hangs. - pub(crate) async fn await_rehash(harness: &Harness, user_id: &str, previous: &str) -> bool { - // Generous: 40 polls at 100 ms is four seconds, far longer than a derivation takes even - // on a slow shared runner, and the loop exits the moment the value changes. - for _ in 0..40 { + /// Polls to a deadline of `attempts` × 100 ms. Callers pass a generous count; the + /// give-up path is reachable — and therefore testable — by passing a small one. + pub(crate) async fn await_rehash_within( + harness: &Harness, + user_id: &str, + previous: &str, + attempts: u32, + ) -> bool { + for _ in 0..attempts { if let Ok(Some(user)) = harness.users.find_by_id(user_id, None).await && user.password_hash.as_deref().unwrap_or_default() != previous { @@ -394,6 +399,13 @@ pub(crate) mod test_support { false } + /// Wait for a detached rehash with a generous deadline — four seconds, far longer than a + /// derivation takes even on a slow shared runner, and the loop exits the moment the value + /// changes. + pub(crate) async fn await_rehash(harness: &Harness, user_id: &str, previous: &str) -> bool { + await_rehash_within(harness, user_id, previous, 40).await + } + pub(crate) fn harness(cfg: AuthConfig, hooks: Option>) -> Option { let users = Arc::new(InMemoryUserRepository::new()); let stores = Arc::new(InMemoryStores::new()); diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 9bd8f7d..5118299 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -1022,7 +1022,10 @@ mod tests { } tokio::time::sleep(Duration::from_millis(100)).await; } - let Some(stored) = stored else { panic!("the stored hash was never upgraded") }; + // Asserted before the destructure so a rehash that never landed fails loudly here; + // the `let-else` below then cannot swallow it into a silent pass. + assert!(stored.is_some(), "the stored hash was never upgraded"); + let Some(stored) = stored else { return }; assert_ne!(stored.password_hash, weak_hash); // The other fire-and-forget task on this path: the successful login is stamped. assert!(stored.last_login_at.is_some()); From 6da0382c332458fe9990076fc966c0a6210d1d3f Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:14 -0300 Subject: [PATCH 087/122] refactor(crypto): drop the legacy credential readers, require the MFA flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both libraries are new and unreleased into production, so the compatibility paths were parsing allowances for a corpus that does not exist — each of them sitting in the credential-verification core, which is where an unused branch is most expensive. Removed: the `scrypt:hex:hex` nest-compat password reader with its fixed `N = 2^15` assumption and bounded-hex parser, the UUID-v4 refresh-token shape, and the matching contract entries. `mfa_enabled` loses its `#[serde(default)]`. A missing value read as `false`, which turns a truncated or corrupt record into a silent second-factor bypass: the gate refuses only a token whose claims say `mfa_enabled && !mfa_verified`, so an absent field reads as "no second factor here" and the rotated token clears every MFA-gated route. A record that cannot be read is now no session at all — which costs the holder a login and costs an attacker the bypass. The contract gains a `passwordHash` entry stating that the parameters travel with the hash. That was already true here — PHC strings carry them — and is what nest-auth just changed to match, after its parameterless form turned out to make `costFactor` unraisable without locking every user out. --- CHANGELOG.md | 18 ++++ conformance/wire-contract.json | 3 +- .../bymax-auth-core/src/services/mfa/tests.rs | 37 +++++-- crates/bymax-auth-core/src/services/mod.rs | 41 +------- crates/bymax-auth-core/src/traits/store.rs | 30 +++--- crates/bymax-auth-crypto/src/password/mod.rs | 27 ++--- crates/bymax-auth-crypto/src/password/phc.rs | 98 +------------------ .../bymax-auth-crypto/src/password/tests.rs | 82 ---------------- 8 files changed, 81 insertions(+), 255 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2aa8d..f1ff767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,8 +138,26 @@ version bump. inside the window, so one captured consumed token could mint a session repeatedly. It is consumed on use now, matching `nest-auth`. +### Removed + +- **Every legacy-compatibility path in the credential surface.** Both libraries are new and + unreleased into production, so a parsing allowance for a corpus that does not exist is a + widened input for nothing — and each of these sat in the credential-verification core: + - the `scrypt:{salt_hex}:{hash_hex}` nest-compat password reader, with its fixed + `N = 2^15` assumption and its bounded-hex parser, + - the UUID-v4 refresh-token shape, + - and the corresponding `refreshTokenLegacy` / `recoveryCodeDigestLegacy` contract entries. + ### Fixed +- **`mfa_enabled` is required on a stored session record.** `#[serde(default)]` made a missing + value read as `false`, which turns a truncated or corrupt record into a silent second-factor + bypass: the gate refuses only a token whose claims say `mfa_enabled && !mfa_verified`, so an + absent field reads as "no second factor here" and the rotated token clears every MFA-gated + route. A record that cannot be read is now no session at all — a login for the holder, and no + bypass for anyone else. nest-auth made the same change. + + - **`PlatformAuthResult`'s account field was named `user` while the wire says `admin`.** The adapter renamed it while building the response, so the TypeScript generated from the struct described a key the server never sends: a consumer reading `result.user` got `undefined` at diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 1cf7259..fe0cbd0 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -158,10 +158,9 @@ "or a session started on one backend cannot continue on the other." ], "refreshToken": "64 lowercase hex characters (32 CSPRNG bytes)", - "refreshTokenLegacy": "uuid-v4, accepted for one refresh lifetime after convergence", + "passwordHash": "self-describing: the parameters the hash was written under travel with it, so a verify never assumes the currently configured cost", "totpSecretAtRest": "aes-256-gcm over the BASE32 TEXT of the secret", "recoveryCodeDigest": "hex hmac-sha256 of the code under the derived identifier key", - "recoveryCodeDigestLegacy": "scrypt:{salt}:{hash}, still verified, never newly written", "wsTicket": "64 lowercase hex characters (32 CSPRNG bytes), single-use, 30 s lifetime" }, diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 7347d9c..0ce3723 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1903,6 +1903,25 @@ fn repository_error_maps_both_variants_to_internal() { /// The file at `conformance/wire-contract.json` is held byte-identical by nest-auth. This section /// is the shape of the credentials themselves, so a drift here is not a parse error on the other /// side — it is a session that cannot continue, or a TOTP code that never verifies. +fn contract_credential_formats() -> serde_json::Map { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/wire-contract.json" + ); + let raw = std::fs::read_to_string(path).unwrap_or_default(); + let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null); + let section = root + .get("credentialFormats") + .and_then(serde_json::Value::as_object) + .cloned() + .unwrap_or_default(); + assert!( + !section.is_empty(), + "the wire contract declared no `credentialFormats` — it did not load" + ); + section +} + fn credential_format(key: &str) -> String { let path = concat!( env!("CARGO_MANIFEST_DIR"), @@ -1943,9 +1962,13 @@ fn the_refresh_token_matches_the_shared_credential_format() { ); } - // The legacy UUID shape is documented as *accepted*, never minted — the allowance only makes - // sense while tokens issued before the convergence are still inside their lifetime. - assert!(credential_format("refreshTokenLegacy").contains("uuid-v4")); + // No legacy shape is declared, and none is accepted: the libraries are new, so a parsing + // allowance for a corpus that does not exist is a widened input for nothing. + assert!( + contract_credential_formats() + .get("refreshTokenLegacy") + .is_none() + ); } #[tokio::test] @@ -1996,9 +2019,11 @@ async fn the_stored_totp_secret_and_recovery_digests_match_the_shared_credential assert_ne!(digest, code, "the recovery code was stored in the clear"); } - // The legacy `scrypt:` form is still verified but never newly written, so no digest above - // may carry its prefix. - assert!(credential_format("recoveryCodeDigestLegacy").contains("scrypt:")); + assert!( + contract_credential_formats() + .get("recoveryCodeDigestLegacy") + .is_none() + ); assert!( !data .hashed_codes diff --git a/crates/bymax-auth-core/src/services/mod.rs b/crates/bymax-auth-core/src/services/mod.rs index 565b643..fbbcc98 100644 --- a/crates/bymax-auth-core/src/services/mod.rs +++ b/crates/bymax-auth-core/src/services/mod.rs @@ -99,16 +99,8 @@ pub(crate) fn now_offset() -> OffsetDateTime { /// malformed value cheaply — no allocation and no SHA-256 over unbounded input — and such a /// value could never match a stored hash anyway. /// -/// The legacy UUID-v4 shape is accepted too. nest-auth minted refresh tokens as UUID v4 -/// before both sides converged on 256-bit tokens, and those live in the shared Redis for a -/// full refresh lifetime (seven days by default). Rejecting them here would refuse to rotate -/// a session that is still perfectly valid and whose `rt:{sha256}` record is right there. -/// Accepting the shape grants nothing on its own — the token still has to hash to a stored -/// session — so this is a parsing allowance, not a weakened check. -/// -/// Remove the legacy arm once every token predating the convergence has expired. pub(crate) fn is_refresh_token_shape(raw: &str) -> bool { - is_hex_token_shape(raw) || is_legacy_uuid_shape(raw) + is_hex_token_shape(raw) } /// The current shape: 64 lower-case hex characters. @@ -116,19 +108,6 @@ fn is_hex_token_shape(raw: &str) -> bool { raw.len() == 64 && raw.bytes().all(is_lower_hex) } -/// The legacy shape: a lower-case UUID v4, `8-4-4-4-12` hex with dashes at fixed offsets. -fn is_legacy_uuid_shape(raw: &str) -> bool { - const DASH_POSITIONS: [usize; 4] = [8, 13, 18, 23]; - raw.len() == 36 - && raw.bytes().enumerate().all(|(index, byte)| { - if DASH_POSITIONS.contains(&index) { - byte == b'-' - } else { - is_lower_hex(byte) - } - }) -} - /// Whether `byte` is a lower-case hexadecimal digit. fn is_lower_hex(byte: u8) -> bool { byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) @@ -151,20 +130,10 @@ mod tests { } #[test] - fn is_refresh_token_shape_accepts_a_legacy_uuid_v4() { - // nest-auth minted UUID-v4 refresh tokens before the two sides converged on 256-bit - // ones, and those survive in the shared Redis for a whole refresh lifetime. Refusing - // the shape would refuse to rotate a session that is still valid and stored. - assert!(is_refresh_token_shape( - "11111111-2222-4333-8444-555555555555" - )); - } - - #[test] - fn is_refresh_token_shape_rejects_uuid_lookalikes() { - // The allowance is for one exact legacy shape, not "anything with dashes": a dash in - // the wrong place, an upper-case digit, a non-hex character, and a wrong length are - // all still refused before any hashing happens. + fn is_refresh_token_shape_rejects_anything_but_64_hex() { + // A UUID is not a refresh token: the shape is 64 lower-case hex characters and + // nothing else, so a dash in any position, an upper-case digit, a non-hex character + // and a wrong length are all refused before any hashing happens. assert!(!is_refresh_token_shape( "111111112-222-4333-8444-555555555555" )); diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index b59a415..60e1300 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -74,9 +74,12 @@ pub struct SessionRecord { /// `mfa_verified` is deliberately NOT stored: it must stay `false` in a rotated token /// so clearing the second factor always goes back through the MFA challenge. /// - /// `default` so a session written before this field existed deserializes as `false` - /// rather than failing the whole record — the same defensive read nest-auth performs. - #[serde(default)] + /// Required on the wire, deliberately. Defaulting a missing value to `false` would turn a + /// truncated or corrupt record into a silent second-factor bypass — the gate only refuses a + /// token whose claims say `mfa_enabled && !mfa_verified`, so an absent field reads as "this + /// account has no second factor" and the rotated token clears every MFA-gated route. A + /// record that cannot be read is treated as no session at all, which costs the holder a + /// login and costs an attacker the bypass. pub mfa_enabled: bool, /// 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. @@ -762,18 +765,17 @@ mod tests { assert_eq!(OtpPurpose::PasswordReset.as_str(), "password_reset"); assert_eq!(OtpPurpose::EmailVerification.as_str(), "email_verification"); } - #[test] - fn session_record_reads_a_legacy_payload_without_the_mfa_flag() { - // Sessions written before `mfaEnabled` existed are still live in Redis (refresh TTL is - // days). Deserialization must default them to `false` rather than fail: a hard error - // would reject every in-flight session at once and log the whole user base out. - let legacy = r#"{"userId":"u1","tenantId":"t1","role":"MEMBER", - "device":"Chrome","ip":"203.0.113.4","createdAt":"1970-01-01T00:00:00Z"}"#; - - let parsed: Result = serde_json::from_str(legacy); - - assert!(matches!(parsed, Ok(ref r) if !r.mfa_enabled && r.user_id == "u1")); + fn a_record_without_the_mfa_flag_is_refused_rather_than_defaulted() { + // Defaulting a missing `mfaEnabled` to `false` would turn a truncated or corrupt record + // into a silent second-factor bypass: the gate refuses only a token whose claims say + // `mfaEnabled && !mfaVerified`, so an absent field reads as "no second factor here" and + // the rotated token clears every MFA-gated route. Refusing the record costs the holder + // a login; defaulting it costs the account. + let without_flag = r#"{"userId":"u1","tenantId":"t1","role":"MEMBER","device":"Chrome", + "ip":"1.2.3.4","createdAt":"1970-01-01T00:00:00Z"}"#; + let parsed: Result = serde_json::from_str(without_flag); + assert!(parsed.is_err()); } #[test] diff --git a/crates/bymax-auth-crypto/src/password/mod.rs b/crates/bymax-auth-crypto/src/password/mod.rs index ae977a9..d258aac 100644 --- a/crates/bymax-auth-crypto/src/password/mod.rs +++ b/crates/bymax-auth-crypto/src/password/mod.rs @@ -1,7 +1,7 @@ //! Password hashing over RustCrypto: scrypt (default) and Argon2id (`argon2` //! feature), producing self-describing PHC strings with constant-time verification, //! rehash-on-verify detection, parameter-floor validation, and a compatibility -//! parser for the legacy `scrypt:hex:hex` corpus. +//! parser. //! //! Run [`hash`] and [`verify`] inside `tokio::task::spawn_blocking` (or equivalent); //! both are synchronous, memory-hard CPU work (~100–200 ms) and would otherwise stall @@ -12,9 +12,9 @@ //! //! New hashes are written as standard PHC strings — `$scrypt$ln=15,r=8,p=1$…` or //! `$argon2id$v=19$m=19456,t=2,p=1$…` — which self-describe their algorithm and -//! parameters. [`verify`] additionally accepts the legacy nest-auth -//! `scrypt:{salt_hex}:{hash_hex}` form (under the `scrypt` feature) and -//! [`needs_rehash`] always reports it as stale so it migrates to PHC on next login. +//! parameters. A hash is always verified under the parameters it records, never under +//! whatever the deployment is configured to write today — which is what makes the cost +//! factor raisable at all, with [`needs_rehash`] driving the migration on next login. #[cfg(feature = "argon2")] mod argon2; @@ -194,9 +194,9 @@ pub fn hash(password: &[u8], params: &PasswordParams) -> Result Result Result { - #[cfg(feature = "scrypt")] - if let Some((salt, expected)) = phc::parse_legacy(phc) { - return Ok(phc::verify_legacy(password, &salt, &expected)); - } Ok(phc::verify_phc(password, phc)) } /// Return `true` when a stored hash should be re-hashed with the current config. /// /// True when the stored hash uses a different algorithm than `current.active`, uses -/// weaker-than-current parameters, is unparseable, or is the legacy -/// `scrypt:hex:hex` format. Drives transparent rehash-on-verify: the caller re-hashes -/// the just-verified plaintext and persists it. +/// weaker-than-current parameters, or is unparseable. Drives transparent rehash-on-verify: +/// the caller re-hashes the just-verified plaintext and persists it. #[must_use] pub fn needs_rehash(phc: &str, current: &PasswordParams) -> bool { - #[cfg(feature = "scrypt")] - if phc::is_legacy(phc) { - return true; - } phc::needs_rehash_phc(phc, current) } diff --git a/crates/bymax-auth-crypto/src/password/phc.rs b/crates/bymax-auth-crypto/src/password/phc.rs index 8bfca36..8312b27 100644 --- a/crates/bymax-auth-crypto/src/password/phc.rs +++ b/crates/bymax-auth-crypto/src/password/phc.rs @@ -1,5 +1,4 @@ -//! PHC parsing, verification, rehash detection, and the legacy `scrypt:hex:hex` -//! compatibility parser. +//! PHC parsing, verification, and rehash detection. use password_hash::{PasswordHash, PasswordVerifier}; @@ -89,98 +88,3 @@ fn argon2_is_stale(hash: &PasswordHash, ident: &str, current: &super::Argon2Para _ => true, } } - -/// Cheap detection of the legacy nest-auth `scrypt:{salt_hex}:{hash_hex}` format, -/// distinguished from a PHC scrypt hash by its `scrypt:` (not `$scrypt$`) prefix. -#[cfg(feature = "scrypt")] -pub(super) fn is_legacy(phc: &str) -> bool { - phc.starts_with("scrypt:") -} - -/// Largest derived-key length, in bytes, the legacy verifier will accept. nest-auth's -/// corpus used a 32-byte key; 64 leaves headroom while bounding the work a crafted -/// over-long hash could force the KDF to do (an attacker controls neither the stored -/// hash nor a verification endpoint, but the cap removes the amplification entirely). -#[cfg(feature = "scrypt")] -const LEGACY_MAX_KEY_LEN: usize = 64; - -/// nest-auth's stored scrypt corpus used `N = 2^15`, `r = 8`, `p = 1` (spec §17.1 / -/// §19.1); the legacy verifier recomputes the KDF with exactly these parameters. -#[cfg(feature = "scrypt")] -const LEGACY_LOG_N: u8 = 15; -#[cfg(feature = "scrypt")] -const LEGACY_BLOCK_SIZE: u32 = 8; -#[cfg(feature = "scrypt")] -const LEGACY_PARALLELISM: u32 = 1; - -/// Parse a legacy `scrypt:{salt_hex}:{hash_hex}` string into `(salt, expected)` bytes. -/// Returns `None` for any other format, invalid hex, or a derived key longer than -/// [`LEGACY_MAX_KEY_LEN`]. -#[cfg(feature = "scrypt")] -pub(super) fn parse_legacy(phc: &str) -> Option<(Vec, Vec)> { - let rest = phc.strip_prefix("scrypt:")?; - let (salt_hex, hash_hex) = rest.split_once(':')?; - if hash_hex.contains(':') { - return None; - } - let salt = decode_hex(salt_hex)?; - let expected = decode_hex(hash_hex)?; - if salt.is_empty() || expected.is_empty() || expected.len() > LEGACY_MAX_KEY_LEN { - return None; - } - Some((salt, expected)) -} - -/// Verify a legacy scrypt hash by recomputing the KDF with nest-auth's parameters -/// (`N = 2^15`, `r = 8`, `p = 1`) and comparing in constant time. -#[cfg(feature = "scrypt")] -pub(super) fn verify_legacy(password: &[u8], salt: &[u8], expected: &[u8]) -> bool { - // Any failure along the way (a bad output length rejected by `Params::new`, or the - // structurally-unreachable KDF error) maps to `None` and so fails verification — - // fail-closed, never a panic and never a silently-discarded error. - scrypt::Params::new( - LEGACY_LOG_N, - LEGACY_BLOCK_SIZE, - LEGACY_PARALLELISM, - expected.len(), - ) - .ok() - .and_then(|params| { - // The derived key is secret material — held in a zeroizing buffer, wiped on drop. - let mut out = zeroize::Zeroizing::new(vec![0u8; expected.len()]); - scrypt::scrypt(password, salt, ¶ms, &mut out) - .ok() - .map(|()| out) - }) - .is_some_and(|out| crate::compare::constant_time_eq(&out, expected)) -} - -/// Decode a lower/upper-case hex string into bytes; `None` on odd length or a -/// non-hex character. -#[cfg(feature = "scrypt")] -fn decode_hex(s: &str) -> Option> { - if !s.len().is_multiple_of(2) { - return None; - } - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len() / 2); - let mut i = 0; - while i < bytes.len() { - let hi = hex_nibble(bytes[i])?; - let lo = hex_nibble(bytes[i + 1])?; - out.push((hi << 4) | lo); - i += 2; - } - Some(out) -} - -/// Map one hex ASCII digit to its nibble value; `None` if not a hex digit. -#[cfg(feature = "scrypt")] -fn hex_nibble(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} diff --git a/crates/bymax-auth-crypto/src/password/tests.rs b/crates/bymax-auth-crypto/src/password/tests.rs index 2d77d78..ccfa647 100644 --- a/crates/bymax-auth-crypto/src/password/tests.rs +++ b/crates/bymax-auth-crypto/src/password/tests.rs @@ -52,14 +52,6 @@ mod scrypt_tests { use super::*; use proptest::prelude::*; - /// A correct password and an independently computed legacy `scrypt:hex:hex` vector - /// (Python `hashlib.scrypt`, N=2^15, r=8, p=1, 32-byte key) — an external KAT - /// proving the legacy verifier reproduces nest-auth's stored format rather than - /// just agreeing with itself. - const LEGACY_PASSWORD: &[u8] = b"correct horse battery staple"; - const LEGACY_HASH: &str = "scrypt:6e6573742d617574682d6c6567616379:\ - f07791588511498573e76f19c5ec479c2fdbd3340e2e1a9e1c817bb0aacbdadf"; - #[test] fn scrypt_hash_round_trips() { // A scrypt hash is a `$scrypt$` PHC string that verifies for the right password @@ -84,80 +76,6 @@ mod scrypt_tests { assert!(matches!(verify(b"same", &b), Ok(true))); } - #[test] - fn legacy_scrypt_hash_verifies_against_external_vector() { - // The legacy `scrypt:hex:hex` corpus verifies (external KAT) for the right - // password and rejects a wrong one — the migration-compatibility guarantee. - assert!(matches!(verify(LEGACY_PASSWORD, LEGACY_HASH), Ok(true))); - assert!(matches!(verify(b"wrong password", LEGACY_HASH), Ok(false))); - } - - #[test] - fn legacy_hash_is_always_stale() { - // A legacy hash always reports needs_rehash → true so the next successful login - // transparently upgrades it to a PHC string. - assert!(needs_rehash(LEGACY_HASH, &PasswordParams::default())); - } - - #[test] - fn legacy_parser_rejects_malformed_hex_and_shapes() { - // Malformed legacy strings (odd-length hex, non-hex chars, a too-short or - // over-long derived key, extra/empty segments) must not verify — exercises the - // hex decoder, the length cap, and the short-key KDF guard. - assert!(matches!(verify(b"pw", "scrypt:abc:00"), Ok(false))); // odd-length salt hex - assert!(matches!(verify(b"pw", "scrypt:zz:00"), Ok(false))); // first nibble non-hex - assert!(matches!(verify(b"pw", "scrypt:az:00"), Ok(false))); // second nibble non-hex - assert!(matches!(verify(b"pw", "scrypt:aa:00"), Ok(false))); // 1-byte key < KDF min - assert!(matches!(verify(b"pw", "scrypt:aa:bb:cc"), Ok(false))); // extra segment - assert!(matches!(verify(b"pw", "scrypt::00"), Ok(false))); // empty salt - assert!(matches!(verify(b"pw", "scrypt:aa:"), Ok(false))); // empty hash - assert!(matches!(verify(b"pw", "scrypt:aa:zz"), Ok(false))); // valid salt, non-hex hash - assert!(matches!(verify(b"pw", "scrypt:no-second-colon"), Ok(false))); // single segment - let over_long = format!("scrypt:aa:{}", "ab".repeat(65)); // 65-byte key > cap - assert!(matches!(verify(b"pw", &over_long), Ok(false))); - // Upper-case hex must decode (exercises the A–F branch of the nibble decoder); - // the recomputed KDF then rejects the wrong password. - let upper = format!("scrypt:AABBCCDD:{}", "AB".repeat(32)); - assert!(matches!(verify(b"pw", &upper), Ok(false))); - } - - #[test] - fn legacy_parser_bounds_each_field_on_its_own() { - // Through `verify` every rejection above collapses to `Ok(false)` — which a wrong - // password produces too — so the guard itself is asserted at the parser, where the - // three conditions can be told apart and the cap has a boundary. - use crate::password::phc::parse_legacy; - assert!(parse_legacy("scrypt::00").is_none(), "empty salt alone"); - assert!(parse_legacy("scrypt:aa:").is_none(), "empty hash alone"); - // The cap is inclusive: exactly 64 bytes is the largest accepted key, and 65 is out. - let at_cap = format!("scrypt:aa:{}", "ab".repeat(64)); - assert!( - parse_legacy(&at_cap).is_some(), - "64 bytes is within the cap" - ); - let over_cap = format!("scrypt:aa:{}", "ab".repeat(65)); - assert!( - parse_legacy(&over_cap).is_none(), - "65 bytes is over the cap" - ); - // A well-formed pair decodes to its bytes. - assert_eq!( - parse_legacy("scrypt:0a0b:0c0d"), - Some((vec![0x0a, 0x0b], vec![0x0c, 0x0d])) - ); - // Upper-case hex decodes to the same bytes — nest-auth's corpus contains both, and a - // decoder that dropped the A–F arm would reject those rows as malformed. Asserted on - // the decoded value, because through `verify` it looks like a wrong password. - assert_eq!( - parse_legacy("scrypt:AABB:CCDD"), - Some((vec![0xaa, 0xbb], vec![0xcc, 0xdd])) - ); - assert_eq!( - parse_legacy("scrypt:AABB:CCDD"), - parse_legacy("scrypt:aabb:ccdd") - ); - } - #[test] fn needs_rehash_is_false_for_a_current_scrypt_hash() { // A hash written with the current params is not stale — rehash-on-verify must From 76386a76d19f3a67a8bab522668140378574ba63 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:28:09 -0300 Subject: [PATCH 088/122] feat(core): rotate the MFA encryption key, drop the last legacy wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stored TOTP ciphertext carries no key identifier, so rotating `mfa.encryption_key` made every enrolled user's secret undecryptable at once, with no way back: the authenticator they set up simply stops matching and nothing in the library can tell them why. This is the same class as the password hash that recorded no parameters and the JWT secret with no retired list — persisted data whose read depends on configuration that can change. `mfa.previous_encryption_keys` closes it. A secret that opens under a retired key is re-encrypted under the current one on the next successful challenge — TOTP and recovery code alike — so the rotation drains on its own instead of requiring the retired key to stay configured forever. A key that still opens every stored secret is not retired. `build()` holds each entry to the same bar as the current key (base64, exactly 32 bytes, never equal to the current key or to another entry), because a malformed one would otherwise surface at a user's first challenge rather than at boot. The remaining "legacy record" prose goes with it. A session with no family is the placeholder a replayed token produces, never something an older version wrote; an absent `epoch` claim reads as `0` because that is the fail-closed direction, not because a corpus of unstamped tokens exists. Saying otherwise invites a future reader to add a compatibility allowance for a corpus that has never existed. The password test that called `scrypt:salt:hash` a legacy corpus now says what it is: a value this library never writes, which is why it always reports stale. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 12 +- README.md | 21 ++- crates/bymax-auth-axum/src/test_support.rs | 1 + crates/bymax-auth-axum/tests/common/mod.rs | 1 + crates/bymax-auth-axum/tests/redis_e2e.rs | 1 + crates/bymax-auth-core/src/config/mod.rs | 17 ++- crates/bymax-auth-core/src/config/validate.rs | 123 ++++++++++++++- crates/bymax-auth-core/src/engine/builder.rs | 2 + .../src/services/mfa/challenge.rs | 24 ++- .../bymax-auth-core/src/services/mfa/mod.rs | 52 ++++++- .../bymax-auth-core/src/services/mfa/tests.rs | 142 +++++++++++++++++- crates/bymax-auth-core/src/services/oauth.rs | 2 +- .../bymax-auth-core/src/services/password.rs | 12 +- .../bymax-auth-core/src/services/platform.rs | 1 + .../src/services/token_manager.rs | 12 +- crates/bymax-auth-core/src/testing/mod.rs | 12 +- crates/bymax-auth-core/src/traits/breach.rs | 4 +- crates/bymax-auth-core/src/traits/store.rs | 41 ++--- .../tests/mfa_lifecycle_e2e.rs | 1 + .../tests/platform_identity_e2e.rs | 1 + crates/bymax-auth-types/src/claims.rs | 13 +- .../rust-auth/src/shared/jwt-payload.types.ts | 13 +- 22 files changed, 438 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ff767..c42d383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,17 @@ version bump. recovery-code digest, which is keyed by an HMAC derived from that secret: users would lose the codes they printed and filed, and find out at the moment they most need them. Both are now readable while the old tokens drain. Signing always uses the current secret, so a rotation is - one-way. `mfa.encryption_key` is a separate key and is not covered. + one-way. +- **`mfa.previous_encryption_keys`** — AES-256 keys retired by a rotation of + `mfa.encryption_key`. The stored ciphertext carries no key identifier, so changing that key + made every enrolled user's TOTP secret undecryptable at once, with no way back: the + authenticator they set up simply stops matching, and nothing in the library could tell them + why. A secret that opens under a retired key is now re-encrypted under the current one on the + next successful challenge — TOTP and recovery code alike — so the rotation drains instead of + requiring the retired key to stay configured forever; a key that still opens every stored + secret is not retired. `build()` holds each entry to the same bar as the current key (base64, + exactly 32 bytes, never equal to the current key or to another entry), because a malformed one + would otherwise surface at a user's first challenge. Same option on both sides. ### Changed diff --git a/README.md b/README.md index 5c0e58b..389a887 100644 --- a/README.md +++ b/README.md @@ -419,11 +419,11 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se | Group | Key options | nest-compat default | | ------------------ | ---------------------------------------------------------------------------- | -------------------------- | -| **jwt** | `secret` (required, ≥ 32 chars), `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days` | `15m`, `7d`, off, HS256 (pinned) | +| **jwt** | `secret` (required, ≥ 32 chars), `previous_secrets`, `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days` | `15m`, `7d`, off, HS256 (pinned) | | **password** | `active_algorithm`, scrypt `cost_factor` / Argon2id `memory_kib` | scrypt N=2¹⁷, r=8, p=1 | | **token_delivery** | `Cookie` \| `Bearer` \| `Both` | `Cookie` | | **cookies** | names, `refresh_cookie_path`, `same_site`, `trusted_origins`, `resolve_domains` | HttpOnly, Secure, Strict, `[]` | -| **mfa** | `encryption_key` (32 bytes), `issuer`, `totp_window`, `recovery_code_count` | — | +| **mfa** | `encryption_key` (32 bytes), `previous_encryption_keys`, `issuer`, `totp_window`, `recovery_code_count` | — | | **sessions** | `enabled`, `default_max_sessions`, `max_sessions_resolver` | `false`, `5` | | **brute_force** | `max_attempts`, `window_seconds` | `5`, `900` | | **rate limiting** | `AxumAuthConfig::rate_limits` — per-route governor limits, pinned to the shared contract | on, per-route | @@ -444,8 +444,21 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se > those are keyed by an HMAC derived from the secret, so users lose the codes they printed and > filed. With it, both keep working while tokens issued under the old secret drain, and a > rotation becomes a rollout. Remove the entry once the longest-lived token signed under it has -> expired: every entry is a key that still opens the door. `mfa.encryption_key` is separate and -> is **not** covered — rotating it requires re-encrypting the stored TOTP secrets. +> expired: every entry is a key that still opens the door. `mfa.encryption_key` rotates the same +> way, through its own list — see below. + +> [!TIP] +> **Rotating the MFA encryption key.** `mfa.previous_encryption_keys` lists AES-256 keys retired +> by a rotation of `mfa.encryption_key`. The stored ciphertext carries no key identifier, so +> without the list a change of key makes every enrolled user's TOTP secret undecryptable at once, +> with no way back — their authenticator simply stops matching. With it, a stored secret that +> opened under a retired key is *re-encrypted under the current one* on the next successful +> challenge, so the rotation drains on its own instead of requiring the retired key to stay +> configured forever. `build()` holds each entry to the same bar as the current key (base64, +> exactly 32 bytes, and never equal to the current key or to another entry), because a malformed +> one would otherwise surface at a user's first challenge rather than at boot. Drop the entry +> once your enrolled users have had time to authenticate at least once. `nest-auth` exposes the +> identical option as `mfa.previousEncryptionKeys`. > [!IMPORTANT] > `jwt.access_expires_in` must not exceed **30 days**, the window a store keeps a bumped token diff --git a/crates/bymax-auth-axum/src/test_support.rs b/crates/bymax-auth-axum/src/test_support.rs index 8bda3c4..7605717 100644 --- a/crates/bymax-auth-axum/src/test_support.rs +++ b/crates/bymax-auth-axum/src/test_support.rs @@ -55,6 +55,7 @@ pub(crate) fn scaffold(delivery: TokenDelivery) -> Option { ])); config.platform.enabled = true; config.mfa = Some(MfaConfig { + previous_encryption_keys: Vec::new(), encryption_key: SecretString::from(mfa_key()), issuer: "Bymax".to_owned(), recovery_code_count: 8, diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index cb0ee74..4664048 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -140,6 +140,7 @@ pub fn build(spec: EngineSpec) -> Option { if spec.mfa { config.mfa = Some(MfaConfig { + previous_encryption_keys: Vec::new(), encryption_key: SecretString::from(mfa_key_b64()), issuer: "Bymax".to_owned(), recovery_code_count: 8, diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 76cf20c..0275092 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -125,6 +125,7 @@ fn build_engine( config.controllers.mfa = true; config.platform.enabled = true; config.mfa = Some(MfaConfig { + previous_encryption_keys: Vec::new(), encryption_key: SecretString::from(mfa_key()), issuer: "Bymax".to_owned(), recovery_code_count: 8, diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index 56bf888..e926576 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -172,8 +172,8 @@ pub struct JwtConfig { /// passed the rotation is refused and the user signs in again. /// /// Off by default because switching it on ends sessions already older than the cap, which - /// is a decision a deployment makes rather than one an upgrade makes for it. A record - /// written before the family birth time existed carries none and is not capped. + /// is a decision a deployment makes rather than one an upgrade makes for it. A record with + /// no family birth time — the replay placeholder — carries no cap to measure from. pub absolute_session_lifetime_days: u32, /// Pinned to HS256. pub algorithm: JwtAlgorithm, @@ -304,6 +304,19 @@ pub struct MfaConfig { /// AES-256-GCM key for TOTP-secret encryption. Must decode (base64 standard or /// url-safe) to exactly 32 bytes. Redacted in `Debug`, zeroized on drop. pub encryption_key: SecretString, + /// Keys retired by a rotation, accepted for **decryption only**. Default: empty. + /// + /// A TOTP secret is stored encrypted under [`MfaConfig::encryption_key`] and the ciphertext + /// records no key identifier, so changing that key without this makes every stored secret + /// undecryptable — every enrolled user's authenticator stops matching, at once, with no way + /// back. Listing the previous key keeps those secrets readable, and each is re-encrypted + /// under the current key the next time its owner passes a challenge, so the rotation drains + /// on its own. + /// + /// Encryption always uses the current key; entries here are only ever tried after it, and + /// only to decrypt. AES-GCM authenticates, so a wrong key fails unambiguously rather than + /// returning garbage. Each is validated exactly like the current key. + pub previous_encryption_keys: Vec, /// Issuer shown in authenticator apps. Required, non-empty. pub issuer: String, /// Recovery codes generated on enable, default 8. diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index e38c0ef..ec80d90 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -134,11 +134,34 @@ impl ResolvedConfig { #[must_use] pub(crate) fn mfa_encryption_key(&self) -> Option> { let mfa = self.config.mfa.as_ref()?; - let decoded = Zeroizing::new(decode_base64_any(mfa.encryption_key.expose_secret())?); - <[u8; 32]>::try_from(decoded.as_slice()) - .ok() - .map(Zeroizing::new) + decode_aes256_key(mfa.encryption_key.expose_secret()) } + + /// The MFA keys retired by a rotation, decoded, in the order configured. Empty unless a + /// rotation is in progress. + /// + /// Decrypt-only: a stored TOTP secret records no key identifier, so without these a change + /// of `mfa.encryption_key` makes every stored secret undecryptable at once. + pub(crate) fn previous_mfa_encryption_keys(&self) -> Vec> { + self.config + .mfa + .as_ref() + .map(|mfa| { + mfa.previous_encryption_keys + .iter() + .filter_map(|key| decode_aes256_key(key.expose_secret())) + .collect() + }) + .unwrap_or_default() + } +} + +/// Decode a base64 AES-256 key, returning `None` unless it is exactly 32 bytes. +fn decode_aes256_key(encoded: &str) -> Option> { + let decoded = Zeroizing::new(decode_base64_any(encoded)?); + <[u8; 32]>::try_from(decoded.as_slice()) + .ok() + .map(Zeroizing::new) } /// Lowercase hexadecimal alphabet, indexed by nibble value. @@ -256,6 +279,24 @@ impl AuthConfig { }); } + // Rule 4c: every retired MFA key is held to the same bar as the current one. They still + // decrypt stored secrets, so a malformed entry would throw at the first challenge + // instead of at startup — and a key equal to the current one means the rotation being + // described did not happen. + if let Some(mfa) = self.mfa.as_ref() { + let mut seen: Vec<&str> = vec![mfa.encryption_key.expose_secret()]; + for previous in &mfa.previous_encryption_keys { + let previous = previous.expose_secret(); + if decode_aes256_key(previous).is_none() { + return Err(ConfigError::MfaKeyInvalidBase64); + } + if seen.contains(&previous) { + return Err(ConfigError::PreviousSecretRepeated); + } + seen.push(previous); + } + } + // Rule 5-7: role hierarchies. if self.roles.hierarchy.is_empty() { return Err(ConfigError::EmptyRoleHierarchy); @@ -873,6 +914,7 @@ mod tests { fn mfa_with_key(key: &str) -> MfaConfig { MfaConfig { + previous_encryption_keys: Vec::new(), encryption_key: SecretString::from(key.to_owned()), issuer: "Acme".to_owned(), recovery_code_count: 8, @@ -1487,4 +1529,77 @@ mod tests { Err(ConfigError::PreviousSecretRepeated) )); } + + #[test] + fn retired_mfa_keys_are_held_to_the_same_bar_as_the_current_one() { + // They still decrypt stored TOTP secrets, so a malformed entry would throw at the first + // challenge instead of at startup — and a key equal to the current one means the + // rotation being described did not happen. + let key = base64::engine::general_purpose::STANDARD.encode([1u8; 32]); + let retired = base64::engine::general_purpose::STANDARD.encode([2u8; 32]); + let mut cfg = valid_config(); + cfg.mfa = Some(mfa_with_key(&key)); + + // A well-formed rotation is accepted and decodes one key per entry. + let mut rotating = cfg.clone(); + if let Some(mfa) = rotating.mfa.as_mut() { + mfa.previous_encryption_keys = vec![SecretString::from(retired.clone())]; + } + assert!(rotating.validate(Environment::Test).is_ok()); + let resolved = ResolvedConfig::new(rotating, Environment::Test, true); + assert_eq!(resolved.previous_mfa_encryption_keys().len(), 1); + assert!(matches!( + resolved.mfa_encryption_key(), + Some(current) if *current != *resolved.previous_mfa_encryption_keys()[0] + )); + + // With no rotation in progress there are no retired keys at all. + let none = ResolvedConfig::new(cfg.clone(), Environment::Test, true); + assert!(none.previous_mfa_encryption_keys().is_empty()); + + // A key that is not 32 bytes is refused at startup, not at the first challenge. + let mut short = cfg.clone(); + if let Some(mfa) = short.mfa.as_mut() { + mfa.previous_encryption_keys = vec![SecretString::from("dG9vLXNob3J0".to_owned())]; + } + assert!(matches!( + short.validate(Environment::Test), + Err(ConfigError::MfaKeyInvalidBase64) + )); + + // Neither is a value that is not base64 at all — the two rejections are separate + // branches, and only one of them being wired would let the other reach a challenge. + let mut garbage = cfg.clone(); + if let Some(mfa) = garbage.mfa.as_mut() { + mfa.previous_encryption_keys = + vec![SecretString::from("!!!!not base64!!!!".to_owned())]; + } + assert!(matches!( + garbage.validate(Environment::Test), + Err(ConfigError::MfaKeyInvalidBase64) + )); + + // The current key repeated, and a duplicate entry: both describe a rotation that did + // not happen. + let mut echoed = cfg.clone(); + if let Some(mfa) = echoed.mfa.as_mut() { + mfa.previous_encryption_keys = vec![SecretString::from(key)]; + } + assert!(matches!( + echoed.validate(Environment::Test), + Err(ConfigError::PreviousSecretRepeated) + )); + + let mut duplicated = cfg; + if let Some(mfa) = duplicated.mfa.as_mut() { + mfa.previous_encryption_keys = vec![ + SecretString::from(retired.clone()), + SecretString::from(retired), + ]; + } + assert!(matches!( + duplicated.validate(Environment::Test), + Err(ConfigError::PreviousSecretRepeated) + )); + } } diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index a0df4ef..0575226 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -567,6 +567,7 @@ fn build_mfa_service(wiring: MfaWiring<'_>) -> Option, /// AES-256-GCM key for the TOTP secret and the plaintext-codes record. encryption_key: Zeroizing<[u8; 32]>, + /// Keys retired by a rotation, tried only after [`Self::encryption_key`] and only to + /// decrypt. Empty unless a rotation is in progress; nothing is ever encrypted under one. + previous_encryption_keys: Vec>, /// The engine's identifier-hashing key, keying every `mfa_setup:`/`tu:` suffix, the /// `challenge:`/`disable:` brute-force ids, and the recovery-code digests. identifier_key: Zeroizing<[u8; 64]>, @@ -161,6 +164,8 @@ pub(crate) struct MfaServiceDeps { pub(crate) email: Arc, pub(crate) hooks: Arc, pub(crate) encryption_key: Zeroizing<[u8; 32]>, + /// MFA keys retired by a rotation (see the field of the same name). + pub(crate) previous_encryption_keys: Vec>, pub(crate) identifier_key: Zeroizing<[u8; 64]>, /// Identifier keys retired by a secret rotation (see the field of the same name). pub(crate) previous_identifier_keys: Vec>, @@ -185,6 +190,7 @@ impl MfaService { email: deps.email, hooks: deps.hooks, encryption_key: deps.encryption_key, + previous_encryption_keys: deps.previous_encryption_keys, identifier_key: deps.identifier_key, previous_identifier_keys: deps.previous_identifier_keys, issuer: deps.issuer, @@ -283,7 +289,30 @@ impl MfaService { /// failure (wrong key, tampered ciphertext, malformed wire) so the failure mode is opaque /// — the caller maps `None` to the appropriate flow error with no padding/format oracle. fn decrypt(&self, wire: &str) -> Option> { - aead::decrypt(wire, &self.encryption_key).ok() + self.decrypt_with_rotation(wire) + .map(|(plaintext, _)| plaintext) + } + + /// Decrypt under the current key, then under each retired one. + /// + /// The ciphertext records no key identifier, so without the retired keys a change of + /// `mfa.encryption_key` makes every stored secret undecryptable — every enrolled user's + /// authenticator stops matching at once, with no way back. AES-GCM authenticates, so a + /// wrong key fails unambiguously rather than returning garbage; trying them in order is + /// safe. + /// + /// Returns the plaintext and whether a RETIRED key produced it — the signal that the + /// record should be rewritten under the current key. + fn decrypt_with_rotation(&self, wire: &str) -> Option<(Vec, bool)> { + if let Ok(plaintext) = aead::decrypt(wire, &self.encryption_key) { + return Some((plaintext, false)); + } + for key in &self.previous_encryption_keys { + if let Ok(plaintext) = aead::decrypt(wire, key) { + return Some((plaintext, true)); + } + } + None } /// Decrypt a stored TOTP secret back to the raw bytes the HMAC uses as its key. @@ -293,9 +322,26 @@ impl MfaService { /// failure — wrong key, tampered ciphertext, non-UTF-8, or invalid Base32 — so the caller /// surfaces one opaque error and no decrypt/format oracle distinguishes them. fn decrypt_secret(&self, wire: &str) -> Option> { - let encoded = self.decrypt(wire)?; + self.decrypt_secret_with_rotation(wire) + .map(|(secret, _)| secret) + } + + /// As [`Self::decrypt_secret`], also reporting whether a key RETIRED by a rotation produced + /// the plaintext — the signal that the stored record should be rewritten under the current + /// key so the rotation drains. + fn decrypt_secret_with_rotation(&self, wire: &str) -> Option<(Vec, bool)> { + let (encoded, stale) = self.decrypt_with_rotation(wire)?; let text = String::from_utf8(encoded).ok()?; - totp::decode_secret_base32(&text).ok() + totp::decode_secret_base32(&text).ok().map(|s| (s, stale)) + } + + /// Re-encrypt a decrypted TOTP secret under the CURRENT key, for a record that opened under + /// a retired one. + /// + /// The secret goes back under the cipher in the same at-rest form it was stored in — the + /// Base32 text, not the raw bytes — because that is the shared contract with nest-auth. + fn reencrypt_secret(&self, raw_secret: &[u8]) -> Result { + self.encrypt(totp::encode_secret_base32(raw_secret).as_bytes()) } /// Verify a 6-digit TOTP `code` against `secret` and, on success, atomically mark it used diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 0ce3723..83ba012 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -62,6 +62,29 @@ fn build_with( wire_platform: bool, email: Option>, hooks: Option>, +) -> Option { + build_full(sessions, wire_platform, email, hooks, Vec::new()) +} + +/// The same harness with a retired MFA encryption key configured, so a secret written under +/// that key still opens through the engine's own flows. +fn build_rotating(retired_b64: String) -> Option { + build_full( + true, + false, + None, + None, + vec![SecretString::from(retired_b64)], + ) +} + +/// The harness builder every variant above delegates to. +fn build_full( + sessions: bool, + wire_platform: bool, + email: Option>, + hooks: Option>, + previous_encryption_keys: Vec, ) -> Option { let users = Arc::new(InMemoryUserRepository::new()); let stores = Arc::new(InMemoryStores::new()); @@ -72,6 +95,7 @@ fn build_with( config.email_verification.required = false; config.sessions.enabled = sessions; config.mfa = Some(MfaConfig { + previous_encryption_keys, encryption_key: SecretString::from(key_b64()), issuer: "Bymax One".to_owned(), recovery_code_count: 8, @@ -1420,6 +1444,7 @@ fn service_deps(store: Arc, users: Arc) -> email: Arc::new(NoOpEmailProvider), hooks: Arc::new(NoOpAuthHooks), encryption_key: zeroize::Zeroizing::new([7u8; 32]), + previous_encryption_keys: Vec::new(), identifier_key: zeroize::Zeroizing::new([9u8; 64]), previous_identifier_keys: Vec::new(), issuer: "Bymax One".to_owned(), @@ -2029,7 +2054,7 @@ async fn the_stored_totp_secret_and_recovery_digests_match_the_shared_credential .hashed_codes .iter() .any(|digest| digest.starts_with("scrypt:")), - "a recovery digest was written in the legacy form" + "a recovery digest was written as a KDF hash instead of a keyed MAC" ); } @@ -2069,3 +2094,118 @@ async fn a_recovery_code_digested_under_a_retired_key_still_verifies() { Some(1) ); } + +#[tokio::test] +async fn a_secret_stored_under_a_retired_key_still_opens_and_is_rewritten() { + // The ciphertext records no key identifier, so without the retired key a change of + // `mfa.encryption_key` makes every stored secret undecryptable — every enrolled user's + // authenticator stops matching at once, with no way back. + let users = Arc::new(InMemoryUserRepository::new()); + let retired = zeroize::Zeroizing::new([3u8; 32]); + + // A service that encrypts under the RETIRED key, to produce the stored form. + let mut old_deps = service_deps(Arc::new(InMemoryStores::new()), users.clone()); + old_deps.encryption_key = retired.clone(); + let old_service = MfaService::new(old_deps); + let (raw_secret, _codes, data) = old_service + .generate_setup_material() + .unwrap_or_else(|_| unreachable!("setup material generation cannot fail")); + + // The current service cannot open it… + let strict = service_over(Arc::new(InMemoryStores::new()), users.clone()); + assert!(strict.decrypt_secret(&data.encrypted_secret).is_none()); + + // …and with the retired key listed it opens, and is reported as needing a rewrite. + let mut deps = service_deps(Arc::new(InMemoryStores::new()), users.clone()); + deps.previous_encryption_keys = vec![retired]; + let rotating = MfaService::new(deps); + let opened = rotating.decrypt_secret_with_rotation(&data.encrypted_secret); + assert!(matches!(opened, Some((ref secret, true)) if *secret == raw_secret)); + + // A record under a key nobody holds is still refused: the retired list widens what opens, + // it does not make decryption lenient. Without this the loop's exhausted path is untested + // and a rotation could silently accept a tampered record. + let mut third_deps = service_deps(Arc::new(InMemoryStores::new()), users); + third_deps.encryption_key = zeroize::Zeroizing::new([9u8; 32]); + let stranger = MfaService::new(third_deps); + let Ok((_, _, foreign)) = stranger.generate_setup_material() else { + return; + }; + assert!( + rotating + .decrypt_secret_with_rotation(&foreign.encrypted_secret) + .is_none() + ); + + // The rewrite produces a record the CURRENT key opens, carrying the same secret. + let rewritten = rotating + .reencrypt_secret(&raw_secret) + .unwrap_or_else(|_| unreachable!("re-encryption cannot fail for a 20-byte secret")); + assert!(matches!( + rotating.decrypt_secret_with_rotation(&rewritten), + Some((ref secret, false)) if *secret == raw_secret + )); + assert!(strict.decrypt_secret(&rewritten).is_some()); +} + +#[tokio::test] +async fn a_totp_challenge_rewrites_a_secret_stored_under_a_retired_key() { + // The rewrite has to happen on the TOTP path too, which persists nothing on its own — + // otherwise the rotation never drains for a user who only ever uses their authenticator, + // and the retired key has to stay configured forever: a key that still opens every secret. + let retired_bytes = [3u8; 32]; + let retired_b64 = base64::engine::general_purpose::STANDARD.encode(retired_bytes); + let Some(h) = build_rotating(retired_b64) else { + return; + }; + let Some(uid) = register(&h.engine, "rot@example.com").await else { + return; + }; + + // Enrol out of band, with the secret encrypted under the RETIRED key — the state a + // deployment is in the moment it rotates `mfa.encryption_key`. + let mut old_deps = service_deps(Arc::new(InMemoryStores::new()), h.users.clone()); + old_deps.encryption_key = zeroize::Zeroizing::new(retired_bytes); + let old_service = MfaService::new(old_deps); + let Ok((raw_secret, _codes, data)) = old_service.generate_setup_material() else { + return; + }; + let stored_under_retired = data.encrypted_secret.clone(); + assert!( + h.users + .update_mfa( + &uid, + bymax_auth_types::UpdateMfaData { + mfa_enabled: true, + mfa_secret: Some(stored_under_retired.clone()), + mfa_recovery_codes: Some(data.hashed_codes), + }, + ) + .await + .is_ok() + ); + + // A plain TOTP challenge succeeds — the retired key opened the secret. + let Some(mfa) = h.engine.mfa() else { return }; + let Some(temp) = login_temp_token(&h.engine, "rot@example.com").await else { + return; + }; + assert!(matches!( + mfa.challenge(&temp, &raw_code(&raw_secret, now_secs()), "1.2.3.4", "ua") + .await, + Ok(LoginResultMfa::Dashboard(_)) + )); + + // …and it was rewritten in place: the stored record changed, and a service holding ONLY + // the current key now opens it. Without the rewrite the retired key could never be dropped. + let Ok(Some(after)) = h.users.find_by_id(&uid, None).await else { + return; + }; + let rewritten = after.mfa_secret.unwrap_or_default(); + assert_ne!(rewritten, stored_under_retired); + let strict = service_over(Arc::new(InMemoryStores::new()), h.users.clone()); + assert_eq!( + strict.decrypt_secret(&rewritten).as_deref(), + Some(raw_secret.as_slice()) + ); +} diff --git a/crates/bymax-auth-core/src/services/oauth.rs b/crates/bymax-auth-core/src/services/oauth.rs index d8afb4a..79d640c 100644 --- a/crates/bymax-auth-core/src/services/oauth.rs +++ b/crates/bymax-auth-core/src/services/oauth.rs @@ -155,7 +155,7 @@ impl AuthEngine { return Err(AuthError::OauthFailed); }; let Ok(payload) = serde_json::from_str::(&raw_payload) else { - // A malformed/legacy payload is treated as a failed state, not an internal error. + // A malformed payload is treated as a failed state, not an internal error. return Err(AuthError::OauthFailed); }; let tenant_id = payload.tenant_id; diff --git a/crates/bymax-auth-core/src/services/password.rs b/crates/bymax-auth-core/src/services/password.rs index f37620b..e0135bf 100644 --- a/crates/bymax-auth-core/src/services/password.rs +++ b/crates/bymax-auth-core/src/services/password.rs @@ -245,9 +245,9 @@ mod tests { } #[tokio::test] - async fn verify_reports_needs_rehash_for_a_legacy_or_weaker_hash() { - // A fresh hash under the active params does not need rehashing; a legacy - // non-PHC value (scrypt builds) always reports stale so it migrates on next login. + async fn verify_reports_needs_rehash_for_an_unreadable_or_weaker_hash() { + // A fresh hash under the active params does not need rehashing; a value that is not a + // PHC string always reports stale so it migrates on the next login. let Some(svc) = service() else { return }; let Ok(phc) = svc.hash("pw").await else { return }; let outcome = svc.verify("pw", &phc).await; @@ -261,10 +261,10 @@ mod tests { #[cfg(feature = "scrypt")] { - // The legacy `scrypt:salt:hash` corpus is always stale; the password need not + // A stored value this library never writes is always stale; the password need not // match for `needs_rehash` to fire (it parses the stored form, not the input). - let legacy = "scrypt:0011:2233"; - let stale = svc.verify("anything", legacy).await; + let unreadable = "scrypt:0011:2233"; + let stale = svc.verify("anything", unreadable).await; assert!(matches!( stale, Ok(VerifyOutcome { diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 5118299..b5158f7 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -447,6 +447,7 @@ mod tests { { use base64::Engine as _; cfg.mfa = Some(crate::config::MfaConfig { + previous_encryption_keys: Vec::new(), encryption_key: SecretString::from( base64::engine::general_purpose::STANDARD.encode([5u8; 32]), ), diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 39be749..6c09cd1 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -767,8 +767,8 @@ impl TokenManagerService { /// established once never has to be established again. The cap measures from the /// **family's** birth, which is carried unchanged through the lineage. /// - /// A session with no birth time predates the field and is not capped — it ages out under - /// the refresh lifetime like any other. A cap of `0` disables the check. + /// A session with no birth time has no cap to measure from and is not capped — it ages out + /// under the refresh lifetime like any other. A cap of `0` disables the check. /// /// # Errors /// @@ -1791,17 +1791,17 @@ mod absolute_lifetime_tests { #[tokio::test] async fn a_record_with_no_birth_time_and_a_zero_cap_both_rotate() { - // A record written before the field predates the mechanism and must not be ended by - // it; a zero cap disables the check outright. Both are the "not capped" answer. + // A record with no birth time has nothing to measure from and must not be ended by the + // cap; a zero cap disables the check outright. Both are the "not capped" answer. let store = Arc::new(InMemoryStores::new()); - let legacy = SessionRecord { + let uncapped = SessionRecord { family_created_at: None, ..record_born(365) }; let old = RawRefreshToken::generate(); assert!( store - .create_session(SessionKind::Dashboard, &old.redis_hash(), &legacy, 3600) + .create_session(SessionKind::Dashboard, &old.redis_hash(), &uncapped, 3600) .await .is_ok() ); diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 9b62c83..c4a46f9 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -419,7 +419,7 @@ impl SessionStore for InMemoryStores { 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 + // so the whole lineage is revocable on reuse detection. A record with no family // simply carries no index entry. if !detail.family_id.is_empty() { lock(&self.families) @@ -482,7 +482,7 @@ impl SessionStore for InMemoryStores { // mirror the Redis store, whose script consumes the pointer and whose host side runs the // same `family_is_alive` test — the in-memory store is what the conformance tier and // nest-auth's end-to-end tier run against, so a weaker rule here would let a divergence - // ship unnoticed. A record written before families existed carries none and recovers as + // ship unnoticed. A record that names no family recovers as // before. if let Some(recovered) = lock(&self.grace).remove(&(kind, rotation.old_hash.clone())) { if recovered.family_id.is_empty() @@ -1292,7 +1292,7 @@ mod tests { 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 + // A session with no family plants no consumed marker, so a post-grace replay is a // plain Invalid, never a reuse. assert!( store @@ -1300,7 +1300,7 @@ mod tests { .await .is_ok() ); - let legacy = SessionRotation { + let familyless = SessionRotation { old_hash: "g1".to_owned(), new_hash: "g2".to_owned(), new_raw: "rawg".to_owned(), @@ -1309,12 +1309,12 @@ mod tests { grace_ttl: 30, }; assert!(matches!( - store.rotate(kind, &legacy).await, + store.rotate(kind, &familyless).await, Ok(RotateOutcome::Rotated(_)) )); assert!(store.delete_grace_pointer(kind, "g1").await.is_ok()); assert!(matches!( - store.rotate(kind, &legacy).await, + store.rotate(kind, &familyless).await, Ok(RotateOutcome::Invalid) )); } diff --git a/crates/bymax-auth-core/src/traits/breach.rs b/crates/bymax-auth-core/src/traits/breach.rs index bf293b5..97fdadb 100644 --- a/crates/bymax-auth-core/src/traits/breach.rs +++ b/crates/bymax-auth-core/src/traits/breach.rs @@ -35,8 +35,8 @@ pub trait PasswordBreachChecker: Send + Sync { /// The default checker: approves every password, and touches no network. /// -/// Registered when the builder is given none, so the credential path behaves exactly as it did -/// before the check existed. +/// Registered when the builder is given none, so a deployment that configures no checker pays +/// nothing for the feature and the credential path is unchanged. #[derive(Clone, Copy, Debug, Default)] pub struct AllowAllBreachChecker; diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 60e1300..c02d26f 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -84,9 +84,10 @@ pub struct SessionRecord { /// 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. + /// token (post-grace) revokes the whole family (section 12.5.2). Empty only on the + /// placeholder a replayed token produces, which is never stored — such a record 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, /// When the **family** was born — the moment of the login this session descends from. @@ -96,8 +97,8 @@ pub struct SessionRecord { /// cap has something to measure: without it, a client rotating every fifteen minutes renews /// its lifetime forever and a session established once never has to be established again. /// - /// Serialized as an ISO-8601 string alongside `family_id`, and omitted with it on a legacy - /// record — such a session is simply not capped. + /// Serialized as an ISO-8601 string alongside `family_id`, and omitted with it on a + /// family-less record — such a session is simply not capped. #[serde( default, with = "optional_rfc3339", @@ -106,8 +107,8 @@ pub struct SessionRecord { pub family_created_at: Option, } -/// Serde adapter for an optional RFC 3339 instant, so a legacy record with no family birth -/// time round-trips as `None` rather than failing the whole record. +/// Serde adapter for an optional RFC 3339 instant, so a record with no family birth time +/// round-trips as `None` rather than failing the whole record. pub mod optional_rfc3339 { use serde::{Deserialize, Deserializer, Serialize, Serializer}; use time::OffsetDateTime; @@ -808,17 +809,17 @@ mod tests { // implementations produce the same key set for the same session. assert!(json.contains("\"mfaEnabled\":false")); - // An empty family id (a legacy record) is omitted from the wire for byte-parity, and a + // An empty family id is omitted from the wire for byte-parity, and a // record with no `familyId` key deserializes back to an empty family. - let legacy = SessionRecord { + let familyless = SessionRecord { family_id: String::new(), family_created_at: Some(OffsetDateTime::UNIX_EPOCH), ..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, ""); + let familyless_json = serde_json::to_string(&familyless)?; + assert!(!familyless_json.contains("familyId")); + let familyless_back: SessionRecord = serde_json::from_str(&familyless_json)?; + assert_eq!(familyless_back.family_id, ""); // Round-trip parity for the full record. let back: SessionRecord = serde_json::from_str(&json)?; @@ -908,7 +909,7 @@ mod tests { fn unix_millis_preserves_pre_epoch_instants_and_rejects_non_numbers() { // The clamp in `unix_millis::serialize` must keep a pre-epoch instant NEGATIVE rather // than saturating it to `i64::MAX`, and the reader must refuse a stringly-typed - // timestamp instead of silently defaulting — a legacy RFC 3339 `sd:` record has to fail + // timestamp instead of silently defaulting — an RFC 3339 `sd:` record has to fail // loudly (and be swept as stale) rather than decode to a bogus time. let detail = SessionDetail { session_hash: "abc123".into(), @@ -922,10 +923,10 @@ mod tests { assert!(json.contains("\"createdAt\":-1000000")); assert!(json.contains("\"lastActivityAt\":0")); - let legacy: Result = serde_json::from_str( + let rfc3339: Result = serde_json::from_str( r#"{"sessionHash":"abc123","device":"Firefox","ip":"198.51.100.7","createdAt":"1970-01-01T00:00:00Z","lastActivityAt":0}"#, ); - assert!(legacy.is_err()); + assert!(rfc3339.is_err()); } #[test] @@ -1136,14 +1137,14 @@ mod tests { // An empty family is omitted from the wire entirely, never written as `""` — nest-auth // omits it the same way, and a record differing by that one key is not byte-identical. - let legacy = SessionRecord { + let familyless = SessionRecord { family_id: String::new(), family_created_at: None, ..session_record() }; - let legacy_json: serde_json::Value = serde_json::to_value(legacy)?; - assert!(legacy_json.get("familyId").is_none()); - assert!(legacy_json.get("familyCreatedAt").is_none()); + let familyless_json: serde_json::Value = serde_json::to_value(familyless)?; + assert!(familyless_json.get("familyId").is_none()); + assert!(familyless_json.get("familyCreatedAt").is_none()); Ok(()) } diff --git a/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs b/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs index 46d7197..dc58158 100644 --- a/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs +++ b/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs @@ -59,6 +59,7 @@ fn build_engine(stores: Arc) -> Option<(AuthEngine, Arc` because + /// through untyped, so a token really can arrive without the key (serde's default only + /// applies when deserializing into this struct). Rendered via `Option::` because /// ts-rs maps 64-bit integers to `bigint`, while `JSON.parse` yields a `number`. #[serde(default)] #[cfg_attr(feature = "ts-export", ts(as = "Option::", optional))] @@ -138,8 +139,8 @@ pub struct PlatformClaims { /// 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. Enforced by **server-side** verification - /// only; the edge/WASM verifier carries it without consulting it. Defaults to `0` on a legacy - /// token, and is exported as an optional TS property for the same reason as + /// only; the edge/WASM verifier carries it without consulting it. Defaults to `0` when the + /// claim is absent, and is exported as an optional TS property for the same reason as /// [`DashboardClaims::epoch`]. #[serde(default)] #[cfg_attr(feature = "ts-export", ts(as = "Option::", optional))] diff --git a/packages/rust-auth/src/shared/jwt-payload.types.ts b/packages/rust-auth/src/shared/jwt-payload.types.ts index e89eca2..fe79fd9 100644 --- a/packages/rust-auth/src/shared/jwt-payload.types.ts +++ b/packages/rust-auth/src/shared/jwt-payload.types.ts @@ -51,12 +51,13 @@ exp: number, * sign-out-everywhere). **Server-side** verification rejects the token when its epoch is * below the user's current stored epoch; the edge/WASM verifier carries this claim but does * not consult it (it checks signature, `iat`, and `exp` only), exactly like the jti - * blacklist. 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). + * blacklist. Defaults to `0` when the claim is absent, which is never rejected while the + * stored epoch is also `0` (the mechanism is inert until a bump) and always rejected once + * it is bumped — the fail-closed direction. * * Exported as an optional TS property: the decode-only edge path passes the raw JWT payload - * through untyped, so a legacy token really does arrive without the key (serde's default - * only applies when deserializing into this struct). Rendered via `Option::` because + * through untyped, so a token really can arrive without the key (serde's default only + * applies when deserializing into this struct). Rendered via `Option::` because * ts-rs maps 64-bit integers to `bigint`, while `JSON.parse` yields a `number`. */ epoch?: number, }; @@ -148,8 +149,8 @@ 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. Enforced by **server-side** verification - * only; the edge/WASM verifier carries it without consulting it. Defaults to `0` on a legacy - * token, and is exported as an optional TS property for the same reason as + * only; the edge/WASM verifier carries it without consulting it. Defaults to `0` when the + * claim is absent, and is exported as an optional TS property for the same reason as * [`DashboardClaims::epoch`]. */ epoch?: number, }; From 08e4b34424d3bcd770eb517673b02374a60528e5 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:45:40 -0300 Subject: [PATCH 089/122] feat(core): bound the parameters that decide how strong a control is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every security parameter in this config carries a `build()` bound — secret entropy and length, the scrypt cost factor, the Argon2 memory and iteration floors, OTP length, the refresh grace window against the refresh lifetime, the access lifetime against the token-epoch retention. Four did not, and they are not minor ones. `mfa.totp_window` decides how much the second factor is worth. It counts 30-second steps on *either* side of now, so `2n + 1` codes verify at any moment: three at 1, but 121 at 60. A six-digit code becomes a hundred times easier to guess while the configuration still reads as "MFA enabled" and every test still passes. Now `0..=10` (`TotpWindowRange`) — generous enough never to refuse a real tolerance for clock skew, tight enough to catch a mistake. The error carries the resulting code count, because the number is the argument. `mfa.recovery_code_count` of zero enrols an account with no way back if the authenticator is lost, and nothing in the flow reports anything wrong. Now `1..=50` (`RecoveryCodeCountRange`). `password.scrypt.block_size` and `parallelization` are the ones that make the cost-factor floor a lie. The memory cost is `128 * N * r`: at `r = 1` the same N buys an eighth of the memory, so the floor stops guaranteeing what it says — invisibly, because the parameter that *is* bounded is still intact. Now `ScryptBlockSize` and `ScryptParallelization`. Found by enumerating the class instead of sampling it: every numeric option against "is there a value here that removes the control this parameter drives?" `nest-auth` had the same four gaps and enforces the identical ranges. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 12 ++ README.md | 12 ++ crates/bymax-auth-core/src/config/validate.rs | 126 ++++++++++++++++++ crates/bymax-auth-core/src/error.rs | 29 ++++ 4 files changed, 179 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42d383..5fff7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,18 @@ version bump. exactly 32 bytes, never equal to the current key or to another entry), because a malformed one would otherwise surface at a user's first challenge. Same option on both sides. +- **Startup bounds on the parameters that carry a control's strength.** `mfa.totp_window` + (`0..=10`, `TotpWindowRange`), `mfa.recovery_code_count` (`1..=50`, + `RecoveryCodeCountRange`), `password.scrypt.block_size` (`>= 8`, `ScryptBlockSize`) and + `password.scrypt.parallelization` (`>= 1`, `ScryptParallelization`) had no validation while + every sibling parameter did. The window counts 30-second steps on *either* side of now, so + `2n + 1` codes are valid at once: at 60 that is 121, and a six-digit code becomes a hundred + times easier to guess while the configuration still reads as "MFA enabled". Zero recovery + codes enrols an account with no way back if the authenticator is lost. And scrypt's memory + cost is `128 * N * r`, so a block size below 8 divides the hardness the cost-factor floor + exists to guarantee — invisibly, because the parameter that *is* bounded stays intact. + `nest-auth` enforces the identical ranges. + ### Changed - **Family-lineage reuse detection replaces the previous sentinel.** A login opens diff --git a/README.md b/README.md index 389a887..8ddb6dc 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,18 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se > once your enrolled users have had time to authenticate at least once. `nest-auth` exposes the > identical option as `mfa.previousEncryptionKeys`. +> [!IMPORTANT] +> **The parameters that carry a control's strength are bounded by `build()`.** `mfa.totp_window` +> must be `0..=10` (`TotpWindowRange`): the window counts 30-second steps on *either* side of +> now, so `2n + 1` codes are valid at once — three at 1, but 121 at 60, which makes a six-digit +> code a hundred times easier to guess while the configuration still reads as "MFA enabled". +> `mfa.recovery_code_count` must be `1..=50` (`RecoveryCodeCountRange`), because zero enrols an +> account with no way back if the authenticator is lost. `password.scrypt.block_size` must be +> at least 8 (`ScryptBlockSize`) and `password.scrypt.parallelization` at least 1 +> (`ScryptParallelization`): the memory cost is `128 * N * r`, so a smaller block size divides +> the hardness that the cost-factor floor exists to guarantee — invisibly, since the bounded +> parameter is still intact. `nest-auth` enforces the identical ranges. + > [!IMPORTANT] > `jwt.access_expires_in` must not exceed **30 days**, the window a store keeps a bumped token > epoch readable. The epoch is what makes a stateless access token revocable: a password reset diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index ec80d90..0344bc9 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -332,6 +332,26 @@ impl AuthConfig { return Err(ConfigError::MfaToggleWithoutConfig); } + // Rule 13b: the two MFA parameters that decide how much a second factor is worth. + // `totp_window` is counted in 30-second steps on either side of now, so `2n + 1` codes + // are valid at once: three at the default of 1, but 121 at 60 — a six-digit code a + // hundred times easier to guess than its length suggests. `recovery_code_count` of + // zero enrols an account with no way back if the authenticator is lost. Every sibling + // security parameter carries a bound; these two decide whether MFA is worth anything. + if let Some(mfa) = self.mfa.as_ref() { + if mfa.totp_window > 10 { + return Err(ConfigError::TotpWindowRange { + got: mfa.totp_window, + valid: u16::from(mfa.totp_window) * 2 + 1, + }); + } + if !(1..=50).contains(&mfa.recovery_code_count) { + return Err(ConfigError::RecoveryCodeCountRange { + got: mfa.recovery_code_count, + }); + } + } + // Rule 14: OTP length range. let otp_length = self.password_reset.otp_length; if !(4..=8).contains(&otp_length) { @@ -523,6 +543,27 @@ fn validate_password(password: &PasswordConfig) -> Result<(), ConfigError> { }); } + // scrypt's memory cost is `128 * N * r`, so the block size is a multiplier on the hardness + // the cost-factor floor exists to guarantee: at `r = 1` the same N buys an eighth of the + // memory and the floor quietly stops meaning what it says. The weakening is invisible + // precisely because the parameter that IS bounded is still intact. + #[cfg(feature = "scrypt")] + if password.scrypt.block_size < 8 { + return Err(ConfigError::ScryptBlockSize { + got: password.scrypt.block_size, + }); + } + + // Below 1 is not a weaker setting but an invalid one — the hasher rejects it at the first + // password, which is a credential path failing at runtime over something startup could + // have caught. + #[cfg(feature = "scrypt")] + if password.scrypt.parallelization < 1 { + return Err(ConfigError::ScryptParallelization { + got: password.scrypt.parallelization, + }); + } + #[cfg(feature = "argon2")] { if password.argon2.memory_kib < 19_456 { @@ -922,6 +963,91 @@ mod tests { } } + #[cfg(feature = "mfa")] + #[test] + fn the_mfa_parameters_that_decide_a_second_factor_are_bounded() { + // `totp_window` is counted in 30-second steps on either side of now, so `2n + 1` codes + // are valid at once. At 60 that is 121, and a six-digit code is a hundred times easier + // to guess than its length suggests — the second factor is worth almost nothing while + // the config still reads as "MFA enabled". Every sibling parameter carries a bound. + let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); + let mut wide = valid_config(); + wide.mfa = Some(MfaConfig { + totp_window: 60, + ..mfa_with_key(&key) + }); + assert!(matches!( + wide.validate(Environment::Test), + Err(ConfigError::TotpWindowRange { + got: 60, + valid: 121 + }) + )); + + // The edges are accepted: 0 is a deliberate hardening (no tolerance at all), 10 is the + // generous ceiling. A bound that refused a legitimate tolerance would be an outage. + for window in [0u8, 1, 10] { + let mut ok = valid_config(); + ok.mfa = Some(MfaConfig { + totp_window: window, + ..mfa_with_key(&key) + }); + assert!(ok.validate(Environment::Test).is_ok()); + } + + // Zero recovery codes enrols an account with no way back if the authenticator is lost, + // and nothing in the flow reports anything wrong — the user finds out at the worst + // possible moment. + let mut none = valid_config(); + none.mfa = Some(MfaConfig { + recovery_code_count: 0, + ..mfa_with_key(&key) + }); + assert!(matches!( + none.validate(Environment::Test), + Err(ConfigError::RecoveryCodeCountRange { got: 0 }) + )); + + let mut many = valid_config(); + many.mfa = Some(MfaConfig { + recovery_code_count: 51, + ..mfa_with_key(&key) + }); + assert!(matches!( + many.validate(Environment::Test), + Err(ConfigError::RecoveryCodeCountRange { got: 51 }) + )); + } + + #[cfg(feature = "scrypt")] + #[test] + fn the_scrypt_parameters_that_carry_the_memory_hardness_are_bounded() { + // The memory cost is `128 * N * r`. Bounding N alone is not enough: at `r = 1` the same + // cost factor buys an eighth of the memory, and the floor quietly stops meaning what it + // says — invisibly, because the parameter that IS bounded is still intact. + let mut thin = valid_config(); + thin.password.scrypt.block_size = 1; + assert!(matches!( + thin.validate(Environment::Test), + Err(ConfigError::ScryptBlockSize { got: 1 }) + )); + + // Below 1 is not weaker but invalid: the hasher rejects it at the first password, which + // is a credential path failing at runtime over something startup could have caught. + let mut zero = valid_config(); + zero.password.scrypt.parallelization = 0; + assert!(matches!( + zero.validate(Environment::Test), + Err(ConfigError::ScryptParallelization { got: 0 }) + )); + + // The defaults sit at the floor and are accepted. + let mut ok = valid_config(); + ok.password.scrypt.block_size = 8; + ok.password.scrypt.parallelization = 1; + assert!(ok.validate(Environment::Test).is_ok()); + } + #[cfg(feature = "mfa")] #[test] fn resolves_the_configured_mfa_key_and_none_without_mfa() { diff --git a/crates/bymax-auth-core/src/error.rs b/crates/bymax-auth-core/src/error.rs index ef8401b..b7e1b62 100644 --- a/crates/bymax-auth-core/src/error.rs +++ b/crates/bymax-auth-core/src/error.rs @@ -81,6 +81,35 @@ pub enum ConfigError { /// The rejected cost factor. got: u32, }, + /// `password.scrypt.block_size` is below the floor that makes the cost factor mean what + /// it says. + #[error("password.scrypt.block_size must be >= 8 (got {got})")] + ScryptBlockSize { + /// The rejected block size. + got: u32, + }, + /// `password.scrypt.parallelization` is below 1. + #[error("password.scrypt.parallelization must be >= 1 (got {got})")] + ScryptParallelization { + /// The rejected parallelization. + got: u32, + }, + /// `mfa.totp_window` is outside the accepted `0..=10` range. + #[error( + "mfa.totp_window must be within 0..=10 (got {got}; {valid} codes would be valid at once)" + )] + TotpWindowRange { + /// The rejected window. + got: u8, + /// How many codes that window would accept simultaneously. + valid: u16, + }, + /// `mfa.recovery_code_count` is outside the accepted `1..=50` range. + #[error("mfa.recovery_code_count must be within 1..=50 (got {got})")] + RecoveryCodeCountRange { + /// The rejected count. + got: u8, + }, /// `password.argon2.memory_kib` is below the OWASP production floor of 19456 KiB. #[error("password.argon2.memory_kib must be >= 19456 (OWASP floor; got {got})")] Argon2Memory { From 04421442b0ea059d4e7d753a4f7922aa6ae32991 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:25:12 -0300 Subject: [PATCH 090/122] fix(core, axum): revoke access tokens on auth-state changes, stamp no-store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from an audit pass over the revocation story, mirrored in nest-auth in the sibling commit. An auth-state change now revokes the outstanding access tokens too. Enabling or disabling MFA, and the platform "log out everywhere" (`revoke_all_platform_sessions`), revoked the refresh sessions but left every access token working to expiry — the enable path even carried a comment claiming the current session would continue, which `revoke_all` had never made true: it kills the current refresh session with the rest, and only the access token survived, the one artifact the epoch is able to reach. For MFA enable that is the worst possible window: every pre-enable token is stamped `mfa_enabled: false`, and the MFA gate refuses only `mfa_enabled && !mfa_verified` — so a stolen token kept clearing every MFA-gated route at the exact moment the user enabled a second factor because they suspected that theft. All three flows now bump the plane-scoped token epoch alongside the session sweep — the rule the password-reset flow already applied. Verification has always enforced the epoch on both planes; what was missing was anything advancing it. Every response of the axum router is stamped `Cache-Control: no-store` plus `Pragma: no-cache`, via `SetResponseHeaderLayer` in the middleware stack (tower-http grows the `set-header` feature; no new dependency). RFC 6749 §5.1 requires it on any response carrying a token, and a CDN or corporate proxy that caches a login response serves one user's tokens to the next caller. A router-wide layer rather than per handler, so a future route cannot forget it; placed outside the body limit so even a 413 goes out uncacheable, inside CORS so a preflight answer is not stamped. Pinned across success, auth-failure and 404 responses by an adapter test. The two adapter lifecycle tests that reused a pre-enable token now re-authenticate through the challenge, as a real client does — and their TOTP codes derive from ONE captured base instant instead of per-call clock reads, which could straddle a 30-second step boundary mid-test and collide two "distinct" steps on the anti-replay marker. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 17 +++ crates/bymax-auth-axum/Cargo.toml | 2 +- crates/bymax-auth-axum/src/middleware.rs | 28 ++++- crates/bymax-auth-axum/tests/adapter.rs | 118 +++++++++++++++--- crates/bymax-auth-axum/tests/common/mod.rs | 31 ++++- .../src/services/mfa/manage.rs | 8 +- .../bymax-auth-core/src/services/mfa/setup.rs | 16 ++- .../bymax-auth-core/src/services/mfa/tests.rs | 96 ++++++++++++++ .../bymax-auth-core/src/services/platform.rs | 37 +++++- 9 files changed, 322 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fff7c0..7bf01ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,23 @@ version bump. ### Fixed +- **An auth-state change now revokes the outstanding access tokens too.** Enabling or + disabling MFA, and the platform "log out everywhere" (`revoke_all_platform_sessions`), + revoked the refresh sessions but left every access token working to expiry — the enable + path even carried a comment claiming the current session would continue, which `revoke_all` + had never made true. For MFA enable that is the worst possible window: every pre-enable + token is stamped `mfa_enabled: false`, and the MFA gate refuses only + `mfa_enabled && !mfa_verified` — so a stolen token kept clearing every MFA-gated route at + the exact moment the user enabled a second factor because they suspected that theft. All + three flows now bump the plane-scoped token epoch alongside the session sweep, the same + rule the password-reset flow already applied. Verification has always enforced the epoch on + both planes; what was missing was anything advancing it. Same change on both sides. +- **Every response of the axum router is stamped `Cache-Control: no-store`** + (plus `Pragma: no-cache`), via `SetResponseHeaderLayer` in the middleware stack. RFC 6749 + §5.1 requires it on any response carrying a token, and a CDN or corporate proxy that caches + a login response serves one user's tokens to the next caller. A router-wide layer rather + than per handler, so a future route cannot forget it. `nest-auth` stamps the identical + headers via a controller interceptor. - **`mfa_enabled` is required on a stored session record.** `#[serde(default)]` made a missing value read as `false`, which turns a truncated or corrupt record into a silent second-factor bypass: the gate refuses only a token whose claims say `mfa_enabled && !mfa_verified`, so an diff --git a/crates/bymax-auth-axum/Cargo.toml b/crates/bymax-auth-axum/Cargo.toml index 592401b..5364ec0 100644 --- a/crates/bymax-auth-axum/Cargo.toml +++ b/crates/bymax-auth-axum/Cargo.toml @@ -26,7 +26,7 @@ tower = { version = "0.5", default-features = false } # The shared middleware layers: tracing spans, request-body limit, sensitive-header # redaction, and the optional CORS layer. No TLS feature — server-side termination is # external, so tower-http never pulls a TLS backend. -tower-http = { version = "0.6", default-features = false, features = ["trace", "limit", "sensitive-headers", "cors"] } +tower-http = { version = "0.6", default-features = false, features = ["trace", "limit", "sensitive-headers", "cors", "set-header"] } # The typed cookie jar + `CookieManagerLayer` for reading and emitting `Set-Cookie` # without parsing raw headers in handlers. tower-cookies = "0.11" diff --git a/crates/bymax-auth-axum/src/middleware.rs b/crates/bymax-auth-axum/src/middleware.rs index 7d55fdf..c58b2a4 100644 --- a/crates/bymax-auth-axum/src/middleware.rs +++ b/crates/bymax-auth-axum/src/middleware.rs @@ -1,16 +1,20 @@ //! The ordered tower middleware stack applied around the whole router (§8.8). //! -//! Layers, outermost first: structured tracing spans, a request-body size cap, -//! sensitive-header redaction (so the credential-bearing headers never reach trace output), -//! an optional consumer-supplied CORS layer, and the cookie manager that makes the typed +//! Layers, outermost first: structured tracing spans, an optional consumer-supplied CORS +//! layer, the `Cache-Control: no-store` response stamp (RFC 6749 §5.1 — no auth response is +//! ever cacheable), sensitive-header redaction (so the credential-bearing headers never +//! reach trace output), a request-body size cap, and the cookie manager that makes the typed //! `CookieJar` available to extractors and the delivery layer. Rate-limit layers are //! **not** here — they attach per route group (§16). The adapter emits `tracing` spans but //! installs **no** subscriber: the consuming application owns subscriber setup. use axum::Router; +use http::HeaderValue; +use http::header::{CACHE_CONTROL, PRAGMA}; use tower_cookies::CookieManagerLayer; use tower_http::limit::RequestBodyLimitLayer; use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; +use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use crate::routes::sensitive_header_names; @@ -46,7 +50,23 @@ pub(crate) fn apply_middleware( )) .layer(CookieManagerLayer::new()) .layer(RequestBodyLimitLayer::new(max_body_bytes)) - .layer(sensitive); + .layer(sensitive) + // Every response of every route this router serves is stamped `Cache-Control: + // no-store` (plus `Pragma: no-cache` for HTTP/1.0 intermediaries). RFC 6749 §5.1 + // requires it on any response carrying a token, and every route here either carries + // one, sets an auth cookie, or answers a question about an authenticated identity — + // all of which a shared cache must never replay to the next caller. Stamped as a + // layer, not per handler, so a future route cannot forget it; placed outside the + // body-limit so even a 413 goes out uncacheable. `nest-auth` stamps the identical + // headers via a controller interceptor. + .layer(SetResponseHeaderLayer::overriding( + CACHE_CONTROL, + HeaderValue::from_static("no-store"), + )) + .layer(SetResponseHeaderLayer::overriding( + PRAGMA, + HeaderValue::from_static("no-cache"), + )); let router = match cors { Some(cors) => router.layer(cors), diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index a3d5f78..bbfec33 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -23,7 +23,7 @@ mod common; use common::{ Captured, EngineSpec, Req, TENANT, build, build_oauth_with_redirects, current_totp, - enable_mfa_flag, router, seed_admin, seed_user, set_status, totp_at, + enable_mfa_flag, router, seed_admin, seed_user, set_status, }; use http::{HeaderName, Method, StatusCode, header}; @@ -104,6 +104,36 @@ async fn an_unknown_route_is_404() { assert_eq!(resp.status, StatusCode::NOT_FOUND); } +#[tokio::test] +async fn every_response_is_stamped_uncacheable() { + // RFC 6749 §5.1: a response carrying a token must never be cacheable — a CDN or + // corporate proxy that caches a login response serves one user's tokens to the next + // caller. The stamp is a router-wide layer rather than per handler, so a future route + // cannot forget it; this test pins success, auth-failure, and 404 responses alike, + // because a cached 401 wedges a client just as a cached login leaks one. `nest-auth` + // stamps the identical headers via a controller interceptor. + let Some(h) = build(EngineSpec::default()) else { return }; + let app = router(&h); + + for path in ["/auth/me", "/auth/does-not-exist"] { + let resp = Req::get(path).send(&app).await; + assert_eq!( + resp.headers + .get(http::header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store"), + "missing no-store on {path}" + ); + assert_eq!( + resp.headers + .get(http::header::PRAGMA) + .and_then(|v| v.to_str().ok()), + Some("no-cache"), + "missing pragma on {path}" + ); + } +} + // ---------------------------------------------------------------------------------------- // auth group + delivery modes // ---------------------------------------------------------------------------------------- @@ -1266,28 +1296,52 @@ async fn platform_mfa_full_lifecycle() { assert_eq!(setup.status, StatusCode::CREATED); let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); - // Each TOTP-gated step uses a distinct window offset so the per-window anti-replay never - // rejects a reused code (the verifier's window tolerance accepts the near-future codes). + // ONE captured base for every code: distinct steps per verification, immune to a step + // boundary passing mid-test (see totp_at_abs). + let base = common::now_unix(); let enable = Req::post("/auth/platform/mfa/verify-enable") .bearer(&access) - .json(serde_json::json!({ "code": totp_at(&secret, 0) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base) })) .send(&app) .await; assert_eq!(enable.status, StatusCode::NO_CONTENT); - // recovery-codes with a fresh-window TOTP returns a fresh set. + // Enabling bumped the platform token epoch, so the pre-enable token is dead — exactly + // what a compromised-account recovery needs. The admin re-authenticates through the + // platform challenge, as a real client would. + let stale = Req::post("/auth/platform/mfa/recovery-codes") + .bearer(&access) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base + 30) })) + .send(&app) + .await; + assert_eq!(stale.status, StatusCode::UNAUTHORIZED); + + let relogin = platform_login(&app, "padmin2@e.com").await; + let temp = relogin.json()["mfaTempToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let challenged = Req::post("/auth/platform/mfa/challenge") + .json(serde_json::json!({ "mfaTempToken": temp, "code": common::totp_at_abs(&secret, base + 30) })) + .send(&app) + .await; + assert_eq!(challenged.status, StatusCode::OK); + let access = platform_access(&challenged); + + // recovery-codes with a fresh-step TOTP returns a fresh set. let recov = Req::post("/auth/platform/mfa/recovery-codes") .bearer(&access) - .json(serde_json::json!({ "code": totp_at(&secret, 30) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base + 60) })) .send(&app) .await; assert_eq!(recov.status, StatusCode::OK); assert!(recov.json()["recoveryCodes"].is_array()); - // disable with another fresh-window TOTP turns it off. + // disable with step base-30: inside the verifier's window, never consumed by anti-replay + // (the steps at base, base+30 and base+60 all were). let disable = Req::post("/auth/platform/mfa/disable") .bearer(&access) - .json(serde_json::json!({ "code": totp_at(&secret, 60) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base - 30) })) .send(&app) .await; assert_eq!(disable.status, StatusCode::NO_CONTENT); @@ -1982,18 +2036,22 @@ async fn platform_me_and_revoke_error_arms_with_a_ghost_admin() { assert_eq!(me.status, StatusCode::UNAUTHORIZED); assert_eq!(me.json()["error"]["code"], "auth.token_invalid"); - // revoke-all for a ghost admin: the engine revokes nothing and returns Ok (204), but the - // path is exercised; a logout for the ghost also runs. + // revoke-all for a ghost admin: the engine revokes nothing but still bumps the ghost's + // epoch — 204, and the path is exercised. let revoke = Req::delete("/auth/platform/sessions") .bearer(&ghost) .send(&app) .await; assert_eq!(revoke.status, StatusCode::NO_CONTENT); + // The token that performed the revoke is now dead like every other token the ghost held: + // revoke-all reaches the access tokens too, not only the refresh sessions. (The plain + // ghost-logout 204 arm is covered by the real-admin logout test; one token cannot both + // revoke-all and then log out, which is the point.) let logout = Req::post("/auth/platform/logout") .bearer(&ghost) .send(&app) .await; - assert_eq!(logout.status, StatusCode::NO_CONTENT); + assert_eq!(logout.status, StatusCode::UNAUTHORIZED); } #[tokio::test] @@ -2171,22 +2229,54 @@ async fn dashboard_mfa_disable_and_recovery_success() { .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); + // ONE captured base for every code in this test: per-call clock reads can straddle a + // 30-second step boundary mid-test and collide two "distinct" steps on the anti-replay + // marker (see totp_at_abs). + let base = common::now_unix(); let _ = Req::post("/auth/mfa/verify-enable") .cookie("access_token", &access) - .json(serde_json::json!({ "code": totp_at(&secret, 0) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base) })) + .send(&app) + .await; + + // Enabling bumped the token epoch, so the pre-enable token is dead — the account must + // re-authenticate through the challenge, which is exactly what a real client does. + let stale = Req::post("/auth/mfa/recovery-codes") + .cookie("access_token", &access) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base + 30) })) + .send(&app) + .await; + assert_eq!(stale.status, StatusCode::UNAUTHORIZED); + + let relogin = Req::post("/auth/login") + .json(serde_json::json!({ + "email": "dr@e.com", "password": "password123", "tenantId": TENANT + })) + .send(&app) + .await; + let temp = relogin.json()["mfaTempToken"] + .as_str() + .unwrap_or("") + .to_owned(); + let challenged = Req::post("/auth/mfa/challenge") + .json(serde_json::json!({ "mfaTempToken": temp, "code": common::totp_at_abs(&secret, base + 30) })) .send(&app) .await; + assert_eq!(challenged.status, StatusCode::OK); + let access = challenged.cookie_value("access_token").unwrap_or_default(); let recov = Req::post("/auth/mfa/recovery-codes") .cookie("access_token", &access) - .json(serde_json::json!({ "code": totp_at(&secret, 30) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base + 60) })) .send(&app) .await; assert_eq!(recov.status, StatusCode::OK); + // Step base-30: inside the verifier's window and never consumed by the anti-replay + // marker (the steps at base, base+30 and base+60 all were). let disable = Req::post("/auth/mfa/disable") .cookie("access_token", &access) - .json(serde_json::json!({ "code": totp_at(&secret, 60) })) + .json(serde_json::json!({ "code": common::totp_at_abs(&secret, base - 30) })) .send(&app) .await; assert_eq!(disable.status, StatusCode::NO_CONTENT); diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 4664048..3b24a57 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -731,14 +731,39 @@ pub fn current_totp(secret_b32: &str) -> String { /// several TOTP-gated operations in one test uses distinct window offsets (0, 30, 60, …) so the /// per-window anti-replay never rejects a reused code. The configured `totp_window` (2) accepts /// codes a couple of steps away from the verifier's clock, so a near-future offset still validates. -pub fn totp_at(secret_b32: &str, offset_secs: u64) -> String { +pub fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// A TOTP code at an absolute instant. Lifecycle tests that need several distinct, +/// non-replayed codes capture ONE base with [`now_unix`] and derive every code from it — +/// per-call clock reads can straddle a 30-second step boundary mid-test, silently colliding +/// two "distinct" steps and tripping the anti-replay marker. +pub fn totp_at_abs(secret_b32: &str, at_unix: i64) -> String { + let raw = bymax_auth_crypto::totp::decode_secret_base32(secret_b32).unwrap_or_default(); + format!( + "{:06}", + bymax_auth_crypto::totp::totp(&raw, u64::try_from(at_unix).unwrap_or(0), 30, 6) + ) +} + +pub fn totp_at(secret_b32: &str, offset_secs: i64) -> String { + // Signed offset: a lifecycle test that has already consumed the present and near-future + // steps against the anti-replay marker still needs a valid in-window code, and the only + // ones left are in the near past. let raw = bymax_auth_crypto::totp::decode_secret_base32(secret_b32).unwrap_or_default(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) + .map(|d| d.as_secs() as i64) .unwrap_or(0) + offset_secs; - format!("{:06}", bymax_auth_crypto::totp::totp(&raw, now, 30, 6)) + format!( + "{:06}", + bymax_auth_crypto::totp::totp(&raw, u64::try_from(now).unwrap_or(0), 30, 6) + ) } /// Mark a seeded user as MFA-enabled with a stored (encrypted-placeholder) secret. diff --git a/crates/bymax-auth-core/src/services/mfa/manage.rs b/crates/bymax-auth-core/src/services/mfa/manage.rs index fda0220..cd57255 100644 --- a/crates/bymax-auth-core/src/services/mfa/manage.rs +++ b/crates/bymax-auth-core/src/services/mfa/manage.rs @@ -32,11 +32,15 @@ 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). + // Revoke every refresh session AND advance the token epoch: an auth-state change + // revokes everything issued under the previous state, in both directions — the same + // rule the password-reset flow applies (see the enable path for the full rationale). self.session_store .revoke_all(session_kind(ctx), user_id) .await?; + self.session_store + .bump_epoch(session_kind(ctx), user_id) + .await?; self.notify_disabled(&view, user_id, ip, user_agent); Ok(()) } diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index 8c61d5a..94f0792 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -130,14 +130,20 @@ 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 currently has exactly one - // trigger — the password-reset flow; `revoke_all` deliberately does not bump either, so - // it too leaves already-issued access tokens valid until they expire. + // Revoke every refresh session AND advance the token epoch. Every access token issued + // before this moment is stamped `mfa_enabled: false`, and the MFA gate refuses only + // `mfa_enabled && !mfa_verified` — so without the bump, a stolen access token keeps + // clearing every MFA-gated route for its remaining lifetime, at the exact moment the + // user enabled a second factor because they suspected that theft. (`revoke_all` kills + // the current session too, so the "current session continues" this comment once + // promised was never true — only its access token survived, the one artifact the + // epoch is able to reach.) self.session_store .revoke_all(session_kind(ctx), user_id) .await?; + self.session_store + .bump_epoch(session_kind(ctx), user_id) + .await?; self.notify_enabled(&view, user_id, ip, user_agent); tracing::info!(%user_id, context = ?ctx, "mfa setup: enabled"); diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 83ba012..0c3f101 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -2209,3 +2209,99 @@ async fn a_totp_challenge_rewrites_a_secret_stored_under_a_retired_key() { Some(raw_secret.as_slice()) ); } + +#[tokio::test] +async fn an_mfa_state_change_kills_the_outstanding_access_tokens() { + // Enabling a second factor advances the token epoch: every access token issued before + // that moment is stamped `mfa_enabled: false`, and the MFA gate refuses only + // `mfa_enabled && !mfa_verified` — so without the bump a stolen access token keeps + // clearing every MFA-gated route for its remaining lifetime, at the exact moment the + // user enabled MFA because they suspected that theft. Disable applies the same rule in + // the other direction: an auth-state change revokes everything issued under the + // previous state, exactly as the password-reset flow does. + let Some(h) = build(true, false) else { return }; + let input = crate::services::auth::RegisterInput { + email: "epoch@example.com".to_owned(), + name: "U".to_owned(), + password: PASSWORD.to_owned(), + tenant_id: TENANT.to_owned(), + }; + let registered = h.engine.register(input, &ctx()).await; + let Ok(LoginResult::Success(auth)) = registered else { + return; + }; + let uid = auth.user.id.clone(); + let pre_enable_token = auth.access_token.clone(); + assert!( + h.engine + .tokens() + .verify_access(&pre_enable_token) + .await + .is_ok() + ); + + // Enable MFA. Distinct TOTP steps per verification, as in the lifecycle test. + let Some(mfa) = h.engine.mfa() else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + return; + }; + let base = now_secs(); + assert!( + mfa.verify_and_enable( + &uid, + &code_at(&setup.secret, base), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + + // The pre-enable token is dead the moment the state changed — not fifteen minutes later. + assert!( + h.engine + .tokens() + .verify_access(&pre_enable_token) + .await + .is_err() + ); + + // A fresh login through the challenge mints a token stamped with the NEW epoch… + let Some(temp) = login_temp_token(&h.engine, "epoch@example.com").await else { + return; + }; + let challenged = mfa + .challenge(&temp, &code_at(&setup.secret, base + 30), "1.2.3.4", "ua") + .await; + let Ok(LoginResultMfa::Dashboard(post_enable)) = challenged else { + return; + }; + assert!( + h.engine + .tokens() + .verify_access(&post_enable.access_token) + .await + .is_ok() + ); + + // …and disabling bumps again, so that token dies with the state that minted it. + assert!( + mfa.disable( + &uid, + &code_at(&setup.secret, base + 60), + "1.2.3.4", + "ua", + MfaContext::Dashboard + ) + .await + .is_ok() + ); + assert!( + h.engine + .tokens() + .verify_access(&post_enable.access_token) + .await + .is_err() + ); +} diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index b5158f7..0841704 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -275,7 +275,11 @@ impl PlatformAuthService { /// Atomically invalidate EVERY platform session for the admin (the "log out everywhere" /// action), clearing the `psess:` set and every member's `prt:`/`psd:` keys in one - /// transaction over the platform keyspace. + /// transaction over the platform keyspace — then advance the admin's token epoch, so + /// outstanding platform **access** tokens die with the refresh sessions rather than + /// working on to expiry. Bumped after the sweep so a failed sweep leaves the operation + /// visibly incomplete instead of reading as done while the sessions live on. The + /// dashboard revoke-all bumps its plane's epoch the same way. /// /// # Errors /// @@ -283,7 +287,11 @@ impl PlatformAuthService { pub async fn revoke_all_platform_sessions(&self, admin_id: &str) -> Result<(), AuthError> { self.session_store .revoke_all(SessionKind::Platform, admin_id) - .await + .await?; + self.session_store + .bump_epoch(SessionKind::Platform, admin_id) + .await?; + Ok(()) } /// The hashed brute-force identifier for a platform login: `hmac_sha256("platform:{email}")` @@ -849,6 +857,14 @@ mod tests { let Ok(PlatformLoginResult::Success(first)) = first_login else { return }; let second_login = svc.login("all@admin.io", "pw", "5.6.7.8", "agent").await; let Ok(PlatformLoginResult::Success(second)) = second_login else { return }; + // Before the revoke, the first login's ACCESS token verifies. + assert!( + h.engine + .tokens() + .verify_platform_access(&first.access_token) + .await + .is_ok() + ); assert!(svc.revoke_all_platform_sessions(&id).await.is_ok()); assert!(matches!( svc.refresh(&first.refresh_token, "1.2.3.4", "agent").await, @@ -858,6 +874,23 @@ mod tests { svc.refresh(&second.refresh_token, "5.6.7.8", "agent").await, Err(AuthError::RefreshTokenInvalid) )); + // The outstanding ACCESS tokens die with the refresh sessions: revoke-all bumps the + // platform token epoch, and every token stamped below it stops verifying. Without + // the bump, "log out everywhere" left every access token working to expiry. + assert!( + h.engine + .tokens() + .verify_platform_access(&first.access_token) + .await + .is_err() + ); + assert!( + h.engine + .tokens() + .verify_platform_access(&second.access_token) + .await + .is_err() + ); } #[tokio::test] From 543b7990ff193175283a9e67ad29920c7cc2c934 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:17:08 -0300 Subject: [PATCH 091/122] fix(core, axum): close three auth bypasses found by an independent audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a blind audit — four Fable agents with no knowledge of this work, two per library, each auditing a disjoint half. Every finding was re-verified against the code before acting. **The MFA lockout was resettable by the attacker at will.** Issuing an MFA temp token cleared the per-user challenge brute-force counter, on the reasoning that a fresh login restarts the MFA budget. But password possession is exactly the attacker's assumed capability in the threat model the second factor exists to cover, so the loop `login → five wrong codes → login` reset the budget forever: the per-account lockout never engaged and only the per-IP limiter remained, which a distributed caller sidesteps. The counter is now cleared by exactly one event — a successful challenge. `MfaTokenSupport` loses the brute-force store and identifier key it only held for that reset; the counter is owned entirely by `MfaService` now. **OAuth sign-in skipped the account-status and email-verification gates.** `finish_oauth_login` went straight to `issue_tokens`. Every other credential flow gates — login, the MFA challenge, both reset steps, the platform login — so a BANNED account holding a linked provider identity walked straight back in. Ban is the primary account kill switch; a flow that ignores it makes it advisory. Both gates now run before the MFA branch, so a blocked account cannot obtain even the intermediate temp token. **The trusted-proxy rate-limit key trusted the client's own address.** `ClientIpSource::TrustedForwardedFor` is documented as trusting the *last* `X-Forwarded-For` entry, but `SmartIpKeyExtractor` takes the first parseable one — and a conforming proxy appends, so the leftmost entry is whatever the client sent. An operator following the docs got limits an attacker evaporates by rotating the header (fresh key per request) and weaponises by spoofing a victim's address (targeted 429). It also honoured `X-Real-IP` and `Forwarded`, which a proxy that manages only `X-Forwarded-For` makes no promise about. Replaced with `RightmostForwardedIpKeyExtractor`: rightmost entry, those two headers ignored, peer-address fallback so a missing header degrades to `PeerAddr` instead of 429-ing every caller. Coverage stays at 100% lines and 100% functions. --- crates/bymax-auth-axum/src/rate_limit.rs | 156 +++++++++++++++++- crates/bymax-auth-core/src/engine/builder.rs | 10 +- crates/bymax-auth-core/src/services/oauth.rs | 123 ++++++++++++++ .../src/services/token_manager.rs | 89 +++++----- 4 files changed, 315 insertions(+), 63 deletions(-) diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index 2a9482d..0d17aa5 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -8,6 +8,7 @@ //! `@Throttle(...)` per handler. The limiter keys on the client IP, derived per the //! configured trusted-proxy strategy ([`crate::state::ClientIpSource`]). +use std::net::IpAddr; use std::sync::Arc; use axum::body::Body; @@ -17,11 +18,66 @@ use governor::middleware::NoOpMiddleware; use http::Response; use tower_governor::GovernorError; use tower_governor::governor::{GovernorConfig, GovernorConfigBuilder}; -use tower_governor::key_extractor::{PeerIpKeyExtractor, SmartIpKeyExtractor}; +use tower_governor::key_extractor::{KeyExtractor, PeerIpKeyExtractor}; use crate::response::error_response; use crate::state::ClientIpSource; +/// A [`KeyExtractor`] that reads the **rightmost** `X-Forwarded-For` entry, falling back to +/// the peer socket address. +/// +/// This replaces `tower_governor`'s `SmartIpKeyExtractor`, which takes the **leftmost** +/// parseable entry and additionally honours `X-Real-IP` and `Forwarded`. A conforming proxy +/// *appends* the address it observed, so the leftmost entry is whatever the client itself +/// sent: an attacker rotating `X-Forwarded-For: ` gets a fresh limiter key per +/// request and every per-route limit evaporates, while spoofing a victim's address exhausts +/// that victim's bucket. `X-Real-IP` and `Forwarded` are ignored entirely — a proxy that +/// appends to `X-Forwarded-For` gives no such guarantee for headers it does not manage. +/// +/// The rightmost entry is the one the *nearest* trusted hop wrote, which is the strongest +/// claim available without a configured trusted-proxy CIDR set. With exactly one proxy in +/// front — the deployment [`ClientIpSource::TrustedForwardedFor`] documents — it is the real +/// client. With N proxies it is the Nth-from-the-client hop: still unforgeable, still a +/// stable key, just coarser. +/// +/// A malformed or absent header falls back to the peer address rather than failing the +/// request, so a missing header degrades to the [`ClientIpSource::PeerAddr`] behaviour +/// instead of 429-ing every caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RightmostForwardedIpKeyExtractor; + +impl KeyExtractor for RightmostForwardedIpKeyExtractor { + type Key = IpAddr; + + fn extract(&self, req: &http::Request) -> Result { + let forwarded = req + .headers() + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(rightmost_forwarded_ip); + + forwarded + .or_else(|| { + req.extensions() + .get::>() + .map(|info| info.0.ip()) + }) + .ok_or(GovernorError::UnableToExtractKey) + } +} + +/// The last parseable IP in a comma-separated `X-Forwarded-For` value. +/// +/// Scans from the right so the first parseable address found is the one the nearest hop +/// appended. Returns `None` when no entry parses, which sends the caller to the peer-address +/// fallback. +fn rightmost_forwarded_ip(header: &str) -> Option { + header + .split(',') + .rev() + .find_map(|entry| entry.trim().parse::().ok()) +} + /// One named edge limit: `burst` requests, replenished over `per_seconds`. Modeled as /// governor's quota — a burst bucket of `burst` cells that refills the whole bucket over /// the window (one cell every `per_seconds / burst` seconds). @@ -136,8 +192,8 @@ impl Default for RateLimitConfig { pub(crate) enum GovernorConfigKind { /// Peer-socket-IP keyed (never reads `X-Forwarded-For`) — the secure default. Peer(Arc>), - /// `X-Forwarded-For`/`X-Real-IP`/`Forwarded` keyed, for a trusted-proxy deployment. - Smart(Arc>), + /// Keyed on the rightmost `X-Forwarded-For` entry, for a trusted-proxy deployment. + Smart(Arc>), } /// Build a per-route governor config for `limit` under the configured `ip_source`. @@ -162,7 +218,7 @@ pub(crate) fn build_governor_config( ClientIpSource::TrustedForwardedFor => GovernorConfigBuilder::default() .per_second(per_second) .burst_size(burst) - .key_extractor(SmartIpKeyExtractor) + .key_extractor(RightmostForwardedIpKeyExtractor) .finish() .map(|config| GovernorConfigKind::Smart(Arc::new(config))), } @@ -311,4 +367,96 @@ mod tests { assert_eq!(cfg.list_sessions, Some(RateLimit::new(30, 60))); assert_eq!(cfg.oauth_callback, Some(RateLimit::new(10, 60))); } + + /// Build a request carrying the given `X-Forwarded-For` value and peer address. + fn req_with(xff: Option<&str>, peer: &str) -> http::Request<()> { + let mut builder = http::Request::builder().uri("/"); + if let Some(value) = xff { + builder = builder.header("x-forwarded-for", value); + } + let mut req = builder.body(()).unwrap_or_default(); + if let Ok(addr) = peer.parse::() { + req.extensions_mut() + .insert(axum::extract::ConnectInfo(addr)); + } + req + } + + #[test] + fn the_forwarded_extractor_keys_on_the_rightmost_entry() { + // A conforming proxy APPENDS the address it observed, so the leftmost entry is + // whatever the client itself sent. Keying on it — which `SmartIpKeyExtractor` does — + // lets an attacker rotate `X-Forwarded-For` for a fresh limiter key per request, + // evaporating every per-route limit; and lets them spoof a victim's address to + // exhaust that victim's bucket. The rightmost entry is the one the nearest trusted + // hop wrote. + let extractor = RightmostForwardedIpKeyExtractor; + + // Attacker-supplied junk on the left, the proxy's observation on the right. + let req = req_with(Some("1.1.1.1, 2.2.2.2, 203.0.113.9"), "10.0.0.1:443"); + assert_eq!( + extractor.extract(&req).ok(), + Some( + "203.0.113.9" + .parse::() + .unwrap_or(IpAddr::from([0, 0, 0, 0])) + ) + ); + + // Two requests whose ONLY difference is the spoofable left-hand side must share a key, + // or the limit is per-attacker-choice rather than per-client. + let spoof_a = req_with(Some("9.9.9.9, 203.0.113.9"), "10.0.0.1:443"); + let spoof_b = req_with(Some("8.8.8.8, 203.0.113.9"), "10.0.0.1:443"); + assert_eq!( + extractor.extract(&spoof_a).ok(), + extractor.extract(&spoof_b).ok() + ); + } + + #[test] + fn the_forwarded_extractor_falls_back_to_the_peer_address() { + // No header, an unparseable header, and an empty header all degrade to the peer + // address — the `PeerAddr` behaviour — rather than failing the request, which would + // 429 every caller behind a proxy that does not set the header. + let extractor = RightmostForwardedIpKeyExtractor; + let peer = "198.51.100.7" + .parse::() + .unwrap_or(IpAddr::from([0, 0, 0, 0])); + + for header in [None, Some("not-an-ip"), Some(""), Some(" , ")] { + let req = req_with(header, "198.51.100.7:443"); + assert_eq!( + extractor.extract(&req).ok(), + Some(peer), + "header {header:?}" + ); + } + } + + #[test] + fn the_forwarded_extractor_ignores_x_real_ip_and_forwarded() { + // `SmartIpKeyExtractor` also honours `X-Real-IP` and `Forwarded`. A proxy that appends + // to `X-Forwarded-For` gives no guarantee about headers it does not manage, so reading + // them reopens the same spoofing hole through a different name. + let extractor = RightmostForwardedIpKeyExtractor; + let mut req = http::Request::builder() + .uri("/") + .header("x-real-ip", "1.2.3.4") + .header("forwarded", "for=5.6.7.8") + .body(()) + .unwrap_or_default(); + if let Ok(addr) = "198.51.100.7:443".parse::() { + req.extensions_mut() + .insert(axum::extract::ConnectInfo(addr)); + } + + assert_eq!( + extractor.extract(&req).ok(), + Some( + "198.51.100.7" + .parse::() + .unwrap_or(IpAddr::from([0, 0, 0, 0])) + ) + ); + } } diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 0575226..6e853a6 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -421,13 +421,9 @@ impl AuthEngineBuilder { // challenge token planted at login is store-backed and brute-force-capped. #[cfg(feature = "mfa")] let tokens = match &mfa_store { - Some(store) => { - tokens.with_mfa_support(crate::services::token_manager::MfaTokenSupport::new( - store.clone(), - brute_force_store.clone(), - config.hmac_key(), - )) - } + Some(store) => tokens.with_mfa_support( + crate::services::token_manager::MfaTokenSupport::new(store.clone()), + ), None => tokens, }; let tokens = Arc::new(tokens); diff --git a/crates/bymax-auth-core/src/services/oauth.rs b/crates/bymax-auth-core/src/services/oauth.rs index 79d640c..3847332 100644 --- a/crates/bymax-auth-core/src/services/oauth.rs +++ b/crates/bymax-auth-core/src/services/oauth.rs @@ -320,6 +320,22 @@ impl AuthEngine { ctx: &RequestContext, hook_ctx: HookContext, ) -> Result { + // Status gate. Every credential flow in this engine runs it — password login, the MFA + // challenge, both password-reset steps, the platform login — and OAuth was the one + // that did not, so a BANNED or SUSPENDED account holding a linked provider identity + // walked straight back in. Ban is the primary account kill switch; a flow that + // ignores it makes it advisory. Run before the MFA branch so a blocked account cannot + // even obtain a temp token. `nest-auth` gates the same point. + self.assert_user_not_blocked(&user.status)?; + + // Email-verification gate, on the same footing as password login: when a deployment + // requires a verified address, an OAuth identity does not substitute for one. The + // create path records what the provider actually asserted, so an unverified provider + // profile stays unverified here rather than being promoted by the act of signing in. + if self.config().config().email_verification.required && !user.email_verified { + return Err(AuthError::EmailNotVerified); + } + if user.mfa_enabled { let mfa_temp_token = self .tokens() @@ -1243,6 +1259,9 @@ mod tests { let stores = Arc::new(InMemoryStores::new()); let mut cfg = base_config(); cfg.controllers.oauth = true; + // Verification is not REQUIRED here, so the sign-in completes and the stored record is + // observable. The sibling test below covers the required case. + cfg.email_verification.required = false; let engine = AuthEngine::builder() .config(cfg) .environment(Environment::Test) @@ -1271,6 +1290,110 @@ mod tests { ); } + #[tokio::test] + async fn oauth_refuses_an_unverified_address_when_verification_is_required() { + // An OAuth identity is not a substitute for a proven mailbox. When a deployment + // requires verification, signing in through a provider that reports the address as + // unverified must fail exactly as password login fails — otherwise the act of signing + // in promotes the address and the deployment's "this email is proven" invariant is + // false for every OAuth account. + let users = Arc::new(InMemoryUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let mut cfg = base_config(); + cfg.controllers.oauth = true; + cfg.email_verification.required = true; + let engine = AuthEngine::builder() + .config(cfg) + .environment(Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores.clone()) + .hooks(Arc::new(DecisionHook(OAuthLoginResult::Create))) + .oauth_provider(Arc::new(crate::testing::MockOAuthProvider::unverified( + "google", + ))) + .oauth_state_store(stores.clone()) + .build(); + let Ok(engine) = engine else { return }; + + let url = engine.oauth_initiate("google", "t1").await; + let Ok(url) = url else { return }; + let state = extract_query_param(&url, "state").unwrap_or_default(); + let outcome = engine + .oauth_callback("google", "auth-code", &state, &ctx()) + .await; + + assert!( + matches!(outcome, Err(AuthError::EmailNotVerified)), + "expected EmailNotVerified, got {outcome:?}" + ); + } + + #[tokio::test] + async fn oauth_honours_the_status_and_email_verification_gates() { + // Every credential flow gates on account status — password login, the MFA challenge, + // both reset steps, the platform login — and OAuth was the one that did not, so a + // BANNED account holding a linked provider identity walked straight back in. Ban is + // the primary account kill switch; a flow that ignores it makes it advisory. + let users = Arc::new(InMemoryUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let mut cfg = base_config(); + cfg.controllers.oauth = true; + let engine = AuthEngine::builder() + .config(cfg) + .environment(Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores.clone()) + .hooks(Arc::new(DecisionHook(OAuthLoginResult::Create))) + .oauth_provider(Arc::new(crate::testing::MockOAuthProvider::new("google"))) + .oauth_state_store(stores.clone()) + .build(); + let Ok(engine) = engine else { return }; + + // First sign-in creates the account and succeeds. + let url = engine.oauth_initiate("google", "t1").await; + let Ok(url) = url else { return }; + let state = extract_query_param(&url, "state").unwrap_or_default(); + assert!(matches!( + engine + .oauth_callback("google", "auth-code", &state, &ctx()) + .await, + Ok(OAuthOutcome::Authenticated(_)) + )); + + // Ban the account, then sign in again through the same provider identity. + let found = users.find_by_email("mock@example.com", "t1").await; + let Ok(Some(user)) = found else { return }; + assert!( + crate::traits::UserRepository::update_status(users.as_ref(), &user.id, "BANNED") + .await + .is_ok() + ); + + let mut linking_cfg = base_config(); + linking_cfg.controllers.oauth = true; + let linking = AuthEngine::builder() + .config(linking_cfg) + .environment(Environment::Test) + .user_repository(users.clone()) + .redis_stores(stores.clone()) + .hooks(Arc::new(DecisionHook(OAuthLoginResult::Link))) + .oauth_provider(Arc::new(crate::testing::MockOAuthProvider::new("google"))) + .oauth_state_store(stores.clone()) + .build(); + let Ok(linking) = linking else { return }; + + let url = linking.oauth_initiate("google", "t1").await; + let Ok(url) = url else { return }; + let state = extract_query_param(&url, "state").unwrap_or_default(); + let banned = linking + .oauth_callback("google", "auth-code", &state, &ctx()) + .await; + assert!( + matches!(banned, Err(AuthError::AccountBanned)), + "a banned account must not complete an OAuth sign-in, got {banned:?}" + ); + } + #[test] fn name_or_local_part_prefers_the_profile_name() { // The profile name wins when present; otherwise the email local-part is used. diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 6c09cd1..e76ef22 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -40,42 +40,25 @@ pub struct MfaTempVerified { pub jti: String, } -/// The collaborators the MFA temp-token methods need beyond JWT signing: the single-use -/// `mfa:` marker store and the brute-force store/key for the per-user challenge counter -/// reset. Held as `Option` on the token manager so a build without a wired MFA store still -/// issues a (sign-only) challenge token; the store-backed single-use path engages only when -/// the support is present. +/// The collaborator the MFA temp-token methods need beyond JWT signing: the single-use +/// `mfa:` marker store. Held as `Option` on the token manager so a build without a wired MFA +/// store still issues a (sign-only) challenge token; the store-backed single-use path engages +/// only when the support is present. +/// +/// It once also carried the brute-force store and identifier key, to clear the per-user +/// challenge counter on every issuance. That reset is gone — it made the per-account MFA +/// lockout unreachable for an attacker who holds the password — so the counter is owned +/// entirely by `MfaService`, which clears it on a successful challenge and nowhere else. #[cfg(feature = "mfa")] pub(crate) struct MfaTokenSupport { store: std::sync::Arc, - brute_force: std::sync::Arc, - challenge_hmac_key: zeroize::Zeroizing<[u8; 64]>, } #[cfg(feature = "mfa")] impl MfaTokenSupport { - /// Assemble the support bundle from the MFA store, the brute-force store, and the engine's - /// derived identifier-hashing key (copied into a zeroizing buffer). - pub(crate) fn new( - store: std::sync::Arc, - brute_force: std::sync::Arc, - hmac_key: &[u8; 64], - ) -> Self { - Self { - store, - brute_force, - challenge_hmac_key: zeroize::Zeroizing::new(*hmac_key), - } - } - - /// The hashed brute-force identifier for the per-user MFA-challenge counter - /// (`hmac_sha256("challenge:{user_id}")`, hex). Namespaced as `challenge:` so it is - /// isolated from the `disable:` counter the management ops use (§7.5.3). - fn challenge_bf_id(&self, user_id: &str) -> String { - crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( - self.challenge_hmac_key.as_ref(), - format!("challenge:{user_id}").as_bytes(), - )) + /// Assemble the support bundle from the MFA store. + pub(crate) fn new(store: std::sync::Arc) -> Self { + Self { store } } } @@ -667,15 +650,22 @@ impl TokenManagerService { } /// Issue a short-lived MFA temp token bridging the password step and the second factor. - /// When the single-use support is wired this signs the challenge JWT, plants the - /// single-use `mfa:{sha256(jti)}` marker (300 s), and resets the per-user MFA-challenge - /// brute-force counter (a fresh login restarts the challenge budget; §7.3.5). Without the - /// support it falls back to signing only. + /// When the single-use support is wired this signs the challenge JWT and plants the + /// single-use `mfa:{sha256(jti)}` marker (300 s). Without the support it falls back to + /// signing only. + /// + /// The per-user MFA-challenge brute-force counter is deliberately **not** reset here. It + /// used to be, on the reasoning that a fresh login proves renewed password possession — + /// but password possession is exactly the attacker's assumed capability in the threat + /// model the second factor exists to cover. Resetting on every issuance let that attacker + /// loop `login → five wrong codes → login` forever, so the per-account lockout never + /// engaged and the only remaining control was the per-IP rate limit, which a distributed + /// caller sidesteps. Exactly one event clears it: a SUCCESSFUL challenge. /// /// # Errors /// /// Returns [`AuthError::Internal`] if signing fails (unreachable), or a store - /// [`AuthError`] if planting the marker or resetting the counter fails. + /// [`AuthError`] if planting the marker fails. #[cfg(feature = "mfa")] pub async fn issue_mfa_temp_token( &self, @@ -692,14 +682,6 @@ impl TokenManagerService { MFA_TEMP_TOKEN_TTL_SECONDS.unsigned_abs(), ) .await?; - // A fresh login proves renewed password possession, so the challenge counter - // restarts from zero. The `disable:` counter is a separate namespace and is left - // untouched, so a pre-auth attacker can neither lock out nor clear the - // authenticated user's management-op budget. - support - .brute_force - .reset(&support.challenge_bf_id(user_id)) - .await?; } Ok(token) } @@ -1461,11 +1443,10 @@ mod tests { #[cfg(feature = "mfa")] fn service_with_mfa(store: Arc) -> TokenManagerService { - // A token manager whose MFA support is backed by the in-memory stores (which satisfy - // both the MFA-marker and brute-force seams), under a fixed identifier-hashing key. - let brute_force: Arc = store.clone(); + // A token manager whose MFA support is backed by the in-memory store satisfying the + // MFA-marker seam. The brute-force counter is no longer this type's business. let mfa_store: Arc = store.clone(); - let support = MfaTokenSupport::new(mfa_store, brute_force, &[7u8; 64]); + let support = MfaTokenSupport::new(mfa_store); TokenManagerService::new( key(), Vec::new(), @@ -1550,10 +1531,14 @@ mod tests { #[cfg(feature = "mfa")] #[tokio::test] - async fn issue_resets_only_the_challenge_brute_force_namespace() { - // Issuing a fresh temp token clears the `challenge:` counter (a fresh login restarts - // the MFA budget) while leaving the `disable:` counter untouched, so the two - // namespaces are isolated. + async fn issuing_a_temp_token_clears_no_brute_force_counter() { + // Issuing a fresh temp token used to clear the `challenge:` counter, on the reasoning + // that a fresh login restarts the MFA budget. But password possession is exactly the + // attacker's assumed capability in the threat model the second factor covers, so that + // let them loop `login -> five wrong codes -> login` forever: the per-account lockout + // never engaged and only the per-IP limit remained, which a distributed caller + // sidesteps. Neither namespace is cleared here now; a SUCCESSFUL challenge is the one + // event that clears the challenge counter. let store = Arc::new(InMemoryStores::new()); let svc = service_with_mfa(store.clone()); let bf: Arc = store.clone(); @@ -1573,13 +1558,13 @@ mod tests { } assert!(matches!(bf.is_locked(&challenge_id, 5).await, Ok(true))); assert!(matches!(bf.is_locked(&disable_id, 5).await, Ok(true))); - // Issuing a token resets the challenge counter only. + // Issuing a token leaves BOTH counters standing. assert!( svc.issue_mfa_temp_token("u1", MfaContext::Dashboard) .await .is_ok() ); - assert!(matches!(bf.is_locked(&challenge_id, 5).await, Ok(false))); + assert!(matches!(bf.is_locked(&challenge_id, 5).await, Ok(true))); assert!(matches!(bf.is_locked(&disable_id, 5).await, Ok(true))); } From 6f0ad731e060405cbbdc7e14f995fc628deab733 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:40:44 -0300 Subject: [PATCH 092/122] fix(redis): stop a revoked session coming back through its predecessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grace pointer is keyed by the OLD refresh hash, so `revoke_session` — which acts on the NEW hash — can neither find it nor delete it, and `revoke_all_except_current` iterates live members only. Replaying the predecessor inside the grace window therefore rebuilt a fresh full-lifetime session out of the very record the user had just revoked. Two independent auditors found this, one per library. The pointer now carries the hash of the session the rotation produced, and recovery is gated on that successor still being live. That is what the window is actually for: covering the one retry where the old token was consumed but the client never received the new one. Once the successor is gone there is nothing left to recover *to*, and the same rule closes the single-session revoke and the bulk sweep without either needing to know a hash it cannot compute. A consumed token whose successor died now falls through to the reuse check, which is the truthful classification of what it is. The stored form becomes `{successorHash}:{session JSON}`, split on the FIRST colon — the hash is hex and the record is JSON, so neither can contain one before the separator, and a fixed width would silently mis-split any hash that is not sha256-hex (the integration tests use short ones, and caught exactly that in the first cut). `nest-auth` carries the byte-identical script. The store-side family check is kept rather than folded into the probe: it covers a race the probe cannot see, where `revoke_family` enumerates the index and then deletes it, leaving a session created in between alive with its family already gone. That state is now reconstructed directly in a test instead of being reached incidentally. Also fixed here, both found by CI rather than by the local gates: - `MfaConfig` gained `previous_encryption_keys` without the two examples being updated; they are a separate cargo workspace and no local gate builds them. - `previous_mfa_encryption_keys` lacked the `#[cfg(feature = "mfa")]` its siblings carry, so the no-default-features leg of the feature matrix failed on a dead-code error. I installed cargo-hack and now run the full matrix, the crypto/umbrella feature legs, the examples build+clippy, and the rustdoc gate locally — the four CI legs my local chain was not covering. Coverage stays at 100% lines and 100% functions. --- crates/bymax-auth-core/src/config/validate.rs | 2 + .../src/lua/refresh_rotate.lua | 32 ++++- crates/bymax-auth-redis/src/stores/session.rs | 8 +- crates/bymax-auth-redis/tests/redis_stores.rs | 115 +++++++++++++++++- examples/axum-mfa/src/main.rs | 4 + examples/bymax-live-auth/src/main.rs | 4 + 6 files changed, 152 insertions(+), 13 deletions(-) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 0344bc9..943ea9d 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -142,6 +142,8 @@ impl ResolvedConfig { /// /// Decrypt-only: a stored TOTP secret records no key identifier, so without these a change /// of `mfa.encryption_key` makes every stored secret undecryptable at once. + #[cfg(feature = "mfa")] + #[must_use] pub(crate) fn previous_mfa_encryption_keys(&self) -> Vec> { self.config .mfa diff --git a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua index f1357bf..f256b81 100644 --- a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua +++ b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua @@ -11,9 +11,18 @@ -- 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[4] = family id of the presented session ('' means "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 +-- ARGV[7] = the live-session key prefix, namespace included, for the successor probe +-- +-- The grace pointer stores `{successorHash}:{session JSON}` — the hash of the session the +-- rotation produced, then a colon, then the record (split on the FIRST colon). Recovery is gated on that successor still +-- being live, because the pointer exists for exactly one purpose: to cover the retry where the +-- old token was consumed but the client never received the new one. Once the successor is gone +-- (revoked from the session list, or swept by "log out everywhere") there is nothing left to +-- recover *to*, and honouring the pointer would rebuild a full-lifetime session out of the very +-- record the user just revoked. -- -- The script never decodes a stored record: every JSON value it touches is handed back to the -- caller and parsed there, by a real parser rather than Lua's `cjson`. That keeps it byte-for-byte @@ -36,12 +45,12 @@ if old then -- A zero grace window means no grace recovery: skip the pointer rather than issue an -- `EX 0` SET, which Redis rejects. if tonumber(ARGV[3]) > 0 then - redis.call('SET', KEYS[3], ARGV[1], 'EX', ARGV[3]) + redis.call('SET', KEYS[3], ARGV[6] .. ':' .. 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. + -- post-grace replay is detected as a reuse and the whole lineage stays revocable. A 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]) @@ -57,7 +66,20 @@ if grace then -- session on every request for the whole window. It exists to cover the one retry where the -- old token was consumed but the client never received the new one. redis.call('DEL', KEYS[3]) - return 'GRACE:' .. grace + -- `{successorHash}:{json}`. Recovery only makes sense while the session the rotation + -- produced is still live: once it has been revoked, the retry this window exists for has + -- nothing to land on. Falling through reaches the reuse check below, which is the correct + -- reading of a consumed token presented after its successor died. + -- Split on the FIRST colon rather than a fixed width: the hash is hex and the record is + -- JSON, so neither can contain one before the separator, and a fixed width would silently + -- mis-split any hash that is not exactly sha256-hex. + local sep = string.find(grace, ':', 1, true) + if sep then + local successor = string.sub(grace, 1, sep - 1) + if redis.call('EXISTS', ARGV[7] .. ':' .. successor) == 1 then + return 'GRACE:' .. string.sub(grace, sep + 1) + end + end 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. diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index ce7dbf9..9fbca64 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -225,9 +225,12 @@ impl RedisStores { 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. + // family 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; + // The namespaced live-session prefix, so the script's grace branch can probe whether + // the session a rotation produced is still alive before honouring the pointer. + let rt_prefix = format!("{}:{}", keys.namespace(), prefixes.rt.as_str()); let fam_key = keys.key(prefixes.fam, family); let new_json = serde_json::to_string(&rotation.new_record)?; @@ -245,6 +248,7 @@ impl RedisStores { .arg(family) .arg(&rotation.old_hash) .arg(&rotation.new_hash) + .arg(&rt_prefix) .invoke_async(&mut conn) .await?; diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index d78c58c..f710fa9 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -296,20 +296,123 @@ async fn a_grace_pointer_cannot_resurrect_a_revoked_family() { )); assert!(stores.revoke_family(kind, "fam-ku").await.is_ok()); - // The still-live sibling pointer must NOT recover a session: its family is gone, so the - // rotation reports the token invalid instead of handing back the revoked lineage. + // The still-live sibling pointer must NOT recover a session. Two independent guards now + // refuse it: the successor probe inside the script (k3 was deleted with the family, so the + // pointer has nothing to recover *to*) and the family-liveness check above it. Because the + // script now falls through the dead-successor pointer to the reuse check, and `cf:k2` is + // still planted, the outcome is the truthful classification — k2 is a consumed token being + // replayed — rather than the flat `Invalid` the family check alone used to produce. What + // the guard cares about is that no session comes back, which both spellings satisfy. assert!( redis.ttl("auth:rp:k2").await > 0, "the pointer itself survives" ); - assert!(matches!( - stores.rotate(kind, &rotation("k2", "kY", "ku")).await, - Ok(RotateOutcome::Invalid) - )); + let replayed = stores.rotate(kind, &rotation("k2", "kY", "ku")).await; + assert!( + matches!( + replayed, + Ok(RotateOutcome::Invalid) | Ok(RotateOutcome::Reused(_)) + ), + "a pointer whose successor is gone must never recover, got {replayed:?}" + ); assert!(matches!(stores.find_session(kind, "kY").await, Ok(None))); assert!(matches!(stores.list_sessions(kind, "ku").await, Ok(v) if v.is_empty())); } +#[tokio::test] +async fn a_revoked_session_cannot_be_rebuilt_from_its_predecessors_grace_pointer() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // SECURITY REGRESSION GUARD. A grace pointer is keyed by the OLD hash, so revoking a + // session — which acts on the NEW hash — cannot find and delete it. Before the successor + // probe, replaying the predecessor inside the (default 30 s) window rebuilt a fresh + // FULL-lifetime session out of the very record the user had just revoked: "log out this + // device" undone by the device's own consumed token. + assert!( + stores + .create_session(kind, "g1", &record("gu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores.rotate(kind, &rotation("g1", "g2", "gu")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!( + redis.ttl("auth:rp:g1").await > 0, + "the predecessor pointer is live" + ); + + // While the successor is alive the pointer still works — the retry it exists for is the + // one where the client never received the new token. This is the control case: without it + // the test below would pass even if grace recovery were simply broken. + assert!(matches!( + stores.rotate(kind, &rotation("g1", "gA", "gu")).await, + Ok(RotateOutcome::Grace(r)) if r.user_id == "gu" + )); + + // Re-plant a pointer, then revoke the live session the way the session list does. + assert!(matches!( + stores.rotate(kind, &rotation("g2", "g3", "gu")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!(redis.ttl("auth:rp:g2").await > 0); + assert!(stores.revoke_session(kind, "gu", "g3").await.is_ok()); + + // The pointer survives (nothing knew to delete it) but must no longer recover: its + // successor g3 is gone. + assert!( + redis.ttl("auth:rp:g2").await > 0, + "the pointer itself survives the revoke" + ); + let replayed = stores.rotate(kind, &rotation("g2", "gZ", "gu")).await; + assert!( + !matches!(replayed, Ok(RotateOutcome::Grace(_))), + "a revoked session must not come back through its predecessor's pointer, got {replayed:?}" + ); + assert!(matches!(stores.find_session(kind, "gZ").await, Ok(None))); +} + +#[tokio::test] +async fn a_grace_recovery_is_refused_when_the_family_index_is_gone() { + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { return }; + let kind = SessionKind::Dashboard; + + // The script's successor probe and this store-side family check are two independent + // guards, and the second is not redundant: `revoke_family` enumerates the index and then + // deletes it, so a session created between those two steps stays live while its family + // index is already gone. This reconstructs exactly that state — successor alive, family + // index deleted — which the probe alone would wave through. + assert!( + stores + .create_session(kind, "f1", &record("fu"), 3600) + .await + .is_ok() + ); + assert!(matches!( + stores.rotate(kind, &rotation("f1", "f2", "fu")).await, + Ok(RotateOutcome::Rotated(_)) + )); + assert!(matches!(stores.find_session(kind, "f2").await, Ok(Some(_)))); + + // Drop the family index only. The successor f2 is untouched and still live. + assert!(redis.del("auth:fam:fam-fu").await); + + let replayed = stores.rotate(kind, &rotation("f1", "fX", "fu")).await; + assert!( + matches!(replayed, Ok(RotateOutcome::Invalid)), + "a lineage whose family index is gone must not recover, got {replayed:?}" + ); + assert!(matches!(stores.find_session(kind, "fX").await, Ok(None))); +} + #[tokio::test] async fn token_epoch_defaults_to_zero_bumps_monotonically_and_is_keyspace_disjoint() { let Some(redis) = common::try_start().await else { diff --git a/examples/axum-mfa/src/main.rs b/examples/axum-mfa/src/main.rs index 5b41ceb..6cc6422 100644 --- a/examples/axum-mfa/src/main.rs +++ b/examples/axum-mfa/src/main.rs @@ -73,6 +73,10 @@ fn build_engine() -> Result> { config.controllers.mfa = true; config.mfa = Some(MfaConfig { encryption_key: SecretString::from(EXAMPLE_MFA_KEY_B64.to_owned()), + // No rotation in progress. A key retired by a rotation of `encryption_key` goes here, + // accepted for decryption only, so stored TOTP secrets keep opening while the + // rotation drains. + previous_encryption_keys: Vec::new(), issuer: "bymax-auth example".to_owned(), recovery_code_count: 8, totp_window: 1, diff --git a/examples/bymax-live-auth/src/main.rs b/examples/bymax-live-auth/src/main.rs index 43ae6bd..57e5a80 100644 --- a/examples/bymax-live-auth/src/main.rs +++ b/examples/bymax-live-auth/src/main.rs @@ -115,6 +115,10 @@ fn build_engine() -> Result> { encryption_key: SecretString::from( std::env::var("MFA_KEY").unwrap_or_else(|_| EXAMPLE_MFA_KEY_B64.to_owned()), ), + // No rotation in progress. A key retired by a rotation of `encryption_key` goes here, + // accepted for decryption only, so stored TOTP secrets keep opening while the + // rotation drains. + previous_encryption_keys: Vec::new(), issuer: "Bymax Live".to_owned(), recovery_code_count: 8, totp_window: 1, From 2f682e95e6a3e217f0cdccd89be0fdf105027535 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:09:43 -0300 Subject: [PATCH 093/122] fix(redis, core): store the OTP as a HASH, bound the lockout and grace knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The OTP record becomes a Redis HASH (`code`, `attempts`).** The verify script bumps the counter with `HINCRBY` instead of decoding JSON with `cjson`, re-encoding, and re-storing under the residual PTTL. The behaviour is the same here — the previous script was already atomic — but it now runs on any EVAL-capable backend, which is what lets `nest-auth` carry the identical script. Its version could not: the in-memory Redis its end-to-end tier runs against provides no `cjson`, so it had been computing the bump in the caller from a value read a round trip earlier. N concurrent wrong guesses there all read `attempts: 0` and all wrote back `1`, and the attempt ceiling could be exceeded by simply submitting in parallel. The `otp:` prefix is pinned in the shared wire contract, so the record shape had to move together. `HINCRBY` also leaves the key's TTL untouched, so the "a wrong guess must never extend the OTP lifetime" property now falls out of the operation rather than being restored by hand with `PX pttl`. The write is a pipelined transaction so the record never exists without its expiry. **`brute_force.window` of zero silently disabled the account lockout.** It reaches the store as `EXPIRE key 0`, which *deletes* the key: every failure counter was destroyed as it was created, the count never exceeded one, and the lockout never engaged while the config still read as enabled. Now rejected. `max_attempts` is bounded `1..=100` for the two opposite failures: `0` locks every account out permanently (a fresh counter already satisfies `count >= 0`), and a huge threshold disables the lockout as effectively as switching it off. **`jwt.refresh_grace_window` had only a relative bound.** "< refresh lifetime" lets a 6-day window through under a 7-day refresh. That window is the span in which an already-consumed refresh token still recovers a session, so it is precisely the replay window for a stolen one — it covers a client that rotated and never received the response, a retry measured in seconds. Capped at 300s. `nest-auth` enforces the identical bounds. Coverage stays at 100% lines and 100% functions. --- crates/bymax-auth-core/src/config/validate.rs | 85 +++++++++++++++++++ crates/bymax-auth-core/src/error.rs | 19 +++++ .../bymax-auth-redis/src/lua/otp_verify.lua | 50 ++++++----- crates/bymax-auth-redis/src/stores/otp.rs | 65 ++++++++------ 4 files changed, 165 insertions(+), 54 deletions(-) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 943ea9d..1468d4f 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -201,6 +201,11 @@ fn derive_hmac_key(secret: &str) -> SecretBox<[u8; 64]> { SecretBox::new(Box::new(key)) } +/// Hard ceiling on `jwt.refresh_grace_window`, in seconds (five minutes). Deliberately far +/// above the 30-second default and far below anything that could be mistaken for a session +/// policy. `nest-auth` enforces the identical bound. +const MAX_REFRESH_GRACE_WINDOW_SECS: u64 = 300; + impl AuthConfig { /// Resolve `secure_cookies`: the explicit value if set, otherwise `true` only in a /// production environment. @@ -269,6 +274,32 @@ impl AuthConfig { if grace >= lifetime { return Err(ConfigError::RefreshGraceTooLarge { grace, lifetime }); } + // The relative bound above is not enough on its own: a 6-day window under a 7-day + // refresh passes it. This window is the span in which an already-consumed refresh + // token still buys a session, so it is the replay window for a stolen one — it exists + // to cover a single network retry, measured in seconds, not a policy knob measured in + // days. `nest-auth` enforces the identical ceiling. + if grace > MAX_REFRESH_GRACE_WINDOW_SECS { + return Err(ConfigError::RefreshGraceCeiling { + got: grace, + max: MAX_REFRESH_GRACE_WINDOW_SECS, + }); + } + + // Rule 3c: the two values that decide whether the account lockout exists at all. + // `window` is handed to the store as the counter's EXPIRE, and Redis DELETES a key on + // `EXPIRE key 0` — a zero window destroys every failure counter as it is created, the + // count never exceeds one, and the lockout silently never engages while the config + // still reads as enabled. `max_attempts` of 0 is the opposite failure: a fresh counter + // already satisfies `count >= 0`, so every account is locked out permanently. + if self.brute_force.window.as_secs() == 0 { + return Err(ConfigError::BruteForceWindowInvalid); + } + if !(1..=100).contains(&self.brute_force.max_attempts) { + return Err(ConfigError::BruteForceAttemptsRange { + got: self.brute_force.max_attempts, + }); + } // Rule 4b: the access-token lifetime must fit inside the token-epoch retention window. // A longer-lived access token could outlive the stored epoch that revokes it, so @@ -1021,6 +1052,60 @@ mod tests { )); } + #[test] + fn the_grace_window_and_lockout_knobs_are_bounded() { + // The grace window is the span in which an ALREADY-CONSUMED refresh token still buys a + // session, so it is precisely the replay window for a stolen one. The relative bound + // ("< refresh lifetime") lets a 6-day window under a 7-day refresh through, which is a + // days-long replay window wearing the name of a network retry. + let mut wide = valid_config(); + wide.jwt.refresh_grace_window = std::time::Duration::from_secs(6 * 86_400); + wide.jwt.refresh_expires_in_days = 7; + assert!(matches!( + wide.validate(Environment::Test), + Err(ConfigError::RefreshGraceCeiling { max: 300, .. }) + )); + + // The edges hold: the default, the ceiling, and 0 (grace disabled outright). + for secs in [0u64, 30, 300] { + let mut ok = valid_config(); + ok.jwt.refresh_grace_window = std::time::Duration::from_secs(secs); + assert!( + ok.validate(Environment::Test).is_ok(), + "grace of {secs}s must be accepted" + ); + } + + // A zero brute-force window reaches the store as `EXPIRE key 0`, which DELETES the key: + // every failure counter is destroyed as it is created, the count never exceeds one, and + // the lockout silently never engages while the config still reads as enabled. + let mut no_window = valid_config(); + no_window.brute_force.window = std::time::Duration::from_secs(0); + assert!(matches!( + no_window.validate(Environment::Test), + Err(ConfigError::BruteForceWindowInvalid) + )); + + // Zero attempts is the opposite failure: a fresh counter already satisfies + // `count >= 0`, so every account is locked out permanently. A huge threshold disables + // the lockout as thoroughly as switching it off. + for got in [0u32, 101, 1_000_000] { + let mut bad = valid_config(); + bad.brute_force.max_attempts = got; + let refused = matches!( + bad.validate(Environment::Test), + Err(ConfigError::BruteForceAttemptsRange { got: reported }) if reported == got + ); + assert!(refused, "max_attempts of {got} must be refused"); + } + + for got in [1u32, 5, 100] { + let mut ok = valid_config(); + ok.brute_force.max_attempts = got; + assert!(ok.validate(Environment::Test).is_ok()); + } + } + #[cfg(feature = "scrypt")] #[test] fn the_scrypt_parameters_that_carry_the_memory_hardness_are_bounded() { diff --git a/crates/bymax-auth-core/src/error.rs b/crates/bymax-auth-core/src/error.rs index b7e1b62..ffc1196 100644 --- a/crates/bymax-auth-core/src/error.rs +++ b/crates/bymax-auth-core/src/error.rs @@ -35,6 +35,25 @@ pub enum ConfigError { /// The refresh-token lifetime, in seconds. lifetime: u64, }, + /// `jwt.refresh_grace_window` exceeds the absolute ceiling: it is the replay window for an + /// already-consumed refresh token, so it covers a network retry, not a session policy. + #[error("jwt.refresh_grace_window ({got}s) must be <= {max}s")] + RefreshGraceCeiling { + /// The configured grace window, in seconds. + got: u64, + /// The ceiling, in seconds. + max: u64, + }, + /// `brute_force.window` is zero, which makes the store delete each counter as it is + /// created (`EXPIRE key 0`) and silently disables the account lockout. + #[error("brute_force.window must be at least 1s (a zero window deletes the counter)")] + BruteForceWindowInvalid, + /// `brute_force.max_attempts` is outside the accepted `1..=100` range. + #[error("brute_force.max_attempts must be within 1..=100 (got {got})")] + BruteForceAttemptsRange { + /// The rejected threshold. + got: u32, + }, /// `jwt.refresh_expires_in_days` is zero (it must be a positive number of days). #[error("jwt.refresh_expires_in_days must be a positive value (got {got})")] RefreshLifetimeInvalid { diff --git a/crates/bymax-auth-redis/src/lua/otp_verify.lua b/crates/bymax-auth-redis/src/lua/otp_verify.lua index d5b77e2..615977f 100644 --- a/crates/bymax-auth-redis/src/lua/otp_verify.lua +++ b/crates/bymax-auth-redis/src/lua/otp_verify.lua @@ -1,45 +1,43 @@ -- otp_verify: attempt-bounded verify + consume (spec section 12.5.4). Makes --- "compare code, bump attempts, consume on success, lock on max" a single atomic step so --- concurrent guesses cannot race past the attempt ceiling. +-- "check the ceiling, compare the code, bump attempts, consume on success" a single atomic +-- step so concurrent guesses cannot race past the attempt ceiling. -- -- The plain compare here only decides the attempts bump and the consume; the AUTHORITATIVE --- constant-time comparison is re-done in Rust via `subtle` (spec section 17). The residual --- TTL is preserved on a wrong guess so an attacker cannot extend the OTP lifetime. +-- constant-time comparison is re-done by the caller (spec section 17). +-- +-- The record is a HASH (`code`, `attempts`) rather than a JSON string. `HINCRBY` bumps the +-- counter in place, which is what makes the whole step atomic without decoding anything — +-- and it leaves the key's TTL untouched, so a wrong guess can never extend the OTP lifetime. +-- The previous JSON form needed `cjson`, which is unavailable in the in-memory Redis the +-- nest-auth end-to-end tier runs against, and a decode-in-the-caller design cannot bump +-- atomically at all: N concurrent guesses each read the same counter and each wrote back +-- 1, so the ceiling could be exceeded arbitrarily by submitting in parallel. -- -- KEYS[1] = otp:{purpose}:{hmac(tenant:email)} -- ARGV[1] = submitted code -- ARGV[2] = max attempts -- -- Returns a two-element array { tag, code }: --- { "EXPIRED", "" } no record (TTL elapsed) +-- { "EXPIRED", "" } no record (TTL elapsed), or a record with no code field -- { "MAX", "" } the attempt ceiling was already reached (record consumed) -- { "PRESENT", storedCode } the record was present and under the ceiling. The record is --- consumed on a plain match and its attempts bumped (residual --- TTL preserved) on a plain mismatch; Rust re-compares --- constant-time to decide the returned outcome. -local raw = redis.call('GET', KEYS[1]) -if not raw then +-- consumed on a plain match and its attempts bumped on a plain +-- mismatch; the caller re-compares constant-time to decide the +-- returned outcome. +local code = redis.call('HGET', KEYS[1], 'code') +if not code then return { 'EXPIRED', '' } end -local record = cjson.decode(raw) -if record.attempts >= tonumber(ARGV[2]) then +local attempts = tonumber(redis.call('HGET', KEYS[1], 'attempts')) or 0 +if attempts >= tonumber(ARGV[2]) then redis.call('DEL', KEYS[1]) return { 'MAX', '' } end -if record.code == ARGV[1] then +if code == ARGV[1] then redis.call('DEL', KEYS[1]) else - record.attempts = record.attempts + 1 - local pttl = redis.call('PTTL', KEYS[1]) - if pttl > 0 then - -- Re-store with the bumped counter under the SAME residual TTL, so a wrong guess can - -- never extend the OTP lifetime. - redis.call('SET', KEYS[1], cjson.encode(record), 'PX', pttl) - else - -- The record exists but reports no positive residual TTL (it is at/past expiry). - -- Fail closed by consuming it rather than re-storing a key without a TTL, which would - -- breach the "every key carries a TTL" invariant. - redis.call('DEL', KEYS[1]) - end + -- In place, so the residual TTL is preserved: a wrong guess costs an attempt, never extra + -- lifetime. + redis.call('HINCRBY', KEYS[1], 'attempts', 1) end -return { 'PRESENT', record.code } +return { 'PRESENT', code } diff --git a/crates/bymax-auth-redis/src/stores/otp.rs b/crates/bymax-auth-redis/src/stores/otp.rs index 215c572..264309e 100644 --- a/crates/bymax-auth-redis/src/stores/otp.rs +++ b/crates/bymax-auth-redis/src/stores/otp.rs @@ -6,23 +6,12 @@ use async_trait::async_trait; use bymax_auth_core::traits::{OtpPurpose, OtpStore}; use bymax_auth_crypto::compare::constant_time_eq; use bymax_auth_types::AuthError; -use redis::AsyncCommands; -use serde::{Deserialize, Serialize}; use crate::error::RedisStoreError; use crate::keys::Prefix; use crate::pool::RedisStores; use crate::script; -/// The stored `otp:` record: the code plus the running failed-attempt counter. -#[derive(Serialize, Deserialize)] -struct OtpRecord { - /// The issued one-time code. - code: String, - /// Failed-verification attempts so far (bumped atomically by the Lua script). - attempts: u32, -} - /// The `otp_verify` reply tag for an absent record (TTL elapsed). const TAG_EXPIRED: &str = "EXPIRED"; /// The `otp_verify` reply tag when the attempt ceiling was already reached. @@ -68,13 +57,22 @@ impl RedisStores { let key = self .keys() .key(Prefix::Otp, &purpose_segment(purpose, identifier)); - let record = OtpRecord { - code: code.to_owned(), - attempts: 0, - }; - let json = serde_json::to_string(&record)?; + // A HASH of `code` + `attempts`, not a JSON string: the verify script bumps the + // counter with `HINCRBY`, which is what lets "check the ceiling, compare, bump or + // consume" be one atomic step without decoding anything — and leaves the TTL alone, so + // a wrong guess cannot extend the OTP's lifetime. Written in a transaction so the + // record never exists without its expiry. let mut conn = self.connection().await?; - conn.set_ex::<_, _, ()>(&key, json, ttl_secs).await?; + redis::pipe() + .atomic() + .hset(&key, "code", code) + .ignore() + .hset(&key, "attempts", 0) + .ignore() + .expire(&key, i64::try_from(ttl_secs).unwrap_or(i64::MAX)) + .ignore() + .query_async::<()>(&mut conn) + .await?; Ok(()) } @@ -203,16 +201,27 @@ mod tests { } #[test] - fn otp_record_serializes_code_and_attempts() { - // The stored record carries the code and a zeroed counter, the shape the Lua decodes. - let json = serde_json::to_string(&OtpRecord { - code: "123456".to_owned(), - attempts: 0, - }) - .unwrap_or_default(); - assert!(json.contains("\"code\":\"123456\"")); - assert!(json.contains("\"attempts\":0")); - let back: Result = serde_json::from_str(&json); - assert!(matches!(back, Ok(r) if r.attempts == 0)); + fn the_verify_script_bumps_in_place_and_never_extends_the_ttl() { + // Static guard on the shared script. The bump must be an in-place `HINCRBY`: computing + // it in the caller is the race the HASH form exists to close (N concurrent guesses each + // read the same counter and each write back 1, so the ceiling is exceeded by simply + // submitting in parallel), and re-writing the record would reset the TTL, letting a + // wrong guess buy extra OTP lifetime. The ceiling is checked BEFORE the comparison so + // an exhausted record cannot be probed further. + let source = include_str!("../lua/otp_verify.lua"); + assert!(source.contains("redis.call('HINCRBY', KEYS[1], 'attempts', 1)")); + // No `cjson` in the executable body — the comment block explains why it left, and the + // in-memory Redis the nest-auth end-to-end tier runs against does not provide it. + let body: String = source + .lines() + .filter(|line| !line.trim_start().starts_with("--")) + .collect::>() + .join("\n"); + assert!(!body.contains("cjson")); + let ceiling = source.find("attempts >= tonumber(ARGV[2])"); + let compare = source.find("code == ARGV[1]"); + assert!(matches!((ceiling, compare), (Some(c), Some(k)) if c < k)); + // A match consumes the record, so a correct code is single-use. + assert!(source.contains("redis.call('DEL', KEYS[1])")); } } From 07184156d401386a54a9d0ee0912d51dc25bd0ba Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:23:53 -0300 Subject: [PATCH 094/122] fix(core): bound totp_window by the verifier's clamp, size the marker by it too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mfa.totp_window` was validated against a ceiling of 10 while `totp::verify` clamps the drift window to 2, so a configured 10 read as "±5 minutes" and behaved as ±1 minute. The bound is now the clamp itself: a configured value always means what it says instead of being silently reduced. `nest-auth`, which clamped nothing at all and allowed the same 10, now matches on both counts. `anti_replay_ttl_seconds` derived the marker's lifetime from the *configured* window rather than the effective one. Over-reserving is harmless in itself, but it left the marker's TTL and the acceptance span describing different windows — the kind of drift that reads as a bug later, and the exact shape of the defect being fixed on the nest side (a marker that expires while the code it protects is still accepted). `MAX_VERIFY_WINDOW` is now public so both the validator and the service derive from one constant. The validator restates it as a literal on the `not(feature = "mfa")` leg, where the verifier is not compiled in; a test pins the two together, because a looser config bound than the verifier's clamp silently reduces a configured window and a tighter one refuses a value the verifier would honour. Found by the feature matrix, not by the local chain: the first cut referenced the constant unconditionally and only the `--no-default-features` leg noticed. Coverage stays at 100% lines and 100% functions. --- crates/bymax-auth-core/src/config/validate.rs | 36 +++++++++++++++++-- .../bymax-auth-core/src/services/mfa/mod.rs | 11 +++++- .../bymax-auth-core/src/services/mfa/tests.rs | 19 +++++++--- crates/bymax-auth-crypto/src/totp.rs | 2 +- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/bymax-auth-core/src/config/validate.rs b/crates/bymax-auth-core/src/config/validate.rs index 1468d4f..c249738 100644 --- a/crates/bymax-auth-core/src/config/validate.rs +++ b/crates/bymax-auth-core/src/config/validate.rs @@ -372,7 +372,15 @@ impl AuthConfig { // zero enrols an account with no way back if the authenticator is lost. Every sibling // security parameter carries a bound; these two decide whether MFA is worth anything. if let Some(mfa) = self.mfa.as_ref() { - if mfa.totp_window > 10 { + // The ceiling is the drift window the verifier actually applies, so a configured + // value always means what it says instead of being silently clamped. Without the + // `mfa` feature the verifier is not compiled in, so the constant is restated — + // the two are pinned together by a test in `bymax-auth-crypto`. + #[cfg(feature = "mfa")] + let max_window = u32::from(bymax_auth_crypto::totp::MAX_VERIFY_WINDOW); + #[cfg(not(feature = "mfa"))] + let max_window = 2u32; + if u32::from(mfa.totp_window) > max_window { return Err(ConfigError::TotpWindowRange { got: mfa.totp_window, valid: u16::from(mfa.totp_window) * 2 + 1, @@ -1017,9 +1025,22 @@ mod tests { }) )); + // The bound is the drift window the VERIFIER applies, so a configured value always + // means what it says. It used to be looser (10) than the verifier's clamp (2), which + // made `totp_window: 10` read as ±5 minutes in the config and behave as ±1 minute. + let mut over_clamp = valid_config(); + over_clamp.mfa = Some(MfaConfig { + totp_window: 3, + ..mfa_with_key(&key) + }); + assert!(matches!( + over_clamp.validate(Environment::Test), + Err(ConfigError::TotpWindowRange { got: 3, valid: 7 }) + )); + // The edges are accepted: 0 is a deliberate hardening (no tolerance at all), 10 is the // generous ceiling. A bound that refused a legitimate tolerance would be an outage. - for window in [0u8, 1, 10] { + for window in [0u8, 1, 2] { let mut ok = valid_config(); ok.mfa = Some(MfaConfig { totp_window: window, @@ -1052,6 +1073,17 @@ mod tests { )); } + #[cfg(feature = "mfa")] + #[test] + fn the_restated_window_ceiling_tracks_the_verifier() { + // `validate` restates the ceiling as a literal when the `mfa` feature is off, because + // the verifier that owns the constant is not compiled in then. The two must not drift: + // a looser config bound than the verifier's clamp means a configured window is + // silently reduced, and a tighter one refuses a value the verifier would honour. This + // is the pin — the only build that can see both is the one with the feature on. + assert_eq!(u32::from(bymax_auth_crypto::totp::MAX_VERIFY_WINDOW), 2); + } + #[test] fn the_grace_window_and_lockout_knobs_are_bounded() { // The grace window is the span in which an ALREADY-CONSUMED refresh token still buys a diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index ee4c318..dee4ee4 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -229,7 +229,16 @@ impl MfaService { /// while the code it protects is still accepted (which would re-open the replay window). A /// fixed literal would be wrong whenever `totp_window` is configured larger. fn anti_replay_ttl_seconds(&self) -> u64 { - let window = u64::from(self.totp_window); + // The CONFIGURED window is not necessarily the one in force: `totp::verify` clamps it + // to `MAX_VERIFY_WINDOW` so an oversized value cannot become a CPU-amplification + // vector. Sizing the marker from the unclamped value would over-reserve (harmless, but + // it makes the marker's TTL and the acceptance span disagree, which is exactly the + // kind of drift that later reads as a bug). Startup validation refuses anything above + // the clamp, so this only matters for a caller reaching the service directly. + let window = u64::from( + self.totp_window + .min(bymax_auth_crypto::totp::MAX_VERIFY_WINDOW), + ); (2 * window + 1) * TOTP_STEP_SECONDS } diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 0c3f101..8f4007d 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1792,13 +1792,22 @@ fn anti_replay_ttl_is_derived_from_the_window_and_scales() { assert_eq!(service.anti_replay_ttl_seconds(), max_window_secs_w2); assert!(service.anti_replay_ttl_seconds() >= max_window_secs_w2); - // It scales with the window: a wider window yields a strictly longer TTL, and a zero - // window collapses to a single step (the code is accepted at exactly one step). - service.totp_window = 4; - assert_eq!(service.anti_replay_ttl_seconds(), (2 * 4 + 1) * 30); - assert!(service.anti_replay_ttl_seconds() > max_window_secs_w2); + // It scales with the window, and a zero window collapses to a single step (the code is + // accepted at exactly one step). + service.totp_window = 1; + assert_eq!(service.anti_replay_ttl_seconds(), 3 * 30); + assert!(service.anti_replay_ttl_seconds() < max_window_secs_w2); service.totp_window = 0; assert_eq!(service.anti_replay_ttl_seconds(), 30); + + // A window past the verifier's clamp sizes the marker to the window ACTUALLY in force, + // not the configured one. `verify` clamps to MAX_VERIFY_WINDOW so an oversized value + // cannot become a CPU-amplification vector; deriving the TTL from the unclamped value + // would leave the marker's lifetime and the acceptance span disagreeing. Startup + // validation refuses anything above the clamp, so this only bites a caller reaching the + // service directly. + service.totp_window = 40; + assert_eq!(service.anti_replay_ttl_seconds(), max_window_secs_w2); } #[tokio::test] diff --git a/crates/bymax-auth-crypto/src/totp.rs b/crates/bymax-auth-crypto/src/totp.rs index 83e2ad7..9594e7c 100644 --- a/crates/bymax-auth-crypto/src/totp.rs +++ b/crates/bymax-auth-crypto/src/totp.rs @@ -20,7 +20,7 @@ const TOTP_DIGITS: u32 = 6; /// larger value is clamped to this, bounding the per-verification work to /// `2 * MAX_VERIFY_WINDOW + 1` HOTP computations so a misconfigured (or hostile) window /// cannot turn `verify` into a CPU-amplification vector. -const MAX_VERIFY_WINDOW: u8 = 2; +pub const MAX_VERIFY_WINDOW: u8 = 2; /// HMAC-SHA1 output length in bytes. const HMAC_SHA1_LEN: usize = 20; /// Upper-case hex alphabet for percent-encoding. From 000a29d848dfd9ed86f452fc917e4a32706f9d22 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:40:29 -0300 Subject: [PATCH 095/122] fix(core): a lost temp-token consume must not issue a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MfaStore::del_temp` returned `()`, so `consume_mfa_temp_token` could not tell "I removed the marker" from "someone else already had". Two concurrent challenges carrying the same temp token and the same recovery code both observed the marker, both deleted it, and both issued a full session — one recovery code minting two sessions, against a credential whose entire security model is that it is single-use. The TOTP path never had this hole: `challenge_consume` fuses the anti-replay mark with the token consume and answers with a bool the caller already gates on. The recovery path has no `tu:` marker to fuse against, so it consumes standalone — and that is the path that was left ungated. `del_temp` now reports the deletion (Redis `DEL` answers with the count; the in-memory store's `remove` answers with the previous value) and the challenge refuses when it loses. Two notes on the tests, because the first attempts proved nothing: - A spawned two-task race passes with or WITHOUT the gate: the in-memory repository serialises the recovery-code splice, so the loser fails on the code rather than on the token. It was replaced by a store that delegates everything to a real in-memory one except `del_temp`, which always loses — that drives the interleaving directly, and red-checks (without the gate the loser receives a full `AuthResult`). - The exactly-once property itself is pinned separately on `consume_mfa_temp_token`: first call true, every later one false. `nest-auth` gates the same point in the sibling commit; its `consumeMfaTempToken` had the same `Promise` signature and the same hole, described in a comment as a benign duplicate. Coverage stays at 100% lines and 100% functions. --- crates/bymax-auth-axum/tests/common/mod.rs | 2 +- .../src/services/mfa/challenge.rs | 10 +- .../bymax-auth-core/src/services/mfa/tests.rs | 138 +++++++++++++++++- .../src/services/token_manager.rs | 52 ++++++- crates/bymax-auth-core/src/testing/mod.rs | 7 +- crates/bymax-auth-core/src/traits/store.rs | 14 +- crates/bymax-auth-redis/src/stores/mfa.rs | 12 +- 7 files changed, 214 insertions(+), 21 deletions(-) diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 3b24a57..52b0b61 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -581,7 +581,7 @@ impl bymax_auth_core::traits::MfaStore for FailingStores { ) -> Result, bymax_auth_types::AuthError> { self.inner.get_temp(jti_hash).await } - async fn del_temp(&self, jti_hash: &str) -> Result<(), bymax_auth_types::AuthError> { + async fn del_temp(&self, jti_hash: &str) -> Result { self.inner.del_temp(jti_hash).await } async fn mark_totp_used( diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index c8b49ba..6386cdd 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -106,8 +106,14 @@ impl MfaService { match self.accept_recovery_code(&user, code) { Some(index) => { // The recovery-code path carries no `tu:` marker, so the temp token is - // consumed standalone now that the code is confirmed valid. - self.tokens.consume_mfa_temp_token(&jti).await?; + // consumed standalone now that the code is confirmed valid — and the + // consume must WIN. Two concurrent challenges on one temp token both + // observe the marker and both delete it; without gating on which delete + // actually removed it, both issued a full session. The fused TOTP step + // gets this by construction; this is the recovery path's equivalent. + if !self.tokens.consume_mfa_temp_token(&jti).await? { + return Err(AuthError::MfaTempTokenInvalid); + } Some(index) } None => return self.reject_code("challenge", &user_id, &bf_id).await, diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 8f4007d..3ea2238 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1360,9 +1360,52 @@ async fn platform_challenge_with_an_undecryptable_secret_is_an_opaque_failure() /// returns a fixed value, so the lost-`SET NX`-race and record-corruption branches of `setup` /// — unreachable with a coherent real store — are driven deterministically. The remaining /// methods return benign defaults (they are not exercised by these tests). +/// An MFA store that delegates everything to a real in-memory one **except** `del_temp`, +/// which always reports that someone else won the consume. +/// +/// This is the interleaving the in-memory repository cannot produce on its own: its +/// recovery-code splice serialises two concurrent challenges, so the loser fails on the code +/// rather than on the token. Forcing the lost consume is what exercises the gate that keeps a +/// single recovery code and a single temp token from minting two sessions. +struct LosingConsumeMfaStore { + inner: Arc, +} + +#[async_trait] +impl MfaStore for LosingConsumeMfaStore { + async fn put_setup_nx(&self, k: &str, v: &str, ttl: u64) -> Result { + self.inner.put_setup_nx(k, v, ttl).await + } + async fn get_setup(&self, k: &str) -> Result, AuthError> { + self.inner.get_setup(k).await + } + async fn take_setup(&self, k: &str) -> Result, AuthError> { + self.inner.take_setup(k).await + } + async fn put_temp(&self, j: &str, u: &str, ttl: u64) -> Result<(), AuthError> { + self.inner.put_temp(j, u, ttl).await + } + async fn get_temp(&self, j: &str) -> Result, AuthError> { + self.inner.get_temp(j).await + } + async fn del_temp(&self, _j: &str) -> Result { + Ok(false) + } + async fn mark_totp_used(&self, r: &str, ttl: u64) -> Result { + self.inner.mark_totp_used(r, ttl).await + } + async fn challenge_consume(&self, r: &str, j: &str, ttl: u64) -> Result { + self.inner.challenge_consume(r, j, ttl).await + } +} + struct ScriptedMfaStore { get_setup: Mutex>>, put_nx: bool, + /// What `del_temp` reports. `false` stands in for losing the consume to a concurrent + /// challenge — the interleaving the in-memory repository cannot produce, because its + /// recovery-code splice serialises the two callers. + del_temp_wins: bool, } #[async_trait] @@ -1388,8 +1431,8 @@ impl MfaStore for ScriptedMfaStore { async fn get_temp(&self, _j: &str) -> Result, AuthError> { Ok(None) } - async fn del_temp(&self, _j: &str) -> Result<(), AuthError> { - Ok(()) + async fn del_temp(&self, _j: &str) -> Result { + Ok(self.del_temp_wins) } async fn mark_totp_used(&self, _r: &str, _ttl: u64) -> Result { Ok(true) @@ -1517,6 +1560,7 @@ async fn setup_returns_the_winner_record_after_a_lost_nx_race() { Some(winner_record("WINNER-0000-CODE")), ])), put_nx: false, + del_temp_wins: true, }); let svc = service_over(store, users); let result = svc.setup(&uid, MfaContext::Dashboard).await; @@ -1533,6 +1577,7 @@ async fn setup_errors_when_the_record_vanishes_after_a_lost_race() { let store = Arc::new(ScriptedMfaStore { get_setup: Mutex::new(VecDeque::from([None, None])), put_nx: false, + del_temp_wins: true, }); let svc = service_over(store, users); assert!(matches!( @@ -1553,6 +1598,7 @@ async fn setup_fast_path_rejects_a_corrupt_or_undecryptable_record() { let garbage = Arc::new(ScriptedMfaStore { get_setup: Mutex::new(VecDeque::from([Some("not json".to_owned())])), put_nx: false, + del_temp_wins: true, }); assert!(matches!( service_over(garbage, users.clone()) @@ -1570,6 +1616,7 @@ async fn setup_fast_path_rejects_a_corrupt_or_undecryptable_record() { let undecryptable = Arc::new(ScriptedMfaStore { get_setup: Mutex::new(VecDeque::from([Some(bad_cipher)])), put_nx: false, + del_temp_wins: true, }); assert!(matches!( service_over(undecryptable, users.clone()) @@ -1584,6 +1631,7 @@ async fn setup_fast_path_rejects_a_corrupt_or_undecryptable_record() { "bad".to_owned(), ))])), put_nx: false, + del_temp_wins: true, }); assert!(matches!( service_over(codes_undecryptable, users.clone()) @@ -1600,6 +1648,7 @@ async fn setup_fast_path_rejects_a_corrupt_or_undecryptable_record() { bad_codes_json, ))])), put_nx: false, + del_temp_wins: true, }); assert!(matches!( service_over(codes_undecodable, users) @@ -1616,6 +1665,7 @@ async fn scripted_store_default_methods_are_inert() { let store = ScriptedMfaStore { get_setup: Mutex::new(VecDeque::new()), put_nx: true, + del_temp_wins: true, }; let store: &dyn MfaStore = &store; assert!(store.put_temp("j", "u", 1).await.is_ok()); @@ -1636,6 +1686,7 @@ async fn enable_fails_when_the_completion_gate_is_lost() { let store = Arc::new(ScriptedMfaStore { get_setup: Mutex::new(VecDeque::from([Some(winner_record("X"))])), put_nx: false, + del_temp_wins: true, }); let svc = service_over(store, users); // `winner_record` encrypts the raw secret `[1u8; 20]`, so a code for those bytes verifies. @@ -1783,6 +1834,7 @@ fn anti_replay_ttl_is_derived_from_the_window_and_scales() { let store: Arc = Arc::new(ScriptedMfaStore { get_setup: Mutex::new(VecDeque::new()), put_nx: true, + del_temp_wins: true, }); let mut service = service_over(store.clone(), users.clone()); @@ -2314,3 +2366,85 @@ async fn an_mfa_state_change_kills_the_outstanding_access_tokens() { .is_err() ); } + +#[tokio::test] +async fn a_recovery_challenge_that_loses_the_temp_token_consume_issues_no_session() { + // The gate that keeps ONE recovery code and ONE temp token from minting TWO sessions. The + // recovery path has no `tu:` marker to fuse against (unlike TOTP), so it consumes the temp + // token standalone — and when that consume reported nothing, both concurrent challenges + // "succeeded". The losing store forces the interleaving directly: the in-memory repository + // serialises the recovery-code splice, so a spawned race resolves on the code instead and + // would pass with or without this gate. + let users = Arc::new(InMemoryUserRepository::new()); + let inner = Arc::new(InMemoryStores::new()); + let losing: Arc = Arc::new(LosingConsumeMfaStore { + inner: inner.clone(), + }); + + let created = users + .create(bymax_auth_types::CreateUserData { + email: "lose@example.com".to_owned(), + name: "L".to_owned(), + password_hash: Some("$scrypt$x".to_owned()), + role: Some("USER".to_owned()), + status: Some("ACTIVE".to_owned()), + tenant_id: TENANT.to_owned(), + email_verified: Some(true), + }) + .await; + let Ok(user) = created else { return }; + + // Enrol with a known recovery code, digested exactly as the service digests one. + let mut deps = service_deps(losing.clone(), users.clone()); + // The token manager needs MFA support over the SAME losing store, so the temp token it + // issues is readable and its consume is the one that loses. + deps.tokens = Arc::new( + TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), + inner.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ) + .with_mfa_support(crate::services::token_manager::MfaTokenSupport::new( + losing.clone(), + )), + ); + let service = MfaService::new(deps); + + let plain = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"; + let digest = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( + &[9u8; 64], + plain.as_bytes(), + )); + let material = service.generate_setup_material(); + let Ok((_, _, data)) = material else { return }; + assert!( + users + .update_mfa( + &user.id, + bymax_auth_types::UpdateMfaData { + mfa_enabled: true, + mfa_secret: Some(data.encrypted_secret), + mfa_recovery_codes: Some(vec![digest]), + }, + ) + .await + .is_ok() + ); + + let issued = service + .tokens + .issue_mfa_temp_token(&user.id, MfaContext::Dashboard) + .await; + let Ok(temp) = issued else { return }; + + // The code is valid and the token is present — only the consume loses. No session. + let outcome = service.challenge(&temp, plain, "1.2.3.4", "ua").await; + assert!( + matches!(outcome, Err(AuthError::MfaTempTokenInvalid)), + "a lost consume must issue no session, got {outcome:?}" + ); +} diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index e76ef22..6bcfc88 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -724,18 +724,23 @@ impl TokenManagerService { }) } - /// Consume an MFA temp token by deleting its `mfa:{sha256(jti)}` marker. Idempotent, and - /// called only after the submitted code is confirmed valid (§7.5.3). For the TOTP path the - /// consume is fused with the anti-replay mark in a single atomic step - /// ([`crate::traits::MfaStore::challenge_consume`]); this standalone form serves the - /// recovery-code path, whose code carries no `tu:` marker. + /// Consume an MFA temp token by deleting its `mfa:{sha256(jti)}` marker, reporting whether + /// **this** call was the one that removed it. Called only after the submitted code is + /// confirmed valid (§7.5.3). For the TOTP path the consume is fused with the anti-replay + /// mark in a single atomic step ([`crate::traits::MfaStore::challenge_consume`]); this + /// standalone form serves the recovery-code path, whose code carries no `tu:` marker. + /// + /// The caller **must** gate success on the returned flag. Without it, two concurrent + /// challenges carrying the same temp token and the same recovery code both observed the + /// marker, both deleted it, and both issued a full session — the exactly-once property the + /// fused TOTP step has by construction. /// /// # Errors /// /// Returns [`AuthError::MfaTempTokenInvalid`] when no single-use support is wired, or a /// store [`AuthError`] on a backend failure. #[cfg(feature = "mfa")] - pub async fn consume_mfa_temp_token(&self, jti: &str) -> Result<(), AuthError> { + pub async fn consume_mfa_temp_token(&self, jti: &str) -> Result { let Some(support) = &self.mfa else { return Err(AuthError::MfaTempTokenInvalid); }; @@ -1529,6 +1534,41 @@ mod tests { )); } + #[cfg(feature = "mfa")] + #[tokio::test] + async fn consuming_a_temp_token_reports_the_winner_exactly_once() { + // The recovery-code challenge path has no `tu:` marker to fuse against, so it consumes + // the temp token standalone and gates success on this flag. The flag is the whole + // guarantee: when the consume reported nothing, two challenges carrying the same temp + // token both observed the marker, both deleted it, and both issued a full session — + // which is a recovery code, whose entire security model is single use, minting two. + // + // The property is exactly-once, so it is pinned here rather than through a concurrency + // test: the in-memory repository serialises the recovery-code splice, so a spawned race + // passes with or without the gate and would prove nothing. + let store = Arc::new(InMemoryStores::new()); + let svc = service_with_mfa(store); + + let issued = svc.issue_mfa_temp_token("u1", MfaContext::Dashboard).await; + let Ok(token) = issued else { return }; + let verified = svc.verify_mfa_temp_token(&token).await; + let Ok(claims) = verified else { return }; + + // The first consume wins; every later one loses, including for a jti that never existed. + assert!(matches!( + svc.consume_mfa_temp_token(&claims.jti).await, + Ok(true) + )); + assert!(matches!( + svc.consume_mfa_temp_token(&claims.jti).await, + Ok(false) + )); + assert!(matches!( + svc.consume_mfa_temp_token("never-issued").await, + Ok(false) + )); + } + #[cfg(feature = "mfa")] #[tokio::test] async fn issuing_a_temp_token_clears_no_brute_force_counter() { diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index c4a46f9..2abd887 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -815,9 +815,10 @@ impl crate::traits::MfaStore for InMemoryStores { Ok(lock(&self.mfa_temp).get(jti_hash).cloned()) } - async fn del_temp(&self, jti_hash: &str) -> Result<(), AuthError> { - lock(&self.mfa_temp).remove(jti_hash); - Ok(()) + async fn del_temp(&self, jti_hash: &str) -> Result { + // `HashMap::remove` answers with the previous value — present exactly for the caller + // that removed it, which is the same exactly-once signal Redis's `DEL` count gives. + Ok(lock(&self.mfa_temp).remove(jti_hash).is_some()) } async fn mark_totp_used(&self, replay_id: &str, _ttl: u64) -> Result { diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index c02d26f..cfb7ed5 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -638,8 +638,18 @@ pub trait MfaStore: Send + Sync { /// mistyped code leaves the token alive for a retry (§7.3.5). `None` when absent/expired. async fn get_temp(&self, jti_hash: &str) -> Result, AuthError>; - /// Delete the MFA temp-token marker at `mfa:{jti_hash}`. Idempotent. - async fn del_temp(&self, jti_hash: &str) -> Result<(), AuthError>; + /// Delete the MFA temp-token marker at `mfa:{jti_hash}`, reporting whether **this** call + /// was the one that removed it. + /// + /// The boolean is what makes the recovery-code path single-use. That path has no `tu:` + /// marker to fuse against (unlike TOTP, see [`MfaStore::challenge_consume`]), so it + /// consumes the temp token standalone — and when the delete reported nothing, two + /// concurrent challenges carrying the same temp token and the same recovery code both saw + /// the marker, both "consumed" it, and both issued a full session. Gating success on the + /// deletion gives that path the same exactly-once property the fused TOTP step has. + /// + /// Idempotent: a second call for the same `jti_hash` returns `false` rather than erroring. + async fn del_temp(&self, jti_hash: &str) -> Result; /// Set the standalone anti-replay marker `tu:{replay_id} = "1"` with `NX EX ttl`. /// Returns `true` when the marker was newly created (the code had not been seen) and diff --git a/crates/bymax-auth-redis/src/stores/mfa.rs b/crates/bymax-auth-redis/src/stores/mfa.rs index 5fd9025..ae25dad 100644 --- a/crates/bymax-auth-redis/src/stores/mfa.rs +++ b/crates/bymax-auth-redis/src/stores/mfa.rs @@ -90,12 +90,14 @@ impl RedisStores { Ok(conn.get(&key).await?) } - /// `DEL mfa:{jti_hash}` — consume the temp-token marker (idempotent). - async fn del_temp_inner(&self, jti_hash: &str) -> Result<(), RedisStoreError> { + /// `DEL mfa:{jti_hash}` — consume the temp-token marker, returning whether this call was + /// the one that removed it. `DEL` answers with the number of keys it deleted, which is + /// exactly the exactly-once signal the recovery-code path gates on. + async fn del_temp_inner(&self, jti_hash: &str) -> Result { let key = self.keys().key(Prefix::Mfa, jti_hash); let mut conn = self.connection().await?; - conn.del::<_, ()>(&key).await?; - Ok(()) + let removed: i64 = conn.del(&key).await?; + Ok(removed > 0) } /// `SET tu:{replay_id} "1" NX EX ttl` — set the standalone anti-replay marker, returning @@ -178,7 +180,7 @@ impl MfaStore for RedisStores { self.get_temp_inner(jti_hash).await.map_err(AuthError::from) } - async fn del_temp(&self, jti_hash: &str) -> Result<(), AuthError> { + async fn del_temp(&self, jti_hash: &str) -> Result { self.del_temp_inner(jti_hash).await.map_err(AuthError::from) } From e505b8db226e17a7d1572e6e8e2ecfe6b2c7af64 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:51:46 -0300 Subject: [PATCH 096/122] fix(core): namespace every MFA key by identity plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending-enrolment record, the TOTP anti-replay marker, and both brute-force counters were all keyed on `hmac(user_id)` with no plane component. The two identity domains draw their ids from *different* consumer repositories, which may hand out the same string — sequential integers make it certain — so a dashboard user and a platform admin sharing an id shared all four. The sharpest of those is the setup record: admin `1` calls `setup(Platform)`, user `1` calls `verify_and_enable(Dashboard)`, and the dashboard row is enrolled with the *platform admin's* secret and recovery digests. The counters are a mutual denial-of-service in both directions — either party can exhaust the other's lockout budget, and either can clear it. Everything else about the two planes is already separate: Redis key prefixes, claim types, session indexes, token epochs. This was the one place the isolation leaked. The plane now goes into the HMAC input as its wire name (`dashboard` / `platform`), so `nest-auth` derives byte-identical keys — it had the same four derivations and the same gap. The `challenge:` / `disable:` split still holds within a plane: the pre-auth counter an attacker can drive must not be able to exhaust the authenticated user's management budget. Coverage stays at 100% lines and 100% functions. --- .../src/services/mfa/challenge.rs | 17 +++++-- .../src/services/mfa/manage.rs | 9 ++-- .../bymax-auth-core/src/services/mfa/mod.rs | 39 +++++++++------ .../bymax-auth-core/src/services/mfa/setup.rs | 6 +-- .../bymax-auth-core/src/services/mfa/tests.rs | 49 +++++++++++++++++++ crates/bymax-auth-types/src/claims.rs | 15 ++++++ 6 files changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 6386cdd..cd6bf8a 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -64,7 +64,7 @@ impl MfaService { user_agent: &str, ) -> Result { let MfaTempVerified { user_id, jti, .. } = verified; - let bf_id = self.challenge_bf_id(&user_id); + let bf_id = self.challenge_bf_id(MfaContext::Dashboard, &user_id); self.assert_not_locked("challenge", &user_id, &bf_id) .await?; @@ -98,7 +98,10 @@ impl MfaService { // else is treated as a recovery code. On any invalid code the temp token is left alive // (retryable within its TTL) and only the failure counter advances. let recovery_index = if is_totp_code(code) { - if !self.accept_totp(&user_id, &raw_secret, code, &jti).await? { + if !self + .accept_totp(MfaContext::Dashboard, &user_id, &raw_secret, code, &jti) + .await? + { return self.reject_code("challenge", &user_id, &bf_id).await; } None @@ -175,7 +178,7 @@ impl MfaService { user_agent: &str, ) -> Result { let MfaTempVerified { user_id, jti, .. } = verified; - let bf_id = self.challenge_bf_id(&user_id); + let bf_id = self.challenge_bf_id(MfaContext::Platform, &user_id); self.assert_not_locked("platform challenge", &user_id, &bf_id) .await?; @@ -211,7 +214,10 @@ impl MfaService { // (retryable within its TTL) and only the failure counter advances. let recovery_codes = admin.mfa_recovery_codes.clone().unwrap_or_default(); let recovery_index = if is_totp_code(code) { - if !self.accept_totp(&user_id, &raw_secret, code, &jti).await? { + if !self + .accept_totp(MfaContext::Platform, &user_id, &raw_secret, code, &jti) + .await? + { return self .reject_code("platform challenge", &user_id, &bf_id) .await; @@ -268,6 +274,7 @@ impl MfaService { /// consumed, `false` for an invalid code or a losing concurrent same-code submission. async fn accept_totp( &self, + ctx: MfaContext, user_id: &str, raw_secret: &[u8], code: &str, @@ -286,7 +293,7 @@ impl MfaService { // (same marker already present) or a different still-valid code (its marker is fresh but // the temp token is already gone) — is rejected, so exactly one session is issued. The // anti-replay TTL is derived from the configured window so the marker outlives the code. - let replay = self.replay_id(user_id, code); + let replay = self.replay_id(ctx, user_id, code); let jti_marker = to_hex(&bymax_auth_crypto::mac::sha256(jti.as_bytes())); self.mfa_store .challenge_consume(&replay, &jti_marker, self.anti_replay_ttl_seconds()) diff --git a/crates/bymax-auth-core/src/services/mfa/manage.rs b/crates/bymax-auth-core/src/services/mfa/manage.rs index cd57255..d2e3654 100644 --- a/crates/bymax-auth-core/src/services/mfa/manage.rs +++ b/crates/bymax-auth-core/src/services/mfa/manage.rs @@ -29,7 +29,7 @@ impl MfaService { ctx: MfaContext, ) -> Result<(), AuthError> { let view = self.fetch_user_mfa(user_id, ctx).await?; - self.reauth_gate(user_id, code, &view).await?; + self.reauth_gate(ctx, 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 every refresh session AND advance the token epoch: an auth-state change @@ -65,7 +65,7 @@ impl MfaService { ctx: MfaContext, ) -> Result, AuthError> { let view = self.fetch_user_mfa(user_id, ctx).await?; - self.reauth_gate(user_id, totp_code, &view).await?; + self.reauth_gate(ctx, user_id, totp_code, &view).await?; // Generate a fresh set with the same entropy/format as setup; persist only the digests. let plain_codes: Vec = (0..self.recovery_code_count) .map(|_| generate_recovery_code()) @@ -95,6 +95,7 @@ impl MfaService { /// [`AuthError::TokenInvalid`], [`AuthError::MfaInvalidCode`], or a store [`AuthError`]. async fn reauth_gate( &self, + ctx: MfaContext, user_id: &str, code: &str, view: &MfaUserView, @@ -102,7 +103,7 @@ impl MfaService { if !view.mfa_enabled { return Err(AuthError::MfaNotEnabled); } - let bf_id = self.disable_bf_id(user_id); + let bf_id = self.disable_bf_id(ctx, user_id); self.assert_not_locked("disable", user_id, &bf_id).await?; // An enabled account with no stored secret is an inconsistency, not a user error. let encrypted = view.mfa_secret.clone().ok_or(AuthError::TokenInvalid)?; @@ -110,7 +111,7 @@ impl MfaService { .decrypt_secret(&encrypted) .ok_or(AuthError::TokenInvalid)?; if !self - .verify_totp_with_anti_replay(user_id, &raw_secret, code) + .verify_totp_with_anti_replay(ctx, user_id, &raw_secret, code) .await? { tracing::warn!(%user_id, "mfa disable: invalid code"); diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index dee4ee4..d022d0c 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -201,22 +201,28 @@ impl MfaService { } } - /// The `mfa_setup:` key suffix for a user (`hmac_sha256(user_id)`, hex). The low-entropy - /// id is keyed, never used raw, so no PII reaches a store key. - fn setup_key(&self, user_id: &str) -> String { + /// The `mfa_setup:` key suffix for a user (`hmac_sha256("{plane}:{user_id}")`, hex). The + /// low-entropy id is keyed, never used raw, so no PII reaches a store key. + /// + /// The plane is part of the input because the two identity domains draw their ids from + /// different consumer repositories, which may hand out the same string. Keyed on the id + /// alone, an admin's pending enrolment and a user's shared one record: the second party to + /// call `verify_and_enable` would adopt the first party's secret and recovery digests. + fn setup_key(&self, ctx: MfaContext, user_id: &str) -> String { to_hex(&hmac_sha256( self.identifier_key.as_ref(), - user_id.as_bytes(), + format!("{}:{user_id}", ctx.as_str()).as_bytes(), )) } - /// The `tu:` anti-replay key suffix for a `(user_id, code)` pair - /// (`hmac_sha256("{user_id}:{code}")`, hex) — ties the marker to both the user and the - /// code value, with no plaintext code in the store and no cross-user replay. - fn replay_id(&self, user_id: &str, code: &str) -> String { + /// The `tu:` anti-replay key suffix for a `(plane, user_id, code)` triple + /// (`hmac_sha256("{plane}:{user_id}:{code}")`, hex) — ties the marker to the plane, the + /// user and the code value, with no plaintext code in the store and no cross-user or + /// cross-plane replay. + fn replay_id(&self, ctx: MfaContext, user_id: &str, code: &str) -> String { to_hex(&hmac_sha256( self.identifier_key.as_ref(), - format!("{user_id}:{code}").as_bytes(), + format!("{}:{user_id}:{code}", ctx.as_str()).as_bytes(), )) } @@ -243,21 +249,23 @@ impl MfaService { } /// The hashed brute-force identifier for the pre-auth challenge counter - /// (`hmac_sha256("challenge:{user_id}")`, hex), isolated from the `disable:` namespace. - fn challenge_bf_id(&self, user_id: &str) -> String { + /// (`hmac_sha256("challenge:{plane}:{user_id}")`, hex), isolated from the `disable:` + /// namespace and from the other identity plane — otherwise a colliding id lets either + /// party exhaust the other's lockout budget. + fn challenge_bf_id(&self, ctx: MfaContext, user_id: &str) -> String { to_hex(&hmac_sha256( self.identifier_key.as_ref(), - format!("challenge:{user_id}").as_bytes(), + format!("challenge:{}:{user_id}", ctx.as_str()).as_bytes(), )) } /// The hashed brute-force identifier for the authenticated management counter /// (`hmac_sha256("disable:{user_id}")`, hex), shared by `disable` and `regenerate` and /// isolated from the `challenge:` namespace. - fn disable_bf_id(&self, user_id: &str) -> String { + fn disable_bf_id(&self, ctx: MfaContext, user_id: &str) -> String { to_hex(&hmac_sha256( self.identifier_key.as_ref(), - format!("disable:{user_id}").as_bytes(), + format!("disable:{}:{user_id}", ctx.as_str()).as_bytes(), )) } @@ -364,6 +372,7 @@ impl MfaService { /// Returns a store [`AuthError`] only if the anti-replay marker cannot be written. async fn verify_totp_with_anti_replay( &self, + ctx: MfaContext, user_id: &str, secret: &[u8], code: &str, @@ -375,7 +384,7 @@ impl MfaService { // newly created — `false` means the code was already seen, i.e. a replay. self.mfa_store .mark_totp_used( - &self.replay_id(user_id, code), + &self.replay_id(ctx, user_id, code), self.anti_replay_ttl_seconds(), ) .await diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index 94f0792..8f75b36 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -25,7 +25,7 @@ impl MfaService { if view.mfa_enabled { return Err(AuthError::MfaAlreadyEnabled); } - let key = self.setup_key(user_id); + let key = self.setup_key(ctx, user_id); // Fast-path idempotency: an existing pending record is re-returned verbatim, so a user // who refreshes the setup page sees the same secret/QR/codes they may already be @@ -88,7 +88,7 @@ impl MfaService { if view.mfa_enabled { return Err(AuthError::MfaAlreadyEnabled); } - let key = self.setup_key(user_id); + let key = self.setup_key(ctx, user_id); // Load and decrypt the pending record. A missing record, a record that will not parse, // and a secret that will not decrypt all collapse to the same opaque `MfaSetupRequired` @@ -107,7 +107,7 @@ impl MfaService { // Verify the code with anti-replay before the completion gate, so an invalid code never // consumes the pending record. if !self - .verify_totp_with_anti_replay(user_id, &raw_secret, code) + .verify_totp_with_anti_replay(ctx, user_id, &raw_secret, code) .await? { tracing::warn!(%user_id, "mfa setup: invalid TOTP code"); diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 3ea2238..b6a73bc 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -2448,3 +2448,52 @@ async fn a_recovery_challenge_that_loses_the_temp_token_consume_issues_no_sessio "a lost consume must issue no session, got {outcome:?}" ); } + +#[test] +fn every_mfa_key_is_namespaced_by_identity_plane() { + // The two planes draw their ids from DIFFERENT consumer repositories, which may hand out + // the same string — sequential integers make it certain. Keyed on the id alone, a dashboard + // user and a platform admin sharing an id shared: the pending-enrolment record (so whoever + // called `verify_and_enable` second adopted the FIRST party's secret and recovery digests), + // the TOTP anti-replay marker, and both brute-force counters (so either could exhaust the + // other's lockout budget, or clear it). + // + // Everything else about the two planes is already separate — Redis prefixes, claim types, + // session indexes. This was the one place the isolation leaked. + let users = Arc::new(InMemoryUserRepository::new()); + let service = service_over(Arc::new(InMemoryStores::new()), users); + let id = "1"; + + assert_ne!( + service.setup_key(MfaContext::Dashboard, id), + service.setup_key(MfaContext::Platform, id), + "a shared pending-enrolment record lets one plane adopt the other's secret" + ); + assert_ne!( + service.replay_id(MfaContext::Dashboard, id, "123456"), + service.replay_id(MfaContext::Platform, id, "123456"), + "a shared anti-replay marker lets one plane burn the other's code" + ); + assert_ne!( + service.challenge_bf_id(MfaContext::Dashboard, id), + service.challenge_bf_id(MfaContext::Platform, id), + "a shared challenge counter lets one plane lock the other out" + ); + assert_ne!( + service.disable_bf_id(MfaContext::Dashboard, id), + service.disable_bf_id(MfaContext::Platform, id), + "a shared disable counter lets one plane lock the other out" + ); + + // The `challenge:` / `disable:` split still holds WITHIN a plane — the pre-auth counter an + // attacker can drive must not be able to exhaust the authenticated user's management + // budget. + assert_ne!( + service.challenge_bf_id(MfaContext::Dashboard, id), + service.disable_bf_id(MfaContext::Dashboard, id) + ); + + // And the plane component is the wire name, so nest-auth derives the same key. + assert_eq!(MfaContext::Dashboard.as_str(), "dashboard"); + assert_eq!(MfaContext::Platform.as_str(), "platform"); +} diff --git a/crates/bymax-auth-types/src/claims.rs b/crates/bymax-auth-types/src/claims.rs index 903f823..b816e88 100644 --- a/crates/bymax-auth-types/src/claims.rs +++ b/crates/bymax-auth-types/src/claims.rs @@ -56,6 +56,21 @@ pub enum MfaContext { Platform, } +impl MfaContext { + /// The plane's wire name, matching how this enum serializes. + /// + /// Used to namespace the MFA store keys and brute-force counters by identity plane. The + /// two planes' ids come from different consumer repositories and may collide, so a key + /// derived from the id alone is shared between an unrelated user and admin. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Dashboard => "dashboard", + Self::Platform => "platform", + } + } +} + /// Access token for tenant/dashboard users. The TypeScript counterpart is /// `DashboardJwtPayload`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] From 081df589636b6b49ffe386548fe5848da2b9b5ab Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:59:45 -0300 Subject: [PATCH 097/122] fix(core): an inviter must belong to the tenant they invite into MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invite()` looked the inviter up with `find_by_id(inviter_user_id, None)` — the `None` being the tenant scope — and then checked one thing: that their role outranks the role being invited. That says *what* role they hold and nothing about *where* they hold it, so an ADMIN of tenant t1 could mint an invitation that provisions an ADMIN account inside t2. Cross-tenant privilege escalation through the public core API. The shipped axum adapter sources `tenant_id` from the caller's own claims, which hides it end to end. But this is a library whose core API hosts call directly, and the authorization contract belongs there rather than in one caller. It also makes `invite` consistent with `accept_invitation`, which already re-validates the role as anti-tamper rather than trusting the stored record. No existing test covered the cross-tenant case — the suite passed before and after the check, which is why it survived. The new test holds the inviter and the role fixed and varies only the tenant, so it fails for exactly one reason. `nest-auth` had the identical gap and is fixed in the sibling commit. Coverage stays at 100% lines and 100% functions. --- .../src/services/auth/invitation.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 4558d11..5d63037 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -86,6 +86,17 @@ impl AuthEngine { .await .map_err(map_repository_error)? .ok_or(AuthError::TokenInvalid)?; + // The inviter must belong to the tenant they are inviting into. Without this the only + // authorization is the role-hierarchy check below, which says nothing about *where* + // the role is held: an ADMIN of tenant A could mint an invitation that provisions an + // ADMIN account inside tenant B. The shipped axum adapter sources `tenant_id` from the + // caller's own claims, which hides it — but this is a library whose core API hosts + // call directly, and the authorization contract belongs here rather than in one + // caller. It is also what makes `invite` consistent with `accept_invitation`, which + // already re-validates the role as anti-tamper. + if inviter.tenant_id != tenant_id { + return Err(AuthError::InsufficientRole); + } if !has_role(&inviter.role, role, hierarchy) { return Err(AuthError::InsufficientRole); } @@ -393,6 +404,32 @@ mod tests { )); } + #[tokio::test] + async fn invite_refuses_a_tenant_the_inviter_does_not_belong_to() { + // The role-hierarchy check says WHAT role the inviter holds and nothing about WHERE + // they hold it, so on its own an ADMIN of tenant t1 could mint an invitation that + // provisions an ADMIN account inside t2 — a tenant they have no relationship with. + // The shipped axum adapter sources `tenant_id` from the caller's own claims, which + // hides it, but this is a library whose core API hosts call directly. + let Some(s) = setup(invite_config()) else { return }; + let admin = seed_admin(&s.users, "t1admin@example.com", "ADMIN").await; + + // Same inviter, same role, only the tenant differs: t1 succeeds, t2 is refused. + assert!(matches!( + s.engine + .invite(&admin, "victim@example.com", "ADMIN", "t2", None) + .await, + Err(AuthError::InsufficientRole) + )); + assert!( + s.engine + .invite(&admin, "ok@example.com", "ADMIN", "t1", None) + .await + .is_ok(), + "the inviter's own tenant must still work" + ); + } + #[tokio::test] async fn invite_rejects_unknown_role_and_insufficient_inviter() { // An undeclared invited role and an inviter who does not outrank the invited role both From de49dde08d213d64f9c26d4a904def430b2ce6c2 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:35:41 -0300 Subject: [PATCH 098/122] fix(core): honour tenant_id_resolver in every tenant-scoped flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver is documented as authoritative over the body's `tenant_id` when configured — that is the whole anti-spoofing promise. Only `login` and `register` called it. `initiate_reset`, `reset_password`, `verify_reset_otp`, `resend_reset_otp`, `verify_email` and `resend_verification_email` passed the caller's value straight into the account lookup and the OTP identifier. On a deployment that derives the tenant from the request — the documented use case — an unauthenticated caller on one tenant can drive reset and verification mail at accounts in a tenant they have no relationship with: a mail-bombing vector and cross-tenant enumeration. It is also a functional break in the other direction: a reset started under the resolved tenant could never be completed, because the initiate step wrote its record under one tenant and the confirm step derived its identifier from another. Completion itself was never at risk — the stored reset context re-binds the tenant — so this is not account takeover. **Breaking.** Those six methods now take `&RequestContext` as their final argument, matching what `login` and `register` already take. The axum routes supply it through the existing `RequestMeta` extractor, which the reset routes were not using at all. The library is not published to production yet, which is the cheap moment for this. The new test is indirect but exact: the harness resolver refuses when no `host` header is present, so a flow that consults it fails with `Forbidden` on an empty context and a flow that ignores it does not. All six used to pass silently. `nest-auth` takes the same change as the Express request. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 12 + crates/bymax-auth-axum/src/routes/auth.rs | 6 +- .../src/routes/password_reset.rs | 13 +- .../src/services/auth/email_verification.rs | 35 ++- .../bymax-auth-core/src/services/auth/mod.rs | 60 ++++ .../src/services/auth/password_reset.rs | 259 ++++++++++++------ crates/bymax-auth-redis/tests/redis_stores.rs | 112 +++++--- 7 files changed, 346 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf01ce..d14d660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,18 @@ version bump. exists to guarantee — invisibly, because the parameter that *is* bounded stays intact. `nest-auth` enforces the identical ranges. +- **`tenant_id_resolver` is now honoured by every tenant-scoped flow.** The resolver is + documented as authoritative over the body's `tenant_id` when configured, which is the whole + anti-spoofing promise — but only `login` and `register` called it. `initiate_reset`, + `reset_password`, `verify_reset_otp`, `resend_reset_otp`, `verify_email` and + `resend_verification_email` read the caller's value verbatim, so on a deployment that + derives the tenant from the request a caller on one tenant could drive reset and + verification mail at accounts in another, and a reset started under the resolved tenant + could never be completed because the two steps derived different identifiers. + **Breaking:** those six methods now take `&RequestContext` as their final argument; the + axum routes supply it through the existing `RequestMeta` extractor. `nest-auth` takes the + same change as the Express request. + ### Changed - **Family-lineage reuse detection replaces the previous sentinel.** A login opens diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index e83f329..8d75701 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -179,11 +179,12 @@ async fn me(State(state): State, user: AuthUser) -> Response { /// `POST /auth/verify-email` (204). Public. Consumes the OTP and marks the account verified. async fn verify_email( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { match state .engine() - .verify_email(&dto.tenant_id, &dto.email, &dto.otp) + .verify_email(&dto.tenant_id, &dto.email, &dto.otp, &ctx) .await { Ok(()) => StatusCode::NO_CONTENT.into_response(), @@ -195,13 +196,14 @@ async fn verify_email( /// regardless of account existence. async fn resend_verification( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { // Anti-enumeration: the response is uniform regardless of the outcome, so even an `Err` // collapses to the same 204 — surfacing it would leak a distinguishable signal. let _ = state .engine() - .resend_verification_email(&dto.tenant_id, &dto.email) + .resend_verification_email(&dto.tenant_id, &dto.email, &ctx) .await; StatusCode::NO_CONTENT.into_response() } diff --git a/crates/bymax-auth-axum/src/routes/password_reset.rs b/crates/bymax-auth-axum/src/routes/password_reset.rs index cc057e7..a9d2141 100644 --- a/crates/bymax-auth-axum/src/routes/password_reset.rs +++ b/crates/bymax-auth-axum/src/routes/password_reset.rs @@ -13,6 +13,7 @@ use bymax_auth_core::services::auth::{ use http::StatusCode; use serde_json::json; +use super::RequestMeta; use crate::dto::{ForgotPasswordDto, ResendOtpDto, ResetPasswordDto, VerifyOtpDto}; use crate::response::error_response; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; @@ -47,6 +48,7 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// `POST /auth/password/forgot-password` (200). Public + anti-enumeration. async fn forgot_password( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { let input = ForgotPasswordInput { @@ -56,13 +58,14 @@ async fn forgot_password( // Anti-enumeration: the response is uniform regardless of the outcome (existence, blocked // status, or an infra hiccup), so even an `Err` collapses to the same 200 body — surfacing // it would leak a distinguishable signal the engine's timing-normalized contract forbids. - let _ = state.engine().initiate_reset(input).await; + let _ = state.engine().initiate_reset(input, &ctx).await; (StatusCode::OK, Json(json!({}))).into_response() } /// `POST /auth/password/reset-password` (204). Public. async fn reset_password( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { let input = ResetPasswordInput { @@ -73,7 +76,7 @@ async fn reset_password( otp: dto.otp, verified_token: dto.verified_token, }; - match state.engine().reset_password(input).await { + match state.engine().reset_password(input, &ctx).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(error) => error_response(&error), } @@ -82,6 +85,7 @@ async fn reset_password( /// `POST /auth/password/verify-otp` (200). Public. Returns the short-lived verified token. async fn verify_otp( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { let input = VerifyResetOtpInput { @@ -89,7 +93,7 @@ async fn verify_otp( tenant_id: dto.tenant_id, otp: dto.otp, }; - match state.engine().verify_reset_otp(input).await { + match state.engine().verify_reset_otp(input, &ctx).await { Ok(verified_token) => ( StatusCode::OK, Json(json!({ "verifiedToken": verified_token })), @@ -102,6 +106,7 @@ async fn verify_otp( /// `POST /auth/password/resend-otp` (200). Public + anti-enumeration. async fn resend_otp( State(state): State, + RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { let input = ResendResetOtpInput { @@ -109,6 +114,6 @@ async fn resend_otp( tenant_id: dto.tenant_id, }; // Anti-enumeration: uniform response regardless of the outcome (see `forgot_password`). - let _ = state.engine().resend_reset_otp(input).await; + let _ = state.engine().resend_reset_otp(input, &ctx).await; (StatusCode::OK, Json(json!({}))).into_response() } diff --git a/crates/bymax-auth-core/src/services/auth/email_verification.rs b/crates/bymax-auth-core/src/services/auth/email_verification.rs index 90b7a49..570cb3c 100644 --- a/crates/bymax-auth-core/src/services/auth/email_verification.rs +++ b/crates/bymax-auth-core/src/services/auth/email_verification.rs @@ -8,6 +8,7 @@ use std::time::Instant; use bymax_auth_types::{AuthError, SafeAuthUser}; +use crate::context::RequestContext; use crate::engine::AuthEngine; use crate::normalize::normalize_email; use crate::services::auth::detached::{run_after_email_verified, run_send_verification_email}; @@ -35,11 +36,17 @@ impl AuthEngine { tenant_id: &str, email: &str, otp: &str, + ctx: &RequestContext, ) -> Result<(), AuthError> { // Canonicalize so the OTP identifier and the repository lookup agree on one // spelling; a verification started under one casing must complete under any. let email = normalize_email(email); let email = email.as_str(); + // The tenant goes through the resolver, exactly as `login` and `register` do: when one + // is configured it is authoritative and the body value is ignored. Without it a caller + // on one tenant could drive verification mail at, or probe for, accounts in another. + let tenant_id = self.resolve_tenant(tenant_id, ctx).await?; + let tenant_id = tenant_id.as_str(); let identifier = self.hashed_identifier(tenant_id, email); self.otp() .verify(OtpPurpose::EmailVerification, &identifier, otp) @@ -84,12 +91,18 @@ impl AuthEngine { &self, tenant_id: &str, email: &str, + ctx: &RequestContext, ) -> Result<(), AuthError> { // Canonicalize so the OTP identifier and the repository lookup agree on one // spelling; a verification started under one casing must complete under any. let email = normalize_email(email); let email = email.as_str(); let started = Instant::now(); + // The tenant goes through the resolver, exactly as `login` and `register` do: when one + // is configured it is authoritative and the body value is ignored. Without it a caller + // on one tenant could drive verification mail at, or probe for, accounts in another. + let tenant_id = self.resolve_tenant(tenant_id, ctx).await?; + let tenant_id = tenant_id.as_str(); let identifier = self.hashed_identifier(tenant_id, email); // Atomic cooldown gate — a second resend inside the window is a silent success. @@ -167,7 +180,7 @@ fn verification_context(user_id: &str, email: &str, tenant_id: &str) -> HookCont #[cfg(test)] mod tests { use super::*; - use crate::services::auth::test_support::{Harness, SeedUser, base_config, harness}; + use crate::services::auth::test_support::{Harness, SeedUser, base_config, ctx, harness}; use crate::traits::UserRepository; use std::time::Duration; @@ -205,7 +218,7 @@ mod tests { let Some(code) = stored else { return }; assert!( h.engine - .verify_email("t1", "v@example.com", &code) + .verify_email("t1", "v@example.com", &code, &ctx()) .await .is_ok() ); @@ -213,7 +226,9 @@ mod tests { assert!(matches!(stored, Ok(Some(u)) if u.email_verified)); // The OTP is consumed: a second submission is now expired. assert!(matches!( - h.engine.verify_email("t1", "v@example.com", &code).await, + h.engine + .verify_email("t1", "v@example.com", &code, &ctx()) + .await, Err(AuthError::OtpExpired) )); } @@ -240,7 +255,9 @@ mod tests { .is_ok() ); assert!(matches!( - h.engine.verify_email("t1", "w@example.com", "000000").await, + h.engine + .verify_email("t1", "w@example.com", "000000", &ctx()) + .await, Err(AuthError::OtpInvalid) )); // An OTP stored for an email with no backing user collapses to OtpInvalid on success. @@ -257,7 +274,7 @@ mod tests { let Some(code) = stored else { return }; assert!(matches!( h.engine - .verify_email("t1", "ghost@example.com", &code) + .verify_email("t1", "ghost@example.com", &code, &ctx()) .await, Err(AuthError::OtpInvalid) )); @@ -282,7 +299,7 @@ mod tests { let started = Instant::now(); assert!( h.engine - .resend_verification_email("t1", "r@example.com") + .resend_verification_email("t1", "r@example.com", &ctx()) .await .is_ok() ); @@ -299,7 +316,7 @@ mod tests { // Second resend within the cooldown is the silent-success branch. assert!( h.engine - .resend_verification_email("t1", "r@example.com") + .resend_verification_email("t1", "r@example.com", &ctx()) .await .is_ok() ); @@ -312,7 +329,7 @@ mod tests { // An absent account is indistinguishable (uniform Ok). assert!( h.engine - .resend_verification_email("t1", "absent@example.com") + .resend_verification_email("t1", "absent@example.com", &ctx()) .await .is_ok() ); @@ -320,7 +337,7 @@ mod tests { let _ = h.seed(SeedUser::active("done@example.com", "pw")).await; assert!( h.engine - .resend_verification_email("t1", "done@example.com") + .resend_verification_email("t1", "done@example.com", &ctx()) .await .is_ok() ); diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index 798b1b0..957301f 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -533,6 +533,66 @@ mod tests { )); } + #[tokio::test] + async fn every_tenant_scoped_flow_honours_the_resolver() { + // The option documents itself as ignoring the body's tenant when a resolver is + // configured, "to prevent tenant spoofing". Only `login` and `register` honoured it: + // password reset (all four steps) and email verification (both) read the body value + // verbatim, so a caller on one tenant could drive reset/verification mail at accounts + // in another — and a reset started under the RESOLVED tenant could never be completed, + // because the stored context and the confirm step disagreed about which tenant it + // belonged to. + // + // The check is indirect but exact: the resolver refuses when no `host` header is + // present, so a flow that consults it fails with `Forbidden` on an empty context and a + // flow that ignores it does not. Every one of these used to be silently fine. + let mut cfg = test_support::base_config(); + cfg.tenant_id_resolver = Some(Arc::new(HostTenantResolver)); + let Some(h) = test_support::harness(cfg, None) else { return }; + let empty = RequestContext::new("1.2.3.4", "ua", BTreeMap::new()); + + let forgot = crate::services::auth::ForgotPasswordInput { + email: "x@example.com".to_owned(), + tenant_id: "body-tenant".to_owned(), + }; + assert!(matches!( + h.engine.initiate_reset(forgot, &empty).await, + Err(AuthError::Forbidden) + )); + + let verify_otp = crate::services::auth::VerifyResetOtpInput { + email: "x@example.com".to_owned(), + tenant_id: "body-tenant".to_owned(), + otp: "123456".to_owned(), + }; + assert!(matches!( + h.engine.verify_reset_otp(verify_otp, &empty).await, + Err(AuthError::Forbidden) + )); + + let resend = crate::services::auth::ResendResetOtpInput { + email: "x@example.com".to_owned(), + tenant_id: "body-tenant".to_owned(), + }; + assert!(matches!( + h.engine.resend_reset_otp(resend, &empty).await, + Err(AuthError::Forbidden) + )); + + assert!(matches!( + h.engine + .verify_email("body-tenant", "x@example.com", "123456", &empty) + .await, + Err(AuthError::Forbidden) + )); + assert!(matches!( + h.engine + .resend_verification_email("body-tenant", "x@example.com", &empty) + .await, + Err(AuthError::Forbidden) + )); + } + #[tokio::test] async fn harness_wires_hooks_and_seed_reports_a_conflict() { // The harness wires an explicit hooks collaborator, and seeding a duplicate email 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 97a0e4f..b1f914d 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -17,6 +17,7 @@ use bymax_auth_crypto::token::generate_secure_token; use bymax_auth_types::{AuthError, AuthUser, SafeAuthUser}; use crate::config::ResetMethod; +use crate::context::RequestContext; use crate::engine::AuthEngine; use crate::normalize::normalize_email; use crate::services::auth::detached::run_after_password_reset; @@ -113,13 +114,25 @@ impl AuthEngine { /// persisting the proof); account state never changes the otherwise-`Ok(())` outcome. The /// timing floor is applied before the error is returned, so an infra error stays /// latency-indistinguishable from a normal response. - pub async fn initiate_reset(&self, input: ForgotPasswordInput) -> Result<(), AuthError> { + pub async fn initiate_reset( + &self, + input: ForgotPasswordInput, + ctx: &RequestContext, + ) -> Result<(), AuthError> { // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, // and the reset context written for the confirm step) must derive from one // spelling, or a reset started under one casing cannot be completed under another. + // + // The tenant goes through the resolver for the same reason `login` and `register` do: + // when one is configured it is authoritative and the body value is ignored, which is + // the whole anti-spoofing promise. Without it a caller on one tenant could drive + // reset mail at accounts in another — and a reset started under the resolved tenant + // could never be completed, because the stored context and the confirm step would + // disagree about which tenant it belonged to. + let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let input = ForgotPasswordInput { email: normalize_email(&input.email), - ..input + tenant_id, }; let started = Instant::now(); // Run the fallible body, then normalize the elapsed time on EVERY exit — including an @@ -168,12 +181,22 @@ impl AuthEngine { /// wrong proof for the method, or an invalid/consumed proof is presented; an OTP error /// ([`AuthError::OtpInvalid`]/[`AuthError::OtpExpired`]/[`AuthError::OtpMaxAttempts`]) for a /// failed OTP; or a hashing/store [`AuthError`]. - pub async fn reset_password(&self, input: ResetPasswordInput) -> Result<(), AuthError> { + pub async fn reset_password( + &self, + input: ResetPasswordInput, + ctx: &RequestContext, + ) -> Result<(), AuthError> { // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, // and the reset context written for the confirm step) must derive from one // spelling, or a reset started under one casing cannot be completed under another. + // The tenant goes through the resolver, exactly as `login` and `register` do: when one + // is configured it is authoritative and the body value is ignored. That is the + // anti-spoofing promise, and it also keeps this step reading the same tenant the + // initiate step wrote under. + let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let input = ResetPasswordInput { email: normalize_email(&input.email), + tenant_id, ..input }; // Classify the proofs: exactly one of token / otp / verified_token must be present. @@ -267,12 +290,22 @@ impl AuthEngine { /// /// Returns the OTP error on a failed verify, [`AuthError::PasswordResetTokenInvalid`] for a /// vanished account, or a store [`AuthError`]. - pub async fn verify_reset_otp(&self, input: VerifyResetOtpInput) -> Result { + pub async fn verify_reset_otp( + &self, + input: VerifyResetOtpInput, + ctx: &RequestContext, + ) -> Result { // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, // and the reset context written for the confirm step) must derive from one // spelling, or a reset started under one casing cannot be completed under another. + // The tenant goes through the resolver, exactly as `login` and `register` do: when one + // is configured it is authoritative and the body value is ignored. That is the + // anti-spoofing promise, and it also keeps this step reading the same tenant the + // initiate step wrote under. + let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let input = VerifyResetOtpInput { email: normalize_email(&input.email), + tenant_id, ..input }; let identifier = self.hashed_identifier(&input.tenant_id, &input.email); @@ -314,13 +347,22 @@ impl AuthEngine { /// account lookup); account state never changes the otherwise-`Ok(())` outcome. The timing /// floor is applied before the error is returned, so an infra error stays /// latency-indistinguishable from a normal response. - pub async fn resend_reset_otp(&self, input: ResendResetOtpInput) -> Result<(), AuthError> { + pub async fn resend_reset_otp( + &self, + input: ResendResetOtpInput, + ctx: &RequestContext, + ) -> Result<(), AuthError> { // Canonicalize first: every key below (the OTP/cooldown identifier, the lookup, // and the reset context written for the confirm step) must derive from one // spelling, or a reset started under one casing cannot be completed under another. + // The tenant goes through the resolver, exactly as `login` and `register` do: when one + // is configured it is authoritative and the body value is ignored. That is the + // anti-spoofing promise, and it also keeps this step reading the same tenant the + // initiate step wrote under. + let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let input = ResendResetOtpInput { email: normalize_email(&input.email), - ..input + tenant_id, }; let started = Instant::now(); // Run the fallible body, then normalize the elapsed time on EVERY exit — the cooldown @@ -531,7 +573,7 @@ fn reset_context_hooks(context: &ResetContext) -> HookContext { #[cfg(test)] mod tests { use super::*; - use crate::services::auth::test_support::{Harness, SeedUser, base_config, harness}; + use crate::services::auth::test_support::{Harness, SeedUser, base_config, ctx, harness}; use crate::traits::{ EmailProvider, OtpStore, PasswordResetStore, SessionKind, SessionStore, UserRepository, }; @@ -600,7 +642,7 @@ mod tests { // drive send_reset_token directly to learn the raw token is single-use end to end. assert!( h.engine - .initiate_reset(forgot("reset@example.com")) + .initiate_reset(forgot("reset@example.com"), &ctx()) .await .is_ok() ); @@ -629,7 +671,7 @@ mod tests { otp: None, verified_token: None, }; - assert!(h.engine.reset_password(reset).await.is_ok()); + assert!(h.engine.reset_password(reset, &ctx()).await.is_ok()); // The password changed and the session was revoked. let after = stored_hash(&h, &id).await; @@ -648,7 +690,7 @@ mod tests { verified_token: None, }; assert!(matches!( - h.engine.reset_password(replay).await, + h.engine.reset_password(replay, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); } @@ -688,7 +730,7 @@ mod tests { otp: None, verified_token: None, }; - assert!(h.engine.reset_password(reset).await.is_ok()); + assert!(h.engine.reset_password(reset, &ctx()).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, @@ -726,7 +768,7 @@ mod tests { verified_token: None, }; assert!(matches!( - h.engine.reset_password(reset).await, + h.engine.reset_password(reset, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); } @@ -742,7 +784,7 @@ mod tests { // Direct OTP path: send, read the code from the in-memory store, reset. assert!( h.engine - .initiate_reset(forgot("otp@example.com")) + .initiate_reset(forgot("otp@example.com"), &ctx()) .await .is_ok() ); @@ -755,23 +797,26 @@ mod tests { otp: Some(code.clone()), verified_token: None, }; - assert!(h.engine.reset_password(reset).await.is_ok()); + assert!(h.engine.reset_password(reset, &ctx()).await.is_ok()); // Verified-token bridge: re-send an OTP, verify it for a token, reset with the token. assert!( h.engine - .initiate_reset(forgot("otp@example.com")) + .initiate_reset(forgot("otp@example.com"), &ctx()) .await .is_ok() ); let Some(code2) = h.stores.peek_otp(OtpPurpose::PasswordReset, &identifier) else { return }; let verified = h .engine - .verify_reset_otp(VerifyResetOtpInput { - email: "otp@example.com".to_owned(), - tenant_id: "t1".to_owned(), - otp: code2, - }) + .verify_reset_otp( + VerifyResetOtpInput { + email: "otp@example.com".to_owned(), + tenant_id: "t1".to_owned(), + otp: code2, + }, + &ctx(), + ) .await; assert!(verified.is_ok()); let Ok(verified_token) = verified else { return }; @@ -783,7 +828,7 @@ mod tests { otp: None, verified_token: Some(verified_token.clone()), }; - assert!(h.engine.reset_password(reset2).await.is_ok()); + assert!(h.engine.reset_password(reset2, &ctx()).await.is_ok()); let _ = id; // The verified token is single-use. let replay = ResetPasswordInput { @@ -795,7 +840,7 @@ mod tests { verified_token: Some(verified_token), }; assert!(matches!( - h.engine.reset_password(replay).await, + h.engine.reset_password(replay, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); } @@ -812,7 +857,7 @@ mod tests { // Started with the address shouted. assert!( h.engine - .initiate_reset(forgot("CASE@Example.COM")) + .initiate_reset(forgot("CASE@Example.COM"), &ctx()) .await .is_ok() ); @@ -835,7 +880,7 @@ mod tests { otp: Some(code), verified_token: None, }; - assert!(h.engine.reset_password(reset).await.is_ok()); + assert!(h.engine.reset_password(reset, &ctx()).await.is_ok()); // The password really changed: the stored hash is not the seeded one. let after = stored_hash(&h, &id).await; assert!(after.is_some() && after != before); @@ -844,7 +889,7 @@ mod tests { // verifies under another and the token it returns completes the reset. assert!( h.engine - .initiate_reset(forgot("case@example.com")) + .initiate_reset(forgot("case@example.com"), &ctx()) .await .is_ok() ); @@ -855,11 +900,14 @@ mod tests { assert!(!second.is_empty()); let verified = h .engine - .verify_reset_otp(VerifyResetOtpInput { - email: "cAsE@eXaMpLe.CoM".to_owned(), - tenant_id: "t1".to_owned(), - otp: second, - }) + .verify_reset_otp( + VerifyResetOtpInput { + email: "cAsE@eXaMpLe.CoM".to_owned(), + tenant_id: "t1".to_owned(), + otp: second, + }, + &ctx(), + ) .await; assert!(verified.is_ok(), "the OTP must verify under any spelling"); let Ok(verified_token) = verified else { return }; @@ -871,7 +919,7 @@ mod tests { otp: None, verified_token: Some(verified_token), }; - assert!(h.engine.reset_password(bridged).await.is_ok()); + assert!(h.engine.reset_password(bridged, &ctx()).await.is_ok()); assert!(stored_hash(&h, &id).await != after); } @@ -888,7 +936,7 @@ mod tests { verified_token: None, }; assert!(matches!( - h.engine.reset_password(none).await, + h.engine.reset_password(none, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); let two = ResetPasswordInput { @@ -900,7 +948,7 @@ mod tests { verified_token: Some("v".to_owned()), }; assert!(matches!( - h.engine.reset_password(two).await, + h.engine.reset_password(two, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); // A token to the OTP method is an explicit mismatch. @@ -913,7 +961,7 @@ mod tests { verified_token: None, }; assert!(matches!( - h.engine.reset_password(mismatch).await, + h.engine.reset_password(mismatch, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); @@ -928,7 +976,7 @@ mod tests { verified_token: None, }; assert!(matches!( - ht.engine.reset_password(otp_to_token).await, + ht.engine.reset_password(otp_to_token, &ctx()).await, Err(AuthError::PasswordResetTokenInvalid) )); } @@ -956,17 +1004,20 @@ mod tests { "absent@example.com", ] { let started = Instant::now(); - assert!(h.engine.initiate_reset(forgot(email)).await.is_ok()); + assert!(h.engine.initiate_reset(forgot(email), &ctx()).await.is_ok()); assert!(started.elapsed() >= Duration::from_millis(300)); } let started = Instant::now(); assert!( h.engine - .resend_reset_otp(ResendResetOtpInput { - email: "present@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .resend_reset_otp( + ResendResetOtpInput { + email: "present@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx() + ) .await .is_ok() ); @@ -974,20 +1025,26 @@ mod tests { // A second resend within the cooldown is the silent-success branch. assert!( h.engine - .resend_reset_otp(ResendResetOtpInput { - email: "present@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .resend_reset_otp( + ResendResetOtpInput { + email: "present@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx() + ) .await .is_ok() ); // An absent account is indistinguishable on resend. assert!( h.engine - .resend_reset_otp(ResendResetOtpInput { - email: "ghost@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .resend_reset_otp( + ResendResetOtpInput { + email: "ghost@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx() + ) .await .is_ok() ); @@ -1006,10 +1063,13 @@ mod tests { ); assert!( h.engine - .resend_reset_otp(ResendResetOtpInput { - email: "SHOUT@Example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .resend_reset_otp( + ResendResetOtpInput { + email: "SHOUT@Example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx() + ) .await .is_ok() ); @@ -1029,17 +1089,20 @@ mod tests { let _ = h.seed(SeedUser::active("vrf@example.com", "pw")).await; assert!( h.engine - .initiate_reset(forgot("vrf@example.com")) + .initiate_reset(forgot("vrf@example.com"), &ctx()) .await .is_ok() ); assert!(matches!( h.engine - .verify_reset_otp(VerifyResetOtpInput { - email: "vrf@example.com".to_owned(), - tenant_id: "t1".to_owned(), - otp: "000000".to_owned(), - }) + .verify_reset_otp( + VerifyResetOtpInput { + email: "vrf@example.com".to_owned(), + tenant_id: "t1".to_owned(), + otp: "000000".to_owned(), + }, + &ctx() + ) .await, Err(AuthError::OtpInvalid) )); @@ -1054,11 +1117,14 @@ mod tests { ); assert!(matches!( h.engine - .verify_reset_otp(VerifyResetOtpInput { - email: "ghost@example.com".to_owned(), - tenant_id: "t1".to_owned(), - otp: "111111".to_owned(), - }) + .verify_reset_otp( + VerifyResetOtpInput { + email: "ghost@example.com".to_owned(), + tenant_id: "t1".to_owned(), + otp: "111111".to_owned(), + }, + &ctx() + ) .await, Err(AuthError::PasswordResetTokenInvalid) )); @@ -1257,7 +1323,7 @@ mod tests { let identifier = h.engine.hashed_identifier("t1", "hooked@example.com"); assert!( h.engine - .initiate_reset(forgot("hooked@example.com")) + .initiate_reset(forgot("hooked@example.com"), &ctx()) .await .is_ok() ); @@ -1274,7 +1340,7 @@ mod tests { otp: Some(code), verified_token: None, }; - assert!(h.engine.reset_password(reset).await.is_ok()); + assert!(h.engine.reset_password(reset, &ctx()).await.is_ok()); // Long enough for the detached notification to have run. tokio::time::sleep(std::time::Duration::from_millis(500)).await; let seen = spy.subjects.lock().map(|s| s.clone()).unwrap_or_default(); @@ -1315,7 +1381,7 @@ mod tests { assert!( engine - .initiate_reset(forgot("mailed@example.com")) + .initiate_reset(forgot("mailed@example.com"), &ctx()) .await .is_ok() ); @@ -1331,7 +1397,7 @@ mod tests { otp: None, verified_token: None, }; - assert!(engine.reset_password(reset).await.is_ok()); + assert!(engine.reset_password(reset, &ctx()).await.is_ok()); let after = users .find_by_id(&user.id, None) .await @@ -1410,7 +1476,7 @@ mod tests { // initiate_reset drives send_reset_token, whose send fails and triggers the cleanup. assert!( engine - .initiate_reset(forgot("fail@example.com")) + .initiate_reset(forgot("fail@example.com"), &ctx()) .await .is_ok() ); @@ -1422,7 +1488,7 @@ mod tests { stores.fail_next_cleanup_writes(1); assert!( engine - .initiate_reset(forgot("fail@example.com")) + .initiate_reset(forgot("fail@example.com"), &ctx()) .await .is_ok() ); @@ -1472,14 +1538,17 @@ mod tests { // invalid, proving the flow did not leave a usable proof behind. assert!(matches!( engine - .reset_password(ResetPasswordInput { - email: "fail@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "x".to_owned(), - token: Some("a".repeat(64)), - otp: None, - verified_token: None, - }) + .reset_password( + ResetPasswordInput { + email: "fail@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "x".to_owned(), + token: Some("a".repeat(64)), + otp: None, + verified_token: None, + }, + &ctx() + ) .await, Err(AuthError::PasswordResetTokenInvalid) )); @@ -1519,7 +1588,7 @@ mod tests { ); assert!( engine - .initiate_reset(forgot("nostore@example.com")) + .initiate_reset(forgot("nostore@example.com"), &ctx()) .await .is_ok() ); @@ -1623,7 +1692,9 @@ mod tests { let Ok(engine) = built else { return }; let started = Instant::now(); - let initiate = engine.initiate_reset(forgot("err@example.com")).await; + let initiate = engine + .initiate_reset(forgot("err@example.com"), &ctx()) + .await; assert!(matches!(initiate, Err(AuthError::Internal(_)))); assert!(started.elapsed() >= Duration::from_millis(300)); @@ -1631,10 +1702,13 @@ mod tests { // backend error surfaces only after the timing floor. let started = Instant::now(); let resend = engine - .resend_reset_otp(ResendResetOtpInput { - email: "err2@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .resend_reset_otp( + ResendResetOtpInput { + email: "err2@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx(), + ) .await; assert!(matches!(resend, Err(AuthError::Internal(_)))); assert!(started.elapsed() >= Duration::from_millis(300)); @@ -1723,14 +1797,17 @@ mod tests { ); assert!( h.engine - .reset_password(ResetPasswordInput { - email: "vanish@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "new".to_owned(), - token: Some(token), - otp: None, - verified_token: None, - }) + .reset_password( + ResetPasswordInput { + email: "vanish@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "new".to_owned(), + token: Some(token), + otp: None, + verified_token: None, + }, + &ctx() + ) .await .is_ok() ); diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index f710fa9..542850c 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -1156,10 +1156,13 @@ async fn engine_runs_password_reset_via_token_against_redis() { // test, so plant a known token via the store to drive the reset deterministically. assert!( engine - .initiate_reset(ForgotPasswordInput { - email: "reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .initiate_reset( + ForgotPasswordInput { + email: "reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx + ) .await .is_ok() ); @@ -1180,14 +1183,17 @@ async fn engine_runs_password_reset_via_token_against_redis() { ); assert!( engine - .reset_password(ResetPasswordInput { - email: "reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "a-brand-new-password".to_owned(), - token: Some("known-reset-token".to_owned()), - otp: None, - verified_token: None, - }) + .reset_password( + ResetPasswordInput { + email: "reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "a-brand-new-password".to_owned(), + token: Some("known-reset-token".to_owned()), + otp: None, + verified_token: None, + }, + &ctx + ) .await .is_ok() ); @@ -1202,14 +1208,17 @@ async fn engine_runs_password_reset_via_token_against_redis() { // The reset token is single-use: a replay is invalid. assert!(matches!( engine - .reset_password(ResetPasswordInput { - email: "reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "again".to_owned(), - token: Some("known-reset-token".to_owned()), - otp: None, - verified_token: None, - }) + .reset_password( + ResetPasswordInput { + email: "reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "again".to_owned(), + token: Some("known-reset-token".to_owned()), + otp: None, + verified_token: None, + }, + &ctx + ) .await, Err(AuthError::PasswordResetTokenInvalid) )); @@ -1227,6 +1236,7 @@ async fn engine_runs_password_reset_via_otp_against_redis() { // OTP method: register, drive the engine to generate+store a real OTP, read the code back // from its `otp:` record, then run the verify→verified-token→reset bridge against real Redis // and confirm the password changed and every session was revoked. + let ctx = RequestContext::new("203.0.113.4", "agent/1.0", BTreeMap::new()); let otp_users = Arc::new(InMemoryUserRepository::new()); let mut otp_config = AuthConfig::default(); otp_config.jwt.secret = SecretString::from("fedcba9876543210fedcba9876543210".to_owned()); @@ -1271,10 +1281,13 @@ async fn engine_runs_password_reset_via_otp_against_redis() { // read the code back from the record's value rather than recomputing the key. assert!( otp_engine - .initiate_reset(ForgotPasswordInput { - email: "otp-reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - }) + .initiate_reset( + ForgotPasswordInput { + email: "otp-reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + }, + &ctx + ) .await .is_ok() ); @@ -1300,24 +1313,30 @@ async fn engine_runs_password_reset_via_otp_against_redis() { // Verify the OTP for a short-lived verified token, then reset through the verified path. let verified = otp_engine - .verify_reset_otp(VerifyResetOtpInput { - email: "otp-reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - otp: code, - }) + .verify_reset_otp( + VerifyResetOtpInput { + email: "otp-reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + otp: code, + }, + &ctx, + ) .await; assert!(verified.is_ok()); let Ok(verified_token) = verified else { return }; assert!( otp_engine - .reset_password(ResetPasswordInput { - email: "otp-reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "a-fresh-new-password".to_owned(), - token: None, - otp: None, - verified_token: Some(verified_token.clone()), - }) + .reset_password( + ResetPasswordInput { + email: "otp-reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "a-fresh-new-password".to_owned(), + token: None, + otp: None, + verified_token: Some(verified_token.clone()), + }, + &ctx + ) .await .is_ok() ); @@ -1343,14 +1362,17 @@ async fn engine_runs_password_reset_via_otp_against_redis() { // The verified token is single-use: a replay through the verified path is rejected. assert!(matches!( otp_engine - .reset_password(ResetPasswordInput { - email: "otp-reset@example.com".to_owned(), - tenant_id: "t1".to_owned(), - new_password: "another".to_owned(), - token: None, - otp: None, - verified_token: Some(verified_token), - }) + .reset_password( + ResetPasswordInput { + email: "otp-reset@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: "another".to_owned(), + token: None, + otp: None, + verified_token: Some(verified_token), + }, + &ctx + ) .await, Err(AuthError::PasswordResetTokenInvalid) )); From 16f59bad643d4d427be3c73feb034367f126fa79 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:47:01 -0300 Subject: [PATCH 099/122] feat(core, axum)!: let a user sign out after their access token expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /auth/logout` sat behind the `AuthUser` extractor. The common case — a user comes back after their access token expired and signs out — therefore answered 401, the engine never ran, and the refresh session stayed live for its full lifetime on a device the user had just told the system to sign out. The long-lived credential is exactly the one logout exists to kill, and it was the one that survived. The refresh token authorizes the operation now, and `AuthEngine::logout` reads the session's owner from the stored record rather than taking it from the caller: the route accepts an absent or forged access token, so a caller-supplied id would let a revocation be aimed at someone else's session. The access token is still verified — signature and pinned algorithm, retired keys included — before its `jti` is blacklisted; only the expiry check is waived, via a new `verify_access_ignoring_expiry`. The blacklist and epoch checks are skipped with it: an already-revoked token is precisely the one whose owner is trying to finish signing out. Reading the token unverified would have been simpler and worse, since `jti` decides which token gets blacklisted. **Breaking.** `logout` drops its `user_id` parameter. `nest-auth` takes the same change. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 10 ++ crates/bymax-auth-axum/src/routes/auth.rs | 17 ++- .../src/services/auth/session_ops.rs | 132 +++++++++++++----- .../src/services/token_manager.rs | 39 +++++- crates/bymax-auth-redis/tests/redis_stores.rs | 2 +- 5 files changed, 153 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d14d660..ea09cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,16 @@ version bump. axum routes supply it through the existing `RequestMeta` extractor. `nest-auth` takes the same change as the Express request. +- **`POST /auth/logout` no longer requires a live access token.** The route sat behind the + `AuthUser` extractor, so a user returning after their access token expired got a 401 and the + engine never ran — the refresh session stayed live for its full lifetime on a device the + user had just told the system to sign out. The refresh token authorizes the operation now, + and `AuthEngine::logout` reads the session's owner from the stored record rather than taking + it from the caller. The access token is still verified (signature + pinned algorithm) before + its `jti` is blacklisted, waiving only the expiry check — an unverified one would let a + caller revoke a token they do not own by naming its id. **Breaking:** `logout` drops its + `user_id` parameter. `nest-auth` takes the same change. + ### Changed - **Family-lineage reuse detection replaces the previous sentinel.** A login opens diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index 8d75701..9451f14 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -102,8 +102,15 @@ async fn login( } } -/// `POST /auth/logout` (204). Requires [`AuthUser`]. Revokes the access JTI and the refresh -/// session, then clears the auth cookies. +/// `POST /auth/logout` (204). Public. Revokes the access JTI and the refresh session, then +/// clears the auth cookies. +/// +/// Deliberately **not** behind [`AuthUser`]. The common case is a user returning after their +/// access token expired and signing out — under that extractor the request answered 401, so +/// the engine never ran and the refresh session stayed live for its full lifetime on a device +/// the user had just told the system to sign out. The refresh token is what authorizes this, +/// and the engine reads the session's owner from the stored record rather than from the +/// caller, so an absent or forged access token cannot aim the revocation elsewhere. /// /// The refresh token is sourced from the cookie **or** the request body, exactly as nest-auth /// does (`AuthController.logout` → `extractRefreshToken`). Reading only the cookie left a real @@ -112,7 +119,6 @@ async fn login( async fn logout( State(state): State, cookies: Cookies, - user: AuthUser, PresentedAccessToken(access_token): PresentedAccessToken, body: axum::body::Bytes, ) -> Response { @@ -125,10 +131,7 @@ async fn logout( &state.config().cookies.refresh_name, dto.refresh_token.as_deref(), ); - let _ = state - .engine() - .logout(&access_token, &refresh, &user.0.sub) - .await; + let _ = state.engine().logout(&access_token, &refresh).await; TokenDelivery::new(state.config()).clear_session(&cookies); StatusCode::NO_CONTENT.into_response() } 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 685b961..fe33b91 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -14,23 +14,45 @@ use crate::traits::{HookContext, SessionKind}; impl AuthEngine { /// Revoke the current session: blacklist the access token's `jti` for its remaining - /// lifetime — only when the token actually verifies — and delete the refresh session + /// lifetime — only when the token's signature verifies — and delete the refresh session /// (idempotent on an already-gone session). /// + /// The caller is **not** required to hold a live access token. The common case is a user + /// returning after their access token expired and signing out: refusing that leaves the + /// refresh session — the long-lived credential logout exists to kill — alive for its whole + /// lifetime, on a device the user just told the system to sign out. The refresh token is + /// what authorizes this operation, and the session's owner is read from the stored record + /// rather than taken from the caller, so an absent or forged access token cannot aim the + /// revocation at somebody else's session. + /// + /// Expiry is waived when verifying the access token, but the signature is not: the `jti` + /// decides which token gets blacklisted, so an unverified one would let a caller revoke a + /// token they do not own by naming its id. + /// /// # Errors /// /// Best-effort cleanup — store failures are swallowed so a logout is never blocked. The /// `Result` is reserved for forward compatibility and currently always returns `Ok`. - pub async fn logout( - &self, - access_token: &str, - raw_refresh: &str, - user_id: &str, - ) -> Result<(), AuthError> { - // Blacklist only a token that actually verifies (signature + algorithm + temporal). - // A forged or expired token needs no revocation, and trusting an unverified `jti` - // would let a caller pollute the revocation set with long-lived junk entries. - if let Ok(claims) = self.tokens().verify_access(access_token).await { + pub async fn logout(&self, access_token: &str, raw_refresh: &str) -> Result<(), AuthError> { + // The stored session names its owner. Presenting the refresh token proves possession; + // the record proves whose it is. An access token's claims cannot serve that purpose + // when the token is allowed to be absent. + let session_hash = is_refresh_token_shape(raw_refresh) + .then(|| RawRefreshToken::from_raw(raw_refresh.to_owned()).redis_hash()); + let user_id = match &session_hash { + Some(hash) => self + .session_store() + .find_session(SessionKind::Dashboard, hash) + .await + .ok() + .flatten() + .map(|record| record.user_id) + .unwrap_or_default(), + None => String::new(), + }; + let user_id = user_id.as_str(); + + if let Ok(claims) = self.tokens().verify_access_ignoring_expiry(access_token) { // Blacklist for the token's residual lifetime only. `try_from` clamps to `0` if // the token lapsed in the window between `verify_access` and this clock read, so a // stale token can never be handed a positive (extended) TTL. Best-effort — a store @@ -47,11 +69,10 @@ impl AuthEngine { // session. Both are best-effort: `SessionNotFound` (already rotated/evicted) and any // other store error are swallowed, so logout is idempotent and never blocks. A // malformed/oversized token is skipped before hashing — it owns no session anyway. - if is_refresh_token_shape(raw_refresh) { - let session_hash = RawRefreshToken::from_raw(raw_refresh.to_owned()).redis_hash(); + if let Some(session_hash) = &session_hash { if let Err(error) = self .session_store() - .revoke_session(SessionKind::Dashboard, user_id, &session_hash) + .revoke_session(SessionKind::Dashboard, user_id, session_hash) .await { // Swallowed by design, but not silently: an operator seeing repeated cleanup @@ -65,7 +86,7 @@ impl AuthEngine { } if let Err(error) = self .session_store() - .delete_grace_pointer(SessionKind::Dashboard, &session_hash) + .delete_grace_pointer(SessionKind::Dashboard, session_hash) .await { tracing::warn!(%error, "logout: grace pointer cleanup failed"); @@ -73,12 +94,16 @@ impl AuthEngine { } tracing::info!(%user_id, "logout: session closed"); - let hook_ctx = identity_only_context(user_id, None, None); - spawn_guarded(run_after_logout( - self.hooks().clone(), - user_id.to_owned(), - hook_ctx, - )); + // The hook names the user who was signed out, so it only fires when the session told + // us who that was. A logout for an already-gone session has nobody to name. + if !user_id.is_empty() { + let hook_ctx = identity_only_context(user_id, None, None); + spawn_guarded(run_after_logout( + self.hooks().clone(), + user_id.to_owned(), + hook_ctx, + )); + } Ok(()) } @@ -229,10 +254,10 @@ mod tests { let mut cfg = base_config(); cfg.email_verification.required = false; let Some(h) = harness(cfg, None) else { return }; - let Some((id, auth)) = logged_in(&h, "out@example.com", "pw").await else { return }; + let Some((_id, auth)) = logged_in(&h, "out@example.com", "pw").await else { return }; assert!( h.engine - .logout(&auth.access_token, &auth.refresh_token, &id) + .logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -248,6 +273,44 @@ mod tests { )); } + #[tokio::test] + async fn logout_revokes_the_session_without_a_live_access_token() { + // The common case: the user comes back after their access token expired and signs + // out. The route used to sit behind the `AuthUser` extractor, so that request answered + // 401 and the engine never ran — the refresh session, the long-lived credential logout + // exists to kill, stayed valid for its full lifetime on a device the user had just + // told the system to sign out. + // + // Driven with NO access token at all, which is the strongest form of the case: the + // owner has to come from the stored session, because there are no claims to read. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let Some((_, auth)) = logged_in(&h, "exp@example.com", "pw").await else { return }; + + assert!(h.engine.logout("", &auth.refresh_token).await.is_ok()); + + // The session is gone: the refresh token no longer rotates. + assert!(matches!( + h.engine + .refresh(&auth.refresh_token, "1.2.3.4", "agent") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + } + + #[tokio::test] + async fn logout_takes_the_owner_from_the_stored_session() { + // A refresh token that matches no live session names nobody, so there is nothing to + // revoke and nothing to attribute — and the call still succeeds, because logout is + // idempotent and must never tell a caller whether a session existed. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let unknown = "0".repeat(64); + assert!(h.engine.logout("", &unknown).await.is_ok()); + } + #[tokio::test] async fn logout_skips_blacklist_for_an_unverified_token_but_revokes_the_session() { // A forged/garbage access token never verifies, so logout skips the blacklist @@ -255,10 +318,10 @@ mod tests { let mut cfg = base_config(); cfg.email_verification.required = false; let Some(h) = harness(cfg, None) else { return }; - let Some((id, auth)) = logged_in(&h, "skip@example.com", "pw").await else { return }; + let Some((_id, auth)) = logged_in(&h, "skip@example.com", "pw").await else { return }; assert!( h.engine - .logout("not-a-jwt", &auth.refresh_token, &id) + .logout("not-a-jwt", &auth.refresh_token) .await .is_ok() ); @@ -282,7 +345,7 @@ mod tests { // unknown user, still succeeding. assert!( h.engine - .logout("not-a-jwt", "unknown-refresh", "user-x") + .logout("not-a-jwt", "unknown-refresh") .await .is_ok() ); @@ -304,12 +367,7 @@ mod tests { epoch: 0, }; let Ok(token) = h.engine.tokens().issue_access(&expired) else { return }; - assert!( - h.engine - .logout(&token, "unknown-refresh", "user-x") - .await - .is_ok() - ); + assert!(h.engine.logout(&token, "unknown-refresh").await.is_ok()); } #[tokio::test] @@ -432,13 +490,13 @@ mod tests { let mut cfg = base_config(); cfg.email_verification.required = false; let Some(h) = harness(cfg, None) else { return }; - let Some((id, auth)) = logged_in(&h, "down@x.io", "pw").await else { return }; + let Some((_id, auth)) = logged_in(&h, "down@x.io", "pw").await else { return }; h.stores.fail_next_cleanup_writes(2); let (events, capture) = crate::log_capture::capture_events(); assert!( h.engine - .logout(&auth.access_token, &auth.refresh_token, &id) + .logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -472,10 +530,10 @@ mod tests { let mut cfg = base_config(); cfg.email_verification.required = false; let Some(h) = harness(cfg, None) else { return }; - let Some((id, auth)) = logged_in(&h, "twice@x.io", "pw").await else { return }; + let Some((_id, auth)) = logged_in(&h, "twice@x.io", "pw").await else { return }; assert!( h.engine - .logout(&auth.access_token, &auth.refresh_token, &id) + .logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -488,7 +546,7 @@ mod tests { let (events, capture) = crate::log_capture::capture_events(); assert!( h.engine - .logout(&auth.access_token, &auth.refresh_token, &id) + .logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 6bcfc88..f0765a9 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -103,18 +103,53 @@ impl TokenManagerService { &self, token: &str, ) -> Result { - let current = verify::(token, &self.key, &VerifyOptions::default()); + self.verify_rotating_with(token, &VerifyOptions::default()) + } + + /// [`Self::verify_rotating`] under caller-chosen options, so one caller can waive the + /// expiry check without every other verification inheriting that. + fn verify_rotating_with( + &self, + token: &str, + opts: &VerifyOptions, + ) -> Result { + let current = verify::(token, &self.key, opts); if current.is_ok() || self.previous_keys.is_empty() { return current; } for key in &self.previous_keys { - if let Ok(claims) = verify::(token, key, &VerifyOptions::default()) { + if let Ok(claims) = verify::(token, key, opts) { return Ok(claims); } } current } + /// Verify an access token's signature under the pinned algorithm while **ignoring its + /// expiry**. + /// + /// Exactly one caller wants this: logout. An access token that expired while the user was + /// away is the normal case there, and refusing the request leaves the refresh session — + /// the long-lived credential logout exists to kill — alive for its whole lifetime. The + /// signature still has to hold: the payload's `jti` decides which token gets blacklisted, + /// so reading it unverified would let a caller revoke an access token they do not own by + /// naming its id. The blacklist and epoch checks are skipped too: an already-revoked token + /// is exactly the one whose owner is trying to finish signing out. + /// + /// # Errors + /// + /// [`AuthError`] when no configured signing key accepts the token. + pub fn verify_access_ignoring_expiry(&self, token: &str) -> Result { + self.verify_rotating_with::( + token, + &VerifyOptions { + validate_exp: false, + ..VerifyOptions::default() + }, + ) + .map_err(map_jwt_error) + } + /// Assemble the token manager from the signing key, the session store, and the /// resolved token lifetimes. pub(crate) fn new( diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index 542850c..eccc9bc 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -1744,7 +1744,7 @@ async fn engine_runs_register_login_refresh_logout_against_redis() { // always Ok. assert!( engine - .logout(&rotated.access_token, &rotated.refresh_token, &auth.user.id) + .logout(&rotated.access_token, &rotated.refresh_token) .await .is_ok() ); From c4d25839e6cc070ebba4c47da9a2594fedd95467 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:23:11 -0300 Subject: [PATCH 100/122] feat(core, axum)!: re-authenticate before MFA enrolment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mfa_setup` was guarded by the access token alone. Enabling MFA changes how the account authenticates, and a token is not proof of who is asking: one lifted by XSS or from a shared machine could enrol an authenticator the attacker holds. The enable then revokes every session and bumps the token epoch — so the real owner is locked out of an account they still know the password to, and the recovery codes were displayed only to the attacker. ASVS requires re-authentication before an authentication factor changes; `disable` already honoured that by demanding a TOTP code. `setup` is gated rather than `verify_and_enable`, so the attacker cannot even obtain a secret they control. An account provisioned purely through OAuth has no local password and is exempt. A missing password still pays the KDF, so "no password sent" and "wrong password" take the same time. **Breaking.** `MfaService::setup` and `AuthEngine::mfa_setup` take `Option<&str>` for the password; both setup routes accept a `password` body field, optional on the wire because the engine is what knows whether this account has one. Found while chasing a coverage gap this change exposed: **the platform recovery-code challenge never gated on winning the temp-token consume**, which the dashboard path was fixed to do earlier. The two planes carry that logic separately and only one had been closed — so one recovery code could still mint two platform sessions. Gated here, with the test that reaches it. Also flushed out by the same gap: several MFA tests seeded accounts with an unparseable `"$scrypt$x"` hash, so enrolment refused and their `else { return }` swallowed it — they passed while exercising nothing. They now seed a real hash (platform admins) or no password at all (the OAuth-shaped unit tests), which is what put the 34 lines those tests were supposed to cover back under test. Coverage stays at 100% lines and 100% functions. --- CHANGELOG.md | 13 + crates/bymax-auth-axum/src/dto.rs | 18 ++ crates/bymax-auth-axum/src/routes/mfa.rs | 16 +- .../src/routes/platform_mfa.rs | 11 +- crates/bymax-auth-axum/tests/adapter.rs | 13 + crates/bymax-auth-axum/tests/redis_e2e.rs | 4 +- crates/bymax-auth-core/src/engine/builder.rs | 3 + .../src/services/adapter_api.rs | 8 +- .../src/services/mfa/challenge.rs | 10 +- .../bymax-auth-core/src/services/mfa/mod.rs | 38 +++ .../bymax-auth-core/src/services/mfa/setup.rs | 19 +- .../bymax-auth-core/src/services/mfa/tests.rs | 237 +++++++++++++++--- .../tests/mfa_lifecycle_e2e.rs | 6 +- .../tests/platform_identity_e2e.rs | 4 +- 14 files changed, 346 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea09cef..eec2068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -150,6 +150,19 @@ version bump. caller revoke a token they do not own by naming its id. **Breaking:** `logout` drops its `user_id` parameter. `nest-auth` takes the same change. +- **MFA enrolment re-authenticates against the account password.** `mfa_setup` was guarded by + the access token alone, so a token lifted by XSS or from a shared machine could enrol an + authenticator the attacker holds — and the enable then revokes every session and bumps the + epoch, locking the real owner out of an account they still know the password to, with the + recovery codes displayed only to the attacker. ASVS requires re-authentication before an + authentication factor changes; `disable` already demanded a TOTP code. An account + provisioned purely through OAuth has no local password and is exempt. **Breaking:** + `MfaService::setup` and `AuthEngine::mfa_setup` take `Option<&str>` for the password, and + the two setup routes accept a `password` body field. `nest-auth` takes the same change. +- **The platform recovery-code challenge gates on winning the temp-token consume**, which the + dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: + the two planes carry the same logic separately, and only one had been fixed. + ### Changed - **Family-lineage reuse detection replaces the previous sentinel.** A login opens diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index 929b083..9094c24 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -133,6 +133,24 @@ pub struct ResendVerificationDto { pub tenant_id: String, } +/// `POST /auth/mfa/setup` body: the account password, re-proving who is asking. +/// +/// Enabling MFA changes how the account authenticates, so an access token alone is not proof +/// of identity: a token lifted by XSS or from a shared machine could otherwise enrol an +/// authenticator the attacker holds, and the enable would revoke every session and lock the +/// real owner out of an account they still know the password to. +/// +/// **Optional in the body, required by the engine whenever the account has a password.** An +/// account provisioned purely through OAuth has none, and refusing those would make MFA +/// unreachable for them. Mirrors nest-auth's `MfaSetupDto`. +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MfaSetupDto { + /// The account password. `None` for an OAuth-only account. + #[garde(length(min = 1, max = 128))] + pub password: Option, +} + /// `POST /auth/mfa/verify-enable` body: the 6-digit TOTP from the authenticator. #[derive(Debug, Deserialize, Validate)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/bymax-auth-axum/src/routes/mfa.rs b/crates/bymax-auth-axum/src/routes/mfa.rs index 4cdb4a7..8c5f522 100644 --- a/crates/bymax-auth-axum/src/routes/mfa.rs +++ b/crates/bymax-auth-axum/src/routes/mfa.rs @@ -18,7 +18,9 @@ use http::StatusCode; use serde_json::json; use crate::delivery::TokenDelivery; -use crate::dto::{MfaChallengeDto, MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaVerifyDto}; +use crate::dto::{ + MfaChallengeDto, MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaSetupDto, MfaVerifyDto, +}; use crate::extractors::AuthUser; use crate::response::error_response; use crate::routes::RequestMeta; @@ -58,10 +60,18 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// /// 201 Created, not 200: nest-auth's `MfaController.setup` carries no `@HttpCode`, so it uses /// Nest's `POST` default of 201, and enrolment does create the pending setup record. -async fn setup(State(state): State, user: AuthUser) -> Response { +async fn setup( + State(state): State, + user: AuthUser, + body: axum::body::Bytes, +) -> Response { + // The body carries the account password. It is optional on the wire — an OAuth-only + // account has none — so an absent or unparseable body degrades to "no password supplied" + // and the engine decides, rather than 400-ing before it can. + let dto: MfaSetupDto = serde_json::from_slice(&body).unwrap_or_default(); match state .engine() - .mfa_setup(&user.0.sub, MfaContext::Dashboard) + .mfa_setup(&user.0.sub, MfaContext::Dashboard, dto.password.as_deref()) .await { Ok(result) => ( diff --git a/crates/bymax-auth-axum/src/routes/platform_mfa.rs b/crates/bymax-auth-axum/src/routes/platform_mfa.rs index 3d653d9..5574473 100644 --- a/crates/bymax-auth-axum/src/routes/platform_mfa.rs +++ b/crates/bymax-auth-axum/src/routes/platform_mfa.rs @@ -45,10 +45,17 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// `POST /auth/platform/mfa/setup` (201). Requires [`PlatformUser`]. 201 for the same reason /// the dashboard enrolment is 201 — the shared nest-auth `MfaController.setup` has no /// `@HttpCode`, so it answers with Nest's `POST` default. -async fn setup(State(state): State, user: PlatformUser) -> Response { +async fn setup( + State(state): State, + user: PlatformUser, + body: axum::body::Bytes, +) -> Response { + // See the dashboard route: the password body is optional on the wire and the engine is + // what decides whether this account needs one. + let dto: crate::dto::MfaSetupDto = serde_json::from_slice(&body).unwrap_or_default(); match state .engine() - .mfa_setup(&user.0.sub, MfaContext::Platform) + .mfa_setup(&user.0.sub, MfaContext::Platform, dto.password.as_deref()) .await { Ok(result) => ( diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index bbfec33..0d63a02 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -787,6 +787,7 @@ async fn mfa_setup_verify_enable_and_challenge_error_arms() { // Nest's `POST` default. let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; assert_eq!(setup.status, StatusCode::CREATED); @@ -951,6 +952,7 @@ async fn platform_mfa_setup_requires_platform_auth() { let access = platform_access(&login); let setup_ok = Req::post("/auth/platform/mfa/setup") .bearer(&access) + .json(serde_json::json!({ "password": "adminpass123" })) .send(&app) .await; assert_eq!(setup_ok.status, StatusCode::CREATED); @@ -1291,6 +1293,7 @@ async fn platform_mfa_full_lifecycle() { // Enrolment answers 201 (Nest's `POST` default, since `MfaController.setup` sets no code). let setup = Req::post("/auth/platform/mfa/setup") .bearer(&access) + .json(serde_json::json!({ "password": "adminpass123" })) .send(&app) .await; assert_eq!(setup.status, StatusCode::CREATED); @@ -1383,6 +1386,7 @@ async fn mfa_dashboard_challenge_success_issues_a_session() { let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1445,6 +1449,7 @@ async fn mfa_challenge_falls_back_to_the_oauth_temp_cookie_and_clears_it() { let access = login0.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1541,6 +1546,7 @@ async fn mfa_challenge_temp_token_sourcing_precedence_and_clearing_policy() { let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1591,6 +1597,7 @@ async fn mfa_challenge_body_token_wins_over_a_stale_cookie() { let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1890,6 +1897,7 @@ async fn platform_login_mfa_challenge_success() { let access = platform_access(&login0); let setup = Req::post("/auth/platform/mfa/setup") .bearer(&access) + .json(serde_json::json!({ "password": "adminpass123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -2073,6 +2081,7 @@ async fn mfa_setup_error_arm_when_already_enabled() { let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -2085,6 +2094,7 @@ async fn mfa_setup_error_arm_when_already_enabled() { // re-enrolment policy, so assert only that it is no longer the 201 success. let again = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; assert_ne!(again.status, StatusCode::CREATED); @@ -2109,6 +2119,7 @@ async fn mfa_verify_enable_error_arm_with_a_wrong_code() { let access = reg.cookie_value("access_token").unwrap_or_default(); let _ = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let resp = Req::post("/auth/mfa/verify-enable") @@ -2226,6 +2237,7 @@ async fn dashboard_mfa_disable_and_recovery_success() { let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) + .json(serde_json::json!({ "password": "password123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -2380,6 +2392,7 @@ async fn mfa_and_platform_challenge_context_mismatch_arms() { let access = platform_access(&login0); let setup = Req::post("/auth/platform/mfa/setup") .bearer(&access) + .json(serde_json::json!({ "password": "adminpass123" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 0275092..264e69f 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -521,7 +521,9 @@ async fn full_router_against_real_redis() { &app, Method::POST, "/auth/mfa/setup", - None, + // Enrolment re-authenticates: the account has a password, so it must be re-proved + // before a factor is minted. + Some(serde_json::json!({ "password": "password123" })), &[("access_token", &mfa_access)], ) .await; diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 6e853a6..babf550 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -453,6 +453,7 @@ impl AuthEngineBuilder { sessions: &sessions, session_store: &session_store, brute_force: &brute_force, + passwords: &passwords, email_provider: &email_provider, hooks: &hooks, sessions_enabled, @@ -540,6 +541,7 @@ struct MfaWiring<'a> { sessions: &'a Arc, session_store: &'a Arc, brute_force: &'a Arc, + passwords: &'a Arc, email_provider: &'a Arc, hooks: &'a Arc, sessions_enabled: bool, @@ -560,6 +562,7 @@ fn build_mfa_service(wiring: MfaWiring<'_>) -> Option, ) -> Result { self.mfa() .ok_or(AuthError::MfaNotEnabled)? - .setup(user_id, ctx) + .setup(user_id, ctx, password) .await } @@ -822,7 +824,7 @@ mod tests { use bymax_auth_types::MfaContext; let Some(h) = harness(base_config(), None) else { return }; assert!(matches!( - h.engine.mfa_setup("u", MfaContext::Dashboard).await, + h.engine.mfa_setup("u", MfaContext::Dashboard, None).await, Err(AuthError::MfaNotEnabled) )); assert!(matches!( diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index cd6bf8a..99ef108 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -228,8 +228,14 @@ impl MfaService { { Some(index) => { // The recovery-code path carries no `tu:` marker, so the temp token is - // consumed standalone now that the code is confirmed valid. - self.tokens.consume_mfa_temp_token(&jti).await?; + // consumed standalone now that the code is confirmed valid — and the + // consume must WIN, exactly as on the dashboard plane. Two concurrent + // challenges on one temp token both observe the marker and both delete + // it; without gating on which delete actually removed it, both issue a + // full session from one single-use recovery code. + if !self.tokens.consume_mfa_temp_token(&jti).await? { + return Err(AuthError::MfaTempTokenInvalid); + } Some(index) } None => { diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index d022d0c..7aa4b48 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -115,6 +115,10 @@ struct MfaUserView { mfa_enabled: bool, mfa_secret: Option, dashboard_user: Option, + /// The account's stored password hash, or `None` for an account provisioned purely + /// through OAuth. Enrolment re-authenticates against it; an account without one has + /// nothing to re-prove here. + password_hash: Option, } /// The MFA lifecycle service. Constructed by the engine builder only when `config.mfa` is @@ -128,6 +132,7 @@ pub struct MfaService { sessions: Arc, session_store: Arc, brute_force: Arc, + passwords: Arc, email: Arc, hooks: Arc, /// AES-256-GCM key for the TOTP secret and the plaintext-codes record. @@ -161,6 +166,7 @@ pub(crate) struct MfaServiceDeps { pub(crate) sessions: Arc, pub(crate) session_store: Arc, pub(crate) brute_force: Arc, + pub(crate) passwords: Arc, pub(crate) email: Arc, pub(crate) hooks: Arc, pub(crate) encryption_key: Zeroizing<[u8; 32]>, @@ -187,6 +193,7 @@ impl MfaService { sessions: deps.sessions, session_store: deps.session_store, brute_force: deps.brute_force, + passwords: deps.passwords, email: deps.email, hooks: deps.hooks, encryption_key: deps.encryption_key, @@ -201,6 +208,35 @@ impl MfaService { } } + /// Require the caller to re-prove the account password before a factor is changed. + /// + /// An account provisioned purely through OAuth has no local password, so there is nothing + /// to re-authenticate against and refusing it would make MFA unreachable for those users + /// — their credential belongs to the provider, which this engine cannot re-verify inline. + /// + /// # Errors + /// + /// [`AuthError::InvalidCredentials`] when the account has a password and the submitted one + /// is absent or wrong. Deliberately the same error a failed login returns: an attacker + /// holding a stolen token learns nothing from it beyond what they already knew. + async fn assert_reauthenticated( + &self, + password_hash: Option<&str>, + password: Option<&str>, + ) -> Result<(), AuthError> { + let Some(hash) = password_hash else { + return Ok(()); + }; + // A missing password still pays the KDF, so "no password sent" and "wrong password" + // take the same time — otherwise the response separates them for free. + let outcome = self.passwords.verify(password.unwrap_or(""), hash).await?; + if outcome.matched { + Ok(()) + } else { + Err(AuthError::InvalidCredentials) + } + } + /// The `mfa_setup:` key suffix for a user (`hmac_sha256("{plane}:{user_id}")`, hex). The /// low-entropy id is keyed, never used raw, so no PII reaches a store key. /// @@ -415,6 +451,7 @@ impl MfaService { email: user.email.clone(), mfa_enabled: user.mfa_enabled, mfa_secret: user.mfa_secret.clone(), + password_hash: user.password_hash.clone(), dashboard_user: Some(SafeAuthUser::from(user)), }) } @@ -432,6 +469,7 @@ impl MfaService { email: admin.email, mfa_enabled: admin.mfa_enabled, mfa_secret: admin.mfa_secret, + password_hash: Some(admin.password_hash), dashboard_user: None, }) } diff --git a/crates/bymax-auth-core/src/services/mfa/setup.rs b/crates/bymax-auth-core/src/services/mfa/setup.rs index 8f75b36..14f17d9 100644 --- a/crates/bymax-auth-core/src/services/mfa/setup.rs +++ b/crates/bymax-auth-core/src/services/mfa/setup.rs @@ -20,11 +20,28 @@ impl MfaService { /// Returns [`AuthError::MfaNotEnabled`] for a platform context with no platform repository /// or a missing account, [`AuthError::MfaAlreadyEnabled`] when MFA is already on, or an /// internal/store [`AuthError`]. - pub async fn setup(&self, user_id: &str, ctx: MfaContext) -> Result { + pub async fn setup( + &self, + user_id: &str, + ctx: MfaContext, + password: Option<&str>, + ) -> Result { let view = self.fetch_user_mfa(user_id, ctx).await?; if view.mfa_enabled { return Err(AuthError::MfaAlreadyEnabled); } + + // Re-authenticate before minting a factor. Enabling MFA changes how the account + // authenticates, and an access token alone is not proof of who is asking: a token + // lifted by XSS or from a shared machine could otherwise enrol an authenticator the + // attacker holds — and the enable then revokes every session and bumps the epoch, + // locking the real owner out of an account they still know the password to, with the + // recovery codes displayed only to the attacker. ASVS requires re-authentication + // before an authentication factor changes; `disable` already demands a TOTP code. + // Gating `setup` rather than `verify_and_enable` means the attacker cannot even obtain + // a secret they control, and it costs the user one prompt at the natural moment. + self.assert_reauthenticated(view.password_hash.as_deref(), password) + .await?; let key = self.setup_key(ctx, user_id); // Fast-path idempotency: an existing pending record is re-returned verbatim, so a user diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index b6a73bc..ae68da6 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -35,6 +35,16 @@ fn key_b64() -> String { base64::engine::general_purpose::STANDARD.encode([7u8; 32]) } +/// A real scrypt hash of [`PASSWORD`], for platform admins seeded straight into the +/// repository. `AuthPlatformUser::password_hash` is a plain `String`, so every admin has one +/// and enrolment's re-authentication always applies on that plane — a placeholder like +/// `"$scrypt$x"` makes `setup` refuse, and a test that swallows the refusal with `else +/// { return }` then passes while exercising nothing. +fn admin_password_hash() -> String { + let params = bymax_auth_crypto::password::PasswordParams::default(); + bymax_auth_crypto::password::hash(PASSWORD.as_bytes(), ¶ms).unwrap_or_default() +} + /// A request context for the engine flows. fn ctx() -> RequestContext { RequestContext::new("203.0.113.4", "agent/1.0", BTreeMap::new()) @@ -239,7 +249,7 @@ async fn full_dashboard_lifecycle() { }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert_eq!(setup.recovery_codes.len(), 8); @@ -253,7 +263,7 @@ async fn full_dashboard_lifecycle() { ); // Idempotent setup returns the same material (fast-path). - let Ok(again) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(again) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert_eq!(setup.secret, again.secret); @@ -277,7 +287,7 @@ async fn full_dashboard_lifecycle() { ); // No read path re-exposes the secret: a further setup is rejected, never re-returning it. assert!(matches!( - mfa.setup(&uid, MfaContext::Dashboard).await, + mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await, Err(AuthError::MfaAlreadyEnabled) )); @@ -467,7 +477,7 @@ async fn every_mfa_state_change_alerts_the_account_owner() { return; }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; let base = now_secs(); @@ -549,7 +559,7 @@ async fn a_challenge_registers_its_session_with_the_session_service() { return; }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; let base = now_secs(); @@ -597,7 +607,7 @@ async fn anti_replay_rejects_a_code_already_used_on_enable() { return; }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; let enable_code = code(&setup.secret, 0); @@ -625,16 +635,16 @@ async fn setup_rejects_already_enabled_and_a_platform_context_without_a_repo() { let Some(mfa) = h.engine.mfa() else { return }; // No platform repository is wired, so a platform context fails fast. assert!(matches!( - mfa.setup(&uid, MfaContext::Platform).await, + mfa.setup(&uid, MfaContext::Platform, Some(PASSWORD)).await, Err(AuthError::MfaNotEnabled) )); // An unknown user is also `MfaNotEnabled`. assert!(matches!( - mfa.setup("ghost", MfaContext::Dashboard).await, + mfa.setup("ghost", MfaContext::Dashboard, None).await, Err(AuthError::MfaNotEnabled) )); // Enable, then a second setup is rejected. - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert!( @@ -649,7 +659,7 @@ async fn setup_rejects_already_enabled_and_a_platform_context_without_a_repo() { .is_ok() ); assert!(matches!( - mfa.setup(&uid, MfaContext::Dashboard).await, + mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await, Err(AuthError::MfaAlreadyEnabled) )); assert!(matches!( @@ -678,7 +688,7 @@ async fn enable_requires_a_pending_record_and_rejects_a_wrong_code() { .await, Err(AuthError::MfaSetupRequired) )); - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; // A wrong code does not enable and does not consume the pending record. @@ -789,7 +799,7 @@ async fn challenge_locks_out_after_repeated_wrong_codes() { return; }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert!( @@ -822,7 +832,7 @@ async fn challenge_locks_out_after_repeated_wrong_codes() { let Some(other) = register(&h.engine, "other@example.com").await else { return; }; - let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard).await else { + let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard, None).await else { return; }; let base = now_secs(); @@ -866,10 +876,10 @@ async fn two_users_setting_up_never_share_a_pending_record() { }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(a) = mfa.setup(&first, MfaContext::Dashboard).await else { + let Ok(a) = mfa.setup(&first, MfaContext::Dashboard, None).await else { return; }; - let Ok(b) = mfa.setup(&second, MfaContext::Dashboard).await else { + let Ok(b) = mfa.setup(&second, MfaContext::Dashboard, None).await else { return; }; assert_ne!(a.secret, b.secret); @@ -914,7 +924,7 @@ async fn disable_is_totp_only_and_regenerate_keeps_sessions() { .await, Err(AuthError::MfaNotEnabled) )); - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert!( @@ -977,7 +987,7 @@ async fn disable_locks_out_after_repeated_wrong_codes() { return; }; let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, Some(PASSWORD)).await else { return; }; assert!( @@ -1010,7 +1020,7 @@ async fn disable_locks_out_after_repeated_wrong_codes() { let Some(other) = register(&h.engine, "dislock2@example.com").await else { return; }; - let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard).await else { + let Ok(other_setup) = mfa.setup(&other, MfaContext::Dashboard, None).await else { return; }; let base = now_secs(); @@ -1047,7 +1057,7 @@ async fn platform_context_routes_to_the_platform_repository() { id: "p1".to_owned(), email: "admin@example.com".to_owned(), name: "Admin".to_owned(), - password_hash: "$scrypt$x".to_owned(), + password_hash: admin_password_hash(), role: "SUPER".to_owned(), status: "ACTIVE".to_owned(), mfa_enabled: false, @@ -1060,7 +1070,7 @@ async fn platform_context_routes_to_the_platform_repository() { }; h.platform.insert(admin); let Some(mfa) = h.engine.mfa() else { return }; - let Ok(setup) = mfa.setup("p1", MfaContext::Platform).await else { + let Ok(setup) = mfa.setup("p1", MfaContext::Platform, Some(PASSWORD)).await else { return; }; assert!( @@ -1116,7 +1126,7 @@ async fn platform_challenge_exchanges_a_temp_token_for_a_full_platform_session() id: "p1".to_owned(), email: "admin@example.com".to_owned(), name: "Admin".to_owned(), - password_hash: "$scrypt$x".to_owned(), + password_hash: admin_password_hash(), role: "SUPER_ADMIN".to_owned(), status: "ACTIVE".to_owned(), mfa_enabled: false, @@ -1132,7 +1142,7 @@ async fn platform_challenge_exchanges_a_temp_token_for_a_full_platform_session() // Enable MFA on the platform admin so a challenge has a secret to verify against. let base = now_secs(); - let Ok(setup) = mfa.setup("p1", MfaContext::Platform).await else { + let Ok(setup) = mfa.setup("p1", MfaContext::Platform, Some(PASSWORD)).await else { return; }; let enable_code = code_at(&setup.secret, base); @@ -1217,7 +1227,7 @@ async fn platform_challenge_rejects_a_wrong_code_and_keeps_the_temp_token_alive( id: "p2".to_owned(), email: "retry@example.com".to_owned(), name: "Admin".to_owned(), - password_hash: "$scrypt$x".to_owned(), + password_hash: admin_password_hash(), role: "SUPER_ADMIN".to_owned(), status: "ACTIVE".to_owned(), mfa_enabled: false, @@ -1230,7 +1240,7 @@ async fn platform_challenge_rejects_a_wrong_code_and_keeps_the_temp_token_alive( }); let Some(mfa) = h.engine.mfa() else { return }; let base = now_secs(); - let Ok(setup) = mfa.setup("p2", MfaContext::Platform).await else { + let Ok(setup) = mfa.setup("p2", MfaContext::Platform, Some(PASSWORD)).await else { return; }; assert!( @@ -1277,7 +1287,7 @@ async fn platform_challenge_rejects_an_admin_without_enabled_mfa_or_a_secret() { id: "p-no".to_owned(), email: "noenroll@example.com".to_owned(), name: "Admin".to_owned(), - password_hash: "$scrypt$x".to_owned(), + password_hash: admin_password_hash(), role: "SUPER_ADMIN".to_owned(), status: "ACTIVE".to_owned(), // MFA not enabled and no secret stored. @@ -1327,7 +1337,7 @@ async fn platform_challenge_with_an_undecryptable_secret_is_an_opaque_failure() id: "p-corrupt".to_owned(), email: "corrupt@example.com".to_owned(), name: "Admin".to_owned(), - password_hash: "$scrypt$x".to_owned(), + password_hash: admin_password_hash(), role: "SUPER_ADMIN".to_owned(), status: "ACTIVE".to_owned(), mfa_enabled: true, @@ -1476,8 +1486,18 @@ fn service_deps(store: Arc, users: Arc) -> 3600, )); let brute_force = Arc::new(BruteForceService::new(brute_force_store, 5, 900)); + // Enrolment re-authenticates against the account password, so the service needs a real + // hasher. The scrypt cost is the configured default — these tests hash at most once. + let passwords = Arc::new( + crate::services::password::PasswordService::new( + &crate::config::PasswordConfig::default(), + Arc::new(crate::traits::AllowAllBreachChecker), + ) + .unwrap_or_else(|_| unreachable!("the default password config always builds")), + ); MfaServiceDeps { mfa_store: store, + passwords, user_repo: users, platform_repo: None, tokens, @@ -1508,7 +1528,10 @@ async fn seed_user(users: &InMemoryUserRepository, email: &str) -> Option = Arc::new(LosingConsumeMfaStore { + inner: inner.clone(), + }); + + let plain = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"; + let digest = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( + &[9u8; 64], + plain.as_bytes(), + )); + + let mut deps = service_deps(losing.clone(), users); + deps.platform_repo = Some(admins.clone()); + deps.tokens = Arc::new( + TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), + inner.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ) + .with_mfa_support(crate::services::token_manager::MfaTokenSupport::new( + losing.clone(), + )), + ); + let service = MfaService::new(deps); + + let material = service.generate_setup_material(); + let Ok((_, _, data)) = material else { return }; + admins.insert(AuthPlatformUser { + id: "plose".to_owned(), + email: "plose@example.com".to_owned(), + name: "Admin".to_owned(), + password_hash: admin_password_hash(), + role: "SUPER_ADMIN".to_owned(), + status: "ACTIVE".to_owned(), + mfa_enabled: true, + mfa_secret: Some(data.encrypted_secret), + mfa_recovery_codes: Some(vec![digest]), + platform_id: None, + last_login_at: None, + updated_at: OffsetDateTime::UNIX_EPOCH, + created_at: OffsetDateTime::UNIX_EPOCH, + }); + + let issued = service + .tokens + .issue_mfa_temp_token("plose", MfaContext::Platform) + .await; + let Ok(temp) = issued else { return }; + + let outcome = service.challenge(&temp, plain, "1.2.3.4", "ua").await; + assert!( + matches!(outcome, Err(AuthError::MfaTempTokenInvalid)), + "a lost consume must issue no platform session, got {outcome:?}" + ); +} diff --git a/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs b/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs index dc58158..515552a 100644 --- a/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs +++ b/crates/bymax-auth-redis/tests/mfa_lifecycle_e2e.rs @@ -132,7 +132,7 @@ async fn full_lifecycle_against_real_redis() { // setup → enable. Compute the lifecycle's distinct TOTP codes from one captured base // (steps s, s+1, s+2, s-1), so they never collide as the clock advances mid-test. - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, None).await else { return }; assert_eq!(setup.recovery_codes.len(), 8); let base = now_secs(); let enable_code = code_at(&setup.secret, base); @@ -217,7 +217,7 @@ async fn concurrent_correct_totp_yields_one_session() { let secret; { let Some(mfa) = engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, None).await else { return }; if mfa .verify_and_enable( &uid, @@ -279,7 +279,7 @@ async fn concurrent_distinct_valid_codes_yield_one_session() { let secret; { let Some(mfa) = engine.mfa() else { return }; - let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard).await else { return }; + let Ok(setup) = mfa.setup(&uid, MfaContext::Dashboard, None).await else { return }; if mfa .verify_and_enable( &uid, diff --git a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs index 41b6c52..9999e94 100644 --- a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs +++ b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs @@ -302,7 +302,9 @@ async fn platform_mfa_challenge_exchange_issues_a_full_session_against_redis() { // exchange it for a full platform session with a valid TOTP code — the login → challenge → // full-token exchange, end to end against Redis. let base = now_secs(); - let setup_result = mfa.setup(&id, MfaContext::Platform).await; + // Enrolment re-authenticates: this admin has a password, so it must be re-proved + // before a factor is minted. + let setup_result = mfa.setup(&id, MfaContext::Platform, Some(PASSWORD)).await; assert!( setup_result.is_ok(), "platform MFA setup must succeed: {setup_result:?}" From 5d0f33dd76b6e616ea4c67f25ae22bde587da050 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:53:29 -0300 Subject: [PATCH 101/122] feat(core, axum)!: bind the OAuth state to the browser that started the flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `state` nonce was validated against the store alone, which proves only that somebody started a flow — not that this browser did. An attacker could run their own authorization, complete consent at the provider, capture the resulting `?code=...&state=...` callback URL without visiting it, and lure the victim there: the victim's browser then received the attacker's session, and everything they did next landed in the attacker's account. PKCE does not cover this, because the verifier is held server-side and replayed for whoever presents the state. `oauth_initiate` now returns `OAuthRedirect { authorize_url, state }` and the Axum adapter plants the raw state as an HttpOnly `oauth_state` cookie; the callback refuses any request that does not carry it back, as RFC 6749 section 10.12 requires. Two details are load-bearing: - `SameSite` is `Lax`, not the value the refresh cookie uses. The provider's callback is a cross-site top-level GET, and `Strict` would withhold the cookie on exactly that hop, breaking every OAuth login on a deployment that hardened the setting everywhere else. - The check runs before `take_state`, so a lured victim cannot burn a state the legitimate browser is still entitled to spend. BREAKING CHANGE: `AuthEngine::oauth_initiate` returns `OAuthRedirect` instead of `String`, and `AuthEngine::oauth_callback` takes the state cookie as its fourth argument. --- CHANGELOG.md | 16 ++ README.md | 6 + crates/bymax-auth-axum/src/delivery.rs | 42 +++++ crates/bymax-auth-axum/src/routes/oauth.rs | 24 ++- crates/bymax-auth-axum/tests/adapter.rs | 37 +++++ crates/bymax-auth-axum/tests/redis_e2e.rs | 4 +- crates/bymax-auth-core/src/lib.rs | 2 +- crates/bymax-auth-core/src/services/oauth.rs | 157 ++++++++++++++++--- crates/bymax-auth-types/src/constants.rs | 17 ++ 9 files changed, 278 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eec2068..29c8ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,22 @@ version bump. provisioned purely through OAuth has no local password and is exempt. **Breaking:** `MfaService::setup` and `AuthEngine::mfa_setup` take `Option<&str>` for the password, and the two setup routes accept a `password` body field. `nest-auth` takes the same change. +- **The OAuth `state` is bound to the browser that started the flow.** The `state` nonce was + validated against the store alone, which proves only that *somebody* started a flow. An + attacker could run their own authorization, complete consent at the provider, capture the + resulting `?code=…&state=…` callback URL without visiting it, and lure the victim there: the + victim's browser then received the attacker's session, and everything they did next — a + payment method, an uploaded document, a linked account — landed in the attacker's hands. + PKCE does not cover this, because the verifier is held server-side and replayed for whoever + presents the state. `oauth_initiate` now returns an `OAuthRedirect { authorize_url, state }` + and the Axum adapter plants the raw state as an HttpOnly `oauth_state` cookie; the callback + refuses any request that does not carry it back, as RFC 6749 §10.12 requires. The cookie is + `SameSite=Lax` — the provider's callback is a cross-site top-level GET, and `Strict` would + withhold the cookie on exactly that hop — and the check runs *before* `take_state`, so a + lured victim cannot burn a state the legitimate browser is still entitled to spend. + **Breaking:** `AuthEngine::oauth_initiate` returns `OAuthRedirect` instead of `String`, and + `AuthEngine::oauth_callback` takes the cookie as its fourth argument. `nest-auth` takes the + same change. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/README.md b/README.md index 8ddb6dc..abb4666 100644 --- a/README.md +++ b/README.md @@ -732,6 +732,12 @@ Route groups mount only when their feature **and** runtime toggle are enabled, s | GET | `/auth/oauth/:provider/callback` | Public | Handle the callback, exchange the code, issue tokens | | POST | `/auth/ws-ticket` | `AuthUser`, `UserStatus`, `MfaSatisfied` | Mint a single-use WebSocket upgrade ticket | +> `GET /auth/oauth/:provider` plants an HttpOnly `oauth_state` cookie carrying the flow's +> `state`, and the callback refuses any request that does not send it back — the binding +> RFC 6749 §10.12 requires, without which an attacker can hand a victim a callback URL and +> have the victim's browser complete the attacker's login. The router's cookie layer handles +> this; a custom mount must keep `CookieManagerLayer` in place. + ### Extractors (Axum `FromRequestParts`) | Extractor | Purpose | diff --git a/crates/bymax-auth-axum/src/delivery.rs b/crates/bymax-auth-axum/src/delivery.rs index 93b1bfc..64ac81e 100644 --- a/crates/bymax-auth-axum/src/delivery.rs +++ b/crates/bymax-auth-axum/src/delivery.rs @@ -127,6 +127,48 @@ impl<'a> TokenDelivery<'a> { cookies.remove(Cookie::build((c.signal_name.clone(), "")).path("/").build()); } + /// Plant the ephemeral OAuth `state` cookie, binding the flow to the browser that started + /// it (RFC 6749 §10.12). Scoped to `/` because the callback path is operator-configured and + /// the core cannot know it; HttpOnly because nothing client-side reads it; Max-Age pinned + /// to the 600 s TTL of the server-side `os:` record so neither half outlives the other. + /// + /// `SameSite` is always `Lax`, never the configured value: the provider redirects the + /// browser back with a top-level GET, which is a **cross-site** navigation, and `Strict` + /// withholds the cookie on exactly that hop — a deployment that hardened the setting + /// everywhere else would find every OAuth login broken with no way to complete it. `Lax` + /// is the tightest value that survives the callback, and it is enough: the cookie is read + /// on that one navigation and is useless to a cross-site *request*. + #[cfg(feature = "oauth")] + pub(crate) fn set_oauth_state_cookie(&self, cookies: &Cookies, state: &str) { + use bymax_auth_types::constants::{ + OAUTH_STATE_COOKIE_MAX_AGE_SECONDS, OAUTH_STATE_COOKIE_NAME, + }; + let max_age = i64::try_from(OAUTH_STATE_COOKIE_MAX_AGE_SECONDS).unwrap_or(i64::MAX); + cookies.add( + Cookie::build((OAUTH_STATE_COOKIE_NAME.to_owned(), state.to_owned())) + .path("/") + .http_only(true) + .secure(self.cookies().secure) + .same_site(SameSite::Lax) + .max_age(Duration::seconds(max_age)) + .build(), + ); + } + + /// Clear the OAuth `state` cookie once its callback has been handled, reusing the exact + /// `Path` it was planted with. The cookie is single-use: a stale one left behind would + /// never match the next flow's freshly minted state, turning one failed login into a + /// permanently broken one. + #[cfg(feature = "oauth")] + pub(crate) fn clear_oauth_state_cookie(&self, cookies: &Cookies) { + use bymax_auth_types::constants::OAUTH_STATE_COOKIE_NAME; + cookies.remove( + Cookie::build((OAUTH_STATE_COOKIE_NAME.to_owned(), "")) + .path("/") + .build(), + ); + } + /// Plant the ephemeral MFA-temp cookie (§14.1): path-scoped to the MFA challenge path, /// HttpOnly, Secure-by-default, `SameSite` aligned with the refresh cookie, Max-Age /// pinned to the temp-token's 300 s lifetime so the cookie can never outlive the JWT. diff --git a/crates/bymax-auth-axum/src/routes/oauth.rs b/crates/bymax-auth-axum/src/routes/oauth.rs index 4087313..8ba37fe 100644 --- a/crates/bymax-auth-axum/src/routes/oauth.rs +++ b/crates/bymax-auth-axum/src/routes/oauth.rs @@ -55,6 +55,7 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout /// `GET /auth/oauth/{provider}` (302). Public. Redirects to the provider authorize URL. async fn initiate( State(state): State, + cookies: Cookies, Path(provider): Path, ValidatedQuery(query): ValidatedQuery, ) -> Response { @@ -63,7 +64,12 @@ async fn initiate( .oauth_initiate(&provider, &query.tenant_id) .await { - Ok(authorize_url) => found(&authorize_url), + Ok(redirect) => { + // Bind the flow to this browser: the callback refuses any request that does not + // send this cookie back (RFC 6749 §10.12). See `set_oauth_state_cookie`. + TokenDelivery::new(state.config()).set_oauth_state_cookie(&cookies, &redirect.state); + found(&redirect.authorize_url) + } Err(error) => error_response(&error), } } @@ -77,9 +83,23 @@ async fn callback( RequestMeta(ctx): RequestMeta, ValidatedQuery(query): ValidatedQuery, ) -> Response { + // The state cookie is single-use: it is spent the moment the callback is handled, whatever + // the outcome. Read before clearing, and cleared before the outcome is known, so a failed + // attempt cannot leave a stale cookie for the next flow to trip over. + let state_cookie = cookies + .get(bymax_auth_types::constants::OAUTH_STATE_COOKIE_NAME) + .map(|cookie| cookie.value().to_owned()); + TokenDelivery::new(state.config()).clear_oauth_state_cookie(&cookies); + let outcome = state .engine() - .oauth_callback(&provider, &query.code, &query.state, &ctx) + .oauth_callback( + &provider, + &query.code, + &query.state, + state_cookie.as_deref(), + &ctx, + ) .await; match outcome { Ok(OAuthOutcome::Authenticated(result)) => { diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 0d63a02..b15dde3 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -1039,10 +1039,35 @@ async fn oauth_initiate_redirects_and_callback_completes() { .unwrap_or("") .to_owned(); + // The initiate response planted the binding cookie: HttpOnly, SameSite=Lax (the provider's + // callback is a cross-site top-level GET, and Strict would withhold the cookie on exactly + // that hop), scoped to `/`, carrying the same state the authorize URL carries. + let planted = initiate.cookie("oauth_state").unwrap_or_default(); + assert!(planted.contains(&format!("oauth_state={state}"))); + assert!(planted.contains("HttpOnly")); + assert!(planted.contains("SameSite=Lax")); + assert!(planted.contains("Path=/")); + + // Without the cookie, a callback carrying a live state is refused — the lured-victim + // request, where the attacker holds the URL and the victim's browser holds no cookie for + // the attacker's flow (RFC 6749 §10.12). Sent BEFORE the legitimate callback below, so + // the 401 cannot be explained by an already-spent state; the success that follows proves + // the refusal did not burn it either. + let lured = Req::get(&format!( + "/auth/oauth/google/callback?code=abc&state={state}" + )) + .send(&app) + .await; + assert_eq!(lured.status, StatusCode::UNAUTHORIZED); + assert_eq!(lured.json()["error"]["code"], "auth.oauth_failed"); + // The callback consumes the state and (via the allowing hook) creates a session. let callback = Req::get(&format!( "/auth/oauth/google/callback?code=abc&state={state}" )) + // The browser replays the `oauth_state` cookie the initiate response planted; without it + // the callback is refused, which is the whole point of the binding (RFC 6749 §10.12). + .cookie("oauth_state", &state) .send(&app) .await; assert_eq!(callback.status, StatusCode::OK); @@ -1482,6 +1507,9 @@ async fn mfa_challenge_falls_back_to_the_oauth_temp_cookie_and_clears_it() { let callback = Req::get(&format!( "/auth/oauth/google/callback?code=abc&state={state}" )) + // The browser replays the `oauth_state` cookie the initiate response planted; without it + // the callback is refused, which is the whole point of the binding (RFC 6749 §10.12). + .cookie("oauth_state", &state) .send(&app) .await; let temp_cookie = callback.cookie_value("mfa_temp_token").unwrap_or_default(); @@ -1687,6 +1715,9 @@ async fn oauth_callback_redirect_branches() { let callback = Req::get(&format!( "/auth/oauth/google/callback?code=abc&state={state}" )) + // The browser replays the `oauth_state` cookie the initiate response planted; without it + // the callback is refused, which is the whole point of the binding (RFC 6749 §10.12). + .cookie("oauth_state", &state) .send(&app) .await; assert_eq!(callback.status, StatusCode::FOUND); @@ -1967,6 +1998,9 @@ async fn oauth_callback_with_mfa_user_takes_the_mfa_redirect_branch() { let callback = Req::get(&format!( "/auth/oauth/google/callback?code=abc&state={state}" )) + // The browser replays the `oauth_state` cookie the initiate response planted; without it + // the callback is refused, which is the whole point of the binding (RFC 6749 §10.12). + .cookie("oauth_state", &state) .send(&app) .await; // The MFA branch: a 302 to the mfa redirect, with the ephemeral mfa_temp cookie planted. @@ -2182,6 +2216,9 @@ async fn oauth_callback_mfa_branch_without_redirect_returns_json() { let callback = Req::get(&format!( "/auth/oauth/google/callback?code=abc&state={state}" )) + // The browser replays the `oauth_state` cookie the initiate response planted; without it + // the callback is refused, which is the whole point of the binding (RFC 6749 §10.12). + .cookie("oauth_state", &state) .send(&app) .await; // No redirect configured → 200 JSON challenge body, with the mfa_temp cookie planted. diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 264e69f..30c15b1 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -420,7 +420,9 @@ async fn full_router_against_real_redis() { Method::GET, &format!("/auth/oauth/google/callback?code=abc&state={state}"), None, - &[], + // The browser replays the `oauth_state` cookie planted on initiate; the callback + // refuses without it (RFC 6749 §10.12). + &[("oauth_state", state.as_str())], ) .await; assert_eq!(callback.status, StatusCode::OK); diff --git a/crates/bymax-auth-core/src/lib.rs b/crates/bymax-auth-core/src/lib.rs index 2cd2d8f..3bf9b22 100644 --- a/crates/bymax-auth-core/src/lib.rs +++ b/crates/bymax-auth-core/src/lib.rs @@ -64,7 +64,7 @@ pub use providers::ReqwestHttpClient; pub use services::mfa::{LoginResultMfa, MfaService, MfaSetupResult}; #[cfg(feature = "oauth")] #[doc(inline)] -pub use services::oauth::OAuthOutcome; +pub use services::oauth::{OAuthOutcome, OAuthRedirect}; #[cfg(feature = "platform")] #[doc(inline)] pub use services::platform::PlatformAuthService; diff --git a/crates/bymax-auth-core/src/services/oauth.rs b/crates/bymax-auth-core/src/services/oauth.rs index 3847332..cfb7c66 100644 --- a/crates/bymax-auth-core/src/services/oauth.rs +++ b/crates/bymax-auth-core/src/services/oauth.rs @@ -22,6 +22,8 @@ use bymax_auth_types::{ }; use serde::{Deserialize, Serialize}; +use bymax_auth_crypto::compare::constant_time_eq; + use crate::RepositoryError; use crate::context::{RequestContext, to_safe_user}; use crate::engine::AuthEngine; @@ -59,6 +61,24 @@ struct OAuthStatePayload { code_verifier: String, } +/// What [`AuthEngine::oauth_initiate`] hands back: the provider authorization URL to redirect +/// to, and the raw `state` the adapter must plant as a cookie on that same response. +/// +/// The two travel together because they are one decision. A `state` validated against the +/// store alone proves only that *somebody* started a flow: an attacker can run their own +/// authorization, hold the resulting `?code=…&state=…` URL without visiting it, and lure the +/// victim there — the victim's browser then completes the attacker's login. RFC 6749 §10.12 +/// requires the state to be bound to the user agent, and the cookie is the only carrier the +/// core can offer a transport it knows nothing about. It deliberately derives no `Debug`: the +/// raw state is a bearer value for the length of the flow and must not reach a log line. +pub struct OAuthRedirect { + /// The provider authorization URL, already carrying the `state` and PKCE challenge. + pub authorize_url: String, + /// The raw `state`, to be planted as the `oauth_state` cookie. Never stored server-side — + /// only its hash is a key. + pub state: String, +} + /// The outcome of [`AuthEngine::oauth_callback`]: either a full authentication or an MFA /// challenge. Discriminated like the password-login [`bymax_auth_types::LoginResult`], so the /// adapter shapes the response the same way. @@ -92,7 +112,7 @@ impl AuthEngine { &self, provider: &str, tenant_id: &str, - ) -> Result { + ) -> Result { // Resolve the provider first: an unknown provider fails without minting state. let provider_impl = self.resolve_oauth_provider(provider)?; @@ -108,7 +128,10 @@ impl AuthEngine { .put_state(&state_key(&state), &payload, OAUTH_STATE_TTL_SECS) .await?; - Ok(provider_impl.authorize_url(&state, Some(&code_challenge))) + Ok(OAuthRedirect { + authorize_url: provider_impl.authorize_url(&state, Some(&code_challenge)), + state, + }) } /// Complete an OAuth callback (§11.3.2): resolve the provider, atomically consume the @@ -130,6 +153,7 @@ impl AuthEngine { provider: &str, code: &str, state: &str, + state_cookie: Option<&str>, ctx: &RequestContext, ) -> Result { // Resolve the provider BEFORE consuming the state, so a misconfigured provider does @@ -146,6 +170,20 @@ impl AuthEngine { return Err(AuthError::OauthFailed); } + // Bind the callback to the browser that started the flow (RFC 6749 §10.12). A `state` + // that merely exists in the store proves only that *somebody* started a flow: an + // attacker can run their own authorization to the point of holding a valid + // `?code=…&state=…` URL, never visit it, and lure the victim there instead — the + // victim's browser would then be logged into the attacker's account, and anything they + // added afterwards would be the attacker's to read. Only the cookie tells the two + // apart, so a missing one is as fatal as a wrong one. Checked before `take_state` so a + // lured callback cannot burn a state the legitimate browser is still entitled to spend. + let bound = state_cookie + .is_some_and(|cookie| constant_time_eq(state.as_bytes(), cookie.as_bytes())); + if !bound { + return Err(AuthError::OauthFailed); + } + // Atomic read-and-delete: existence is the CSRF check, deletion is the replay guard. let Some(raw_payload) = self .require_oauth_state_store()? @@ -729,10 +767,10 @@ mod tests { /// (the recording transport ignores it); the `state` is recovered from the authorize URL. async fn run_flow(h: &OAuthHarness) -> Result { let url = h.engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return Err(AuthError::OauthFailed) }; + let Ok(url) = url.map(|r| r.authorize_url) else { return Err(AuthError::OauthFailed) }; let state = extract_query_param(&url, "state").unwrap_or_default(); h.engine - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await } @@ -753,8 +791,10 @@ mod tests { let hooks: Arc = Arc::new(DecisionHook(OAuthLoginResult::Create)); let Some(h) = harness(hooks, Arc::new(RoutingHttpClient::new()), false) else { return }; let url = h.engine.oauth_initiate("google", "t1").await; - assert!(matches!(&url, Ok(u) if u.starts_with("https://accounts.google.com/"))); - let Ok(url) = url else { return }; + assert!( + matches!(&url, Ok(r) if r.authorize_url.starts_with("https://accounts.google.com/")) + ); + let Ok(url) = url.map(|r| r.authorize_url) else { return }; assert!(url.contains("code_challenge_method=S256")); let state = extract_query_param(&url, "state").unwrap_or_default(); assert_eq!(state.len(), 64, "state is 64 hex chars"); @@ -814,12 +854,12 @@ mod tests { let hooks: Arc = Arc::new(DecisionHook(OAuthLoginResult::Create)); let Some(h) = harness(hooks, Arc::new(RoutingHttpClient::new()), false) else { return }; let url = h.engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); let challenge = extract_query_param(&url, "code_challenge").unwrap_or_default(); let done = h .engine - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await; assert!(done.is_ok()); let Some(exchange) = h.http.exchange_body() else { return }; @@ -921,28 +961,91 @@ mod tests { // Forged / missing state. assert!(matches!( h.engine - .oauth_callback("google", "code", &"f".repeat(64), &ctx()) + .oauth_callback( + "google", + "code", + &"f".repeat(64), + Some(&"f".repeat(64)), + &ctx() + ) .await, Err(AuthError::OauthFailed) )); // Issue a real state, consume it once, then replay it. let url = h.engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); assert!( h.engine - .oauth_callback("google", "code", &state, &ctx()) + .oauth_callback("google", "code", &state, Some(&state), &ctx()) .await .is_ok() ); assert!(matches!( h.engine - .oauth_callback("google", "code", &state, &ctx()) + .oauth_callback("google", "code", &state, Some(&state), &ctx()) .await, Err(AuthError::OauthFailed) )); } + #[tokio::test] + async fn callback_requires_the_state_cookie_and_does_not_burn_the_state_without_it() { + // The browser binding RFC 6749 §10.12 requires. A `state` that merely exists in the + // store proves only that *somebody* started a flow: an attacker can run their own + // authorization to the point of holding a valid `?code=…&state=…` URL, never visit it, + // and lure the victim there — the victim's browser would then be logged into the + // attacker's account. The cookie is what tells the two apart, so a missing one and a + // mismatched one are both fatal. + let hooks: Arc = Arc::new(DecisionHook(OAuthLoginResult::Create)); + let Some(h) = harness(hooks, Arc::new(RoutingHttpClient::new()), false) else { return }; + let url = h.engine.oauth_initiate("google", "t1").await; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; + let state = extract_query_param(&url, "state").unwrap_or_default(); + + // No cookie at all — the lured-victim request. + assert!(matches!( + h.engine + .oauth_callback("google", "code", &state, None, &ctx()) + .await, + Err(AuthError::OauthFailed) + )); + // A cookie from some other flow, and an empty one — neither is "close enough". + for cookie in ["b".repeat(64), String::new()] { + assert!(matches!( + h.engine + .oauth_callback("google", "code", &state, Some(&cookie), &ctx()) + .await, + Err(AuthError::OauthFailed) + )); + } + + // The state survived all three refusals: it is still spendable by the browser that + // owns it. A check placed after `take_state` would have burned it, turning the lure + // into a denial of service against a login the victim never asked to start. + assert!( + h.engine + .oauth_callback("google", "code", &state, Some(&state), &ctx()) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn initiate_returns_the_state_it_put_in_the_authorize_url() { + // The adapter plants `redirect.state` as the cookie and the callback compares it to + // the `state` query parameter the provider echoes back — which only works if the two + // are the same value. A struct that returned a *fresh* state would leave every + // callback unsatisfiable, and no other test would notice. + let hooks: Arc = Arc::new(DecisionHook(OAuthLoginResult::Create)); + let Some(h) = harness(hooks, Arc::new(RoutingHttpClient::new()), false) else { return }; + let Ok(redirect) = h.engine.oauth_initiate("google", "t1").await else { return }; + assert_eq!( + extract_query_param(&redirect.authorize_url, "state").as_deref(), + Some(redirect.state.as_str()) + ); + } + #[tokio::test] async fn callback_rejects_a_bad_shape_state_before_hashing_or_lookup() { // A `state` that is not 64 lower-case hex is rejected as OauthFailed BEFORE it is @@ -973,7 +1076,9 @@ mod tests { "g".repeat(64), ] { assert!(matches!( - engine.oauth_callback("google", "code", &bad, &ctx()).await, + engine + .oauth_callback("google", "code", &bad, Some(&bad), &ctx()) + .await, Err(AuthError::OauthFailed) )); } @@ -1001,7 +1106,7 @@ mod tests { ); assert!(matches!( h.engine - .oauth_callback("google", "code", &state, &ctx()) + .oauth_callback("google", "code", &state, Some(&state), &ctx()) .await, Err(AuthError::OauthFailed) )); @@ -1014,7 +1119,13 @@ mod tests { let Some(h) = harness(hooks, Arc::new(RoutingHttpClient::new()), false) else { return }; assert!(matches!( h.engine - .oauth_callback("github", "code", &"a".repeat(64), &ctx()) + .oauth_callback( + "github", + "code", + &"a".repeat(64), + Some(&"a".repeat(64)), + &ctx() + ) .await, Err(AuthError::OauthFailed) )); @@ -1276,10 +1387,10 @@ mod tests { let Ok(engine) = engine else { return }; let url = engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); let outcome = engine - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await; assert!(matches!(&outcome, Ok(OAuthOutcome::Authenticated(_)))); @@ -1316,10 +1427,10 @@ mod tests { let Ok(engine) = engine else { return }; let url = engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); let outcome = engine - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await; assert!( @@ -1351,11 +1462,11 @@ mod tests { // First sign-in creates the account and succeeds. let url = engine.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); assert!(matches!( engine - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await, Ok(OAuthOutcome::Authenticated(_)) )); @@ -1383,10 +1494,10 @@ mod tests { let Ok(linking) = linking else { return }; let url = linking.oauth_initiate("google", "t1").await; - let Ok(url) = url else { return }; + let Ok(url) = url.map(|r| r.authorize_url) else { return }; let state = extract_query_param(&url, "state").unwrap_or_default(); let banned = linking - .oauth_callback("google", "auth-code", &state, &ctx()) + .oauth_callback("google", "auth-code", &state, Some(&state), &ctx()) .await; assert!( matches!(banned, Err(AuthError::AccountBanned)), diff --git a/crates/bymax-auth-types/src/constants.rs b/crates/bymax-auth-types/src/constants.rs index 0de0379..70128b7 100644 --- a/crates/bymax-auth-types/src/constants.rs +++ b/crates/bymax-auth-types/src/constants.rs @@ -33,6 +33,21 @@ pub const MFA_TEMP_COOKIE_NAME: &str = "mfa_temp_token"; /// cookie can never outlive the token. pub const MFA_TEMP_COOKIE_MAX_AGE_SECONDS: u64 = 300; +/// Cookie binding an in-flight OAuth `state` to the browser that started the flow. +/// +/// The `state` parameter alone proves only that *somebody* started a flow, not that **this** +/// browser did. Without the binding an attacker can begin their own authorization, complete +/// consent at the provider, capture the resulting `?code=…&state=…` callback URL without +/// visiting it, and lure the victim there: the victim's browser then receives the *attacker's* +/// session, and everything the victim does next lands in the attacker's account. PKCE does not +/// help, because the verifier lives server-side and is replayed for whoever presents the state. +/// RFC 6749 §10.12 requires the state to be bound to the user agent; this cookie is that binding. +pub const OAUTH_STATE_COOKIE_NAME: &str = "oauth_state"; + +/// Max-Age of the OAuth state cookie, pinned to the 600 s TTL of the server-side `os:` record +/// it is paired with so neither half outlives the other. +pub const OAUTH_STATE_COOKIE_MAX_AGE_SECONDS: u64 = 600; + /// Default route prefix. Every path in [`routes`] is built under it. pub const AUTH_ROUTE_PREFIX: &str = "auth"; @@ -139,6 +154,8 @@ mod tests { assert_eq!(AUTH_REFRESH_COOKIE_PATH, "/auth"); assert_eq!(MFA_TEMP_COOKIE_NAME, "mfa_temp_token"); assert_eq!(MFA_TEMP_COOKIE_MAX_AGE_SECONDS, 300); + assert_eq!(OAUTH_STATE_COOKIE_NAME, "oauth_state"); + assert_eq!(OAUTH_STATE_COOKIE_MAX_AGE_SECONDS, 600); } #[test] From 3aaf521440d65c4bdf7da20d5da55c422a657964 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:19:01 -0300 Subject: [PATCH 102/122] fix(axum): honour cookies.resolve_domains instead of ignoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field was configurable and never read: a deployment that set it got host-only cookies anyway, with nothing to say so. The adapter now asks the resolver per request — handing it the request host with the port stripped, since it names a domain and not an origin — and stamps the answer on all three session cookies and on the logout clear, which must mirror the plant or the browser keeps the cookie it was asked to delete. Only the first domain is used. A browser rejects a `Set-Cookie` whose `Domain` is not a suffix of the host that sent the response (RFC 6265 section 5.3.6), so a second one on the same response is either the same scope written twice or a value that gets dropped on the floor. The resolver is handed the host precisely so it can answer with the scope that applies to it. Unset — the default — still means no `Domain` attribute at all, which is what a session cookie should be: `Domain=app.example.com` is sent to every subdomain of that name, so a session scoped that way is readable by a marketing site, a user-content host, or a stale DNS record someone else now answers for. --- CHANGELOG.md | 9 ++ crates/bymax-auth-axum/src/delivery.rs | 79 ++++++++++++-- crates/bymax-auth-axum/src/routes/auth.rs | 20 ++-- .../bymax-auth-axum/src/routes/invitations.rs | 18 +++- crates/bymax-auth-axum/src/routes/mfa.rs | 5 +- crates/bymax-auth-axum/src/routes/mod.rs | 43 ++++++++ crates/bymax-auth-axum/src/routes/oauth.rs | 5 +- crates/bymax-auth-axum/tests/adapter.rs | 100 ++++++++++++++++++ crates/bymax-auth-axum/tests/common/mod.rs | 44 +++++++- 9 files changed, 299 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c8ec2..23bdb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,15 @@ version bump. **Breaking:** `AuthEngine::oauth_initiate` returns `OAuthRedirect` instead of `String`, and `AuthEngine::oauth_callback` takes the cookie as its fourth argument. `nest-auth` takes the same change. +- **`cookies.resolve_domains` is honoured.** The field was configurable and never read: a + deployment that set it got host-only cookies anyway, with nothing to say so. The adapter now + asks the resolver per request — handing it the request host with the port stripped — and + stamps the answer on all three session cookies and on the logout clear, which must mirror it + or the browser keeps the cookie it was asked to delete. Only the first domain is used: a + browser rejects a `Set-Cookie` whose `Domain` is not a suffix of the responding host + (RFC 6265 §5.3.6), so a second one on the same response is either a duplicate scope or a + value that gets dropped. Unset — the default — still means no `Domain` attribute at all, + which is what a session cookie should be; `nest-auth` now defaults the same way. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/crates/bymax-auth-axum/src/delivery.rs b/crates/bymax-auth-axum/src/delivery.rs index 64ac81e..238bf03 100644 --- a/crates/bymax-auth-axum/src/delivery.rs +++ b/crates/bymax-auth-axum/src/delivery.rs @@ -27,6 +27,19 @@ use tower_cookies::cookie::{Cookie, SameSite}; use crate::state::{ResolvedConfig, ResolvedCookies}; +/// Stamp a `Domain` attribute onto a built cookie, or leave it host-only when `domain` is +/// `None`. Kept as a free function so both the plant and the clear go through one place. +fn with_domain(cookie: Cookie<'static>, domain: Option<&str>) -> Cookie<'static> { + match domain { + Some(value) => { + let mut cookie = cookie; + cookie.set_domain(value.to_owned()); + cookie + } + None => cookie, + } +} + /// Map the engine's `SameSite` to the cookie crate's `SameSite`. fn map_same_site(value: ConfigSameSite) -> SameSite { match value { @@ -41,12 +54,42 @@ fn map_same_site(value: ConfigSameSite) -> SameSite { /// attributes computed once at router build. pub(crate) struct TokenDelivery<'a> { config: &'a ResolvedConfig, + /// The `Domain` attribute(s) to stamp on the session cookies, resolved per request from + /// `cookies.resolve_domains`. Empty — the default — means **no `Domain` attribute**, i.e. + /// a host-only cookie, which is what a session cookie should be: a cookie carrying + /// `Domain=app.example.com` is sent to every subdomain of that name (RFC 6265 §5.2.3), so + /// deriving it from the request host would hand the session to a marketing site, a + /// user-content host, or a stale DNS record someone else now answers for. Sharing across + /// subdomains is a deliberate deployment decision, and the resolver is where it is made. + domains: &'a [String], } impl<'a> TokenDelivery<'a> { - /// Construct a delivery helper over the resolved adapter config. + /// Construct a delivery helper over the resolved adapter config, planting host-only + /// cookies. Use [`TokenDelivery::with_domains`] on the routes that deliver a session. pub(crate) fn new(config: &'a ResolvedConfig) -> Self { - Self { config } + Self { + config, + domains: &[], + } + } + + /// Construct a delivery helper that stamps `domains` onto the session cookies — the + /// per-request answer from `cookies.resolve_domains`, empty when unconfigured. + pub(crate) fn with_domains(config: &'a ResolvedConfig, domains: &'a [String]) -> Self { + Self { config, domains } + } + + /// The `Domain` to stamp on each session cookie: `None` (host-only) when no resolver is + /// configured, otherwise the **first** domain the resolver returned for this request. + /// + /// Only one can take effect, so only one is emitted. A browser rejects a `Set-Cookie` + /// whose `Domain` is not a suffix of the host that sent the response (RFC 6265 §5.3.6), + /// which means a second domain on the same response is either the same scope written + /// twice or a value the browser drops on the floor. The resolver is handed the request + /// host precisely so it can answer with the scope that applies to it. + fn domain(&self) -> Option<&str> { + self.domains.first().map(String::as_str) } /// The cookie attributes resolved at router build. @@ -100,9 +143,16 @@ impl<'a> TokenDelivery<'a> { /// Plant the full auth-cookie set (access + path-scoped refresh + the session signal) /// into the jar. Used by login/register/refresh/MFA-success/invitation-accept. fn set_auth_cookies(&self, cookies: &Cookies, access: &str, refresh: &str) { - cookies.add(self.build_access_cookie(access.to_owned())); - cookies.add(self.build_refresh_cookie(refresh.to_owned())); - cookies.add(self.build_signal_cookie()); + let domain = self.domain(); + cookies.add(with_domain( + self.build_access_cookie(access.to_owned()), + domain, + )); + cookies.add(with_domain( + self.build_refresh_cookie(refresh.to_owned()), + domain, + )); + cookies.add(with_domain(self.build_signal_cookie(), domain)); } /// Plant the auth cookies for a browser-redirect flow (the OAuth success/MFA redirect), @@ -118,13 +168,24 @@ impl<'a> TokenDelivery<'a> { /// sending). `Cookies::remove` emits the expiry `Set-Cookie`. pub(crate) fn clear_session(&self, cookies: &Cookies) { let c = self.cookies(); - cookies.remove(Cookie::build((c.access_name.clone(), "")).path("/").build()); - cookies.remove( + // The clear has to mirror the plant on every attribute the browser matches a deletion + // by — name, domain and path. Clearing a `Domain=`-scoped cookie host-only leaves the + // real one in place, and the user who signed out stays signed in. + let domain = self.domain(); + cookies.remove(with_domain( + Cookie::build((c.access_name.clone(), "")).path("/").build(), + domain, + )); + cookies.remove(with_domain( Cookie::build((c.refresh_name.clone(), "")) .path(c.refresh_path.clone()) .build(), - ); - cookies.remove(Cookie::build((c.signal_name.clone(), "")).path("/").build()); + domain, + )); + cookies.remove(with_domain( + Cookie::build((c.signal_name.clone(), "")).path("/").build(), + domain, + )); } /// Plant the ephemeral OAuth `state` cookie, binding the flow to the browser that started diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index 9451f14..59a8de7 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -19,7 +19,8 @@ use crate::dto::{LoginDto, RegisterDto, ResendVerificationDto, VerifyEmailDto}; use crate::extractors::AuthUser; use crate::response::error_response; use crate::routes::{ - PresentedAccessToken, RequestMeta, parse_optional_refresh_body, source_refresh_token, + CookieDomains, PresentedAccessToken, RequestMeta, parse_optional_refresh_body, + source_refresh_token, }; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedJson; @@ -69,6 +70,7 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout async fn register( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { @@ -79,7 +81,7 @@ async fn register( tenant_id: dto.tenant_id, }; match state.engine().register(input, &ctx).await { - Ok(result) => deliver_login(&state, &cookies, result, StatusCode::CREATED), + Ok(result) => deliver_login(&state, &cookies, &domains, result, StatusCode::CREATED), Err(error) => error_response(&error), } } @@ -88,6 +90,7 @@ async fn register( async fn login( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { @@ -97,7 +100,7 @@ async fn login( tenant_id: dto.tenant_id, }; match state.engine().login(input, &ctx).await { - Ok(result) => deliver_login(&state, &cookies, result, StatusCode::OK), + Ok(result) => deliver_login(&state, &cookies, &domains, result, StatusCode::OK), Err(error) => error_response(&error), } } @@ -119,6 +122,7 @@ async fn login( async fn logout( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, PresentedAccessToken(access_token): PresentedAccessToken, body: axum::body::Bytes, ) -> Response { @@ -132,7 +136,7 @@ async fn logout( dto.refresh_token.as_deref(), ); let _ = state.engine().logout(&access_token, &refresh).await; - TokenDelivery::new(state.config()).clear_session(&cookies); + TokenDelivery::with_domains(state.config(), &domains).clear_session(&cookies); StatusCode::NO_CONTENT.into_response() } @@ -144,6 +148,7 @@ async fn logout( async fn refresh( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, RequestMeta(ctx): RequestMeta, body: axum::body::Bytes, ) -> Response { @@ -165,7 +170,9 @@ async fn refresh( Err(error) => return error_response(&error), }; match rotated_into_auth_result(&state, tokens).await { - Ok(result) => TokenDelivery::new(state.config()).deliver_refresh(&cookies, &result), + Ok(result) => { + TokenDelivery::with_domains(state.config(), &domains).deliver_refresh(&cookies, &result) + } Err(error) => error_response(&error), } } @@ -216,10 +223,11 @@ async fn resend_verification( fn deliver_login( state: &AuthState, cookies: &Cookies, + domains: &[String], result: LoginResult, success_status: StatusCode, ) -> Response { - let delivery = TokenDelivery::new(state.config()); + let delivery = TokenDelivery::with_domains(state.config(), domains); match result { LoginResult::Success(auth) => delivery.deliver_auth(cookies, &auth, success_status), LoginResult::MfaChallenge(challenge) => delivery.deliver_mfa_challenge(&challenge), diff --git a/crates/bymax-auth-axum/src/routes/invitations.rs b/crates/bymax-auth-axum/src/routes/invitations.rs index e725c52..54f8631 100644 --- a/crates/bymax-auth-axum/src/routes/invitations.rs +++ b/crates/bymax-auth-axum/src/routes/invitations.rs @@ -14,7 +14,7 @@ use crate::delivery::TokenDelivery; use crate::dto::{AcceptInvitationDto, CreateInvitationDto}; use crate::extractors::AuthUser; use crate::response::error_response; -use crate::routes::RequestMeta; +use crate::routes::{CookieDomains, RequestMeta}; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedJson; use bymax_auth_types::AuthResult; @@ -61,6 +61,7 @@ async fn create( async fn accept( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { @@ -79,12 +80,21 @@ async fn accept( ) .await { - Ok(result) => deliver_accept(&state, &cookies, &result), + Ok(result) => deliver_accept(&state, &cookies, &domains, &result), Err(error) => error_response(&error), } } /// Deliver a successful invitation acceptance (201) per the configured mode. -fn deliver_accept(state: &AuthState, cookies: &Cookies, result: &AuthResult) -> Response { - TokenDelivery::new(state.config()).deliver_auth(cookies, result, StatusCode::CREATED) +fn deliver_accept( + state: &AuthState, + cookies: &Cookies, + domains: &[String], + result: &AuthResult, +) -> Response { + TokenDelivery::with_domains(state.config(), domains).deliver_auth( + cookies, + result, + StatusCode::CREATED, + ) } diff --git a/crates/bymax-auth-axum/src/routes/mfa.rs b/crates/bymax-auth-axum/src/routes/mfa.rs index 8c5f522..63d2c3a 100644 --- a/crates/bymax-auth-axum/src/routes/mfa.rs +++ b/crates/bymax-auth-axum/src/routes/mfa.rs @@ -23,7 +23,7 @@ use crate::dto::{ }; use crate::extractors::AuthUser; use crate::response::error_response; -use crate::routes::RequestMeta; +use crate::routes::{CookieDomains, RequestMeta}; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedJson; @@ -126,10 +126,11 @@ async fn verify_enable( async fn challenge( State(state): State, cookies: tower_cookies::Cookies, + CookieDomains(domains): CookieDomains, RequestMeta(ctx): RequestMeta, ValidatedJson(dto): ValidatedJson, ) -> Response { - let delivery = TokenDelivery::new(state.config()); + let delivery = TokenDelivery::with_domains(state.config(), &domains); let cookie_token = mfa_temp_cookie(&cookies); // Neither channel carried a token: that is an invalid temp token (nest-auth throws // `MFA_TEMP_TOKEN_INVALID` here), never a generic field-validation 400. diff --git a/crates/bymax-auth-axum/src/routes/mod.rs b/crates/bymax-auth-axum/src/routes/mod.rs index 9a00208..ad39ffc 100644 --- a/crates/bymax-auth-axum/src/routes/mod.rs +++ b/crates/bymax-auth-axum/src/routes/mod.rs @@ -216,6 +216,49 @@ where } } +/// A handler extractor that resolves the cookie `Domain`(s) for this request by asking the +/// configured `cookies.resolve_domains` resolver, or an empty vector when none is configured. +/// +/// Empty means **host-only** — no `Domain` attribute at all, which is the right default for a +/// session cookie: `Domain=app.example.com` is sent to every subdomain of that name +/// (RFC 6265 §5.2.3), so a session scoped that way is readable by a marketing site, a +/// user-content host, or a stale DNS record someone else now answers for. Sharing across +/// subdomains is a deliberate deployment decision; the resolver is where it is made. +/// +/// The host comes from the `Host` header (or HTTP/2 `:authority`, which axum normalizes into +/// it), port stripped — the resolver names a domain, not an origin. Infallible: an absent or +/// unreadable host reaches the resolver as an empty string, exactly as it does in `nest-auth`. +pub(crate) struct CookieDomains(pub Vec); + +impl FromRequestParts for CookieDomains +where + AuthState: FromRef, + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let auth_state = AuthState::from_ref(state); + let Some(resolver) = auth_state + .engine() + .config() + .config() + .cookies + .resolve_domains + .clone() + else { + return Ok(Self(Vec::new())); + }; + let host = parts + .headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(':').next().unwrap_or(value)) + .unwrap_or(""); + Ok(Self(resolver.resolve(host))) + } +} + /// A handler extractor that resolves the raw access token from the configured channel /// (cookie or `Authorization` header), or an empty string when absent — used by `logout` to /// blacklist the presented token. Infallible (logout never blocks on a missing token). diff --git a/crates/bymax-auth-axum/src/routes/oauth.rs b/crates/bymax-auth-axum/src/routes/oauth.rs index 8ba37fe..b5a7164 100644 --- a/crates/bymax-auth-axum/src/routes/oauth.rs +++ b/crates/bymax-auth-axum/src/routes/oauth.rs @@ -14,7 +14,7 @@ use tower_cookies::Cookies; use crate::delivery::TokenDelivery; use crate::dto::{OAuthCallbackQuery, OAuthInitiateQuery}; use crate::response::error_response; -use crate::routes::RequestMeta; +use crate::routes::{CookieDomains, RequestMeta}; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedQuery; @@ -79,6 +79,7 @@ async fn initiate( async fn callback( State(state): State, cookies: Cookies, + CookieDomains(domains): CookieDomains, Path(provider): Path, RequestMeta(ctx): RequestMeta, ValidatedQuery(query): ValidatedQuery, @@ -103,7 +104,7 @@ async fn callback( .await; match outcome { Ok(OAuthOutcome::Authenticated(result)) => { - let delivery = TokenDelivery::new(state.config()); + let delivery = TokenDelivery::with_domains(state.config(), &domains); match state.engine().oauth_success_redirect_url() { // Browser flow: plant the auth cookies into the jar (the cookie-manager layer // emits them on the redirect response), then 302 to the configured URL. diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index b15dde3..d79d060 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -95,6 +95,106 @@ async fn always_on_groups_are_mounted_and_optional_groups_are_absent_by_default( assert!(!derived.invitations && !derived.platform_mfa); } +#[tokio::test] +async fn session_cookies_are_host_only_unless_a_domain_resolver_is_configured() { + // Default: no `Domain` attribute at all. A cookie carrying `Domain=app.example.com` is + // sent to every subdomain of that name (RFC 6265 §5.2.3), so deriving it from the request + // host would hand the session to a marketing site, a user-content host, or a stale DNS + // record someone else now answers for. Host-only is the safe default; sharing is opted in. + let Some(h) = build(EngineSpec::default()) else { return }; + let app = router(&h); + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "hostonly@e.com", "password": "password123", "name": "Hana", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(reg.status, StatusCode::CREATED); + assert!(!reg.set_cookies.is_empty()); + for cookie in ®.set_cookies { + assert!( + !cookie.to_ascii_lowercase().contains("domain="), + "unexpected Domain attribute: {cookie}" + ); + } +} + +#[tokio::test] +async fn a_configured_domain_resolver_stamps_every_session_cookie_and_the_logout_clear() { + // `cookies.resolve_domains` was a config field the adapter never read — an option that + // silently did nothing. The resolved domain is stamped on all three session cookies, and + // the logout clear mirrors it: a browser matches a deletion on name + domain + path, so a + // host-only clear would leave the real cookie in place and the user who signed out would + // stay signed in. Only the first domain is used — a browser rejects a `Set-Cookie` whose + // `Domain` is not a suffix of the responding host (RFC 6265 §5.3.6), so a second one on + // the same response is either a duplicate scope or a value the browser drops. + let Some(h) = build(EngineSpec { + cookie_domains: &[".example.com", ".api.example.com"], + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + + let reg = Req::post("/auth/register") + .header(header::HOST, "app.example.com:8443") + .json(serde_json::json!({ + "email": "shared@e.com", "password": "password123", "name": "Sara", "tenantId": TENANT + })) + .send(&app) + .await; + + // The resolver is handed the request host with the port stripped — it names a domain, not + // an origin, and `app.example.com:8443` is not a legal `Domain` value. A resolver that + // received an empty string could not scope anything, which is how this stayed dead config. + assert_eq!( + h.domain_resolver.as_ref().and_then(|r| r + .seen_host + .lock() + .ok() + .and_then(|seen| seen.clone())), + Some("app.example.com".to_owned()) + ); + assert_eq!(reg.status, StatusCode::CREATED); + assert_eq!(reg.set_cookies.len(), 3); + assert_eq!( + reg.set_cookies + .iter() + .filter(|c| c.contains("Domain=example.com")) + .count(), + 3, + // The leading dot the resolver returned is dropped on the way out: RFC 6265 §4.1.2.3 + // ignores it, and `Domain=example.com` already matches every subdomain. + "expected all three session cookies scoped to the resolved domain" + ); + + // The clear has to replay the cookies: `tower-cookies` only emits a removal for a cookie + // the request actually carried, so a jar-less logout would emit nothing and prove nothing. + let logout = Req::post("/auth/logout") + .cookie( + "access_token", + ®.cookie_value("access_token").unwrap_or_default(), + ) + .cookie( + "refresh_token", + ®.cookie_value("refresh_token").unwrap_or_default(), + ) + .cookie("has_session", "1") + .send(&app) + .await; + assert_eq!(logout.status, StatusCode::NO_CONTENT); + assert_eq!(logout.set_cookies.len(), 3); + assert_eq!( + logout + .set_cookies + .iter() + .filter(|c| c.contains("Domain=example.com")) + .count(), + 3, + "expected all three clears scoped to the resolved domain" + ); +} + #[tokio::test] async fn an_unknown_route_is_404() { // A path outside the mounted table is a clean 404. diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 52b0b61..68caae3 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::net::SocketAddr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use axum::Router; @@ -70,6 +70,9 @@ pub struct EngineSpec { /// Origins the deployment trusts for cross-site, cookie-authenticated writes. Setting any /// switches `SameSite` to `None`, since the two are only valid together. pub trusted_origins: &'static [&'static str], + /// Cookie `Domain`(s) a configured `resolve_domains` should return. Empty leaves the + /// resolver unconfigured, which is the host-only default. + pub cookie_domains: &'static [&'static str], } impl Default for EngineSpec { @@ -84,10 +87,29 @@ impl Default for EngineSpec { verification_required: false, allow_oauth: false, trusted_origins: &[], + cookie_domains: &[], } } } +/// A `CookieDomainResolver` that answers with a fixed list, and records the request host it +/// was handed so a test can assert the adapter passes the real one (port stripped) rather than +/// an empty string — a resolver that cannot see the host cannot scope anything. +pub struct RecordingDomains { + domains: Vec, + /// The last host the adapter handed the resolver. + pub seen_host: Mutex>, +} + +impl bymax_auth_core::config::CookieDomainResolver for RecordingDomains { + fn resolve(&self, request_host: &str) -> Vec { + if let Ok(mut seen) = self.seen_host.lock() { + *seen = Some(request_host.to_owned()); + } + self.domains.clone() + } +} + /// A base AES key (32 bytes) for the MFA config, base64-encoded. fn mfa_key_b64() -> String { use base64::Engine as _; @@ -106,6 +128,8 @@ pub struct Harness { pub users: Arc, pub admins: Arc, pub stores: Arc, + /// The cookie-domain resolver, present only when `EngineSpec::cookie_domains` asked for one. + pub domain_resolver: Option>, } /// Build an engine + router over in-memory stores per `spec`. @@ -128,6 +152,20 @@ pub fn build(spec: EngineSpec) -> Option { config.controllers.invitations = spec.invitations; config.invitations.enabled = spec.invitations; config.controllers.oauth = spec.oauth; + let domain_resolver = if spec.cookie_domains.is_empty() { + None + } else { + let resolver = Arc::new(RecordingDomains { + domains: spec + .cookie_domains + .iter() + .map(|domain| (*domain).to_owned()) + .collect(), + seen_host: Mutex::new(None), + }); + config.cookies.resolve_domains = Some(resolver.clone()); + Some(resolver) + }; if !spec.trusted_origins.is_empty() { config.cookies.same_site = bymax_auth_core::config::SameSite::None; config.cookies.trusted_origins = spec @@ -178,6 +216,7 @@ pub fn build(spec: EngineSpec) -> Option { users, admins, stores, + domain_resolver, }) } @@ -213,6 +252,7 @@ pub fn build_oauth_with_redirects() -> Option { users, admins, stores, + domain_resolver: None, }) } @@ -651,6 +691,7 @@ pub fn build_failing() -> Option { users, admins, stores: inert, + domain_resolver: None, }) } @@ -685,6 +726,7 @@ pub fn build_failing_blacklist() -> Option { users, admins, stores: inert, + domain_resolver: None, }) } From 017f4db27000e89623ddf03248d59d1602b6cf5e Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:50:22 -0300 Subject: [PATCH 103/122] fix(axum): let the provider error callback reach the OAuth error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6749 section 4.1.2.1 defines a callback carrying `error` and no `code` — the response a provider sends when the user declines consent. `OAuthCallbackQuery` required `code`, so a user who simply clicked "Cancel" got a validation envelope rather than the configured error redirect. `code` is now optional, and the handler refuses a callback carrying neither it nor `error` on the same path. The provider's value is logged and never echoed back: it is provider-chosen text that would otherwise land in a URL the browser follows, and `oauth_failed` already says everything the library is willing to vouch for. The refusal and the failed-exchange path now share one helper, so the status, the code and the destination cannot drift apart. That exposed a branch no test covered: an infrastructure failure must NOT become an error redirect — a store that is down is not a refusal, and dressing it up as `?error=oauth_failed` would tell the user to retry while hiding an outage from whatever watches 5xx. There is now a harness for it. --- CHANGELOG.md | 8 +++ crates/bymax-auth-axum/src/dto.rs | 24 ++++++++- crates/bymax-auth-axum/src/routes/oauth.rs | 49 +++++++++++++----- crates/bymax-auth-axum/src/validation.rs | 2 +- crates/bymax-auth-axum/tests/adapter.rs | 60 +++++++++++++++++++++- crates/bymax-auth-axum/tests/common/mod.rs | 54 +++++++++++++++++++ 6 files changed, 179 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bdb6d..9840848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,14 @@ version bump. (RFC 6265 §5.3.6), so a second one on the same response is either a duplicate scope or a value that gets dropped. Unset — the default — still means no `Domain` attribute at all, which is what a session cookie should be; `nest-auth` now defaults the same way. +- **The provider's error callback reaches the OAuth error handling instead of the validator.** + RFC 6749 §4.1.2.1 defines a callback carrying `error` and no `code` — the response a provider + sends when the user declines consent. `OAuthCallbackQuery` required `code`, so a user who + simply clicked "Cancel" got a validation envelope rather than the configured error redirect. + `code` is now optional and the handler refuses a callback carrying neither it nor `error`. + The provider's value is logged and never echoed: it is provider-chosen text that would + otherwise land in a URL the browser follows, and `oauth_failed` already says everything the + library is willing to vouch for. `nest-auth` takes the same change. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index 9094c24..0bafd2d 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -273,8 +273,28 @@ pub struct OAuthInitiateQuery { #[serde(rename_all = "camelCase")] pub struct OAuthCallbackQuery { /// The authorization code returned by the provider. - #[garde(length(min = 1, max = 2048))] - pub code: String, + /// + /// Absent on the error callback RFC 6749 §4.1.2.1 defines — the response a provider sends + /// when the user declines consent, which used to be rejected as a malformed query for the + /// missing field: a user who simply changed their mind saw a validation envelope instead + /// of the configured error redirect. The handler refuses a callback carrying neither this + /// nor `error`. + #[garde(inner(length(min = 1, max = 2048)))] + pub code: Option, + /// The provider's error code (RFC 6749 §4.1.2.1) — `access_denied` when the user clicks + /// "Cancel" at the consent screen, plus `server_error`, `temporarily_unavailable` and the + /// rest. Logged and never echoed to the caller: the response stays `auth.oauth_failed`, so + /// the provider cannot choose what appears in a redirect URL the browser follows. + #[garde(inner(length(max = 128)))] + pub error: Option, + /// Human-readable detail accompanying `error`. Accepted so the query validates; logged + /// with `error`, never echoed. + #[garde(inner(length(max = 512)))] + pub error_description: Option, + /// URI of a provider page describing `error`. Accepted so the query validates; never + /// followed and never echoed. + #[garde(inner(length(max = 512)))] + pub error_uri: Option, /// The CSRF `state` nonce (matched against the stored single-use record). #[garde(length(min = 1, max = 128))] pub state: String, diff --git a/crates/bymax-auth-axum/src/routes/oauth.rs b/crates/bymax-auth-axum/src/routes/oauth.rs index b5a7164..d349917 100644 --- a/crates/bymax-auth-axum/src/routes/oauth.rs +++ b/crates/bymax-auth-axum/src/routes/oauth.rs @@ -37,6 +37,19 @@ fn found(url: &str) -> Response { } } +/// The response for an OAuth refusal: the configured error redirect when there is one, the +/// `auth.oauth_failed` envelope otherwise. One helper so the provider's own error response and +/// a failed exchange cannot drift apart in status, code, or destination. +fn oauth_failure(state: &AuthState) -> Response { + match state + .engine() + .oauth_error_redirect_url(OAUTH_ERROR_SHORT_CODE) + { + Some(url) => found(&url), + None => error_response(&bymax_auth_types::AuthError::OauthFailed), + } +} + /// Assemble the `oauth` group under the `oauth` segment with per-route rate limits. Axum 0.8 /// brace path syntax: `/{provider}` and `/{provider}/callback`. pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Router { @@ -92,15 +105,29 @@ async fn callback( .map(|cookie| cookie.value().to_owned()); TokenDelivery::new(state.config()).clear_oauth_state_cookie(&cookies); + // The provider refused before it ever minted a code (RFC 6749 §4.1.2.1) — most often + // because the user clicked "Cancel" at the consent screen. That is a normal outcome of a + // normal flow, and it used to answer a validation error for the missing `code` instead of + // the configured error redirect. The provider's value is logged and never echoed back: it + // would otherwise be provider-chosen text landing in a URL the browser follows, and the + // caller learns nothing from it that `oauth_failed` does not already say. + // + // A callback carrying neither `code` nor `error` takes the same path: `code` is optional + // on the query only so the error response can validate, never so a codeless exchange can + // proceed. + let Some(code) = query.code.as_deref().filter(|_| query.error.is_none()) else { + let provider_error = query.error.as_deref().unwrap_or(""); + tracing::warn!( + provider = %provider, + error = provider_error, + "oauth callback carried a provider error or no code" + ); + return oauth_failure(&state); + }; + let outcome = state .engine() - .oauth_callback( - &provider, - &query.code, - &query.state, - state_cookie.as_deref(), - &ctx, - ) + .oauth_callback(&provider, code, &query.state, state_cookie.as_deref(), &ctx) .await; match outcome { Ok(OAuthOutcome::Authenticated(result)) => { @@ -128,12 +155,8 @@ async fn callback( Err(error) => { // Only an `OauthFailed`-family error is converted to an error redirect; any other // (e.g. an internal transport failure) propagates so monitoring sees it. - if matches!(error, bymax_auth_types::AuthError::OauthFailed) - && let Some(url) = state - .engine() - .oauth_error_redirect_url(OAUTH_ERROR_SHORT_CODE) - { - return found(&url); + if matches!(error, bymax_auth_types::AuthError::OauthFailed) { + return oauth_failure(&state); } error_response(&error) } diff --git a/crates/bymax-auth-axum/src/validation.rs b/crates/bymax-auth-axum/src/validation.rs index 1f8fa1a..5141b62 100644 --- a/crates/bymax-auth-axum/src/validation.rs +++ b/crates/bymax-auth-axum/src/validation.rs @@ -159,7 +159,7 @@ mod tests { let ok = deserialize_query::( "code=abc&state=xyz&authuser=0&delegatedClientId=foo&unexpected=1", ); - assert!(matches!(ok, Ok(q) if q.code == "abc" && q.state == "xyz")); + assert!(matches!(ok, Ok(q) if q.code.as_deref() == Some("abc") && q.state == "xyz")); } #[test] diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index d79d060..9e8ba0c 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -22,8 +22,9 @@ mod common; use common::{ - Captured, EngineSpec, Req, TENANT, build, build_oauth_with_redirects, current_totp, - enable_mfa_flag, router, seed_admin, seed_user, set_status, + Captured, EngineSpec, Req, TENANT, build, build_oauth_with_failing_state_store, + build_oauth_with_redirects, current_totp, enable_mfa_flag, router, seed_admin, seed_user, + set_status, }; use http::{HeaderName, Method, StatusCode, header}; @@ -1787,6 +1788,61 @@ async fn oauth_query_validation_rejects_a_missing_tenant_id() { // oauth: configured success/mfa/error redirect branches (302) // ---------------------------------------------------------------------------------------- +#[tokio::test] +async fn a_provider_error_callback_is_an_oauth_failure_not_a_validation_error() { + // RFC 6749 §4.1.2.1: a provider that refuses answers with `error` and no `code` — which is + // what Google sends when the user clicks "Cancel". The query used to require `code`, so a + // user who simply changed their mind got a validation envelope instead of the library's + // own refusal. A callback carrying neither takes the same path. + let Some(h) = build(EngineSpec { + oauth: true, + allow_oauth: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + + for query in [ + "error=access_denied&state=deadbeef", + "error=temporarily_unavailable&error_description=try%20later&state=deadbeef", + "state=deadbeef", + ] { + let res = Req::get(&format!("/auth/oauth/google/callback?{query}")) + .send(&app) + .await; + assert_eq!(res.status, StatusCode::UNAUTHORIZED, "for query {query}"); + assert_eq!(res.json()["error"]["code"], "auth.oauth_failed"); + // The provider's own string never reaches the caller: it is provider-chosen text, and + // `oauth_failed` already says everything the library is willing to vouch for. + let body = String::from_utf8_lossy(&res.body).to_string(); + assert!(!body.contains("access_denied")); + assert!(!body.contains("temporarily_unavailable")); + } +} + +#[tokio::test] +async fn an_infrastructure_failure_on_the_callback_does_not_become_an_error_redirect() { + // The error redirect exists to explain a refusal to a user. A store that is down is not a + // refusal: dressing it up as `?error=oauth_failed` would tell the user to try again while + // hiding an outage from whatever watches 5xx. Only an `OauthFailed` gets the redirect. + let Some(h) = build_oauth_with_failing_state_store() else { return }; + let app = router(&h); + + // A well-formed 64-hex state: a malformed one is refused on shape before the store is + // ever consulted, and the test would pass on the wrong path. + let state = "a".repeat(64); + let res = Req::get(&format!( + "/auth/oauth/google/callback?code=abc&state={state}" + )) + .cookie("oauth_state", &state) + .send(&app) + .await; + + assert_eq!(res.status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!res.headers.contains_key(header::LOCATION)); +} + #[tokio::test] async fn oauth_callback_redirect_branches() { use bymax_auth_axum::{AuthRouter, AxumAuthConfig}; diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 68caae3..41da30e 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -256,6 +256,43 @@ pub fn build_oauth_with_redirects() -> Option { }) } +/// Build an OAuth-enabled engine whose state store fails on `take_state`, with the error +/// redirect configured. Drives the one path where the callback must NOT redirect: an +/// infrastructure failure has to surface to monitoring rather than be dressed up as a user +/// declining consent. +pub fn build_oauth_with_failing_state_store() -> Option { + let users = Arc::new(InMemoryUserRepository::new()); + let admins = Arc::new(InMemoryPlatformUserRepository::new()); + let failing = Arc::new(FailingStores::with_failing_oauth_state()); + let inert = Arc::new(InMemoryStores::new()); + + let mut config = AuthConfig::default(); + config.jwt.secret = SecretString::from(JWT_SECRET.to_owned()); + config.roles.hierarchy = HashMap::from([("USER".to_owned(), Vec::new())]); + config.controllers.oauth = true; + config.oauth.error_redirect_url = Some("http://localhost/error".to_owned()); + config.oauth.redirect_allowlist = vec!["localhost".to_owned()]; + + let engine = AuthEngine::builder() + .config(config) + .environment(Environment::Development) + .user_repository(users.clone()) + .platform_user_repository(admins.clone()) + .redis_stores(failing.clone()) + .oauth_provider(Arc::new(MockOAuthProvider::new("google"))) + .oauth_state_store(failing) + .hooks(Arc::new(AllowOAuthHooks)) + .build() + .ok()?; + Some(Harness { + engine: Arc::new(engine), + users, + admins, + stores: inert, + domain_resolver: None, + }) +} + /// Assemble the adapter router for the harness engine with default adapter config. pub fn router(harness: &Harness) -> Router { bymax_auth_axum::AuthRouter::from_engine(harness.engine.clone(), AxumAuthConfig::default()) @@ -335,6 +372,10 @@ pub struct FailingStores { /// revocation check), so a test can drive the auth-extractor's internal-error path rather /// than the handler error arms. Default `false` keeps the auth extractors passing. blacklist_fails: bool, + /// When set, `take_state` fails, so the OAuth callback surfaces a store error rather than + /// `OauthFailed` — the one input that proves an infrastructure failure is NOT swallowed + /// into the friendly `?error=oauth_failed` redirect. + oauth_state_fails: bool, } impl FailingStores { @@ -342,6 +383,7 @@ impl FailingStores { Self { inner: Arc::new(InMemoryStores::new()), blacklist_fails: false, + oauth_state_fails: false, } } @@ -349,6 +391,15 @@ impl FailingStores { Self { inner: Arc::new(InMemoryStores::new()), blacklist_fails: true, + oauth_state_fails: false, + } + } + + fn with_failing_oauth_state() -> Self { + Self { + inner: Arc::new(InMemoryStores::new()), + blacklist_fails: false, + oauth_state_fails: true, } } } @@ -655,6 +706,9 @@ impl bymax_auth_core::traits::OAuthStateStore for FailingStores { &self, state_hash: &str, ) -> Result, bymax_auth_types::AuthError> { + if self.oauth_state_fails { + return Err(fail()); + } self.inner.take_state(state_hash).await } } From dda602ce312706b93a7070b428fd0971767fb6f9 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:58:53 -0300 Subject: [PATCH 104/122] fix(axum): mark Set-Cookie sensitive so tracing cannot print the token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request side was already redacted from traces; the response side is where the credential travels outward. Every successful login, refresh and OAuth callback answers with `Set-Cookie: access_token=`, so a deployment whose tracing records response headers — a reasonable thing to switch on while debugging — was writing live session tokens into its logs, where they outlive the session and are read by people it was never issued to. Marking the header sensitive costs nothing and makes that mistake unavailable. --- CHANGELOG.md | 7 +++++++ crates/bymax-auth-axum/src/middleware.rs | 14 +++++++++++++- crates/bymax-auth-axum/tests/adapter.rs | 24 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9840848..e31ea71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,13 @@ version bump. The provider's value is logged and never echoed: it is provider-chosen text that would otherwise land in a URL the browser follows, and `oauth_failed` already says everything the library is willing to vouch for. `nest-auth` takes the same change. +- **`Set-Cookie` is marked sensitive on every response** + (`SetSensitiveResponseHeadersLayer`). The request side was already redacted from traces; the + response side is where the credential travels outward — every successful login, refresh and + OAuth callback answers with `Set-Cookie: access_token=`. A deployment whose + tracing records response headers, a reasonable thing to switch on while debugging, was + writing live session tokens into its logs, where they outlive the session and are read by + people it was never issued to. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/crates/bymax-auth-axum/src/middleware.rs b/crates/bymax-auth-axum/src/middleware.rs index c58b2a4..1df1499 100644 --- a/crates/bymax-auth-axum/src/middleware.rs +++ b/crates/bymax-auth-axum/src/middleware.rs @@ -13,7 +13,9 @@ use http::HeaderValue; use http::header::{CACHE_CONTROL, PRAGMA}; use tower_cookies::CookieManagerLayer; use tower_http::limit::RequestBodyLimitLayer; -use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer; +use tower_http::sensitive_headers::{ + SetSensitiveRequestHeadersLayer, SetSensitiveResponseHeadersLayer, +}; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; @@ -38,6 +40,15 @@ pub(crate) fn apply_middleware( // `cookie`, and `x-csrf-token` are all masked before the tracing layer records them. let sensitive = SetSensitiveRequestHeadersLayer::new(sensitive_header_names()); + // The same treatment for the response side, where the credential travels outward: every + // successful login, refresh, and OAuth callback answers with `Set-Cookie: access_token=`. Request redaction alone leaves that value printable, so a deployment whose + // tracing records response headers — a reasonable thing to switch on while debugging — + // writes live session tokens into its logs, where they outlive the session and are read by + // people the session was never issued to. Marking the header sensitive costs nothing and + // makes that mistake unavailable. + let sensitive_out = SetSensitiveResponseHeadersLayer::new([http::header::SET_COOKIE]); + // Layered innermost-last: the cookie manager runs closest to the handler so the jar is // ready, then body-limit, redaction, optional CORS, and tracing wrap outward. // The cross-site check sits innermost, next to the handlers: it must see the request @@ -51,6 +62,7 @@ pub(crate) fn apply_middleware( .layer(CookieManagerLayer::new()) .layer(RequestBodyLimitLayer::new(max_body_bytes)) .layer(sensitive) + .layer(sensitive_out) // Every response of every route this router serves is stamped `Cache-Control: // no-store` (plus `Pragma: no-cache` for HTTP/1.0 intermediaries). RFC 6749 §5.1 // requires it on any response carrying a token, and every route here either carries diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 9e8ba0c..a9cbbc5 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -96,6 +96,30 @@ async fn always_on_groups_are_mounted_and_optional_groups_are_absent_by_default( assert!(!derived.invitations && !derived.platform_mfa); } +#[tokio::test] +async fn the_set_cookie_header_is_marked_sensitive_so_tracing_cannot_print_the_token() { + // Every successful login answers with `Set-Cookie: access_token=`. Marking + // the header sensitive is what stops a deployment whose tracing records response headers — + // a reasonable thing to switch on while debugging — from writing live session tokens into + // its logs, where they outlive the session and are read by people it was never issued to. + let Some(h) = build(EngineSpec::default()) else { return }; + let app = router(&h); + + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "sensitive@e.com", "password": "password123", "name": "Sensi", "tenantId": TENANT + })) + .send(&app) + .await; + + assert_eq!(reg.status, StatusCode::CREATED); + let cookies: Vec<_> = reg.headers.get_all(header::SET_COOKIE).iter().collect(); + assert!(!cookies.is_empty(), "the login must set cookies at all"); + for value in cookies { + assert!(value.is_sensitive(), "Set-Cookie must be marked sensitive"); + } +} + #[tokio::test] async fn session_cookies_are_host_only_unless_a_domain_resolver_is_configured() { // Default: no `Domain` attribute at all. A cookie carrying `Domain=app.example.com` is From ae7d7afa3737867f037a72ccdd0dc21a6f3bf249 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:49:58 -0300 Subject: [PATCH 105/122] fix(core, axum, nextjs): close five findings from the blind audit round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initiate_reset` now claims the same cooldown `resend_reset_otp` does, under the same key. The throttle on one door was decorative while the other was free — and worse, every issuance rewrites the OTP record with `attempts: 0`, so an untimed initiate turned the 5-attempt ceiling into 5 attempts per call. An attacker who knew an address could loop "initiate, guess five times" at a six-digit code indefinitely, mailing the victim once per lap. The absolute session-lifetime cap is enforced on the grace-recovery path, on both planes. The check ran against the seed, and there the seed is the placeholder used when the live key is already gone: its `family_created_at` is `None`, so the check returned early and applied nothing. A lineage that had just passed its cap could still mint a fresh access token and a full-length refresh session through its grace window — the cap ended normal rotation and left the one remaining door open. `GET /auth/me` is pinned in the wire contract and the TypeScript client reads the bare user object the route actually returns. It was still unwrapping a `{ user }` envelope, so `getMe()` resolved to `undefined` while every other signal said authenticated. The test that should have caught it mocked the old envelope; the contract had no `me` entry, which is why nothing else did. `POST /auth/logout` and `POST /auth/ws-ticket` are rate-limited at 20/60s. Logout is public by necessity and was unlimited; ws-ticket is authenticated but writes a fresh single-use key per call. The Next.js proxy strips the caller's `x-user-*` headers on a public path — that arm forwarded them verbatim, so the advisory identity headers were forgeable with no token at all — and `isPublicPath` matches at a segment boundary, so `/login` no longer exempts `/loginhistory`. --- CHANGELOG.md | 26 +++++ conformance/wire-contract.json | 6 + crates/bymax-auth-axum/src/rate_limit.rs | 21 +++- crates/bymax-auth-axum/src/routes/auth.rs | 16 ++- .../src/services/auth/password_reset.rs | 106 +++++++++++++++++- .../src/services/token_manager.rs | 68 +++++++++++ crates/bymax-auth-core/src/testing/mod.rs | 10 ++ packages/rust-auth/src/client/index.ts | 12 +- packages/rust-auth/src/nextjs/proxy.ts | 34 +++++- packages/rust-auth/tests/client.test.ts | 7 +- packages/rust-auth/tests/nextjs.test.ts | 46 ++++++++ 11 files changed, 332 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e31ea71..83a0af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,32 @@ version bump. tracing records response headers, a reasonable thing to switch on while debugging, was writing live session tokens into its logs, where they outlive the session and are read by people it was never issued to. +- **`initiate_reset` shares the resend cooldown.** `resend_reset_otp` was throttled and + `initiate_reset` was not, which made the throttle decorative — a caller just used the other + door. It also made the OTP's 5-attempt ceiling per-issuance rather than per-account, because + every issuance rewrites the record with `attempts: 0`: an attacker who knew an address could + loop "initiate, guess five times" at a six-digit code indefinitely, mailing the victim once + per lap. Both entry points now claim one budget under one key. `nest-auth` takes the same + change. +- **The absolute session-lifetime cap is enforced on the grace-recovery path.** The check ran + against the seed, and on that path the seed is the placeholder used when the live key is + already gone — its `family_created_at` is `None`, so the check returned early and applied + nothing. A lineage that had just passed its cap could still mint a fresh access token and a + full-length refresh session by presenting a token inside its grace window: the cap ended + normal rotation and left the one remaining door open. Both planes take the check. +- **`GET /auth/me` is pinned in the wire contract, and the TypeScript client reads the shape + the server actually sends.** The route returns the bare user object; the client still + unwrapped a `{ user }` envelope, so `getMe()` resolved to `undefined` while every other + signal said authenticated — `AuthProvider` reported a session with no user, and a consumer + reading `user.role` threw on a perfectly good login. The test that should have caught it + mocked the old envelope. The contract had no `me` entry, which is why nothing else did. +- **`POST /auth/logout` and `POST /auth/ws-ticket` are rate-limited** (20/60s each, pinned in + the contract). Logout is public by necessity and was unlimited; ws-ticket is authenticated + but writes a fresh single-use key per call. +- **The Next.js proxy strips the caller's `x-user-*` headers on a public path** — that arm + forwarded them verbatim, so the advisory identity headers were forgeable with no token at + all — and `isPublicPath` now matches at a segment boundary, so `/login` no longer exempts + `/loginhistory`. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index fe0cbd0..7e22963 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -179,6 +179,8 @@ "login": "5/60", "register": "10/3600", "refresh": "10/60", + "logout": "20/60", + "wsTicket": "20/60", "forgotPassword": "3/300", "resetPassword": "3/300", "verifyOtp": "3/300", @@ -232,6 +234,10 @@ "bearer": ["admin", "accessToken", "refreshToken"], "$comment": "Always bearer — a platform admin console is not a browser session — and the account key is `admin`." }, + "me": { + "envelope": "none", + "$comment": "GET /auth/me answers with the BARE user object — no `{ user }` wrapper. Pinned because its absence from this table is what let one side change shape while the other kept unwrapping a key that was no longer there: the client returned `undefined` while every other signal said authenticated, and the test that should have caught it mocked the old envelope." + }, "mfaChallenge": ["mfaRequired", "mfaTempToken"], "wsTicket": ["ticket", "expiresIn"] }, diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index 0d17aa5..c63fa96 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -155,6 +155,21 @@ pub struct RateLimitConfig { pub oauth_initiate: Option, /// `GET /auth/oauth/{provider}/callback` — 10 / 60s. pub oauth_callback: Option, + /// `POST /auth/logout` — 20 / 60s. + /// + /// The route is public: it has to be, or a user whose access token expired could not sign + /// out and the refresh session would live out its full lifetime on a device they had just + /// abandoned. Public and unlimited is a different thing, though — each call costs a hash + /// and several store round trips, and nothing about the caller is known. The ceiling is + /// deliberately loose: a browser with several tabs can legitimately fire a handful at once, + /// and being rate-limited out of signing out would be its own security problem. + pub logout: Option, + /// `POST /auth/ws-ticket` — 20 / 60s. + /// + /// Authenticated, but every call writes a fresh single-use ticket key, so an authenticated + /// caller could otherwise mint them without bound. A reconnecting client needs one per + /// socket; 20 covers a flapping connection without covering a loop. + pub ws_ticket: Option, } impl Default for RateLimitConfig { @@ -181,6 +196,8 @@ impl Default for RateLimitConfig { revoke_all_sessions: Some(RateLimit::new(5, 60)), oauth_initiate: Some(RateLimit::new(10, 60)), oauth_callback: Some(RateLimit::new(10, 60)), + logout: Some(RateLimit::new(20, 60)), + ws_ticket: Some(RateLimit::new(20, 60)), } } } @@ -267,7 +284,7 @@ mod tests { fn every_default_limit_matches_the_shared_wire_contract() { let contract = contract_limits(); let defaults = RateLimitConfig::default(); - let pairs: [(&str, Option); 21] = [ + let pairs: [(&str, Option); 23] = [ ("login", defaults.login), ("register", defaults.register), ("refresh", defaults.refresh), @@ -289,6 +306,8 @@ mod tests { ("revokeAllSessions", defaults.revoke_all_sessions), ("oauthInitiate", defaults.oauth_initiate), ("oauthCallback", defaults.oauth_callback), + ("logout", defaults.logout), + ("wsTicket", defaults.ws_ticket), ]; for (name, limit) in pairs { diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index 59a8de7..c9f16d9 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -37,7 +37,10 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout "/login", crate::router::throttled(post(login), limits.login, ip_source), ) - .route("/logout", post(logout)) + .route( + "/logout", + crate::router::throttled(post(logout), limits.logout, ip_source), + ) .route( "/refresh", crate::router::throttled(post(refresh), limits.refresh, ip_source), @@ -56,11 +59,14 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout ), ); - // The WS-ticket mint endpoint compiles only under the `websocket` feature; it is NOT - // assigned an edge limit (§16.3) — it is an authenticated, status- and MFA-gated route, - // not a credential-entry path. A consumer needing to cap mint volume adds an outer limit. + // The WS-ticket mint endpoint compiles only under the `websocket` feature. It IS limited, + // despite being authenticated and status/MFA-gated: every call writes a fresh single-use + // ticket key, so without a ceiling one authenticated caller can mint them without bound. #[cfg(feature = "websocket")] - let router = router.route("/ws-ticket", post(crate::ws::ws_ticket)); + let router = router.route( + "/ws-ticket", + crate::router::throttled(post(crate::ws::ws_ticket), limits.ws_ticket, ip_source), + ); router } 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 b1f914d..619ada6 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -28,7 +28,13 @@ use crate::traits::{HookContext, OtpPurpose, ResetContext}; /// OTP verification to the reset form (§7.8 `VERIFIED_TOKEN_TTL_SECONDS`). const VERIFIED_TOKEN_TTL_SECONDS: u64 = 300; -/// The atomic resend cooldown for the OTP method, in seconds (§7.8.4). +/// Seconds one account must wait between reset sends (§7.8.4), shared by `initiate_reset` and +/// `resend_reset_otp`. +/// +/// It is not only about mail volume. Every issuance rewrites the OTP record with `attempts: 0`, +/// so an entry point that can be called freely converts the 5-attempt ceiling into 5 attempts +/// *per call*, and a six-digit code stops being a secret. Both doors therefore draw on one +/// budget under one key. const RESEND_COOLDOWN_SECS: u64 = 60; /// The bytes of entropy in a reset link / verified token before hex-encoding (256-bit). @@ -147,6 +153,24 @@ impl AuthEngine { /// the anti-enumeration timing floor to every exit path (success and infra error alike). async fn initiate_reset_inner(&self, input: &ForgotPasswordInput) -> Result<(), AuthError> { let config = self.config().config(); + + // The SAME cooldown `resend_reset_otp` claims, under the same key, so the two entry + // points share one budget rather than one throttling itself while the other hands out + // fresh sends for free. Two things depended on that: every issuance rewrites the OTP + // record with `attempts: 0`, so an untimed initiate turns the 5-attempt ceiling into + // 5 attempts *per call* — an unbounded supply of guesses at a six-digit code — and each + // call also mails the victim, which is a mail bomb aimed at an address the caller merely + // has to know. A cooldown hit is a silent success, and the caller's anti-enumeration + // floor still applies, so the throttle does not itself answer whether the account exists. + let identifier = self.hashed_identifier(&input.tenant_id, &input.email); + if !self + .otp() + .try_begin_resend(OtpPurpose::PasswordReset, &identifier, RESEND_COOLDOWN_SECS) + .await? + { + return Ok(()); + } + // Look up the account; an unknown email or a blocked account takes no visible branch. if let Some(user) = self .user_repository() @@ -800,6 +824,13 @@ mod tests { assert!(h.engine.reset_password(reset, &ctx()).await.is_ok()); // Verified-token bridge: re-send an OTP, verify it for a token, reset with the token. + // The earlier initiate claimed the resend cooldown — that gate is what stops a caller + // re-minting an OTP (and a fresh `attempts: 0`) at will — so release it explicitly + // rather than silently getting no second code and skipping the rest of this test. + assert!( + h.stores + .expire_resend_cooldown(OtpPurpose::PasswordReset, &identifier) + ); assert!( h.engine .initiate_reset(forgot("otp@example.com"), &ctx()) @@ -845,6 +876,63 @@ mod tests { )); } + #[tokio::test] + async fn initiate_shares_the_resend_cooldown_so_the_otp_attempt_ceiling_cannot_be_reset() { + // `resend_reset_otp` was throttled and `initiate_reset` was not, which made the + // throttle decorative: the caller just used the other door. It also made the OTP's + // 5-attempt ceiling per-issuance rather than per-account, because every issuance + // rewrites the record with `attempts: 0` — so an attacker who knows an address could + // loop "initiate, guess five times" at a six-digit code forever, and mail the victim + // once per lap while doing it. + let Some(h) = otp_harness() else { return }; + let _ = h + .seed(SeedUser::active("throttled@example.com", "old")) + .await; + let identifier = h.engine.hashed_identifier("t1", "throttled@example.com"); + + assert!( + h.engine + .initiate_reset(forgot("throttled@example.com"), &ctx()) + .await + .is_ok() + ); + let first = h + .stores + .peek_otp(OtpPurpose::PasswordReset, &identifier) + .unwrap_or_default(); + assert!(!first.is_empty(), "the first initiate mints an OTP"); + + // Burn four of the five attempts, then try to buy five more with a second initiate. + for _ in 0..4 { + let _ = h + .engine + .verify_reset_otp( + VerifyResetOtpInput { + email: "throttled@example.com".to_owned(), + tenant_id: "t1".to_owned(), + otp: "000000".to_owned(), + }, + &ctx(), + ) + .await; + } + + assert!( + h.engine + .initiate_reset(forgot("throttled@example.com"), &ctx()) + .await + .is_ok(), + "a throttled initiate is still a silent success — it must not answer whether the account exists" + ); + // The stored code is untouched, which means its attempt counter is too: the second + // call bought nothing. + assert_eq!( + h.stores.peek_otp(OtpPurpose::PasswordReset, &identifier), + Some(first), + "the cooldown must stop a second issuance from resetting the attempt counter" + ); + } + #[tokio::test] async fn a_reset_started_in_one_casing_completes_in_another() { // Both entry points canonicalize the address before anything derives a key from it. @@ -886,7 +974,13 @@ mod tests { assert!(after.is_some() && after != before); // The verified-token bridge canonicalizes too, so the OTP minted under one spelling - // verifies under another and the token it returns completes the reset. + // verifies under another and the token it returns completes the reset. The first + // initiate above claimed the resend cooldown — which is the point of that gate — so + // release it explicitly rather than silently getting no second OTP. + assert!( + h.stores + .expire_resend_cooldown(OtpPurpose::PasswordReset, &identifier) + ); assert!( h.engine .initiate_reset(forgot("case@example.com"), &ctx()) @@ -1484,7 +1578,13 @@ mod tests { // …and again with the rollback itself refused. The caller still sees the same // anti-enumerating success — it must not learn that the address exists, let alone that // the store is down — so the log is the only place a token now stranded in Redis for - // its whole TTL can surface at all. + // its whole TTL can surface at all. The first initiate claimed the resend cooldown, so + // release it: otherwise this second call returns before reaching the send at all, and + // the rollback branch under test never runs. + assert!(stores.expire_resend_cooldown( + OtpPurpose::PasswordReset, + &engine.hashed_identifier("t1", "fail@example.com") + )); stores.fail_next_cleanup_writes(1); assert!( engine diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index f0765a9..567d131 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -327,6 +327,15 @@ impl TokenManagerService { }) } RotateOutcome::Grace(recovered) => { + // The cap is measured again here, against the RECOVERED record. The check + // before the script ran against the seed — and on this path the seed is the + // placeholder used when the live key is already gone, whose `family_created_at` + // is `None`, so that check returned early and applied nothing. Without this + // second check a lineage that had just passed its absolute cap could still mint + // a fresh access token and a full-length refresh session by presenting a token + // inside its grace window: the cap ends normal rotation and the one remaining + // door stays open. + self.assert_within_absolute_lifetime(&recovered)?; // Lost the rotation race: mint a fresh session for the recovered identity // rather than re-planting a grace pointer. let fresh = RawRefreshToken::generate(); @@ -509,6 +518,15 @@ impl TokenManagerService { }) } RotateOutcome::Grace(recovered) => { + // The cap is measured again here, against the RECOVERED record. The check + // before the script ran against the seed — and on this path the seed is the + // placeholder used when the live key is already gone, whose `family_created_at` + // is `None`, so that check returned early and applied nothing. Without this + // second check a lineage that had just passed its absolute cap could still mint + // a fresh access token and a full-length refresh session by presenting a token + // inside its grace window: the cap ends normal rotation and the one remaining + // door stays open. + self.assert_within_absolute_lifetime(&recovered)?; // Lost the rotation race: mint a fresh platform session for the recovered // identity rather than re-planting a grace pointer. let fresh = RawRefreshToken::generate(); @@ -1763,6 +1781,56 @@ mod absolute_lifetime_tests { } } + #[tokio::test] + async fn a_grace_recovery_is_refused_once_the_family_outlives_the_cap() { + // The cap must hold on the GRACE path too. The check before the script runs against the + // seed, and on this path the seed is the placeholder used when the live key is already + // gone — its `family_created_at` is `None`, so that check returns early and applies + // nothing. Without a second check against the RECOVERED record, a lineage that had just + // passed its cap could still mint a fresh access token and a full-length refresh + // session by presenting a token inside its grace window: the cap ends normal rotation + // and the one remaining door stays open. + let store = Arc::new(InMemoryStores::new()); + // An UNCAPPED manager plants the grace pointer, because a capped one would refuse the + // first rotation outright and there would be no pointer to recover from. + let uncapped = TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), + store.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ); + let old = RawRefreshToken::generate(); + assert!( + store + .create_session( + SessionKind::Dashboard, + &old.redis_hash(), + &record_born(31), + 3600 + ) + .await + .is_ok() + ); + assert!( + uncapped + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await + .is_ok(), + "the first rotation plants the grace pointer" + ); + + // Now replay the consumed token against a CAPPED manager: the live key is gone, so this + // takes the grace path, and the recovered record is 31 days old against a 30-day cap. + let refused = capped(store.clone()) + .reissue_tokens(old.expose_secret(), "203.0.113.4", "Chrome") + .await; + + assert!(matches!(refused, Err(AuthError::RefreshTokenInvalid))); + } + #[tokio::test] async fn a_rotation_is_refused_once_the_family_outlives_the_cap() { // `refresh_expires_in_days` bounds a single token, not a session: a client rotating diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 2abd887..65f7f84 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -386,6 +386,16 @@ impl InMemoryStores { *lock(&self.last_rotate_ttl_secs) } + /// Drop the resend-cooldown marker for `(purpose, identifier)`, letting a test drive a + /// second issuance without waiting out the window. Returns whether a marker was held. + /// + /// Production has no such door: the cooldown is what keeps a caller from re-minting an OTP + /// (and with it a fresh `attempts: 0`) as often as they like. A test that needs two + /// issuances is testing something else and says so by calling this. + pub fn expire_resend_cooldown(&self, purpose: OtpPurpose, identifier: &str) -> bool { + lock(&self.resend).remove(&(purpose, identifier.to_owned())) + } + /// Read the stored OTP code for a purpose + identifier without consuming it. A test-only /// inspection helper (the real store never exposes a stored code), used to drive the /// verification flow end to end against the in-memory double. diff --git a/packages/rust-auth/src/client/index.ts b/packages/rust-auth/src/client/index.ts index fa26f8b..53a3f46 100644 --- a/packages/rust-auth/src/client/index.ts +++ b/packages/rust-auth/src/client/index.ts @@ -345,12 +345,12 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { }, refresh: async () => readJson(await authFetch(routes.refresh, { method: "POST" })), - getMe: async () => { - const wrapper = await readJson<{ user: AuthUserClient }>( - await authFetch(routes.me, { method: "GET" }), - ); - return wrapper.user; - }, + // `GET /auth/me` answers with the bare user object, not a `{ user }` envelope — see + // `user_body` in the axum adapter. Unwrapping a key the server does not send returned + // `undefined` while every other signal said "authenticated", so `AuthProvider` reported a + // session with no user and a consumer reading `user.role` threw on a perfectly good login. + getMe: async () => + readJson(await authFetch(routes.me, { method: "GET" })), mfaChallenge: async (tempToken, code) => readJson( await authFetch(routes.mfaChallenge, jsonPost({ mfaTempToken: tempToken, code })), diff --git a/packages/rust-auth/src/nextjs/proxy.ts b/packages/rust-auth/src/nextjs/proxy.ts index 4ab54a6..3051f2d 100644 --- a/packages/rust-auth/src/nextjs/proxy.ts +++ b/packages/rust-auth/src/nextjs/proxy.ts @@ -143,7 +143,12 @@ export function createAuthProxy(config: AuthProxyConfig): AuthProxyInstance { const proxy = async (request: NextRequest): Promise => { const { pathname } = request.nextUrl; if (isPublicPath(pathname, resolved.publicPaths)) { - return NextResponse.next(); + // A public path still has to have the caller's `x-user-*` stripped: they are forgeable + // by anyone and the page behind a public route renders with whatever it is handed. This + // arm used to forward them verbatim, so the headers were spoofable with no token at all. + const headers = new Headers(request.headers); + stripUserHeaders(headers); + return NextResponse.next({ request: { headers } }); } const token = request.cookies.get(AUTH_ACCESS_COOKIE_NAME)?.value; @@ -254,12 +259,30 @@ function redirectToLogin( return NextResponse.redirect(url); } -/** Forward the request with UI-only `x-user-*` headers (advisory; never authoritative). */ +/** + * Forward the request with UI-only `x-user-*` headers (advisory; never authoritative). + * + * Every one of them is DELETED from the caller's copy first, then set only from the verified + * token. The `tenantId` and `status` sets are conditional, and a request that reaches here + * always carries both claims — the proxy refuses a token missing either — so this is + * defence-in-depth rather than a hole being closed: it removes the asymmetry where two of the + * four headers were authoritative and two depended on a claim being present, which is not a + * distinction a consumer reading them could see. The public-path arm below is the case that + * WAS reachable: it forwarded the caller's headers verbatim, with no token involved at all. + */ +function stripUserHeaders(headers: Headers): void { + headers.delete("x-user-id"); + headers.delete("x-user-role"); + headers.delete("x-user-tenant-id"); + headers.delete("x-user-status"); +} + function forwardWithUserHeaders( request: NextRequest, user: { id: string; role: string; tenantId: string | undefined; status: string | undefined }, ): NextResponse { const headers = new Headers(request.headers); + stripUserHeaders(headers); headers.set("x-user-id", user.id); headers.set("x-user-role", user.role); if (user.tenantId !== undefined) headers.set("x-user-tenant-id", user.tenantId); @@ -269,7 +292,12 @@ function forwardWithUserHeaders( /** Whether a pathname is matched by any configured public prefix. */ function isPublicPath(pathname: string, publicPaths: readonly string[]): boolean { - return publicPaths.some((prefix) => pathname === prefix || pathname.startsWith(prefix)); + // The prefix must end at a segment boundary. A bare `startsWith` made `/login` exempt + // `/loginhistory` too, and the direction of that mistake is fail-open: a route the operator + // meant to protect becomes public because its name happens to start with a public one. + return publicPaths.some( + (prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`), + ); } /** diff --git a/packages/rust-auth/tests/client.test.ts b/packages/rust-auth/tests/client.test.ts index 843698f..fb9f1b4 100644 --- a/packages/rust-auth/tests/client.test.ts +++ b/packages/rust-auth/tests/client.test.ts @@ -324,9 +324,12 @@ describe("AuthClient — endpoint, payload, and error mapping", () => { expect(first(calls).url).toBe("https://api.test/auth/refresh"); }); - it("getMe GETs /auth/me and unwraps the { user } envelope", async () => { + // The route answers with the BARE user object, not a `{ user }` envelope — mocking the + // envelope here is what let the client's `wrapper.user` unwrap survive the server changing + // shape: the test passed while returning `undefined` to every real caller. + it("getMe GETs /auth/me and returns the bare user body", async () => { const user = makeUser(); - const calls = installFetch(async () => jsonResponse({ user })); + const calls = installFetch(async () => jsonResponse(user)); const client = createAuthClient({ baseUrl, timeout: 0 }); const value = await client.getMe(); diff --git a/packages/rust-auth/tests/nextjs.test.ts b/packages/rust-auth/tests/nextjs.test.ts index cd434b2..994f4fb 100644 --- a/packages/rust-auth/tests/nextjs.test.ts +++ b/packages/rust-auth/tests/nextjs.test.ts @@ -237,6 +237,52 @@ describe("createAuthProxy — fail-closed verification (S1) and token-type asser }); }); +describe("createAuthProxy — client-supplied x-user-* headers never survive", () => { + /** A protected request that forges every advisory identity header. */ + function forged(token: string): NextRequest { + return new NextRequest("https://app.test/dashboard", { + headers: { + cookie: `access_token=${token}`, + "x-user-id": "victim", + "x-user-role": "ADMIN", + "x-user-tenant-id": "victim-tenant", + "x-user-status": "ACTIVE", + }, + }); + } + + it("overwrites every forged header from the verified token", async () => { + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET }); + const response = await proxy(forged(dashboardToken())); + + expect(response.headers.get("x-middleware-request-x-user-id")).toBe("u_1"); + expect(response.headers.get("x-middleware-request-x-user-role")).not.toBe("ADMIN"); + }); + + it("strips them on a public path, where there is no token at all", async () => { + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET, publicPaths: ["/public"] }); + const response = await proxy( + new NextRequest("https://app.test/public", { + headers: { "x-user-id": "victim", "x-user-role": "ADMIN" }, + }), + ); + + const injected = response.headers.get("x-middleware-override-headers"); + expect(injected).not.toBeNull(); + expect(injected).not.toContain("x-user-id"); + expect(response.headers.get("x-middleware-request-x-user-id")).not.toBe("victim"); + }); + + it("does not exempt a protected path that merely starts with a public prefix", async () => { + // `/login` must not make `/loginhistory` public. The direction of that mistake is + // fail-open: a route the operator meant to protect becomes reachable unauthenticated. + const { proxy } = createAuthProxy({ accessTokenSecret: SECRET, publicPaths: ["/login"] }); + const response = await proxy(new NextRequest("https://app.test/loginhistory")); + + expect(redirectedToLogin(response)).toBe(true); + }); +}); + describe("createAuthProxy — forged background headers are not an auth bypass (RC10)", () => { /** * Whether a response is the bare, uncacheable 401 the proxy owes an unauthenticated From f795171f36e925e3b30a40bb1fa59d82b5db2f12 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:19:04 -0300 Subject: [PATCH 106/122] fix(core, axum)!: close the refresh gate, the platform logout, and revoke-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh re-reads the account and re-applies both gates login applies. Rotation worked entirely from the store record, so nothing on that path ever looked at the user again — and rotation is the door a signed-in caller actually uses. A banned account renewed its access token for the refresh token's whole lifetime; an address that was never verified held a session indefinitely, because `register` issues one deliberately and only `login` ever checked. A blocked account that touches the system now has every session revoked and its epoch bumped in the same breath; an unverified one is refused without compensation, because an unproven address is an unfinished onboarding and revoking would kill the very token rendering the "check your inbox" screen. `POST /auth/platform/logout` no longer requires a live access token. It required `PlatformUser`, which refuses an expired one, so an operator who stepped away for longer than the access lifetime could not sign out and the refresh session of the highest-privilege identity in the system stayed live on a console they believed they had left. `DELETE /auth/sessions/all` accepts the refresh token from the body and refuses when it cannot identify the caller's session. It read the cookie only, and a bearer-mode deployment plants none — so it could never identify one, and the engine treated that as "revoke nothing, successfully": a user with a compromised second device clicked "sign out my other devices", got 204, and nothing happened. The in-memory store double now clears grace pointers on `revoke_all`, as the real Lua does. Keeping them made the double weaker than production, so the property those flows exist to guarantee was asserted against a fake that could not break it. BREAKING CHANGE: `AuthEngine::refresh` returns `RefreshedSession`, and `AuthEngine::platform_logout` drops its `admin_id` parameter and returns the revoked session's owner instead. --- CHANGELOG.md | 32 +++ crates/bymax-auth-axum/src/routes/auth.rs | 43 +--- crates/bymax-auth-axum/src/routes/platform.rs | 12 +- crates/bymax-auth-axum/src/routes/sessions.rs | 14 +- crates/bymax-auth-axum/tests/adapter.rs | 60 ++++- crates/bymax-auth-axum/tests/redis_e2e.rs | 35 +++ .../src/services/adapter_api.rs | 41 ++-- .../src/services/auth/session_ops.rs | 209 +++++++++++++++++- .../bymax-auth-core/src/services/platform.rs | 101 ++++++--- .../src/services/token_manager.rs | 25 +++ crates/bymax-auth-core/src/testing/mod.rs | 18 ++ .../tests/platform_identity_e2e.rs | 4 +- crates/bymax-auth-redis/tests/redis_stores.rs | 6 +- 13 files changed, 510 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a0af6..876744f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,38 @@ version bump. forwarded them verbatim, so the advisory identity headers were forgeable with no token at all — and `isPublicPath` now matches at a segment boundary, so `/login` no longer exempts `/loginhistory`. +- **Refresh re-reads the account and re-applies the status and email-verification gates.** + Rotation worked entirely from the store record, so nothing on that path ever looked at the + user again — and rotation is the door a signed-in caller actually uses. A banned account + renewed its access token for the refresh token's whole lifetime (ASVS v5 §7.4.2 requires + disabling an account to terminate its sessions), and an address that was never verified held + a session indefinitely, because `register` issues one deliberately and only `login` ever + checked. A blocked account that touches the system now has every session revoked and its + epoch bumped in the same breath; an unverified one is refused without compensation, since an + unproven address is an unfinished onboarding and revoking would kill the token rendering the + "check your inbox" screen. **Breaking:** `AuthEngine::refresh` returns `RefreshedSession` + (the rotated tokens plus the account), which also removes the adapter's second verify-and-look-up. +- **`AuthEngine::revoke_all_sessions(user_id)`** — the dashboard twin of `platform_revoke_all`, + for the moment a host suspends, bans or deletes an account. `revoke_all_except_current` could + not serve: it wants the hash of a session to keep, and an administrator banning somebody else + has none. +- **`POST /auth/platform/logout` no longer requires a live access token.** It required + `PlatformUser`, which refuses an expired one, so an operator who stepped away for longer than + the access lifetime could not sign out and the refresh session of the highest-privilege + identity in the system stayed live on a console they believed they had left. Same fix the + dashboard plane took earlier in this cycle. **Breaking:** `AuthEngine::platform_logout` drops + its `admin_id` parameter — the owner is read from the stored record — and returns it instead. +- **`DELETE /auth/sessions/all` accepts the refresh token from the body and refuses when it + cannot identify the caller's session.** It read the cookie only, and a bearer-mode deployment + plants none — so it could never identify one, and the engine treated that as "revoke nothing, + successfully". A user with a compromised second device clicked "sign out my other devices", + got 204, and nothing happened. `nest-auth` has always refused this case with + `session_not_found`. +- **The in-memory `SessionStore` double clears grace pointers on `revoke_all`**, as the real + Lua does. Keeping them made the double *weaker* than production: a token inside its grace + window would still recover a session after "sign out everywhere", a password reset, or an MFA + change — the exact property those flows exist to guarantee, asserted against a fake that + could not break it. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/crates/bymax-auth-axum/src/routes/auth.rs b/crates/bymax-auth-axum/src/routes/auth.rs index c9f16d9..f4f84ad 100644 --- a/crates/bymax-auth-axum/src/routes/auth.rs +++ b/crates/bymax-auth-axum/src/routes/auth.rs @@ -10,7 +10,7 @@ use axum::extract::State; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use bymax_auth_core::services::auth::{LoginInput, RegisterInput}; -use bymax_auth_types::{AuthError, AuthResult, LoginResult, RotatedTokens}; +use bymax_auth_types::{AuthResult, LoginResult}; use http::StatusCode; use tower_cookies::Cookies; @@ -167,20 +167,22 @@ async fn refresh( let body_refresh = dto.refresh_token.as_deref(); let refresh = source_refresh_token(&cookies, &state.config().cookies.refresh_name, body_refresh); - let tokens = match state + let refreshed = match state .engine() .refresh(&refresh, &ctx.ip, &ctx.user_agent) .await { - Ok(tokens) => tokens, + Ok(refreshed) => refreshed, Err(error) => return error_response(&error), }; - match rotated_into_auth_result(&state, tokens).await { - Ok(result) => { - TokenDelivery::with_domains(state.config(), &domains).deliver_refresh(&cookies, &result) - } - Err(error) => error_response(&error), - } + // No second verify-and-look-up here: `refresh` already re-read the account to apply the + // status and email-verification gates, and hands it back with the tokens. + let result = AuthResult { + user: refreshed.user, + access_token: refreshed.tokens.access_token, + refresh_token: refreshed.tokens.refresh_token, + }; + TokenDelivery::with_domains(state.config(), &domains).deliver_refresh(&cookies, &result) } /// `GET /auth/me` (200). Requires [`AuthUser`]. Returns the credential-free user as the @@ -239,26 +241,3 @@ fn deliver_login( LoginResult::MfaChallenge(challenge) => delivery.deliver_mfa_challenge(&challenge), } } - -/// Pair a rotated token pair with the account it belongs to, producing the [`AuthResult`] the -/// delivery layer shapes into the refresh body. -/// -/// The engine's `refresh` returns only the pair, so the subject is recovered from the -/// freshly-minted access token (it verifies by construction) and the account is re-read -/// through `me` — the same "rotate, then `getMe`" sequence nest-auth's controller performs, -/// which also guarantees the echoed record reflects any change made since the last issuance. -async fn rotated_into_auth_result( - state: &AuthState, - tokens: RotatedTokens, -) -> Result { - let claims = state - .engine() - .verify_access_token(&tokens.access_token) - .await?; - let user = state.engine().me(&claims.sub).await?; - Ok(AuthResult { - user, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - }) -} diff --git a/crates/bymax-auth-axum/src/routes/platform.rs b/crates/bymax-auth-axum/src/routes/platform.rs index 651e841..d7182c6 100644 --- a/crates/bymax-auth-axum/src/routes/platform.rs +++ b/crates/bymax-auth-axum/src/routes/platform.rs @@ -118,14 +118,20 @@ async fn me(State(state): State, user: PlatformUser) -> Response { } } -/// `POST /auth/platform/logout` (204). Requires [`PlatformUser`]. +/// `POST /auth/platform/logout` (204). Public — deliberately. +/// +/// It used to require [`PlatformUser`], which refuses an EXPIRED access token, so an operator +/// who stepped away for longer than the access lifetime could not sign out at all and the +/// refresh session of the highest-privilege identity in the system stayed live on a console +/// they believed they had left. The refresh token is what authorizes the operation, the stored +/// record names its owner, and the access token is still verified — signature and pinned +/// algorithm, expiry aside — before its `jti` is blacklisted. Same shape as the dashboard route. /// /// The refresh token is read from the request body only: platform delivery never planted a /// cookie, so there is none to read, and honouring the dashboard refresh cookie here would let /// it shadow the body value and leave the platform session alive after logout. async fn logout( State(state): State, - user: PlatformUser, PresentedPlatformAccessToken(access_token): PresentedPlatformAccessToken, body: axum::body::Bytes, ) -> Response { @@ -135,7 +141,7 @@ async fn logout( let refresh = dto.refresh_token.unwrap_or_default(); let _ = state .engine() - .platform_logout(&access_token, &refresh, &user.0.sub) + .platform_logout(&access_token, &refresh) .await; StatusCode::NO_CONTENT.into_response() } diff --git a/crates/bymax-auth-axum/src/routes/sessions.rs b/crates/bymax-auth-axum/src/routes/sessions.rs index 4fffe28..cf8dd40 100644 --- a/crates/bymax-auth-axum/src/routes/sessions.rs +++ b/crates/bymax-auth-axum/src/routes/sessions.rs @@ -67,8 +67,20 @@ async fn revoke_all( _status: UserStatus, user: AuthUser, cookies: Cookies, + body: axum::body::Bytes, ) -> Response { - let refresh = source_refresh_token(&cookies, &state.config().cookies.refresh_name, None); + // The body channel matters as much as the cookie: a bearer-mode deployment plants no + // cookies at all, so reading only the jar meant this route could never identify the + // caller's current session — and it answered 204 having revoked nothing. `logout` and + // `refresh` both accept the token either way for exactly this reason. An unparseable body + // degrades to "no body-supplied token" rather than 400-ing, which the engine then refuses + // explicitly instead of silently succeeding. + let dto = crate::routes::parse_optional_refresh_body(&body).unwrap_or_default(); + let refresh = source_refresh_token( + &cookies, + &state.config().cookies.refresh_name, + dto.refresh_token.as_deref(), + ); let raw = (!refresh.is_empty()).then_some(refresh.as_str()); match state .engine() diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index a9cbbc5..382722f 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -120,6 +120,55 @@ async fn the_set_cookie_header_is_marked_sensitive_so_tracing_cannot_print_the_t } } +#[tokio::test] +async fn revoke_all_sessions_works_in_bearer_mode_and_never_reports_a_silent_success() { + // A bearer deployment plants no cookies at all, and this route read the caller's current + // session only from the jar — so it could never identify one, and the engine treated that + // as "revoke nothing, successfully". A user with a compromised second device clicked + // "sign out my other devices", got 204, and nothing happened: the attacker's refresh token + // kept rotating. The body channel now works, and an unidentifiable session is refused + // rather than answered. + let Some(h) = build(EngineSpec { + delivery: bymax_auth_core::config::TokenDelivery::Bearer, + sessions: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "revoker@e.com", "password": "password123", "name": "Reva", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(reg.status, StatusCode::CREATED); + let body = reg.json(); + let access = body["accessToken"].as_str().unwrap_or_default().to_owned(); + let refresh = body["refreshToken"].as_str().unwrap_or_default().to_owned(); + assert!( + !refresh.is_empty(), + "bearer mode returns the tokens in the body" + ); + + // No body-supplied token: the route cannot tell which session to keep, and says so. + let blind = Req::delete("/auth/sessions/all") + .bearer(&access) + .send(&app) + .await; + assert_eq!(blind.status, StatusCode::NOT_FOUND); + assert_eq!(blind.json()["error"]["code"], "auth.session_not_found"); + + // With the token in the body — the channel a bearer client actually has — it works. + let revoked = Req::delete("/auth/sessions/all") + .bearer(&access) + .json(serde_json::json!({ "refreshToken": refresh })) + .send(&app) + .await; + assert_eq!(revoked.status, StatusCode::NO_CONTENT); +} + #[tokio::test] async fn session_cookies_are_host_only_unless_a_domain_resolver_is_configured() { // Default: no `Domain` attribute at all. A cookie carrying `Domain=app.example.com` is @@ -2266,14 +2315,15 @@ async fn platform_me_and_revoke_error_arms_with_a_ghost_admin() { .await; assert_eq!(revoke.status, StatusCode::NO_CONTENT); // The token that performed the revoke is now dead like every other token the ghost held: - // revoke-all reaches the access tokens too, not only the refresh sessions. (The plain - // ghost-logout 204 arm is covered by the real-admin logout test; one token cannot both - // revoke-all and then log out, which is the point.) - let logout = Req::post("/auth/platform/logout") + // revoke-all reaches the access tokens too, not only the refresh sessions. Probed with + // `me` rather than `logout` — logout is deliberately public now, so that an operator whose + // access token expired can still end their session, which makes it useless as evidence + // that a token is still alive. + let dead = Req::get("/auth/platform/me") .bearer(&ghost) .send(&app) .await; - assert_eq!(logout.status, StatusCode::UNAUTHORIZED); + assert_eq!(dead.status, StatusCode::UNAUTHORIZED); } #[tokio::test] diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 30c15b1..8231768 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -147,6 +147,14 @@ fn build_engine( } /// Seed an active dashboard user; returns its id. +/// The id of an account created through the HTTP route, looked up by address. +async fn user_id_of(users: &InMemoryUserRepository, email: &str) -> String { + match users.find_by_email(email, TENANT).await { + Ok(Some(user)) => user.id, + _ => String::new(), + } +} + async fn seed_user(users: &InMemoryUserRepository, email: &str, role: &str) -> String { let created = users .create(CreateUserData { @@ -350,6 +358,33 @@ async fn full_router_against_real_redis() { // The safe user is the top-level body — no `{ user: … }` wrapper (nest-auth parity). assert_eq!(me.json()["email"], "r@e.com"); + // Rotation is refused while the address is unproven. `register` issues a full session + // deliberately — a consumer needs one to render the "check your inbox" screen — and the + // verification requirement has to survive rotation, or that window is unbounded: the gate + // used to live only on `login`, a door the caller never has to open again once register + // handed them a refresh token. This assertion is the reason the next lines verify first. + let unverified = call( + &app, + Method::POST, + "/auth/refresh", + None, + &[("refresh_token", &refresh)], + ) + .await; + assert_eq!(unverified.status, StatusCode::FORBIDDEN); + assert_eq!( + unverified.json()["error"]["code"], + "auth.email_not_verified" + ); + + // Prove the address (the OTP round trip has its own tests; this one is about the router + // against real Redis), then rotate. + assert!( + users + .update_email_verified(&user_id_of(&users, "r@e.com").await, true) + .await + .is_ok() + ); let rotated = call( &app, Method::POST, diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 281b9dc..edb71ed 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -159,8 +159,14 @@ impl AuthEngine { .revoke_all_except_current(user_id, ¤t) .await } - // No identifiable current session: do not revoke the live request's own session. - None => Ok(()), + // Without the caller's refresh token there is no way to tell which session to + // keep. Refuse rather than answer success having done nothing: a user who clicks + // "sign out my other devices" because they believe one is compromised is making a + // statement about right now, and a silent 204 tells them it was acted on when it + // was not. `SessionNotFound` rather than `RefreshTokenInvalid` — this is a missing + // credential, not a rejected one, and the distinction keeps a client from + // pointlessly attempting a refresh. `nest-auth` has always answered this way. + None => Err(AuthError::SessionNotFound), } } @@ -439,11 +445,10 @@ impl AuthEngine { &self, access_token: &str, raw_refresh: &str, - admin_id: &str, - ) -> Result<(), AuthError> { + ) -> Result { self.platform_auth() .ok_or(AuthError::PlatformAuthRequired)? - .logout(access_token, raw_refresh, admin_id) + .logout(access_token, raw_refresh) .await } @@ -667,18 +672,26 @@ mod tests { )); } - /// `revoke_other_user_sessions` with no identifiable current session (absent/malformed - /// refresh token) is a no-op `Ok(())` — it never wipes the live request's own session. + /// `revoke_other_user_sessions` with no identifiable current session (absent or malformed + /// refresh token) REFUSES. It used to answer `Ok(())` having revoked nothing, which reads + /// as success to a caller who just clicked "sign out my other devices" because they + /// believe one is compromised — and in a bearer-mode deployment, where no cookie is ever + /// planted, that was every call. `SessionNotFound` rather than `RefreshTokenInvalid`: the + /// credential is missing, not rejected, and the distinction keeps a client from + /// pointlessly attempting a refresh. #[tokio::test] - async fn revoke_other_user_sessions_without_a_current_hash_is_a_noop() { + async fn revoke_other_user_sessions_without_a_current_hash_refuses() { let Some(h) = harness(base_config(), None) else { return }; - assert!(h.engine.revoke_other_user_sessions("u", None).await.is_ok()); - assert!( + assert!(matches!( + h.engine.revoke_other_user_sessions("u", None).await, + Err(AuthError::SessionNotFound) + )); + assert!(matches!( h.engine .revoke_other_user_sessions("u", Some("not-shaped")) - .await - .is_ok() - ); + .await, + Err(AuthError::SessionNotFound) + )); } /// A sample set of dashboard claims for the ticket surfaces. @@ -870,7 +883,7 @@ mod tests { Err(AuthError::PlatformAuthRequired) )); assert!(matches!( - h.engine.platform_logout("t", "r", "a").await, + h.engine.platform_logout("t", "r").await, Err(AuthError::PlatformAuthRequired) )); assert!(matches!( 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 fe33b91..b757c5e 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -6,12 +6,29 @@ use std::collections::BTreeMap; use bymax_auth_jwt::RawRefreshToken; use bymax_auth_types::{AuthError, AuthResult, RotatedTokens, SafeAuthUser}; +use crate::context::to_safe_user; use crate::engine::AuthEngine; use crate::services::auth::detached::{run_after_login, run_after_logout, run_update_last_login}; use crate::services::auth::{map_repository_error, spawn_guarded}; use crate::services::{is_refresh_token_shape, now_unix}; use crate::traits::{HookContext, SessionKind}; +/// What a **dashboard** refresh returns: the rotated tokens plus the account behind them. +/// +/// Rotation itself works entirely from the store record, so the repository read this carries is +/// what lets the status and email-verification gates apply on refresh at all. Without them a +/// suspended account renews its access token for the refresh token's whole lifetime — the login +/// door a ban closes is one a signed-in user never needs to open again (ASVS v5 §7.4.2) — and +/// an address that was never proven holds a session indefinitely. The user is returned rather +/// than discarded so the adapter does not verify the token and read the repository a second +/// time to build the response body. `nest-auth` returns the same shape. +pub struct RefreshedSession { + /// The freshly minted access + refresh pair. + pub tokens: RotatedTokens, + /// The account behind the rotated session, re-read and re-checked during the rotation. + pub user: SafeAuthUser, +} + impl AuthEngine { /// Revoke the current session: blacklist the access token's `jti` for its remaining /// lifetime — only when the token's signature verifies — and delete the refresh session @@ -137,10 +154,87 @@ impl AuthEngine { old_refresh: &str, ip: &str, user_agent: &str, - ) -> Result { - self.tokens() + ) -> Result { + let rotated = self + .tokens() .reissue_tokens(old_refresh, ip, user_agent) + .await?; + + // The owner: read from the token we just minted, which is the only place it is + // available on both the live and the grace path. The adapter verified this same token + // to build its response body, so the work is moved rather than added. + let claims = self.tokens().verify_access(&rotated.access_token).await?; + let user_id = claims.sub; + + // Re-read the account and re-apply the two gates `login` applies. Rotation works + // entirely from the store record, so nothing else on this path ever looks at the user + // again — and rotation is the door a signed-in caller actually uses. Without this, two + // things hold in the default configuration: a banned account renews its access token + // for the refresh token's whole lifetime, because the ban closes only the login door + // (ASVS v5 §7.4.2 requires disabling an account to terminate its sessions); and an + // address that was never verified holds a session indefinitely, because `register` + // issues one deliberately and only `login` ever checked. + // + // The check runs AFTER rotation because that is the only point where the owner is known + // on both the live and the grace path. The compensation is deliberately total: every + // session the account holds is revoked, including the one just minted, and the epoch + // bump kills the access token issued a moment ago. + let user = match self + .user_repository() + .find_by_id(&user_id, None) .await + .map_err(map_repository_error)? + { + Some(user) => user, + None => { + // The account is gone and the session record outlived it. End it rather than + // hand back a token for a user nobody can look up. + self.revoke_all_sessions(&user_id).await?; + return Err(AuthError::TokenInvalid); + } + }; + if let Err(error) = self.assert_user_not_blocked(&user.status) { + self.revoke_all_sessions(&user_id).await?; + return Err(error); + } + // Refused, but NOT compensated. An unproven address is an unfinished onboarding, not a + // denied account: the refusal alone bounds the window to one access-token lifetime, + // which is exactly what the specification promises. Revoking everything here would + // also kill the token the consumer is using to render its "check your inbox" screen, + // breaking the flow that issued it. + if self.config().config().email_verification.required && !user.email_verified { + return Err(AuthError::EmailNotVerified); + } + + Ok(RefreshedSession { + tokens: rotated, + user: to_safe_user(&user), + }) + } + + /// End every dashboard session for one account, and kill the access tokens already issued. + /// + /// The dashboard twin of [`AuthEngine::platform_revoke_all`]. It exists because a + /// library cannot see the moment a host suspends, bans, or deletes an account — the user + /// record is the host's — and until the host says so, that account's live sessions keep + /// working. ASVS v5 §7.4.2 requires that moment to terminate them, so the host needs a + /// supported way to say it; `revoke_all_except_current` cannot serve, because it wants the + /// hash of a session to keep and an administrator banning somebody else has none. + /// + /// The epoch is bumped after the sweep, not before: a failure in the sweep then leaves the + /// operation visibly incomplete rather than reading as done while the sessions live on. + /// + /// # Errors + /// + /// Returns a store [`AuthError`] when the sweep or the epoch bump fails. + pub async fn revoke_all_sessions(&self, user_id: &str) -> Result<(), AuthError> { + self.session_store() + .revoke_all(SessionKind::Dashboard, user_id) + .await?; + self.session_store() + .bump_epoch(SessionKind::Dashboard, user_id) + .await?; + Ok(()) } /// Issue a full dashboard session for an existing user **without** a password @@ -230,7 +324,9 @@ mod tests { use super::*; use crate::services::auth::LoginInput; use crate::services::auth::test_support::{Harness, SeedUser, base_config, ctx, harness}; + use crate::traits::{SessionRecord, SessionStore as _, UserRepository as _}; use bymax_auth_types::{DashboardClaims, LoginResult}; + use time::OffsetDateTime; fn login_input(email: &str, password: &str) -> LoginInput { LoginInput { @@ -247,6 +343,113 @@ mod tests { Some((id, *auth)) } + #[tokio::test] + async fn refresh_refuses_and_revokes_everything_for_an_account_blocked_after_login() { + // A ban has to end an existing session, not merely refuse the next login — a door a + // signed-in user never needs to open again. Rotation works entirely from the store + // record, so without a re-read a suspended account renews its access token for the + // refresh token's whole lifetime (ASVS v5 §7.4.2). + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let Some((id, auth)) = logged_in(&h, "banned@e.com", "pw123456").await else { return }; + + // The operator bans the account through their own admin surface. + assert!(h.users.update_status(&id, "BANNED").await.is_ok()); + + let refused = h + .engine + .refresh(&auth.refresh_token, "1.2.3.4", "agent") + .await; + assert!(matches!(refused, Err(AuthError::AccountBanned))); + + // Total compensation: the session just minted goes with every other one the account + // holds, so a second attempt has nothing left to rotate. + let again = h + .engine + .refresh(&auth.refresh_token, "1.2.3.4", "agent") + .await; + assert!( + matches!(again, Err(AuthError::RefreshTokenInvalid)), + "unexpected ok" + ); + } + + #[tokio::test] + async fn refresh_refuses_when_the_account_no_longer_exists() { + // The session record outlived the account. Hand back nothing, and clear the orphan + // rather than leaving it to be rotated again. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, None) else { return }; + let Some((id, auth)) = logged_in(&h, "deleted@e.com", "password123").await else { return }; + + h.users.remove(&id); + + let refused = h + .engine + .refresh(&auth.refresh_token, "1.2.3.4", "agent") + .await; + assert!(matches!(refused, Err(AuthError::TokenInvalid))); + } + + #[tokio::test] + async fn refresh_refuses_an_unverified_address_without_revoking_the_session() { + // `register` issues a full session deliberately — a consumer needs one to render the + // "check your inbox" screen — and the specification bounds that window at one + // access-token lifetime. Rotation is what un-bounded it: the gate lived only on + // `login`, a door the caller never has to open again once register handed them a + // refresh token. The refusal alone restores the bound; revoking everything would also + // kill the token rendering that very screen, so this path is refused, not compensated. + // + // The session is seeded straight into the store because every issuance path applies + // the very gate under test — going through one would prove only that the gate it + // already had works. + let Some(h) = harness(base_config(), None) else { return }; + let id = h + .seed(SeedUser { + email: "unverified@e.com".to_owned(), + password: "password123".to_owned(), + tenant_id: "t1".to_owned(), + status: "ACTIVE".to_owned(), + email_verified: false, + mfa_enabled: false, + }) + .await; + let raw = RawRefreshToken::generate(); + let record = SessionRecord { + user_id: id, + tenant_id: Some("t1".to_owned()), + role: "USER".to_owned(), + device: "Chrome".to_owned(), + ip: "1.2.3.4".to_owned(), + created_at: OffsetDateTime::now_utc(), + mfa_enabled: false, + family_id: "fam-unverified".to_owned(), + family_created_at: Some(OffsetDateTime::now_utc()), + }; + let seeded = h + .stores + .create_session(SessionKind::Dashboard, &raw.redis_hash(), &record, 3600) + .await; + assert!(seeded.is_ok()); + + let refused = h + .engine + .refresh(raw.expose_secret(), "1.2.3.4", "agent") + .await; + assert!(matches!(refused, Err(AuthError::EmailNotVerified))); + + // NOT compensated: the account still holds its sessions, so the token that rendered + // the "check your inbox" screen keeps working for its remaining lifetime. + assert!( + h.stores + .find_session(SessionKind::Dashboard, &raw.redis_hash()) + .await + .is_ok() + ); + } + #[tokio::test] async fn logout_blacklists_the_access_token_and_revokes_the_session() { // After logout the access jti is blacklisted (verify_access rejects it) and the @@ -396,7 +599,7 @@ mod tests { .engine .refresh(&auth.refresh_token, "1.2.3.4", "agent") .await; - assert!(matches!(&rotated, Ok(r) if r.refresh_token != auth.refresh_token)); + assert!(matches!(&rotated, Ok(r) if r.tokens.refresh_token != auth.refresh_token)); } #[tokio::test] diff --git a/crates/bymax-auth-core/src/services/platform.rs b/crates/bymax-auth-core/src/services/platform.rs index 0841704..7988e5b 100644 --- a/crates/bymax-auth-core/src/services/platform.rs +++ b/crates/bymax-auth-core/src/services/platform.rs @@ -230,17 +230,37 @@ impl PlatformAuthService { /// # Errors /// /// The `Result` is reserved for forward compatibility and currently always returns `Ok`. - pub async fn logout( - &self, - access_token: &str, - raw_refresh: &str, - admin_id: &str, - ) -> Result<(), AuthError> { - // Blacklist only a token that actually verifies as a PLATFORM token: a forged/expired - // token needs no revocation, and a dashboard token can never verify here (the platform + pub async fn logout(&self, access_token: &str, raw_refresh: &str) -> Result { + // The stored session names its owner. Presenting the refresh token proves possession; + // the record proves whose it is. The admin id used to come from the route's + // `PlatformUser` extractor — which is why the route refused an EXPIRED access token, + // so an operator who stepped away for longer than the access lifetime could not sign + // out at all and the refresh session of the highest-privilege identity in the system + // stayed live on a console they believed they had left. The dashboard plane was fixed + // for exactly this; the platform plane kept the old shape. + let admin_id = if is_refresh_token_shape(raw_refresh) { + let hash = RawRefreshToken::from_raw(raw_refresh.to_owned()).redis_hash(); + self.session_store + .find_session(SessionKind::Platform, &hash) + .await? + .map(|record| record.user_id) + .unwrap_or_default() + } else { + String::new() + }; + let admin_id = admin_id.as_str(); + + // Blacklist only a token that actually verifies as a PLATFORM token: a forged token + // needs no revocation, and a dashboard token can never verify here (the platform // discriminator rejects it), so a dashboard `jti` can never pollute the platform-session - // logout path. Best-effort — a store failure must not block the logout. - if let Ok(claims) = self.tokens.verify_platform_access(access_token).await { + // logout path. The expiry is waived — an expired token is the normal case at logout — + // but the signature is not: the `jti` decides which token gets blacklisted, so reading + // it unverified would let a caller revoke one they do not own by naming its id. + // Best-effort — a store failure must not block the logout. + if let Ok(claims) = self + .tokens + .verify_platform_access_ignoring_expiry(access_token) + { let ttl = u64::try_from(claims.exp.saturating_sub(now_unix())).unwrap_or(0); let _ = self.tokens.revoke_access(&claims.jti, ttl).await; } @@ -264,13 +284,18 @@ impl PlatformAuthService { .await; } - let hook_ctx = identity_only_context(admin_id); - spawn_guarded(run_after_logout( - self.hooks.clone(), - admin_id.to_owned(), - hook_ctx, - )); - Ok(()) + // No live session matched the presented token — already signed out, or expired. There + // is no identity to hand the hook, and logout stays a success either way: it is + // idempotent, and answering an error would tell a caller whether a token was live. + if !admin_id.is_empty() { + let hook_ctx = identity_only_context(admin_id); + spawn_guarded(run_after_logout( + self.hooks.clone(), + admin_id.to_owned(), + hook_ctx, + )); + } + Ok(admin_id.to_owned()) } /// Atomically invalidate EVERY platform session for the admin (the "log out everywhere" @@ -766,7 +791,7 @@ mod tests { let logged = svc.login("hooked@admin.io", "pw", "1.2.3.4", "agent").await; let Ok(PlatformLoginResult::Success(auth)) = logged else { return }; assert!( - svc.logout(&auth.access_token, &auth.refresh_token, &id) + svc.logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -776,17 +801,43 @@ mod tests { assert_eq!(seen, vec![id]); } + #[tokio::test] + async fn logout_works_without_a_live_access_token() { + // The route used to require a live `PlatformUser`, so an operator who stepped away for + // longer than the fifteen-minute access lifetime could not sign out at all — and the + // refresh session of the highest-privilege identity in the system stayed live on a + // console they believed they had left. The refresh token is what authorizes this; the + // stored record names its owner. + let Some(h) = harness(platform_config()) else { return }; + let id = seed_admin(&h.admins, "away@admin.io", "pw"); + let Some(svc) = h.engine.platform_auth() else { return }; + let logged = svc.login("away@admin.io", "pw", "1.2.3.4", "agent").await; + let Ok(PlatformLoginResult::Success(auth)) = logged else { return }; + + // No access token at all — the shape a client sends once its bearer token has expired + // and it has nothing live to present. + let owner = svc.logout("", &auth.refresh_token).await; + assert_eq!(owner.ok(), Some(id)); + + // The session is gone: the refresh token no longer rotates. + assert!( + svc.refresh(&auth.refresh_token, "1.2.3.4", "agent") + .await + .is_err() + ); + } + #[tokio::test] async fn logout_blacklists_the_jti_and_revokes_the_session() { // After logout the platform access jti is blacklisted (verify rejects it) and the // refresh session is gone, so the refresh token no longer rotates. let Some(h) = harness(platform_config()) else { return }; - let id = seed_admin(&h.admins, "out@admin.io", "pw"); + let _id = seed_admin(&h.admins, "out@admin.io", "pw"); let Some(svc) = h.engine.platform_auth() else { return }; let logged = svc.login("out@admin.io", "pw", "1.2.3.4", "agent").await; let Ok(PlatformLoginResult::Success(auth)) = logged else { return }; assert!( - svc.logout(&auth.access_token, &auth.refresh_token, &id) + svc.logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -803,11 +854,7 @@ mod tests { )); // Logout tolerates a non-shaped refresh token, a garbage access token, and an unknown // admin, still succeeding. - assert!( - svc.logout("not-a-jwt", "unknown-refresh", "nobody") - .await - .is_ok() - ); + assert!(svc.logout("not-a-jwt", "unknown-refresh").await.is_ok()); } #[tokio::test] @@ -819,7 +866,7 @@ mod tests { // 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 _id = seed_admin(&h.admins, "grace@admin.io", "pw"); let Some(svc) = h.engine.platform_auth() else { return }; let logged = svc.login("grace@admin.io", "pw", "1.2.3.4", "agent").await; let Ok(PlatformLoginResult::Success(auth)) = logged else { return }; @@ -828,7 +875,7 @@ mod tests { let Ok(rotated) = rotation else { return }; // Logging out the OLD token cleans its grace pointer. assert!( - svc.logout(&auth.access_token, &auth.refresh_token, &id) + svc.logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 567d131..7d76686 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -150,6 +150,31 @@ impl TokenManagerService { .map_err(map_jwt_error) } + /// The platform twin of [`TokenManagerService::verify_access_ignoring_expiry`], for the + /// same single caller: logout. + /// + /// An operator who walks away for longer than the access-token lifetime and then signs out + /// is the ordinary case, and refusing them leaves the refresh session of the + /// highest-privilege identity in the system alive on a console they believed they had left. + /// + /// # Errors + /// + /// [`AuthError`] when no configured signing key accepts the token. + #[cfg(feature = "platform")] + pub fn verify_platform_access_ignoring_expiry( + &self, + token: &str, + ) -> Result { + self.verify_rotating_with::( + token, + &VerifyOptions { + validate_exp: false, + ..VerifyOptions::default() + }, + ) + .map_err(map_jwt_error) + } + /// Assemble the token manager from the signing key, the session store, and the /// resolved token lifetimes. pub(crate) fn new( diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 65f7f84..1dc57ff 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -51,6 +51,16 @@ impl InMemoryUserRepository { Self::default() } + /// Delete a user outright, so a test can drive the "the account is gone but its session + /// record outlived it" branch. Returns whether a row was removed. + /// + /// The `UserRepository` trait deliberately has no delete — account deletion is the host's + /// domain — so this exists only on the double, and only to reach a branch the engine has + /// to handle when a host does delete one. + pub fn remove(&self, id: &str) -> bool { + lock(&self.users).remove(id).is_some() + } + /// Allocate a fresh, monotonically-increasing user id. fn allocate_id(&self) -> String { format!("user-{}", self.next_id.fetch_add(1, Ordering::Relaxed)) @@ -571,6 +581,14 @@ impl SessionStore for InMemoryStores { sessions.remove(&(kind, detail.session_hash)); } } + // Every grace pointer the user holds goes too. The real store deletes them because they + // are members of the same `sess:` index the sweep walks (`invalidate_user_sessions.lua`), + // and they are keyed by the SUPERSEDED hash — which is not the hash the index carries + // after a rotation, so mirroring this by index membership alone would miss them. A + // double that keeps them is *weaker* than production: a token inside its grace window + // would still recover a session after "sign out everywhere", a password reset, or an + // MFA change, which is the exact property those flows exist to guarantee. + lock(&self.grace).retain(|(k, _), record| *k != kind || record.user_id != user_id); Ok(()) } diff --git a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs index 9999e94..0475d6f 100644 --- a/crates/bymax-auth-redis/tests/platform_identity_e2e.rs +++ b/crates/bymax-auth-redis/tests/platform_identity_e2e.rs @@ -223,7 +223,7 @@ async fn platform_login_me_refresh_logout_and_revoke_all_against_redis() { let pre_logout = redis.all_keys().await; assert!(pre_logout.iter().any(|k| k.starts_with("auth:prp:"))); assert!( - svc.logout(&auth.access_token, &auth.refresh_token, &id) + svc.logout(&auth.access_token, &auth.refresh_token) .await .is_ok() ); @@ -242,7 +242,7 @@ async fn platform_login_me_refresh_logout_and_revoke_all_against_redis() { // Logout also revokes the live (rotated) session: the rotated refresh token no longer rotates, // proving the primary refresh key was cleaned in the platform keyspace. assert!( - svc.logout(&rotated.access_token, &rotated.refresh_token, &id) + svc.logout(&rotated.access_token, &rotated.refresh_token) .await .is_ok() ); diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index eccc9bc..8c7eace 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -1735,7 +1735,7 @@ async fn engine_runs_register_login_refresh_logout_against_redis() { .refresh(&auth.refresh_token, "203.0.113.4", "agent/1.0") .await; assert!( - matches!(&refreshed, Ok(tokens) if tokens.refresh_token != auth.refresh_token), + matches!(&refreshed, Ok(session) if session.tokens.refresh_token != auth.refresh_token), "refresh should rotate to a new token" ); let Ok(rotated) = refreshed else { return }; @@ -1744,14 +1744,14 @@ async fn engine_runs_register_login_refresh_logout_against_redis() { // always Ok. assert!( engine - .logout(&rotated.access_token, &rotated.refresh_token) + .logout(&rotated.tokens.access_token, &rotated.tokens.refresh_token) .await .is_ok() ); // The revoked refresh token no longer rotates after logout. assert!(matches!( engine - .refresh(&rotated.refresh_token, "203.0.113.4", "agent/1.0") + .refresh(&rotated.tokens.refresh_token, "203.0.113.4", "agent/1.0") .await, Err(AuthError::RefreshTokenInvalid) )); From 761cc934af40e2ebc5390921f64b7e925cc39455 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:40:41 -0300 Subject: [PATCH 107/122] feat(core, axum)!: add authenticated password change and screen common passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ASVS v5 Level 1 gaps, both found independently by two capability audits. `POST /auth/password/change` is the authenticated rotation section 6.2.2 and 6.2.3 require. It was the one credential operation this library did not own, so a host either sent users through the *unauthenticated* recovery flow to rotate a password they already knew, or hand-rolled hashing against `bymax-auth-crypto` with duplicated parameters and no guarantee the sessions were revoked. The current password is what makes it safe: a session alone is not proof of identity, so a token lifted by XSS or from a shared machine could otherwise rotate the credential, lock the real owner out of an account they still know the password to, and keep the attacker in. Every other session ends on success and the epoch is bumped; the caller's own survives when the request carries its refresh token. `CommonPasswordChecker` replaces `AllowAllBreachChecker` as the default screen. NIST SP 800-63B section 3.1.1.2 says a verifier SHALL compare against a blocklist of common passwords and ASVS section 6.2.4 asks for it at Level 1; the old default approved everything, so a deployment on defaults accepted `password1` and the brute-force machinery never fired, because spraying one password across ten thousand accounts never crosses any single account's threshold. The new one is offline, which is what lets it be a default where the HIBP checker could not: it refuses common base words, keyboard walks, repeats, runs, padded fragments, and any decorated form of those — `Password1`, `P@ssw0rd` and `PASSWORD123!` reduce to one base, which is why a few hundred entries stand in for a much longer list. `EmailProvider::send_password_changed` fires after a change and after a reset. NIST section 4.6 wants the subscriber told through a channel independent of the transaction, and this was the one credential change the trait stayed silent about while announcing every MFA change unprompted. BREAKING CHANGE: the default password screen now refuses common passwords. A deployment that relied on the previous approve-everything default must accept the screen or opt back out with `AllowAllBreachChecker`. --- CHANGELOG.md | 31 ++ conformance/wire-contract.json | 1 + crates/bymax-auth-axum/src/dto.rs | 29 + crates/bymax-auth-axum/src/lib.rs | 8 +- crates/bymax-auth-axum/src/rate_limit.rs | 11 +- .../src/routes/password_reset.rs | 38 +- crates/bymax-auth-axum/src/test_support.rs | 2 +- crates/bymax-auth-axum/tests/adapter.rs | 252 ++++++--- crates/bymax-auth-axum/tests/redis_e2e.rs | 10 +- crates/bymax-auth-client/src/lib.rs | 6 +- crates/bymax-auth-core/src/engine/builder.rs | 25 +- .../src/services/auth/password_reset.rs | 256 ++++++++- .../src/services/auth/session_ops.rs | 4 +- .../src/traits/common_password.rs | 502 ++++++++++++++++++ crates/bymax-auth-core/src/traits/email.rs | 21 + crates/bymax-auth-core/src/traits/mod.rs | 2 + .../bymax-auth-core/tests/engine_assembly.rs | 4 +- 17 files changed, 1093 insertions(+), 109 deletions(-) create mode 100644 crates/bymax-auth-core/src/traits/common_password.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 876744f..cce40d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -257,6 +257,37 @@ version bump. window would still recover a session after "sign out everywhere", a password reset, or an MFA change — the exact property those flows exist to guarantee, asserted against a fake that could not break it. +- **`POST /auth/password/change` — authenticated password change.** ASVS v5 §6.2.2 and §6.2.3 + require it at **Level 1** — "users can change their password", and the change "requires the + user's current and new password" — and it was the one credential operation this library did + not own. Without it a host either sends users through the *unauthenticated* recovery flow to + rotate a password they already know, or hand-rolls hashing against `bymax-auth-crypto` with + duplicated parameters and no guarantee the sessions are revoked afterwards. The current + password is what makes it safe: a session alone is not proof of identity, so a token lifted + by XSS or from a shared machine could otherwise rotate the credential, lock the real owner + out of an account they still know the password to, and keep the attacker in. Every other + session ends on success and the epoch is bumped (§7.4.3); the caller's own survives when the + request carries its refresh token. `nest-auth` takes the same change. +- **`CommonPasswordChecker` is the default password screen.** NIST SP 800-63B §3.1.1.2 states a + verifier **SHALL** compare a prospective secret against a blocklist of commonly used values, + and ASVS v5 §6.2.4 asks for it at **Level 1**. The previous default, `AllowAllBreachChecker`, + approved everything: a deployment on defaults accepted `password1` and `12345678`, and the + brute-force machinery never fired, because a spraying campaign that tries one password across + ten thousand accounts never crosses any single account's threshold. The new default is + offline, which is what lets it be a default where the HIBP checker could not — it refuses + common base words, keyboard walks, repeats, sequential runs, fragments padded out with + decoration, and any *decorated* form of those: `Password1`, `P@ssw0rd` and `PASSWORD123!` + reduce to one base, which is why a few hundred entries stand in for a much longer list. It is + a floor, not a corpus: `CommonPasswordChecker::with_extra_words` adds the context-specific + words §6.2.11 asks for, and the HIBP checker remains the opt-in upgrade to a real breach + corpus. `AllowAllBreachChecker` stays available for a deployment with a deliberate reason to + screen nothing. **Breaking:** a deployment that relied on the approve-everything default must + accept the screen or opt back out explicitly. +- **`EmailProvider::send_password_changed`** — fired after an authenticated change and after a + completed reset. NIST SP 800-63B §4.6 requires the subscriber to be notified through a channel + independent of the transaction that bound the new credential, and this was the one credential + change the trait stayed silent about while announcing every MFA change unprompted. Defaulted + to a no-op so an existing provider keeps compiling. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 7e22963..588e856 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -183,6 +183,7 @@ "wsTicket": "20/60", "forgotPassword": "3/300", "resetPassword": "3/300", + "changePassword": "5/60", "verifyOtp": "3/300", "resendPasswordOtp": "3/300", "verifyEmail": "5/60", diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index 0bafd2d..52dcbda 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -54,6 +54,35 @@ pub struct ForgotPasswordDto { pub tenant_id: String, } +/// `POST /auth/password/change` body — the **authenticated** rotation. +/// +/// Distinct from [`ResetPasswordDto`], which serves the unauthenticated recovery flow and +/// proves identity with an emailed token or OTP. Here the proof is the current password, which +/// is the one thing a stolen session does not carry. +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChangePasswordDto { + /// The account's current password, re-proving who is asking (ASVS v5 §6.2.3). + /// + /// The floor is 1, not the policy length: rejecting the empty string keeps a caller from + /// spending a KDF derivation for free, while enforcing the deployment's real policy here + /// would leak it as a pre-KDF signal — and this is a *current* password, which may predate + /// whatever the policy says today. + #[garde(length(min = 1, max = 128))] + pub current_password: String, + /// The new password (8–128 chars). + #[garde(length(min = 8, max = 128))] + pub new_password: String, + /// The caller's refresh token, when it has one to send. + /// + /// Optional, and only used to spare the caller's own session from the sweep: with it, the + /// device that made the change stays signed in; without it, every session goes, this one + /// included. A change that leaves an unidentified session alive is the failure the control + /// exists to prevent, so the safe branch is the one that takes them all. + #[garde(skip)] + pub refresh_token: Option, +} + /// `POST /auth/password/reset-password` body. Exactly one of `token` / `otp` / /// `verified_token` carries the reset proof (validated by the engine, not garde). #[derive(Debug, Deserialize, Validate)] diff --git a/crates/bymax-auth-axum/src/lib.rs b/crates/bymax-auth-axum/src/lib.rs index d0443dd..8203561 100644 --- a/crates/bymax-auth-axum/src/lib.rs +++ b/crates/bymax-auth-axum/src/lib.rs @@ -40,10 +40,10 @@ mod ws; mod test_support; pub use dto::{ - AcceptInvitationDto, CreateInvitationDto, ForgotPasswordDto, LoginDto, MfaChallengeDto, - MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaVerifyDto, OAuthCallbackQuery, - OAuthInitiateQuery, PlatformLoginDto, RefreshDto, RegisterDto, ResendOtpDto, - ResendVerificationDto, ResetPasswordDto, VerifyEmailDto, VerifyOtpDto, + AcceptInvitationDto, ChangePasswordDto, CreateInvitationDto, ForgotPasswordDto, LoginDto, + MfaChallengeDto, MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaVerifyDto, + OAuthCallbackQuery, OAuthInitiateQuery, PlatformLoginDto, RefreshDto, RegisterDto, + ResendOtpDto, ResendVerificationDto, ResetPasswordDto, VerifyEmailDto, VerifyOtpDto, }; pub use extractors::{ AdminRole, AuthUser, CurrentUser, MfaSatisfied, OptionalAuthUser, RequireRole, Role, diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index c63fa96..6abb09a 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -155,6 +155,13 @@ pub struct RateLimitConfig { pub oauth_initiate: Option, /// `GET /auth/oauth/{provider}/callback` — 10 / 60s. pub oauth_callback: Option, + /// `POST /auth/password/change` — 5 / 60s. + /// + /// Authenticated, so the caller is already known — but each call spends a KDF verification + /// of the current password plus a derivation of the new one, the most expensive pair of + /// operations in the library. The ceiling matches `login`'s for the same reason: it is a + /// password-guessing surface, just one that needs a live session first. + pub change_password: Option, /// `POST /auth/logout` — 20 / 60s. /// /// The route is public: it has to be, or a user whose access token expired could not sign @@ -196,6 +203,7 @@ impl Default for RateLimitConfig { revoke_all_sessions: Some(RateLimit::new(5, 60)), oauth_initiate: Some(RateLimit::new(10, 60)), oauth_callback: Some(RateLimit::new(10, 60)), + change_password: Some(RateLimit::new(5, 60)), logout: Some(RateLimit::new(20, 60)), ws_ticket: Some(RateLimit::new(20, 60)), } @@ -284,7 +292,7 @@ mod tests { fn every_default_limit_matches_the_shared_wire_contract() { let contract = contract_limits(); let defaults = RateLimitConfig::default(); - let pairs: [(&str, Option); 23] = [ + let pairs: [(&str, Option); 24] = [ ("login", defaults.login), ("register", defaults.register), ("refresh", defaults.refresh), @@ -306,6 +314,7 @@ mod tests { ("revokeAllSessions", defaults.revoke_all_sessions), ("oauthInitiate", defaults.oauth_initiate), ("oauthCallback", defaults.oauth_callback), + ("changePassword", defaults.change_password), ("logout", defaults.logout), ("wsTicket", defaults.ws_ticket), ]; diff --git a/crates/bymax-auth-axum/src/routes/password_reset.rs b/crates/bymax-auth-axum/src/routes/password_reset.rs index a9d2141..d552fc5 100644 --- a/crates/bymax-auth-axum/src/routes/password_reset.rs +++ b/crates/bymax-auth-axum/src/routes/password_reset.rs @@ -14,7 +14,10 @@ use http::StatusCode; use serde_json::json; use super::RequestMeta; -use crate::dto::{ForgotPasswordDto, ResendOtpDto, ResetPasswordDto, VerifyOtpDto}; +use crate::dto::{ + ChangePasswordDto, ForgotPasswordDto, ResendOtpDto, ResetPasswordDto, VerifyOtpDto, +}; +use crate::extractors::{AuthUser, UserStatus}; use crate::response::error_response; use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; use crate::validation::ValidatedJson; @@ -41,6 +44,10 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout .route( "/resend-otp", crate::router::throttled(post(resend_otp), limits.resend_password_otp, ip_source), + ) + .route( + "/change", + crate::router::throttled(post(change_password), limits.change_password, ip_source), ), ) } @@ -62,6 +69,35 @@ async fn forgot_password( (StatusCode::OK, Json(json!({}))).into_response() } +/// `POST /auth/password/change` (204). **Authenticated** — the only route in this group that +/// is, which is the point: the four beside it answer to anyone who can read the account's +/// mailbox, and this one answers only to someone who holds a live session *and* knows the +/// password. +/// +/// ASVS v5 §6.2.2 and §6.2.3 require it at Level 1. Without it a user who wants to rotate a +/// password they already know has to go through the anonymous recovery flow, and an attacker +/// holding a stolen session cannot be raced out of the account by the owner changing it. +async fn change_password( + State(state): State, + _status: UserStatus, + user: AuthUser, + ValidatedJson(dto): ValidatedJson, +) -> Response { + match state + .engine() + .change_password( + &user.0.sub, + &dto.current_password, + &dto.new_password, + dto.refresh_token.as_deref(), + ) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error_response(&error), + } +} + /// `POST /auth/password/reset-password` (204). Public. async fn reset_password( State(state): State, diff --git a/crates/bymax-auth-axum/src/test_support.rs b/crates/bymax-auth-axum/src/test_support.rs index 7605717..571e027 100644 --- a/crates/bymax-auth-axum/src/test_support.rs +++ b/crates/bymax-auth-axum/src/test_support.rs @@ -118,7 +118,7 @@ fn mfa_key() -> String { /// Seed an active dashboard user with the given role; returns its id. pub(crate) async fn seed(users: &InMemoryUserRepository, email: &str, role: &str) -> String { let params = bymax_auth_crypto::password::PasswordParams::default(); - let hash = bymax_auth_crypto::password::hash(b"password123", ¶ms).unwrap_or_default(); + let hash = bymax_auth_crypto::password::hash(b"glidingwalnut42", ¶ms).unwrap_or_default(); let created = users .create(CreateUserData { email: email.to_owned(), diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 382722f..073cacf 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -107,7 +107,7 @@ async fn the_set_cookie_header_is_marked_sensitive_so_tracing_cannot_print_the_t let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "sensitive@e.com", "password": "password123", "name": "Sensi", "tenantId": TENANT + "email": "sensitive@e.com", "password": "glidingwalnut42", "name": "Sensi", "tenantId": TENANT })) .send(&app) .await; @@ -120,6 +120,98 @@ async fn the_set_cookie_header_is_marked_sensitive_so_tracing_cannot_print_the_t } } +#[tokio::test] +async fn password_change_requires_a_session_and_the_current_password() { + // The four recovery routes beside it answer to anyone who can read the account's mailbox; + // this one answers only to someone who holds a live session AND knows the password. ASVS v5 + // §6.2.2 and §6.2.3 require it at Level 1, and it was the one credential operation this + // library did not own. + let Some(h) = build(EngineSpec { + delivery: bymax_auth_core::config::TokenDelivery::Bearer, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + + let reg = Req::post("/auth/register") + .json(serde_json::json!({ + "email": "changer@e.com", "password": "oldsecret77", "name": "Cha", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(reg.status, StatusCode::CREATED); + let access = reg.json()["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + + // Unauthenticated: refused before anything is read. + let anonymous = Req::post("/auth/password/change") + .json(serde_json::json!({ + "currentPassword": "oldsecret77", "newPassword": "glidingwalnut42" + })) + .send(&app) + .await; + assert_eq!(anonymous.status, StatusCode::UNAUTHORIZED); + + // Authenticated but unable to produce the current password — the stolen-session case. + let wrong = Req::post("/auth/password/change") + .bearer(&access) + .json(serde_json::json!({ + "currentPassword": "not-the-password", "newPassword": "glidingwalnut42" + })) + .send(&app) + .await; + assert_eq!(wrong.status, StatusCode::UNAUTHORIZED); + assert_eq!(wrong.json()["error"]["code"], "auth.invalid_credentials"); + + // With both, it rotates. + let changed = Req::post("/auth/password/change") + .bearer(&access) + .json(serde_json::json!({ + "currentPassword": "oldsecret77", "newPassword": "glidingwalnut42" + })) + .send(&app) + .await; + assert_eq!(changed.status, StatusCode::NO_CONTENT); + + // The new password is what logs in afterwards. + let login = Req::post("/auth/login") + .json(serde_json::json!({ + "email": "changer@e.com", "password": "glidingwalnut42", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(login.status, StatusCode::OK); +} + +#[tokio::test] +async fn register_refuses_a_common_password_by_default() { + // NIST SP 800-63B §3.1.1.2 says a verifier SHALL screen against a blocklist of common + // passwords, and ASVS v5 §6.2.4 asks for it at Level 1. The previous default approved + // everything, so a deployment on defaults accepted this — and the brute-force machinery + // never fired, because spraying one password across ten thousand accounts never crosses any + // single account's threshold. + let Some(h) = build(EngineSpec::default()) else { return }; + let app = router(&h); + + for weak in ["Password1", "12345678", "qwerty123", "iloveyou"] { + let res = Req::post("/auth/register") + .json(serde_json::json!({ + "email": format!("{weak}@e.com"), "password": weak, "name": "Weak", + "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!( + res.json()["error"]["code"], + "auth.password_compromised", + "for {weak}" + ); + } +} + #[tokio::test] async fn revoke_all_sessions_works_in_bearer_mode_and_never_reports_a_silent_success() { // A bearer deployment plants no cookies at all, and this route read the caller's current @@ -139,7 +231,7 @@ async fn revoke_all_sessions_works_in_bearer_mode_and_never_reports_a_silent_suc let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "revoker@e.com", "password": "password123", "name": "Reva", "tenantId": TENANT + "email": "revoker@e.com", "password": "glidingwalnut42", "name": "Reva", "tenantId": TENANT })) .send(&app) .await; @@ -179,7 +271,7 @@ async fn session_cookies_are_host_only_unless_a_domain_resolver_is_configured() let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "hostonly@e.com", "password": "password123", "name": "Hana", "tenantId": TENANT + "email": "hostonly@e.com", "password": "glidingwalnut42", "name": "Hana", "tenantId": TENANT })) .send(&app) .await; @@ -213,7 +305,7 @@ async fn a_configured_domain_resolver_stamps_every_session_cookie_and_the_logout let reg = Req::post("/auth/register") .header(header::HOST, "app.example.com:8443") .json(serde_json::json!({ - "email": "shared@e.com", "password": "password123", "name": "Sara", "tenantId": TENANT + "email": "shared@e.com", "password": "glidingwalnut42", "name": "Sara", "tenantId": TENANT })) .send(&app) .await; @@ -320,7 +412,7 @@ async fn register_login_me_logout_cookie_mode() { let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "a@e.com", "password": "password123", "name": "Ada", "tenantId": TENANT + "email": "a@e.com", "password": "glidingwalnut42", "name": "Ada", "tenantId": TENANT })) .send(&app) .await; @@ -404,9 +496,9 @@ async fn login_bearer_mode_returns_tokens_in_body_and_no_cookies() { }; let Some(h) = build(spec) else { return }; let app = router(&h); - seed_user(&h, "b@e.com", "password123", "USER").await; + seed_user(&h, "b@e.com", "glidingwalnut42", "USER").await; - let resp = login(&app, "b@e.com", "password123").await; + let resp = login(&app, "b@e.com", "glidingwalnut42").await; assert_eq!(resp.status, StatusCode::OK); assert!(resp.set_cookies.is_empty()); let token = resp.json()["accessToken"].as_str().unwrap_or("").to_owned(); @@ -434,9 +526,9 @@ async fn login_both_mode_sets_cookies_and_body_tokens() { }; let Some(h) = build(spec) else { return }; let app = router(&h); - seed_user(&h, "c@e.com", "password123", "USER").await; + seed_user(&h, "c@e.com", "glidingwalnut42", "USER").await; - let resp = login(&app, "c@e.com", "password123").await; + let resp = login(&app, "c@e.com", "glidingwalnut42").await; assert_eq!(resp.status, StatusCode::OK); assert!(resp.has_cookie_value("access_token")); let token = resp.json()["accessToken"].as_str().unwrap_or("").to_owned(); @@ -461,7 +553,7 @@ async fn refresh_rotates_in_cookie_and_bearer_modes() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "r@e.com", "password": "password123", "name": "Ray", "tenantId": TENANT + "email": "r@e.com", "password": "glidingwalnut42", "name": "Ray", "tenantId": TENANT })) .send(&app) .await; @@ -486,8 +578,8 @@ async fn refresh_rotates_in_cookie_and_bearer_modes() { }; let Some(hb) = build(spec) else { return }; let appb = router(&hb); - seed_user(&hb, "rb@e.com", "password123", "USER").await; - let login = login(&appb, "rb@e.com", "password123").await; + seed_user(&hb, "rb@e.com", "glidingwalnut42", "USER").await; + let login = login(&appb, "rb@e.com", "glidingwalnut42").await; let refresh_token = login.json()["refreshToken"] .as_str() .unwrap_or("") @@ -528,8 +620,8 @@ async fn bearer_mode_logout_revokes_the_refresh_session_from_the_body() { }; let Some(h) = build(spec) else { return }; let app = router(&h); - seed_user(&h, "blo@e.com", "password123", "USER").await; - let session = login(&app, "blo@e.com", "password123").await; + seed_user(&h, "blo@e.com", "glidingwalnut42", "USER").await; + let session = login(&app, "blo@e.com", "glidingwalnut42").await; let access = session.json()["accessToken"] .as_str() .unwrap_or("") @@ -566,9 +658,9 @@ async fn logout_without_any_refresh_token_still_succeeds() { }; let Some(h) = build(spec) else { return }; let app = router(&h); - seed_user(&h, "nolo@e.com", "password123", "USER").await; + seed_user(&h, "nolo@e.com", "glidingwalnut42", "USER").await; - let first = login(&app, "nolo@e.com", "password123").await; + let first = login(&app, "nolo@e.com", "glidingwalnut42").await; let access = first.json()["accessToken"] .as_str() .unwrap_or("") @@ -579,7 +671,7 @@ async fn logout_without_any_refresh_token_still_succeeds() { let after = Req::get("/auth/me").bearer(&access).send(&app).await; assert_eq!(after.status, StatusCode::UNAUTHORIZED); - let second = login(&app, "nolo@e.com", "password123").await; + let second = login(&app, "nolo@e.com", "glidingwalnut42").await; let access2 = second.json()["accessToken"] .as_str() .unwrap_or("") @@ -629,13 +721,13 @@ async fn invalid_credentials_and_unknown_email_are_indistinguishable() { // 401 invalid-credentials. let Some(h) = build(EngineSpec::default()) else { return }; let app = router(&h); - seed_user(&h, "known@e.com", "password123", "USER").await; + seed_user(&h, "known@e.com", "glidingwalnut42", "USER").await; let wrong = login(&app, "known@e.com", "wrongpass").await; assert_eq!(wrong.status, StatusCode::UNAUTHORIZED); assert_eq!(wrong.json()["error"]["code"], "auth.invalid_credentials"); - let unknown = login(&app, "nobody@e.com", "password123").await; + let unknown = login(&app, "nobody@e.com", "glidingwalnut42").await; assert_eq!(unknown.status, StatusCode::UNAUTHORIZED); assert_eq!(unknown.json()["error"]["code"], "auth.invalid_credentials"); } @@ -663,7 +755,7 @@ async fn validation_rejects_unknown_fields_and_bad_fields() { // A bad email fails the `garde(email)` rule. let bad_email = Req::post("/auth/register") .json(serde_json::json!({ - "email": "not-an-email", "password": "password123", "name": "X", "tenantId": TENANT + "email": "not-an-email", "password": "glidingwalnut42", "name": "X", "tenantId": TENANT })) .send(&app) .await; @@ -688,7 +780,7 @@ async fn validation_rejects_unknown_fields_and_bad_fields() { async fn auth_user_rejects_missing_invalid_and_revoked_tokens() { let Some(h) = build(EngineSpec::default()) else { return }; let app = router(&h); - seed_user(&h, "tok@e.com", "password123", "USER").await; + seed_user(&h, "tok@e.com", "glidingwalnut42", "USER").await; // Missing → token_invalid. let missing = Req::get("/auth/me").send(&app).await; @@ -704,8 +796,8 @@ async fn auth_user_rejects_missing_invalid_and_revoked_tokens() { assert_eq!(malformed.json()["error"]["code"], "auth.token_invalid"); // Revoked (after logout) → still token_invalid (no expired/revoked oracle). - let access = login_access_cookie(&app, "tok@e.com", "password123").await; - let login_resp = login(&app, "tok@e.com", "password123").await; + let access = login_access_cookie(&app, "tok@e.com", "glidingwalnut42").await; + let login_resp = login(&app, "tok@e.com", "glidingwalnut42").await; let refresh_value = login_resp.cookie_value("refresh_token").unwrap_or_default(); let access2 = login_resp.cookie_value("access_token").unwrap_or_default(); let _ = Req::post("/auth/logout") @@ -821,7 +913,7 @@ async fn sessions_list_revoke_one_and_revoke_all() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "s@e.com", "password": "password123", "name": "Sam", "tenantId": TENANT + "email": "s@e.com", "password": "glidingwalnut42", "name": "Sam", "tenantId": TENANT })) .send(&app) .await; @@ -892,7 +984,7 @@ async fn sessions_list_revoke_one_and_revoke_all() { // A second device, so there is a real session to revoke by id. let second = Req::post("/auth/login") .json(serde_json::json!({ - "email": "s@e.com", "password": "password123", "tenantId": TENANT + "email": "s@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -950,7 +1042,7 @@ async fn mfa_setup_verify_enable_and_challenge_error_arms() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "m@e.com", "password": "password123", "name": "Mo", "tenantId": TENANT + "email": "m@e.com", "password": "glidingwalnut42", "name": "Mo", "tenantId": TENANT })) .send(&app) .await; @@ -961,7 +1053,7 @@ async fn mfa_setup_verify_enable_and_challenge_error_arms() { // Nest's `POST` default. let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; assert_eq!(setup.status, StatusCode::CREATED); @@ -1011,11 +1103,11 @@ async fn login_with_mfa_returns_a_challenge_body() { return; }; let app = router(&h); - let id = seed_user(&h, "mfauser@e.com", "password123", "USER").await; + let id = seed_user(&h, "mfauser@e.com", "glidingwalnut42", "USER").await; enable_mfa_flag(&h, &id).await; let login = Req::post("/auth/login") .json(serde_json::json!({ - "email": "mfauser@e.com", "password": "password123", "tenantId": TENANT + "email": "mfauser@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -1065,11 +1157,11 @@ async fn platform_login_me_logout_and_dashboard_token_is_rejected() { assert!(me.json().get("user").is_none()); // A dashboard token on a platform route is `platform_auth_required`. - let dash = seed_user(&h, "tenant@e.com", "password123", "USER").await; + let dash = seed_user(&h, "tenant@e.com", "glidingwalnut42", "USER").await; let _ = dash; let dash_login = Req::post("/auth/login") .json(serde_json::json!({ - "email": "tenant@e.com", "password": "password123", "tenantId": TENANT + "email": "tenant@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -1147,11 +1239,11 @@ async fn invitation_create_and_accept() { }; let app = router(&h); // An authenticated ADMIN can create an invitation (204); tenant comes from the claims. - let admin_id = seed_user(&h, "inviter@e.com", "password123", "ADMIN").await; + let admin_id = seed_user(&h, "inviter@e.com", "glidingwalnut42", "ADMIN").await; let _ = admin_id; let login = Req::post("/auth/login") .json(serde_json::json!({ - "email": "inviter@e.com", "password": "password123", "tenantId": TENANT + "email": "inviter@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -1173,7 +1265,7 @@ async fn invitation_create_and_accept() { // Accepting a bogus token is an invalid-invitation-token 400. let accept = Req::post("/auth/invitations/accept") .json( - serde_json::json!({ "token": "bogus", "name": "New User", "password": "password123" }), + serde_json::json!({ "token": "bogus", "name": "New User", "password": "glidingwalnut42" }), ) .send(&app) .await; @@ -1269,7 +1361,7 @@ async fn ws_ticket_mint_and_single_use_redeem() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "ws@e.com", "password": "password123", "name": "Wes", "tenantId": TENANT + "email": "ws@e.com", "password": "glidingwalnut42", "name": "Wes", "tenantId": TENANT })) .send(&app) .await; @@ -1306,7 +1398,7 @@ async fn exceeding_the_login_limit_returns_a_429_envelope_with_retry_after() { // The default login limit is 5/60s; the 6th rapid attempt from the same IP is throttled. let Some(h) = build(EngineSpec::default()) else { return }; let app = router(&h); - seed_user(&h, "rl@e.com", "password123", "USER").await; + seed_user(&h, "rl@e.com", "glidingwalnut42", "USER").await; let mut throttled = None; for _ in 0..12 { @@ -1332,7 +1424,7 @@ async fn exceeding_the_login_limit_returns_a_429_envelope_with_retry_after() { // A different route's limit is independent (register is not throttled by login attempts). let register = Req::new(Method::POST, "/auth/register") .json(serde_json::json!({ - "email": "fresh@e.com", "password": "password123", "name": "Fresh", "tenantId": TENANT + "email": "fresh@e.com", "password": "glidingwalnut42", "name": "Fresh", "tenantId": TENANT })) .send(&app) .await; @@ -1345,7 +1437,7 @@ async fn a_custom_rate_limit_override_changes_the_threshold() { // Override the login limit to 1/60s and a per-route disable for register, proving the // config knobs flow through. let Some(h) = build(EngineSpec::default()) else { return }; - seed_user(&h, "ov@e.com", "password123", "USER").await; + seed_user(&h, "ov@e.com", "glidingwalnut42", "USER").await; let limits = RateLimitConfig { login: Some(RateLimit::new(1, 60)), register: None, @@ -1578,14 +1670,14 @@ async fn mfa_dashboard_challenge_success_issues_a_session() { // Enrol MFA for a fresh user via setup + verify-enable. let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "ch@e.com", "password": "password123", "name": "Cho", "tenantId": TENANT + "email": "ch@e.com", "password": "glidingwalnut42", "name": "Cho", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1604,7 +1696,7 @@ async fn mfa_dashboard_challenge_success_issues_a_session() { // A fresh login now returns an MFA challenge with a temp token. let login = Req::post("/auth/login") .json(serde_json::json!({ - "email": "ch@e.com", "password": "password123", "tenantId": TENANT + "email": "ch@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -1640,15 +1732,15 @@ async fn mfa_challenge_falls_back_to_the_oauth_temp_cookie_and_clears_it() { }; // The mock provider resolves to this account; enabling MFA makes the callback yield a // challenge rather than a session. - let id = seed_user(&h, "mock@example.com", "password123", "USER").await; + let id = seed_user(&h, "mock@example.com", "glidingwalnut42", "USER").await; let app = router(&h); // Enrol MFA properly so a recovery code exists for the challenge. - let login0 = login(&app, "mock@example.com", "password123").await; + let login0 = login(&app, "mock@example.com", "glidingwalnut42").await; let access = login0.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1741,14 +1833,14 @@ async fn mfa_challenge_temp_token_sourcing_precedence_and_clearing_policy() { // recoverable, so the cookie must survive for the retry. let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "keep@e.com", "password": "password123", "name": "Kee", "tenantId": TENANT + "email": "keep@e.com", "password": "glidingwalnut42", "name": "Kee", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1757,7 +1849,7 @@ async fn mfa_challenge_temp_token_sourcing_precedence_and_clearing_policy() { .json(serde_json::json!({ "code": current_totp(&secret) })) .send(&app) .await; - let mfa_login = login(&app, "keep@e.com", "password123").await; + let mfa_login = login(&app, "keep@e.com", "glidingwalnut42").await; let temp = mfa_login.json()["mfaTempToken"] .as_str() .unwrap_or("") @@ -1792,14 +1884,14 @@ async fn mfa_challenge_body_token_wins_over_a_stale_cookie() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "pref@e.com", "password": "password123", "name": "Pre", "tenantId": TENANT + "email": "pref@e.com", "password": "glidingwalnut42", "name": "Pre", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -1813,7 +1905,7 @@ async fn mfa_challenge_body_token_wins_over_a_stale_cookie() { .send(&app) .await; - let mfa_login = login(&app, "pref@e.com", "password123").await; + let mfa_login = login(&app, "pref@e.com", "glidingwalnut42").await; let temp = mfa_login.json()["mfaTempToken"] .as_str() .unwrap_or("") @@ -1975,10 +2067,10 @@ async fn register_duplicate_email_hits_the_error_arm() { // A duplicate registration triggers the engine error arm of `register` (409). let Some(h) = build(EngineSpec::default()) else { return }; let app = router(&h); - seed_user(&h, "dup@e.com", "password123", "USER").await; + seed_user(&h, "dup@e.com", "glidingwalnut42", "USER").await; let resp = Req::post("/auth/register") .json(serde_json::json!({ - "email": "dup@e.com", "password": "password123", "name": "Dup", "tenantId": TENANT + "email": "dup@e.com", "password": "glidingwalnut42", "name": "Dup", "tenantId": TENANT })) .send(&app) .await; @@ -1998,7 +2090,7 @@ async fn session_revoke_with_a_malformed_hash_hits_the_error_arm() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "rv@e.com", "password": "password123", "name": "Rv", "tenantId": TENANT + "email": "rv@e.com", "password": "glidingwalnut42", "name": "Rv", "tenantId": TENANT })) .send(&app) .await; @@ -2021,10 +2113,10 @@ async fn invitation_create_with_an_unknown_role_hits_the_error_arm() { return; }; let app = router(&h); - seed_user(&h, "inv2@e.com", "password123", "ADMIN").await; + seed_user(&h, "inv2@e.com", "glidingwalnut42", "ADMIN").await; let login = Req::post("/auth/login") .json(serde_json::json!({ - "email": "inv2@e.com", "password": "password123", "tenantId": TENANT + "email": "inv2@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -2045,7 +2137,7 @@ async fn password_reset_otp_two_step_success_flow() { use bymax_auth_core::traits::OtpPurpose; let Some(h) = build(EngineSpec::default()) else { return }; let app = router(&h); - seed_user(&h, "pw@e.com", "password123", "USER").await; + seed_user(&h, "pw@e.com", "glidingwalnut42", "USER").await; // Trigger the reset so an OTP record exists. let _ = Req::post("/auth/password/forgot-password") @@ -2115,7 +2207,7 @@ async fn invitation_accept_success_creates_a_session() { return; }; let app = router(&h); - let inviter = seed_user(&h, "host@e.com", "password123", "ADMIN").await; + let inviter = seed_user(&h, "host@e.com", "glidingwalnut42", "ADMIN").await; let invitation = StoredInvitation { email: "joiner@e.com".to_owned(), role: "USER".to_owned(), @@ -2130,7 +2222,7 @@ async fn invitation_accept_success_creates_a_session() { let accept = Req::post("/auth/invitations/accept") .json(serde_json::json!({ - "token": "invite-token-xyz", "name": "New Joiner", "password": "password123" + "token": "invite-token-xyz", "name": "New Joiner", "password": "glidingwalnut42" })) .send(&app) .await; @@ -2198,7 +2290,7 @@ async fn oauth_callback_with_mfa_user_takes_the_mfa_redirect_branch() { // configured the callback 302-redirects and plants the mfa_temp cookie (the MFA branch). let Some(h) = build_oauth_with_redirects() else { return }; // Pre-create the OAuth user with MFA enabled so the callback resolves to a challenge. - let id = seed_user(&h, "mock@example.com", "password123", "USER").await; + let id = seed_user(&h, "mock@example.com", "glidingwalnut42", "USER").await; enable_mfa_flag(&h, &id).await; // Link the OAuth identity so the callback finds this user (provider id from the mock). use bymax_auth_core::traits::UserRepository; @@ -2338,14 +2430,14 @@ async fn mfa_setup_error_arm_when_already_enabled() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "me2@e.com", "password": "password123", "name": "M", "tenantId": TENANT + "email": "me2@e.com", "password": "glidingwalnut42", "name": "M", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -2358,7 +2450,7 @@ async fn mfa_setup_error_arm_when_already_enabled() { // re-enrolment policy, so assert only that it is no longer the 201 success. let again = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; assert_ne!(again.status, StatusCode::CREATED); @@ -2376,14 +2468,14 @@ async fn mfa_verify_enable_error_arm_with_a_wrong_code() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "ve@e.com", "password": "password123", "name": "V", "tenantId": TENANT + "email": "ve@e.com", "password": "glidingwalnut42", "name": "V", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let _ = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let resp = Req::post("/auth/mfa/verify-enable") @@ -2424,7 +2516,7 @@ async fn oauth_callback_mfa_branch_without_redirect_returns_json() { }) else { return; }; - let id = seed_user(&h, "mock@example.com", "password123", "USER").await; + let id = seed_user(&h, "mock@example.com", "glidingwalnut42", "USER").await; enable_mfa_flag(&h, &id).await; use bymax_auth_core::traits::UserRepository; let _ = h.users.link_oauth(&id, "google", "mock-123").await; @@ -2471,7 +2563,7 @@ async fn verify_email_success_with_a_live_otp() { // Register so the user exists and a verification OTP is dispatched. let _ = Req::post("/auth/register") .json(serde_json::json!({ - "email": "vfy@e.com", "password": "password123", "name": "Vfy", "tenantId": TENANT + "email": "vfy@e.com", "password": "glidingwalnut42", "name": "Vfy", "tenantId": TENANT })) .send(&app) .await; @@ -2497,14 +2589,14 @@ async fn dashboard_mfa_disable_and_recovery_success() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "dr@e.com", "password": "password123", "name": "Dr", "tenantId": TENANT + "email": "dr@e.com", "password": "glidingwalnut42", "name": "Dr", "tenantId": TENANT })) .send(&app) .await; let access = reg.cookie_value("access_token").unwrap_or_default(); let setup = Req::post("/auth/mfa/setup") .cookie("access_token", &access) - .json(serde_json::json!({ "password": "password123" })) + .json(serde_json::json!({ "password": "glidingwalnut42" })) .send(&app) .await; let secret = setup.json()["secret"].as_str().unwrap_or("").to_owned(); @@ -2529,7 +2621,7 @@ async fn dashboard_mfa_disable_and_recovery_success() { let relogin = Req::post("/auth/login") .json(serde_json::json!({ - "email": "dr@e.com", "password": "password123", "tenantId": TENANT + "email": "dr@e.com", "password": "glidingwalnut42", "tenantId": TENANT })) .send(&app) .await; @@ -2711,7 +2803,7 @@ async fn sessions_list_and_revoke_all_store_failure_arms() { // Register against the delegating store (writes succeed) to obtain a valid session. let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "sf@e.com", "password": "password123", "name": "Sf", "tenantId": TENANT + "email": "sf@e.com", "password": "glidingwalnut42", "name": "Sf", "tenantId": TENANT })) .send(&app) .await; @@ -2743,7 +2835,7 @@ async fn ws_ticket_mint_store_failure_arm() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "wsf@e.com", "password": "password123", "name": "Wf", "tenantId": TENANT + "email": "wsf@e.com", "password": "glidingwalnut42", "name": "Wf", "tenantId": TENANT })) .send(&app) .await; @@ -2786,7 +2878,7 @@ async fn refresh_surfaces_a_failure_to_re_read_the_account_after_rotation() { let app = router(&h); let reg = Req::post("/auth/register") .json(serde_json::json!({ - "email": "rr@e.com", "password": "password123", "name": "Rr", "tenantId": TENANT + "email": "rr@e.com", "password": "glidingwalnut42", "name": "Rr", "tenantId": TENANT })) .send(&app) .await; @@ -2871,8 +2963,8 @@ async fn a_cookie_authenticated_write_from_an_untrusted_origin_is_refused() { return; }; let app = router(&h); - seed_user(&h, "csrf@e.com", "password123", "USER").await; - let access = login_access_cookie(&app, "csrf@e.com", "password123").await; + seed_user(&h, "csrf@e.com", "glidingwalnut42", "USER").await; + let access = login_access_cookie(&app, "csrf@e.com", "glidingwalnut42").await; let refused = Req::post("/auth/logout") .cookie("access_token", &access) @@ -2906,10 +2998,10 @@ async fn the_cross_site_check_admits_what_it_should() { return; }; let app = router(&h); - seed_user(&h, "origin@e.com", "password123", "USER").await; + seed_user(&h, "origin@e.com", "glidingwalnut42", "USER").await; // A listed origin is what the allow-list is for. - let access = login_access_cookie(&app, "origin@e.com", "password123").await; + let access = login_access_cookie(&app, "origin@e.com", "glidingwalnut42").await; let allowed = Req::post("/auth/logout") .cookie("access_token", &access) .header(header::ORIGIN, "https://app.example.com") @@ -2919,7 +3011,7 @@ async fn the_cross_site_check_admits_what_it_should() { // The app calling itself never consults the list — which is what keeps a same-origin // deployment working with nothing configured. - let access = login_access_cookie(&app, "origin@e.com", "password123").await; + let access = login_access_cookie(&app, "origin@e.com", "glidingwalnut42").await; let same_origin = Req::post("/auth/logout") .cookie("access_token", &access) .header(header::ORIGIN, "https://evil.example.com") @@ -2951,7 +3043,7 @@ async fn the_cross_site_check_admits_what_it_should() { let read = Req::get("/auth/me") .cookie( "access_token", - &login_access_cookie(&app, "origin@e.com", "password123").await, + &login_access_cookie(&app, "origin@e.com", "glidingwalnut42").await, ) .header(header::ORIGIN, "https://evil.example.com") .send(&app) @@ -2971,8 +3063,8 @@ async fn a_cross_site_fetch_with_no_origin_header_is_refused() { return; }; let app = router(&h); - seed_user(&h, "nohdr@e.com", "password123", "USER").await; - let access = login_access_cookie(&app, "nohdr@e.com", "password123").await; + seed_user(&h, "nohdr@e.com", "glidingwalnut42", "USER").await; + let access = login_access_cookie(&app, "nohdr@e.com", "glidingwalnut42").await; let refused = Req::post("/auth/logout") .cookie("access_token", &access) diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 8231768..5f39e9b 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -160,7 +160,7 @@ async fn seed_user(users: &InMemoryUserRepository, email: &str, role: &str) -> S .create(CreateUserData { email: email.to_owned(), name: "User".to_owned(), - password_hash: Some(hash_password("password123")), + password_hash: Some(hash_password("glidingwalnut42")), role: Some(role.to_owned()), status: Some("ACTIVE".to_owned()), tenant_id: TENANT.to_owned(), @@ -336,7 +336,7 @@ async fn full_router_against_real_redis() { Method::POST, "/auth/register", Some(serde_json::json!({ - "email": "r@e.com", "password": "password123", "name": "Ray", "tenantId": TENANT + "email": "r@e.com", "password": "glidingwalnut42", "name": "Ray", "tenantId": TENANT })), &[], ) @@ -525,7 +525,7 @@ async fn full_router_against_real_redis() { Method::POST, "/auth/login", Some(serde_json::json!({ - "email": "inviter@e.com", "password": "password123", "tenantId": TENANT + "email": "inviter@e.com", "password": "glidingwalnut42", "tenantId": TENANT })), &[], ) @@ -548,7 +548,7 @@ async fn full_router_against_real_redis() { Method::POST, "/auth/login", Some(serde_json::json!({ - "email": "mfa@e.com", "password": "password123", "tenantId": TENANT + "email": "mfa@e.com", "password": "glidingwalnut42", "tenantId": TENANT })), &[], ) @@ -560,7 +560,7 @@ async fn full_router_against_real_redis() { "/auth/mfa/setup", // Enrolment re-authenticates: the account has a password, so it must be re-proved // before a factor is minted. - Some(serde_json::json!({ "password": "password123" })), + Some(serde_json::json!({ "password": "glidingwalnut42" })), &[("access_token", &mfa_access)], ) .await; diff --git a/crates/bymax-auth-client/src/lib.rs b/crates/bymax-auth-client/src/lib.rs index ac38be2..1dc4440 100644 --- a/crates/bymax-auth-client/src/lib.rs +++ b/crates/bymax-auth-client/src/lib.rs @@ -650,7 +650,7 @@ mod tests { fn register_input() -> RegisterRequest { RegisterRequest { email: "u@e.com".to_owned(), - password: "password123".to_owned(), + password: "glidingwalnut42".to_owned(), name: "U".to_owned(), tenant_id: "t1".to_owned(), } @@ -659,7 +659,7 @@ mod tests { fn login_input() -> LoginRequest { LoginRequest { email: "u@e.com".to_owned(), - password: "password123".to_owned(), + password: "glidingwalnut42".to_owned(), tenant_id: "t1".to_owned(), } } @@ -945,7 +945,7 @@ mod tests { with_server(plan, |client| async move { let input = ResetPasswordRequest { email: "u@e.com".to_owned(), - new_password: "newpassword123".to_owned(), + new_password: "newglidingwalnut42".to_owned(), proof, tenant_id: "t1".to_owned(), }; diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index babf550..48c89cb 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -19,7 +19,7 @@ use crate::services::token_manager::TokenManagerService; #[cfg(feature = "mfa")] use crate::traits::MfaStore; use crate::traits::{ - AllowAllBreachChecker, AuthHooks, BruteForceStore, EmailProvider, HttpClient, InvitationStore, + AuthHooks, BruteForceStore, CommonPasswordChecker, EmailProvider, HttpClient, InvitationStore, NoOpAuthHooks, NoOpEmailProvider, OAuthProvider, OtpStore, PasswordBreachChecker, PasswordResetStore, PlatformUserRepository, SessionStore, UserRepository, WsTicketStore, }; @@ -120,12 +120,20 @@ impl AuthEngineBuilder { self } - /// Set the breach checker consulted wherever a password is set (defaults to - /// [`AllowAllBreachChecker`], which approves everything and touches no network). + /// Set the password screen consulted wherever a password is set (defaults to + /// [`CommonPasswordChecker`], which refuses the common passwords offline). /// - /// Wiring one is opt-in on purpose: a crate should not start talking to a third-party - /// corpus because it was upgraded. The bundled `HibpBreachChecker` (feature `breach`) - /// runs over the same [`HttpClient`](crate::traits::HttpClient) seam the OAuth flows use. + /// The *network* check stays opt-in: a crate should not start talking to a third-party + /// corpus because it was upgraded. The bundled `HibpBreachChecker` (feature `breach`) runs + /// over the same [`HttpClient`](crate::traits::HttpClient) seam the OAuth flows use. + /// + /// The offline screen is a different matter, and is on by default. NIST SP 800-63B + /// §3.1.1.2 says a verifier SHALL compare against a blocklist of common passwords and ASVS + /// v5 §6.2.4 asks for it at Level 1; the previous default, + /// [`AllowAllBreachChecker`](crate::traits::AllowAllBreachChecker), approved everything, + /// so a deployment on defaults accepted `password1`. That one is still available for a + /// deployment with a deliberate reason to screen nothing — a migration importing legacy + /// accounts — which now has to say so. #[must_use] pub fn breach_checker(mut self, checker: Arc) -> Self { self.breach_checker = Some(checker); @@ -375,8 +383,9 @@ impl AuthEngineBuilder { // Build the password service (and its startup sentinel hash) from the validated // password config before it is moved into the resolved bundle. - let breach_checker = breach_checker - .unwrap_or_else(|| Arc::new(AllowAllBreachChecker) as Arc); + let breach_checker = breach_checker.unwrap_or_else(|| { + Arc::new(CommonPasswordChecker::new()) as Arc + }); let passwords = Arc::new(PasswordService::new(&config.password, breach_checker)?); // Capture the scalar token/brute-force settings and the signing key before the 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 619ada6..1e8051f 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -14,6 +14,7 @@ use std::time::Instant; use bymax_auth_crypto::mac::{sha256, verify_digest}; use bymax_auth_crypto::token::generate_secure_token; +use bymax_auth_jwt::RawRefreshToken; use bymax_auth_types::{AuthError, AuthUser, SafeAuthUser}; use crate::config::ResetMethod; @@ -22,7 +23,8 @@ use crate::engine::AuthEngine; use crate::normalize::normalize_email; use crate::services::auth::detached::run_after_password_reset; use crate::services::auth::{map_repository_error, normalize_anti_enum, spawn_guarded}; -use crate::traits::{HookContext, OtpPurpose, ResetContext}; +use crate::services::is_refresh_token_shape; +use crate::traits::{HookContext, OtpPurpose, ResetContext, SessionKind}; /// The lifetime, in seconds, of the short-lived verified token that bridges a successful /// OTP verification to the reset form (§7.8 `VERIFIED_TOKEN_TTL_SECONDS`). @@ -492,6 +494,112 @@ impl AuthEngine { /// Apply the verified reset: hash the new password, persist it, then revoke every session. /// + /// Change the password of an already-authenticated account, proving identity with the + /// current password rather than an emailed token. + /// + /// This is the flow ASVS v5 §6.2.2 and §6.2.3 require at Level 1 — "users can change their + /// password", and "password change functionality requires the user's current and new + /// password" — and it was the one credential operation this library did not own. Without + /// it a host either sends users through the *unauthenticated* recovery flow to rotate a + /// password they already know, or hand-rolls hashing against `bymax-auth-crypto` with + /// duplicated parameters and no guarantee that the sessions are revoked afterwards. + /// + /// The current password is what makes it safe. A session alone is not proof of identity: a + /// token lifted by XSS or from a shared machine would otherwise be enough to rotate the + /// credential, lock the real owner out of an account they still know the password to, and + /// keep the attacker in. + /// + /// Every other session ends on success (ASVS v5 §7.4.3) and the token epoch is bumped, so + /// already-issued access tokens die with them. The caller's own refresh session survives + /// when `current_refresh` identifies it, so the device that made the change stays signed in + /// and silently re-mints its access token on the next rotation. When it cannot be + /// identified, every session goes, this one included: a change that leaves an unknown + /// session alive is the failure the control exists to prevent. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidCredentials`] when the current password does not match, the + /// account is gone, or it has no local password (an OAuth-only account has nothing to + /// change — its credential belongs to the provider); [`AuthError::PasswordCompromised`] + /// when the screen refuses the new password; or a repository/store [`AuthError`]. + pub async fn change_password( + &self, + user_id: &str, + current_password: &str, + new_password: &str, + current_refresh: Option<&str>, + ) -> Result<(), AuthError> { + let user = self + .user_repository() + .find_by_id(user_id, None) + .await + .map_err(map_repository_error)?; + // A verified token whose subject is gone, and an account with no local password, answer + // identically: the caller cannot prove a credential this account does not have. + let Some(phc) = user.and_then(|user| user.password_hash) else { + return Err(AuthError::InvalidCredentials); + }; + + if !self + .passwords() + .verify(current_password, &phc) + .await? + .matched + { + tracing::warn!(user_id = %user_id, "password change: current password rejected"); + return Err(AuthError::InvalidCredentials); + } + + self.passwords() + .assert_not_compromised(new_password) + .await?; + let new_hash = self.passwords().hash(new_password).await?; + self.user_repository() + .update_password(user_id, &new_hash) + .await + .map_err(map_repository_error)?; + + // Sessions go only after the password is durably written, for the same reason the reset + // flow orders it that way: a crash between the two leaves stale refresh tokens alive + // until their TTL, but the old password is already dead. + match current_refresh.filter(|raw| is_refresh_token_shape(raw)) { + Some(raw) => { + let hash = RawRefreshToken::from_raw(raw.to_owned()).redis_hash(); + self.sessions() + .revoke_all_except_current(user_id, &hash) + .await?; + } + None => { + self.session_store() + .revoke_all(SessionKind::Dashboard, user_id) + .await?; + self.session_store() + .bump_epoch(SessionKind::Dashboard, user_id) + .await?; + } + } + + tracing::info!(user_id = %user_id, "password change: completed, other sessions revoked"); + self.notify_password_changed(user_id).await; + Ok(()) + } + + /// Send the "your password changed" notice, detached and best-effort. + /// + /// NIST SP 800-63B §4.6 asks for a notification through a channel independent of the + /// transaction that bound the new credential. Never awaited and never allowed to fail the + /// operation: a delivery problem must not undo a password that is already written, nor + /// answer differently to the caller. + async fn notify_password_changed(&self, user_id: &str) { + let Ok(Some(user)) = self.user_repository().find_by_id(user_id, None).await else { + return; + }; + spawn_guarded(run_send_password_changed( + self.email_provider().clone(), + user.email, + )); + } + /// **Operation order is security-critical:** the password is updated **before** sessions /// are invalidated. A crash between the two leaves stale refresh tokens alive only until /// their TTL — but the old password is already dead, so a stolen password cannot mint new @@ -528,6 +636,10 @@ impl AuthEngine { // tokens, so it belongs in the audit trail even when nothing failed. tracing::info!(user_id = %context.user_id, "password reset: completed, all sessions revoked"); + // A reset needs the notice at least as much as a change does: the classic takeover + // completes one from a compromised mailbox and deletes the mail. + self.notify_password_changed(&context.user_id).await; + let hook_ctx = reset_context_hooks(context); let safe = self.project_user_for_hook(context).await; if let Some(safe) = safe { @@ -594,13 +706,23 @@ fn reset_context_hooks(context: &ResetContext) -> HookContext { } } +/// Send the "password changed" email (a named future so the detached spawn owns its data). +async fn run_send_password_changed( + email: std::sync::Arc, + recipient: String, +) -> Result<(), crate::traits::EmailError> { + email.send_password_changed(&recipient, None).await +} + #[cfg(test)] mod tests { use super::*; + use crate::services::auth::LoginInput; use crate::services::auth::test_support::{Harness, SeedUser, base_config, ctx, harness}; use crate::traits::{ EmailProvider, OtpStore, PasswordResetStore, SessionKind, SessionStore, UserRepository, }; + use bymax_auth_types::{AuthResult, CreateUserData, LoginResult}; use std::time::Duration; fn token_harness() -> Option { @@ -1871,6 +1993,136 @@ mod tests { } } + /// Log in and return the session, or `None` — so a caller's `let-else` fits on one line. + /// (Coverage is per line: a `return` on its own line inside a multi-line `let-else` is + /// never executed, and reads as a gap rather than as the panic-free idiom it is.) + async fn login_ok(h: &Harness, email: &str, password: &str) -> Option { + let input = LoginInput { + email: email.to_owned(), + password: password.to_owned(), + tenant_id: "t1".to_owned(), + }; + let result = h.engine.login(input, &ctx()).await; + let Ok(LoginResult::Success(auth)) = result else { return None }; + Some(*auth) + } + + #[tokio::test] + async fn change_password_requires_the_current_one_and_rotates() { + // ASVS v5 §6.2.2 and §6.2.3 at Level 1: users can change their password, and the change + // takes both the current and the new one. The current password is what makes it safe — + // a session alone is not proof of identity, so a token lifted by XSS or from a shared + // machine must not be enough to rotate the credential and lock the owner out. + let Some(h) = token_harness() else { return }; + let id = h + .seed(SeedUser::active("changer@example.com", "oldsecret77")) + .await; + + // A wrong current password writes nothing. + let refused = h + .engine + .change_password(&id, "not-the-password", "glidingwalnut42", None) + .await; + assert!(matches!(refused, Err(AuthError::InvalidCredentials))); + + // The right one rotates it, and the new password is what logs in afterwards. + assert!( + h.engine + .change_password(&id, "oldsecret77", "glidingwalnut42", None) + .await + .is_ok() + ); + assert!( + login_ok(&h, "changer@example.com", "glidingwalnut42") + .await + .is_some() + ); + } + + #[tokio::test] + async fn change_password_spares_the_caller_session_when_it_is_identified() { + // ASVS v5 §7.4.3: the other sessions end. The caller's own survives, so the device that + // made the change is not signed out by making it — it silently re-mints its access + // token on the next rotation instead. + let Some(h) = token_harness() else { return }; + let id = h + .seed(SeedUser::active("keeper@example.com", "oldsecret77")) + .await; + let Some(mine) = login_ok(&h, "keeper@example.com", "oldsecret77").await else { return }; + let Some(other) = login_ok(&h, "keeper@example.com", "oldsecret77").await else { return }; + + assert!( + h.engine + .change_password( + &id, + "oldsecret77", + "glidingwalnut42", + Some(&mine.refresh_token) + ) + .await + .is_ok() + ); + + // The other device is gone… + assert!( + h.engine + .refresh(&other.refresh_token, "1.2.3.4", "agent") + .await + .is_err() + ); + // …and the caller's own still rotates. + assert!( + h.engine + .refresh(&mine.refresh_token, "1.2.3.4", "agent") + .await + .is_ok() + ); + } + + #[tokio::test] + async fn change_password_refuses_an_account_with_no_local_password() { + // An account provisioned purely through OAuth has nothing to prove and nothing to + // change — its credential belongs to the provider. Answering the same + // `InvalidCredentials` as a wrong password keeps the two indistinguishable. + let Some(h) = token_harness() else { return }; + let created = h + .users + .create(CreateUserData { + email: "oauth-only@example.com".to_owned(), + name: "OAuth Only".to_owned(), + password_hash: None, + role: None, + status: Some("ACTIVE".to_owned()), + tenant_id: "t1".to_owned(), + email_verified: Some(true), + }) + .await; + let Ok(user) = created else { return }; + + let refused = h + .engine + .change_password(&user.id, "anything", "glidingwalnut42", None) + .await; + assert!(matches!(refused, Err(AuthError::InvalidCredentials))); + } + + #[tokio::test] + async fn change_password_refuses_a_new_password_the_screen_rejects() { + // The screen runs on the change path as it does on register and reset — otherwise the + // one flow a user reaches *because* they were told their password was weak is the one + // that lets them pick another weak one. + let Some(h) = token_harness() else { return }; + let id = h + .seed(SeedUser::active("weak@example.com", "oldsecret77")) + .await; + + let refused = h + .engine + .change_password(&id, "oldsecret77", "Password123", None) + .await; + assert!(matches!(refused, Err(AuthError::PasswordCompromised))); + } + #[tokio::test] async fn apply_reset_skips_the_hook_for_a_vanished_subject() { // A reset whose bound context points at a user id that no longer resolves still @@ -1901,7 +2153,7 @@ mod tests { ResetPasswordInput { email: "vanish@example.com".to_owned(), tenant_id: "t1".to_owned(), - new_password: "new".to_owned(), + new_password: "glidingwalnut42".to_owned(), token: Some(token), otp: None, verified_token: None, 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 b757c5e..64386cf 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -382,7 +382,7 @@ mod tests { let mut cfg = base_config(); cfg.email_verification.required = false; let Some(h) = harness(cfg, None) else { return }; - let Some((id, auth)) = logged_in(&h, "deleted@e.com", "password123").await else { return }; + let Some((id, auth)) = logged_in(&h, "gone@e.com", "walnut42x").await else { return }; h.users.remove(&id); @@ -409,7 +409,7 @@ mod tests { let id = h .seed(SeedUser { email: "unverified@e.com".to_owned(), - password: "password123".to_owned(), + password: "glidingwalnut42".to_owned(), tenant_id: "t1".to_owned(), status: "ACTIVE".to_owned(), email_verified: false, diff --git a/crates/bymax-auth-core/src/traits/common_password.rs b/crates/bymax-auth-core/src/traits/common_password.rs new file mode 100644 index 0000000..763210a --- /dev/null +++ b/crates/bymax-auth-core/src/traits/common_password.rs @@ -0,0 +1,502 @@ +//! The default password screen: refuses passwords that are common, structural, or trivially +//! decorated versions of either — offline, with no network call. +//! +//! NIST SP 800-63B §3.1.1.2 states that a verifier **SHALL** compare a prospective secret +//! against a blocklist of commonly used, expected, or compromised values, and ASVS v5 §6.2.4 +//! asks for it at Level 1 — the baseline every application needs. The default used to be +//! [`AllowAllBreachChecker`](super::breach::AllowAllBreachChecker), which approved everything: +//! a deployment on defaults accepted `password1` and `12345678`, and the brute-force machinery +//! never fired, because a spraying campaign that tries one password across ten thousand +//! accounts never crosses any single account's threshold. +//! +//! Being offline is what lets this be a default where the HIBP checker could not: a library +//! should not start talking to a third party because it was upgraded, but it can perfectly well +//! start refusing `password`. `nest-auth` ships the identical screen. + +use std::collections::HashSet; + +use async_trait::async_trait; + +use super::breach::PasswordBreachChecker; + +/// Base words behind the overwhelming majority of real-world weak passwords. +/// +/// Deliberately short. It is not a top-3000 dump and does not try to be: [`reduce_to_base_word`] +/// strips the decorations people actually add — case, leet substitutions, trailing digits and +/// punctuation — so one entry here covers `Password1`, `p@ssw0rd`, `PASSWORD123!` and the rest +/// of a family that a raw list would have to spell out one member at a time. A few hundred +/// bases is where the published top-N lists mostly *come from*; enumerating their mutations is +/// what makes those lists long, not what makes them effective. +/// +/// Entries are stored already reduced, because that is the form they are compared in. +const COMMON_BASE_WORDS: &[&str] = &[ + // The perennial top of every published list. + "password", + "passwort", + "passwd", + "senha", + "contrasena", + "motdepasse", + "welcome", + "letmein", + "changeme", + "secret", + "default", + "temporary", + "temppassword", + "admin", + "administrator", + "root", + "toor", + "guest", + "test", + "testing", + "demo", + "sample", + "login", + "user", + "username", + "account", + "access", + "private", + "security", + "secure", + // Keyboard rows and walks, in the shapes people type them. + "qwerty", + "qwertyui", + "qwertyuiop", + "azerty", + "qwertz", + "asdfgh", + "asdfghjk", + "asdfghjkl", + "zxcvbn", + "zxcvbnm", + "qazwsx", + "qazwsxedc", + "wsxedc", + "qweasd", + "qweasdzxc", + "poiuytrewq", + // Affection, the second-largest family after keyboards. + "iloveyou", + "ilovegod", + "loveyou", + "lovely", + "sweetheart", + "darling", + "princess", + "prince", + "sunshine", + "baby", + "angel", + "honey", + "butterfly", + "flower", + "kisses", + // Sport, entertainment, and the fandom perennials. + "football", + "baseball", + "basketball", + "softball", + "soccer", + "hockey", + "liverpool", + "arsenal", + "chelsea", + "barcelona", + "realmadrid", + "juventus", + "manutd", + "manchester", + "superman", + "batman", + "spiderman", + "starwars", + "pokemon", + "minecraft", + "fortnite", + "thomas", + "harley", + "ferrari", + "porsche", + "mercedes", + "corvette", + "mustang", + // Names that top every leak, and the words that keep them company. + "michael", + "jennifer", + "jessica", + "ashley", + "daniel", + "charlie", + "matthew", + "joshua", + "andrew", + "robert", + "william", + "nicole", + "hunter", + "jordan", + "taylor", + "george", + "maggie", + "buster", + "shadow", + "ginger", + "tigger", + "pepper", + "cookie", + "peanut", + "snoopy", + // Words people reach for when told "make it strong". + "dragon", + "monkey", + "master", + "freedom", + "whatever", + "trustno", + "nothing", + "anything", + "computer", + "internet", + "samsung", + "google", + "facebook", + "apple", + "microsoft", + "windows", + "letmeinnow", + "iamgod", + "ihateyou", + "fuckyou", + "fuckoff", + "bullshit", + "asshole", + "summer", + "winter", + "spring", + "autumn", + "january", + "february", + "october", + "november", + "december", + "september", + "monday", + "friday", + "sunday", + "money", + "business", + "company", + "office", + "manager", + "director", + "service", + "support", + "chocolate", + "cheese", + "orange", + "purple", + "yellow", + "silver", + "golden", + "diamond", + "phoenix", + "thunder", + "lightning", + "warrior", + "ranger", + "killer", + "legend", + "forever", + "together", + "nevermind", + "whatsup", + "blessed", + "jesus", + "jesuschrist", +]; + +/// Sequences a password may not consist of, in either direction. +/// +/// A run long enough to fill the minimum length is not a password no matter which characters it +/// is made of, and no word list can enumerate every window of every sequence. +const SEQUENCE_ALPHABETS: &[&str] = &[ + "abcdefghijklmnopqrstuvwxyz", + "01234567890", + "qwertyuiopasdfghjklzxcvbnm", +]; + +/// The shortest reduced base that is treated as a word rather than a fragment. +/// +/// Below this every string is a substring of some alphabet, which would make the sequence check +/// meaningless rather than selective — and a password whose entire word content is under four +/// characters (`a1234567`, `abc12345`) is padding around a fragment, which no list can catch +/// because there is no entry to write. +const MIN_BASE_LENGTH: usize = 4; + +/// Map a leet character back to the letter it stands in for. +fn undo_leet(c: char) -> char { + match c { + '0' => 'o', + '1' => 'i', + '3' => 'e', + '4' => 'a', + '5' => 's', + '7' => 't', + '8' => 'b', + '9' => 'g', + '@' => 'a', + '$' => 's', + '!' | '|' => 'i', + '+' => 't', + other => other, + } +} + +/// Reduce a password to the base word its author started from. +/// +/// Lowercases, strips the trailing digits and punctuation people append to satisfy a complexity +/// rule, maps leet substitutions back to letters, and drops what is left that is not +/// alphanumeric. `P@ssw0rd!`, `Password123`, and `password` all reduce to `password`, which is +/// why a few hundred bases stand in for a list many times longer. +/// +/// The order matters: decoration comes off **first**, while the digits are still digits. +/// Mapping leet before that would turn the trailing `1` of `Password1` into an `i` and leave +/// `passwordi`, which matches nothing — the difference between the mechanism working and the +/// list quietly covering only its literal entries. +#[must_use] +pub fn reduce_to_base_word(password: &str) -> String { + let lowered = password.to_lowercase(); + let undecorated = lowered.trim_end_matches(|c: char| !c.is_alphabetic()); + undecorated + .chars() + .map(undo_leet) + .filter(char::is_ascii_alphanumeric) + .collect() +} + +/// Whether a string is a run along one of [`SEQUENCE_ALPHABETS`], forwards or backwards. +fn is_sequential(value: &str) -> bool { + SEQUENCE_ALPHABETS.iter().any(|alphabet| { + let reversed: String = alphabet.chars().rev().collect(); + alphabet.contains(value) || reversed.contains(value) + }) +} + +/// Whether the value is one short unit repeated to reach the length floor (`abcabcabc`). +/// Bounded to units of 1–4 so this stays a check on padding, not on any repetition. +fn is_padded_repeat(value: &str) -> bool { + let chars: Vec = value.chars().collect(); + (1..=4).any(|unit| { + chars.len() > unit * 2 + && chars.len().is_multiple_of(unit) + && chars.chunks(unit).all(|chunk| chunk == &chars[..unit]) + }) +} + +/// The default password screen. See the module docs for what it is and is not. +/// +/// **A floor, not a corpus.** It refuses the base words behind the bulk of real-world weak +/// passwords, keyboard walks, single-character repeats, sequential runs, fragments padded out +/// with decoration, and any decorated form of those — but it is not the full top-3000, and it +/// knows nothing about breach corpora. A deployment that wants that extends it with +/// [`CommonPasswordChecker::with_extra_words`] (the context-specific words ASVS v5 §6.2.11 asks +/// for) or supplies the HIBP checker, which searches a real corpus over the network. +pub struct CommonPasswordChecker { + blocked: HashSet, +} + +impl Default for CommonPasswordChecker { + fn default() -> Self { + Self::new() + } +} + +impl CommonPasswordChecker { + /// The shipped screen, with no deployment-specific words. + #[must_use] + pub fn new() -> Self { + Self { + blocked: COMMON_BASE_WORDS.iter().map(|w| (*w).to_owned()).collect(), + } + } + + /// The shipped screen plus the deployment's own context words — its product, company and + /// domain names, which are exactly the words its users reach for and which no general + /// corpus contains (ASVS v5 §6.2.11). + /// + /// Entries are reduced the same way a candidate is, so listing `Acme` also refuses + /// `Acme2024!` and `@cme123` without anyone having to think of them. + #[must_use] + pub fn with_extra_words(extra: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let mut checker = Self::new(); + checker.blocked.extend( + extra + .into_iter() + .map(|word| reduce_to_base_word(word.as_ref())), + ); + checker + } +} + +#[async_trait] +impl PasswordBreachChecker for CommonPasswordChecker { + async fn is_breached(&self, password: &str) -> bool { + let base = reduce_to_base_word(password); + + // Almost nothing survived the reduction, so the password was decoration wrapped around + // a fragment: `!!!!!!!!` and `12345678` leave nothing at all, `a1234567` leaves `a`. + if base.len() < MIN_BASE_LENGTH { + return true; + } + + if self.blocked.contains(&base) { + return true; + } + + // A single character repeated, before or after reduction. + let mut chars = base.chars(); + if let Some(first) = chars.next() + && chars.all(|c| c == first) + { + return true; + } + + is_sequential(&base) || is_padded_repeat(&base) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The reduction is the whole mechanism: one base entry has to stand in for the family of + /// decorated forms a raw list would need to spell out one at a time. + #[test] + fn the_reduction_collapses_every_decorated_form_onto_one_base() { + for input in [ + "password", + "Password", + "PASSWORD", + "Password1", + "password123", + "P@ssw0rd", + "p@$$w0rd!", + "Password2024!", + "pa55word", + ] { + assert_eq!(reduce_to_base_word(input), "password", "for {input}"); + } + } + + /// Every leet substitution is exercised, because an unmapped one is a silent hole: the + /// candidate simply fails to match its base and sails through. + #[test] + fn every_leet_substitution_maps_back() { + assert_eq!(reduce_to_base_word("p455w0rd"), "password"); + assert_eq!(reduce_to_base_word("7hunder"), "thunder"); + assert_eq!(reduce_to_base_word("8u5ter"), "buster"); + assert_eq!(reduce_to_base_word("9inger"), "ginger"); + assert_eq!(reduce_to_base_word("+3s+ing"), "testing"); + assert_eq!(reduce_to_base_word("m|chael"), "michael"); + assert_eq!(reduce_to_base_word("$hadow"), "shadow"); + } + + /// Only TRAILING decoration comes off. A leading digit is part of the word, so `1password` + /// must not collapse onto `password` — over-blocking is its own failure. + #[test] + fn a_leading_digit_is_not_decoration() { + assert_ne!(reduce_to_base_word("1password"), "password"); + } + + #[tokio::test] + async fn it_refuses_the_entries_every_published_list_opens_with() { + let checker = CommonPasswordChecker::new(); + for password in [ + "password", + "Password1", + "P@ssw0rd", + "password123", + "qwertyui", + "qwerty123", + "iloveyou", + "sunshine", + "football", + "superman", + "michael1", + "letmein123", + "welcome1", + "changeme", + "administrator", + "trustno1", + ] { + assert!(checker.is_breached(password).await, "allowed {password}"); + } + } + + /// Structural weakness no word list can enumerate: a run, a repeat, or a fragment padded + /// out to reach the length floor. + #[tokio::test] + async fn it_refuses_structural_weakness() { + let checker = CommonPasswordChecker::new(); + for password in [ + "12345678", + "87654321", + "abcdefgh", + "aaaaaaaa", + "abcabcabc", + "1212121212", + "!!!!!!!!", + "a1234567", + "abc12345", + ] { + assert!(checker.is_breached(password).await, "allowed {password}"); + } + } + + /// The screen must not become a general complexity rule. A password with no relation to the + /// bases and no structural pattern passes, whatever it looks like. + #[tokio::test] + async fn it_allows_a_password_that_is_merely_unusual() { + let checker = CommonPasswordChecker::new(); + for password in [ + "correct-horse-battery-staple", + "Tr0ub4dor&3xyz", + "gliding-walnut-forecast", + "9fK2mQwZ", + "1password", + ] { + assert!(!checker.is_breached(password).await, "refused {password}"); + } + } + + /// ASVS v5 §6.2.11: the deployment's own words, and their decorated forms for free. + #[tokio::test] + async fn it_refuses_a_deployment_context_word_and_its_decorations() { + let checker = CommonPasswordChecker::with_extra_words(["Acme"]); + + assert!(checker.is_breached("acme").await); + assert!(checker.is_breached("Acme2024!").await); + assert!(checker.is_breached("@cme123").await); + // …and the word is not in the shipped screen for everyone else. + assert!(!CommonPasswordChecker::new().is_breached("acmecorp").await); + // …while the shipped bases still apply. + assert!(checker.is_breached("Password1").await); + } + + /// `Default` is the same screen as `new`, since the builder may construct it either way. + #[tokio::test] + async fn default_is_the_shipped_screen() { + assert!( + CommonPasswordChecker::default() + .is_breached("password") + .await + ); + } +} diff --git a/crates/bymax-auth-core/src/traits/email.rs b/crates/bymax-auth-core/src/traits/email.rs index 209a040..4c41418 100644 --- a/crates/bymax-auth-core/src/traits/email.rs +++ b/crates/bymax-auth-core/src/traits/email.rs @@ -46,6 +46,27 @@ pub trait EmailProvider: Send + Sync { locale: Option<&str>, ) -> Result<(), EmailError>; + /// Security alert: the account password changed — after an authenticated change, and + /// after a completed reset. + /// + /// NIST SP 800-63B §4.6 requires the subscriber to be notified through a channel + /// independent of the transaction that bound the new credential. The classic takeover + /// starts with a compromised mailbox: the attacker triggers a reset, completes it, and + /// deletes the mail. This notice is what turns "the victim finds out days later, at a + /// failed login" into "the victim finds out now" — and it was the one credential change + /// this trait stayed silent about while announcing every MFA change unprompted. + /// + /// Defaulted to a no-op so an existing provider keeps compiling; a deployment that wants + /// the notice implements it. + async fn send_password_changed( + &self, + email: &str, + locale: Option<&str>, + ) -> Result<(), EmailError> { + let _ = (email, locale); + Ok(()) + } + /// Security alert: MFA was enabled on the account. async fn send_mfa_enabled(&self, email: &str, locale: Option<&str>) -> Result<(), EmailError>; diff --git a/crates/bymax-auth-core/src/traits/mod.rs b/crates/bymax-auth-core/src/traits/mod.rs index 5727c41..006b2da 100644 --- a/crates/bymax-auth-core/src/traits/mod.rs +++ b/crates/bymax-auth-core/src/traits/mod.rs @@ -5,6 +5,7 @@ //! [`HttpClient`] transport. pub mod breach; +pub mod common_password; pub mod email; pub mod hooks; pub mod http; @@ -17,6 +18,7 @@ pub mod store; pub use breach::HibpBreachChecker; #[doc(inline)] pub use breach::{AllowAllBreachChecker, PasswordBreachChecker}; +pub use common_password::{CommonPasswordChecker, reduce_to_base_word}; #[doc(inline)] pub use email::{EmailError, EmailProvider, InviteData, NoOpEmailProvider, SessionInfo}; #[doc(inline)] diff --git a/crates/bymax-auth-core/tests/engine_assembly.rs b/crates/bymax-auth-core/tests/engine_assembly.rs index 89ee4d9..8549fc9 100644 --- a/crates/bymax-auth-core/tests/engine_assembly.rs +++ b/crates/bymax-auth-core/tests/engine_assembly.rs @@ -106,7 +106,7 @@ async fn a_wired_breach_checker_refuses_a_compromised_password_at_registration() .environment(Environment::Test) .user_repository(users) .redis_stores(Arc::new(InMemoryStores::new())) - .breach_checker(Arc::new(RejectsOnePassword("password123"))) + .breach_checker(Arc::new(RejectsOnePassword("glidingwalnut42"))) .build(); assert!(engine.is_ok(), "valid wiring must assemble"); let Ok(engine) = engine else { return }; @@ -120,7 +120,7 @@ async fn a_wired_breach_checker_refuses_a_compromised_password_at_registration() .register( bymax_auth_core::services::auth::RegisterInput { email: "breached@example.com".to_owned(), - password: "password123".to_owned(), + password: "glidingwalnut42".to_owned(), name: "Ada".to_owned(), tenant_id: "t1".to_owned(), }, From a3a3438888dcdd842909730a09009e754775e078 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:41:02 -0300 Subject: [PATCH 108/122] docs(readme): list the authenticated password-change route --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index abb4666..b3cea2d 100644 --- a/README.md +++ b/README.md @@ -712,6 +712,7 @@ Route groups mount only when their feature **and** runtime toggle are enabled, s | POST | `/auth/password/reset-password` | Public | Submit a new password | | POST | `/auth/password/verify-otp` | Public | Verify a password-reset OTP | | POST | `/auth/password/resend-otp` | Public | Resend the password-reset OTP | +| POST | `/auth/password/change` | `AuthUser` + `UserStatus` | Change the password, proving the current one | | POST | `/auth/mfa/setup` | `AuthUser` | Generate the TOTP secret + recovery codes | | POST | `/auth/mfa/verify-enable` | `AuthUser` | Confirm setup and enable MFA | | POST | `/auth/mfa/challenge` | Public (MFA temp token) | Submit a TOTP / recovery code after login | From 71ef03d6d51c358bd2c2dbeb0830685344a8d45e Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:08:17 -0300 Subject: [PATCH 109/122] fix(core): re-validate an invitation's inviter and bind reset proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invitation is a delegation of authority, and authority is revocable. It was validated when the link was minted and never again, so for the token's whole life the invitation outlived the person behind it: an admin could send one, be banned and stripped of their role, and the invitee would still arrive as an admin of that tenant with a live session — a clean way to keep a foothold across the account kill switch, which makes the switch advisory. The inviter must now still exist, still be in good standing, still belong to the tenant, and still out-rank the role being granted, answered as an invalid token because the redeemer is not the one who lost authority. `ResetContext` gains `passwordFingerprint`, a digest of the password hash in force when the proof was minted, and a proof is refused once that no longer matches. Several proofs can be alive at once and completing one left the rest valid — the wrong end state exactly when it matters: a victim who resets BECAUSE an attacker read a link from their mailbox had not closed the link the attacker read. The hash itself never leaves the repository, and an absent field reads as "no binding" so a rolling deploy does not break the resets already in flight. --- CHANGELOG.md | 17 +++ conformance/wire-contract.json | 3 +- .../src/services/auth/invitation.rs | 110 ++++++++++++- .../src/services/auth/password_reset.rs | 144 +++++++++++++++++- crates/bymax-auth-core/src/testing/mod.rs | 1 + crates/bymax-auth-core/src/traits/store.rs | 16 ++ crates/bymax-auth-redis/tests/redis_stores.rs | 3 + 7 files changed, 289 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cce40d2..f1def62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,23 @@ version bump. independent of the transaction that bound the new credential, and this was the one credential change the trait stayed silent about while announcing every MFA change unprompted. Defaulted to a no-op so an existing provider keeps compiling. +- **An invitation is re-validated against its inviter at redemption.** The inviter's authority + was checked when the link was minted and never again, so for the token's whole life the + invitation outlived the person behind it: an admin could send one, be banned and stripped of + their role, and the invitee would still arrive as an admin of that tenant with a live session. + That is a clean way to keep a foothold across the account kill switch, which makes the switch + advisory. The inviter must now still exist, still be in good standing, still belong to the + tenant, and still out-rank the role being granted — answered as an invalid token, because the + redeemer is not the one who lost authority. `nest-auth` takes the same change. +- **A completed reset or password change invalidates the proofs issued beside it.** + `ResetContext` gains `passwordFingerprint`, a digest of the password hash in force when the + proof was minted; a proof is refused once that no longer matches. Several proofs can be alive + at once — a 60-second send cooldown against a 600-second TTL allows up to ten — and completing + one left the rest valid, which is the wrong end state exactly when it matters: a victim who + resets *because* an attacker read a link from their mailbox had not closed the link the + attacker read. The hash itself never leaves the repository, and an absent field is read as + "no binding" so a rolling deploy does not break the resets already in flight. Pinned in + `conformance/wire-contract.json`. - **The platform recovery-code challenge gates on winning the temp-token consume**, which the dashboard path already did. Found while chasing a coverage gap the enrolment change exposed: the two planes carry the same logic separately, and only one had been fixed. diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 588e856..b450f60 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -148,7 +148,8 @@ }, "passwordResetContext": { "key": "pw_reset:{sha256(token)}", - "fields": ["userId", "email", "tenantId"] + "fields": ["userId", "email", "tenantId", "passwordFingerprint"], + "passwordFingerprint": "sha256 of the account's password hash at the moment the token was minted, or the empty string when the account had none. Several reset tokens can be alive at once, and completing one used to leave the rest valid — the wrong end state exactly when it matters, since a victim resetting BECAUSE an attacker read a link from their mailbox had not closed the link the attacker read. The binding makes the first completed reset invalidate all of them, with no per-user index to keep in step. An ABSENT field is read as 'no binding' and accepted, so a rolling deploy does not break the resets already in flight." } }, diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 5d63037..e2fdff8 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -184,6 +184,14 @@ impl AuthEngine { return Err(AuthError::InvalidInvitationToken); } + // …and re-validate the INVITER, whose authority is what the invitation rests on. It was + // checked when the link was minted and never again, so for the token's whole lifetime + // the invitation outlived the person behind it: an admin could send one, be banned and + // stripped of their role, and the invitee would still arrive as an admin of that tenant + // with a live session. That is a clean way to keep a foothold across the account kill + // switch, which makes the switch advisory. + self.assert_inviter_still_authorised(&invitation).await?; + // Duplicate-registration guard within the tenant. if self .user_repository() @@ -240,6 +248,44 @@ impl AuthEngine { )); Ok(result) } + + /// Re-check, at redemption time, everything that was true of the inviter when the link was + /// minted. + /// + /// An invitation is a delegation of authority, and authority is revocable. Validating it + /// only at creation means a token carries whatever power its author had at the moment they + /// clicked send — surviving their suspension, their demotion, and their removal from the + /// tenant. The failure is answered as `InvalidInvitationToken` rather than as a role error: + /// the redeemer is not the one who lost authority, and telling them *why* would describe + /// the inviter's account status to someone who may be a stranger to it. + async fn assert_inviter_still_authorised( + &self, + invitation: &crate::traits::StoredInvitation, + ) -> Result<(), AuthError> { + let inviter = self + .user_repository() + .find_by_id(&invitation.inviter_user_id, None) + .await + .map_err(map_repository_error)?; + let still_authorised = inviter.is_some_and(|inviter| { + self.assert_user_not_blocked(&inviter.status).is_ok() + && inviter.tenant_id == invitation.tenant_id + && has_role( + &inviter.role, + &invitation.role, + &self.config().config().roles.hierarchy, + ) + }); + if !still_authorised { + tracing::warn!( + inviter_user_id = %invitation.inviter_user_id, + role = %invitation.role, + "invitation: the inviter can no longer grant this invitation" + ); + return Err(AuthError::InvalidInvitationToken); + } + Ok(()) + } } /// Whether `holder` satisfies `required` against the fully-denormalized role hierarchy: @@ -254,7 +300,6 @@ fn has_role(holder: &str, required: &str, hierarchy: &HashMap Result<(), AuthError> { + if context.password_fingerprint.is_empty() { + return Ok(()); + } + let current = self + .user_repository() + .find_by_id(&context.user_id, None) + .await + .map_err(map_repository_error)? + .map(|user| password_fingerprint(&user)) + .unwrap_or_default(); + + if current == context.password_fingerprint { + return Ok(()); + } + tracing::warn!( + user_id = %context.user_id, + "password reset: refusing a proof issued against a password that has since changed" + ); + Err(AuthError::PasswordResetTokenInvalid) + } + /// **Operation order is security-critical:** the password is updated **before** sessions /// are invalidated. A crash between the two leaves stale refresh tokens alive only until /// their TTL — but the old password is already dead, so a stolen password cannot mint new @@ -706,6 +743,19 @@ fn reset_context_hooks(context: &ResetContext) -> HookContext { } } +/// A digest of the account's current password hash, binding a reset proof to that password. +/// +/// The hash itself never leaves the repository — only this digest goes into the store, so a +/// leaked snapshot of the reset keyspace reveals nothing about the credential. An account with +/// no local password yields the empty string, which is a value like any other: a proof minted +/// then is invalidated as soon as one is set. +fn password_fingerprint(user: &AuthUser) -> String { + match user.password_hash.as_deref() { + Some(phc) => to_hex(&sha256(phc.as_bytes())), + None => String::new(), + } +} + /// Send the "password changed" email (a named future so the detached spawn owns its data). async fn run_send_password_changed( email: std::sync::Arc, @@ -724,6 +774,7 @@ mod tests { }; use bymax_auth_types::{AuthResult, CreateUserData, LoginResult}; use std::time::Duration; + use time::OffsetDateTime; fn token_harness() -> Option { let mut cfg = base_config(); @@ -803,6 +854,7 @@ mod tests { user_id: user.id.clone(), email: "reset@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }, 600 ) @@ -862,6 +914,7 @@ mod tests { user_id: id.clone(), email: "epoch@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }, 600, ) @@ -898,6 +951,7 @@ mod tests { user_id: id, email: "bind@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }, 600 ) @@ -2007,6 +2061,93 @@ mod tests { Some(*auth) } + /// An account with no local password fingerprints as the empty string, which the consume + /// path reads as "no binding". That is the right reading: there was no password to bind to, + /// and a proof minted then is invalidated the moment one is set — because the fingerprint + /// computed at consume time will no longer be empty. + #[test] + fn a_passwordless_account_fingerprints_as_empty() { + let mut user = AuthUser { + id: "u1".into(), + email: "user@example.com".into(), + name: "User".into(), + password_hash: None, + role: "MEMBER".into(), + status: "ACTIVE".into(), + tenant_id: "t1".into(), + email_verified: true, + mfa_enabled: false, + mfa_secret: None, + mfa_recovery_codes: None, + oauth_provider: Some("google".into()), + oauth_provider_id: Some("google-123".into()), + last_login_at: None, + created_at: OffsetDateTime::UNIX_EPOCH, + }; + assert_eq!(password_fingerprint(&user), ""); + + user.password_hash = Some("$scrypt$abc".to_owned()); + assert_ne!(password_fingerprint(&user), ""); + } + + #[tokio::test] + async fn a_completed_reset_invalidates_the_tokens_issued_beside_it() { + // Each `forgot-password` writes its own key, so several proofs can be alive at once, and + // completing one used to leave the rest valid. That is the wrong end state exactly when + // it matters: a victim who resets BECAUSE an attacker read a link from their mailbox had + // not closed the link the attacker read, and the attacker could set the password again + // for the rest of the TTL. + let Some(h) = token_harness() else { return }; + let id = h + .seed(SeedUser::active("siblings@example.com", "oldsecret77")) + .await; + let Some(store) = h.engine.password_reset_store() else { return }; + + // Two proofs, both bound to the password in force now. + let Ok(Some(user)) = h.users.find_by_id(&id, None).await else { return }; + let context = ResetContext { + user_id: id.clone(), + email: "siblings@example.com".to_owned(), + tenant_id: "t1".to_owned(), + password_fingerprint: password_fingerprint(&user), + }; + let first = "1".repeat(64); + let second = "2".repeat(64); + assert!(store.put_token(&first, &context, 600).await.is_ok()); + assert!(store.put_token(&second, &context, 600).await.is_ok()); + + // The victim completes the reset with the second link. + let input = |token: &str, password: &str| ResetPasswordInput { + email: "siblings@example.com".to_owned(), + tenant_id: "t1".to_owned(), + new_password: password.to_owned(), + token: Some(token.to_owned()), + otp: None, + verified_token: None, + }; + assert!( + h.engine + .reset_password(input(&second, "victimchosen456"), &ctx()) + .await + .is_ok() + ); + + // The first link — the one the attacker read — no longer works. + assert!(matches!( + h.engine + .reset_password(input(&first, "attackerchosen789"), &ctx()) + .await, + Err(AuthError::PasswordResetTokenInvalid) + )); + + // …and the victim's password is the one that stands. + assert!( + login_ok(&h, "siblings@example.com", "victimchosen456") + .await + .is_some() + ); + } + #[tokio::test] async fn change_password_requires_the_current_one_and_rotates() { // ASVS v5 §6.2.2 and §6.2.3 at Level 1: users can change their password, and the change @@ -2141,6 +2282,7 @@ mod tests { user_id: "ghost-user-id".to_owned(), email: "vanish@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }, 600 ) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 1dc57ff..b4b5534 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1534,6 +1534,7 @@ mod tests { user_id: "u1".to_owned(), email: "u@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }; assert!(store.put_token("tok", &context, 600).await.is_ok()); assert!(matches!( diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index cfb7ed5..e9595e0 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -496,6 +496,20 @@ pub struct ResetContext { pub email: String, /// The tenant scope the reset proof was issued for (re-checked on consume). pub tenant_id: String, + /// A digest of the password hash this proof was issued against, binding it to that password. + /// + /// Several reset tokens can be alive at once — a 60-second send cooldown against a + /// 600-second TTL allows up to ten — and completing one used to leave the rest valid. That + /// is the wrong end state precisely when it matters: a victim who resets *because* an + /// attacker read a link from their mailbox had not closed the link the attacker read. The + /// binding makes the first completed rotation invalidate all of them, with no per-user + /// index to keep in step. + /// + /// Empty when the account had no password at issue time. **Absent** on a record written by + /// an older build, or by a sibling that has not taken this change, which `serde` reads as + /// empty — accepted as "no binding" so a rolling deploy does not break resets in flight. + #[serde(default)] + pub password_fingerprint: String, } /// The trusted metadata stored for a pending invitation under `inv:` keyed by @@ -1004,6 +1018,7 @@ mod tests { user_id: "u1".into(), email: "user@example.com".into(), tenant_id: "t1".into(), + password_fingerprint: String::new(), }; let json = serde_json::to_string(&context)?; assert!(json.contains("\"userId\":\"u1\"")); @@ -1226,6 +1241,7 @@ mod tests { user_id: "u1".into(), email: "u1@example.com".into(), tenant_id: "t1".into(), + password_fingerprint: String::new(), }; let json: serde_json::Value = serde_json::to_value(context)?; for field in contract_fields("passwordResetContext") { diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index 8c7eace..62b598b 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -983,6 +983,7 @@ async fn keys_are_namespaced_no_pii_and_carry_a_ttl() { user_id: "user-42".to_owned(), email: "victim@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }; assert!( stores @@ -1053,6 +1054,7 @@ async fn password_reset_and_invitation_stores_are_single_use_via_getdel() { user_id: "u1".to_owned(), email: "u@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }; assert!(stores.put_token("rt-secret", &reset, 600).await.is_ok()); assert!(matches!( @@ -1175,6 +1177,7 @@ async fn engine_runs_password_reset_via_token_against_redis() { user_id: auth.user.id.clone(), email: "reset@example.com".to_owned(), tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), }, 600, ) From f8bb4742ab5905923c3dd6a444c9f2677a2e1699 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:43:43 -0300 Subject: [PATCH 110/122] feat(core): add the failure-side hooks, and let reuse detection name its victim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing hook fired on a success path, which left the failure side of authentication with no structured seam: a burst of wrong passwords, an account tripping its lockout, and a stolen refresh token being replayed existed only as English log lines whose wording is not a contract and whose change is not semver-visible. - `on_login_failed` carries a `LoginFailureReason`, and the user id only when the address resolved — so a consumer can tell "someone is guessing at this account" from "someone is spraying addresses", a distinction the uniform `InvalidCredentials` response deliberately hides from the caller but not from the deployment defending itself. - `on_lockout` fires on the attempt that CROSSES the threshold, not the next one: an attacker who trips the lock and walks away would otherwise never produce the event, and the account would sit locked with nothing having announced it. - `on_refresh_token_reuse_detected` is the strongest evidence of compromise the library produces — a token already exchanged has been presented again, so one of its two holders is not the owner. That last one had nothing to name its victim with. The replayed token's own `rt:` key is deleted when it is rotated, so by the time the replay is caught the family index is the only surviving link to an account — and `revoke_family` already reads a member record to find the session index it prunes. It now returns that owner (`Result, AuthError>`), and the event fires on both the dashboard and the platform plane. Implementors of `SessionStore` must widen the return type; `Ok(None)` preserves the previous behaviour. All three hooks are fire-and-forget: a failing hook is logged and dropped, and the refusal the caller receives is unchanged. --- CHANGELOG.md | 26 ++ crates/bymax-auth-axum/tests/adapter.rs | 21 ++ crates/bymax-auth-axum/tests/common/mod.rs | 2 +- crates/bymax-auth-core/src/engine/builder.rs | 3 +- .../src/services/adapter_api.rs | 23 +- .../src/services/auth/login.rs | 337 +++++++++++++++++- .../bymax-auth-core/src/services/session.rs | 4 +- .../src/services/token_manager.rs | 222 +++++++++++- crates/bymax-auth-core/src/testing/mod.rs | 24 +- crates/bymax-auth-core/src/traits/hooks.rs | 123 +++++++ crates/bymax-auth-core/src/traits/mod.rs | 4 +- crates/bymax-auth-core/src/traits/store.rs | 12 +- crates/bymax-auth-redis/src/stores/session.rs | 33 +- 13 files changed, 791 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1def62..a294448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ version bump. ### Added +- **Failure-side hooks: `on_login_failed`, `on_lockout`, `on_refresh_token_reuse_detected`** + (`crates/bymax-auth-core/src/traits/hooks.rs`). Every existing hook fired on a success + path, which left the failure side of authentication with no structured seam at all: a + burst of wrong passwords, an account tripping its lockout, and a stolen refresh token + being replayed existed only as English log lines whose wording is not a contract and whose + change is not semver-visible. ASVS v5 §16.3.1 expects authentication operations to be + logged with their outcome and §6.1.1 an *adaptive* response, which needs a signal to adapt + to. `on_login_failed` carries a `LoginFailureReason` and — only when the address resolved — + the user id, so a consumer can tell "someone is guessing at this account" from "someone is + spraying addresses", a distinction the uniform `InvalidCredentials` response deliberately + hides from the caller but not from the deployment. `on_lockout` fires on the attempt that + **crosses** the threshold, not the next one: an attacker who trips the lock and walks away + would otherwise never produce the event. All three are fire-and-forget — a hook that fails + is logged and dropped, and the refusal the caller receives is unchanged. + + - Initial workspace scaffolding: the Cargo workspace, the facade and internal crate skeletons, the WASM edge binding, the npm package stub, the pinned toolchain and lint posture, the supply-chain policy (`cargo-deny` / `cargo-vet`), @@ -311,6 +327,16 @@ version bump. ### Changed +- **`SessionStore::revoke_family` now returns the account the family belonged to** + (`Result, AuthError>`). Reuse detection had no way to name its victim: the + replayed token's own `rt:` key is deleted when it is rotated, so by the time the replay is + caught the family index is the only surviving link between that token and an account — and + the revocation already reads a member record to find the session index it prunes. Returning + what it found there turns the strongest compromise signal the library produces from an + anonymous log line into an attributable event. Implementors of the trait must widen the + return type; returning `Ok(None)` preserves the previous behaviour. + + - **Family-lineage reuse detection replaces the previous sentinel.** A login opens a family; every rotation inherits it; a replay past the grace window revokes that lineage and only that lineage. `revoke_family` prunes the **prefixed** index diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 073cacf..59b5dbd 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -1013,6 +1013,27 @@ async fn sessions_list_revoke_one_and_revoke_all() { .await; assert_eq!(revoke_one.status, StatusCode::NO_CONTENT); + // Revoking ONE session advances the epoch for the same reason revoking all of them does: + // deleting the refresh session stops rotation but says nothing about the access token that + // device is already carrying, and someone revoking a device they believe is compromised is + // deciding about right now. The caller's own token goes stale too and is re-minted on the + // next rotation — which the shipped client does silently, and which this test does by hand. + let stale_again = Req::get("/auth/sessions") + .cookie("access_token", &access) + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(stale_again.status, StatusCode::UNAUTHORIZED); + let rotated = Req::post("/auth/refresh") + .cookie("refresh_token", &refresh) + .send(&app) + .await; + assert_eq!(rotated.status, StatusCode::OK); + let access = rotated + .cookie_value("access_token") + .unwrap_or_default() + .to_owned(); + // A blocked status fails the `UserStatus` gate on the sessions list. let banned_id = reg.json()["user"]["id"] .as_str() diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 41da30e..c0270f4 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -469,7 +469,7 @@ impl bymax_auth_core::traits::SessionStore for FailingStores { &self, kind: bymax_auth_core::traits::SessionKind, family_id: &str, - ) -> Result<(), bymax_auth_types::AuthError> { + ) -> Result, bymax_auth_types::AuthError> { self.inner.revoke_family(kind, family_id).await } async fn blacklist_access( diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 48c89cb..f06f1b0 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -425,7 +425,8 @@ impl AuthEngineBuilder { refresh_days, grace_window, absolute_session_lifetime_days, - ); + ) + .with_hooks(hooks.clone()); // Wire the MFA temp-token single-use support when an MFA store is supplied, so the // challenge token planted at login is store-backed and brute-force-capped. #[cfg(feature = "mfa")] diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index edb71ed..673ba0a 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -137,7 +137,28 @@ impl AuthEngine { user_id: &str, session_hash: &str, ) -> Result<(), AuthError> { - self.sessions().revoke_session(user_id, session_hash).await + self.sessions() + .revoke_session(user_id, session_hash) + .await?; + // …and cut the access tokens with it. Deleting the refresh session stops rotation but + // says nothing about the stateless access token that session's holder is already + // carrying, and that token keeps working until it expires — up to whatever + // `access_expires_in` allows. Someone who opens their session list and revokes a device + // does so because they think it is compromised: a decision about *right now*. + // + // The epoch is the only lever available, since a session hash does not name the `jti` + // of any access token. The collateral is that the account's other devices lose their + // access tokens too and silently re-mint one on their next rotation — they still hold + // live refresh sessions. The revoked device cannot, which is the point. + // + // Bumped AFTER the revoke: a failure above then leaves the epoch untouched and the + // operation visibly incomplete, rather than signing every device out for a session that + // is in fact still alive. `logout` keeps the plain revoke — it blacklists its own `jti` + // by name, and ending one session must not reach every other device's access token. + self.session_store() + .bump_epoch(crate::traits::SessionKind::Dashboard, user_id) + .await?; + Ok(()) } /// Revoke every session for the caller except the current one (`DELETE /auth/sessions/all`). diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index a0eaa69..acf36d9 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -18,7 +18,7 @@ use crate::services::auth::detached::{ }; use crate::services::auth::{LoginInput, map_repository_error, normalize_anti_enum, spawn_guarded}; use crate::status_gate::assert_not_blocked; -use crate::traits::HookContext; +use crate::traits::{HookContext, LoginFailure, LoginFailureReason}; impl AuthEngine { /// Authenticate email + password, returning either a full session or an MFA challenge. @@ -46,21 +46,29 @@ impl AuthEngine { let tenant_id = self.resolve_tenant(&input.tenant_id, ctx).await?; let identifier = self.hashed_identifier(&tenant_id, &input.email); + let hook_ctx = HookContext::from_request( + ctx, + None, + Some(input.email.clone()), + Some(tenant_id.clone()), + ); + // Brute-force gate first (so an already-locked account never increments again). if let Err(error) = self.assert_not_locked(&identifier).await { // Kept on one line on purpose: a `tracing` field expression on its own line is // never evaluated without an installed subscriber, so it would read as an // uncovered line under the 100% gate while being perfectly exercised. tracing::warn!(email = %mask_email(&input.email), %tenant_id, "login: account locked"); + self.fire_login_failed( + &input.email, + &tenant_id, + None, + LoginFailureReason::LockedOut, + &hook_ctx, + ) + .await; return Err(error); } - - let hook_ctx = HookContext::from_request( - ctx, - None, - Some(input.email.clone()), - Some(tenant_id.clone()), - ); self.hooks() .before_login(&input.email, &tenant_id, &hook_ctx) .await @@ -79,14 +87,41 @@ impl AuthEngine { // the KDF cost is paid either way, then record the failure and return generically. let Some(user) = user.filter(has_local_hash) else { self.passwords().verify_sentinel(&input.password).await?; - return self.record_failure_and_reject(&identifier, started).await; + return self + .record_failure_and_reject( + &identifier, + started, + &input.email, + &tenant_id, + None, + &hook_ctx, + ) + .await; }; // Status gate runs before the KDF so a blocked account never consumes hashing CPU. - self.assert_user_not_blocked(&user.status)?; + if let Err(error) = self.assert_user_not_blocked(&user.status) { + self.fire_login_failed( + &input.email, + &tenant_id, + Some(&user.id), + LoginFailureReason::AccountBlocked, + &hook_ctx, + ) + .await; + return Err(error); + } // Email-verification gate. if config.email_verification.required && !user.email_verified { + self.fire_login_failed( + &input.email, + &tenant_id, + Some(&user.id), + LoginFailureReason::EmailNotVerified, + &hook_ctx, + ) + .await; return Err(AuthError::EmailNotVerified); } @@ -94,7 +129,16 @@ impl AuthEngine { let phc = user.password_hash.clone().unwrap_or_default(); let outcome = self.passwords().verify(&input.password, &phc).await?; if !outcome.matched { - return self.record_failure_and_reject(&identifier, started).await; + return self + .record_failure_and_reject( + &identifier, + started, + &input.email, + &tenant_id, + Some(&user.id), + &hook_ctx, + ) + .await; } // Password proven: clear the failure counter. @@ -142,13 +186,92 @@ impl AuthEngine { &self, identifier: &str, started: Instant, + email: &str, + tenant_id: &str, + user_id: Option<&str>, + hook_ctx: &HookContext, ) -> Result { tracing::warn!("login: invalid credentials"); self.brute_force().record_failure(identifier).await?; + self.fire_login_failed( + email, + tenant_id, + user_id, + LoginFailureReason::InvalidCredentials, + hook_ctx, + ) + .await; + // Read the lock AFTER recording, so the event fires on the attempt that crosses the + // threshold rather than on the next one — an attacker who trips the lock and walks + // away would otherwise never produce it. + self.fire_lockout_if_crossed(identifier, email, tenant_id, hook_ctx) + .await; normalize_anti_enum(started).await; Err(AuthError::InvalidCredentials) } + /// Fire the fire-and-forget [`AuthHooks::on_login_failed`] hook. + /// + /// Swallowed like every other notification hook: a consumer's SIEM being unreachable is + /// not an authentication decision, and the refusal the caller receives is unchanged. + /// + /// [`AuthHooks::on_login_failed`]: crate::traits::AuthHooks::on_login_failed + async fn fire_login_failed( + &self, + email: &str, + tenant_id: &str, + user_id: Option<&str>, + reason: LoginFailureReason, + hook_ctx: &HookContext, + ) { + let failure = LoginFailure { + email, + tenant_id, + user_id, + reason, + }; + if let Err(error) = self.hooks().on_login_failed(&failure, hook_ctx).await { + tracing::error!(%error, "login: on_login_failed hook returned an error (ignored)"); + } + } + + /// Fire [`AuthHooks::on_lockout`] when the failure just recorded closed the window. + /// + /// A store error here is swallowed too: it means the *hook* could not be decided, not + /// that the login should answer differently. + /// + /// [`AuthHooks::on_lockout`]: crate::traits::AuthHooks::on_lockout + async fn fire_lockout_if_crossed( + &self, + identifier: &str, + email: &str, + tenant_id: &str, + hook_ctx: &HookContext, + ) { + let crossed = match self.brute_force().is_locked(identifier).await { + Ok(locked) => locked, + Err(error) => { + tracing::error!(%error, "login: could not read the lockout state for the hook"); + return; + } + }; + if !crossed { + return; + } + let retry = self + .brute_force() + .remaining_lockout_secs(identifier) + .await + .unwrap_or(0); + if let Err(error) = self + .hooks() + .on_lockout(email, tenant_id, retry, hook_ctx) + .await + { + tracing::error!(%error, "login: on_lockout hook returned an error (ignored)"); + } + } + /// Reject the login when the identifier is already locked out, surfacing the retry hint. /// /// # Errors @@ -224,6 +347,7 @@ mod tests { use super::*; use crate::services::auth::test_support::{Harness, SeedUser, base_config, ctx, harness}; use crate::traits::UserRepository; + use std::sync::Arc; use std::time::Duration; fn login_input(email: &str, password: &str) -> LoginInput { @@ -681,6 +805,197 @@ mod tests { } } + /// The failure-side hook surface, recorded in order. + #[derive(Default)] + struct FailureSpy { + calls: std::sync::Mutex>, + } + + impl FailureSpy { + fn seen(&self) -> Vec { + self.calls.lock().map(|c| c.clone()).unwrap_or_default() + } + } + + #[async_trait::async_trait] + impl crate::traits::AuthHooks for FailureSpy { + async fn on_login_failed( + &self, + failure: &LoginFailure<'_>, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + if let Ok(mut calls) = self.calls.lock() { + calls.push(format!( + "failed:{}:{}:{}", + failure.reason, + failure.email, + failure.user_id.unwrap_or("-") + )); + } + Ok(()) + } + async fn on_lockout( + &self, + email: &str, + _tenant_id: &str, + retry_after_seconds: u64, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + if let Ok(mut calls) = self.calls.lock() { + calls.push(format!("lockout:{email}:{retry_after_seconds}")); + } + Ok(()) + } + } + + /// Hooks that fail on every failure-side callback, to prove the refusal is unchanged. + struct BrokenFailureHooks; + + #[async_trait::async_trait] + impl crate::traits::AuthHooks for BrokenFailureHooks { + async fn on_login_failed( + &self, + _failure: &LoginFailure<'_>, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + Err(crate::traits::HookError::Rejected( + "siem unreachable".to_owned(), + )) + } + async fn on_lockout( + &self, + _email: &str, + _tenant_id: &str, + _retry: u64, + _ctx: &HookContext, + ) -> Result<(), crate::traits::HookError> { + Err(crate::traits::HookError::Rejected( + "siem unreachable".to_owned(), + )) + } + } + + #[tokio::test] + async fn every_refusal_reaches_the_failure_hook_with_its_own_reason() { + // The whole point of the hook is that the four refusals are DISTINGUISHABLE to the + // deployment while staying uniform to the caller. An unknown address carries no user + // id; a wrong password against a real account carries one — that is what separates + // "someone is guessing at this account" from "someone is spraying addresses". + let spy = Arc::new(FailureSpy::default()); + let mut cfg = base_config(); + cfg.email_verification.required = true; + let Some(h) = harness(cfg, Some(spy.clone())) else { + return; + }; + let known = h.seed(SeedUser::active("known@example.com", "right")).await; + let blocked = h + .seed(SeedUser { + status: "SUSPENDED".to_owned(), + ..SeedUser::active("blocked@example.com", "right") + }) + .await; + let pending = h + .seed(SeedUser { + email_verified: false, + ..SeedUser::active("pending@example.com", "right") + }) + .await; + + for (email, password) in [ + ("nobody@example.com", "whatever"), + ("known@example.com", "wrong"), + ("blocked@example.com", "right"), + ("pending@example.com", "right"), + ] { + let _ = h.engine.login(login_input(email, password), &ctx()).await; + } + + assert_eq!( + spy.seen(), + vec![ + "failed:invalid_credentials:nobody@example.com:-".to_owned(), + format!("failed:invalid_credentials:known@example.com:{known}"), + format!("failed:account_blocked:blocked@example.com:{blocked}"), + format!("failed:email_not_verified:pending@example.com:{pending}"), + ] + ); + } + + #[tokio::test] + async fn the_lockout_hook_fires_on_the_attempt_that_crosses_the_threshold() { + // Not on the next one. An attacker who trips the lock and walks away never makes a + // sixth attempt, so a hook fired from the already-locked gate would never run and the + // account would sit locked with nothing having announced it. The fifth failure — the + // one that closes the window — is where the event belongs. + let spy = Arc::new(FailureSpy::default()); + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, Some(spy.clone())) else { + return; + }; + let _ = h.seed(SeedUser::active("lock@example.com", "right")).await; + + for _ in 0..5 { + let _ = h + .engine + .login(login_input("lock@example.com", "wrong"), &ctx()) + .await; + } + + let lockouts: Vec = spy + .seen() + .into_iter() + .filter(|call| call.starts_with("lockout:")) + .collect(); + assert_eq!(lockouts.len(), 1, "exactly one lockout event: {lockouts:?}"); + // The retry hint is the remaining window, not a placeholder. + let retry: u64 = lockouts[0] + .rsplit(':') + .next() + .unwrap_or("0") + .parse() + .unwrap_or(0); + assert!(retry > 0, "the lockout carried no retry hint: {lockouts:?}"); + + // …and the sixth attempt, refused at the gate before any credential check, reports + // itself as `locked_out` rather than as another credential failure. + let _ = h + .engine + .login(login_input("lock@example.com", "right"), &ctx()) + .await; + assert_eq!( + spy.seen().last().map(String::as_str), + Some("failed:locked_out:lock@example.com:-") + ); + } + + #[tokio::test] + async fn a_failing_failure_hook_never_changes_the_refusal() { + // A consumer's SIEM being unreachable is not an authentication decision. The refusal + // is still a refusal, and the lockout still locks. + let mut cfg = base_config(); + cfg.email_verification.required = false; + let Some(h) = harness(cfg, Some(Arc::new(BrokenFailureHooks))) else { + return; + }; + let _ = h + .seed(SeedUser::active("broken@example.com", "right")) + .await; + + for _ in 0..5 { + let attempt = h + .engine + .login(login_input("broken@example.com", "wrong"), &ctx()) + .await; + assert!(matches!(attempt, Err(AuthError::InvalidCredentials))); + } + let locked = h + .engine + .login(login_input("broken@example.com", "right"), &ctx()) + .await; + assert!(matches!(locked, Err(AuthError::AccountLocked { .. }))); + } + #[test] fn has_local_hash_reflects_password_presence() { // The predicate used by the login filter is true only for a stored local hash. diff --git a/crates/bymax-auth-core/src/services/session.rs b/crates/bymax-auth-core/src/services/session.rs index 2e93ae7..bc73b4b 100644 --- a/crates/bymax-auth-core/src/services/session.rs +++ b/crates/bymax-auth-core/src/services/session.rs @@ -1192,8 +1192,8 @@ mod tests { &self, _kind: SessionKind, _family_id: &str, - ) -> Result<(), AuthError> { - Ok(()) + ) -> Result, AuthError> { + Ok(None) } async fn blacklist_access( &self, diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 7d76686..c604307 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -21,7 +21,10 @@ use bymax_auth_types::{PlatformAuthResult, PlatformClaims, PlatformType, SafeAut use crate::services::session::normalize_session_metadata; use crate::services::{internal_error, is_refresh_token_shape, new_uuid_v4, now_offset, now_unix}; -use crate::traits::{RotateOutcome, SessionKind, SessionRecord, SessionRotation, SessionStore}; +use crate::traits::{ + AuthHooks, HookContext, RotateOutcome, SessionKind, SessionRecord, SessionRotation, + SessionStore, +}; /// MFA temp-token lifetime, in seconds (§7.3 constant `MFA_TEMP_TOKEN_TTL_SECONDS`). const MFA_TEMP_TOKEN_TTL_SECONDS: i64 = 300; @@ -82,6 +85,12 @@ pub struct TokenManagerService { refresh_ttl_secs: u64, grace_ttl_secs: u64, absolute_lifetime_secs: u64, + /// The consumer's hooks, and the only reason this otherwise dependency-light service + /// knows about them: refresh-token reuse is detected here and nowhere else, and it is the + /// strongest evidence of compromise the library produces. Routing it out through the + /// error would lose the family id, and losing it leaves a consumer with nothing to + /// correlate against — every replay would look like any other invalid token. + hooks: Arc, /// The MFA single-use temp-token support, wired only when an MFA store is supplied. #[cfg(feature = "mfa")] mfa: Option, @@ -194,11 +203,23 @@ impl TokenManagerService { refresh_ttl_secs: u64::from(refresh_expires_in_days) * 86_400, grace_ttl_secs: grace_window.as_secs(), absolute_lifetime_secs: u64::from(absolute_session_lifetime_days) * 86_400, + hooks: Arc::new(crate::traits::NoOpAuthHooks), #[cfg(feature = "mfa")] mfa: None, } } + /// Install the consumer's hooks, so reuse detection can report itself. + /// + /// Separate from [`Self::new`] rather than a parameter, because the hooks are defaulted + /// late in the builder (after the OAuth wiring check) and every other caller — the tests + /// included — has no interest in them. + #[must_use] + pub(crate) fn with_hooks(mut self, hooks: Arc) -> Self { + self.hooks = hooks; + self + } + /// Attach the MFA temp-token support (the single-use `mfa:` marker store and the /// brute-force counter reset), enabling the store-backed single-use challenge path. Set by /// the builder when an MFA store is wired. @@ -391,9 +412,14 @@ impl TokenManagerService { tracing::warn!( "refresh: reuse of a consumed refresh token detected — revoking the token family" ); - self.session_store + // The owner comes back from the revocation, and can come from nowhere + // else: the replayed token's own key was deleted when it was rotated, so + // the family index is the last surviving link to an account. + let owner = self + .session_store .revoke_family(SessionKind::Dashboard, &family) .await?; + self.fire_reuse_detected(owner.as_deref(), &family).await; Err(AuthError::RefreshTokenInvalid) } RotateOutcome::Invalid => { @@ -581,9 +607,14 @@ impl TokenManagerService { tracing::warn!( "platform refresh: reuse of a consumed refresh token detected — revoking the token family" ); - self.session_store + // The owner comes back from the revocation, and can come from nowhere + // else: the replayed token's own key was deleted when it was rotated, so + // the family index is the last surviving link to an account. + let owner = self + .session_store .revoke_family(SessionKind::Platform, &family) .await?; + self.fire_reuse_detected(owner.as_deref(), &family).await; Err(AuthError::RefreshTokenInvalid) } RotateOutcome::Invalid => { @@ -825,6 +856,28 @@ impl TokenManagerService { support.store.del_temp(&jti_hash(jti)).await } + /// Fire the fire-and-forget [`AuthHooks::on_refresh_token_reuse_detected`] hook. + /// + /// Skipped when the owner is unknown: a replay of a token whose live key is already gone + /// leaves nothing to read, and an event naming no account is worse than no event — a + /// consumer would have to treat it as unattributable noise. + /// + /// [`AuthHooks::on_refresh_token_reuse_detected`]: + /// crate::traits::AuthHooks::on_refresh_token_reuse_detected + async fn fire_reuse_detected(&self, user_id: Option<&str>, family_id: &str) { + let Some(user_id) = user_id else { return }; + // The rotation carries no request context of its own — the identity fields are what + // the hook itself already names. + let ctx = HookContext::detached(user_id); + if let Err(error) = self + .hooks + .on_refresh_token_reuse_detected(user_id, family_id, &ctx) + .await + { + tracing::error!(%error, "refresh: reuse hook returned an error (ignored)"); + } + } + /// Refuse a rotation once the login it descends from has outlived the absolute cap. /// /// `refresh_expires_in_days` bounds a single refresh token, not a session: a client @@ -1142,6 +1195,169 @@ mod tests { )); } + /// Records the reuse events the rotation reports. + #[derive(Default)] + struct ReuseSpy { + calls: std::sync::Mutex>, + } + + #[async_trait::async_trait] + impl crate::traits::AuthHooks for ReuseSpy { + async fn on_refresh_token_reuse_detected( + &self, + user_id: &str, + family_id: &str, + ctx: &crate::traits::HookContext, + ) -> Result<(), crate::traits::HookError> { + if let Ok(mut calls) = self.calls.lock() { + // The context names the account and nothing it never observed: an empty `ip` + // is honest about a rotation carrying no request, where a placeholder would + // read to a consumer as an address someone actually connected from. + calls.push(format!( + "{user_id}:{family_id}:{}:{}", + ctx.user_id.clone().unwrap_or_default(), + ctx.ip + )); + } + Ok(()) + } + } + + #[tokio::test] + async fn a_detected_reuse_reports_the_owner_it_recovered_from_the_family() { + // The replayed token's own key was deleted when it was rotated, so nothing about the + // token still names an account — the family index is the only surviving link. Without + // it the event would fire anonymously, which is worse than not firing: a consumer + // cannot act on a takeover signal that names no victim. + let spy = Arc::new(ReuseSpy::default()); + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()).with_hooks(spy.clone()); + let Ok(issued) = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + let old_hash = RawRefreshToken::from_raw(issued.refresh_token.clone()).redis_hash(); + let Ok(_rotated) = svc + .reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await + else { + return; + }; + assert!( + store + .delete_grace_pointer(SessionKind::Dashboard, &old_hash) + .await + .is_ok() + ); + + assert!(matches!( + svc.reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + + let seen = spy.calls.lock().map(|c| c.clone()).unwrap_or_default(); + assert_eq!(seen.len(), 1, "exactly one reuse event: {seen:?}"); + let owner = user().id; + assert!( + seen[0].starts_with(&format!("{owner}:")), + "the event named no owner: {seen:?}" + ); + // The family id is carried through, and the detached context repeats the owner while + // inventing no request fields. + assert!(seen[0].ends_with(&format!(":{owner}:")), "{seen:?}"); + } + + #[tokio::test] + async fn a_reuse_with_no_recoverable_owner_fires_no_event() { + // A family whose every member has expired names nobody. The refusal is unchanged and + // the hook stays silent rather than emitting an unattributable alert. + let spy = Arc::new(ReuseSpy::default()); + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()).with_hooks(spy.clone()); + let Ok(issued) = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + let old_hash = RawRefreshToken::from_raw(issued.refresh_token.clone()).redis_hash(); + let Ok(rotated) = svc + .reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await + else { + return; + }; + assert!( + store + .delete_grace_pointer(SessionKind::Dashboard, &old_hash) + .await + .is_ok() + ); + // Drop the only live descendant, so the family index survives with nothing readable. + assert!( + store + .revoke_session(SessionKind::Dashboard, &user().id, &rotated_hash(&rotated)) + .await + .is_ok() + ); + + assert!(matches!( + svc.reissue_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await, + Err(AuthError::RefreshTokenInvalid) + )); + + assert!( + spy.calls.lock().map(|c| c.is_empty()).unwrap_or(false), + "an unattributable reuse event was emitted" + ); + } + + #[cfg(feature = "platform")] + #[tokio::test] + async fn a_replayed_platform_token_reports_reuse_too() { + // The plane that usually carries more authority. An operator watching for takeover + // must not be blind on it. + let spy = Arc::new(ReuseSpy::default()); + let store = Arc::new(InMemoryStores::new()); + let svc = service(store.clone()).with_hooks(spy.clone()); + let Ok(issued) = svc + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + let old_hash = RawRefreshToken::from_raw(issued.refresh_token.clone()).redis_hash(); + let Ok(_rotated) = svc + .reissue_platform_tokens(&issued.refresh_token, "10.0.0.1", "agent/1.0") + .await + 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) + )); + + let seen = spy.calls.lock().map(|c| c.clone()).unwrap_or_default(); + assert_eq!(seen.len(), 1, "exactly one platform reuse event: {seen:?}"); + assert!( + seen[0].starts_with(&format!("{}:", platform_admin().id)), + "{seen:?}" + ); + } + /// 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() diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index b4b5534..b308b3c 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -592,26 +592,34 @@ impl SessionStore for InMemoryStores { Ok(()) } - async fn revoke_family(&self, kind: SessionKind, family_id: &str) -> Result<(), AuthError> { + 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(()); + return Ok(None); } let Some(hashes) = lock(&self.families).remove(&(kind, family_id.to_owned())) else { - return Ok(()); + return Ok(None); }; let mut sessions = lock(&self.sessions); let mut index = lock(&self.session_index); + let mut owner = None; 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); + if let Some(record) = sessions.remove(&(kind, hash.clone())) { + if owner.is_none() && !record.user_id.is_empty() { + owner = Some(record.user_id.clone()); + } + if let Some(details) = index.get_mut(&(kind, record.user_id.clone())) { + details.retain(|detail| detail.session_hash != hash); + } } } - Ok(()) + Ok(owner) } async fn blacklist_access( diff --git a/crates/bymax-auth-core/src/traits/hooks.rs b/crates/bymax-auth-core/src/traits/hooks.rs index 7c3d157..4a8e86c 100644 --- a/crates/bymax-auth-core/src/traits/hooks.rs +++ b/crates/bymax-auth-core/src/traits/hooks.rs @@ -46,7 +46,79 @@ pub struct HookContext { pub sanitized_headers: BTreeMap, } +/// Why an authentication attempt was refused, as reported to [`AuthHooks::on_login_failed`]. +/// +/// Kept separate from `AuthError` on purpose: the error is what the *caller* is told, and it +/// is deliberately uniform across the credential paths. This is what the *deployment* is +/// told, and it is allowed to be specific. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoginFailureReason { + /// Unknown address, an account with no local password, or a wrong password. One variant + /// for all three, because distinguishing them is exactly what anti-enumeration forbids + /// the response from doing — and the hook already carries `user_id` for the accounts + /// that resolved. + InvalidCredentials, + /// The account exists but its status forbids signing in. + AccountBlocked, + /// Verification is required and still pending. + EmailNotVerified, + /// The identifier was already locked out when the attempt arrived, so no credential was + /// even checked. + LockedOut, +} + +impl LoginFailureReason { + /// The stable machine-readable spelling, for a consumer writing the reason into a log + /// line or an event payload. Guaranteed not to change with a phrasing edit. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidCredentials => "invalid_credentials", + Self::AccountBlocked => "account_blocked", + Self::EmailNotVerified => "email_not_verified", + Self::LockedOut => "locked_out", + } + } +} + +impl std::fmt::Display for LoginFailureReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The payload of a refused authentication attempt. +#[derive(Clone, Copy, Debug)] +pub struct LoginFailure<'a> { + /// The normalized address the attempt was made against. + pub email: &'a str, + /// The tenant the attempt was resolved into. + pub tenant_id: &'a str, + /// The account the address resolved to, absent when it resolved to nothing. + pub user_id: Option<&'a str>, + /// Why it was refused. + pub reason: LoginFailureReason, +} + impl HookContext { + /// A context for an event that has no request behind it. + /// + /// Refresh-token reuse is detected inside the rotation, which carries no + /// [`RequestContext`]: the fields that would come from one are absent rather than + /// invented, so a consumer reading an empty `ip` knows it was never observed instead of + /// trusting a placeholder that looks like an address. + #[must_use] + pub fn detached(user_id: &str) -> Self { + Self { + user_id: Some(user_id.to_owned()), + email: None, + tenant_id: None, + ip: String::new(), + user_agent: String::new(), + sanitized_headers: BTreeMap::new(), + } + } + /// Build a hook context from a [`RequestContext`] plus the identity fields known at /// the call site. The sanitized headers are copied across from the request context. #[must_use] @@ -279,6 +351,57 @@ pub trait AuthHooks: Send + Sync { Ok(()) } + /// Called when an authentication attempt is refused, whatever the reason. + /// + /// The single seam for the failure side of authentication. `user_id` is present only + /// when the address resolved to an account, which is what lets a consumer tell "someone + /// is guessing at this account" from "someone is spraying addresses" — a distinction the + /// uniform [`AuthError::InvalidCredentials`] response deliberately hides from the + /// caller, but not from the deployment defending itself. + /// + /// [`AuthError::InvalidCredentials`]: bymax_auth_types::AuthError::InvalidCredentials + async fn on_login_failed( + &self, + failure: &LoginFailure<'_>, + ctx: &HookContext, + ) -> Result<(), HookError> { + let _ = (failure, ctx); + Ok(()) + } + + /// Called on the attempt that **crosses** the brute-force threshold, not the next one. + /// + /// An attacker who trips the lock and walks away would otherwise never produce the + /// event, and the account would sit locked with nothing having announced it. + /// `retry_after_seconds` is the remaining window at the moment of the lockout. + async fn on_lockout( + &self, + email: &str, + tenant_id: &str, + retry_after_seconds: u64, + ctx: &HookContext, + ) -> Result<(), HookError> { + let _ = (email, tenant_id, retry_after_seconds, ctx); + Ok(()) + } + + /// Called when a refresh token already exchanged is presented again after its grace + /// window closed. + /// + /// The strongest evidence of compromise the library produces: one of that token's two + /// holders is not the owner. The engine has already revoked the whole family by the time + /// this fires — the hook is for the consumer's own response (alert, forced re-verify, + /// support ticket), not for the decision. + async fn on_refresh_token_reuse_detected( + &self, + user_id: &str, + family_id: &str, + ctx: &HookContext, + ) -> Result<(), HookError> { + let _ = (user_id, family_id, ctx); + Ok(()) + } + /// Called when the session manager evicts a session to make room for a new one (FIFO). /// `evicted_session_hash` is the stored hash, never the raw token. async fn on_session_evicted( diff --git a/crates/bymax-auth-core/src/traits/mod.rs b/crates/bymax-auth-core/src/traits/mod.rs index 006b2da..a37624f 100644 --- a/crates/bymax-auth-core/src/traits/mod.rs +++ b/crates/bymax-auth-core/src/traits/mod.rs @@ -23,8 +23,8 @@ pub use common_password::{CommonPasswordChecker, reduce_to_base_word}; pub use email::{EmailError, EmailProvider, InviteData, NoOpEmailProvider, SessionInfo}; #[doc(inline)] pub use hooks::{ - AuthHooks, BeforeRegisterResult, HookContext, HookError, NoOpAuthHooks, OAuthLoginResult, - RegisterAttempt, RegisterOverrides, + AuthHooks, BeforeRegisterResult, HookContext, HookError, LoginFailure, LoginFailureReason, + NoOpAuthHooks, OAuthLoginResult, RegisterAttempt, RegisterOverrides, }; #[doc(inline)] pub use http::{HttpClient, HttpError, HttpMethod, HttpRequest, HttpResponse}; diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index e9595e0..b754382 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -377,7 +377,17 @@ pub trait SessionStore: Send + Sync { /// 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>; + /// + /// Returns the id of the account the family belonged to, or `None` when no member record + /// was readable. The owner is reported because the reuse-detection caller cannot obtain it + /// any other way: the replayed token's own `rt:` key was deleted when it was rotated, so + /// the family index is the only surviving link between that token and an account — and an + /// implementation already has to read a member to find the session index it prunes. + 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. diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index 9fbca64..a201447 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -308,19 +308,22 @@ impl RedisStores { &self, kind: SessionKind, family_id: &str, - ) -> Result<(), RedisStoreError> { + ) -> Result, RedisStoreError> { // An empty family id has no index key; nothing to revoke. if family_id.is_empty() { - return Ok(()); + return Ok(None); } let prefixes = kind_prefixes(kind); let keys = self.keys(); let fam_key = keys.key(prefixes.fam, family_id); let mut conn = self.connection().await?; let members: Vec = conn.smembers(&fam_key).await?; - let owner_index = self - .resolve_family_owner_index(&mut conn, &prefixes, &members) + let owner = self + .resolve_family_owner(&mut conn, &prefixes, &members) .await?; + let owner_index = owner + .as_ref() + .map_or_else(String::new, |id| keys.key(prefixes.sess, id)); script::REVOKE_FAMILY .prepare() .key(&fam_key) @@ -330,18 +333,18 @@ impl RedisStores { .arg(&owner_index) .invoke_async::(&mut conn) .await?; - Ok(()) + Ok(owner) } - /// Resolve the namespaced session-index key of the user a family belongs to, or an empty - /// string when no member record is readable — every member may have already expired, in - /// which case there is no index left to prune. - async fn resolve_family_owner_index( + /// Resolve the id of the user a family belongs to, or `None` when no member record is + /// readable — every member may have already expired, in which case there is no index left + /// to prune and nobody left to name. + async fn resolve_family_owner( &self, conn: &mut Connection, prefixes: &KindPrefixes, members: &[String], - ) -> Result { + ) -> Result, RedisStoreError> { let keys = self.keys(); for hash in members { let raw: Option = conn.get(keys.key(prefixes.rt, hash)).await?; @@ -350,10 +353,10 @@ impl RedisStores { continue; }; if !record.user_id.is_empty() { - return Ok(keys.key(prefixes.sess, &record.user_id)); + return Ok(Some(record.user_id)); } } - Ok(String::new()) + Ok(None) } /// Move the session-index membership and detail from the old hash to the new hash after a @@ -689,7 +692,11 @@ impl SessionStore for RedisStores { .map_err(AuthError::from) } - async fn revoke_family(&self, kind: SessionKind, family_id: &str) -> Result<(), AuthError> { + async fn revoke_family( + &self, + kind: SessionKind, + family_id: &str, + ) -> Result, AuthError> { self.revoke_family_inner(kind, family_id) .await .map_err(AuthError::from) From 19c1d66a1c00a0cf9aa2245066fb5f8b8916ae78 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:12:57 -0300 Subject: [PATCH 111/122] feat(core): make a pending invitation withdrawable, and a lockout clearable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invitation provisions an account, at a role, inside a tenant, to whoever holds the link — a credential in every sense — and once sent it stayed redeemable for its whole TTL with nothing an operator could do about it. A link sent to the wrong address was unrecoverable. Nothing on the issuing side could even NAME a pending invitation: the record is keyed by the hash of a token only the invitee's mailbox ever held. So the withdrawal needs an index. `invidx:{tenantId}:{sha256(email)}` carries the invitation's TTL and points at its record; the email is hashed so a dump of the keyspace does not enumerate who a tenant has been inviting, which the record itself never exposed either. `InvitationStore` grows five methods for it — implementors must supply them. Re-inviting an address now supersedes the previous invitation through that index rather than adding a second live token: two tokens for one invitee is two chances for an intercepted link to be redeemed, and a revoke would only ever have reached the newest. The revoker is held to the same bar as the issuer — in the tenant, in good standing, out-ranking the role being granted, read from the invitation itself since the caller names an address rather than a role. The route answers 204 whether or not anything was pending, or it would be an oracle for which addresses have invitations. `unlock_account` closes the matching gap on the other side. A lockout is a denial of service the library imposes on its own users and it could only be waited out: the counter is keyed by an HMAC no host can derive, so nobody could answer "I am locked out and I need in now" — nor undo an attacker deliberately locking one account out of its own service. It grants no access; the password, the status gate, the verification gate and MFA all still apply. No adapter route ships with it, because who may unlock whom is the application's decision. --- CHANGELOG.md | 27 ++ README.md | 1 + conformance/wire-contract.json | 20 +- crates/bymax-auth-axum/src/dto.rs | 14 + crates/bymax-auth-axum/src/rate_limit.rs | 6 +- .../bymax-auth-axum/src/routes/invitations.rs | 29 +- crates/bymax-auth-axum/tests/adapter.rs | 25 ++ crates/bymax-auth-axum/tests/common/mod.rs | 38 +++ crates/bymax-auth-axum/tests/redis_e2e.rs | 33 ++ .../src/services/auth/invitation.rs | 289 ++++++++++++++++++ .../src/services/auth/login.rs | 76 +++++ crates/bymax-auth-core/src/testing/mod.rs | 47 +++ crates/bymax-auth-core/src/traits/store.rs | 40 +++ crates/bymax-auth-redis/src/keys.rs | 6 + .../bymax-auth-redis/src/stores/single_use.rs | 84 +++++ docs/technical_specification.md | 2 +- 16 files changed, 730 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a294448..0faa9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,33 @@ version bump. ### Added +- **`AuthEngine::unlock_account(email, tenant_id)` — clearing a brute-force lockout.** A + lockout is a denial of service the library imposes on its own users, and it could only be + waited out: the counter is keyed by an HMAC of `{tenant_id}:{email}` under the library's own + HMAC key, so a host facing "I am locked out and I need in now" had nothing to offer — and + neither did an operator watching an attacker deliberately lock one account out of its own + service. Undoing that is part of the defence, not a convenience (ASVS v5 §6.1.1). It grants + no access: the password, the status gate, the verification gate and MFA all still apply. No + adapter route ships with it, because who may unlock whom is a decision only the application + can make. + +- **`POST /auth/invitations/revoke` — withdrawing a pending invitation.** An invitation + provisions an account, at a role, inside a tenant, to whoever holds the link — a credential + in every sense — and once sent it stayed redeemable for its whole TTL with nothing an + operator could do about it. ASVS v5 §6.1.1 expects an administrative path to invalidate a + credential that should no longer work. Nothing on the issuing side could even *name* a + pending invitation, since the record is keyed by the hash of a token only the invitee's + mailbox ever held, so the withdrawal needs an index: `invidx:{tenantId}:{sha256(email)}` + carries the invitation's TTL and points at its record, with the email hashed so a dump of + the keyspace does not enumerate who a tenant has been inviting. Re-inviting an address now + supersedes the previous invitation through that index rather than adding a second live + token. The revoker is held to the same bar as the issuer (in the tenant, in good standing, + out-ranking the granted role), and the route answers `204` whether or not anything was + pending. Adds five `InvitationStore` methods (`put_invitation_index`, + `read_invitation_index`, `take_invitation_index`, `read_invitation_by_hash`, + `delete_invitation_by_hash`) — implementors of the trait must supply them. + + - **Failure-side hooks: `on_login_failed`, `on_lockout`, `on_refresh_token_reuse_detected`** (`crates/bymax-auth-core/src/traits/hooks.rs`). Every existing hook fired on a success path, which left the failure side of authentication with no structured seam at all: a diff --git a/README.md b/README.md index b3cea2d..016c489 100644 --- a/README.md +++ b/README.md @@ -723,6 +723,7 @@ Route groups mount only when their feature **and** runtime toggle are enabled, s | DELETE | `/auth/sessions/:id` | `AuthUser`, `UserStatus` | Revoke a specific session (ownership-checked) | | POST | `/auth/invitations` | `AuthUser` | Create a tenant invitation | | POST | `/auth/invitations/accept` | Public | Accept an invitation and create the user | +| POST | `/auth/invitations/revoke` | `AuthUser` | Withdraw a pending invitation | | POST | `/auth/platform/login` | Public | Platform-admin login (separate context) | | POST | `/auth/platform/mfa/challenge`| Public | Platform-admin MFA challenge | | GET | `/auth/platform/me` | `PlatformUser` | Current platform admin | diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index b450f60..333a51f 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -140,6 +140,19 @@ "createdAt": "iso8601-string", "$comment": "Consumption is a single-use GETDEL, so a record the reader rejects is destroyed rather than retried — a missing field loses the invitation outright." }, + "invitationIndex": { + "key": "invidx:{tenantId}:{sha256(email)}", + "value": "the invitation's token hash (lowercase hex), pointing at its `inv:` record", + "$comment": [ + "The one handle the issuing side has on a pending invitation: the record itself is keyed by", + "the hash of a token only the invitee's mailbox ever held, so without this nobody can name a", + "pending invitation, let alone withdraw one. Carries the invitation's TTL, so the pair expires", + "together. The email is hashed rather than stored in the clear — a dump of the keyspace must", + "not enumerate who a tenant has been inviting. Re-inviting an address supersedes the previous", + "invitation through this key: two live tokens for one invitee is two chances for an", + "intercepted link, and a revoke would only ever reach the newest." + ] + }, "wsTicket": { "key": "wst:{sha256(ticket)}", "fields": ["sub", "tenantId", "role", "status", "mfaEnabled", "mfaVerified"], @@ -180,11 +193,11 @@ "login": "5/60", "register": "10/3600", "refresh": "10/60", - "logout": "20/60", - "wsTicket": "20/60", + "logout": "20/60", + "wsTicket": "20/60", "forgotPassword": "3/300", "resetPassword": "3/300", - "changePassword": "5/60", + "changePassword": "5/60", "verifyOtp": "3/300", "resendPasswordOtp": "3/300", "verifyEmail": "5/60", @@ -196,6 +209,7 @@ "platformLogin": "5/60", "invitationCreate": "10/3600", "invitationAccept": "5/60", + "invitationRevoke": "10/3600", "listSessions": "30/60", "revokeSession": "10/60", "revokeAllSessions": "5/60", diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index 52dcbda..7c93aed 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -255,6 +255,20 @@ pub struct CreateInvitationDto { pub tenant_name: Option, } +/// `POST /auth/invitations/revoke` body (authenticated). +/// +/// The address is the entire payload because it is the only handle the issuing side has: the +/// invitation record is keyed by the hash of a token only the invitee's mailbox ever held. +/// `tenant_id` is absent for the same reason it is absent from [`CreateInvitationDto`] — it +/// comes from the caller's claims, never the body. +#[derive(Debug, Deserialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RevokeInvitationDto { + /// The invited address whose pending invitation is being withdrawn. + #[garde(email)] + pub email: String, +} + /// `POST /auth/invitations/accept` body (public). #[derive(Debug, Deserialize, Validate)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index 6abb09a..4537ceb 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -145,6 +145,8 @@ pub struct RateLimitConfig { pub invitation_create: Option, /// `POST /auth/invitations/accept` — 5 / 60s. pub invitation_accept: Option, + /// `POST /auth/invitations/revoke` — 10 / 3600s, matching the mint. + pub invitation_revoke: Option, /// `GET /auth/sessions` — 30 / 60s. pub list_sessions: Option, /// `DELETE /auth/sessions/{id}` — 10 / 60s. @@ -198,6 +200,7 @@ impl Default for RateLimitConfig { platform_login: Some(RateLimit::new(5, 60)), invitation_create: Some(RateLimit::new(10, 3600)), invitation_accept: Some(RateLimit::new(5, 60)), + invitation_revoke: Some(RateLimit::new(10, 3600)), list_sessions: Some(RateLimit::new(30, 60)), revoke_session: Some(RateLimit::new(10, 60)), revoke_all_sessions: Some(RateLimit::new(5, 60)), @@ -292,7 +295,7 @@ mod tests { fn every_default_limit_matches_the_shared_wire_contract() { let contract = contract_limits(); let defaults = RateLimitConfig::default(); - let pairs: [(&str, Option); 24] = [ + let pairs: [(&str, Option); 25] = [ ("login", defaults.login), ("register", defaults.register), ("refresh", defaults.refresh), @@ -309,6 +312,7 @@ mod tests { ("platformLogin", defaults.platform_login), ("invitationCreate", defaults.invitation_create), ("invitationAccept", defaults.invitation_accept), + ("invitationRevoke", defaults.invitation_revoke), ("listSessions", defaults.list_sessions), ("revokeSession", defaults.revoke_session), ("revokeAllSessions", defaults.revoke_all_sessions), diff --git a/crates/bymax-auth-axum/src/routes/invitations.rs b/crates/bymax-auth-axum/src/routes/invitations.rs index 54f8631..a5aacb5 100644 --- a/crates/bymax-auth-axum/src/routes/invitations.rs +++ b/crates/bymax-auth-axum/src/routes/invitations.rs @@ -1,6 +1,6 @@ //! The `invitations` route group (§8.2.8), gated behind the `invitations` feature: create //! (authenticated; `tenant_id` derived from the inviter's claims, **never** the body) and -//! accept (public, 201). +//! accept (public, 201), and revoke (authenticated; withdraws a pending invitation). use axum::Router; use axum::extract::State; @@ -11,7 +11,7 @@ use http::StatusCode; use tower_cookies::Cookies; use crate::delivery::TokenDelivery; -use crate::dto::{AcceptInvitationDto, CreateInvitationDto}; +use crate::dto::{AcceptInvitationDto, CreateInvitationDto, RevokeInvitationDto}; use crate::extractors::AuthUser; use crate::response::error_response; use crate::routes::{CookieDomains, RequestMeta}; @@ -31,6 +31,10 @@ pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Rout "/invitations/accept", crate::router::throttled(post(accept), limits.invitation_accept, ip_source), ) + .route( + "/invitations/revoke", + crate::router::throttled(post(revoke), limits.invitation_revoke, ip_source), + ) } /// `POST /auth/invitations` (204). Requires [`AuthUser`]. The `tenant_id` is taken from the @@ -56,6 +60,27 @@ async fn create( } } +/// `POST /auth/invitations/revoke` (204). Requires [`AuthUser`]. The `tenant_id` comes from +/// the caller's claims — never the body — so a request cannot withdraw another tenant's +/// invitations. +/// +/// Answers 204 whether or not anything was pending: reporting the difference would turn the +/// endpoint into an oracle for which addresses have invitations. +async fn revoke( + State(state): State, + user: AuthUser, + ValidatedJson(dto): ValidatedJson, +) -> Response { + match state + .engine() + .revoke_invitation(&user.0.sub, &dto.email, &user.0.tenant_id) + .await + { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error_response(&error), + } +} + /// `POST /auth/invitations/accept` (201). Public. Consumes the single-use token, creates the /// verified user, and issues a full session. async fn accept( diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index 59b5dbd..b1781fb 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -1295,6 +1295,31 @@ async fn invitation_create_and_accept() { accept.json()["error"]["code"], "auth.invalid_invitation_token" ); + + // The invitation created above can be withdrawn — an invitation is a credential, and + // until this route existed it stayed redeemable for its whole TTL whatever happened. + let revoke = Req::post("/auth/invitations/revoke") + .cookie("access_token", &access) + .json(serde_json::json!({ "email": "invitee@e.com" })) + .send(&app) + .await; + assert_eq!(revoke.status, StatusCode::NO_CONTENT); + + // …and so is an address that has none: the endpoint answers the same either way, or it + // becomes an oracle for which addresses have pending invitations. + let absent = Req::post("/auth/invitations/revoke") + .cookie("access_token", &access) + .json(serde_json::json!({ "email": "nobody@e.com" })) + .send(&app) + .await; + assert_eq!(absent.status, StatusCode::NO_CONTENT); + + // Withdrawing without auth is rejected, exactly like minting one. + let no_auth_revoke = Req::post("/auth/invitations/revoke") + .json(serde_json::json!({ "email": "invitee@e.com" })) + .send(&app) + .await; + assert_eq!(no_auth_revoke.status, StatusCode::UNAUTHORIZED); } #[tokio::test] diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index c0270f4..df8b11f 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -634,6 +634,44 @@ impl bymax_auth_core::traits::InvitationStore for FailingStores { { self.inner.consume_invitation(token).await } + async fn put_invitation_index( + &self, + tenant_id: &str, + email: &str, + token_hash: &str, + ttl_secs: u64, + ) -> Result<(), bymax_auth_types::AuthError> { + self.inner + .put_invitation_index(tenant_id, email, token_hash, ttl_secs) + .await + } + async fn read_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, bymax_auth_types::AuthError> { + self.inner.read_invitation_index(tenant_id, email).await + } + async fn take_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, bymax_auth_types::AuthError> { + self.inner.take_invitation_index(tenant_id, email).await + } + async fn read_invitation_by_hash( + &self, + token_hash: &str, + ) -> Result, bymax_auth_types::AuthError> + { + self.inner.read_invitation_by_hash(token_hash).await + } + async fn delete_invitation_by_hash( + &self, + token_hash: &str, + ) -> Result { + self.inner.delete_invitation_by_hash(token_hash).await + } } #[async_trait] diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 5f39e9b..54824cd 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -541,6 +541,39 @@ async fn full_router_against_real_redis() { .await; assert_eq!(create.status, StatusCode::NO_CONTENT); + // Re-inviting the same address supersedes the first invitation through the invitee index, + // then the whole pair is withdrawn — the `invidx:` keyspace end to end over real Redis, + // which is the only place its SET/GET/GETDEL/DEL round-trip is exercised for real. + let reinvite = call( + &app, + Method::POST, + "/auth/invitations", + Some(serde_json::json!({ "email": "invitee@e.com", "role": "USER" })), + &[("access_token", &inviter_access)], + ) + .await; + assert_eq!(reinvite.status, StatusCode::NO_CONTENT); + let revoke = call( + &app, + Method::POST, + "/auth/invitations/revoke", + Some(serde_json::json!({ "email": "invitee@e.com" })), + &[("access_token", &inviter_access)], + ) + .await; + assert_eq!(revoke.status, StatusCode::NO_CONTENT); + // Idempotent: the second withdrawal finds nothing and answers the same, so the endpoint + // never reports whether an address has a pending invitation. + let again = call( + &app, + Method::POST, + "/auth/invitations/revoke", + Some(serde_json::json!({ "email": "invitee@e.com" })), + &[("access_token", &inviter_access)], + ) + .await; + assert_eq!(again.status, StatusCode::NO_CONTENT); + // ---- MFA enrolment over real Redis (setup → verify-enable with a live TOTP) --------- seed_user(&users, "mfa@e.com", "USER").await; let mlogin = call( diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index e2fdff8..92c34ca 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -25,6 +25,12 @@ use crate::traits::{HookContext, InviteData, StoredInvitation}; /// The bytes of entropy in an invitation token before hex-encoding (256-bit, 64 hex chars). const INVITE_TOKEN_BYTES: usize = 32; +/// The stored key suffix for a raw invitation token: `sha256(token)` in lowercase hex, the +/// same form the store derives, so the index can point at a record the engine never keys. +fn token_hash(token: &str) -> String { + crate::services::to_hex(&bymax_auth_crypto::mac::sha256(token.as_bytes())) +} + /// Input to accept an invitation: the single-use token plus the new account's credentials. /// The `Debug` impl redacts the token and the password. #[derive(Clone)] @@ -116,7 +122,20 @@ impl AuthEngine { // rejection would destroy the invitation rather than merely fail it. created_at: OffsetDateTime::now_utc(), }; + // Re-inviting an address supersedes the previous invitation rather than adding a + // second one. Two live tokens for one invitee is two chances for an intercepted link + // to be redeemed, and a revoke would only ever reach the newest — the older would sit + // valid and unreferenced for the rest of its TTL. + if let Some(previous) = store.take_invitation_index(tenant_id, &email).await? { + store.delete_invitation_by_hash(&previous).await?; + } store.put_invitation(&raw, &invitation, ttl).await?; + // The invitee index is what makes an invitation manageable at all: the record is keyed + // by the hash of a token only the recipient's mailbox holds, so without this nobody on + // the issuing side can name a pending invitation, let alone withdraw one. + store + .put_invitation_index(tenant_id, &email, &token_hash(&raw), ttl) + .await?; // The email provider builds the accept URL from the raw token (never logged). let expires_at = OffsetDateTime::now_utc() @@ -184,6 +203,14 @@ impl AuthEngine { return Err(AuthError::InvalidInvitationToken); } + // The record is already gone; drop the index that pointed at it so a later revoke does + // not report success over an invitation that was accepted. Both carry the same TTL, so + // this is tidiness rather than correctness — but a stale pointer is exactly the kind of + // thing an operator reads as "still pending". + store + .take_invitation_index(&invitation.tenant_id, &invitation.email) + .await?; + // …and re-validate the INVITER, whose authority is what the invitation rests on. It was // checked when the link was minted and never again, so for the token's whole lifetime // the invitation outlived the person behind it: an admin could send one, be banned and @@ -248,6 +275,74 @@ impl AuthEngine { )); Ok(result) } + /// Withdraw a pending invitation before it is accepted. + /// + /// An invitation is a credential: it provisions an account, at a role, inside a tenant, + /// to whoever holds the link. Until now the library could mint one and had no way to take + /// it back — a link sent to the wrong address, or sent by someone who has since left, + /// stayed redeemable for its whole TTL with nothing an operator could do about it. ASVS v5 + /// §6.1.1 expects an administrative path to invalidate a credential that should no longer + /// work. + /// + /// The revoker is held to the same bar as the issuer: they must belong to the tenant, be + /// in good standing, and out-rank the role the invitation grants. Anything looser would + /// let a member cancel an admin's invitations. + /// + /// Idempotent: revoking an invitation that never existed, already expired, or was already + /// accepted is not an error — the caller asked for an end state and gets it, and reporting + /// the difference would tell them whether an address has a pending invitation, which is + /// precisely what hashing the email in the index avoids disclosing. + /// + /// # Errors + /// + /// Returns [`AuthError::TokenInvalid`] when the revoker no longer exists, + /// [`AuthError::InsufficientRole`] when they may not withdraw this invitation, or a + /// store/repository [`AuthError`]. + pub async fn revoke_invitation( + &self, + revoker_user_id: &str, + email: &str, + tenant_id: &str, + ) -> Result { + let email = normalize_email(email); + let store = self + .invitation_store() + .ok_or_else(|| crate::services::internal_error("invitation store not configured"))?; + + let revoker = self + .user_repository() + .find_by_id(revoker_user_id, None) + .await + .map_err(map_repository_error)? + .ok_or(AuthError::TokenInvalid)?; + if revoker.tenant_id != tenant_id { + return Err(AuthError::InsufficientRole); + } + + let Some(hash) = store.read_invitation_index(tenant_id, &email).await? else { + return Ok(false); + }; + + // The role check reads the invitation itself rather than the request: the caller names + // an address, not a role, so the only way to know what authority is being withdrawn is + // to look. A record that no longer parses reads as absent and is withdrawn without a + // role check — it can no longer be accepted either, and leaving it would be worse. + if let Some(invitation) = store.read_invitation_by_hash(&hash).await? + && !(self.assert_user_not_blocked(&revoker.status).is_ok() + && has_role( + &revoker.role, + &invitation.role, + &self.config().config().roles.hierarchy, + )) + { + return Err(AuthError::InsufficientRole); + } + + store.take_invitation_index(tenant_id, &email).await?; + let removed = store.delete_invitation_by_hash(&hash).await?; + tracing::info!(%tenant_id, %revoker_user_id, "invitation: withdrawn"); + Ok(removed) + } /// Re-check, at redemption time, everything that was true of the inviter when the link was /// minted. @@ -933,4 +1028,198 @@ mod tests { .is_ok() ); } + + /// Read the token the invitee index points at, so a test can assert over the record. + async fn indexed(s: &Setup, email: &str) -> Option { + s.stores + .read_invitation_index("t1", &normalize_email(email)) + .await + .ok() + .flatten() + } + + #[tokio::test] + async fn revoking_withdraws_the_invitation_and_its_index() { + // The capability the library documented and never had: an invitation provisions an + // account at a role, and it was unwithdrawable for its whole TTL. + let Some(s) = setup(invite_config()) else { return }; + let inviter = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + assert!( + s.engine + .invite(&inviter, "invitee@example.com", "MEMBER", "t1", None) + .await + .is_ok() + ); + let Some(hash) = indexed(&s, "invitee@example.com").await else { + return; + }; + + assert!(matches!( + s.engine + .revoke_invitation(&inviter, " Invitee@Example.com ", "t1") + .await, + Ok(true) + )); + // Both the record and the pointer are gone — a surviving index would read to an + // operator as "still pending". + assert!(indexed(&s, "invitee@example.com").await.is_none()); + assert!(matches!( + s.stores.read_invitation_by_hash(&hash).await, + Ok(None) + )); + } + + #[tokio::test] + async fn revoking_nothing_is_not_an_error() { + // Idempotent, and deliberately silent about which case it was: answering differently + // would turn the endpoint into an oracle for "does this address have an invitation". + let Some(s) = setup(invite_config()) else { return }; + let inviter = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + + assert!(matches!( + s.engine + .revoke_invitation(&inviter, "nobody@example.com", "t1") + .await, + Ok(false) + )); + } + + #[tokio::test] + async fn a_member_cannot_withdraw_an_admins_invitation() { + // The revoker is held to the same bar as the issuer, or a member could cancel the + // invitations of someone who out-ranks them. + let Some(s) = setup(invite_config()) else { return }; + let admin = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + let member = seed_admin(&s.users, "member@example.com", "MEMBER").await; + assert!( + s.engine + .invite(&admin, "invitee@example.com", "ADMIN", "t1", None) + .await + .is_ok() + ); + + assert!(matches!( + s.engine + .revoke_invitation(&member, "invitee@example.com", "t1") + .await, + Err(AuthError::InsufficientRole) + )); + // …and the invitation survived the refusal. + assert!(indexed(&s, "invitee@example.com").await.is_some()); + } + + #[tokio::test] + async fn a_revoker_from_another_tenant_is_refused_before_any_lookup() { + let Some(s) = setup(invite_config()) else { return }; + let inviter = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + + assert!(matches!( + s.engine + .revoke_invitation(&inviter, "invitee@example.com", "t2") + .await, + Err(AuthError::InsufficientRole) + )); + } + + #[tokio::test] + async fn a_revoker_who_no_longer_exists_is_refused() { + let Some(s) = setup(invite_config()) else { return }; + + assert!(matches!( + s.engine + .revoke_invitation("ghost", "invitee@example.com", "t1") + .await, + Err(AuthError::TokenInvalid) + )); + } + + #[tokio::test] + async fn reinviting_the_same_address_supersedes_the_previous_invitation() { + // Two live tokens for one invitee is two chances for an intercepted link to be + // redeemed, and a revoke would only ever reach the newest — the older would sit valid + // and unreferenced for the rest of its TTL. + let Some(s) = setup(invite_config()) else { return }; + let inviter = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + assert!( + s.engine + .invite(&inviter, "invitee@example.com", "MEMBER", "t1", None) + .await + .is_ok() + ); + let Some(first) = indexed(&s, "invitee@example.com").await else { + return; + }; + + assert!( + s.engine + .invite(&inviter, "invitee@example.com", "MEMBER", "t1", None) + .await + .is_ok() + ); + let Some(second) = indexed(&s, "invitee@example.com").await else { + return; + }; + + assert_ne!(first, second, "the re-invite reused the first token"); + assert!( + matches!(s.stores.read_invitation_by_hash(&first).await, Ok(None)), + "the superseded invitation is still redeemable" + ); + } + + #[tokio::test] + async fn accepting_clears_the_invitee_index() { + // A pointer left behind after an acceptance reads to an operator as still pending, and + // a later revoke would report success over an invitation that was already redeemed. + let Some(s) = setup(invite_config()) else { return }; + let inviter = seed_admin(&s.users, "admin@example.com", "ADMIN").await; + assert!( + s.engine + .invite(&inviter, "invitee@example.com", "MEMBER", "t1", None) + .await + .is_ok() + ); + // The raw token is opaque, so plant a known one and point the index at it — exactly + // the pair `invite` writes. + let token = "d".repeat(64); + let hash = token_hash(&token); + assert!( + s.stores + .put_invitation( + &token, + &StoredInvitation { + email: "invitee@example.com".to_owned(), + role: "MEMBER".to_owned(), + tenant_id: "t1".to_owned(), + inviter_user_id: inviter.clone(), + created_at: OffsetDateTime::UNIX_EPOCH, + }, + 600 + ) + .await + .is_ok() + ); + assert!( + s.stores + .put_invitation_index("t1", "invitee@example.com", &hash, 600) + .await + .is_ok() + ); + let accepted = s + .engine + .accept_invitation( + AcceptInvitationInput { + token, + name: "Invitee".to_owned(), + password: "correct-horse-battery-staple".to_owned(), + }, + "1.2.3.4", + "agent/1.0", + BTreeMap::new(), + ) + .await; + assert!(accepted.is_ok(), "the invitation was not accepted"); + + assert!(indexed(&s, "invitee@example.com").await.is_none()); + } } diff --git a/crates/bymax-auth-core/src/services/auth/login.rs b/crates/bymax-auth-core/src/services/auth/login.rs index acf36d9..0f50725 100644 --- a/crates/bymax-auth-core/src/services/auth/login.rs +++ b/crates/bymax-auth-core/src/services/auth/login.rs @@ -272,6 +272,35 @@ impl AuthEngine { } } + /// Clear an account's brute-force lockout so the next attempt is judged on its merits. + /// + /// A lockout is a denial of service the library imposes on its own users, and until now it + /// could only be waited out: the counter is keyed by an HMAC of `{tenant_id}:{email}` + /// under the library's own HMAC key, which no consumer can derive, so a host facing "I am + /// locked out and I need in now" had nothing to offer. ASVS v5 §6.1.1 asks for an + /// administrative path to clear it — and the lockout is also the lever an attacker pulls + /// to deny service to a specific account, which makes the ability to undo it part of the + /// defence rather than a convenience. + /// + /// **This grants no access.** It restores the ability to *try*: the password, the status + /// gate, the verification gate and MFA all still apply. Authorising the caller is the + /// host's job — the adapter deliberately ships no route for this, because who may unlock + /// whom is a decision only the application can make. + /// + /// Idempotent: unlocking an account that is not locked is a no-op. + /// + /// # Errors + /// + /// Returns a store [`AuthError`] if the counter cannot be cleared. + pub async fn unlock_account(&self, email: &str, tenant_id: &str) -> Result<(), AuthError> { + // Normalized exactly as login normalizes it, or the derived key misses the counter the + // lockout actually wrote and the unlock silently does nothing. + let identifier = self.hashed_identifier(tenant_id, &normalize_email(email)); + self.brute_force().reset(&identifier).await?; + tracing::info!(email = %mask_email(email), %tenant_id, "lockout cleared"); + Ok(()) + } + /// Reject the login when the identifier is already locked out, surfacing the retry hint. /// /// # Errors @@ -996,6 +1025,53 @@ mod tests { assert!(matches!(locked, Err(AuthError::AccountLocked { .. }))); } + #[tokio::test] + async fn unlocking_lets_the_account_try_again() { + // The counter is keyed by an HMAC no consumer can derive, so before this the lockout + // could only be waited out — and it is also the lever an attacker pulls to deny + // service to one account, which makes undoing it part of the defence. It grants no + // access: the correct password is still required after the unlock. + let Some(h) = active_harness(false).await else { return }; + let _ = h + .seed(SeedUser::active("locked@example.com", "right")) + .await; + for _ in 0..5 { + let _ = h + .engine + .login(login_input("locked@example.com", "wrong"), &ctx()) + .await; + } + assert!(matches!( + h.engine + .login(login_input("locked@example.com", "right"), &ctx()) + .await, + Err(AuthError::AccountLocked { .. }) + )); + + // The address is normalized on the way in, so a differently-cased spelling still + // clears the counter the lockout wrote. + assert!( + h.engine + .unlock_account(" Locked@Example.com ", "t1") + .await + .is_ok() + ); + + assert!(matches!( + h.engine + .login(login_input("locked@example.com", "right"), &ctx()) + .await, + Ok(LoginResult::Success(_)) + )); + // …and a wrong password is still wrong: the unlock restored the ability to try. + assert!(matches!( + h.engine + .login(login_input("locked@example.com", "wrong"), &ctx()) + .await, + Err(AuthError::InvalidCredentials) + )); + } + #[test] fn has_local_hash_reflects_password_presence() { // The predicate used by the login filter is true only for a stored local hash. diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index b308b3c..3c4d048 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -337,6 +337,8 @@ pub struct InMemoryStores { reset_tokens: Mutex>, reset_verified: Mutex>, invitations: Mutex>, + /// The invitee index: `{tenantId}:{sha256(email)}` -> the invitation's token hash. + invitation_index: Mutex>, /// `mfa_setup:` — the AES-protected pending-setup record keyed by `hmac_sha256(user_id)`. #[cfg(feature = "mfa")] mfa_setup: Mutex>, @@ -751,6 +753,11 @@ impl WsTicketStore for InMemoryStores { /// Hash an opaque token to its store-key form, mirroring the real store's /// "the raw token is never a key" guarantee (so the test double exercises the same /// hash-then-key path the engine relies on). +/// The invitee index key, mirroring the Redis store's `invidx:{tenantId}:{sha256(email)}`. +fn invitee_key(tenant_id: &str, email: &str) -> String { + format!("{tenant_id}:{}", token_key(email)) +} + fn token_key(token: &str) -> String { let mut out = String::with_capacity(64); for byte in bymax_auth_crypto::mac::sha256(token.as_bytes()) { @@ -812,6 +819,46 @@ impl InvitationStore for InMemoryStores { async fn consume_invitation(&self, token: &str) -> Result, AuthError> { Ok(lock(&self.invitations).remove(&token_key(token))) } + + async fn put_invitation_index( + &self, + tenant_id: &str, + email: &str, + token_hash: &str, + _ttl_secs: u64, + ) -> Result<(), AuthError> { + lock(&self.invitation_index).insert(invitee_key(tenant_id, email), token_hash.to_owned()); + Ok(()) + } + + async fn read_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError> { + Ok(lock(&self.invitation_index) + .get(&invitee_key(tenant_id, email)) + .cloned()) + } + + async fn take_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError> { + Ok(lock(&self.invitation_index).remove(&invitee_key(tenant_id, email))) + } + + async fn read_invitation_by_hash( + &self, + token_hash: &str, + ) -> Result, AuthError> { + Ok(lock(&self.invitations).get(token_hash).cloned()) + } + + async fn delete_invitation_by_hash(&self, token_hash: &str) -> Result { + Ok(lock(&self.invitations).remove(token_hash).is_some()) + } } #[cfg(feature = "mfa")] diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index b754382..47cfe6b 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -613,6 +613,46 @@ pub trait InvitationStore: Send + Sync { /// Atomically consume (`getdel`) an invitation. `None` when the token is unknown, /// expired, or already consumed. async fn consume_invitation(&self, token: &str) -> Result, AuthError>; + + /// Point the invitee index (`invidx:{tenantId}:{sha256(email)}`) at a pending + /// invitation's token hash, with the invitation's own TTL so the pair expires together. + /// + /// The index is what makes an invitation manageable at all: the record is keyed by the + /// hash of a token only the invitee's mailbox ever held, so without it nobody on the + /// issuing side can name a pending invitation, let alone withdraw one. The email is + /// hashed by the implementation — a dump of the keyspace must not enumerate who a tenant + /// has been inviting. + async fn put_invitation_index( + &self, + tenant_id: &str, + email: &str, + token_hash: &str, + ttl_secs: u64, + ) -> Result<(), AuthError>; + + /// Read the token hash the invitee index points at, leaving the entry in place. + async fn read_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError>; + + /// Atomically take (`getdel`) the invitee index entry. + async fn take_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError>; + + /// Read an invitation by its stored token **hash**, without consuming it — the revocation + /// path, which reaches the record through the index rather than through a raw token. + async fn read_invitation_by_hash( + &self, + token_hash: &str, + ) -> Result, AuthError>; + + /// Delete an invitation by its stored token **hash**. `true` when a record was removed. + async fn delete_invitation_by_hash(&self, token_hash: &str) -> Result; } /// The MFA storage seam: the AES-protected pending-setup record, the short-lived MFA diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 15ae588..26d4d8c 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -60,6 +60,10 @@ pub enum Prefix { PwVtok, /// Pending invitation (`inv`). Inv, + /// Invitee index for a pending invitation (`invidx`). Keyed by + /// `{tenantId}:{sha256(email)}` and holding the invitation's token hash — the only handle + /// the issuing side has on a record keyed by a token it never saw. + Invidx, /// Platform-admin refresh session (`prt`). Prt, /// Platform rotation grace pointer (`prp`). @@ -105,6 +109,7 @@ impl Prefix { Self::PwReset => "pw_reset", Self::PwVtok => "pw_vtok", Self::Inv => "inv", + Self::Invidx => "invidx", Self::Prt => "prt", Self::Prp => "prp", Self::Pcf => "pcf", @@ -304,6 +309,7 @@ mod tests { (Prefix::PwReset, "auth:pw_reset:abc"), (Prefix::PwVtok, "auth:pw_vtok:abc"), (Prefix::Inv, "auth:inv:abc"), + (Prefix::Invidx, "auth:invidx:abc"), (Prefix::Prt, "auth:prt:abc"), (Prefix::Prp, "auth:prp:abc"), (Prefix::Pcf, "auth:pcf:abc"), diff --git a/crates/bymax-auth-redis/src/stores/single_use.rs b/crates/bymax-auth-redis/src/stores/single_use.rs index 7b21626..f4d96bc 100644 --- a/crates/bymax-auth-redis/src/stores/single_use.rs +++ b/crates/bymax-auth-redis/src/stores/single_use.rs @@ -22,6 +22,16 @@ impl RedisStores { self.keys().key(prefix, &to_hex(&sha256(token.as_bytes()))) } + /// The invitee index key: `invidx:{tenantId}:{sha256(email)}`. The address is hashed so a + /// dump of the keyspace does not enumerate who a tenant has been inviting, which the + /// invitation record itself never exposes either. + fn invitee_key(&self, tenant_id: &str, email: &str) -> String { + self.keys().key( + Prefix::Invidx, + &format!("{tenant_id}:{}", to_hex(&sha256(email.as_bytes()))), + ) + } + /// Store a JSON-serializable value under `prefix:{sha256(token)}` with a TTL. async fn put_value( &self, @@ -136,4 +146,78 @@ impl InvitationStore for RedisStores { .await .map_err(AuthError::from) } + + async fn put_invitation_index( + &self, + tenant_id: &str, + email: &str, + token_hash: &str, + ttl_secs: u64, + ) -> Result<(), AuthError> { + let key = self.invitee_key(tenant_id, email); + let mut conn = self.connection().await.map_err(AuthError::from)?; + redis::cmd("SET") + .arg(&key) + .arg(token_hash) + .arg("EX") + .arg(ttl_secs) + .query_async::<()>(&mut conn) + .await + .map_err(|error| AuthError::from(RedisStoreError::from(error))) + } + + async fn read_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError> { + let key = self.invitee_key(tenant_id, email); + let mut conn = self.connection().await.map_err(AuthError::from)?; + redis::cmd("GET") + .arg(&key) + .query_async::>(&mut conn) + .await + .map_err(|error| AuthError::from(RedisStoreError::from(error))) + } + + async fn take_invitation_index( + &self, + tenant_id: &str, + email: &str, + ) -> Result, AuthError> { + let key = self.invitee_key(tenant_id, email); + let mut conn = self.connection().await.map_err(AuthError::from)?; + redis::cmd("GETDEL") + .arg(&key) + .query_async::>(&mut conn) + .await + .map_err(|error| AuthError::from(RedisStoreError::from(error))) + } + + async fn read_invitation_by_hash( + &self, + token_hash: &str, + ) -> Result, AuthError> { + let key = self.keys().key(Prefix::Inv, token_hash); + let mut conn = self.connection().await.map_err(AuthError::from)?; + let raw: Option = redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(|error| AuthError::from(RedisStoreError::from(error)))?; + // A record that no longer parses is answered as absent: the revocation path deletes it + // either way, and it could not have been accepted either. + Ok(raw.and_then(|json| serde_json::from_str(&json).ok())) + } + + async fn delete_invitation_by_hash(&self, token_hash: &str) -> Result { + let key = self.keys().key(Prefix::Inv, token_hash); + let mut conn = self.connection().await.map_err(AuthError::from)?; + let removed: i64 = redis::cmd("DEL") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(|error| AuthError::from(RedisStoreError::from(error)))?; + Ok(removed > 0) + } } diff --git a/docs/technical_specification.md b/docs/technical_specification.md index c87f5cd..eb2a090 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -1251,7 +1251,7 @@ The route groups and the toggle/feature that gates each: | Sessions | `/sessions` (GET, DELETE) `/sessions/:id` (DELETE) | `sessions` (auto when `sessions.enabled`) | `sessions` | | Platform | `/platform/login` `/platform/me` `/platform/logout` `/platform/refresh` `/platform/sessions` (and `/platform/mfa/challenge` when `mfa` is also on) | `platform` (auto when `platform.enabled`) | `platform` | | OAuth | `/oauth/:provider/authorize` `/oauth/:provider/callback` | `oauth` (opt-in) | `oauth` | -| Invitations | `/invitations` `/invitations/accept` | `invitations` (auto when `invitations.enabled`) | `invitations` | +| Invitations | `/invitations` `/invitations/accept` `/invitations/revoke` | `invitations` (auto when `invitations.enabled`) | `invitations` | For consumers using the framework-agnostic core directly (no Axum), the toggles still gate which engine methods are wired and which background tasks run; routing is simply the consumer's responsibility. The edge JWT verifier (`bymax-auth-wasm`) is independent of all toggles — it only ever verifies HS256 access tokens locally and mounts no routes. From 957c6ea9e72ae22a8598fe2516af901dd4e8ada2 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:29:12 -0300 Subject: [PATCH 112/122] fix(redis): index the rotated session inside the rotation script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-user session index was maintained by the store after the rotation script returned. That left a window between the atomic consume and the SADD in which `revoke_all` could sweep the index without seeing the session the rotation had just minted. That session survived a revocation the user was told had happened — and went on rotating, re-stamping a fresh access token under every later epoch, so the token epoch did not contain it either. The window is attacker-aimable rather than theoretical: a thief holding a stolen refresh token and refreshing in a loop is most likely to be mid-rotation exactly when the victim's password reset is trying to evict them. `refresh_rotate.lua` gained KEYS[6] (`sess:{userId}`) and the two member prefixes, and does the bookkeeping itself, so the two operations serialize: either the sweep sees the new member and revokes it, or the rotation runs after the sweep and finds no live key to rotate. The owner comes from the record the caller already pre-read, which is the real owner wherever it matters — the script touches the index only on the live path, and that path is unreachable unless that pre-read found the session. What stays outside is the per-session detail, which the revocation never reaches through: a stale `sd:` costs a row in a session listing, not a session that should have died. It is now issued as an atomic MULTI rather than a bare pipeline. --- CHANGELOG.md | 14 ++++++ conformance/wire-contract.json | 7 ++- .../src/lua/refresh_rotate.lua | 24 ++++++++++ crates/bymax-auth-redis/src/stores/session.rs | 44 +++++++------------ 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faa9cc..f90a4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -354,6 +354,20 @@ version bump. ### Changed +- **The session index is maintained by the rotation script, not after it** + (`crates/bymax-auth-redis/src/lua/refresh_rotate.lua`). The script gained `KEYS[6]` + (`sess:{userId}`) and two member prefixes, and does the index bookkeeping itself. Doing it in + the store after the script left a window between the atomic consume and the `SADD` in which + `revoke_all` could sweep the index without seeing the session the rotation had just minted: + that session survived a revocation the user was told had happened, and went on rotating — + re-stamping a fresh access token under every later epoch, so the token epoch did not contain + it either. The window is attacker-aimable: a thief holding a stolen refresh token and + refreshing in a loop is most likely to be mid-rotation exactly when a password reset is + trying to evict them. Inside the script the two operations serialize. What stays outside is + the per-session detail, which the revocation never reaches through, now issued as an atomic + `MULTI`. Held byte-compatible with nest-auth, which rotates the same sessions. + + - **`SessionStore::revoke_family` now returns the account the family belonged to** (`Result, AuthError>`). Reuse detection had no way to name its victim: the replayed token's own `rt:` key is deleted when it is rotated, so by the time the replay is diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 333a51f..5069306 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -71,7 +71,12 @@ "Index members are FULL key suffixes, not bare hashes. The member has to name its own", "keyspace: an atomic revoke rebuilds keys from members, and a bare hash cannot say whether", "it is a live session or a rotation grace pointer. Grace pointers are members too, which is", - "what lets a revoke-all also kill a token that was rotated away but is still in its window." + "what lets a revoke-all also kill a token that was rotated away but is still in its window.", + "The membership is maintained BY THE ROTATION SCRIPT, not by the caller afterwards. Done", + "after the script it left a window between the consume and the SADD in which a revoke-all", + "sweep could miss the session the rotation had just minted — leaving it alive, and still", + "rotating, after a revocation the user was told had happened. Inside the script the two", + "serialize. Either backend rotating the other's sessions must keep it there." ], "dashboardLive": "rt:{sha256(refreshToken)}", "dashboardGrace": "rp:{sha256(supersededToken)}", diff --git a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua index f256b81..c5b2193 100644 --- a/crates/bymax-auth-redis/src/lua/refresh_rotate.lua +++ b/crates/bymax-auth-redis/src/lua/refresh_rotate.lua @@ -8,6 +8,7 @@ -- 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) +-- KEYS[6] = sess:{userId} the owner's session index SET (touched only on the live path) -- 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) @@ -15,6 +16,8 @@ -- 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 -- ARGV[7] = the live-session key prefix, namespace included, for the successor probe +-- ARGV[8] = the live-session member prefix for the session index ('rt' / 'prt') +-- ARGV[9] = the grace-pointer member prefix for the session index ('rp' / 'prp') -- -- The grace pointer stores `{successorHash}:{session JSON}` — the hash of the session the -- rotation produced, then a colon, then the record (split on the FIRST colon). Recovery is gated on that successor still @@ -57,6 +60,27 @@ if old then redis.call('SADD', KEYS[5], ARGV[6]) redis.call('EXPIRE', KEYS[5], ARGV[2]) end + -- Session-index bookkeeping, INSIDE the script rather than after it. Left to the caller, + -- it opened a window between the consume and the SADD in which "log out everywhere" could + -- sweep the index: the sweep would not see the session this rotation had just minted, and + -- that session would survive a revocation the user was told had happened — and go on + -- rotating, re-stamping a fresh access token under every later epoch. An attacker holding + -- a stolen token and refreshing in a loop can aim for that window, and the moment they + -- would aim for it is precisely the password reset trying to evict them. Inside the + -- script the two serialize: either the sweep sees the new member and revokes it, or the + -- rotation runs after the sweep and finds no live key to rotate. + -- + -- The grace pointer is indexed too, or a token rotated away moments before the sweep + -- could still recover a session for the whole grace window. + -- + -- KEYS[6] is touched only here, on the live path — which the caller can only reach when + -- its own pre-read of KEYS[1] succeeded, so the key is always the real owner's index. + redis.call('SREM', KEYS[6], ARGV[8] .. ':' .. ARGV[5]) + redis.call('SADD', KEYS[6], ARGV[8] .. ':' .. ARGV[6]) + if tonumber(ARGV[3]) > 0 then + redis.call('SADD', KEYS[6], ARGV[9] .. ':' .. ARGV[5]) + end + redis.call('EXPIRE', KEYS[6], ARGV[2]) redis.call('DEL', KEYS[1]) return old end diff --git a/crates/bymax-auth-redis/src/stores/session.rs b/crates/bymax-auth-redis/src/stores/session.rs index a201447..d24164c 100644 --- a/crates/bymax-auth-redis/src/stores/session.rs +++ b/crates/bymax-auth-redis/src/stores/session.rs @@ -232,6 +232,10 @@ impl RedisStores { // the session a rotation produced is still alive before honouring the pointer. let rt_prefix = format!("{}:{}", keys.namespace(), prefixes.rt.as_str()); let fam_key = keys.key(prefixes.fam, family); + // The owner's session index. The script touches it only on the live-rotation path, + // which the caller can reach only when its own pre-read of the old key succeeded — so + // `new_record.user_id` is the real owner there, never a placeholder. + let sess_key = keys.key(prefixes.sess, &rotation.new_record.user_id); let new_json = serde_json::to_string(&rotation.new_record)?; let mut conn = self.connection().await?; @@ -242,6 +246,7 @@ impl RedisStores { .key(&rp_old) .key(&cf_old) .key(&fam_key) + .key(&sess_key) .arg(&new_json) .arg(rotation.refresh_ttl) .arg(rotation.grace_ttl) @@ -249,6 +254,8 @@ impl RedisStores { .arg(&rotation.old_hash) .arg(&rotation.new_hash) .arg(&rt_prefix) + .arg(prefixes.rt.as_str()) + .arg(prefixes.rp.as_str()) .invoke_async(&mut conn) .await?; @@ -266,7 +273,7 @@ impl RedisStores { } RotateParsed::Reused(family) => Ok(RotateOutcome::Reused(family)), RotateParsed::Rotated(old_record) => { - self.move_session_member(&mut conn, &prefixes, rotation, &old_record.user_id) + self.move_session_detail(&mut conn, &prefixes, rotation) .await?; Ok(RotateOutcome::Rotated(old_record)) } @@ -368,50 +375,33 @@ impl RedisStores { /// could still recover a live session through the grace window for the whole grace TTL, /// even after the user revoked every session. A zero-width grace window writes no pointer, /// so no member is added for it. - async fn move_session_member( + async fn move_session_detail( &self, conn: &mut Connection, prefixes: &KindPrefixes, rotation: &SessionRotation, - user_id: &str, ) -> Result<(), RedisStoreError> { let keys = self.keys(); - let sess_key = keys.key(prefixes.sess, user_id); let sd_old = keys.key(prefixes.sd, &rotation.old_hash); let sd_new = keys.key(prefixes.sd, &rotation.new_hash); - let old_member = index_member(prefixes.rt, &rotation.old_hash); - let new_member = index_member(prefixes.rt, &rotation.new_hash); let detail_json = serde_json::to_string(&SessionDetailValue::at_creation(&rotation.new_record))?; - let ttl_window = i64::try_from(rotation.refresh_ttl).unwrap_or(i64::MAX); - let mut pipe = redis::pipe(); - pipe.cmd("SREM") - .arg(&sess_key) - .arg(&old_member) - .ignore() + // The index membership itself moved into the rotation script — a sweep racing this + // step used to be able to miss the session the rotation had just minted. What is left + // is the per-session DETAIL, which names nothing the revocation reaches through: a + // stale `sd:` is cosmetic, and losing one costs a device row in the session list + // rather than a session that should have died. + redis::pipe() + .atomic() .cmd("DEL") .arg(&sd_old) .ignore() - .cmd("SADD") - .arg(&sess_key) - .arg(&new_member) - .ignore(); - if rotation.grace_ttl > 0 { - pipe.cmd("SADD") - .arg(&sess_key) - .arg(index_member(prefixes.rp, &rotation.old_hash)) - .ignore(); - } - pipe.cmd("SET") + .cmd("SET") .arg(&sd_new) .arg(&detail_json) .arg("EX") .arg(rotation.refresh_ttl) .ignore() - .cmd("EXPIRE") - .arg(&sess_key) - .arg(ttl_window) - .ignore() .query_async::<()>(conn) .await?; Ok(()) From 1266516d5fe05085142da5cadb16339fd67f4107 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:51:55 -0300 Subject: [PATCH 113/122] fix(core): claim a recovery code before accepting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consuming a recovery code is a read-modify-write against the consumer's user repository: the challenge reads the whole array, removes one entry, and writes the rest back. Two challenges landing together both read the array containing the code, both match it, and both write — one code minting two sessions, which is the one property a recovery code has. The temp-token consume does not cover this. That gate is per token, and two logins hold two tokens. The engine cannot make the consumer's repository atomic; its atomicity is the consumer's to define. It can be atomic in the store it owns. `MfaStore::claim_recovery_code` sets `rcu:{hmac(plane:userId:code)}` with `NX EX`, and the loser reads as an invalid code — which is what a code already spent is. Implementors of the trait must supply the new method. Same construction as the TOTP anti-replay marker, for the same reasons: the key discloses neither the user nor the code, and binding the identity plane stops a dashboard user and a platform admin who share an id from burning each other's codes. Both planes are gated — the platform one is where a spent code buys the most. The marker is deliberately short-lived. It serializes a race measured in milliseconds; the durable record of consumption is the repository write. Outliving that write would turn a failed write into a code the account can still see in its list but can never use. --- CHANGELOG.md | 12 ++ conformance/wire-contract.json | 22 ++- crates/bymax-auth-axum/tests/common/mod.rs | 7 + .../src/services/mfa/challenge.rs | 74 ++++++- .../bymax-auth-core/src/services/mfa/mod.rs | 9 + .../bymax-auth-core/src/services/mfa/tests.rs | 186 ++++++++++++++++++ crates/bymax-auth-core/src/testing/mod.rs | 8 + crates/bymax-auth-core/src/traits/store.rs | 11 ++ crates/bymax-auth-redis/src/keys.rs | 4 + crates/bymax-auth-redis/src/stores/mfa.rs | 28 ++- .../bymax-auth-redis/tests/mfa_store_e2e.rs | 18 ++ 11 files changed, 369 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f90a4cb..fb8162e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -354,6 +354,18 @@ version bump. ### Changed +- **A recovery code is claimed before it is accepted.** Consuming one is a read-modify-write + against the consumer's user repository: the challenge reads the whole array, removes one + entry, and writes the rest back. Two challenges landing together both read the array + containing the code, both match it, and both write — one code minting two sessions, which is + the one property a recovery code has. The per-token consume does not cover it, because two + logins hold two temp tokens. The engine cannot make the consumer's repository atomic, so it + claims the code in the store it owns: `MfaStore::claim_recovery_code` sets + `rcu:{hmac(plane:userId:code)}` with `NX EX`, and the loser reads as an invalid code — which + is what a code already spent is. Same construction as the TOTP anti-replay marker, for the + same reasons. Implementors of `MfaStore` must supply the new method. + + - **The session index is maintained by the rotation script, not after it** (`crates/bymax-auth-redis/src/lua/refresh_rotate.lua`). The script gained `KEYS[6]` (`sess:{userId}`) and two member prefixes, and does the index bookkeeping itself. Doing it in diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 5069306..54eaba1 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -63,7 +63,27 @@ "totpReplayMarker": "tu", "oauthState": "os", "wsTicket": "wst", - "invitation": "inv" + "invitation": "inv", + "recoveryCodeClaim": "rcu" + }, + + "recoveryCodeClaim": { + "key": "rcu:{hmac_sha256(hmacKey, '{plane}:{userId}:{code}')}", + "value": "'1' — presence is the whole meaning", + "ttlSeconds": 300, + "$comment": [ + "Consuming a recovery code is a read-modify-write against the CONSUMER's user repository:", + "the challenge reads the whole array, removes one entry, and writes the rest back. Two", + "challenges landing together both read the array, both match, and both write — one code", + "mints two sessions, which is the one property a recovery code has. Neither library can fix", + "that in the repository, whose atomicity is the consumer's to define, so both claim the code", + "with SET NX in the store they share. First claim wins; every other reads as an invalid code.", + "Same construction as the TOTP anti-replay marker: the key discloses neither the user nor the", + "code, and binding the plane stops a dashboard user and a platform admin with the same id", + "burning each other's codes. Deliberately short-lived — it serializes a race, it is not the", + "durable record of consumption, and outliving the repository write would turn a failed write", + "into a code the account can see but can never use." + ] }, "sessionIndexMembers": { diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index df8b11f..7bb053a 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -720,6 +720,13 @@ impl bymax_auth_core::traits::MfaStore for FailingStores { ) -> Result { self.inner.mark_totp_used(replay_id, ttl).await } + async fn claim_recovery_code( + &self, + claim_id: &str, + ttl: u64, + ) -> Result { + self.inner.claim_recovery_code(claim_id, ttl).await + } async fn challenge_consume( &self, replay_id: &str, diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 99ef108..8da6b4e 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -106,7 +106,10 @@ impl MfaService { } None } else { - match self.accept_recovery_code(&user, code) { + match self + .accept_recovery_code(MfaContext::Dashboard, &user, code) + .await? + { Some(index) => { // The recovery-code path carries no `tu:` marker, so the temp token is // consumed standalone now that the code is confirmed valid — and the @@ -224,7 +227,9 @@ impl MfaService { } None } else { - match super::verify_recovery_code(&recovery_codes, &self.recovery_code_candidates(code)) + match self + .claim_matched_recovery_code(MfaContext::Platform, &user_id, code, &recovery_codes) + .await? { Some(index) => { // The recovery-code path carries no `tu:` marker, so the temp token is @@ -308,10 +313,71 @@ impl MfaService { /// Scan the stored recovery-code digests for a constant-time match of `code`, returning /// the matched index or `None`. - fn accept_recovery_code(&self, user: &AuthUser, code: &str) -> Option { + async fn accept_recovery_code( + &self, + ctx: MfaContext, + user: &AuthUser, + code: &str, + ) -> Result, AuthError> { let candidates = self.recovery_code_candidates(code); let stored = user.mfa_recovery_codes.clone().unwrap_or_default(); - super::verify_recovery_code(&stored, &candidates) + let Some(index) = super::verify_recovery_code(&stored, &candidates) else { + return Ok(None); + }; + // A matched code still has to be CLAIMED. Splicing it out of the stored set is a + // read-modify-write against the consumer's repository, so two challenges landing + // together both read the array containing it, both match, and both write — one code + // minting two sessions, the one property a recovery code has. The engine cannot make + // that repository atomic; it can be atomic in the store it owns. The loser reads as an + // invalid code, which is what a code already spent is. + if !self.claim_recovery_code(ctx, &user.id, code).await? { + return Ok(None); + } + Ok(Some(index)) + } + + /// The plane-shared core of [`Self::accept_recovery_code`]: match the code against a stored + /// set, then claim it. Split out because the platform path already holds the stored set and + /// has no `AuthUser` to hand over. + async fn claim_matched_recovery_code( + &self, + ctx: MfaContext, + user_id: &str, + code: &str, + stored: &[String], + ) -> Result, AuthError> { + let Some(index) = super::verify_recovery_code(stored, &self.recovery_code_candidates(code)) + else { + return Ok(None); + }; + if !self.claim_recovery_code(ctx, user_id, code).await? { + return Ok(None); + } + Ok(Some(index)) + } + + /// Claim a matched recovery code for exactly one challenge, `SET NX` over an HMAC of plane, + /// user and code. + /// + /// Identical construction to the TOTP anti-replay marker, for identical reasons: the key + /// discloses neither the user nor the code, and binding the plane stops a dashboard user + /// and a platform admin who share an id from burning each other's codes. + /// + /// The marker is deliberately short-lived. It serializes a race measured in milliseconds; + /// the durable record of consumption is the repository write. Outliving that write would + /// turn a failed write into a code the account can still see in its list but can never use. + async fn claim_recovery_code( + &self, + ctx: MfaContext, + user_id: &str, + code: &str, + ) -> Result { + self.mfa_store + .claim_recovery_code( + &self.replay_id(ctx, user_id, code), + super::RECOVERY_CODE_CLAIM_TTL_SECONDS, + ) + .await } /// Remove the just-used recovery code from the stored set and persist the smaller set diff --git a/crates/bymax-auth-core/src/services/mfa/mod.rs b/crates/bymax-auth-core/src/services/mfa/mod.rs index 7aa4b48..f3b9598 100644 --- a/crates/bymax-auth-core/src/services/mfa/mod.rs +++ b/crates/bymax-auth-core/src/services/mfa/mod.rs @@ -43,6 +43,15 @@ const MFA_SETUP_TTL_SECONDS: u64 = 600; /// the anti-replay marker TTL from the acceptance window. const TOTP_STEP_SECONDS: u64 = 30; /// The number of random bytes behind one recovery code (96 bits of entropy, §7.5). +/// TTL of the single-use claim on a recovery code (5 minutes). +/// +/// The claim serializes concurrent challenges presenting the same code; it is not the durable +/// record of consumption, which is the repository write that removes the code from the account. +/// Outliving that write by much would turn a failed write into a code the user can no longer +/// use but can still see in their list — so the marker is deliberately far shorter than the +/// code's real lifetime, and long enough that no plausible request pair slips past it. +pub(super) const RECOVERY_CODE_CLAIM_TTL_SECONDS: u64 = 300; + const RECOVERY_CODE_BYTES: usize = 12; /// The number of random bytes behind a TOTP secret (160 bits, RFC 6238 / §7.5.1). const TOTP_SECRET_BYTES: usize = 20; diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index ae68da6..6f6fa62 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -1383,6 +1383,9 @@ struct LosingConsumeMfaStore { #[async_trait] impl MfaStore for LosingConsumeMfaStore { + async fn claim_recovery_code(&self, id: &str, ttl: u64) -> Result { + self.inner.claim_recovery_code(id, ttl).await + } async fn put_setup_nx(&self, k: &str, v: &str, ttl: u64) -> Result { self.inner.put_setup_nx(k, v, ttl).await } @@ -1420,6 +1423,9 @@ struct ScriptedMfaStore { #[async_trait] impl MfaStore for ScriptedMfaStore { + async fn claim_recovery_code(&self, _id: &str, _ttl: u64) -> Result { + Ok(true) + } async fn put_setup_nx(&self, _k: &str, _v: &str, _ttl: u64) -> Result { Ok(self.put_nx) } @@ -2472,6 +2478,102 @@ async fn a_recovery_challenge_that_loses_the_temp_token_consume_issues_no_sessio ); } +#[tokio::test] +async fn one_recovery_code_cannot_be_spent_twice_even_with_two_temp_tokens() { + // Splicing a code out of the stored set is a read-modify-write against the CONSUMER's + // repository: two challenges landing together both read the array containing the code, both + // match it, and both write. The temp-token consume does not cover this — that gate is per + // token, and two logins hold two tokens. One code, two sessions, which is the one property + // a recovery code has. The claim in the store the engine owns is what closes it. + let users = Arc::new(InMemoryUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let created = users + .create(bymax_auth_types::CreateUserData { + email: "twice@example.com".to_owned(), + name: "T".to_owned(), + password_hash: Some("$scrypt$x".to_owned()), + role: Some("USER".to_owned()), + status: Some("ACTIVE".to_owned()), + tenant_id: TENANT.to_owned(), + email_verified: Some(true), + }) + .await; + let Ok(user) = created else { return }; + + // The token manager needs MFA support over the SAME store, or the temp tokens it issues + // are not store-backed and no challenge can consume one. + let mfa_store: Arc = stores.clone(); + let mut deps = service_deps(mfa_store.clone(), users.clone()); + deps.tokens = Arc::new( + TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), + stores.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ) + .with_mfa_support(crate::services::token_manager::MfaTokenSupport::new( + mfa_store, + )), + ); + let service = MfaService::new(deps); + let plain = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"; + let digest = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( + &[9u8; 64], + plain.as_bytes(), + )); + let material = service.generate_setup_material(); + let Ok((_, _, data)) = material else { return }; + // The SAME digest stored twice, so the second challenge would still find a match after the + // first spliced one out — the shape a stale read produces, without needing real + // concurrency to reproduce it. + assert!( + users + .update_mfa( + &user.id, + bymax_auth_types::UpdateMfaData { + mfa_enabled: true, + mfa_secret: Some(data.encrypted_secret), + mfa_recovery_codes: Some(vec![digest.clone(), digest]), + }, + ) + .await + .is_ok() + ); + + // Two temp tokens: two independent logins, so the per-token consume gate does not apply. + let Ok(first_token) = service + .tokens + .issue_mfa_temp_token(&user.id, MfaContext::Dashboard) + .await + else { + return; + }; + let Ok(second_token) = service + .tokens + .issue_mfa_temp_token(&user.id, MfaContext::Dashboard) + .await + else { + return; + }; + + let first = service + .challenge(&first_token, plain, "1.2.3.4", "ua") + .await; + assert!(first.is_ok(), "the first use must succeed: {first:?}"); + + let second = service + .challenge(&second_token, plain, "1.2.3.4", "ua") + .await; + // The code is spent. It reads as an invalid code, which is what it now is. + assert!( + matches!(second, Err(AuthError::MfaInvalidCode)), + "a spent recovery code must not mint a second session, got {second:?}" + ); +} + #[test] fn every_mfa_key_is_namespaced_by_identity_plane() { // The two planes draw their ids from DIFFERENT consumer repositories, which may hand out @@ -2658,3 +2760,87 @@ async fn a_platform_recovery_challenge_that_loses_the_consume_issues_no_session( "a lost consume must issue no platform session, got {outcome:?}" ); } + +#[tokio::test] +async fn a_platform_recovery_code_is_claimed_before_it_is_accepted() { + // The platform twin of the double-spend gate. Splicing a code out is a read-modify-write + // against the consumer's platform repository, so two challenges landing together both read + // the array containing it, both match, and both write — and the per-token consume does not + // cover it, because two logins hold two tokens. This is the plane where a spent code buys + // the most. + let users = Arc::new(InMemoryUserRepository::new()); + let admins = Arc::new(InMemoryPlatformUserRepository::new()); + let stores = Arc::new(InMemoryStores::new()); + let mfa_store: Arc = stores.clone(); + + let plain = "AAAA-BBBB-CCCC-DDDD-EEEE-FFFF"; + let digest = crate::services::to_hex(&bymax_auth_crypto::mac::hmac_sha256( + &[9u8; 64], + plain.as_bytes(), + )); + + let mut deps = service_deps(mfa_store.clone(), users); + deps.platform_repo = Some(admins.clone()); + deps.tokens = Arc::new( + TokenManagerService::new( + HsKey::from_bytes(b"0123456789abcdef0123456789abcdef"), + Vec::new(), + stores.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ) + .with_mfa_support(crate::services::token_manager::MfaTokenSupport::new( + mfa_store, + )), + ); + let service = MfaService::new(deps); + + let material = service.generate_setup_material(); + let Ok((_, _, data)) = material else { return }; + // The same digest twice, so the second challenge still finds a match after the first + // spliced one out — the shape a stale read produces, without needing real concurrency. + admins.insert(AuthPlatformUser { + id: "ptwice".to_owned(), + email: "ptwice@example.com".to_owned(), + name: "Admin".to_owned(), + password_hash: admin_password_hash(), + role: "SUPER_ADMIN".to_owned(), + status: "ACTIVE".to_owned(), + mfa_enabled: true, + mfa_secret: Some(data.encrypted_secret), + mfa_recovery_codes: Some(vec![digest.clone(), digest]), + platform_id: None, + last_login_at: None, + updated_at: OffsetDateTime::UNIX_EPOCH, + created_at: OffsetDateTime::UNIX_EPOCH, + }); + + let Ok(first_token) = service + .tokens + .issue_mfa_temp_token("ptwice", MfaContext::Platform) + .await + else { + return; + }; + let first = service + .challenge(&first_token, plain, "1.2.3.4", "ua") + .await; + assert!(first.is_ok(), "the first use must succeed: {first:?}"); + + let Ok(second_token) = service + .tokens + .issue_mfa_temp_token("ptwice", MfaContext::Platform) + .await + else { + return; + }; + let second = service + .challenge(&second_token, plain, "1.2.3.4", "ua") + .await; + assert!( + matches!(second, Err(AuthError::MfaInvalidCode)), + "a spent platform recovery code must not mint a second session, got {second:?}" + ); +} diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 3c4d048..183aa04 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -348,6 +348,8 @@ pub struct InMemoryStores { /// `tu:` — the TOTP anti-replay markers keyed by `hmac_sha256("{user_id}:{code}")`. #[cfg(feature = "mfa")] mfa_replay: Mutex>, + /// Single-use claims on MFA recovery codes (`rcu:`). + recovery_claims: Mutex>, /// `os:` — the single-use OAuth `state` + PKCE payload keyed by `sha256(state)`. #[cfg(feature = "oauth")] oauth_state: Mutex>, @@ -910,6 +912,12 @@ impl crate::traits::MfaStore for InMemoryStores { Ok(lock(&self.mfa_replay).insert(replay_id.to_owned())) } + async fn claim_recovery_code(&self, claim_id: &str, _ttl: u64) -> Result { + // Same "was it new?" decision as the TOTP marker, over its own set so a code and a + // TOTP value can never collide into one another's claim. + Ok(lock(&self.recovery_claims).insert(claim_id.to_owned())) + } + async fn challenge_consume( &self, replay_id: &str, diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 47cfe6b..48e394b 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -721,6 +721,17 @@ pub trait MfaStore: Send + Sync { /// `regenerate_recovery_codes`, which have no temp token to consume. async fn mark_totp_used(&self, replay_id: &str, ttl: u64) -> Result; + /// Claim a recovery code for exactly one challenge: `rcu:{claim_id} = "1"` with `NX EX + /// ttl`. Returns `true` when this caller created the marker. + /// + /// Consuming a recovery code is a read-modify-write against the CONSUMER's user + /// repository — read the array, remove one entry, write the rest back. Two challenges + /// landing together both read the array containing the code, both match it, and both + /// write, so one code mints two sessions: the one property a recovery code has. The + /// engine cannot make that repository atomic, since its atomicity is the consumer's to + /// define. It can be atomic here, in the store it owns. + async fn claim_recovery_code(&self, claim_id: &str, ttl: u64) -> Result; + /// The **fused** challenge step (§7.5.6): set `tu:{replay_id}` `NX EX ttl` and, *iff* that /// marker was newly created, delete the temp token `mfa:{jti_hash}` — in one atomic Lua /// script. The temp-token deletion is the single-consume gate: returns `true` only when this diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index 26d4d8c..be2e972 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -60,6 +60,8 @@ pub enum Prefix { PwVtok, /// Pending invitation (`inv`). Inv, + /// Single-use claim on an MFA recovery code (`rcu`). + Rcu, /// Invitee index for a pending invitation (`invidx`). Keyed by /// `{tenantId}:{sha256(email)}` and holding the invitation's token hash — the only handle /// the issuing side has on a record keyed by a token it never saw. @@ -109,6 +111,7 @@ impl Prefix { Self::PwReset => "pw_reset", Self::PwVtok => "pw_vtok", Self::Inv => "inv", + Self::Rcu => "rcu", Self::Invidx => "invidx", Self::Prt => "prt", Self::Prp => "prp", @@ -309,6 +312,7 @@ mod tests { (Prefix::PwReset, "auth:pw_reset:abc"), (Prefix::PwVtok, "auth:pw_vtok:abc"), (Prefix::Inv, "auth:inv:abc"), + (Prefix::Rcu, "auth:rcu:abc"), (Prefix::Invidx, "auth:invidx:abc"), (Prefix::Prt, "auth:prt:abc"), (Prefix::Prp, "auth:prp:abc"), diff --git a/crates/bymax-auth-redis/src/stores/mfa.rs b/crates/bymax-auth-redis/src/stores/mfa.rs index ae25dad..19496ba 100644 --- a/crates/bymax-auth-redis/src/stores/mfa.rs +++ b/crates/bymax-auth-redis/src/stores/mfa.rs @@ -100,14 +100,16 @@ impl RedisStores { Ok(removed > 0) } - /// `SET tu:{replay_id} "1" NX EX ttl` — set the standalone anti-replay marker, returning - /// whether it was newly created (the code had not been seen). - async fn mark_totp_used_inner( + /// `SET {prefix}:{id} "1" NX EX ttl` — a single-use marker, returning whether this call + /// created it. Two keyspaces share the shape: the TOTP anti-replay marker (`tu:`) and the + /// recovery-code claim (`rcu:`). Both mean the same thing — presence is "already spent". + async fn set_nx_marker( &self, - replay_id: &str, + prefix: Prefix, + id: &str, ttl: u64, ) -> Result { - let key = self.keys().key(Prefix::Tu, replay_id); + let key = self.keys().key(prefix, id); let mut conn = self.connection().await?; let set: Option = redis::cmd("SET") .arg(&key) @@ -120,6 +122,16 @@ impl RedisStores { Ok(set.is_some()) } + /// `SET tu:{replay_id} "1" NX EX ttl` — set the standalone anti-replay marker, returning + /// whether it was newly created (the code had not been seen). + async fn mark_totp_used_inner( + &self, + replay_id: &str, + ttl: u64, + ) -> Result { + self.set_nx_marker(Prefix::Tu, replay_id, ttl).await + } + /// The fused `mfa_challenge` Lua: set `tu:{replay_id}` `NX EX ttl` and, iff newly created, /// `DEL mfa:{jti_hash}`, gating success on the deletion — returning whether this call both /// freshly marked the code and removed the still-present temp token (the sole winner), in one @@ -184,6 +196,12 @@ impl MfaStore for RedisStores { self.del_temp_inner(jti_hash).await.map_err(AuthError::from) } + async fn claim_recovery_code(&self, claim_id: &str, ttl: u64) -> Result { + self.set_nx_marker(Prefix::Rcu, claim_id, ttl) + .await + .map_err(AuthError::from) + } + async fn mark_totp_used(&self, replay_id: &str, ttl: u64) -> Result { self.mark_totp_used_inner(replay_id, ttl) .await diff --git a/crates/bymax-auth-redis/tests/mfa_store_e2e.rs b/crates/bymax-auth-redis/tests/mfa_store_e2e.rs index a06e7aa..67243aa 100644 --- a/crates/bymax-auth-redis/tests/mfa_store_e2e.rs +++ b/crates/bymax-auth-redis/tests/mfa_store_e2e.rs @@ -112,6 +112,24 @@ async fn standalone_replay_marker_rejects_a_second_use() { stores.mark_totp_used("replay-id", 90).await, Ok(false) )); + + // The recovery-code claim is the same primitive over its own keyspace: first caller wins, + // every other reads as already spent. Two challenges racing one code is how a single-use + // recovery code mints two sessions, and the repository splice cannot stop it. + assert!(matches!( + stores.claim_recovery_code("claim-id", 300).await, + Ok(true) + )); + assert!(matches!( + stores.claim_recovery_code("claim-id", 300).await, + Ok(false) + )); + // …and the two keyspaces are distinct: an id already marked as a TOTP replay is still + // claimable as a recovery code, or one would silently spend the other. + assert!(matches!( + stores.claim_recovery_code("replay-id", 300).await, + Ok(true) + )); } #[tokio::test] From d3f7d3401acea41446a39cd9a4660ab1fa8aea52 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:08:25 -0300 Subject: [PATCH 114/122] test: pin the invitee index at the store, where the route cannot see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo mutants` over this round's changed lines left 18 survivors, and every one of them was in the invitation-index keyspace. The cause is a property this round deliberately built: the withdrawal answers 204 whether or not anything was pending, so it cannot be used as an oracle for which addresses have invitations. That same silence leaves an end-to-end test unable to tell a working index from one that does nothing — `put_invitation_index -> Ok(())` survived, meaning the whole index could have been a no-op with the suite green. The fix is testing at the level the behaviour lives at. The store tests now drive the index API directly against real Redis and assert values rather than status codes: - the index points at the record, and the record reads back through the hash it points at — the two halves of the only path a withdrawal has; - the key derives from BOTH tenant and address, so a key that ignored either would let one tenant withdraw another's invitations, or any address withdraw any other's; - reading leaves the entry, taking removes it exactly once; - deleting reports whether THIS call removed the record, so a withdrawal cannot report success over an invitation that was already accepted; - a record that no longer parses reads as absent and is still deletable. The in-memory double gets the same treatment. A consumer testing their own withdrawal flow against it is relying on it behaving like the store it stands in for, so an index keyed by less than (tenant, address) would report their flow as correct while it was not. `revoke_family` now has its recovered owner asserted, including the case where no member names one — an event naming the empty string is worse than no event, because a consumer would act on it. That test uses a single anonymous member on purpose: the family index is a set, so a family holding both an anonymous and a named record would be read in whichever order the set happened to yield, and the assertion would pass or fail by luck. 98 mutants over the round's diff: 82 caught, 16 unviable, none missed. --- crates/bymax-auth-core/src/testing/mod.rs | 133 ++++++++++++++++- crates/bymax-auth-redis/tests/redis_stores.rs | 140 ++++++++++++++++++ 2 files changed, 270 insertions(+), 3 deletions(-) diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 183aa04..8ee6518 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -1378,11 +1378,31 @@ mod tests { // 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()); + // …and it reports the account the family belonged to. That owner is the only thing + // reuse detection can name its victim with: the replayed token's own key was deleted + // when it was rotated, so the family index is the last surviving link to an account. + assert!(matches!( + store.revoke_family(kind, "famA").await, + Ok(Some(owner)) if owner == "u1" + )); 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 family with nothing left readable names nobody rather than someone. + assert!(matches!(store.revoke_family(kind, "famA").await, Ok(None))); + assert!(matches!(store.revoke_family(kind, "").await, Ok(None))); + + // A member whose record carries no owner is skipped rather than reported: an event + // naming the empty string is worse than no event, because a consumer would act on it. + // One member only — the family index is a set, so a family holding both an anonymous + // and a named record would be read in whichever order the set happened to yield, and + // the assertion would pass or fail by luck. + assert!( + store + .create_session(kind, "anon", &record_in_family("", "famB"), 60) + .await + .is_ok() + ); + assert!(matches!(store.revoke_family(kind, "famB").await, Ok(None))); // A session with no family plants no consumed marker, so a post-grace replay is a // plain Invalid, never a reuse. @@ -1674,6 +1694,113 @@ mod tests { )); } + #[tokio::test] + async fn invitation_index_is_keyed_by_tenant_and_address() { + // The double has to behave like the Redis store it stands in for, because a consumer + // testing their own withdrawal flow against it is relying on exactly that. An index + // keyed by anything less than (tenant, address) would let one tenant's withdrawal + // reach another's invitation, and the double would report the flow as correct. + let store = InMemoryStores::new(); + assert!( + store + .put_invitation_index("t1", "invitee@example.com", "hash-1", 600) + .await + .is_ok() + ); + assert!( + store + .put_invitation_index("t2", "invitee@example.com", "hash-2", 600) + .await + .is_ok() + ); + + // Same address, different tenants: two entries, not one overwriting the other. + assert!(matches!( + store.read_invitation_index("t1", "invitee@example.com").await, + Ok(Some(h)) if h == "hash-1" + )); + assert!(matches!( + store.read_invitation_index("t2", "invitee@example.com").await, + Ok(Some(h)) if h == "hash-2" + )); + // …and a different address in a tenant that has one names nothing. + assert!(matches!( + store.read_invitation_index("t1", "other@example.com").await, + Ok(None) + )); + + // Reading leaves the entry; taking removes it, exactly once. + assert!(matches!( + store + .read_invitation_index("t1", "invitee@example.com") + .await, + Ok(Some(_)) + )); + assert!(matches!( + store.take_invitation_index("t1", "invitee@example.com").await, + Ok(Some(h)) if h == "hash-1" + )); + assert!(matches!( + store + .take_invitation_index("t1", "invitee@example.com") + .await, + Ok(None) + )); + // …and taking one tenant's entry left the other's alone. + assert!(matches!( + store + .read_invitation_index("t2", "invitee@example.com") + .await, + Ok(Some(_)) + )); + } + + #[tokio::test] + async fn an_invitation_is_readable_and_deletable_by_its_stored_hash() { + // The revocation path reaches the record through the index rather than through a raw + // token, so the double needs the by-hash pair the withdrawal actually calls. + let store = InMemoryStores::new(); + let invitation = StoredInvitation { + email: "invitee@example.com".to_owned(), + role: "MEMBER".to_owned(), + tenant_id: "t1".to_owned(), + inviter_user_id: "owner".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, + }; + assert!( + store + .put_invitation("inv-tok", &invitation, 600) + .await + .is_ok() + ); + let hash = token_key("inv-tok"); + + assert!(matches!( + store.read_invitation_by_hash(&hash).await, + Ok(Some(i)) if i.role == "MEMBER" + )); + assert!(matches!( + store.read_invitation_by_hash("never-stored").await, + Ok(None) + )); + + // The delete reports whether THIS call removed it, so a withdrawal cannot report + // success over an invitation that was already accepted. + assert!(matches!( + store.delete_invitation_by_hash(&hash).await, + Ok(true) + )); + assert!(matches!( + store.delete_invitation_by_hash(&hash).await, + Ok(false) + )); + // …and the accept path no longer finds it either. + assert!(matches!( + store.consume_invitation("inv-tok").await, + Ok(None) + )); + } + #[tokio::test] async fn ws_ticket_store_is_single_use() { let store = InMemoryStores::new(); diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index 62b598b..dcadf69 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -34,6 +34,15 @@ use time::OffsetDateTime; /// 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. +/// `sha256(token)` in lowercase hex — the form every opaque-token keyspace is keyed by, and +/// the value the invitee index points at. +fn token_hash(token: &str) -> String { + bymax_auth_crypto::mac::sha256(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn record(user: &str) -> SessionRecord { SessionRecord { user_id: user.to_owned(), @@ -1113,6 +1122,137 @@ async fn password_reset_and_invitation_stores_are_single_use_via_getdel() { )); } +#[tokio::test] +async fn the_invitee_index_is_the_only_handle_on_a_pending_invitation() { + // The invitation record is keyed by the hash of a token only the invitee's mailbox ever + // held, so the index is the whole of what the issuing side can name. Its behaviour is + // asserted HERE, at the store, and not through the route: the withdrawal answers 204 + // whether or not anything was pending — deliberately, so it cannot be used as an oracle + // for which addresses have invitations — which leaves an end-to-end test unable to tell a + // working index from one that does nothing at all. + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { + return; + }; + + let invitation = StoredInvitation { + email: "invitee@example.com".to_owned(), + role: "MEMBER".to_owned(), + tenant_id: "t1".to_owned(), + inviter_user_id: "owner".to_owned(), + created_at: OffsetDateTime::UNIX_EPOCH, + }; + let hash = token_hash("inv-secret"); + assert!( + stores + .put_invitation("inv-secret", &invitation, 604_800) + .await + .is_ok() + ); + assert!( + stores + .put_invitation_index("t1", "invitee@example.com", &hash, 604_800) + .await + .is_ok() + ); + + // The index points at the record, and the record reads back through the hash it points at + // — the two halves of the only path a withdrawal has. + assert!(matches!( + stores.read_invitation_index("t1", "invitee@example.com").await, + Ok(Some(h)) if h == hash + )); + assert!(matches!( + stores.read_invitation_by_hash(&hash).await, + Ok(Some(i)) if i.role == "MEMBER" && i.email == "invitee@example.com" + )); + + // The key is derived from BOTH the tenant and the address. A key that ignored either + // would let one tenant read — and withdraw — another's invitations, or let any address + // withdraw any other's. + assert!(matches!( + stores + .read_invitation_index("t2", "invitee@example.com") + .await, + Ok(None) + )); + assert!(matches!( + stores + .read_invitation_index("t1", "someone@example.com") + .await, + Ok(None) + )); + + // Reading leaves the entry in place; TAKING removes it. `invite` supersedes through the + // taking form, and a read that consumed would drop the index on every revoke that then + // refuses on the role check. + assert!(matches!( + stores + .read_invitation_index("t1", "invitee@example.com") + .await, + Ok(Some(_)) + )); + assert!(matches!( + stores.take_invitation_index("t1", "invitee@example.com").await, + Ok(Some(h)) if h == hash + )); + assert!(matches!( + stores + .take_invitation_index("t1", "invitee@example.com") + .await, + Ok(None) + )); + + // Deleting by hash reports whether THIS call removed the record. The second call finds + // nothing: a withdrawal that reported success over an already-accepted invitation would + // tell an operator the link was live when it was already spent. + assert!(matches!( + stores.delete_invitation_by_hash(&hash).await, + Ok(true) + )); + assert!(matches!( + stores.delete_invitation_by_hash(&hash).await, + Ok(false) + )); + // …and the record is gone for the accept path too. + assert!(matches!( + stores.read_invitation_by_hash(&hash).await, + Ok(None) + )); + assert!(matches!( + stores.consume_invitation("inv-secret").await, + Ok(None) + )); +} + +#[tokio::test] +async fn a_stored_invitation_that_no_longer_parses_reads_as_absent() { + // The revocation path reaches the record through the index rather than through a token, so + // it can meet a value the accept path never would. A corrupted record answers `None` — the + // withdrawal then removes it without a role check, which is the right end state: it could + // not have been accepted either, and leaving it indexed would be worse. + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { + return; + }; + + assert!(redis.set_raw("auth:inv:deadbeef", r#"{"nope":true}"#).await); + + assert!(matches!( + stores.read_invitation_by_hash("deadbeef").await, + Ok(None) + )); + // …and it is still deletable, or the corrupted record would sit there for its whole TTL. + assert!(matches!( + stores.delete_invitation_by_hash("deadbeef").await, + Ok(true) + )); +} + #[tokio::test] async fn engine_runs_password_reset_via_token_against_redis() { let Some(redis) = common::try_start().await else { From 050c09fbb165961d02f478229d0f06ad622f9330 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:12:35 -0300 Subject: [PATCH 115/122] docs(spec): list the routes and limits this round added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route tables and the rate-limit table were missing four routes: the authenticated password change, the invitation withdrawal, and the logout and ws-ticket limits. All four are already enforced — the limits by the conformance test against `wire-contract.json`, the routes by the router — so the tables were the only place they were absent. --- docs/technical_specification.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/technical_specification.md b/docs/technical_specification.md index eb2a090..5b8b97c 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -1246,7 +1246,7 @@ The route groups and the toggle/feature that gates each: | Route group | Mounted paths (under `route_prefix`) | Gated by toggle | Cargo feature | | --- | --- | --- | --- | | Auth | `/register` `/login` `/logout` `/refresh` `/me` `/verify-email` `/resend-verification` | `auth` (default on) | — (always compiled) | -| Password reset | `/password/forgot-password` `/password/reset-password` `/password/verify-otp` `/password/resend-otp` | `password_reset` (default on) | — (always compiled) | +| Password reset | `/password/forgot-password` `/password/reset-password` `/password/verify-otp` `/password/resend-otp` `/password/change` | `password_reset` (default on) | — (always compiled) | | MFA | `/mfa/setup` `/mfa/verify-enable` `/mfa/challenge` `/mfa/disable` | `mfa` (opt-in) | `mfa` | | Sessions | `/sessions` (GET, DELETE) `/sessions/:id` (DELETE) | `sessions` (auto when `sessions.enabled`) | `sessions` | | Platform | `/platform/login` `/platform/me` `/platform/logout` `/platform/refresh` `/platform/sessions` (and `/platform/mfa/challenge` when `mfa` is also on) | `platform` (auto when `platform.enabled`) | `platform` | @@ -2617,8 +2617,11 @@ their ordering reproduces the NestJS guard pipeline. | POST | `/auth/password/reset-password` | `reset_password` | `ValidatedJson` | 204 | `ResetPasswordDto` | always | | POST | `/auth/password/verify-otp` | `verify_otp` | `ValidatedJson` | 200 | `VerifyOtpDto` | always | | POST | `/auth/password/resend-otp` | `resend_otp` | `ValidatedJson` | 200 | `ResendOtpDto` | always | +| POST | `/auth/password/change` | `change_password` | `AuthUser`, `ValidatedJson` | 204 | `ChangePasswordDto` | always | -> All four are public. `forgot-password`, `resend-otp` are anti-enumeration: the +> The first four are public. `change` is not: it re-proves the current password, so a stolen +> access token alone cannot rewrite the credential it was minted from. +> `forgot-password`, `resend-otp` are anti-enumeration: the > handler always returns the same status/body regardless of email existence, and > the engine normalizes timing. @@ -2680,9 +2683,14 @@ their ordering reproduces the NestJS guard pipeline. | ------ | --------------------------- | ------------------- | --------------------------------------------------------- | ------- | --------------------- | ----------- | | POST | `/auth/invitations` | `create_invitation` | `AuthUser` (+ `RequireRole` when configured) | 204 | `CreateInvitationDto` | invitations | | POST | `/auth/invitations/accept` | `accept_invitation` | `ValidatedJson` | 201 | `AcceptInvitationDto` | invitations | +| POST | `/auth/invitations/revoke` | `revoke_invitation` | `AuthUser` | 204 | `RevokeInvitationDto` | invitations | > `create_invitation` derives `tenant_id` from the authenticated user's claims — > never from the body — to prevent cross-tenant injection. `accept` is public. +> `revoke_invitation` takes `tenant_id` from the claims for the same reason, and answers 204 +> whether or not anything was pending: reporting the difference would make the route an oracle +> for which addresses have invitations, which is exactly what hashing the address in the +> `invidx:` index avoids disclosing. --- @@ -5018,10 +5026,13 @@ brute-force headroom per IP. | `POST /auth/login` | `login` | 5 | 60 | Brute-force resistance per IP (complements engine per-account lockout). | | `POST /auth/register` | `register` | 10 | 3600 | Throttle mass account creation. | | `POST /auth/refresh` | `refresh` | 10 | 60 | Bound refresh churn. | +| `POST /auth/logout` | `logout` | 20 | 60 | Public route: bound the cost of an unauthenticated call. | +| `POST /auth/ws-ticket` | `ws_ticket` | 20 | 60 | Bound socket-upgrade ticket minting. | | `POST /auth/password/forgot-password` | `forgot_password` | 3 | 300 | Prevent reset-email spam. | | `POST /auth/password/reset-password` | `reset_password` | 3 | 300 | Protect the reset endpoint. | | `POST /auth/password/verify-otp` | `verify_otp` | 3 | 300 | Earlier IP block than the engine's 5-attempt-per-OTP cap. | | `POST /auth/password/resend-otp` | `resend_password_otp` | 3 | 300 | Prevent reset-OTP spam (pairs with the engine resend-cooldown). | +| `POST /auth/password/change` | `change_password` | 5 | 60 | Bound credential rewriting behind a stolen access token. | | `POST /auth/verify-email` | `verify_email` | 5 | 60 | Bound email-verification attempts. | | `POST /auth/resend-verification` | `resend_verification` | 3 | 300 | Prevent verification-email spam. | | `POST /auth/mfa/setup` | `mfa_setup` | 5 | 60 | Bound setup attempts. | @@ -5031,6 +5042,7 @@ brute-force headroom per IP. | `POST /auth/platform/login` | `platform_login` | 5 | 60 | Protect admin login. | | `POST /auth/invitations` | `invitation_create` | 10 | 3600 | Prevent invitation flooding / email abuse. | | `POST /auth/invitations/accept` | `invitation_accept` | 5 | 60 | Protect invitation acceptance (token-guess resistance). | +| `POST /auth/invitations/revoke` | `invitation_revoke` | 10 | 3600 | Matches the mint, so withdrawing costs what issuing does. | | `GET /auth/sessions` | `list_sessions` | 30 | 60 | Generous read limit. | | `DELETE /auth/sessions/{id}` | `revoke_session` | 10 | 60 | Bound single-session revocation. | | `DELETE /auth/sessions/all` | `revoke_all_sessions` | 5 | 60 | Bound bulk revocation. | From 7b773942e704f9f44b17d53a65710f63cc55835e Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:29:51 -0300 Subject: [PATCH 116/122] fix(features): compile every feature combination the matrix builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature-matrix job was red on four items, all the same shape: something whose only users sit behind a `cfg` gate, but which carries no gate itself. Build without that feature and the item is orphaned, which `-D dead-code` turns into a compile error. Three of the four were invisible until `cargo hack` reached the combination that drops the feature. - `MfaService::claim_matched_recovery_code` — its only caller is `challenge_platform`, gated on `platform`. Same gate on the method. - `TokenDelivery::new` — outside tests it is reached only from the platform and OAuth route groups. Gated on `any(test, platform, oauth)`. - `InMemoryStores::recovery_claims` — its only user is the `claim_recovery_code` impl inside a `#[cfg(feature = "mfa")]` block, and the three neighbouring MFA fields already carry that gate. This one had been missed. The fourth was not a lint but a gap in the public surface: `MfaSetupDto` and `RevokeInvitationDto` were missing from the `pub use dto::{…}` list, so a consumer could name every sibling MFA and invitation body type but not the body of `POST /auth/mfa/setup` or `POST /auth/invitations/revoke`. Dead-code analysis is what surfaced it. Exporting them is the fix — gating them away would have buried the omission instead. Verified with the exact commands CI runs, under `RUSTFLAGS=-D warnings`: the 36-combination `cargo hack --each-feature` matrix, the hasher-gated combinations for `bymax-auth-crypto` and `bymax-auth`, and `cargo test --workspace --all-features` (all suites green, 441 in core). --- crates/bymax-auth-axum/src/delivery.rs | 5 +++++ crates/bymax-auth-axum/src/lib.rs | 5 +++-- crates/bymax-auth-core/src/services/mfa/challenge.rs | 5 +++++ crates/bymax-auth-core/src/testing/mod.rs | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/bymax-auth-axum/src/delivery.rs b/crates/bymax-auth-axum/src/delivery.rs index 238bf03..f61c750 100644 --- a/crates/bymax-auth-axum/src/delivery.rs +++ b/crates/bymax-auth-axum/src/delivery.rs @@ -67,6 +67,11 @@ pub(crate) struct TokenDelivery<'a> { impl<'a> TokenDelivery<'a> { /// Construct a delivery helper over the resolved adapter config, planting host-only /// cookies. Use [`TokenDelivery::with_domains`] on the routes that deliver a session. + /// + /// Gated on the features that own its call sites: outside tests it is only reached from the + /// platform and OAuth route groups, so a build with neither compiles it away rather than + /// tripping `-D dead-code`. + #[cfg(any(test, feature = "platform", feature = "oauth"))] pub(crate) fn new(config: &'a ResolvedConfig) -> Self { Self { config, diff --git a/crates/bymax-auth-axum/src/lib.rs b/crates/bymax-auth-axum/src/lib.rs index 8203561..19e2a35 100644 --- a/crates/bymax-auth-axum/src/lib.rs +++ b/crates/bymax-auth-axum/src/lib.rs @@ -41,9 +41,10 @@ mod test_support; pub use dto::{ AcceptInvitationDto, ChangePasswordDto, CreateInvitationDto, ForgotPasswordDto, LoginDto, - MfaChallengeDto, MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaVerifyDto, + MfaChallengeDto, MfaDisableDto, MfaRegenerateRecoveryCodesDto, MfaSetupDto, MfaVerifyDto, OAuthCallbackQuery, OAuthInitiateQuery, PlatformLoginDto, RefreshDto, RegisterDto, - ResendOtpDto, ResendVerificationDto, ResetPasswordDto, VerifyEmailDto, VerifyOtpDto, + ResendOtpDto, ResendVerificationDto, ResetPasswordDto, RevokeInvitationDto, VerifyEmailDto, + VerifyOtpDto, }; pub use extractors::{ AdminRole, AuthUser, CurrentUser, MfaSatisfied, OptionalAuthUser, RequireRole, Role, diff --git a/crates/bymax-auth-core/src/services/mfa/challenge.rs b/crates/bymax-auth-core/src/services/mfa/challenge.rs index 8da6b4e..6d7817e 100644 --- a/crates/bymax-auth-core/src/services/mfa/challenge.rs +++ b/crates/bymax-auth-core/src/services/mfa/challenge.rs @@ -339,6 +339,11 @@ impl MfaService { /// The plane-shared core of [`Self::accept_recovery_code`]: match the code against a stored /// set, then claim it. Split out because the platform path already holds the stored set and /// has no `AuthUser` to hand over. + /// + /// Gated on `platform` because [`Self::challenge_platform`] is its only caller and carries the + /// same gate; without it the method is dead in every build that leaves the feature off, and + /// `-D dead-code` turns that into a compile error. + #[cfg(feature = "platform")] async fn claim_matched_recovery_code( &self, ctx: MfaContext, diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index 8ee6518..aa84f28 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -349,6 +349,7 @@ pub struct InMemoryStores { #[cfg(feature = "mfa")] mfa_replay: Mutex>, /// Single-use claims on MFA recovery codes (`rcu:`). + #[cfg(feature = "mfa")] recovery_claims: Mutex>, /// `os:` — the single-use OAuth `state` + PKCE payload keyed by `sha256(state)`. #[cfg(feature = "oauth")] From 25647fc5f6cd06fca62ffb505336d6cca70d1661 Mon Sep 17 00:00:00 2001 From: Maximiliano Salvatti <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:42:04 -0300 Subject: [PATCH 117/122] test(invitations): cover the refused withdrawal, the one error path revoke has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `revoke` answers 204 for an address with no pending invitation — deliberately, so the endpoint is not an oracle for who has been invited. That idempotence meant every existing case took the `Ok` arm, leaving the handler's `Err` arm the single uncovered line in the adapter and dropping the crate under the 100% gate. The path that does error is authority: withdrawal is checked against the role recorded on the INVITATION, not the one named in the request, because the caller supplies only an address. A USER naming an address that holds an ADMIN invitation is refused with 403. Assert that, then assert the invitation survived the refusal and its issuer can still withdraw it — a guard that refused the caller but deleted the record anyway would pass the first assertion alone. Red-checked: with the `InsufficientRole` return removed from `revoke_invitation`, the case fails on 204 vs 403, so it observes the guard and not something incidental. `routes/invitations.rs` goes to 100% lines, functions and regions; the package reaches 100% lines and functions. --- crates/bymax-auth-axum/tests/redis_e2e.rs | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/bymax-auth-axum/tests/redis_e2e.rs b/crates/bymax-auth-axum/tests/redis_e2e.rs index 54824cd..acb6281 100644 --- a/crates/bymax-auth-axum/tests/redis_e2e.rs +++ b/crates/bymax-auth-axum/tests/redis_e2e.rs @@ -574,6 +574,53 @@ async fn full_router_against_real_redis() { .await; assert_eq!(again.status, StatusCode::NO_CONTENT); + // Withdrawal is authority-checked against the role recorded on the INVITATION, not the one + // named in the request — the caller only supplies an address. A USER naming an address that + // holds an ADMIN invitation is refused, which is the single path where `revoke` answers with + // an error instead of its idempotent 204. Without this case the endpoint's whole `Err` arm + // is unexercised, and a regression that downgraded the refusal into a successful withdrawal + // would let any account cancel an administrator's invitation. + let peer = call( + &app, + Method::POST, + "/auth/invitations", + Some(serde_json::json!({ "email": "peer@e.com", "role": "ADMIN" })), + &[("access_token", &inviter_access)], + ) + .await; + assert_eq!(peer.status, StatusCode::NO_CONTENT); + seed_user(&users, "plain@e.com", "USER").await; + let plogin = call( + &app, + Method::POST, + "/auth/login", + Some(serde_json::json!({ + "email": "plain@e.com", "password": "glidingwalnut42", "tenantId": TENANT + })), + &[], + ) + .await; + let plain_access = plogin.cookie_value("access_token"); + let denied = call( + &app, + Method::POST, + "/auth/invitations/revoke", + Some(serde_json::json!({ "email": "peer@e.com" })), + &[("access_token", &plain_access)], + ) + .await; + assert_eq!(denied.status, StatusCode::FORBIDDEN); + // The invitation survived the refused withdrawal: the ADMIN who issued it still can. + let cleanup = call( + &app, + Method::POST, + "/auth/invitations/revoke", + Some(serde_json::json!({ "email": "peer@e.com" })), + &[("access_token", &inviter_access)], + ) + .await; + assert_eq!(cleanup.status, StatusCode::NO_CONTENT); + // ---- MFA enrolment over real Redis (setup → verify-enable with a live TOTP) --------- seed_user(&users, "mfa@e.com", "USER").await; let mlogin = call( From 7810f858cb549df67733689ac19d314f535c0749 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:06:57 -0300 Subject: [PATCH 118/122] feat(core, axum)!: let an account change its email address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address is the account's recovery credential: whoever controls it can drive a password reset to a mailbox the owner does not read. Until now the library could mint one and never move it, so a user whose address died — job change, provider shutdown — was locked out permanently, with no path back that did not involve an operator editing the database by hand. Two steps, and the split is the security property: - `POST /auth/email/change` re-proves the current password and mails a single-use token to the NEW address. Nothing about the account changes. The re-prove is what stops a stolen access token from moving the recovery address: a thief has to already hold the credential that would take the account anyway. - `POST /auth/email/change/confirm` consumes that token. Public, because the person holding it is proving control of a mailbox rather than of a session — requiring a login would break the case the flow exists to serve. The old address is then told (NIST SP 800-63B §4.6). That notice is the last message the owner can receive somewhere they still control. Delivery is fire-and-forget but logged; the verification's is not, because a change whose token could not be sent has not started. No session is revoked. Anyone who can complete the flow could already sign in, so ending the caller's other devices would cost the user and buy nothing — the defence is the re-prove plus the notification. The stored token is bound to the password in force when it was minted, the same binding reset proofs carry: a planted request dies the moment the victim changes their password. Uniqueness is re-checked at confirm time, because the two steps are separated by the whole TTL and whoever registers the address in between would otherwise lose it. BREAKING: `UserRepository` gains `update_email`; `PasswordResetStore` gains `put_email_change` / `consume_email_change`; `EmailProvider` gains a REQUIRED `send_email_change_verification`. Required rather than defaulted on purpose — a no-op default would swallow the token and leave the flow minting `ec:` keys nobody receives, which looks like success from every side. Here the compiler enforces what nest-auth can only check at boot. --- CHANGELOG.md | 27 ++ conformance/wire-contract.json | 48 +- crates/bymax-auth-axum/src/dto.rs | 30 ++ crates/bymax-auth-axum/src/rate_limit.rs | 10 +- crates/bymax-auth-axum/src/router.rs | 3 + .../src/routes/email_change.rs | 66 +++ crates/bymax-auth-axum/src/routes/mod.rs | 2 + crates/bymax-auth-axum/src/state.rs | 3 + crates/bymax-auth-axum/tests/adapter.rs | 73 +++ crates/bymax-auth-axum/tests/common/mod.rs | 19 + crates/bymax-auth-core/src/config/mod.rs | 27 ++ crates/bymax-auth-core/src/config/profiles.rs | 7 +- .../src/services/auth/detached.rs | 9 + .../src/services/auth/email_change.rs | 445 ++++++++++++++++++ .../src/services/auth/invitation.rs | 9 + .../bymax-auth-core/src/services/auth/mod.rs | 1 + .../src/services/auth/password_reset.rs | 28 +- .../bymax-auth-core/src/services/mfa/tests.rs | 9 + crates/bymax-auth-core/src/testing/mod.rs | 40 +- crates/bymax-auth-core/src/traits/email.rs | 59 +++ crates/bymax-auth-core/src/traits/mod.rs | 6 +- .../bymax-auth-core/src/traits/repository.rs | 21 + crates/bymax-auth-core/src/traits/store.rs | 43 ++ crates/bymax-auth-redis/src/keys.rs | 4 + .../bymax-auth-redis/src/stores/single_use.rs | 22 +- crates/bymax-auth-types/src/error.rs | 11 + packages/rust-auth/src/shared/error-codes.ts | 2 +- 27 files changed, 1008 insertions(+), 16 deletions(-) create mode 100644 crates/bymax-auth-axum/src/routes/email_change.rs create mode 100644 crates/bymax-auth-core/src/services/auth/email_change.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8162e..de7e3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,33 @@ version bump. ### Added +- **Changing the address on an account** — `POST /auth/email/change` and + `POST /auth/email/change/confirm`, opt-in behind `controllers.email_change`. The address is + the account's recovery credential: whoever controls it can drive a password reset to a + mailbox the owner does not read. Until now the library could mint one and never move it, so + a user whose address died was locked out permanently. + + Two steps, and the split is the security property. The request re-proves the current password + and mails a single-use token to the NEW address; nothing about the account changes. The + confirmation consumes that token and is public, because the person holding it is proving + control of a mailbox rather than of a session. The old address is then notified (NIST SP + 800-63B §4.6) — the last message the owner can receive somewhere they still control, and what + turns a silent takeover into one they can see. + + No session is revoked: anyone who can complete the flow could already sign in, so ending the + caller's devices would cost the user and buy nothing. The stored token is bound to the + password in force when it was minted, so a planted request dies the moment the victim changes + their password; uniqueness is re-checked at confirm time, because the two steps are separated + by the whole TTL. + + BREAKING: `UserRepository` gains `update_email`, `PasswordResetStore` gains + `put_email_change` / `consume_email_change`, and `EmailProvider` gains a **required** + `send_email_change_verification`. That one is required rather than defaulted on purpose — a + no-op default would swallow the token and leave the flow minting `ec:` keys nobody receives, + a failure that looks like success from every side. The notice to the old address + (`send_email_changed_notification`) is defaulted, like the other notices. + + - **`AuthEngine::unlock_account(email, tenant_id)` — clearing a brute-force lockout.** A lockout is a denial of service the library imposes on its own users, and it could only be waited out: the counter is keyed by an HMAC of `{tenant_id}:{email}` under the library's own diff --git a/conformance/wire-contract.json b/conformance/wire-contract.json index 54eaba1..2cb8082 100644 --- a/conformance/wire-contract.json +++ b/conformance/wire-contract.json @@ -64,7 +64,8 @@ "oauthState": "os", "wsTicket": "wst", "invitation": "inv", - "recoveryCodeClaim": "rcu" + "recoveryCodeClaim": "rcu", + "emailChangeToken": "ec" }, "recoveryCodeClaim": { @@ -184,6 +185,28 @@ "tenantId": "omitted from the record entirely when the ticket is not tenant-scoped", "$comment": "A verified-identity SNAPSHOT, never a token: no jti, no signature, no expiry of its own. The socket it authorizes cannot be turned back into a session. Redemption is a single-use GETDEL, so a captured upgrade URL is worthless once the socket opens." }, + "emailChangeContext": { + "key": "ec:{sha256(token)}", + "fields": ["userId", "newEmail", "tenantId", "passwordFingerprint"], + "$comment": [ + "The pending address change, held under a token that is mailed to the NEW address and", + "nowhere else — proving the requester controls it before it becomes the account's", + "address. Consumed with GETDEL, so a link works exactly once.", + "", + "`newEmail` is stored ALREADY NORMALIZED (trimmed, lowercased), because the uniqueness", + "check at confirm time compares it against the repository the same way login does. The", + "old address is not stored: it is read from the account at confirm time, so a record", + "that outlives an intervening change still notifies wherever the account actually is.", + "", + "`passwordFingerprint` binds the token to the password in force when it was minted,", + "exactly as the reset context does. An attacker who plants a change request and waits", + "loses it the moment the victim changes their password — which is the first thing a", + "victim does. An ABSENT field is read as 'no binding' and accepted, so a rolling deploy", + "does not break the changes already in flight." + ], + "passwordFingerprint": "sha256 of the account's password hash when the token was minted, or the empty string when the account had none." + }, + "passwordResetContext": { "key": "pw_reset:{sha256(token)}", "fields": ["userId", "email", "tenantId", "passwordFingerprint"], @@ -235,6 +258,8 @@ "invitationCreate": "10/3600", "invitationAccept": "5/60", "invitationRevoke": "10/3600", + "emailChangeRequest": "3/300", + "emailChangeConfirm": "5/60", "listSessions": "30/60", "revokeSession": "10/60", "revokeAllSessions": "5/60", @@ -250,6 +275,27 @@ "inert until the user's first bump. Absent from the MFA challenge token, which grants no", "resource access on its own." ], + + "issuerAndAudience": { + "claims": ["iss", "aud"], + "type": "string, or absent", + "configuredBy": "jwt.issuer / jwt.audience", + "$comment": [ + "Optional on both sides and absent by default, so an existing deployment is unchanged.", + "When configured, the value is STAMPED on every token the backend mints and REQUIRED on", + "every token it verifies: a token carrying the wrong issuer or audience, or none at all,", + "is rejected. That is the whole point — a verifier that accepts an unstamped token gives", + "an attacker a way to opt out of the check.", + "", + "Both backends must be configured with the SAME pair or they stop accepting each other's", + "tokens, which is the one way this setting can split a shared deployment. It is also the", + "reason it is not on by default.", + "", + "Turning it on invalidates the access tokens already in flight, because they were minted", + "without the claims. The window is one access-token lifetime and clients recover by", + "refreshing — the refresh token is opaque and carries no claims, so rotation is unaffected." + ] + }, "epoch": { "claim": "epoch", "type": "non-negative integer", diff --git a/crates/bymax-auth-axum/src/dto.rs b/crates/bymax-auth-axum/src/dto.rs index 7c93aed..a4de80b 100644 --- a/crates/bymax-auth-axum/src/dto.rs +++ b/crates/bymax-auth-axum/src/dto.rs @@ -255,6 +255,36 @@ pub struct CreateInvitationDto { pub tenant_name: Option, } +/// `POST /auth/email/change` body (authenticated). +/// +/// The account is never named here — it comes from the caller's own claims. A body that could +/// name a user id would let anyone holding any session move any account's recovery address. +#[derive(Debug, Deserialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChangeEmailDto { + /// The address to move to. + #[garde(email)] + pub new_email: String, + /// The account's current password, re-proved because the address is the recovery + /// credential. Bounded at 128 to match the hasher's input limit — an unbounded field is a + /// cheap way to make someone else pay for a key derivation. + #[garde(length(min = 1, max = 128))] + pub current_password: String, +} + +/// `POST /auth/email/change/confirm` body (public). +/// +/// The token is the whole payload: it already names the account, the target address and the +/// tenant, all fixed when it was minted. Accepting any of those from the body would let the +/// holder of one link redirect it at a different account. +#[derive(Debug, Deserialize, Validate)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConfirmEmailChangeDto { + /// The single-use token mailed to the new address — exactly 64 hex characters. + #[garde(length(min = 64, max = 64))] + pub token: String, +} + /// `POST /auth/invitations/revoke` body (authenticated). /// /// The address is the entire payload because it is the only handle the issuing side has: the diff --git a/crates/bymax-auth-axum/src/rate_limit.rs b/crates/bymax-auth-axum/src/rate_limit.rs index 4537ceb..931ecce 100644 --- a/crates/bymax-auth-axum/src/rate_limit.rs +++ b/crates/bymax-auth-axum/src/rate_limit.rs @@ -147,6 +147,10 @@ pub struct RateLimitConfig { pub invitation_accept: Option, /// `POST /auth/invitations/revoke` — 10 / 3600s, matching the mint. pub invitation_revoke: Option, + /// `POST /auth/email/change` — 3 / 300s, matching the reset-email limits. + pub email_change_request: Option, + /// `POST /auth/email/change/confirm` — 5 / 60s. + pub email_change_confirm: Option, /// `GET /auth/sessions` — 30 / 60s. pub list_sessions: Option, /// `DELETE /auth/sessions/{id}` — 10 / 60s. @@ -201,6 +205,8 @@ impl Default for RateLimitConfig { invitation_create: Some(RateLimit::new(10, 3600)), invitation_accept: Some(RateLimit::new(5, 60)), invitation_revoke: Some(RateLimit::new(10, 3600)), + email_change_request: Some(RateLimit::new(3, 300)), + email_change_confirm: Some(RateLimit::new(5, 60)), list_sessions: Some(RateLimit::new(30, 60)), revoke_session: Some(RateLimit::new(10, 60)), revoke_all_sessions: Some(RateLimit::new(5, 60)), @@ -295,7 +301,7 @@ mod tests { fn every_default_limit_matches_the_shared_wire_contract() { let contract = contract_limits(); let defaults = RateLimitConfig::default(); - let pairs: [(&str, Option); 25] = [ + let pairs: [(&str, Option); 27] = [ ("login", defaults.login), ("register", defaults.register), ("refresh", defaults.refresh), @@ -313,6 +319,8 @@ mod tests { ("invitationCreate", defaults.invitation_create), ("invitationAccept", defaults.invitation_accept), ("invitationRevoke", defaults.invitation_revoke), + ("emailChangeRequest", defaults.email_change_request), + ("emailChangeConfirm", defaults.email_change_confirm), ("listSessions", defaults.list_sessions), ("revokeSession", defaults.revoke_session), ("revokeAllSessions", defaults.revoke_all_sessions), diff --git a/crates/bymax-auth-axum/src/router.rs b/crates/bymax-auth-axum/src/router.rs index 547cb7c..ed0dcdd 100644 --- a/crates/bymax-auth-axum/src/router.rs +++ b/crates/bymax-auth-axum/src/router.rs @@ -141,6 +141,9 @@ fn mount_optional_groups( if groups.invitations { router = router.merge(crate::routes::invitations::routes(config, ip_source)); } + if groups.email_change { + router = router.merge(crate::routes::email_change::routes(config, ip_source)); + } router } diff --git a/crates/bymax-auth-axum/src/routes/email_change.rs b/crates/bymax-auth-axum/src/routes/email_change.rs new file mode 100644 index 0000000..e705d94 --- /dev/null +++ b/crates/bymax-auth-axum/src/routes/email_change.rs @@ -0,0 +1,66 @@ +//! The `email` route group (§8.2.9): requesting an address change behind a password re-prove, +//! and confirming it with the token that was mailed to the new address. +//! +//! The split is the security property. The request is authenticated and changes nothing; the +//! confirmation is public because the person holding the token is proving control of a +//! mailbox, not of a session — requiring a login there would break the case the flow exists to +//! serve, where someone confirms from the device their new mail is on. + +use axum::Router; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use http::StatusCode; + +use crate::dto::{ChangeEmailDto, ConfirmEmailChangeDto}; +use crate::extractors::AuthUser; +use crate::response::error_response; +use crate::state::{AuthState, AxumAuthConfig, ClientIpSource}; +use crate::validation::ValidatedJson; + +/// Assemble the `email` group under the `email` segment with per-route limits. +pub(crate) fn routes(config: &AxumAuthConfig, ip_source: ClientIpSource) -> Router { + let limits = &config.rate_limits; + Router::new() + .route( + "/email/change", + crate::router::throttled(post(request), limits.email_change_request, ip_source), + ) + .route( + "/email/change/confirm", + crate::router::throttled(post(confirm), limits.email_change_confirm, ip_source), + ) +} + +/// `POST /auth/email/change` (204). Requires [`AuthUser`]. The account comes from the caller's +/// claims — never the body — so a request cannot move someone else's address. +/// +/// Answers 204 and nothing else: the failure modes worth reporting (the address is taken, the +/// password was wrong) are already errors, and anything beyond that would be describing the +/// state of an account to whoever is holding its token. +async fn request( + State(state): State, + user: AuthUser, + ValidatedJson(dto): ValidatedJson, +) -> Response { + match state + .engine() + .request_email_change(&user.0.sub, &dto.new_email, &dto.current_password) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error_response(&error), + } +} + +/// `POST /auth/email/change/confirm` (204). Public and rate-limited. Guessing is bounded by the +/// token being 256 bits of entropy looked up by its SHA-256 — a wrong value reaches no record. +async fn confirm( + State(state): State, + ValidatedJson(dto): ValidatedJson, +) -> Response { + match state.engine().confirm_email_change(&dto.token).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error_response(&error), + } +} diff --git a/crates/bymax-auth-axum/src/routes/mod.rs b/crates/bymax-auth-axum/src/routes/mod.rs index ad39ffc..2d00df8 100644 --- a/crates/bymax-auth-axum/src/routes/mod.rs +++ b/crates/bymax-auth-axum/src/routes/mod.rs @@ -9,6 +9,8 @@ pub(crate) mod auth; pub(crate) mod password_reset; +pub(crate) mod email_change; + #[cfg(feature = "invitations")] pub(crate) mod invitations; #[cfg(feature = "mfa")] diff --git a/crates/bymax-auth-axum/src/state.rs b/crates/bymax-auth-axum/src/state.rs index febb295..ba36d5a 100644 --- a/crates/bymax-auth-axum/src/state.rs +++ b/crates/bymax-auth-axum/src/state.rs @@ -86,6 +86,8 @@ pub struct RouteGroups { pub oauth: bool, /// The invitations group. pub invitations: bool, + /// The address-change group. + pub email_change: bool, } impl RouteGroups { @@ -102,6 +104,7 @@ impl RouteGroups { platform_mfa: toggles.platform && toggles.mfa, oauth: toggles.oauth, invitations: toggles.invitations, + email_change: toggles.email_change, } } } diff --git a/crates/bymax-auth-axum/tests/adapter.rs b/crates/bymax-auth-axum/tests/adapter.rs index b1781fb..2a4994e 100644 --- a/crates/bymax-auth-axum/tests/adapter.rs +++ b/crates/bymax-auth-axum/tests/adapter.rs @@ -3120,3 +3120,76 @@ async fn a_cross_site_fetch_with_no_origin_header_is_refused() { assert_eq!(refused.status, StatusCode::FORBIDDEN); } + +#[tokio::test] +async fn the_address_change_routes_move_an_account_only_after_the_new_address_proves_itself() { + // End to end through the router: the request changes nothing, the confirmation changes + // everything, and each guard along the way answers through the HTTP surface a consumer + // actually sees. + let Some(h) = build(EngineSpec { + email_change: true, + ..EngineSpec::default() + }) else { + return; + }; + let app = router(&h); + seed_user(&h, "old@e.com", "glidingwalnut42", "USER").await; + let login = Req::post("/auth/login") + .json(serde_json::json!({ + "email": "old@e.com", "password": "glidingwalnut42", "tenantId": TENANT + })) + .send(&app) + .await; + let access = login.cookie_value("access_token").unwrap_or_default(); + + // Unauthenticated, the request is refused before anything is read. + let anonymous = Req::post("/auth/email/change") + .json(serde_json::json!({ "newEmail": "new@e.com", "currentPassword": "glidingwalnut42" })) + .send(&app) + .await; + assert_eq!(anonymous.status, StatusCode::UNAUTHORIZED); + + // The wrong current password reads as invalid credentials — the same answer a failed login + // gives, so a thief holding the token learns nothing new. + let wrong = Req::post("/auth/email/change") + .cookie("access_token", &access) + .json(serde_json::json!({ "newEmail": "new@e.com", "currentPassword": "not-it" })) + .send(&app) + .await; + assert_eq!(wrong.status, StatusCode::UNAUTHORIZED); + + // The right one starts the change… + let requested = Req::post("/auth/email/change") + .cookie("access_token", &access) + .json(serde_json::json!({ "newEmail": "new@e.com", "currentPassword": "glidingwalnut42" })) + .send(&app) + .await; + assert_eq!(requested.status, StatusCode::NO_CONTENT); + + // …and the account still answers under the OLD address, because nothing has been proved. + let still_old = Req::post("/auth/login") + .json(serde_json::json!({ + "email": "old@e.com", "password": "glidingwalnut42", "tenantId": TENANT + })) + .send(&app) + .await; + assert_eq!(still_old.status, StatusCode::OK); + + // A bogus token reaches no record. + let bogus = Req::post("/auth/email/change/confirm") + .json(serde_json::json!({ "token": "0".repeat(64) })) + .send(&app) + .await; + assert_eq!(bogus.status, StatusCode::BAD_REQUEST); + assert_eq!( + bogus.json()["error"]["code"], + "auth.email_change_token_invalid" + ); + + // …and a token of the wrong shape is refused by validation before it is hashed at all. + let malformed = Req::post("/auth/email/change/confirm") + .json(serde_json::json!({ "token": "too-short" })) + .send(&app) + .await; + assert_eq!(malformed.status, StatusCode::BAD_REQUEST); +} diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 7bb053a..34b7449 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -64,6 +64,7 @@ pub struct EngineSpec { pub platform: bool, pub oauth: bool, pub invitations: bool, + pub email_change: bool, pub sessions: bool, pub verification_required: bool, pub allow_oauth: bool, @@ -83,6 +84,7 @@ impl Default for EngineSpec { platform: false, oauth: false, invitations: false, + email_change: false, sessions: false, verification_required: false, allow_oauth: false, @@ -150,6 +152,7 @@ pub fn build(spec: EngineSpec) -> Option { config.sessions.enabled = spec.sessions; config.controllers.sessions = spec.sessions; config.controllers.invitations = spec.invitations; + config.controllers.email_change = spec.email_change; config.invitations.enabled = spec.invitations; config.controllers.oauth = spec.oauth; let domain_resolver = if spec.cookie_domains.is_empty() { @@ -584,6 +587,22 @@ impl bymax_auth_core::traits::WsTicketStore for FailingStores { #[async_trait] impl bymax_auth_core::traits::PasswordResetStore for FailingStores { + async fn put_email_change( + &self, + token: &str, + context: &bymax_auth_core::traits::EmailChangeContext, + ttl_secs: u64, + ) -> Result<(), bymax_auth_types::AuthError> { + self.inner.put_email_change(token, context, ttl_secs).await + } + async fn consume_email_change( + &self, + token: &str, + ) -> Result, bymax_auth_types::AuthError> + { + self.inner.consume_email_change(token).await + } + async fn put_token( &self, token: &str, diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index e926576..dd9375b 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -453,6 +453,28 @@ impl Default for InvitationConfig { } } +/// Address-change policy. +/// +/// The flow itself is switched on by `controllers.email_change`; this only tunes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EmailChangeConfig { + /// Verification-token TTL, default 3600s (1h). + /// + /// Shorter than an invitation because the recipient is a user who just asked for the + /// change and is waiting on the message — and because the token points at the account's + /// recovery credential, so a link sitting in a mailbox for two days is a longer window + /// than the flow needs. + pub token_ttl: Duration, +} + +impl Default for EmailChangeConfig { + fn default() -> Self { + Self { + token_ttl: Duration::from_secs(3_600), + } + } +} + /// OAuth redirect/flow knobs and the built-in Google provider credentials. #[derive(Clone, Debug, Default)] pub struct OAuthConfig { @@ -520,6 +542,8 @@ pub struct ControllerToggles { pub oauth: bool, /// The invitations group (auto-true when `invitations.enabled`), default false. pub invitations: bool, + /// Mount the address-change routes, default false. Opt-in. + pub email_change: bool, } impl Default for ControllerToggles { @@ -528,6 +552,7 @@ impl Default for ControllerToggles { auth: true, password_reset: true, mfa: false, + email_change: false, sessions: false, platform: false, oauth: false, @@ -570,6 +595,8 @@ pub struct AuthConfig { pub platform: PlatformConfig, /// Invitation configuration. pub invitations: InvitationConfig, + /// Address-change policy. + pub email_change: EmailChangeConfig, /// OAuth configuration. pub oauth: OAuthConfig, /// Route prefix, default `auth`. diff --git a/crates/bymax-auth-core/src/config/profiles.rs b/crates/bymax-auth-core/src/config/profiles.rs index f1a81a3..fc47591 100644 --- a/crates/bymax-auth-core/src/config/profiles.rs +++ b/crates/bymax-auth-core/src/config/profiles.rs @@ -11,9 +11,9 @@ use std::time::Duration; use super::{ - AuthConfig, BruteForceConfig, ControllerToggles, CookieConfig, EmailVerificationConfig, - InvitationConfig, JwtConfig, OAuthConfig, PasswordAlgorithm, PasswordConfig, - PasswordResetConfig, PlatformConfig, RolesConfig, SessionConfig, TokenDelivery, + AuthConfig, BruteForceConfig, ControllerToggles, CookieConfig, EmailChangeConfig, + EmailVerificationConfig, InvitationConfig, JwtConfig, OAuthConfig, PasswordAlgorithm, + PasswordConfig, PasswordResetConfig, PlatformConfig, RolesConfig, SessionConfig, TokenDelivery, }; impl AuthConfig { @@ -37,6 +37,7 @@ impl AuthConfig { email_verification: EmailVerificationConfig::default(), platform: PlatformConfig::default(), invitations: InvitationConfig::default(), + email_change: EmailChangeConfig::default(), oauth: OAuthConfig::default(), route_prefix: "auth".to_owned(), redis_namespace: "auth".to_owned(), diff --git a/crates/bymax-auth-core/src/services/auth/detached.rs b/crates/bymax-auth-core/src/services/auth/detached.rs index 9e98ccd..28dba75 100644 --- a/crates/bymax-auth-core/src/services/auth/detached.rs +++ b/crates/bymax-auth-core/src/services/auth/detached.rs @@ -310,6 +310,15 @@ mod tests { #[async_trait::async_trait] impl EmailProvider for RecordingEmails { + async fn send_email_change_verification( + &self, + _new_email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_token( &self, email: &str, diff --git a/crates/bymax-auth-core/src/services/auth/email_change.rs b/crates/bymax-auth-core/src/services/auth/email_change.rs new file mode 100644 index 0000000..f22c95b --- /dev/null +++ b/crates/bymax-auth-core/src/services/auth/email_change.rs @@ -0,0 +1,445 @@ +//! Changing the address on an account (§7.11), in two steps. +//! +//! The address is the account's recovery credential: whoever controls it can drive a password +//! reset to a mailbox the owner does not read. That makes moving it a security operation, not +//! a profile edit, and it is why the flow costs three things rather than one. +//! +//! **The current password is re-proved.** A stolen access token alone cannot move the recovery +//! address — the thief has to already hold the credential that would let them take the account +//! anyway. It is also why no session is revoked here: anyone who can complete this flow could +//! already sign in, so ending the caller's other sessions would cost the user their devices +//! and buy nothing. +//! +//! **The new address is proved before it is adopted.** A token goes to it and nowhere else, so +//! a typo cannot lock the owner out of their own account and an attacker cannot point the +//! account at a mailbox they merely claim. +//! +//! **The old address is told.** NIST SP 800-63B §4.6 asks for notification of a credential +//! change, and this is the one that matters most: it is the last message the owner can receive +//! at an address they still control, and it is what turns a silent takeover into one they can +//! see happening. +//! +//! Held byte-compatible with nest-auth over the shared `ec:` keyspace, so a change requested +//! through one backend is confirmable through the other. + +use bymax_auth_crypto::token::generate_secure_token; +use bymax_auth_types::{AuthError, AuthUser}; + +use crate::engine::AuthEngine; +use crate::normalize::normalize_email; +use crate::services::auth::map_repository_error; +use crate::traits::EmailChangeContext; + +/// Bytes of entropy in an address-change token before hex encoding (256-bit, 64 hex chars). +const EMAIL_CHANGE_TOKEN_BYTES: usize = 32; + +impl AuthEngine { + /// Start an address change: re-prove the password, then mail a single-use token to the new + /// address. Nothing about the account changes until that token comes back. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidCredentials`] when the account has no local password or the + /// submitted one is wrong — the same error a failed login returns, so a thief holding an + /// access token learns nothing they did not already know. + /// Returns [`AuthError::EmailAlreadyExists`] when the address is the account's own or + /// belongs to another account in the tenant, or a store/repository [`AuthError`]. + pub async fn request_email_change( + &self, + user_id: &str, + new_email: &str, + current_password: &str, + ) -> Result<(), AuthError> { + let new_email = normalize_email(new_email); + let store = self.password_reset_store().ok_or_else(|| { + crate::services::internal_error("password reset store not configured") + })?; + + let user = self + .user_repository() + .find_by_id(user_id, None) + .await + .map_err(map_repository_error)? + // A verified token whose subject no longer exists, and an account with no local + // password, answer identically: the caller cannot prove a credential this account + // does not have. + .ok_or(AuthError::InvalidCredentials)?; + let Some(phc) = user.password_hash.clone() else { + return Err(AuthError::InvalidCredentials); + }; + + if !self + .passwords() + .verify(current_password, &phc) + .await? + .matched + { + tracing::warn!(%user_id, "email change: current password rejected"); + return Err(AuthError::InvalidCredentials); + } + + self.assert_address_is_free(&user, &new_email).await?; + + let raw = generate_secure_token(EMAIL_CHANGE_TOKEN_BYTES); + let context = EmailChangeContext { + user_id: user_id.to_owned(), + new_email: new_email.clone(), + tenant_id: user.tenant_id.clone(), + // Binds the token to the password in force right now, exactly as a reset proof is + // bound. An attacker who plants a change request and waits loses it the moment the + // victim changes their password — which is the first thing a victim does. + password_fingerprint: super::password_reset::password_fingerprint(&user), + }; + let ttl = self.config().config().email_change.token_ttl.as_secs(); + store.put_email_change(&raw, &context, ttl).await?; + + // Delivery failure is surfaced, not swallowed: a change whose verification could not be + // sent has not started, and telling the caller it succeeded would leave them waiting on + // a message that is never coming. + self.email_provider() + .send_email_change_verification(&new_email, &raw, None) + .await + .map_err(|error| { + tracing::error!(%error, "email change: verification could not be delivered"); + crate::services::internal_error("email change verification delivery failed") + })?; + + tracing::info!(%user_id, "email change: verification sent"); + Ok(()) + } + + /// Complete an address change against a token that came back from the new address. + /// + /// # Errors + /// + /// Returns [`AuthError::EmailChangeTokenInvalid`] when the token is unknown, expired, + /// already used, or no longer bound to the account's password; + /// [`AuthError::EmailAlreadyExists`] when the address was taken between the request and the + /// confirmation; or a store/repository [`AuthError`]. + pub async fn confirm_email_change(&self, token: &str) -> Result<(), AuthError> { + let store = self.password_reset_store().ok_or_else(|| { + crate::services::internal_error("password reset store not configured") + })?; + + // Atomic read-and-delete: a link works exactly once, whatever happens after. + let context = store + .consume_email_change(token) + .await? + .ok_or(AuthError::EmailChangeTokenInvalid)?; + + let user = self + .user_repository() + .find_by_id(&context.user_id, None) + .await + .map_err(map_repository_error)? + .ok_or(AuthError::EmailChangeTokenInvalid)?; + + assert_still_bound(&context, &user)?; + // Re-checked here and not only at request time: the two are separated by the whole TTL, + // and whoever registers the address in between would otherwise lose it to this change. + self.assert_address_is_free(&user, &context.new_email) + .await?; + + let old_email = user.email.clone(); + self.user_repository() + .update_email(&context.user_id, &context.new_email) + .await + .map_err(map_repository_error)?; + tracing::info!(user_id = %context.user_id, "email change: address changed"); + + // Fire-and-forget, but logged: a change the user asked for and proved is not rolled + // back because a mail server was down, and an operator needs to know when the notice — + // the owner's last chance to see a takeover — did not go out. + if let Err(error) = self + .email_provider() + .send_email_changed_notification(&old_email, &context.new_email, None) + .await + { + tracing::error!(%error, "email change: notification to the previous address failed"); + } + Ok(()) + } + + /// Refuse an address the account already has, or that another account in the tenant holds. + /// + /// Answering [`AuthError::EmailAlreadyExists`] does disclose that an address is registered + /// — the same disclosure `register` and invitation acceptance already make, and the same + /// one the caller could obtain there. Withholding it here would buy nothing while leaving a + /// user who typos into a colleague's address waiting on a message that never comes, with no + /// way to tell why. + /// + /// The account's own current address is refused through the same error: it is a change that + /// changes nothing, and letting it through would send a verification for a move that is not + /// happening. + async fn assert_address_is_free( + &self, + user: &AuthUser, + new_email: &str, + ) -> Result<(), AuthError> { + if normalize_email(&user.email) == new_email { + return Err(AuthError::EmailAlreadyExists); + } + let taken = self + .user_repository() + .find_by_email(new_email, &user.tenant_id) + .await + .map_err(map_repository_error)? + .is_some(); + if taken { + return Err(AuthError::EmailAlreadyExists); + } + Ok(()) + } +} + +/// Refuse a token whose binding no longer matches the account's password. +/// +/// An empty stored fingerprint means the token predates the binding — a rolling deploy, or a +/// sibling implementation that has not taken this change — and is accepted, exactly as the +/// reset flow accepts one: refusing them would break every change in flight for a window this +/// narrow. +fn assert_still_bound(context: &EmailChangeContext, user: &AuthUser) -> Result<(), AuthError> { + if context.password_fingerprint.is_empty() { + return Ok(()); + } + if context.password_fingerprint == super::password_reset::password_fingerprint(user) { + return Ok(()); + } + tracing::warn!( + user_id = %context.user_id, + "email change: token no longer bound to the account password" + ); + Err(AuthError::EmailChangeTokenInvalid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::auth::test_support::{Harness, SeedUser, base_config, harness}; + use crate::traits::{PasswordResetStore, UserRepository}; + + /// A harness with the address-change flow tunable, and email verification off so seeding + /// stays about the address rather than about the onboarding gate. + fn setup() -> Option { + let mut cfg = base_config(); + cfg.email_verification.required = false; + harness(cfg, None) + } + + /// Read the address currently stored for an account. + async fn stored_email(h: &Harness, id: &str) -> Option { + h.users + .find_by_id(id, None) + .await + .ok() + .flatten() + .map(|user| user.email) + } + + #[tokio::test] + async fn a_change_is_not_applied_until_the_new_address_proves_itself() { + // The whole point of the two steps. A flow that wrote the address at request time and + // verified afterwards would hand an attacker the account for the length of the TTL. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + + assert!( + h.engine + .request_email_change(&id, "new@example.com", "right") + .await + .is_ok() + ); + + assert_eq!( + stored_email(&h, &id).await.as_deref(), + Some("old@example.com") + ); + } + + #[tokio::test] + async fn the_wrong_current_password_mints_nothing() { + // The re-prove is the gate that stops a stolen access token from moving the recovery + // address. Without it, a thief with a token takes the account outright. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + + assert!(matches!( + h.engine + .request_email_change(&id, "new@example.com", "wrong") + .await, + Err(AuthError::InvalidCredentials) + )); + assert_eq!( + stored_email(&h, &id).await.as_deref(), + Some("old@example.com") + ); + } + + #[tokio::test] + async fn an_account_that_cannot_prove_a_password_is_refused() { + // A subject that no longer exists answers exactly as one with no local password: the + // caller learns nothing either way. + let Some(h) = setup() else { return }; + + assert!(matches!( + h.engine + .request_email_change("ghost", "new@example.com", "right") + .await, + Err(AuthError::InvalidCredentials) + )); + } + + #[tokio::test] + async fn an_address_that_is_taken_or_unchanged_is_refused() { + // Moving onto an address someone else holds would put two accounts on one recovery + // credential; moving onto the account's own is a change that changes nothing and would + // send a verification for a move that is not happening. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + let _ = h.seed(SeedUser::active("taken@example.com", "right")).await; + + assert!(matches!( + h.engine + .request_email_change(&id, "taken@example.com", "right") + .await, + Err(AuthError::EmailAlreadyExists) + )); + assert!(matches!( + h.engine + .request_email_change(&id, "OLD@Example.com", "right") + .await, + Err(AuthError::EmailAlreadyExists) + )); + } + + #[tokio::test] + async fn a_confirmed_change_moves_the_address_exactly_once() { + // The link is single-use: the read and the delete are one operation, so clicking twice + // — or racing — applies once. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + + // The raw token is opaque, so plant a known one — the pair `request` writes. + let token = "d".repeat(64); + let context = EmailChangeContext { + user_id: id.clone(), + new_email: "new@example.com".to_owned(), + tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), + }; + assert!( + h.stores + .put_email_change(&token, &context, 3600) + .await + .is_ok() + ); + + assert!(h.engine.confirm_email_change(&token).await.is_ok()); + assert_eq!( + stored_email(&h, &id).await.as_deref(), + Some("new@example.com") + ); + + // …and the same link a second time reaches nothing. + assert!(matches!( + h.engine.confirm_email_change(&token).await, + Err(AuthError::EmailChangeTokenInvalid) + )); + } + + #[tokio::test] + async fn a_token_no_longer_bound_to_the_password_is_refused() { + // An attacker who plants a change request and waits loses it the moment the victim + // changes their password — which is the first thing a victim does. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + + let token = "e".repeat(64); + let context = EmailChangeContext { + user_id: id.clone(), + new_email: "new@example.com".to_owned(), + tenant_id: "t1".to_owned(), + // A fingerprint that matches no password this account has ever had. + password_fingerprint: "f".repeat(64), + }; + assert!( + h.stores + .put_email_change(&token, &context, 3600) + .await + .is_ok() + ); + + assert!(matches!( + h.engine.confirm_email_change(&token).await, + Err(AuthError::EmailChangeTokenInvalid) + )); + assert_eq!( + stored_email(&h, &id).await.as_deref(), + Some("old@example.com") + ); + } + + #[tokio::test] + async fn an_unknown_token_and_a_vanished_account_are_both_refused() { + let Some(h) = setup() else { return }; + + assert!(matches!( + h.engine.confirm_email_change(&"0".repeat(64)).await, + Err(AuthError::EmailChangeTokenInvalid) + )); + + // A record naming an account that has since been deleted. + let token = "1".repeat(64); + let context = EmailChangeContext { + user_id: "ghost".to_owned(), + new_email: "new@example.com".to_owned(), + tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), + }; + assert!( + h.stores + .put_email_change(&token, &context, 3600) + .await + .is_ok() + ); + assert!(matches!( + h.engine.confirm_email_change(&token).await, + Err(AuthError::EmailChangeTokenInvalid) + )); + } + + #[tokio::test] + async fn an_address_taken_between_the_request_and_the_confirmation_is_refused() { + // The two are separated by the whole TTL, and whoever registered the address in + // between would otherwise lose it to a change requested before they existed. + let Some(h) = setup() else { return }; + let id = h.seed(SeedUser::active("old@example.com", "right")).await; + + let token = "2".repeat(64); + let context = EmailChangeContext { + user_id: id.clone(), + new_email: "contested@example.com".to_owned(), + tenant_id: "t1".to_owned(), + password_fingerprint: String::new(), + }; + assert!( + h.stores + .put_email_change(&token, &context, 3600) + .await + .is_ok() + ); + // Someone registers it while the link sits in a mailbox. + let _ = h + .seed(SeedUser::active("contested@example.com", "right")) + .await; + + assert!(matches!( + h.engine.confirm_email_change(&token).await, + Err(AuthError::EmailAlreadyExists) + )); + assert_eq!( + stored_email(&h, &id).await.as_deref(), + Some("old@example.com") + ); + } +} diff --git a/crates/bymax-auth-core/src/services/auth/invitation.rs b/crates/bymax-auth-core/src/services/auth/invitation.rs index 92c34ca..bd7e402 100644 --- a/crates/bymax-auth-core/src/services/auth/invitation.rs +++ b/crates/bymax-auth-core/src/services/auth/invitation.rs @@ -901,6 +901,15 @@ mod tests { #[async_trait::async_trait] impl crate::traits::EmailProvider for FailingInviteEmail { + async fn send_email_change_verification( + &self, + _new_email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_token( &self, _email: &str, diff --git a/crates/bymax-auth-core/src/services/auth/mod.rs b/crates/bymax-auth-core/src/services/auth/mod.rs index 957301f..8895caf 100644 --- a/crates/bymax-auth-core/src/services/auth/mod.rs +++ b/crates/bymax-auth-core/src/services/auth/mod.rs @@ -7,6 +7,7 @@ //! (tenant resolution, the status gate, hook context, and fire-and-forget dispatch). pub(crate) mod detached; +mod email_change; mod email_verification; mod invitation; mod login; 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 66c0a3b..6e0dfb9 100644 --- a/crates/bymax-auth-core/src/services/auth/password_reset.rs +++ b/crates/bymax-auth-core/src/services/auth/password_reset.rs @@ -749,7 +749,7 @@ fn reset_context_hooks(context: &ResetContext) -> HookContext { /// leaked snapshot of the reset keyspace reveals nothing about the credential. An account with /// no local password yields the empty string, which is a value like any other: a proof minted /// then is invalidated as soon as one is set. -fn password_fingerprint(user: &AuthUser) -> String { +pub(super) fn password_fingerprint(user: &AuthUser) -> String { match user.password_hash.as_deref() { Some(phc) => to_hex(&sha256(phc.as_bytes())), None => String::new(), @@ -1434,6 +1434,15 @@ mod tests { #[async_trait::async_trait] impl crate::traits::EmailProvider for FailingResetEmail { + async fn send_email_change_verification( + &self, + _new_email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_token( &self, _email: &str, @@ -1499,6 +1508,15 @@ mod tests { #[async_trait::async_trait] impl crate::traits::EmailProvider for CapturingResetEmail { + async fn send_email_change_verification( + &self, + _new_email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_token( &self, _email: &str, @@ -1876,6 +1894,14 @@ mod tests { #[async_trait::async_trait] impl UserRepository for FailingLookupRepo { + async fn update_email( + &self, + _id: &str, + _email: &str, + ) -> Result<(), crate::RepositoryError> { + Ok(()) + } + async fn find_by_id( &self, _id: &str, diff --git a/crates/bymax-auth-core/src/services/mfa/tests.rs b/crates/bymax-auth-core/src/services/mfa/tests.rs index 6f6fa62..6ea6841 100644 --- a/crates/bymax-auth-core/src/services/mfa/tests.rs +++ b/crates/bymax-auth-core/src/services/mfa/tests.rs @@ -366,6 +366,15 @@ impl AlertSpy { #[async_trait] impl EmailProvider for AlertSpy { + async fn send_email_change_verification( + &self, + _new_email: &str, + _token: &str, + _locale: Option<&str>, + ) -> Result<(), crate::traits::EmailError> { + Ok(()) + } + async fn send_password_reset_token( &self, _email: &str, diff --git a/crates/bymax-auth-core/src/testing/mod.rs b/crates/bymax-auth-core/src/testing/mod.rs index aa84f28..09c964a 100644 --- a/crates/bymax-auth-core/src/testing/mod.rs +++ b/crates/bymax-auth-core/src/testing/mod.rs @@ -22,11 +22,11 @@ use time::OffsetDateTime; use crate::RepositoryError; use crate::traits::{ - BruteForceStore, HttpClient, HttpError, HttpRequest, HttpResponse, InvitationStore, - OAuthProfile, OAuthProvider, OAuthProviderError, OAuthTokens, OtpPurpose, OtpStore, - PasswordResetStore, PlatformUserRepository, ResetContext, RotateOutcome, SessionDetail, - SessionKind, SessionRecord, SessionRotation, SessionStore, StoredInvitation, UserRepository, - WsTicketSnapshot, WsTicketStore, + BruteForceStore, EmailChangeContext, HttpClient, HttpError, HttpRequest, HttpResponse, + InvitationStore, OAuthProfile, OAuthProvider, OAuthProviderError, OAuthTokens, OtpPurpose, + OtpStore, PasswordResetStore, PlatformUserRepository, ResetContext, RotateOutcome, + SessionDetail, SessionKind, SessionRecord, SessionRotation, SessionStore, StoredInvitation, + UserRepository, WsTicketSnapshot, WsTicketStore, }; pub use crate::traits::{NoOpAuthHooks, NoOpEmailProvider}; @@ -163,6 +163,17 @@ impl UserRepository for InMemoryUserRepository { Ok(()) } + async fn update_email(&self, id: &str, email: &str) -> Result<(), RepositoryError> { + // The address is proven before this runs, so the account stays verified across the + // change — a store that cleared the flag here would sign the user out of a state it + // had just proved. + let mut users = lock(&self.users); + if let Some(user) = users.get_mut(id) { + user.email = email.to_owned(); + } + Ok(()) + } + async fn find_by_oauth_id( &self, provider: &str, @@ -339,6 +350,8 @@ pub struct InMemoryStores { invitations: Mutex>, /// The invitee index: `{tenantId}:{sha256(email)}` -> the invitation's token hash. invitation_index: Mutex>, + /// Pending address changes (`ec:`), keyed by the token hash. + email_changes: Mutex>, /// `mfa_setup:` — the AES-protected pending-setup record keyed by `hmac_sha256(user_id)`. #[cfg(feature = "mfa")] mfa_setup: Mutex>, @@ -772,6 +785,23 @@ fn token_key(token: &str) -> String { #[async_trait] impl PasswordResetStore for InMemoryStores { + async fn put_email_change( + &self, + token: &str, + context: &EmailChangeContext, + _ttl_secs: u64, + ) -> Result<(), AuthError> { + lock(&self.email_changes).insert(token_key(token), context.clone()); + Ok(()) + } + + async fn consume_email_change( + &self, + token: &str, + ) -> Result, AuthError> { + Ok(lock(&self.email_changes).remove(&token_key(token))) + } + async fn put_token( &self, token: &str, diff --git a/crates/bymax-auth-core/src/traits/email.rs b/crates/bymax-auth-core/src/traits/email.rs index 4c41418..915c1ae 100644 --- a/crates/bymax-auth-core/src/traits/email.rs +++ b/crates/bymax-auth-core/src/traits/email.rs @@ -67,6 +67,54 @@ pub trait EmailProvider: Send + Sync { Ok(()) } + /// Deliver the address-change verification token to the **new** address. + /// + /// The token goes here and nowhere else: receiving it is what proves the requester + /// controls the address before it becomes the account's. The provider builds the + /// confirmation URL from it. + /// + /// **Required, unlike the notices below.** A defaulted no-op would swallow the token and + /// leave the flow minting `ec:` keys nobody ever receives — a failure that looks like + /// success from every side, and that a user experiences as a verification email that + /// simply never arrives. Every other trait method that carries a token is required for + /// the same reason; making this one optional would be the exception, not the rule. + /// + /// # Errors + /// + /// Returns [`EmailError`] when delivery fails. The engine surfaces it: a change whose + /// verification could not be sent has not started. + async fn send_email_change_verification( + &self, + new_email: &str, + token: &str, + locale: Option<&str>, + ) -> Result<(), EmailError>; + + /// Notify the **old** address that the account's address has changed. + /// + /// NIST SP 800-63B §4.6 asks for notification of a credential change, and the address is + /// the recovery credential: someone who moves it can then drive a password reset to a + /// mailbox the owner does not read. This message is what puts that in front of the owner + /// while they still control the address it arrives at. + /// + /// Defaulted to a no-op so an existing provider keeps compiling, exactly like + /// [`Self::send_password_changed`] — it is the same kind of notice, and it carries no + /// token whose loss would break the flow. + /// + /// # Errors + /// + /// Returns [`EmailError`] when delivery fails. The engine logs and drops it: a change the + /// user asked for and proved is not rolled back because a mail server was down. + async fn send_email_changed_notification( + &self, + old_email: &str, + new_email: &str, + locale: Option<&str>, + ) -> Result<(), EmailError> { + let _ = (old_email, new_email, locale); + Ok(()) + } + /// Security alert: MFA was enabled on the account. async fn send_mfa_enabled(&self, email: &str, locale: Option<&str>) -> Result<(), EmailError>; @@ -152,6 +200,17 @@ pub struct NoOpEmailProvider; #[async_trait] impl EmailProvider for NoOpEmailProvider { + async fn send_email_change_verification( + &self, + new_email: &str, + token: &str, + _locale: Option<&str>, + ) -> Result<(), EmailError> { + let _ = (new_email, token); + tracing::debug!("NoOpEmailProvider: send_email_change_verification"); + Ok(()) + } + async fn send_password_reset_token( &self, email: &str, diff --git a/crates/bymax-auth-core/src/traits/mod.rs b/crates/bymax-auth-core/src/traits/mod.rs index a37624f..b8546ed 100644 --- a/crates/bymax-auth-core/src/traits/mod.rs +++ b/crates/bymax-auth-core/src/traits/mod.rs @@ -40,7 +40,7 @@ pub use store::MfaStore; pub use store::OAuthStateStore; #[doc(inline)] pub use store::{ - BruteForceStore, InvitationStore, OtpPurpose, OtpStore, PasswordResetStore, ResetContext, - RotateOutcome, SessionDetail, SessionKind, SessionRecord, SessionRotation, SessionStore, - StoredInvitation, TOKEN_EPOCH_RETENTION_SECS, WsTicketSnapshot, WsTicketStore, + BruteForceStore, EmailChangeContext, InvitationStore, OtpPurpose, OtpStore, PasswordResetStore, + ResetContext, RotateOutcome, SessionDetail, SessionKind, SessionRecord, SessionRotation, + SessionStore, StoredInvitation, TOKEN_EPOCH_RETENTION_SECS, WsTicketSnapshot, WsTicketStore, }; diff --git a/crates/bymax-auth-core/src/traits/repository.rs b/crates/bymax-auth-core/src/traits/repository.rs index c07169d..a4ada58 100644 --- a/crates/bymax-auth-core/src/traits/repository.rs +++ b/crates/bymax-auth-core/src/traits/repository.rs @@ -63,6 +63,23 @@ pub trait UserRepository: Send + Sync { /// Mark the email verified or unverified. async fn update_email_verified(&self, id: &str, verified: bool) -> Result<(), RepositoryError>; + /// Replace the account's email address. + /// + /// Called only after the new address has been proven — a token mailed to it and nothing + /// else has come back — so the account stays verified across the change. An implementation + /// that also cleared its own verification flag would sign the user out of a state they had + /// just proved. + /// + /// The uniqueness of `email` within the tenant is checked by the caller immediately before + /// this runs, but a unique index on `(tenant_id, email)` is still the right thing to have: + /// the check and this write are not one transaction, and the index is what makes the race + /// a failed write instead of two accounts sharing a recovery address. + /// + /// # Errors + /// + /// Returns [`RepositoryError`] when the address is taken or the write fails. + async fn update_email(&self, id: &str, email: &str) -> Result<(), RepositoryError>; + /// Find a user by OAuth identity. Query by BOTH provider and provider id to avoid /// cross-provider id collisions, scoped by tenant for isolation. async fn find_by_oauth_id( @@ -135,6 +152,10 @@ mod tests { #[async_trait] impl UserRepository for DummyRepo { + async fn update_email(&self, _id: &str, _email: &str) -> Result<(), RepositoryError> { + Ok(()) + } + async fn find_by_id( &self, _id: &str, diff --git a/crates/bymax-auth-core/src/traits/store.rs b/crates/bymax-auth-core/src/traits/store.rs index 48e394b..3ff2c9d 100644 --- a/crates/bymax-auth-core/src/traits/store.rs +++ b/crates/bymax-auth-core/src/traits/store.rs @@ -522,6 +522,30 @@ pub struct ResetContext { pub password_fingerprint: String, } +/// The trusted metadata stored for a pending address change under `ec:{sha256(token)}`. +/// +/// Held byte-compatible with nest-auth: the two backends share this keyspace, so a change +/// requested through one is confirmable through the other. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmailChangeContext { + /// The account the change belongs to. + pub user_id: String, + /// The address being moved to, stored already normalized (trimmed, lowercased) because + /// the uniqueness re-check at confirm time compares it the same way login does. + pub new_email: String, + /// The tenant the account belongs to, for that re-check. + pub tenant_id: String, + /// Digest of the password hash in force when the token was minted. + /// + /// Binds the token to that password exactly as [`ResetContext`] does: an attacker who + /// plants a change request and waits loses it the moment the victim changes their + /// password. An ABSENT field is read as "no binding" and accepted, so a rolling deploy + /// does not break the changes already in flight. + #[serde(default)] + pub password_fingerprint: String, +} + /// The trusted metadata stored for a pending invitation under `inv:` keyed by /// `sha256(token)` — the raw token is never a key. Read back on accept; because the payload /// is trusted, the accept flow re-validates `role` against the hierarchy as anti-tamper (a @@ -590,6 +614,25 @@ pub trait PasswordResetStore: Send + Sync { /// Atomically consume (`getdel`) a verified-token context. `None` when the token is /// unknown, expired, or already consumed. async fn consume_verified(&self, token: &str) -> Result, AuthError>; + + /// Store a pending address change under `ec:{sha256(token)}` with a TTL. + /// + /// Lives on this trait rather than its own because it is the same thing: a single-use + /// opaque token, mailed to a mailbox, keyed by its hash and consumed exactly once. A + /// separate trait would duplicate the seam without separating any concern. + async fn put_email_change( + &self, + token: &str, + context: &EmailChangeContext, + ttl_secs: u64, + ) -> Result<(), AuthError>; + + /// Atomically consume (`getdel`) a pending address change. `None` when the token is + /// unknown, expired, or already consumed. + async fn consume_email_change( + &self, + token: &str, + ) -> Result, AuthError>; } /// Single-use invitation storage. A [`StoredInvitation`] is held under `inv:{sha256(token)}` diff --git a/crates/bymax-auth-redis/src/keys.rs b/crates/bymax-auth-redis/src/keys.rs index be2e972..8176b7d 100644 --- a/crates/bymax-auth-redis/src/keys.rs +++ b/crates/bymax-auth-redis/src/keys.rs @@ -62,6 +62,8 @@ pub enum Prefix { Inv, /// Single-use claim on an MFA recovery code (`rcu`). Rcu, + /// Pending address change (`ec`). + Ec, /// Invitee index for a pending invitation (`invidx`). Keyed by /// `{tenantId}:{sha256(email)}` and holding the invitation's token hash — the only handle /// the issuing side has on a record keyed by a token it never saw. @@ -112,6 +114,7 @@ impl Prefix { Self::PwVtok => "pw_vtok", Self::Inv => "inv", Self::Rcu => "rcu", + Self::Ec => "ec", Self::Invidx => "invidx", Self::Prt => "prt", Self::Prp => "prp", @@ -313,6 +316,7 @@ mod tests { (Prefix::PwVtok, "auth:pw_vtok:abc"), (Prefix::Inv, "auth:inv:abc"), (Prefix::Rcu, "auth:rcu:abc"), + (Prefix::Ec, "auth:ec:abc"), (Prefix::Invidx, "auth:invidx:abc"), (Prefix::Prt, "auth:prt:abc"), (Prefix::Prp, "auth:prp:abc"), diff --git a/crates/bymax-auth-redis/src/stores/single_use.rs b/crates/bymax-auth-redis/src/stores/single_use.rs index f4d96bc..91d4b2b 100644 --- a/crates/bymax-auth-redis/src/stores/single_use.rs +++ b/crates/bymax-auth-redis/src/stores/single_use.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use bymax_auth_core::traits::{ - InvitationStore, PasswordResetStore, ResetContext, StoredInvitation, + EmailChangeContext, InvitationStore, PasswordResetStore, ResetContext, StoredInvitation, }; use bymax_auth_crypto::mac::sha256; use bymax_auth_types::AuthError; @@ -121,6 +121,26 @@ impl PasswordResetStore for RedisStores { .map_err(AuthError::from) } + async fn put_email_change( + &self, + token: &str, + context: &EmailChangeContext, + ttl_secs: u64, + ) -> Result<(), AuthError> { + self.put_value(Prefix::Ec, token, context, ttl_secs) + .await + .map_err(AuthError::from) + } + + async fn consume_email_change( + &self, + token: &str, + ) -> Result, AuthError> { + self.consume_value(Prefix::Ec, token) + .await + .map_err(AuthError::from) + } + async fn consume_verified(&self, token: &str) -> Result, AuthError> { self.consume_value(Prefix::PwVtok, token) .await diff --git a/crates/bymax-auth-types/src/error.rs b/crates/bymax-auth-types/src/error.rs index e8e23b8..1b46e75 100644 --- a/crates/bymax-auth-types/src/error.rs +++ b/crates/bymax-auth-types/src/error.rs @@ -76,6 +76,11 @@ pub enum AuthErrorCode { /// Login when email verification is required and the email is unverified. #[serde(rename = "auth.email_not_verified")] EmailNotVerified, + /// Address-change token unknown, expired, already used, or no longer bound to the + /// password it was minted against. One code for all four: the holder of a bad link learns + /// only that it does not work, which is all they can act on. + #[serde(rename = "auth.email_change_token_invalid")] + EmailChangeTokenInvalid, // MFA /// Endpoint demands verified MFA but the JWT lacks `mfaVerified: true`. @@ -216,6 +221,7 @@ impl AuthErrorCode { | Self::PasswordResetTokenInvalid | Self::PasswordResetTokenExpired | Self::InvalidInvitationToken + | Self::EmailChangeTokenInvalid | Self::Validation => 400, Self::AccountLocked | Self::OtpMaxAttempts | Self::TooManyRequests => 429, Self::Internal => 500, @@ -244,6 +250,7 @@ impl AuthErrorCode { Self::TokenMissing => "Token missing", Self::EmailAlreadyExists => "Email already registered", Self::EmailNotVerified => "Email not verified", + Self::EmailChangeTokenInvalid => "Invalid or expired email change link", Self::MfaRequired => "Two-factor authentication required", Self::MfaInvalidCode => "Invalid MFA code", Self::MfaAlreadyEnabled => "MFA is already enabled", @@ -413,6 +420,9 @@ pub enum AuthError { /// Email not verified while verification is required. #[error("email not verified")] EmailNotVerified, + /// Address-change token invalid, expired, spent, or no longer bound. + #[error("invalid email change token")] + EmailChangeTokenInvalid, // MFA /// Verified MFA required but absent from the JWT. @@ -532,6 +542,7 @@ impl AuthError { Self::TokenMissing => AuthErrorCode::TokenMissing, Self::EmailAlreadyExists => AuthErrorCode::EmailAlreadyExists, Self::EmailNotVerified => AuthErrorCode::EmailNotVerified, + Self::EmailChangeTokenInvalid => AuthErrorCode::EmailChangeTokenInvalid, Self::MfaRequired => AuthErrorCode::MfaRequired, Self::MfaInvalidCode => AuthErrorCode::MfaInvalidCode, Self::MfaAlreadyEnabled => AuthErrorCode::MfaAlreadyEnabled, diff --git a/packages/rust-auth/src/shared/error-codes.ts b/packages/rust-auth/src/shared/error-codes.ts index ec0b364..7d2ef69 100644 --- a/packages/rust-auth/src/shared/error-codes.ts +++ b/packages/rust-auth/src/shared/error-codes.ts @@ -5,7 +5,7 @@ * string literal, byte-identical to nest-auth's `AUTH_ERROR_CODES`, and maps to a * fixed HTTP status via [`AuthErrorCode::http_status`]. */ -export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_compromised" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.untrusted_origin" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; +export type AuthErrorCode = "auth.invalid_credentials" | "auth.account_locked" | "auth.account_inactive" | "auth.account_suspended" | "auth.account_banned" | "auth.pending_approval" | "auth.token_expired" | "auth.token_revoked" | "auth.token_invalid" | "auth.refresh_token_invalid" | "auth.session_expired" | "auth.session_limit_reached" | "auth.session_not_found" | "auth.token_missing" | "auth.email_already_exists" | "auth.email_not_verified" | "auth.email_change_token_invalid" | "auth.mfa_required" | "auth.mfa_invalid_code" | "auth.mfa_already_enabled" | "auth.mfa_not_enabled" | "auth.mfa_setup_required" | "auth.mfa_temp_token_invalid" | "auth.recovery_code_invalid" | "auth.password_too_weak" | "auth.password_compromised" | "auth.password_reset_token_invalid" | "auth.password_reset_token_expired" | "auth.otp_invalid" | "auth.otp_expired" | "auth.otp_max_attempts" | "auth.insufficient_role" | "auth.forbidden" | "auth.untrusted_origin" | "auth.invalid_invitation_token" | "auth.oauth_failed" | "auth.oauth_email_mismatch" | "auth.platform_auth_required" | "auth.validation" | "auth.too_many_requests" | "auth.internal"; export const AUTH_ERROR_CODES = { INVALID_CREDENTIALS: "auth.invalid_credentials", ACCOUNT_LOCKED: "auth.account_locked", From 24893057ad8d3e9e6cddb45ae34cbc6a36f42673 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:31:54 -0300 Subject: [PATCH 119/122] feat(core): bind tokens to an issuer and an audience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tokens said nothing about who minted them or who they were for. With HS256 that gap has teeth: the verifier can also sign, so every service holding the secret to check a token can mint one. Audience binding is what stops a token minted for one service being replayed at another that trusts the same secret, and issuer binding is what a verifier needs when it is not the issuer. `jwt.issuer` and `jwt.audience` are optional and absent by default. When set, the value is stamped on every token this backend mints and REQUIRED on every one it verifies. A token carrying a different value is rejected, and so is one carrying none: a verifier that accepted an unstamped token would give an attacker a way to opt out of the check by omitting the claim. Both sides read one `TokenBinding` held on the service, so they cannot disagree — a token stamped with an issuer the verifier does not require, or required where none is stamped, is a deployment that rejects its own tokens. The check sits at the single verification chokepoint, which is what makes it hold across a secret rotation: a retired key buys a token signature acceptance and nothing else, and without this it would have been a way for anyone holding a secret the deployment used to use to bypass the binding entirely. A failure reports as a decode error, which maps to the public `token_invalid` like every other rejection. Telling a holder that their token was well-formed but aimed at the wrong audience is telling them which audience to aim at next. Opt-in rather than on by default: both backends of a shared deployment must carry the same pair or they stop accepting each other's tokens, and turning it on invalidates the access tokens already in flight — one access-token lifetime, which clients close by refreshing, since the refresh token is opaque. An empty string reads as unconfigured rather than as "require the empty issuer", so a host threading an unset environment variable through does not silently turn the check on and start minting tokens its own verifier rejects. --- CHANGELOG.md | 21 ++ bindings/bymax-auth-wasm/src/jwt_edge.rs | 10 + bindings/bymax-auth-wasm/src/lib.rs | 2 + crates/bymax-auth-axum/src/extractors/mfa.rs | 2 + .../src/extractors/platform.rs | 2 + crates/bymax-auth-axum/tests/common/mod.rs | 4 + crates/bymax-auth-core/src/config/mod.rs | 20 ++ crates/bymax-auth-core/src/engine/builder.rs | 19 +- .../src/services/adapter_api.rs | 4 + .../src/services/auth/session_ops.rs | 2 + .../src/services/token_manager.rs | 276 +++++++++++++++++- crates/bymax-auth-jwt/src/hs256.rs | 8 + crates/bymax-auth-jwt/src/keys.rs | 28 ++ crates/bymax-auth-types/src/claims.rs | 44 +++ .../rust-auth/src/shared/jwt-payload.types.ts | 42 ++- 15 files changed, 476 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e3a8..0827e85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ version bump. ### Added +- **`jwt.issuer` and `jwt.audience` — binding tokens to who minted them and who they are for.** + Optional and `None` by default, so an existing deployment is unchanged. When set, the value + is stamped on every token this backend mints — dashboard, platform and MFA challenge alike — + and **required** on every token it verifies: one carrying a different value, or none at all, + is rejected. Accepting an unstamped token would give an attacker a way to opt out of the + check simply by omitting the claim. + + This matters because HS256 means the verifier can also sign: every service holding the secret + to check a token can mint one, so audience binding is what stops a token minted for one + service being replayed at another that trusts the same secret. The check sits at the single + verification chokepoint, so a retired signing key does not waive it — a retired key buys a + token signature acceptance and nothing else. + + Opt-in because both backends of a shared deployment must carry the same pair or they stop + accepting each other's tokens, and because turning it on invalidates the access tokens + already in flight. An empty string reads as unconfigured rather than as "require the empty + issuer". `DashboardClaims`, `PlatformClaims` and `MfaTempClaims` gain `iss`/`aud` fields, + both `Option` and both skipped when absent, so the wire shape is unchanged for a + deployment that configures neither. + + - **Changing the address on an account** — `POST /auth/email/change` and `POST /auth/email/change/confirm`, opt-in behind `controllers.email_change`. The address is the account's recovery credential: whoever controls it can drive a password reset to a diff --git a/bindings/bymax-auth-wasm/src/jwt_edge.rs b/bindings/bymax-auth-wasm/src/jwt_edge.rs index 0d90133..ce5683b 100644 --- a/bindings/bymax-auth-wasm/src/jwt_edge.rs +++ b/bindings/bymax-auth-wasm/src/jwt_edge.rs @@ -193,6 +193,8 @@ mod tests { fn dashboard(iat: i64, exp: i64) -> DashboardClaims { DashboardClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-1".to_owned(), tenant_id: "t_1".to_owned(), @@ -226,6 +228,8 @@ mod tests { fn verifies_a_platform_token() { // Platform access tokens dispatch to PlatformClaims and round-trip to JSON. let claims = PlatformClaims { + iss: None, + aud: None, sub: "p_1".to_owned(), jti: "jti-2".to_owned(), role: "admin".to_owned(), @@ -245,6 +249,8 @@ mod tests { fn verifies_an_mfa_temp_token() { // MFA-temp tokens dispatch to MfaTempClaims. let claims = MfaTempClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-3".to_owned(), token_type: MfaTempType::MfaChallenge, @@ -349,6 +355,8 @@ mod tests { // Exercise the platform and mfa-temp projection arms too. let platform = sign( &PlatformClaims { + iss: None, + aud: None, sub: "p_1".to_owned(), jti: "jti-2".to_owned(), role: "admin".to_owned(), @@ -368,6 +376,8 @@ mod tests { let mfa = sign( &MfaTempClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-3".to_owned(), token_type: MfaTempType::MfaChallenge, diff --git a/bindings/bymax-auth-wasm/src/lib.rs b/bindings/bymax-auth-wasm/src/lib.rs index 7c499a0..8550f56 100644 --- a/bindings/bymax-auth-wasm/src/lib.rs +++ b/bindings/bymax-auth-wasm/src/lib.rs @@ -140,6 +140,8 @@ mod tests { use bymax_auth_jwt::{HsKey, sign}; use bymax_auth_types::{DashboardClaims, DashboardType}; let claims = DashboardClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-1".to_owned(), tenant_id: "t_1".to_owned(), diff --git a/crates/bymax-auth-axum/src/extractors/mfa.rs b/crates/bymax-auth-axum/src/extractors/mfa.rs index cea534b..a3db2bb 100644 --- a/crates/bymax-auth-axum/src/extractors/mfa.rs +++ b/crates/bymax-auth-axum/src/extractors/mfa.rs @@ -59,6 +59,8 @@ mod tests { // Mint a token with mfa_enabled=true, mfa_verified=false (no normal flow issues one). let claims = DashboardClaims { + iss: None, + aud: None, sub: id.clone(), jti: "jti-unverified".to_owned(), tenant_id: "t1".to_owned(), diff --git a/crates/bymax-auth-axum/src/extractors/platform.rs b/crates/bymax-auth-axum/src/extractors/platform.rs index d2b5ef3..a70a8d4 100644 --- a/crates/bymax-auth-axum/src/extractors/platform.rs +++ b/crates/bymax-auth-axum/src/extractors/platform.rs @@ -124,6 +124,8 @@ mod tests { fn platform_claims(role: &str) -> PlatformClaims { PlatformClaims { + iss: None, + aud: None, sub: "admin-1".to_owned(), jti: "jti-platform".to_owned(), role: role.to_owned(), diff --git a/crates/bymax-auth-axum/tests/common/mod.rs b/crates/bymax-auth-axum/tests/common/mod.rs index 34b7449..baf6aef 100644 --- a/crates/bymax-auth-axum/tests/common/mod.rs +++ b/crates/bymax-auth-axum/tests/common/mod.rs @@ -328,6 +328,8 @@ pub async fn seed_user(harness: &Harness, email: &str, password: &str, role: &st pub fn mint_dashboard_token(sub: &str, role: &str, status: &str) -> String { use bymax_auth_types::{DashboardClaims, DashboardType}; let claims = DashboardClaims { + iss: None, + aud: None, sub: sub.to_owned(), jti: "jti-mint".to_owned(), tenant_id: TENANT.to_owned(), @@ -348,6 +350,8 @@ pub fn mint_dashboard_token(sub: &str, role: &str, status: &str) -> String { pub fn mint_platform_token(sub: &str, role: &str) -> String { use bymax_auth_types::{PlatformClaims, PlatformType}; let claims = PlatformClaims { + iss: None, + aud: None, sub: sub.to_owned(), jti: "jti-mint-p".to_owned(), role: role.to_owned(), diff --git a/crates/bymax-auth-core/src/config/mod.rs b/crates/bymax-auth-core/src/config/mod.rs index dd9375b..75b8c48 100644 --- a/crates/bymax-auth-core/src/config/mod.rs +++ b/crates/bymax-auth-core/src/config/mod.rs @@ -177,6 +177,24 @@ pub struct JwtConfig { pub absolute_session_lifetime_days: u32, /// Pinned to HS256. pub algorithm: JwtAlgorithm, + /// The `iss` claim stamped on every token this backend mints, and REQUIRED on every token + /// it verifies. `None` by default, so an existing deployment is unchanged. + /// + /// When set, a token carrying a different issuer — or none at all — is rejected. That is + /// the point: a verifier that accepted an unstamped token would give an attacker a way to + /// opt out of the check by omitting the claim. + /// + /// Both backends sharing a deployment must carry the same value, or they stop accepting + /// each other's tokens. Turning it on invalidates the access tokens already in flight; the + /// window is one access-token lifetime and clients recover by refreshing, since the + /// refresh token is opaque and carries no claims. + pub issuer: Option, + /// The `aud` claim, with the same semantics as [`Self::issuer`]. + /// + /// Names who the token is *for*. With HS256 the verifier can also sign, so audience + /// binding is what stops a token minted for one service being replayed at another that + /// trusts the same secret. + pub audience: Option, /// Grace window during which a rotated refresh token stays valid, default 30s. pub refresh_grace_window: Duration, } @@ -194,6 +212,8 @@ impl Default for JwtConfig { refresh_expires_in_days: 7, absolute_session_lifetime_days: 0, algorithm: JwtAlgorithm::Hs256, + issuer: None, + audience: None, refresh_grace_window: Duration::from_secs(30), } } diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index f06f1b0..49e6fcc 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -426,7 +426,24 @@ impl AuthEngineBuilder { grace_window, absolute_session_lifetime_days, ) - .with_hooks(hooks.clone()); + .with_hooks(hooks.clone()) + // Empty strings read as unconfigured rather than as "require the empty issuer": a + // host threading an unset environment variable through must not silently turn the + // check on and start minting tokens its own verifier rejects. + .with_binding(crate::services::token_manager::TokenBinding { + issuer: config + .config() + .jwt + .issuer + .clone() + .filter(|value| !value.is_empty()), + audience: config + .config() + .jwt + .audience + .clone() + .filter(|value| !value.is_empty()), + }); // Wire the MFA temp-token single-use support when an MFA store is supplied, so the // challenge token planted at login is store-backed and brute-force-capped. #[cfg(feature = "mfa")] diff --git a/crates/bymax-auth-core/src/services/adapter_api.rs b/crates/bymax-auth-core/src/services/adapter_api.rs index 673ba0a..e83b2c4 100644 --- a/crates/bymax-auth-core/src/services/adapter_api.rs +++ b/crates/bymax-auth-core/src/services/adapter_api.rs @@ -231,6 +231,8 @@ impl AuthEngine { let snapshot = store.redeem(ticket).await?.ok_or(AuthError::TokenInvalid)?; let now = now_unix(); Ok(DashboardClaims { + iss: None, + aud: None, sub: snapshot.sub, jti: new_uuid_v4(), tenant_id: snapshot.tenant_id.unwrap_or_default(), @@ -718,6 +720,8 @@ mod tests { /// A sample set of dashboard claims for the ticket surfaces. fn sample_claims() -> DashboardClaims { DashboardClaims { + iss: None, + aud: None, sub: "u".to_owned(), jti: new_uuid_v4(), tenant_id: "t1".to_owned(), 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 64386cf..436a93a 100644 --- a/crates/bymax-auth-core/src/services/auth/session_ops.rs +++ b/crates/bymax-auth-core/src/services/auth/session_ops.rs @@ -557,6 +557,8 @@ mod tests { // and logout still succeeds. let now = crate::services::now_unix(); let expired = DashboardClaims { + iss: None, + aud: None, sub: "user-x".to_owned(), jti: crate::services::new_uuid_v4(), tenant_id: "t1".to_owned(), diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index c604307..f6ca223 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -72,6 +72,46 @@ fn jti_hash(jti: &str) -> String { crate::services::to_hex(&bymax_auth_crypto::mac::sha256(jti.as_bytes())) } +/// The `iss`/`aud` pair a deployment binds its tokens to, or neither. +/// +/// Absent by default, so an existing deployment is unchanged. Both backends sharing a +/// deployment must carry the same pair or they stop accepting each other's tokens, which is +/// the one way this setting can split them — and the reason it is opt-in. +#[derive(Clone, Debug, Default)] +pub(crate) struct TokenBinding { + /// The `iss` to stamp and require. + pub issuer: Option, + /// The `aud` to stamp and require. + pub audience: Option, +} + +/// Claims that can carry the binding. Implemented for the three minted shapes so one helper +/// stamps them all — a shape the stamping skipped would be a shape the verifier rejects. +pub(crate) trait Stampable { + /// A copy of these claims carrying `iss`/`aud`. + fn stamped(&self, issuer: Option, audience: Option) -> Self; +} + +impl Stampable for DashboardClaims { + fn stamped(&self, issuer: Option, audience: Option) -> Self { + Self { + iss: issuer, + aud: audience, + ..self.clone() + } + } +} + +impl Stampable for PlatformClaims { + fn stamped(&self, issuer: Option, audience: Option) -> Self { + Self { + iss: issuer, + aud: audience, + ..self.clone() + } + } +} + /// Issues and rotates the dashboard token pair over the [`SessionStore`] seam. Platform /// issuance (`SafeAuthPlatformUser`/`PlatformClaims`) is a separate identity surface and /// is wired with the platform domain. @@ -91,6 +131,11 @@ pub struct TokenManagerService { /// error would lose the family id, and losing it leaves a consumer with nothing to /// correlate against — every replay would look like any other invalid token. hooks: Arc, + /// The `iss`/`aud` pair to stamp and to require, empty when the deployment configured + /// neither. Held here so the sign and the verify sides read the same value — a token + /// stamped with an issuer the verifier does not require, or required where none is + /// stamped, is a deployment that rejects its own tokens. + binding: TokenBinding, /// The MFA single-use temp-token support, wired only when an MFA store is supplied. #[cfg(feature = "mfa")] mfa: Option, @@ -122,18 +167,35 @@ impl TokenManagerService { token: &str, opts: &VerifyOptions, ) -> Result { - let current = verify::(token, &self.key, opts); + let current = verify::(token, &self.key, opts).and_then(|claims| self.bound(claims)); if current.is_ok() || self.previous_keys.is_empty() { return current; } for key in &self.previous_keys { - if let Ok(claims) = verify::(token, key, opts) { + if let Ok(claims) = verify::(token, key, opts).and_then(|c| self.bound(c)) { return Ok(claims); } } current } + /// Gate verified claims on the configured binding, mapping a failure onto the same opaque + /// error every other rejection uses. A retired signing key buys a token signature + /// acceptance and nothing else — the binding still has to hold. + fn bound( + &self, + claims: C, + ) -> Result { + if self.binding_holds(&claims) { + Ok(claims) + } else { + // Reported as a decode failure, which maps to the public `token_invalid` like + // every other rejection: telling a holder that their token was well-formed but + // aimed at the wrong audience is telling them which audience to aim at next. + Err(bymax_auth_jwt::JwtError::Decode) + } + } + /// Verify an access token's signature under the pinned algorithm while **ignoring its /// expiry**. /// @@ -204,6 +266,7 @@ impl TokenManagerService { grace_ttl_secs: grace_window.as_secs(), absolute_lifetime_secs: u64::from(absolute_session_lifetime_days) * 86_400, hooks: Arc::new(crate::traits::NoOpAuthHooks), + binding: TokenBinding::default(), #[cfg(feature = "mfa")] mfa: None, } @@ -220,6 +283,40 @@ impl TokenManagerService { self } + /// Bind every token this service mints — and every one it accepts — to an issuer and an + /// audience. + /// + /// Absent by default. With HS256 the verifier can also sign, so audience binding is what + /// stops a token minted for one service being replayed at another that trusts the same + /// secret; issuer binding is what a verifier needs when it is not the issuer. + #[must_use] + pub(crate) fn with_binding(mut self, binding: TokenBinding) -> Self { + self.binding = binding; + self + } + + /// Stamp the configured pair onto claims about to be signed. + fn stamp(&self, claims: &C) -> C { + claims.stamped(self.binding.issuer.clone(), self.binding.audience.clone()) + } + + /// Refuse claims whose `iss`/`aud` do not satisfy the configured binding. + /// + /// A token carrying NO claim is refused as firmly as one carrying the wrong value: a + /// verifier that accepted an unstamped token would give an attacker a way to opt out of + /// the check simply by omitting it. + fn binding_holds(&self, claims: &C) -> bool { + let issuer_ok = match self.binding.issuer.as_deref() { + Some(expected) => claims.iss() == Some(expected), + None => true, + }; + let audience_ok = match self.binding.audience.as_deref() { + Some(expected) => claims.aud() == Some(expected), + None => true, + }; + issuer_ok && audience_ok + } + /// Attach the MFA temp-token support (the single-use `mfa:` marker store and the /// brute-force counter reset), enabling the store-backed single-use challenge path. Set by /// the builder when an MFA store is wired. @@ -236,7 +333,7 @@ impl TokenManagerService { /// Returns [`AuthError::Internal`] only if claim serialization fails (unreachable for /// the crate's claim types). pub fn issue_access(&self, claims: &DashboardClaims) -> Result { - sign(claims, &self.key).map_err(signing_failed) + sign(&self.stamp(claims), &self.key).map_err(signing_failed) } /// Issue a fresh access JWT plus an opaque refresh token for `user`, persisting the @@ -263,6 +360,8 @@ impl TokenManagerService { .current_epoch(SessionKind::Dashboard, &user.id) .await?; let claims = DashboardClaims { + iss: None, + aud: None, sub: user.id.clone(), jti: new_uuid_v4(), tenant_id: user.tenant_id.clone(), @@ -438,7 +537,7 @@ impl TokenManagerService { /// crate's claim types). #[cfg(feature = "platform")] pub fn issue_platform_access(&self, claims: &PlatformClaims) -> Result { - sign(claims, &self.key).map_err(signing_failed) + sign(&self.stamp(claims), &self.key).map_err(signing_failed) } /// Issue a fresh platform access JWT plus an opaque refresh token for `admin`, persisting @@ -465,6 +564,8 @@ impl TokenManagerService { .current_epoch(SessionKind::Platform, &admin.id) .await?; let claims = PlatformClaims { + iss: None, + aud: None, sub: admin.id.clone(), jti: new_uuid_v4(), role: admin.role.clone(), @@ -664,6 +765,8 @@ impl TokenManagerService { fn rotated_platform_claims(&self, record: &SessionRecord, epoch: u64) -> PlatformClaims { let now = now_unix(); PlatformClaims { + iss: None, + aud: None, sub: record.user_id.clone(), jti: new_uuid_v4(), role: record.role.clone(), @@ -731,6 +834,8 @@ impl TokenManagerService { let now = now_unix(); let jti = new_uuid_v4(); let claims = MfaTempClaims { + iss: None, + aud: None, sub: user_id.to_owned(), jti: jti.clone(), token_type: MfaTempType::MfaChallenge, @@ -921,6 +1026,8 @@ impl TokenManagerService { fn rotated_claims(&self, record: &SessionRecord, epoch: u64) -> DashboardClaims { let now = now_unix(); DashboardClaims { + iss: None, + aud: None, sub: record.user_id.clone(), jti: new_uuid_v4(), tenant_id: record.tenant_id.clone().unwrap_or_default(), @@ -1488,6 +1595,8 @@ mod tests { // Craft an already-expired token by signing claims with exp in the past. let now = now_unix(); let expired = DashboardClaims { + iss: None, + aud: None, sub: "u1".to_owned(), jti: new_uuid_v4(), tenant_id: "t1".to_owned(), @@ -1986,6 +2095,165 @@ mod tests { Ok(claims) if claims.sub == "u1" )); } + // --------------------------------------------------------------------------- + // iss / aud binding + // --------------------------------------------------------------------------- + + /// A service bound to an issuer and/or an audience. + fn bound_service( + store: Arc, + issuer: Option<&str>, + audience: Option<&str>, + ) -> TokenManagerService { + service(store).with_binding(TokenBinding { + issuer: issuer.map(str::to_owned), + audience: audience.map(str::to_owned), + }) + } + + #[tokio::test] + async fn an_unbound_deployment_mints_and_accepts_unstamped_tokens() { + // Absent by default, so an existing deployment is unchanged. + let store = Arc::new(InMemoryStores::new()); + let svc = service(store); + let Ok(issued) = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + let Ok(claims) = svc.verify_access(&issued.access_token).await else { + return; + }; + assert_eq!(claims.iss, None); + assert_eq!(claims.aud, None); + } + + #[tokio::test] + async fn a_bound_deployment_stamps_what_it_mints() { + // The claim has to be ON the token, or the verifier that requires it rejects the + // backend's own output. + let store = Arc::new(InMemoryStores::new()); + let svc = bound_service(store, Some("bymax"), Some("dashboard")); + let Ok(issued) = svc + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + let Ok(claims) = svc.verify_access(&issued.access_token).await else { + return; + }; + assert_eq!(claims.iss.as_deref(), Some("bymax")); + assert_eq!(claims.aud.as_deref(), Some("dashboard")); + } + + #[tokio::test] + async fn a_bound_verifier_refuses_an_unstamped_token() { + // The whole point. A verifier that accepted an unstamped token would give an attacker + // a way to opt out of the check simply by omitting the claim. + let store = Arc::new(InMemoryStores::new()); + let unbound = service(store.clone()); + let Ok(issued) = unbound + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + // Same signing key, same session store — only the binding differs. + let bound = bound_service(store, Some("bymax"), None); + assert!(bound.verify_access(&issued.access_token).await.is_err()); + } + + #[tokio::test] + async fn a_bound_verifier_refuses_the_wrong_value() { + // The case that matters when one deployment's token is replayed at another that + // happens to trust the same secret. + let store = Arc::new(InMemoryStores::new()); + let theirs = bound_service(store.clone(), Some("someone-else"), Some("their-service")); + let Ok(issued) = theirs + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + let ours = bound_service(store, Some("bymax"), Some("dashboard")); + assert!(ours.verify_access(&issued.access_token).await.is_err()); + } + + #[cfg(feature = "platform")] + #[tokio::test] + async fn the_platform_plane_is_bound_too() { + // The plane that carries the most authority is not the one to leave unstamped. + let store = Arc::new(InMemoryStores::new()); + let svc = bound_service(store.clone(), Some("bymax"), Some("platform")); + let Ok(issued) = svc + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + let Ok(claims) = svc.verify_platform_access(&issued.access_token).await else { + return; + }; + assert_eq!(claims.iss.as_deref(), Some("bymax")); + assert_eq!(claims.aud.as_deref(), Some("platform")); + + // …and a token minted without the binding is refused on this plane as on the other. + let unbound = service(store); + let Ok(plain) = unbound + .issue_platform_tokens(&platform_admin(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + assert!( + svc.verify_platform_access(&plain.access_token) + .await + .is_err() + ); + } + + #[tokio::test] + async fn a_retired_signing_key_does_not_waive_the_binding() { + // A retired key buys a token signature acceptance and nothing else. Without this the + // binding would be bypassable by anyone holding a secret the deployment used to use. + let store = Arc::new(InMemoryStores::new()); + let retired = HsKey::from_bytes(b"retired-secret-retired-secret-32"); + let old = TokenManagerService::new( + retired, + Vec::new(), + store.clone(), + Duration::from_secs(900), + 7, + Duration::from_secs(30), + 0, + ); + let Ok(issued) = old + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + // A service that accepts the retired key, and requires the binding the old one never + // stamped. + let rotating = service_rotating( + store, + vec![HsKey::from_bytes(b"retired-secret-retired-secret-32")], + ) + .with_binding(TokenBinding { + issuer: Some("bymax".to_owned()), + audience: None, + }); + + assert!(rotating.verify_access(&issued.access_token).await.is_err()); + } } #[cfg(test)] diff --git a/crates/bymax-auth-jwt/src/hs256.rs b/crates/bymax-auth-jwt/src/hs256.rs index ef5fcfb..6cea1f4 100644 --- a/crates/bymax-auth-jwt/src/hs256.rs +++ b/crates/bymax-auth-jwt/src/hs256.rs @@ -183,6 +183,8 @@ mod tests { fn dashboard(iat: i64, exp: i64) -> DashboardClaims { DashboardClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-1".to_owned(), tenant_id: "t_1".to_owned(), @@ -217,6 +219,8 @@ mod tests { ); let platform = PlatformClaims { + iss: None, + aud: None, sub: "p_1".to_owned(), jti: "jti-2".to_owned(), role: "admin".to_owned(), @@ -234,6 +238,8 @@ mod tests { ); let mfa = MfaTempClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-3".to_owned(), token_type: MfaTempType::MfaChallenge, @@ -533,6 +539,8 @@ mod tests { let key = key(); let exp = iat + span; let claims = DashboardClaims { + iss: None, + aud: None, sub, jti, tenant_id: "t".to_owned(), role, token_type: DashboardType::Dashboard, status: "ACTIVE".to_owned(), mfa_enabled, mfa_verified, iat, exp, epoch, diff --git a/crates/bymax-auth-jwt/src/keys.rs b/crates/bymax-auth-jwt/src/keys.rs index ed75400..6de9b59 100644 --- a/crates/bymax-auth-jwt/src/keys.rs +++ b/crates/bymax-auth-jwt/src/keys.rs @@ -104,9 +104,19 @@ pub trait JwtClaims: sealed::Sealed { fn exp(&self) -> i64; /// Issued-at, in Unix seconds. fn iat(&self) -> i64; + /// The `iss` claim, when the token carries one. + fn iss(&self) -> Option<&str>; + /// The `aud` claim, when the token carries one. + fn aud(&self) -> Option<&str>; } impl JwtClaims for DashboardClaims { + fn iss(&self) -> Option<&str> { + self.iss.as_deref() + } + fn aud(&self) -> Option<&str> { + self.aud.as_deref() + } fn exp(&self) -> i64 { self.exp } @@ -116,6 +126,12 @@ impl JwtClaims for DashboardClaims { } impl JwtClaims for PlatformClaims { + fn iss(&self) -> Option<&str> { + self.iss.as_deref() + } + fn aud(&self) -> Option<&str> { + self.aud.as_deref() + } fn exp(&self) -> i64 { self.exp } @@ -125,6 +141,12 @@ impl JwtClaims for PlatformClaims { } impl JwtClaims for MfaTempClaims { + fn iss(&self) -> Option<&str> { + self.iss.as_deref() + } + fn aud(&self) -> Option<&str> { + self.aud.as_deref() + } fn exp(&self) -> i64 { self.exp } @@ -263,6 +285,8 @@ mod tests { // The sealed trait surfaces exp/iat for the temporal check; confirm each claim // type forwards its own fields. let dashboard = DashboardClaims { + iss: None, + aud: None, sub: "u".to_owned(), jti: "j".to_owned(), tenant_id: "t".to_owned(), @@ -279,6 +303,8 @@ mod tests { assert_eq!(JwtClaims::exp(&dashboard), 20); let platform = PlatformClaims { + iss: None, + aud: None, sub: "u".to_owned(), jti: "j".to_owned(), role: "r".to_owned(), @@ -293,6 +319,8 @@ mod tests { assert_eq!(JwtClaims::exp(&platform), 21); let mfa = MfaTempClaims { + iss: None, + aud: None, sub: "u".to_owned(), jti: "j".to_owned(), token_type: bymax_auth_types::MfaTempType::MfaChallenge, diff --git a/crates/bymax-auth-types/src/claims.rs b/crates/bymax-auth-types/src/claims.rs index b816e88..4f78d33 100644 --- a/crates/bymax-auth-types/src/claims.rs +++ b/crates/bymax-auth-types/src/claims.rs @@ -120,6 +120,20 @@ pub struct DashboardClaims { #[serde(default)] #[cfg_attr(feature = "ts-export", ts(as = "Option::", optional))] pub epoch: u64, + /// The `iss` claim, present only when the deployment configured `jwt.issuer`. + /// + /// Absent by default. When the verifier is configured with a value, a token carrying a + /// different one — or none at all — is rejected: accepting an unstamped token would give + /// an attacker a way to opt out of the check by omitting the claim. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub iss: Option, + /// The `aud` claim, with the same semantics as [`Self::iss`]. With HS256 the verifier can + /// also sign, so audience binding is what stops a token minted for one service being + /// replayed at another that trusts the same secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub aud: Option, } /// Access token for platform admins — no `tenantId`. The TypeScript counterpart is @@ -160,6 +174,20 @@ pub struct PlatformClaims { #[serde(default)] #[cfg_attr(feature = "ts-export", ts(as = "Option::", optional))] pub epoch: u64, + /// The `iss` claim, present only when the deployment configured `jwt.issuer`. + /// + /// Absent by default. When the verifier is configured with a value, a token carrying a + /// different one — or none at all — is rejected: accepting an unstamped token would give + /// an attacker a way to opt out of the check by omitting the claim. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub iss: Option, + /// The `aud` claim, with the same semantics as [`Self::iss`]. With HS256 the verifier can + /// also sign, so audience binding is what stops a token minted for one service being + /// replayed at another that trusts the same secret. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub aud: Option, } /// Short-lived token bridging the password step and the MFA challenge. The TypeScript @@ -187,6 +215,14 @@ pub struct MfaTempClaims { /// Expiry (seconds since the Unix epoch). #[cfg_attr(feature = "ts-export", ts(type = "number"))] pub exp: i64, + /// The `iss` claim, present only when the deployment configured `jwt.issuer`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub iss: Option, + /// The `aud` claim, with the same semantics as [`Self::iss`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-export", ts(optional))] + pub aud: Option, } #[cfg(test)] @@ -195,6 +231,8 @@ mod tests { fn dashboard_claims() -> DashboardClaims { DashboardClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-1".to_owned(), tenant_id: "t_1".to_owned(), @@ -244,6 +282,8 @@ mod tests { fn platform_claims_have_no_tenant_id() { // Platform tokens never carry a tenant scope; the field is absent by type. let claims = PlatformClaims { + iss: None, + aud: None, sub: "p_1".to_owned(), jti: "jti-2".to_owned(), role: "super_admin".to_owned(), @@ -265,6 +305,8 @@ mod tests { // The temp token's `type` is `mfa_challenge` and its `context` routes // persistence to the dashboard or platform store downstream. let claims = MfaTempClaims { + iss: None, + aud: None, sub: "u_1".to_owned(), jti: "jti-3".to_owned(), token_type: MfaTempType::MfaChallenge, @@ -344,6 +386,8 @@ mod tests { let dashboard = serde_json::to_value(dashboard_claims()).unwrap_or_default(); assert_eq!(dashboard.get("epoch"), Some(&serde_json::json!(3))); let platform = serde_json::to_value(PlatformClaims { + iss: None, + aud: None, sub: "p_1".to_owned(), jti: "jti-2".to_owned(), role: "super_admin".to_owned(), diff --git a/packages/rust-auth/src/shared/jwt-payload.types.ts b/packages/rust-auth/src/shared/jwt-payload.types.ts index fe79fd9..0c059e7 100644 --- a/packages/rust-auth/src/shared/jwt-payload.types.ts +++ b/packages/rust-auth/src/shared/jwt-payload.types.ts @@ -60,7 +60,21 @@ exp: number, * applies when deserializing into this struct). Rendered via `Option::` because * ts-rs maps 64-bit integers to `bigint`, while `JSON.parse` yields a `number`. */ -epoch?: number, }; +epoch?: number, +/** + * The `iss` claim, present only when the deployment configured `jwt.issuer`. + * + * Absent by default. When the verifier is configured with a value, a token carrying a + * different one — or none at all — is rejected: accepting an unstamped token would give + * an attacker a way to opt out of the check by omitting the claim. + */ +iss?: string, +/** + * The `aud` claim, with the same semantics as [`Self::iss`]. With HS256 the verifier can + * also sign, so audience binding is what stops a token minted for one service being + * replayed at another that trusts the same secret. + */ +aud?: string, }; /** * Discriminator value for a dashboard access token. Serializes to `"dashboard"`. @@ -101,7 +115,15 @@ iat: number, /** * Expiry (seconds since the Unix epoch). */ -exp: number, }; +exp: number, +/** + * The `iss` claim, present only when the deployment configured `jwt.issuer`. + */ +iss?: string, +/** + * The `aud` claim, with the same semantics as [`Self::iss`]. + */ +aud?: string, }; /** * Discriminator value for an MFA-temp token. Serializes to `"mfa_challenge"`. @@ -153,7 +175,21 @@ exp: number, * claim is absent, and is exported as an optional TS property for the same reason as * [`DashboardClaims::epoch`]. */ -epoch?: number, }; +epoch?: number, +/** + * The `iss` claim, present only when the deployment configured `jwt.issuer`. + * + * Absent by default. When the verifier is configured with a value, a token carrying a + * different one — or none at all — is rejected: accepting an unstamped token would give + * an attacker a way to opt out of the check by omitting the claim. + */ +iss?: string, +/** + * The `aud` claim, with the same semantics as [`Self::iss`]. With HS256 the verifier can + * also sign, so audience binding is what stops a token minted for one service being + * replayed at another that trusts the same secret. + */ +aud?: string, }; /** * Discriminator value for a platform access token. Serializes to `"platform"`. From fb539c51f4978f877e6bb9552a3e5c2df1b42848 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:35:50 -0300 Subject: [PATCH 120/122] fix(core): stamp the MFA challenge token, and close the binding survivors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo mutants` reported survivors on the trivial `iss()`/`aud()` accessors of `MfaTempClaims`. An accessor surviving is usually noise; here it was a symptom. The binding was VERIFIED in one place — the right design — but STAMPED in several, and `Stampable` was implemented for `DashboardClaims` and `PlatformClaims` only. A deployment that configured `jwt.issuer` therefore minted its MFA challenge token without the claim and then rejected it on verification: MFA login broken outright, and only for the deployments that turned the binding on. Red-checked — removing the stamp again fails the new test with `MfaTempTokenInvalid`. The rule the mutant was pointing at: when a value is required in one place and produced in several, the asymmetry is the bug waiting to happen. All three minted shapes now implement `Stampable`, so the list of implementors is the list of things that can go missing. The rest were test gaps: - `!is_empty()` in the builder had no test at all here, though nest-auth had one — two end-to-end cases through the builder now cover both halves; - `binding_holds` was only ever exercised varying issuer AND audience together, so inverting either comparison went unnoticed; each half now has its own case, plus the one-sided configurations that must not start requiring the other; - `put_email_change` / `consume_email_change` had no store-level test — the same blind spot the invitee index had, for the same reason: both routes answer 204 either way, so an end-to-end test cannot see the keyspace; - the `iss`/`aud` accessors are asserted in their own crate, since the check that reads them lives in another one and cargo-mutants tests a mutant against its own package. One assertion was vacuous rather than missing: `let Ok(claims) = … else { return }` over the operation under test turns a failure into a pass, which is exactly what it did when the stamp was mutated away. Asserted before destructuring now. The defaulted `send_email_changed_notification` body is a documented equivalent, in the shape the config already uses for the hook defaults: `let _ = (args); Ok(())` replaced by `Ok(())` is the same program. 80 mutants over the round's diff: 60 caught, 20 unviable, none missed. --- .cargo/mutants.toml | 7 + crates/bymax-auth-core/src/engine/builder.rs | 83 ++++++++++ .../src/services/token_manager.rs | 148 ++++++++++++++++-- crates/bymax-auth-jwt/src/keys.rs | 38 +++++ crates/bymax-auth-redis/tests/redis_stores.rs | 57 ++++++- 5 files changed, 320 insertions(+), 13 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 6578bb3..fd51672 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -101,6 +101,12 @@ additional_cargo_args = ["--all-features"] # 12. `replace | with ^ in decode_hex` — same shape as 1 and 10: `(hi << 4) | lo` combines a # high nibble with a value `hex_nibble` bounds to 0..=15, so the two never share a set bit # and XOR writes the identical byte. +# 13. `EmailProvider::send_email_changed_notification` default body — the same shape as 8: it is +# literally `let _ = (args); Ok(())`, where the binding exists only to tell the compiler the +# arguments are deliberately unused. Replacing it with `Ok(())` is the same program. The +# pattern matches the TRAIT default only; a real provider's implementation is still mutated, +# which is what catches a notice that stops firing. The verification sender next to it is +# deliberately NOT defaulted, so it generates no such mutant. exclude_re = [ 'replace \| with \^ in new_uuid_v4', 'replace AuthEngine::builder -> AuthEngineBuilder with Default::default\(\)', @@ -115,4 +121,5 @@ exclude_re = [ 'replace \| with \^ in hotp', 'replace is_legacy -> bool with false', 'replace \| with \^ in decode_hex', + 'replace EmailProvider::send_email_changed_notification -> Result<\(\), EmailError> with Ok\(\(\)\)', ] diff --git a/crates/bymax-auth-core/src/engine/builder.rs b/crates/bymax-auth-core/src/engine/builder.rs index 49e6fcc..b885fb4 100644 --- a/crates/bymax-auth-core/src/engine/builder.rs +++ b/crates/bymax-auth-core/src/engine/builder.rs @@ -981,6 +981,89 @@ mod tests { assert!(matches!(&result, Ok(engine) if engine.oauth_state_store().is_some())); } + /// A minimal verified user, for the tests that mint a token through a built engine. + fn builder_user() -> bymax_auth_types::SafeAuthUser { + bymax_auth_types::SafeAuthUser { + id: "u1".to_owned(), + email: "u@example.com".to_owned(), + name: "U".to_owned(), + role: "ADMIN".to_owned(), + tenant_id: "t1".to_owned(), + status: "ACTIVE".to_owned(), + email_verified: true, + mfa_enabled: false, + created_at: time::OffsetDateTime::UNIX_EPOCH, + last_login_at: None, + oauth_provider: None, + oauth_provider_id: None, + } + } + + #[tokio::test] + async fn an_empty_issuer_or_audience_reads_as_unconfigured() { + // A host threading an unset environment variable through must not silently turn the + // binding on: the engine would start stamping the empty string and requiring it, and + // every token it minted would be rejected by any peer that left the setting alone. + // Empty is "not configured", not "require the empty issuer". + let s = stores(); + let mut cfg = valid_config(); + cfg.jwt.issuer = Some(String::new()); + cfg.jwt.audience = Some(String::new()); + let engine = AuthEngine::builder() + .config(cfg) + .user_repository(user_repo()) + .redis_stores(s) + .build(); + assert!(engine.is_ok(), "an empty binding must still build"); + let Ok(engine) = engine else { return }; + + let issued = engine + .tokens() + .issue_tokens(&builder_user(), "10.0.0.1", "agent/1.0", false) + .await; + assert!(issued.is_ok(), "an empty binding must still mint"); + let Ok(issued) = issued else { return }; + let claims = engine.tokens().verify_access(&issued.access_token).await; + assert!(claims.is_ok(), "an empty binding rejected its own token"); + // …and nothing was stamped, so a peer that configured neither still accepts it. + let Ok(claims) = claims else { return }; + assert_eq!(claims.iss, None); + assert_eq!(claims.aud, None); + } + + #[tokio::test] + async fn a_configured_issuer_and_audience_reach_the_minted_token() { + // The other half: a real value does turn the binding on, end to end through the + // builder — the wiring between the config and the token manager is what these two + // tests hold, and it is the one place a `!is_empty()` inversion would hide. + let s = stores(); + let mut cfg = valid_config(); + cfg.jwt.issuer = Some("bymax".to_owned()); + cfg.jwt.audience = Some("dashboard".to_owned()); + let engine = AuthEngine::builder() + .config(cfg) + .user_repository(user_repo()) + .redis_stores(s) + .build(); + assert!(engine.is_ok(), "a configured binding must build"); + let Ok(engine) = engine else { return }; + + let issued = engine + .tokens() + .issue_tokens(&builder_user(), "10.0.0.1", "agent/1.0", false) + .await; + assert!(issued.is_ok(), "a configured binding must mint"); + let Ok(issued) = issued else { return }; + let claims = engine.tokens().verify_access(&issued.access_token).await; + assert!( + claims.is_ok(), + "a configured binding rejected its own token" + ); + let Ok(claims) = claims else { return }; + assert_eq!(claims.iss.as_deref(), Some("bymax")); + assert_eq!(claims.aud.as_deref(), Some("dashboard")); + } + #[test] fn oauth_enabled_without_custom_hook_flags_only_the_default_hook_case() { // The warning predicate fires exactly when OAuth is enabled AND no custom hooks were diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index f6ca223..4f3a12e 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -112,6 +112,16 @@ impl Stampable for PlatformClaims { } } +impl Stampable for MfaTempClaims { + fn stamped(&self, issuer: Option, audience: Option) -> Self { + Self { + iss: issuer, + aud: audience, + ..self.clone() + } + } +} + /// Issues and rotates the dashboard token pair over the [`SessionStore`] seam. Platform /// issuance (`SafeAuthPlatformUser`/`PlatformClaims`) is a separate identity surface and /// is wired with the platform domain. @@ -843,7 +853,7 @@ impl TokenManagerService { iat: now, exp: now.saturating_add(MFA_TEMP_TOKEN_TTL_SECONDS), }; - let token = sign(&claims, &self.key).map_err(signing_failed)?; + let token = sign(&self.stamp(&claims), &self.key).map_err(signing_failed)?; Ok((token, jti)) } @@ -2123,9 +2133,12 @@ mod tests { return; }; - let Ok(claims) = svc.verify_access(&issued.access_token).await else { - return; - }; + let verified = svc.verify_access(&issued.access_token).await; + assert!( + verified.is_ok(), + "an unbound service rejected its own token" + ); + let Ok(claims) = verified else { return }; assert_eq!(claims.iss, None); assert_eq!(claims.aud, None); } @@ -2143,9 +2156,15 @@ mod tests { return; }; - let Ok(claims) = svc.verify_access(&issued.access_token).await else { - return; - }; + // Asserted, not `let-else`-ed: on this test the verification failing IS the failure + // under test — a stamp that never happened makes the bound verifier reject the + // backend's own token, and an early return would score that as a pass. + let verified = svc.verify_access(&issued.access_token).await; + assert!( + verified.is_ok(), + "a bound service rejected its own token: {verified:?}" + ); + let Ok(claims) = verified else { return }; assert_eq!(claims.iss.as_deref(), Some("bymax")); assert_eq!(claims.aud.as_deref(), Some("dashboard")); } @@ -2185,6 +2204,112 @@ mod tests { assert!(ours.verify_access(&issued.access_token).await.is_err()); } + #[tokio::test] + async fn each_half_of_the_binding_is_checked_on_its_own() { + // Both clauses need their own case. A token whose ISSUER matches but whose AUDIENCE + // does not is the shape that catches an inverted audience comparison, and vice versa — + // a test that only ever varies both at once cannot tell the two apart, and half the + // check could be inverted without a single failure. + let store = Arc::new(InMemoryStores::new()); + let ours = bound_service(store.clone(), Some("bymax"), Some("dashboard")); + + // Right issuer, wrong audience. + let wrong_audience = bound_service(store.clone(), Some("bymax"), Some("another-service")); + let Ok(issued) = wrong_audience + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + assert!( + ours.verify_access(&issued.access_token).await.is_err(), + "a token aimed at another audience was accepted" + ); + + // Wrong issuer, right audience. + let wrong_issuer = bound_service(store, Some("someone-else"), Some("dashboard")); + let Ok(issued) = wrong_issuer + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + assert!( + ours.verify_access(&issued.access_token).await.is_err(), + "a token from another issuer was accepted" + ); + } + + #[tokio::test] + async fn binding_only_one_half_leaves_the_other_unchecked() { + // Configuring an issuer alone must not start requiring an audience: a deployment that + // set one field would otherwise reject every token, including its own. + let store = Arc::new(InMemoryStores::new()); + let issuer_only = bound_service(store.clone(), Some("bymax"), None); + let Ok(issued) = issuer_only + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + + let verified = issuer_only.verify_access(&issued.access_token).await; + assert!( + verified.is_ok(), + "an issuer-only binding rejected its own token" + ); + let Ok(claims) = verified else { return }; + assert_eq!(claims.iss.as_deref(), Some("bymax")); + assert_eq!(claims.aud, None); + + // …and the same for an audience alone. + let audience_only = bound_service(store, None, Some("dashboard")); + let Ok(issued) = audience_only + .issue_tokens(&user(), "10.0.0.1", "agent/1.0", false) + .await + else { + return; + }; + let verified = audience_only.verify_access(&issued.access_token).await; + assert!( + verified.is_ok(), + "an audience-only binding rejected its own token" + ); + let Ok(claims) = verified else { return }; + assert_eq!(claims.iss, None); + assert_eq!(claims.aud.as_deref(), Some("dashboard")); + } + + #[cfg(feature = "mfa")] + #[tokio::test] + async fn the_mfa_challenge_token_is_stamped_like_every_other() { + // The token that bridges the password step and the second factor is minted by this + // service and verified by it, so it has to carry the binding too. Stamping the two + // access shapes and forgetting this one would leave a bound deployment rejecting its + // own challenge token — MFA login broken outright, and only for the deployments that + // turned the binding on. + let store = Arc::new(InMemoryStores::new()); + let svc = service_with_mfa(store).with_binding(TokenBinding { + issuer: Some("bymax".to_owned()), + audience: Some("dashboard".to_owned()), + }); + + let issued = svc + .issue_mfa_temp_token("user-1", MfaContext::Dashboard) + .await; + assert!( + issued.is_ok(), + "a bound service must still mint an MFA challenge token" + ); + let Ok(token) = issued else { return }; + + let verified = svc.verify_mfa_temp_token(&token).await; + assert!( + verified.is_ok(), + "a bound service rejected its own MFA challenge token: {verified:?}" + ); + } + #[cfg(feature = "platform")] #[tokio::test] async fn the_platform_plane_is_bound_too() { @@ -2198,9 +2323,12 @@ mod tests { return; }; - let Ok(claims) = svc.verify_platform_access(&issued.access_token).await else { - return; - }; + let verified = svc.verify_platform_access(&issued.access_token).await; + assert!( + verified.is_ok(), + "a bound service rejected its own platform token: {verified:?}" + ); + let Ok(claims) = verified else { return }; assert_eq!(claims.iss.as_deref(), Some("bymax")); assert_eq!(claims.aud.as_deref(), Some("platform")); diff --git a/crates/bymax-auth-jwt/src/keys.rs b/crates/bymax-auth-jwt/src/keys.rs index 6de9b59..2bae365 100644 --- a/crates/bymax-auth-jwt/src/keys.rs +++ b/crates/bymax-auth-jwt/src/keys.rs @@ -330,5 +330,43 @@ mod tests { }; assert_eq!(JwtClaims::iat(&mfa), 12); assert_eq!(JwtClaims::exp(&mfa), 22); + + // …and the binding accessors, on all three. They are what the engine's issuer/audience + // check reads, and that check lives in another crate — so without asserting them here + // this crate ships three pairs of accessors its own suite never calls. An accessor that + // answered `None` for a stamped token, or a constant for any token, would disarm the + // binding wherever it is configured: the verifier would compare the wrong value and + // either accept everything or reject everything. + assert_eq!(JwtClaims::iss(&dashboard), None); + assert_eq!(JwtClaims::aud(&dashboard), None); + assert_eq!(JwtClaims::iss(&platform), None); + assert_eq!(JwtClaims::aud(&platform), None); + assert_eq!(JwtClaims::iss(&mfa), None); + assert_eq!(JwtClaims::aud(&mfa), None); + + // A stamped claim forwards its own value rather than a constant. + let stamped_dashboard = DashboardClaims { + iss: Some("bymax".to_owned()), + aud: Some("dashboard".to_owned()), + ..dashboard + }; + assert_eq!(JwtClaims::iss(&stamped_dashboard), Some("bymax")); + assert_eq!(JwtClaims::aud(&stamped_dashboard), Some("dashboard")); + + let stamped_platform = PlatformClaims { + iss: Some("bymax".to_owned()), + aud: Some("platform".to_owned()), + ..platform + }; + assert_eq!(JwtClaims::iss(&stamped_platform), Some("bymax")); + assert_eq!(JwtClaims::aud(&stamped_platform), Some("platform")); + + let stamped_mfa = MfaTempClaims { + iss: Some("bymax".to_owned()), + aud: Some("challenge".to_owned()), + ..mfa + }; + assert_eq!(JwtClaims::iss(&stamped_mfa), Some("bymax")); + assert_eq!(JwtClaims::aud(&stamped_mfa), Some("challenge")); } } diff --git a/crates/bymax-auth-redis/tests/redis_stores.rs b/crates/bymax-auth-redis/tests/redis_stores.rs index dcadf69..927856e 100644 --- a/crates/bymax-auth-redis/tests/redis_stores.rs +++ b/crates/bymax-auth-redis/tests/redis_stores.rs @@ -22,9 +22,9 @@ use bymax_auth_core::services::auth::{ use bymax_auth_core::services::auth::{LoginInput, RegisterInput}; use bymax_auth_core::testing::InMemoryUserRepository; use bymax_auth_core::traits::{ - BruteForceStore, InvitationStore, OtpPurpose, OtpStore, PasswordResetStore, ResetContext, - RotateOutcome, SessionKind, SessionRecord, SessionRotation, SessionStore, StoredInvitation, - UserRepository, WsTicketSnapshot, WsTicketStore, + BruteForceStore, EmailChangeContext, InvitationStore, OtpPurpose, OtpStore, PasswordResetStore, + ResetContext, RotateOutcome, SessionKind, SessionRecord, SessionRotation, SessionStore, + StoredInvitation, UserRepository, WsTicketSnapshot, WsTicketStore, }; use bymax_auth_core::{AuthConfig, AuthEngine, Environment}; use bymax_auth_types::{AuthError, LoginResult}; @@ -1227,6 +1227,57 @@ async fn the_invitee_index_is_the_only_handle_on_a_pending_invitation() { )); } +#[tokio::test] +async fn a_pending_address_change_round_trips_and_is_consumed_once() { + // Asserted at the store, not through the route: the request answers 204 and the + // confirmation answers 204, so an end-to-end test cannot tell a working `ec:` keyspace + // from one that writes nothing and reads nothing — the same blindness the invitation + // index had, for the same reason. + let Some(redis) = common::try_start().await else { + return; + }; + let Some(stores) = redis.stores() else { + return; + }; + + let context = EmailChangeContext { + user_id: "user-42".to_owned(), + new_email: "new@example.com".to_owned(), + tenant_id: "t1".to_owned(), + password_fingerprint: "a".repeat(64), + }; + assert!( + stores + .put_email_change("change-secret", &context, 3600) + .await + .is_ok() + ); + + // Every field survives the round trip. `new_email` and `tenant_id` drive the uniqueness + // re-check at confirm time, and `password_fingerprint` is what makes a planted request die + // when the victim changes their password — a field lost in transit silently disarms it. + assert!(matches!( + stores.consume_email_change("change-secret").await, + Ok(Some(c)) + if c.user_id == "user-42" + && c.new_email == "new@example.com" + && c.tenant_id == "t1" + && c.password_fingerprint == "a".repeat(64) + )); + + // Single-use: the read and the delete are one operation, so a link clicked twice — or + // raced — applies once. + assert!(matches!( + stores.consume_email_change("change-secret").await, + Ok(None) + )); + // …and a token that was never issued reaches nothing. + assert!(matches!( + stores.consume_email_change("never-issued").await, + Ok(None) + )); +} + #[tokio::test] async fn a_stored_invitation_that_no_longer_parses_reads_as_absent() { // The revocation path reaches the record through the index rather than through a token, so From cd24076e2a7d82dacfcd7b6711b7013cbc4b470a Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:01:30 -0300 Subject: [PATCH 121/122] fix(core): gate the platform Stampable impl behind its feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformClaims` only exists under the `platform` feature, so an ungated impl broke every build without it — caught by the feature matrix, which is the gate that exists for exactly this and which the branch had just gained a fix for. --- crates/bymax-auth-core/src/services/token_manager.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/bymax-auth-core/src/services/token_manager.rs b/crates/bymax-auth-core/src/services/token_manager.rs index 4f3a12e..a7c78a2 100644 --- a/crates/bymax-auth-core/src/services/token_manager.rs +++ b/crates/bymax-auth-core/src/services/token_manager.rs @@ -102,6 +102,9 @@ impl Stampable for DashboardClaims { } } +// Gated with the type it stamps: `PlatformClaims` only exists under the `platform` feature, +// and the feature matrix builds every combination. +#[cfg(feature = "platform")] impl Stampable for PlatformClaims { fn stamped(&self, issuer: Option, audience: Option) -> Self { Self { From c9944d61023f6776ffa8635446d9c6cae5230208 Mon Sep 17 00:00:00 2001 From: Maximiliano <40447063+msalvatti@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:21:39 -0300 Subject: [PATCH 122/122] docs: document the address-change routes and the token binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two features shipped with a CHANGELOG entry and nothing else: the README's route table, the specification's route-group index, its handler table and its rate-limit table all predate them, and `jwt.issuer`/`jwt.audience` appeared in no prose at all — a consumer reading the docs would not know either exists. The binding gets its own note next to the secret-rotation one, because the two things a deployer has to know before switching it on are not obvious from the field names: both backends must carry the same pair, and enabling it invalidates the access tokens already in flight. --- README.md | 18 +++++++++++++++++- docs/technical_specification.md | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 016c489..0709c68 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se | Group | Key options | nest-compat default | | ------------------ | ---------------------------------------------------------------------------- | -------------------------- | -| **jwt** | `secret` (required, ≥ 32 chars), `previous_secrets`, `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days` | `15m`, `7d`, off, HS256 (pinned) | +| **jwt** | `secret` (required, ≥ 32 chars), `previous_secrets`, `access_ttl`, `refresh_expires_in_days`, `absolute_session_lifetime_days`, `issuer`, `audience` | `15m`, `7d`, off, HS256 (pinned), both off | | **password** | `active_algorithm`, scrypt `cost_factor` / Argon2id `memory_kib` | scrypt N=2¹⁷, r=8, p=1 | | **token_delivery** | `Cookie` \| `Bearer` \| `Both` | `Cookie` | | **cookies** | names, `refresh_cookie_path`, `same_site`, `trusted_origins`, `resolve_domains` | HttpOnly, Secure, Strict, `[]` | @@ -438,6 +438,20 @@ Everything is configured through `AuthConfig`. Two ready-made profiles bundle se > `build()` validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, `SameSite=None ⇒ Secure`, `SameSite=None ⇔ trusted_origins`, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise `ConfigError`. > [!TIP] +> **Binding tokens to an issuer and an audience.** `jwt.issuer` and `jwt.audience` are `None` +> by default. Set either and its value is stamped on every token this backend mints — dashboard, +> platform and MFA challenge alike — and **required** on every token it verifies: one carrying a +> different value, or none at all, is rejected. That matters with HS256, where the verifier can +> also sign: every service holding the secret to check a token can mint one, so audience binding +> is what stops a token minted for one service being replayed at another that trusts the same +> secret. The check sits at the single verification chokepoint, so a retired signing key does not +> waive it. +> +> Two things to know before switching it on. Both backends of a shared deployment must carry the +> same pair, or they stop accepting each other's tokens. And enabling it invalidates the access +> tokens already in flight — a window of one access-token lifetime, which clients close by +> refreshing. An empty string reads as unconfigured rather than as "require the empty issuer". + > **Rotating the signing secret.** `jwt.previous_secrets` lists secrets retired by a rotation, > accepted for verification only. Without it, changing `jwt.secret` signs every user out the > moment the new configuration rolls out *and* invalidates every stored recovery-code digest — @@ -724,6 +738,8 @@ Route groups mount only when their feature **and** runtime toggle are enabled, s | POST | `/auth/invitations` | `AuthUser` | Create a tenant invitation | | POST | `/auth/invitations/accept` | Public | Accept an invitation and create the user | | POST | `/auth/invitations/revoke` | `AuthUser` | Withdraw a pending invitation | +| POST | `/auth/email/change` | `AuthUser` | Request an address change (re-proves the password) | +| POST | `/auth/email/change/confirm` | Public | Confirm it with the token sent to the new address | | POST | `/auth/platform/login` | Public | Platform-admin login (separate context) | | POST | `/auth/platform/mfa/challenge`| Public | Platform-admin MFA challenge | | GET | `/auth/platform/me` | `PlatformUser` | Current platform admin | diff --git a/docs/technical_specification.md b/docs/technical_specification.md index 5b8b97c..2fab8d1 100644 --- a/docs/technical_specification.md +++ b/docs/technical_specification.md @@ -1252,6 +1252,7 @@ The route groups and the toggle/feature that gates each: | Platform | `/platform/login` `/platform/me` `/platform/logout` `/platform/refresh` `/platform/sessions` (and `/platform/mfa/challenge` when `mfa` is also on) | `platform` (auto when `platform.enabled`) | `platform` | | OAuth | `/oauth/:provider/authorize` `/oauth/:provider/callback` | `oauth` (opt-in) | `oauth` | | Invitations | `/invitations` `/invitations/accept` `/invitations/revoke` | `invitations` (auto when `invitations.enabled`) | `invitations` | +| Email change | `/email/change` `/email/change/confirm` | `email_change` (opt-in) | — (always compiled) | For consumers using the framework-agnostic core directly (no Axum), the toggles still gate which engine methods are wired and which background tasks run; routing is simply the consumer's responsibility. The edge JWT verifier (`bymax-auth-wasm`) is independent of all toggles — it only ever verifies HS256 access tokens locally and mounts no routes. @@ -2685,6 +2686,17 @@ their ordering reproduces the NestJS guard pipeline. | POST | `/auth/invitations/accept` | `accept_invitation` | `ValidatedJson` | 201 | `AcceptInvitationDto` | invitations | | POST | `/auth/invitations/revoke` | `revoke_invitation` | `AuthUser` | 204 | `RevokeInvitationDto` | invitations | +| Method | Path | Handler | Extractors / guards | Success | Body DTO | Feature | +| ------ | ------------------------------- | ------------------ | ---------------------------------------------- | ------- | ------------------------ | ------- | +| POST | `/auth/email/change` | `request` | `AuthUser`, `ValidatedJson` | 204 | `ChangeEmailDto` | always | +| POST | `/auth/email/change/confirm` | `confirm` | `ValidatedJson` | 204 | `ConfirmEmailChangeDto` | always | + +> The request re-proves the current password and mails a single-use token to the NEW address; +> nothing about the account changes there. The confirmation is public because the person holding +> that token is proving control of a mailbox, not of a session — requiring a login would break +> the case the flow exists to serve. The old address is notified once the change lands +> (NIST SP 800-63B §4.6). + > `create_invitation` derives `tenant_id` from the authenticated user's claims — > never from the body — to prevent cross-tenant injection. `accept` is public. > `revoke_invitation` takes `tenant_id` from the claims for the same reason, and answers 204 @@ -5043,6 +5055,8 @@ brute-force headroom per IP. | `POST /auth/invitations` | `invitation_create` | 10 | 3600 | Prevent invitation flooding / email abuse. | | `POST /auth/invitations/accept` | `invitation_accept` | 5 | 60 | Protect invitation acceptance (token-guess resistance). | | `POST /auth/invitations/revoke` | `invitation_revoke` | 10 | 3600 | Matches the mint, so withdrawing costs what issuing does. | +| `POST /auth/email/change` | `email_change_request` | 3 | 300 | Sends mail to a caller-supplied address; matches the reset limits. | +| `POST /auth/email/change/confirm` | `email_change_confirm` | 5 | 60 | Bounds guessing at the address-change token. | | `GET /auth/sessions` | `list_sessions` | 30 | 60 | Generous read limit. | | `DELETE /auth/sessions/{id}` | `revoke_session` | 10 | 60 | Bound single-session revocation. | | `DELETE /auth/sessions/all` | `revoke_all_sessions` | 5 | 60 | Bound bulk revocation. |