feat(auth): rotating refresh sessions, reuse detection, and logout - #143
Merged
3m1n3nc3 merged 1 commit intoAug 21, 2026
Merged
Conversation
Replace stateless logout with short-lived access + opaque rotating refresh tokens backed by a refresh_tokens table with family linkage. Replaying a consumed token revokes the whole family and its session. Adds /auth/refresh, /auth/logout, and /auth/logout/all with JSON-body or httpOnly-cookie transport and a documented CSRF policy. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #130
Summary
Replaces the stateless logout flow with stateful, rotating refresh sessions per API Roadmap Phase 1 (#130). Login now issues a short-lived access token plus an opaque refresh token; the refresh token rotates on every use, and replaying a consumed token revokes the entire family.
Motivation
Previously there was no server-side session state:
POST /auth/logoutjust returned a client-side reminder, access tokens lived 1 day, and there was no refresh or reuse-detection path. This PR closes those gaps.Changes
Data model
prisma/schema.prisma+ migration20260820130000_refresh_token_rotationRefreshTokentable — one row per opaque token, storing only a SHA-256 hash (tokenHash, unique).familyIdlinks rotated tokens into a rotation family; cascade-deletes withsessions.Token issuance & rotation (
src/services/refresh-token.service.ts)JWT_ACCESS_TTL_SECONDS, default 900s) plus opaque 256-bit refresh tokens (REFRESH_TOKEN_TTL_SECONDS, default 30 days).ACTIVE → ROTATEDclaim means a concurrent refresh of the same token loses and is treated as replay.ROTATEDtoken revokes the whole family + parent session, audited asREFRESH_REUSE_DETECTED.Endpoints
POST /auth/refresh— rotate →200 { accessToken, refreshToken, expiresIn, tokenType }, or401 REFRESH_{INVALID,EXPIRED,REVOKED,REUSE_DETECTED}.POST /auth/logout— logout current (revoke one family).POST /auth/logout/all— logout all sessions for the identified user.register/login/OTP-login now returnaccessToken+refreshToken+expiresIn+tokenType.Transport & CSRF
Authorization: Bearer.refreshTokenor an httpOnlyrefresh_tokencookie (the server only reads the cookie; it never sets it).docs/security/refresh-token-rotation.md;AUTH_POLICY.md,API.md, andROADMAP.mdupdated.Security properties
Testing
tests/refresh-token.service.test.ts— rotation, reuse, race, expiry, revocation, logout, audit actions.tests/auth.controller.test.ts— new response shape, body + cookie transport, and error codes.tsc --noEmitclean,eslintclean on changed files,vitest run→ 668 passed / 3 skipped (the 3 skips are pre-existing DB-requiring integration tests; no Postgres in this environment).Verification evidence (redacted)
Login
Refresh (rotation)
Replay (reuse → family revocation)
Logout
Notes
refresh/logoutare public (refresh-token possession is the credential) and intentionally not rate-limited; happy to add anauthLimiterif desired.reactivateaccount flow still returns a single short-lived access token (out of scope for this PR).