fixed: refresh-token - #123
Conversation
EmeditWeb
left a comment
There was a problem hiding this comment.
✅ Automated Audit: solves
The PR addresses all three root causes from issue #119, not symptoms: session families via a family_id column + 'fam' JWT claim with family-wide revocation and an auth.refresh_token_reuse audit event on replay; a per-request blocked-status check in JwtStrategy.validate() backed by a TTL cache with a documented 30s staleness bound; and an hourly cleanup job mirroring the established nonce-cleanup pattern. Regression tests were added for each acceptance criterion (replay→family revocation+audit, blocked denial within TTL, cleanup deleting only expired rows), satisfying the testing standard. Backward compatibility is handled sensibly (legacy tokens without 'fam' degrade to the old AUTH_SESSION_NOT_FOUND).
Audited by stepfi-audit-bot 🤖
Close #119
PR: Session families, refresh-token replay detection, blocked-user enforcement & session cleanup
Summary
This PR hardens the refresh-token rotation flow and closes three security/hygiene gaps in the auth module:
auth.refresh_token_reuseaudit event is written, and re-authentication is forced.Response shapes are backward-compatible: success responses are unchanged, and legacy refresh tokens without a family claim keep returning the original
AUTH_SESSION_NOT_FOUNDerror.Changes
1. Session families + refresh-token replay detection
Files:
src/modules/auth/auth.service.ts,supabase/migrations/20260824000001_add_session_family_id.sql,src/modules/admin/audit.service.ts(consumed),src/modules/auth/auth.module.tssessions.family_id UUID NOT NULL DEFAULT gen_random_uuid()with indexidx_sessions_family_id.family_id. The refresh JWT now carries afamclaim ({ wallet, type: 'refresh', fam }).AUTH_SESSION_EXPIRED(unchanged).famclaim present → replay detected:AuditService: actionauth.refresh_token_reuse, resourcesession, metadata{ family_id }, after-state{ revoked_sessions: N }.401 AUTH_REFRESH_TOKEN_REUSEDand must sign in again.famclaim (pre-migration token) → falls back to the originalAUTH_SESSION_NOT_FOUNDresponse; nothing to revoke.AuthModuleimportsAdminModule(which exportsAuditService); no circular dependency introduced.2. Blocked-user enforcement on every request
Files:
src/modules/auth/user-status.service.ts(new),src/modules/auth/jwt.strategy.tsUserStatusService: per-wallet status lookup with an in-memory TTL cache.JwtStrategy.validate()is now async and callsensureNotBlocked(wallet)after signature verification. Blocked wallets get401 AUTH_USER_BLOCKEDon every request instead of retaining access until token expiry.USER_STATUS_CACHE_TTL_MS = 30_000). Blocking a wallet takes effect within ~30s on each instance, independent of the remaining access-token lifetime.Mapcache rather than Redis: the check runs on every request; a Redis round trip would double auth latency, and a 30s bound does not justify shared state. Multi-instance deployments each hold their own cache with the same bound.activeand the failure is logged — a DB blip must not lock out every authenticated user. Negative results (blocked) are also cached for the TTL, so repeated denied requests do not hammer the DB.invalidate(wallet)helper forces a fresh DB check (admin/test escape hatch).3. Session cleanup cron job
Files:
src/jobs/session-cleanup/session-cleanup.module.ts,src/jobs/session-cleanup/session-cleanup.service.ts(new),src/app.module.ts@Cron(CronExpression.EVERY_HOUR)mirroring the established nonce-cleanup pattern (per architecture rules:@nestjs/schedule, no BullMQ).expires_atolder than 1 hour (grace window mirrors nonce cleanup and keeps borderline "expired" responses accurate).app.module.tsalongside the other job modules.Files changed
supabase/migrations/20260824000001_add_session_family_id.sqlfamily_idcolumn + indexsrc/modules/auth/auth.service.tssrc/modules/auth/auth.module.tsAdminModule, provide/exportUserStatusServicesrc/modules/auth/jwt.strategy.tsvalidate(), blocked-user checksrc/modules/auth/user-status.service.tssrc/jobs/session-cleanup/*src/app.module.tsSessionCleanupModuletest/unit/modules/auth/auth.service.spec.tstest/unit/modules/auth/user-status.service.spec.tstest/unit/jobs/session-cleanup/session-cleanup.service.spec.tscontext/progress-tracker.md2026-08-24Tests
New coverage (13 tests):
refreshTokens(auth.service.spec.ts):famclaim preserved across rotation)auth.refresh_token_reuseaudit event written +AUTH_REFRESH_TOKEN_REUSEDfamclaim → originalAUTH_SESSION_NOT_FOUND, no audit eventAUTH_SESSION_EXPIREDAUTH_USER_BLOCKEDAUTH_REFRESH_TOKEN_INVALIDuser-status.service.spec.ts:
AUTH_USER_BLOCKEDwhen status is blockedinvalidate()forces fresh checksession-cleanup.service.spec.ts:
expires_atcutoff (cutoff asserted ≈ now − 1h)Also updated existing
generateTokenstests for thefamclaim and verified the session insert carries the samefamily_id.Verification
Acceptance criteria
audit_logsentry)Deployment notes
family_id; existing refresh tokens lack thefamclaim and degrade gracefully to the old behavior until their next login.Risks / trade-offs