From 969d4ba8e1c29454d61d62a28a43a1abfc68f24b Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 10:59:51 +0200 Subject: [PATCH 1/7] feat(auth): add chainid to refreshtoken for chain revocation --- package-lock.json | 4 ++-- package.json | 2 +- .../migration.sql | 11 +++++++++++ prisma/migrations/20260612085236/migration.sql | 2 ++ prisma/schema.prisma | 2 ++ 5 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260612085023_add_chain_id_to_refresh_tokens/migration.sql create mode 100644 prisma/migrations/20260612085236/migration.sql diff --git a/package-lock.json b/package-lock.json index aff3a27..ca1638f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.2", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.2", + "version": "1.1.3", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 8a9de46..b3b0308 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.2", + "version": "1.1.3", "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") } From 4c437e67dabd65d15da9cf1272e661fa90d6ecf7 Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:01:45 +0200 Subject: [PATCH 2/7] feat(auth): add chainid to createrefreshToken and revokechain method --- package-lock.json | 4 ++-- package.json | 2 +- src/modules/auth/auth.repository.ts | 10 +++++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca1638f..c77354d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.3", + "version": "1.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.3", + "version": "1.1.4", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index b3b0308..89d325b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.3", + "version": "1.1.4", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", 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() }, + }); + }, }; } From e11f4e4c43da5a3e8ee5fe56487ae4dbff1c03ce Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:10:33 +0200 Subject: [PATCH 3/7] feat(auth): detect refresh token reuse and revoke chain on replay --- eslint.config.js | 5 +---- package-lock.json | 4 ++-- package.json | 2 +- src/modules/auth/auth.service.ts | 25 +++++++++++++++++++++---- tests/integration/auth.test.ts | 7 ++++++- 5 files changed, 31 insertions(+), 12 deletions(-) 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 c77354d..8ddf1a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.4", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.4", + "version": "1.1.5", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 89d325b..b7365bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.4", + "version": "1.1.5", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 0c1cde4..7f9c3e8 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 = 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..88e611e 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'); From 2ce46d778710884a7a5a8c7d2a3abb620c02da21 Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:12:38 +0200 Subject: [PATCH 4/7] docs(adr): document refresh token reuse detection strategy --- .../adr/0006-refresh-token-reuse-detection.md | 36 +++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0006-refresh-token-reuse-detection.md 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/package-lock.json b/package-lock.json index 8ddf1a8..0283e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.5", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.5", + "version": "1.1.6", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index b7365bd..2a1e859 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.5", + "version": "1.1.6", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", From 39e4737e6752148388767bbc4f310190f37c9d17 Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:15:32 +0200 Subject: [PATCH 5/7] test(auth): add integration test for refresh token reuse detection --- package-lock.json | 4 ++-- package.json | 2 +- tests/integration/auth.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0283e6a..0be13f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.6", + "version": "1.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.6", + "version": "1.1.7", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 2a1e859..76d3abd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.6", + "version": "1.1.7", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 88e611e..62e975b 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -71,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 }>(); From 950162c19c28506f17e8257fd9153c0834915ade Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:22:07 +0200 Subject: [PATCH 6/7] fix(auth): explicitly type chainid parameter as string to satisfy tsc --- package-lock.json | 4 ++-- package.json | 2 +- src/modules/auth/auth.service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0be13f7..3752bda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.7", + "version": "1.1.8", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 76d3abd..76b41f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.7", + "version": "1.1.8", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 7f9c3e8..69b0a78 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -24,7 +24,7 @@ export function createAuthService(repo: AuthRepository) { async function issueTokens( userId: string, email: string, - chainId = randomUUID(), + chainId: string = randomUUID(), ): Promise { const accessToken = await signAccessToken({ userId, email }); From 143f88aba06512f191cc835ceea9a2604a1b315a Mon Sep 17 00:00:00 2001 From: Yentec Date: Fri, 12 Jun 2026 11:30:39 +0200 Subject: [PATCH 7/7] chore(release): 1.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3752bda..6d3cef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.8", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.8", + "version": "1.2.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 76b41f4..47643d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.8", + "version": "1.2.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT",