diff --git a/docs/adr/0006-refresh-token-reuse-detection.md b/docs/adr/0006-refresh-token-reuse-detection.md new file mode 100644 index 0000000..faab950 --- /dev/null +++ b/docs/adr/0006-refresh-token-reuse-detection.md @@ -0,0 +1,36 @@ +# ADR 0006 — Refresh token reuse detection via chain revocation + +**Status:** accepted • **Date:** 2026-06 + +## Context + +ADR 0002 introduced single-use opaque refresh tokens with rotation: presenting a +token revokes it and issues a new pair. This closes the window where a stolen +token remains valid indefinitely, but leaves a gap: if an attacker steals a +refresh token before the legitimate user rotates it, both parties hold a valid +token and the system cannot detect the compromise. + +## Decision + +Add a `chainId` (UUID) column to `RefreshToken`. All tokens issued from a single +login share the same `chainId`; rotation preserves it. On `/auth/refresh`, if +the presented token is found but already revoked (`revokedAt IS NOT NULL`), treat +it as a replay attack: + +1. Revoke every token in the chain (`UPDATE ... WHERE chainId = ? AND revokedAt IS NULL`). +2. Log a `warn` with `userId` and `chainId` for audit. +3. Return 401. + +The user must log in again to obtain a new chain. + +## Consequences + +- **Legitimate users are protected**: a replayed token triggers full session + invalidation, limiting the blast radius of a stolen token. +- **Possible false positive**: a client that retries a `/auth/refresh` request + after a network failure (the server responded but the client never received + the new token) will be logged out. This is an accepted trade-off; the + alternative is to keep a compromised session alive. +- **Single atomic query**: chain revocation uses `updateMany`, not a loop — + one round-trip regardless of chain length. +- **No user-facing notification**: out of scope for this iteration. diff --git a/eslint.config.js b/eslint.config.js index 9df772a..091c285 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,10 +13,7 @@ export default tseslint.config( }, }, rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_' }, - ], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-explicit-any': 'error', }, diff --git a/package-lock.json b/package-lock.json index aff3a27..6d3cef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 8a9de46..47643d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.2", + "version": "1.2.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/prisma/migrations/20260612085023_add_chain_id_to_refresh_tokens/migration.sql b/prisma/migrations/20260612085023_add_chain_id_to_refresh_tokens/migration.sql new file mode 100644 index 0000000..9ca7ab6 --- /dev/null +++ b/prisma/migrations/20260612085023_add_chain_id_to_refresh_tokens/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `chainId` to the `refresh_tokens` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "refresh_tokens" ADD COLUMN "chainId" UUID NOT NULL DEFAULT gen_random_uuid(); + +-- CreateIndex +CREATE INDEX "refresh_tokens_chainId_idx" ON "refresh_tokens"("chainId"); diff --git a/prisma/migrations/20260612085236/migration.sql b/prisma/migrations/20260612085236/migration.sql new file mode 100644 index 0000000..8c37164 --- /dev/null +++ b/prisma/migrations/20260612085236/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "refresh_tokens" ALTER COLUMN "chainId" DROP DEFAULT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01cb5f6..9c82ffd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,12 +24,14 @@ model RefreshToken { id String @id @default(uuid()) @db.Uuid tokenHash String @unique userId String @db.Uuid + chainId String @db.Uuid user User @relation(fields: [userId], references: [id], onDelete: Cascade) expiresAt DateTime revokedAt DateTime? createdAt DateTime @default(now()) @@index([userId]) + @@index([chainId]) @@map("refresh_tokens") } diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts index 28cb29b..b382498 100644 --- a/src/modules/auth/auth.repository.ts +++ b/src/modules/auth/auth.repository.ts @@ -14,12 +14,13 @@ export function createAuthRepository(db: PrismaClient) { }); }, - createRefreshToken(tokenHash: string, userId: string, expiresAt: Date) { + createRefreshToken(tokenHash: string, userId: string, expiresAt: Date, chainId: string) { return db.refreshToken.create({ data: { tokenHash, userId, expiresAt, + chainId, }, }); }, @@ -37,5 +38,12 @@ export function createAuthRepository(db: PrismaClient) { data: { revokedAt: new Date() }, }); }, + + revokeChain(chainId: string) { + return db.refreshToken.updateMany({ + where: { chainId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + }, }; } diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0c1cde4..69b0a78 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,7 +1,9 @@ +import { randomUUID } from 'crypto'; import { Errors } from '@/shared/errors/app-error'; import { hashPassword, verifyPassword } from '@/shared/auth/password'; import { ACCESS_TTL_SECONDS, signAccessToken } from '@/shared/auth/jwt'; import { generateToken, sha256 } from '@/shared/auth/tokens'; +import { logger } from '@/config/logger'; import type { createAuthRepository } from './auth.repository'; @@ -19,7 +21,11 @@ type AuthRepository = ReturnType; export type AuthService = ReturnType; export function createAuthService(repo: AuthRepository) { - async function issueTokens(userId: string, email: string): Promise { + async function issueTokens( + userId: string, + email: string, + chainId: string = randomUUID(), + ): Promise { const accessToken = await signAccessToken({ userId, email }); const refreshToken = generateToken(); @@ -28,6 +34,7 @@ export function createAuthService(repo: AuthRepository) { sha256(refreshToken), userId, new Date(Date.now() + REFRESH_TTL_MS), + chainId, ); return { @@ -71,14 +78,24 @@ export function createAuthService(repo: AuthRepository) { async refresh(rawToken: string): Promise { const stored = await repo.findRefreshToken(sha256(rawToken)); - if (!stored || stored.revokedAt || stored.expiresAt < new Date()) { + if (!stored || stored.expiresAt < new Date()) { throw Errors.unauthorized('Invalid refresh token'); } - // Rotation: token is single-use + if (stored.revokedAt) { + // Presented token was already rotated: replay detected, revoke entire chain. + await repo.revokeChain(stored.chainId); + logger.warn( + { userId: stored.userId, chainId: stored.chainId }, + 'refresh token replay detected — chain revoked', + ); + throw Errors.unauthorized('Invalid refresh token'); + } + + // Rotation: token is single-use, new token inherits the same chain. await repo.revokeRefreshToken(stored.id); - return issueTokens(stored.userId, stored.user.email); + return issueTokens(stored.userId, stored.user.email, stored.chainId); }, async logout(rawToken: string): Promise { diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 3a45249..62e975b 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -25,7 +25,12 @@ describe('Auth flow', () => { it('registers a new user and returns a token pair', async () => { const res = await register(); expect(res.statusCode).toBe(201); - const body = res.json<{ tokenType: string; expiresIn: number; accessToken: string; refreshToken: string }>(); + const body = res.json<{ + tokenType: string; + expiresIn: number; + accessToken: string; + refreshToken: string; + }>(); expect(body).toMatchObject({ tokenType: 'Bearer', expiresIn: 900 }); expect(typeof body.accessToken).toBe('string'); expect(typeof body.refreshToken).toBe('string'); @@ -66,6 +71,31 @@ describe('Auth flow', () => { expect(reused.statusCode).toBe(401); }); + it('revokes the entire chain when a rotated token is replayed', async () => { + const { refreshToken: tokenA } = (await register()).json<{ refreshToken: string }>(); + + // Normal rotation: tokenA → tokenB + const { refreshToken: tokenB } = ( + await app.inject({ method: 'POST', url: '/v1/auth/refresh', payload: { refreshToken: tokenA } }) + ).json<{ refreshToken: string }>(); + + // Replay tokenA (already rotated): chain must be revoked + const replay = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { refreshToken: tokenA }, + }); + expect(replay.statusCode).toBe(401); + + // tokenB must also be revoked (chain revocation) + const withTokenB = await app.inject({ + method: 'POST', + url: '/v1/auth/refresh', + payload: { refreshToken: tokenB }, + }); + expect(withTokenB.statusCode).toBe(401); + }); + it('protects /auth/me and accepts a Bearer token', async () => { const { accessToken } = (await register()).json<{ accessToken: string }>();