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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bindings/bymax-auth-wasm/src/jwt_edge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ mod tests {
mfa_verified: false,
iat,
exp,
epoch: 0,
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -355,6 +357,7 @@ mod tests {
mfa_verified: true,
iat: 1_000,
exp: 2_000,
epoch: 0,
},
&HsKey::from_bytes(SECRET),
)
Expand Down
1 change: 1 addition & 0 deletions bindings/bymax-auth-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ mod tests {
mfa_verified: false,
iat,
exp,
epoch: 0,
};
sign(&claims, &HsKey::from_bytes(secret.as_bytes())).unwrap_or_default()
}
Expand Down
1 change: 1 addition & 0 deletions crates/bymax-auth-axum/src/extractors/mfa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions crates/bymax-auth-axum/src/extractors/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ mod tests {
mfa_verified: false,
iat: 1_700_000_000,
exp: 4_700_000_000,
epoch: 0,
}
}

Expand Down
23 changes: 23 additions & 0 deletions crates/bymax-auth-axum/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -358,6 +360,13 @@ impl bymax_auth_core::traits::SessionStore for FailingStores {
) -> Result<(), bymax_auth_types::AuthError> {
Err(fail())
}
async fn revoke_family(
&self,
kind: bymax_auth_core::traits::SessionKind,
family_id: &str,
) -> Result<(), bymax_auth_types::AuthError> {
self.inner.revoke_family(kind, family_id).await
}
async fn blacklist_access(
&self,
jti_or_hash: &str,
Expand All @@ -373,6 +382,20 @@ impl bymax_auth_core::traits::SessionStore for FailingStores {
}
self.inner.is_blacklisted(jti_or_hash).await
}
async fn current_epoch(
&self,
kind: bymax_auth_core::traits::SessionKind,
user_id: &str,
) -> Result<u64, bymax_auth_types::AuthError> {
self.inner.current_epoch(kind, user_id).await
}
async fn bump_epoch(
&self,
kind: bymax_auth_core::traits::SessionKind,
user_id: &str,
) -> Result<u64, bymax_auth_types::AuthError> {
self.inner.bump_epoch(kind, user_id).await
}
}

#[async_trait]
Expand Down
4 changes: 4 additions & 0 deletions crates/bymax-auth-core/src/services/adapter_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -626,6 +629,7 @@ mod tests {
mfa_verified: false,
iat: now_unix(),
exp: now_unix(),
epoch: 0,
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/bymax-auth-core/src/services/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 50 additions & 1 deletion crates/bymax-auth-core/src/services/auth/password_reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -536,6 +541,7 @@ mod tests {
device: "Chrome".to_owned(),
ip: "1.2.3.4".to_owned(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
family_id: "fam-test".to_owned(),
};
assert!(
h.stores
Expand Down Expand Up @@ -601,6 +607,49 @@ mod tests {
));
}

#[tokio::test]
async fn reset_bumps_the_token_epoch_so_pre_reset_access_tokens_are_invalidated() {
// A reset revokes the refresh sessions AND advances the user's token epoch, so every
// already-issued (stateless) access token is rejected on its next verification rather
// than lingering for its remaining lifetime.
let Some(h) = token_harness() else { return };
let id = h.seed(SeedUser::active("epoch@example.com", "pw")).await;
// Before the reset the user carries the inert epoch 0.
assert!(matches!(
h.stores.current_epoch(SessionKind::Dashboard, &id).await,
Ok(0)
));
let known = "e".repeat(64);
assert!(
h.stores
.put_token(
&known,
&ResetContext {
user_id: id.clone(),
email: "epoch@example.com".to_owned(),
tenant_id: "t1".to_owned(),
},
600,
)
.await
.is_ok()
);
let reset = ResetPasswordInput {
email: "epoch@example.com".to_owned(),
tenant_id: "t1".to_owned(),
new_password: "brand-new-pw".to_owned(),
token: Some(known),
otp: None,
verified_token: None,
};
assert!(h.engine.reset_password(reset).await.is_ok());
// The reset advanced the epoch: any token stamped at 0 is now below the current value.
assert!(matches!(
h.stores.current_epoch(SessionKind::Dashboard, &id).await,
Ok(1)
));
}

#[tokio::test]
async fn token_binding_rejects_a_mismatched_email() {
// A token whose stored context was bound to a different email is rejected on reset.
Expand Down
1 change: 1 addition & 0 deletions crates/bymax-auth-core/src/services/auth/session_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 2 additions & 0 deletions crates/bymax-auth-core/src/services/mfa/challenge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/bymax-auth-core/src/services/mfa/manage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
4 changes: 4 additions & 0 deletions crates/bymax-auth-core/src/services/mfa/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
26 changes: 15 additions & 11 deletions crates/bymax-auth-core/src/services/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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]
Expand Down
24 changes: 24 additions & 0 deletions crates/bymax-auth-core/src/services/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -1084,6 +1092,16 @@ mod tests {
async fn is_blacklisted(&self, _jti_or_hash: &str) -> Result<bool, AuthError> {
Ok(false)
}
async fn current_epoch(
&self,
_kind: SessionKind,
_user_id: &str,
) -> Result<u64, AuthError> {
Ok(0)
}
async fn bump_epoch(&self, _kind: SessionKind, _user_id: &str) -> Result<u64, AuthError> {
Ok(1)
}
}

#[tokio::test]
Expand Down Expand Up @@ -1134,6 +1152,12 @@ mod tests {
.is_ok()
);
assert!(store.revoke_all(SessionKind::Dashboard, "u1").await.is_ok());
assert!(
store
.revoke_family(SessionKind::Dashboard, "fam-1")
.await
.is_ok()
);
assert!(store.blacklist_access("jti", 60).await.is_ok());
assert!(matches!(store.is_blacklisted("jti").await, Ok(false)));
}
Expand Down
Loading
Loading