Skip to content
Merged
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
36 changes: 36 additions & 0 deletions docs/adr/0006-refresh-token-reuse-detection.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 1 addition & 4 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
2 changes: 2 additions & 0 deletions prisma/migrations/20260612085236/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "refresh_tokens" ALTER COLUMN "chainId" DROP DEFAULT;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
10 changes: 9 additions & 1 deletion src/modules/auth/auth.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
},
Expand All @@ -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() },
});
},
};
}
25 changes: 21 additions & 4 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,7 +21,11 @@ type AuthRepository = ReturnType<typeof createAuthRepository>;
export type AuthService = ReturnType<typeof createAuthService>;

export function createAuthService(repo: AuthRepository) {
async function issueTokens(userId: string, email: string): Promise<AuthTokens> {
async function issueTokens(
userId: string,
email: string,
chainId: string = randomUUID(),
): Promise<AuthTokens> {
const accessToken = await signAccessToken({ userId, email });

const refreshToken = generateToken();
Expand All @@ -28,6 +34,7 @@ export function createAuthService(repo: AuthRepository) {
sha256(refreshToken),
userId,
new Date(Date.now() + REFRESH_TTL_MS),
chainId,
);

return {
Expand Down Expand Up @@ -71,14 +78,24 @@ export function createAuthService(repo: AuthRepository) {
async refresh(rawToken: string): Promise<AuthTokens> {
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<void> {
Expand Down
32 changes: 31 additions & 1 deletion tests/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 }>();

Expand Down
Loading