Skip to content

feat(auth): rotating refresh sessions, reuse detection, and logout - #143

Merged
3m1n3nc3 merged 1 commit into
learnault:mainfrom
augustinemartins:feat/refresh-token-rotation
Aug 21, 2026
Merged

feat(auth): rotating refresh sessions, reuse detection, and logout#143
3m1n3nc3 merged 1 commit into
learnault:mainfrom
augustinemartins:feat/refresh-token-rotation

Conversation

@augustinemartins

@augustinemartins augustinemartins commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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/logout just 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 + migration 20260820130000_refresh_token_rotation
    • New RefreshToken table — one row per opaque token, storing only a SHA-256 hash (tokenHash, unique).
    • familyId links rotated tokens into a rotation family; cascade-deletes with sessions.

Token issuance & rotation (src/services/refresh-token.service.ts)

  • Short-lived access tokens (JWT_ACCESS_TTL_SECONDS, default 900s) plus opaque 256-bit refresh tokens (REFRESH_TOKEN_TTL_SECONDS, default 30 days).
  • Atomic rotation: a conditional ACTIVE → ROTATED claim means a concurrent refresh of the same token loses and is treated as replay.
  • Reuse detection: presenting a ROTATED token revokes the whole family + parent session, audited as REFRESH_REUSE_DETECTED.

Endpoints

  • POST /auth/refresh — rotate → 200 { accessToken, refreshToken, expiresIn, tokenType }, or 401 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 return accessToken + refreshToken + expiresIn + tokenType.

Transport & CSRF

  • Access token: Authorization: Bearer.
  • Refresh token: JSON body refreshToken or an httpOnly refresh_token cookie (the server only reads the cookie; it never sets it).
  • Documented in docs/security/refresh-token-rotation.md; AUTH_POLICY.md, API.md, and ROADMAP.md updated.

Security properties

  • Raw refresh tokens are never persisted (SHA-256 hash only).
  • Refresh tokens are single-use; replay triggers family revocation.
  • Logout prevents targeted refresh.
  • Rotation is race-safe via a conditional update (no double-spend under concurrency).

Testing

  • New tests/refresh-token.service.test.ts — rotation, reuse, race, expiry, revocation, logout, audit actions.
  • Updated tests/auth.controller.test.ts — new response shape, body + cookie transport, and error codes.
  • tsc --noEmit clean, eslint clean 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

curl -s -X POST http://localhost:3000/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@example.com","password":"••••••••••"}'
# 200
# { "message":"Login successful","accessToken":"<jwt>","refreshToken":"<opaque-1>","expiresIn":900,"tokenType":"Bearer","user":{...} }

Refresh (rotation)

curl -s -X POST http://localhost:3000/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<opaque-1>"}'
# 200 — returns a NEW refreshToken; <opaque-1> is now consumed

Replay (reuse → family revocation)

curl -s -X POST http://localhost:3000/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<opaque-1>"}'
# 401 { "error":"Refresh token reuse detected; the session has been revoked","code":"REFRESH_REUSE_DETECTED" }

Logout

curl -s -X POST http://localhost:3000/api/v1/auth/logout \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<opaque-2>"}'
# 200 { "message":"Logged out successfully","revokedCount":1 }

Notes

  • refresh/logout are public (refresh-token possession is the credential) and intentionally not rate-limited; happy to add an authLimiter if desired.
  • The reactivate account flow still returns a single short-lived access token (out of scope for this PR).

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>
@3m1n3nc3
3m1n3nc3 merged commit faa0ccc into learnault:main Aug 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Implement Refresh Rotation and Logout API

2 participants