From e62b77d3539abb3c7c50580a49888b5363bca310 Mon Sep 17 00:00:00 2001 From: "Mickael N." Date: Sun, 31 May 2026 18:25:05 +0200 Subject: [PATCH 1/3] feat(auth): add grace window, rotate=false, and atomic CAS rotation for refresh tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add reuseWindowMs and rotate options to DynamicApiRefreshTokenOptions. - reuseWindowMs: grace window in ms — the superseded jti is accepted within this period and returns the cached token pair, preventing false-positive 401s on concurrent multi-tab / multi-device bursts. - rotate: false — persistent-token mode; validates the stored hash on each call without rotating it; server-side revocation via logout still works. - Atomic rotation via findOneAndUpdate (compare-and-swap on stored raw value) — eliminates the race condition between concurrent read-then-write operations; CAS misses fall back to the grace window. - Changed storage format from plain bcrypt hash to JSON RefreshTokenRecord ({ currentHash, previousHash?, rotatedAt?, cachedTokens? }); backward-compatible with existing plain-hash values. Unit tests: 86 passing, 98.93% branch coverage. E2E tests: 14 passing (grace window burst, rotate=false multi-use, revocation after rotate=false, grace window expiry). --- README/authentication.md | 73 ++++ .../src/modules/auth/auth.helper.ts | 2 + .../auth/interfaces/auth-options.interface.ts | 19 + .../auth/services/base-auth.service.spec.ts | 326 ++++++++++++++++-- .../auth/services/base-auth.service.ts | 202 +++++++++-- .../auth-api-refresh-token.e2e-spec.ts | 123 +++++++ 6 files changed, 692 insertions(+), 53 deletions(-) diff --git a/README/authentication.md b/README/authentication.md index 29bdb281..265d08f4 100644 --- a/README/authentication.md +++ b/README/authentication.md @@ -123,6 +123,8 @@ DynamicApiModule.forRoot('mongodb-uri', { refreshTokenField?: keyof Entity; // Entity field to store the bcrypt hash of the refresh token useCookie?: boolean; // Send/read refresh token via httpOnly cookie (default: false) refreshTokenExpiresIn?: string | number; // Default: '7d' + rotate?: boolean; // Rotate token on each use (default: true). Set false for persistent-token mode. + reuseWindowMs?: number; // Grace window in ms: accept previous jti within this period (default: 0). Recommended: 10000. }, // Passwordless / OTP Configuration @@ -1638,6 +1640,8 @@ DynamicApiModule.forRoot('mongodb-uri', { refreshTokenField: 'refreshToken', // Entity field that stores the bcrypt hash useCookie: false, // false = Bearer header (default), true = httpOnly cookie refreshTokenExpiresIn: '7d', // v4 default + rotate: true, // Rotate token on each use (default: true) + reuseWindowMs: 10000, // 10 s grace window for concurrent bursts (default: 0) }, }, }) @@ -1648,6 +1652,75 @@ DynamicApiModule.forRoot('mongodb-uri', { | `refreshTokenField` | `keyof Entity` | — | Field on the entity where the bcrypt hash of the refresh token is stored. Required for server-side revocation. | | `useCookie` | `boolean` | `false` | When `true`, the refresh token is transported via an httpOnly, SameSite=Strict cookie named `refreshToken` instead of the response body / `Authorization` header. | | `refreshTokenExpiresIn` | `string \| number` | `'7d'` | Expiration duration for the refresh token. | +| `rotate` | `boolean` | `true` | When `false`, the stored hash is **not rotated** on each refresh call. The same token remains valid until logout or explicit revocation (persistent-token mode). Only meaningful when `refreshTokenField` is configured. | +| `reuseWindowMs` | `number` | `0` | Grace window in milliseconds. Within this window after a rotation, the superseded (previous) jti is still accepted and returns the cached token pair. Prevents false-positive 401s on concurrent multi-tab / multi-device cold-start bursts. Set to `0` to disable (strict single-use). Recommended: `10000` (10 s). Only meaningful when `refreshTokenField` is set and `rotate` is `true`. | + +--- + +### Grace Window (`reuseWindowMs`) — Concurrent Burst Protection + +By default, rotation is **strict single-use**: once a refresh token is used, it is immediately invalidated. This can cause false-positive 401s when multiple tabs or devices open simultaneously and all try to refresh with the same token at once. + +Setting `reuseWindowMs` enables a **grace window**: for N milliseconds after a rotation, the immediately preceding (superseded) jti is still accepted and returns the **same cached token pair** that was issued by the winning rotation. Concurrent losers get the winner's tokens instead of a 401. + +```typescript +// src/app.module.ts +import { Module } from '@nestjs/common'; +import { DynamicApiModule } from 'mongodb-dynamic-api'; +import { User } from './user.entity'; + +@Module({ + imports: [ + DynamicApiModule.forRoot(process.env.MONGO_URI, { + useAuth: { + userEntity: User, + jwt: { + secret: process.env.JWT_SECRET, + expiresIn: '15m', + refreshTokenExpiresIn: '7d', + }, + refreshToken: { + refreshTokenField: 'refreshTokenHash', // bcrypt hash stored in the User entity + reuseWindowMs: 10000, // 10 s grace window — recommended for multi-tab apps + }, + }, + }), + ], +}) +export class AppModule {} +``` + +**How it works:** +1. Client A (tab 1) and Client B (tab 2) both start with the same refresh token `RT₀`. +2. Both call `POST /auth/refresh-token` concurrently. +3. The server processes Client A first → rotates `RT₀ → RT₁`, caches `{ RT₁, AT₁ }` in the stored record. +4. Client B arrives with `RT₀` (now superseded). Server checks the grace window → `RT₀` is the previous jti, `rotatedAt` is within `reuseWindowMs` → returns the **cached `{ RT₁, AT₁ }`** instead of 401. +5. Both clients end up with `{ RT₁, AT₁ }` — session intact. + +> **Security note:** A token that is truly stolen and replayed from a completely different session (where `RT₀` was superseded *more than* `reuseWindowMs` ago) will still receive a 401. + +--- + +### Persistent Token Mode (`rotate: false`) + +Setting `rotate: false` disables per-call rotation. The stored hash is verified on each request but **never replaced**, so the same refresh token can be used multiple times until: +- The user calls `POST /auth/logout` (server-side revocation still works). +- The JWT itself expires (`refreshTokenExpiresIn`). + +```typescript +DynamicApiModule.forRoot(process.env.MONGO_URI, { + useAuth: { + userEntity: User, + jwt: { secret: process.env.JWT_SECRET, expiresIn: '15m', refreshTokenExpiresIn: '7d' }, + refreshToken: { + refreshTokenField: 'refreshTokenHash', + rotate: false, // Persistent token — valid until logout + }, + }, +}) +``` + +> **Use case:** Apps that need server-side revocation (logout) but where single-use token rotation is impractical — e.g., native mobile apps, IoT devices. --- diff --git a/libs/dynamic-api/src/modules/auth/auth.helper.ts b/libs/dynamic-api/src/modules/auth/auth.helper.ts index 545326ba..08dcb48d 100644 --- a/libs/dynamic-api/src/modules/auth/auth.helper.ts +++ b/libs/dynamic-api/src/modules/auth/auth.helper.ts @@ -129,6 +129,8 @@ function createAuthServiceProvider( protected passwordField = passwordField; protected refreshTokenField = refreshToken?.refreshTokenField; protected refreshTokenOnUpdate = DynamicApiModule.state.get('refreshTokenOnUpdate') ?? false; + protected rotate = refreshToken?.rotate ?? true; + protected reuseWindowMs = refreshToken?.reuseWindowMs ?? 0; protected beforeRegisterCallback = register?.beforeSaveCallback; protected registerCallback = register?.callback; diff --git a/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts b/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts index e36ec8fc..66eb44e0 100644 --- a/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts +++ b/libs/dynamic-api/src/modules/auth/interfaces/auth-options.interface.ts @@ -124,6 +124,25 @@ type DynamicApiRefreshTokenOptions = { * When false (default), the refresh token is sent/read via the Authorization Bearer header exclusively. */ useCookie?: boolean; + /** + * When false, the stored refresh token hash is NOT rotated on each call. + * The same token remains valid until logout or an explicit revocation. + * Useful when you only want server-side revocation (logout) without strict single-use enforcement. + * Default: true (each refresh call issues a new token and invalidates the previous one). + * Only meaningful when `refreshTokenField` is configured. + */ + rotate?: boolean; + /** + * Grace window in milliseconds during which the immediately preceding refresh token + * (the one superseded by the last rotation) is still accepted. + * When a token reuse is detected within this window, the cached token pair from the + * winning rotation is returned — making concurrent bursts (multi-tab, multi-device cold start) + * safe without triggering a false-positive theft detection. + * Set to 0 (default) to disable the grace window (strict single-use). + * Only meaningful when `refreshTokenField` is configured and `rotate` is true. + * Recommended value: 5000–15000 (5 s – 15 s). + */ + reuseWindowMs?: number; }; type PasswordlessOptions = { diff --git a/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts b/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts index 2a2ba8f4..c458588b 100644 --- a/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts +++ b/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts @@ -255,49 +255,310 @@ describe('BaseAuthService', () => { ); }); - it('should rotate refresh token and update hash in DB on valid token', async () => { - exec.mockResolvedValueOnce({ ...fakeUser, nickname: fakeHash }); + it('should handle rawToken without jti (decode returns object without jti)', async () => { + exec.mockResolvedValueOnce({ ...fakeUser, nickname: JSON.stringify({ currentHash: fakeHash }) }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({}); + + await expect(service['refreshToken'](fakeUser, 'token-without-jti')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should rotate via CAS on valid token', async () => { + const jsonRecord = JSON.stringify({ currentHash: fakeHash }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }) // findOne + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }); // CAS success + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); spyBcryptCompare.mockResolvedValueOnce(true); - spyBcriptHashPassword.mockResolvedValueOnce('new-hashed-refresh'); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); + + const result = await service['refreshToken'](fakeUser, 'valid-token'); + + expect(spyBcryptCompare).toHaveBeenCalledWith('input-jti', fakeHash); + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { _id: fakeUser._id, nickname: jsonRecord }, + { $set: { nickname: JSON.stringify({ currentHash: 'new-hash' }) } }, + { new: false }, + ); + expect(model.updateOne).not.toHaveBeenCalled(); + expect(result).toEqual({ accessToken, refreshToken }); + }); + + it('should support legacy plain hash format (backward compat)', async () => { + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: fakeHash }) // findOne (legacy) + .mockResolvedValueOnce({ ...fakeUser, nickname: fakeHash }); // CAS success jest.spyOn(jwtService, 'decode') - .mockReturnValueOnce({ jti: 'input-jti' }) // decode rawToken for validation - .mockReturnValueOnce({ jti: 'new-jti' }); // decode new refreshToken for storage + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); const result = await service['refreshToken'](fakeUser, 'valid-token'); expect(spyBcryptCompare).toHaveBeenCalledWith('input-jti', fakeHash); - expect(spyBcriptHashPassword).toHaveBeenCalledWith('new-jti'); - expect(model.updateOne).toHaveBeenCalledWith( - { _id: fakeUser._id }, - { $set: { nickname: 'new-hashed-refresh' } }, + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { _id: fakeUser._id, nickname: fakeHash }, + { $set: { nickname: JSON.stringify({ currentHash: 'new-hash' }) } }, + { new: false }, ); expect(result).toEqual({ accessToken, refreshToken }); }); - it('should use user.id fallback when user._id is absent and update DB', async () => { - const userWithoutId = { ...fakeUser, _id: undefined as unknown as ObjectId, id: 'only-id' }; - exec.mockResolvedValueOnce({ ...fakeUser, nickname: fakeHash }); + it('should use empty jti when decode returns null for new refreshToken in buildRotatedRecord', async () => { + const jsonRecord = JSON.stringify({ currentHash: fakeHash }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }) + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }); + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) // decode rawToken + .mockReturnValueOnce(null); // decode new refreshToken → null → jti='' spyBcryptCompare.mockResolvedValueOnce(true); - spyBcriptHashPassword.mockResolvedValueOnce('new-hashed-refresh'); + spyBcriptHashPassword.mockResolvedValueOnce('hash-empty-jti'); + + await service['refreshToken'](fakeUser, 'valid-token'); + + expect(spyBcriptHashPassword).toHaveBeenCalledWith(''); + }); + + it('should use user.id fallback when user._id is absent', async () => { + const userWithoutId = { ...fakeUser, _id: undefined as unknown as ObjectId, id: 'only-id' }; + const jsonRecord = JSON.stringify({ currentHash: fakeHash }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }) + .mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }); jest.spyOn(jwtService, 'decode') .mockReturnValueOnce({ jti: 'input-jti' }) .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); await service['refreshToken'](userWithoutId as unknown as User, 'valid-token'); - expect(model.updateOne).toHaveBeenCalledWith( - { _id: 'only-id' }, - { $set: { nickname: 'new-hashed-refresh' } }, + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { _id: 'only-id', nickname: jsonRecord }, + expect.anything(), + expect.anything(), ); }); - it('should handle rawToken without jti (decode returns object without jti)', async () => { - exec.mockResolvedValueOnce({ ...fakeUser, nickname: fakeHash }); - jest.spyOn(jwtService, 'decode').mockReturnValueOnce({}); + describe('rotate = false', () => { + beforeEach(() => { service['rotate'] = false; }); + afterEach(() => { service['rotate'] = true; }); - await expect(service['refreshToken'](fakeUser, 'token-without-jti')).rejects.toThrow( - new UnauthorizedException('Invalid refresh token'), - ); + it('should validate and return new pair without updating DB', async () => { + const jsonRecord = JSON.stringify({ currentHash: fakeHash }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'input-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + + const result = await service['refreshToken'](fakeUser, 'valid-token'); + + expect(model.findOneAndUpdate).not.toHaveBeenCalled(); + expect(model.updateOne).not.toHaveBeenCalled(); + expect(result).toEqual({ accessToken, refreshToken }); + }); + + it('should throw 401 on invalid token even when rotate=false', async () => { + const jsonRecord = JSON.stringify({ currentHash: fakeHash }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: jsonRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'bad-jti' }); + spyBcryptCompare.mockResolvedValueOnce(false); + + await expect(service['refreshToken'](fakeUser, 'bad-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + expect(model.findOneAndUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('reuseWindowMs > 0 (grace window)', () => { + const cachedTokens = { accessToken: 'cached-access', refreshToken: 'cached-refresh' }; + const previousHash = 'prev-hash'; + const rotatedRecently = Date.now() - 3000; + + beforeEach(() => { service['reuseWindowMs'] = 10000; }); + afterEach(() => { service['reuseWindowMs'] = 0; }); + + it('should return cached tokens when previous jti used within grace window', async () => { + const graceRecord = JSON.stringify({ + currentHash: 'current-hash', + previousHash, + rotatedAt: rotatedRecently, + cachedTokens, + }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: graceRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'old-jti' }); + spyBcryptCompare + .mockResolvedValueOnce(false) // vs currentHash + .mockResolvedValueOnce(true); // vs previousHash + + const result = await service['refreshToken'](fakeUser, 'old-token'); + + expect(result).toEqual(cachedTokens); + expect(model.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('should throw 401 when previous jti used but grace window expired', async () => { + const expiredRecord = JSON.stringify({ + currentHash: 'current-hash', + previousHash, + rotatedAt: Date.now() - 20000, + cachedTokens, + }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: expiredRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'old-jti' }); + spyBcryptCompare.mockResolvedValueOnce(false); + + await expect(service['refreshToken'](fakeUser, 'old-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should throw 401 when previous jti used within window but hash does not match', async () => { + const graceRecord = JSON.stringify({ + currentHash: 'current-hash', + previousHash, + rotatedAt: rotatedRecently, + cachedTokens, + }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: graceRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'wrong-jti' }); + spyBcryptCompare + .mockResolvedValueOnce(false) // vs currentHash + .mockResolvedValueOnce(false); // vs previousHash + + await expect(service['refreshToken'](fakeUser, 'bad-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should throw 401 when no previousHash in record', async () => { + const noGraceRecord = JSON.stringify({ currentHash: 'current-hash' }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: noGraceRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'old-jti' }); + spyBcryptCompare.mockResolvedValueOnce(false); + + await expect(service['refreshToken'](fakeUser, 'old-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should throw 401 when grace window matches but cachedTokens absent in record', async () => { + // Record has previousHash + rotatedAt but no cachedTokens (e.g. migrated record) + const noCacheRecord = JSON.stringify({ + currentHash: 'current-hash', + previousHash, + rotatedAt: rotatedRecently, + // no cachedTokens + }); + exec.mockResolvedValueOnce({ ...fakeUser, nickname: noCacheRecord }); + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'old-jti' }); + spyBcryptCompare + .mockResolvedValueOnce(false) // vs currentHash + .mockResolvedValueOnce(true); // vs previousHash (matches) + + await expect(service['refreshToken'](fakeUser, 'old-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should store previousHash + rotatedAt + cachedTokens in rotated record', async () => { + const simpleRecord = JSON.stringify({ currentHash: fakeHash }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: simpleRecord }) + .mockResolvedValueOnce({ ...fakeUser, nickname: simpleRecord }); + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); + + await service['refreshToken'](fakeUser, 'valid-token'); + + const casCall = model.findOneAndUpdate.mock.calls[0]; + const stored = JSON.parse(casCall[1].$set.nickname); + expect(stored.currentHash).toBe('new-hash'); + expect(stored.previousHash).toBe(fakeHash); + expect(stored.rotatedAt).toBeGreaterThan(0); + expect(stored.cachedTokens).toEqual({ accessToken, refreshToken }); + }); + }); + + describe('CAS race condition handling', () => { + const cachedTokens = { accessToken: 'cached-access', refreshToken: 'cached-refresh' }; + const winnerRotatedAt = Date.now() - 1000; + + beforeEach(() => { service['reuseWindowMs'] = 10000; }); + afterEach(() => { service['reuseWindowMs'] = 0; }); + + it('should return cached tokens from winner when CAS misses within grace window', async () => { + const storedRecord = JSON.stringify({ currentHash: fakeHash }); + const winnerRecord = JSON.stringify({ + currentHash: 'winner-hash', + previousHash: fakeHash, + rotatedAt: winnerRotatedAt, + cachedTokens, + }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: storedRecord }) // first findOne + .mockResolvedValueOnce(null) // CAS miss + .mockResolvedValueOnce({ ...fakeUser, nickname: winnerRecord }); // re-read + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare + .mockResolvedValueOnce(true) // vs storedRecord.currentHash (valid, proceed to rotate) + .mockResolvedValueOnce(true); // grace: winnerRecord.previousHash vs input-jti + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); + + const result = await service['refreshToken'](fakeUser, 'valid-token'); + + expect(result).toEqual(cachedTokens); + }); + + it('should throw 401 when CAS misses and grace window expired in winner record', async () => { + const storedRecord = JSON.stringify({ currentHash: fakeHash }); + const expiredWinner = JSON.stringify({ + currentHash: 'winner-hash', + previousHash: fakeHash, + rotatedAt: Date.now() - 20000, + cachedTokens, + }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: storedRecord }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...fakeUser, nickname: expiredWinner }); + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); + + await expect(service['refreshToken'](fakeUser, 'valid-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); + + it('should throw 401 when CAS misses and re-read returns null stored value', async () => { + const storedRecord = JSON.stringify({ currentHash: fakeHash }); + exec + .mockResolvedValueOnce({ ...fakeUser, nickname: storedRecord }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); // user gone + jest.spyOn(jwtService, 'decode') + .mockReturnValueOnce({ jti: 'input-jti' }) + .mockReturnValueOnce({ jti: 'new-jti' }); + spyBcryptCompare.mockResolvedValueOnce(true); + spyBcriptHashPassword.mockResolvedValueOnce('new-hash'); + + await expect(service['refreshToken'](fakeUser, 'valid-token')).rejects.toThrow( + new UnauthorizedException('Invalid refresh token'), + ); + }); }); }); }); @@ -429,7 +690,7 @@ describe('BaseAuthService', () => { expect(spyBcriptHashPassword).toHaveBeenCalledWith('fake-jti'); expect(model.updateOne).toHaveBeenCalledWith( { _id: fakeUser._id }, - { $set: { nickname: 'hashed-refresh' } }, + { $set: { nickname: JSON.stringify({ currentHash: 'hashed-refresh' }) } }, ); service['refreshTokenField'] = undefined; }); @@ -454,7 +715,24 @@ describe('BaseAuthService', () => { expect(model.updateOne).toHaveBeenCalledWith( { _id: 'only-id' }, - { $set: { nickname: 'hashed-refresh' } }, + { $set: { nickname: JSON.stringify({ currentHash: 'hashed-refresh' }) } }, + ); + service['refreshTokenField'] = undefined; + }); + + it('should store hash with empty jti when decode returns null for refreshToken', async () => { + jest.spyOn(service, 'buildInstance').mockReturnValueOnce(fakeUserInstance); + // decode returns null (defensive branch) + jest.spyOn(jwtService, 'decode').mockReturnValueOnce(null); + service['refreshTokenField'] = 'nickname' as keyof User; + spyBcriptHashPassword.mockResolvedValueOnce('hash-empty'); + + await service['login'](fakeUser); + + expect(spyBcriptHashPassword).toHaveBeenCalledWith(''); + expect(model.updateOne).toHaveBeenCalledWith( + { _id: fakeUser._id }, + { $set: { nickname: JSON.stringify({ currentHash: 'hash-empty' }) } }, ); service['refreshTokenField'] = undefined; }); diff --git a/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts b/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts index 9a6d25fa..6a8abcc8 100644 --- a/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts +++ b/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, Type, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { randomInt, randomUUID } from 'node:crypto'; -import { Model, UpdateQuery, UpdateWithAggregationPipeline } from 'mongoose'; +import { FilterQuery, Model, UpdateQuery, UpdateWithAggregationPipeline } from 'mongoose'; import { DynamicApiResetPasswordCallbackMethods, BeforeSaveCallback, AfterSaveCallback } from '../../../interfaces'; import { MongoDBDynamicApiLogger } from '../../../logger'; import { BaseEntity } from '../../../models'; @@ -10,6 +10,21 @@ import { DynamicApiModule } from '../../../dynamic-api.module'; import { OtpCode } from '../models/otp-code.model'; import { DynamicApiResetPasswordOptions, PasswordlessOptions } from '../interfaces'; +/** Internal record stored in `refreshTokenField` (JSON-encoded). */ +interface RefreshTokenRecord { + /** bcrypt hash of the current valid jti. */ + currentHash: string; + /** bcrypt hash of the previous jti — kept for grace-window reuse detection. */ + previousHash?: string; + /** Epoch ms when the last rotation occurred — used to enforce `reuseWindowMs`. */ + rotatedAt?: number; + /** Token pair cached at rotation time — returned on grace-window hits (idempotency). */ + cachedTokens?: { + accessToken: string; + refreshToken: string; + }; +} + export abstract class BaseAuthService extends BaseService { protected entity: Type; protected loginField = 'email' as keyof Entity; @@ -28,6 +43,21 @@ export abstract class BaseAuthService extends BaseSer /** refreshTokenOnUpdate */ protected refreshTokenOnUpdate = false; + /** + * When false, the stored hash is NOT rotated on each refresh call. + * Enables persistent-token mode: same token valid until logout/revocation. + * Default: true. + */ + protected rotate = true; + + /** + * Grace-window in milliseconds. + * Within this window after a rotation, the superseded (previous) jti is still accepted + * and returns the cached token pair, preventing false-positive 401s on concurrent bursts. + * Default: 0 (disabled). + */ + protected reuseWindowMs = 0; + private resetPasswordCallbackMethods: DynamicApiResetPasswordCallbackMethods | undefined; private readonly logger = new MongoDBDynamicApiLogger('AuthService'); @@ -84,10 +114,11 @@ export abstract class BaseAuthService extends BaseSer const decodedRefresh = this.jwtService.decode(refreshToken); const jti: string = decodedRefresh && typeof decodedRefresh !== 'string' ? decodedRefresh['jti'] : ''; const hashedRefreshToken = await this.bcryptService.hashPassword(jti); + const record: RefreshTokenRecord = { currentHash: hashedRefreshToken }; await this.model.updateOne( { _id: user._id || user.id }, // @ts-ignore - { $set: { [this.refreshTokenField]: hashedRefreshToken } }, + { $set: { [this.refreshTokenField]: JSON.stringify(record) } }, ).exec(); } @@ -297,45 +328,72 @@ export abstract class BaseAuthService extends BaseSer this.verifyArguments(user); if (this.refreshTokenField) { - const storedUser = await this.model.findOne({ _id: user._id || user.id }).lean().exec(); - const storedHash = storedUser?.[this.refreshTokenField] as string | undefined; + const userId = user._id || user.id; + const storedUser = await this.model.findOne({ _id: userId }).lean().exec(); + const storedRaw = storedUser?.[this.refreshTokenField] as string | undefined; const decodedRaw = rawToken ? this.jwtService.decode(rawToken) : undefined; - const jtiFromToken: string | undefined = decodedRaw && typeof decodedRaw !== 'string' ? decodedRaw['jti'] : undefined; + const incomingJti: string | undefined = + decodedRaw && typeof decodedRaw !== 'string' ? decodedRaw['jti'] : undefined; - if (!storedHash || !jtiFromToken) { + if (!storedRaw || !incomingJti) { throw new UnauthorizedException('Invalid refresh token'); } - const isValid = await this.bcryptService.comparePassword(jtiFromToken, storedHash); - if (!isValid) { + const record = this.parseRefreshTokenRecord(storedRaw); + const isCurrentValid = await this.bcryptService.comparePassword(incomingJti, record.currentHash); + + if (!isCurrentValid) { + // Current jti invalid — check grace window against previous jti. + const cached = await this.checkGraceWindow(incomingJti, record); + if (cached) { + this.logger.debug('Refresh token reused within grace window — returning cached pair', { userId }); + return cached; + } throw new UnauthorizedException('Invalid refresh token'); } - } - const fieldsToBuild = [ - '_id' as keyof Entity, - 'id' as keyof Entity, - this.loginField, - ...this.additionalRequestFields, - ]; - - const payload: object = { ...this.buildUserFields(user, fieldsToBuild) }; + // Current jti valid. + if (this.rotate === false) { + // Persistent-token mode: validate only, no rotation. + return this.buildTokenPair(user); + } - const accessToken = this.jwtService.sign(payload); - const refreshToken = this.buildRefreshToken(payload); + // Build new token pair and attempt atomic CAS rotation. + const newPair = await this.buildTokenPair(user); + const newRecord = await this.buildRotatedRecord(record, newPair); + const newRecordJson = JSON.stringify(newRecord); + + // findOneAndUpdate acts as compare-and-swap: only updates if the stored value + // still matches what we just read (prevents duplicate rotations under concurrency). + const casResult = await this.model.findOneAndUpdate( + // @ts-ignore — dynamic field key + { _id: userId, [this.refreshTokenField]: storedRaw } as FilterQuery, + // @ts-ignore — dynamic field key + { $set: { [this.refreshTokenField]: newRecordJson } }, + { new: false }, + ).lean().exec(); + + if (!casResult) { + // CAS missed — a concurrent rotation already happened. + // Re-read and check if the grace window of the winner covers this request. + this.logger.debug('CAS miss on refresh rotation — checking grace window of winning rotation', { userId }); + const rereadUser = await this.model.findOne({ _id: userId }).lean().exec(); + const rereadRaw = rereadUser?.[this.refreshTokenField] as string | undefined; + if (rereadRaw) { + const rereadRecord = this.parseRefreshTokenRecord(rereadRaw); + const cached = await this.checkGraceWindow(incomingJti, rereadRecord); + if (cached) { + this.logger.debug('CAS miss covered by grace window — returning cached pair', { userId }); + return cached; + } + } + throw new UnauthorizedException('Invalid refresh token'); + } - if (this.refreshTokenField && (user._id || user.id)) { - const decodedRefresh = this.jwtService.decode(refreshToken); - const jti: string = decodedRefresh && typeof decodedRefresh !== 'string' ? decodedRefresh['jti'] : ''; - const hashedRefreshToken = await this.bcryptService.hashPassword(jti); - await this.model.updateOne( - { _id: user._id || user.id }, - // @ts-ignore - { $set: { [this.refreshTokenField]: hashedRefreshToken } }, - ).exec(); + return newPair; } - return { accessToken, refreshToken }; + return this.buildTokenPair(user); } protected async logout(user: Entity) { @@ -431,6 +489,92 @@ export abstract class BaseAuthService extends BaseSer ); } + /** + * Builds an `{ accessToken, refreshToken }` pair from the user's payload fields. + * Pure: does NOT update the database. + */ + private async buildTokenPair(user: Entity): Promise<{ accessToken: string; refreshToken: string }> { + const fieldsToBuild = [ + '_id' as keyof Entity, + 'id' as keyof Entity, + this.loginField, + ...this.additionalRequestFields, + ]; + const payload: object = { ...this.buildUserFields(user, fieldsToBuild) }; + const accessToken = this.jwtService.sign(payload); + const refreshToken = this.buildRefreshToken(payload); + return { accessToken, refreshToken }; + } + + /** + * Parses the value stored in `refreshTokenField`. + * Supports both the legacy plain-bcrypt-hash format and the new JSON `RefreshTokenRecord` format. + */ + private parseRefreshTokenRecord(storedRaw: string): RefreshTokenRecord { + try { + const parsed: unknown = JSON.parse(storedRaw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'currentHash' in parsed && + typeof (parsed as RefreshTokenRecord).currentHash === 'string' + ) { + return parsed as RefreshTokenRecord; + } + } catch { + // Not JSON — fall through to legacy plain-hash treatment. + } + // Legacy format: the entire stored string is the bcrypt hash of the jti. + return { currentHash: storedRaw }; + } + + /** + * Checks whether `incomingJti` matches the `previousHash` of `record` and the rotation + * occurred within the configured `reuseWindowMs` grace window. + * Returns the cached token pair if the window is active, `null` otherwise. + */ + private async checkGraceWindow( + incomingJti: string, + record: RefreshTokenRecord, + ): Promise<{ accessToken: string; refreshToken: string } | null> { + if (this.reuseWindowMs <= 0 || !record.previousHash || !record.rotatedAt) { + return null; + } + if (Date.now() - record.rotatedAt > this.reuseWindowMs) { + return null; + } + const isPreviousValid = await this.bcryptService.comparePassword(incomingJti, record.previousHash); + if (!isPreviousValid) { + return null; + } + return record.cachedTokens ?? null; + } + + /** + * Builds the `RefreshTokenRecord` that will replace the current stored record after rotation. + * When `reuseWindowMs > 0`, the current hash is preserved as `previousHash` together with + * the cached new token pair so that grace-window hits can return an idempotent response. + */ + private async buildRotatedRecord( + currentRecord: RefreshTokenRecord, + newPair: { accessToken: string; refreshToken: string }, + ): Promise { + const decodedNew = this.jwtService.decode(newPair.refreshToken); + const newJti: string = + decodedNew && typeof decodedNew !== 'string' ? (decodedNew['jti'] as string) : ''; + const newHash = await this.bcryptService.hashPassword(newJti); + + const newRecord: RefreshTokenRecord = { currentHash: newHash }; + + if (this.reuseWindowMs > 0) { + newRecord.previousHash = currentRecord.currentHash; + newRecord.rotatedAt = Date.now(); + newRecord.cachedTokens = newPair; + } + + return newRecord; + } + private buildUserFields(user: Entity, fieldsToBuild: (keyof Entity)[]) { return this.buildInstance(fieldsToBuild.reduce( (acc, field) => ( diff --git a/libs/dynamic-api/test/for-root/auth-api-refresh-token.e2e-spec.ts b/libs/dynamic-api/test/for-root/auth-api-refresh-token.e2e-spec.ts index 3861114f..b2a3a563 100644 --- a/libs/dynamic-api/test/for-root/auth-api-refresh-token.e2e-spec.ts +++ b/libs/dynamic-api/test/for-root/auth-api-refresh-token.e2e-spec.ts @@ -189,4 +189,127 @@ describe('DynamicApiModule forRoot - POST /auth/refresh-token (e2e)', () => { expect(status).toBe(401); }); }); + + describe('with reuseWindowMs configured (grace window)', () => { + let app: INestApplication; + let refreshToken: string; + + beforeEach(async () => { + const User = createUserWithRefreshTokenEntity(); + app = await initModule({ + useAuth: { + userEntity: User, + jwt: { + secret: 'test-secret', + expiresIn: '15m', + refreshTokenExpiresIn: '7d', + }, + refreshToken: { + refreshTokenField: 'refreshTokenHash', + reuseWindowMs: 10000, // 10 s grace window + }, + }, + }); + + await server.post('/auth/register', { email: 'grace@test.co', password: 'test' }); + const { body } = await server.post('/auth/login', { email: 'grace@test.co', password: 'test' }); + refreshToken = body.refreshToken; + }); + + it('should return new tokens on first use of refresh token', async () => { + const headers = { Authorization: `Bearer ${refreshToken}` }; + const { body, status } = await server.post('/auth/refresh-token', {}, { headers }); + + expect(status).toBe(200); + expect(body).toEqual({ accessToken: expect.any(String), refreshToken: expect.any(String) }); + }); + + it('should accept the same refresh token again within the grace window (concurrent burst)', async () => { + const headers = { Authorization: `Bearer ${refreshToken}` }; + + // First refresh — rotates the token. + const { status: s1 } = await server.post('/auth/refresh-token', {}, { headers }); + expect(s1).toBe(200); + + // Second refresh with SAME token — within grace window → must NOT return 401. + const { status: s2, body: b2 } = await server.post('/auth/refresh-token', {}, { headers }); + expect(s2).toBe(200); + expect(b2).toHaveProperty('accessToken'); + expect(b2).toHaveProperty('refreshToken'); + }); + + it('should reject the same refresh token after grace window expires', async () => { + const User2 = createUserWithRefreshTokenEntity(); + await closeTestingApp(mongoose.connections); + DynamicApiModule.state['resetState'](); + app = await initModule({ + useAuth: { + userEntity: User2, + jwt: { secret: 'test-secret', expiresIn: '15m', refreshTokenExpiresIn: '7d' }, + refreshToken: { refreshTokenField: 'refreshTokenHash', reuseWindowMs: 1 }, + }, + }); + await server.post('/auth/register', { email: 'grace-expired@test.co', password: 'test' }); + const { body: loginBody } = await server.post('/auth/login', { + email: 'grace-expired@test.co', + password: 'test', + }); + const shortWindowToken = loginBody.refreshToken; + + const hdrs = { Authorization: `Bearer ${shortWindowToken}` }; + await server.post('/auth/refresh-token', {}, { headers: hdrs }); + await wait(50); + + const { status } = await server.post('/auth/refresh-token', {}, { headers: hdrs }); + expect(status).toBe(401); + }, 10000); + }); + + describe('with rotate=false (persistent token mode)', () => { + let app: INestApplication; + let refreshToken: string; + + beforeEach(async () => { + const User = createUserWithRefreshTokenEntity(); + app = await initModule({ + useAuth: { + userEntity: User, + jwt: { + secret: 'test-secret', + expiresIn: '15m', + refreshTokenExpiresIn: '7d', + }, + refreshToken: { + refreshTokenField: 'refreshTokenHash', + rotate: false, + }, + }, + }); + + await server.post('/auth/register', { email: 'norotate@test.co', password: 'test' }); + const { body } = await server.post('/auth/login', { email: 'norotate@test.co', password: 'test' }); + refreshToken = body.refreshToken; + }); + + it('should accept the same refresh token on multiple calls', async () => { + const headers = { Authorization: `Bearer ${refreshToken}` }; + + const { status: s1 } = await server.post('/auth/refresh-token', {}, { headers }); + expect(s1).toBe(200); + + const { status: s2 } = await server.post('/auth/refresh-token', {}, { headers }); + expect(s2).toBe(200); + + const { status: s3 } = await server.post('/auth/refresh-token', {}, { headers }); + expect(s3).toBe(200); + }); + + it('should reject the refresh token after logout (revocation still works)', async () => { + const headers = { Authorization: `Bearer ${refreshToken}` }; + await server.post('/auth/logout', {}, { headers }); + + const { status } = await server.post('/auth/refresh-token', {}, { headers }); + expect(status).toBe(401); + }); + }); }); From ce9ec9d94cc5f49f9cd4abbf2a09dda3a27b388f Mon Sep 17 00:00:00 2001 From: "Mickael N." Date: Sun, 31 May 2026 18:35:54 +0200 Subject: [PATCH 2/3] refactor(auth): reduce cognitive complexity of refreshToken from 24 to ~6 Extract 4 private helpers to flatten the refreshToken method: - extractIncomingJti: decodes rawToken and returns the jti claim - handleInvalidCurrentJti: grace-window check + throw on invalid current jti - rotateCasOrThrow: atomic CAS rotation, delegates CAS miss to handleCasMiss - handleCasMiss: re-read + grace-window check after concurrent rotation All existing tests pass (97). New unit tests added for each helper. Coverage: 100% stmt/lines/fn on base-auth.service.ts. --- .../auth/services/base-auth.service.spec.ts | 91 +++++++++ .../auth/services/base-auth.service.ts | 173 ++++++++++++------ 2 files changed, 204 insertions(+), 60 deletions(-) diff --git a/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts b/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts index c458588b..59b2739a 100644 --- a/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts +++ b/libs/dynamic-api/src/modules/auth/services/base-auth.service.spec.ts @@ -563,6 +563,97 @@ describe('BaseAuthService', () => { }); }); + describe('extractIncomingJti (private)', () => { + it('should return undefined when rawToken is absent', () => { + expect(service['extractIncomingJti'](undefined)).toBeUndefined(); + }); + + it('should return undefined when decode returns null', () => { + jest.spyOn(jwtService, 'decode').mockReturnValueOnce(null); + expect(service['extractIncomingJti']('some-token')).toBeUndefined(); + }); + + it('should return undefined when decode returns a plain string', () => { + jest.spyOn(jwtService, 'decode').mockReturnValueOnce('plain-string'); + expect(service['extractIncomingJti']('some-token')).toBeUndefined(); + }); + + it('should return undefined when decoded object has no jti', () => { + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ sub: '123' }); + expect(service['extractIncomingJti']('some-token')).toBeUndefined(); + }); + + it('should return jti when decoded object has jti', () => { + jest.spyOn(jwtService, 'decode').mockReturnValueOnce({ jti: 'abc-jti' }); + expect(service['extractIncomingJti']('some-token')).toBe('abc-jti'); + }); + }); + + describe('handleInvalidCurrentJti (private)', () => { + const userId = 'user-123'; + + afterEach(() => jest.restoreAllMocks()); + + it('should return cached tokens when grace window is valid', async () => { + const cachedTokens = { accessToken: 'a', refreshToken: 'r' }; + jest.spyOn(service as any, 'checkGraceWindow').mockResolvedValueOnce(cachedTokens); + + const result = await service['handleInvalidCurrentJti']('jti', { currentHash: 'h' }, userId); + + expect(result).toEqual(cachedTokens); + }); + + it('should throw UnauthorizedException when grace window returns null', async () => { + jest.spyOn(service as any, 'checkGraceWindow').mockResolvedValueOnce(null); + + await expect(service['handleInvalidCurrentJti']('jti', { currentHash: 'h' }, userId)) + .rejects.toThrow(new UnauthorizedException('Invalid refresh token')); + }); + }); + + describe('handleCasMiss (private)', () => { + const userId = 'user-456'; + + beforeEach(() => { + service['refreshTokenField'] = 'nickname' as keyof User; + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should return cached tokens when re-read record is within grace window', async () => { + const cachedTokens = { accessToken: 'ca', refreshToken: 'cr' }; + const rereadRecord = { currentHash: 'h2', previousHash: 'prev', rotatedAt: Date.now() - 500, cachedTokens }; + exec.mockResolvedValueOnce({ ...fakeUser, nickname: JSON.stringify(rereadRecord) }); + jest.spyOn(service as any, 'checkGraceWindow').mockResolvedValueOnce(cachedTokens); + + const result = await service['handleCasMiss'](userId, 'jti'); + + expect(result).toEqual(cachedTokens); + }); + + it('should throw when re-read record grace window returns null', async () => { + exec.mockResolvedValueOnce({ ...fakeUser, nickname: JSON.stringify({ currentHash: 'h2' }) }); + jest.spyOn(service as any, 'checkGraceWindow').mockResolvedValueOnce(null); + + await expect(service['handleCasMiss'](userId, 'jti')) + .rejects.toThrow(new UnauthorizedException('Invalid refresh token')); + }); + + it('should throw when re-read user has no stored value', async () => { + exec.mockResolvedValueOnce({ ...fakeUser, nickname: null }); + + await expect(service['handleCasMiss'](userId, 'jti')) + .rejects.toThrow(new UnauthorizedException('Invalid refresh token')); + }); + + it('should throw when re-read user not found', async () => { + exec.mockResolvedValueOnce(null); + + await expect(service['handleCasMiss'](userId, 'jti')) + .rejects.toThrow(new UnauthorizedException('Invalid refresh token')); + }); + }); + describe('logout', () => { let spyLoggerWarn: jest.SpyInstance; diff --git a/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts b/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts index 6a8abcc8..837b8615 100644 --- a/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts +++ b/libs/dynamic-api/src/modules/auth/services/base-auth.service.ts @@ -327,75 +327,128 @@ export abstract class BaseAuthService extends BaseSer this.logger.debug('Refreshing token', { userId: user?.id }); this.verifyArguments(user); - if (this.refreshTokenField) { - const userId = user._id || user.id; - const storedUser = await this.model.findOne({ _id: userId }).lean().exec(); - const storedRaw = storedUser?.[this.refreshTokenField] as string | undefined; - const decodedRaw = rawToken ? this.jwtService.decode(rawToken) : undefined; - const incomingJti: string | undefined = - decodedRaw && typeof decodedRaw !== 'string' ? decodedRaw['jti'] : undefined; - - if (!storedRaw || !incomingJti) { - throw new UnauthorizedException('Invalid refresh token'); - } + if (!this.refreshTokenField) { + return this.buildTokenPair(user); + } - const record = this.parseRefreshTokenRecord(storedRaw); - const isCurrentValid = await this.bcryptService.comparePassword(incomingJti, record.currentHash); + const userId = user._id || user.id; + const storedUser = await this.model.findOne({ _id: userId }).lean().exec(); + const storedRaw = storedUser?.[this.refreshTokenField] as string | undefined; + const incomingJti = this.extractIncomingJti(rawToken); - if (!isCurrentValid) { - // Current jti invalid — check grace window against previous jti. - const cached = await this.checkGraceWindow(incomingJti, record); - if (cached) { - this.logger.debug('Refresh token reused within grace window — returning cached pair', { userId }); - return cached; - } - throw new UnauthorizedException('Invalid refresh token'); - } + if (!storedRaw || !incomingJti) { + throw new UnauthorizedException('Invalid refresh token'); + } - // Current jti valid. - if (this.rotate === false) { - // Persistent-token mode: validate only, no rotation. - return this.buildTokenPair(user); - } + const record = this.parseRefreshTokenRecord(storedRaw); + const isCurrentValid = await this.bcryptService.comparePassword(incomingJti, record.currentHash); - // Build new token pair and attempt atomic CAS rotation. - const newPair = await this.buildTokenPair(user); - const newRecord = await this.buildRotatedRecord(record, newPair); - const newRecordJson = JSON.stringify(newRecord); - - // findOneAndUpdate acts as compare-and-swap: only updates if the stored value - // still matches what we just read (prevents duplicate rotations under concurrency). - const casResult = await this.model.findOneAndUpdate( - // @ts-ignore — dynamic field key - { _id: userId, [this.refreshTokenField]: storedRaw } as FilterQuery, - // @ts-ignore — dynamic field key - { $set: { [this.refreshTokenField]: newRecordJson } }, - { new: false }, - ).lean().exec(); - - if (!casResult) { - // CAS missed — a concurrent rotation already happened. - // Re-read and check if the grace window of the winner covers this request. - this.logger.debug('CAS miss on refresh rotation — checking grace window of winning rotation', { userId }); - const rereadUser = await this.model.findOne({ _id: userId }).lean().exec(); - const rereadRaw = rereadUser?.[this.refreshTokenField] as string | undefined; - if (rereadRaw) { - const rereadRecord = this.parseRefreshTokenRecord(rereadRaw); - const cached = await this.checkGraceWindow(incomingJti, rereadRecord); - if (cached) { - this.logger.debug('CAS miss covered by grace window — returning cached pair', { userId }); - return cached; - } - } - throw new UnauthorizedException('Invalid refresh token'); - } + if (!isCurrentValid) { + return this.handleInvalidCurrentJti(incomingJti, record, userId); + } + + if (this.rotate === false) { + // Persistent-token mode: validate only, no rotation. + return this.buildTokenPair(user); + } + + return this.rotateCasOrThrow(userId, storedRaw, incomingJti, user, record); + } + + /** + * Extracts the `jti` claim from a raw JWT string. + * Returns `undefined` when `rawToken` is absent, not decodable, or carries no `jti`. + */ + private extractIncomingJti(rawToken?: string): string | undefined { + if (!rawToken) { + return undefined; + } + const decoded = this.jwtService.decode(rawToken); + if (!decoded || typeof decoded === 'string') { + return undefined; + } + return decoded['jti'] as string | undefined; + } - return newPair; + /** + * Called when the incoming jti does NOT match `record.currentHash`. + * Checks the grace window against `record.previousHash`; returns the cached pair + * if still valid, otherwise throws `UnauthorizedException`. + */ + private async handleInvalidCurrentJti( + incomingJti: string, + record: RefreshTokenRecord, + userId: Entity['_id'] | string, + ): Promise<{ accessToken: string; refreshToken: string }> { + const cached = await this.checkGraceWindow(incomingJti, record); + if (cached) { + this.logger.debug('Refresh token reused within grace window — returning cached pair', { userId }); + return cached; + } + throw new UnauthorizedException('Invalid refresh token'); + } + + /** + * Builds a new token pair and attempts an atomic compare-and-swap (CAS) rotation. + * On a CAS miss (concurrent rotation), delegates to `handleCasMiss`. + */ + private async rotateCasOrThrow( + userId: Entity['_id'] | string, + storedRaw: string, + incomingJti: string, + user: Entity, + record: RefreshTokenRecord, + ): Promise<{ accessToken: string; refreshToken: string }> { + // Build new token pair and attempt atomic CAS rotation. + const newPair = await this.buildTokenPair(user); + const newRecord = await this.buildRotatedRecord(record, newPair); + const newRecordJson = JSON.stringify(newRecord); + + // findOneAndUpdate acts as compare-and-swap: only updates if the stored value + // still matches what we just read (prevents duplicate rotations under concurrency). + const casResult = await this.model.findOneAndUpdate( + // @ts-ignore — dynamic field key + { _id: userId, [this.refreshTokenField]: storedRaw } as FilterQuery, + // @ts-ignore — dynamic field key + { $set: { [this.refreshTokenField]: newRecordJson } }, + { new: false }, + ).lean().exec(); + + if (!casResult) { + return this.handleCasMiss(userId, incomingJti); + } + + return newPair; + } + + /** + * Called when the CAS rotation missed (concurrent winner already rotated). + * Re-reads the stored record and checks whether the winner's grace window covers + * the incoming jti; returns the cached pair if so, otherwise throws. + */ + private async handleCasMiss( + userId: Entity['_id'] | string, + incomingJti: string, + ): Promise<{ accessToken: string; refreshToken: string }> { + // CAS missed — a concurrent rotation already happened. + // Re-read and check if the grace window of the winner covers this request. + this.logger.debug('CAS miss on refresh rotation — checking grace window of winning rotation', { userId }); + const rereadUser = await this.model.findOne({ _id: userId }).lean().exec(); + const rereadRaw = rereadUser?.[this.refreshTokenField] as string | undefined; + + if (rereadRaw) { + const rereadRecord = this.parseRefreshTokenRecord(rereadRaw); + const cached = await this.checkGraceWindow(incomingJti, rereadRecord); + if (cached) { + this.logger.debug('CAS miss covered by grace window — returning cached pair', { userId }); + return cached; + } } - return this.buildTokenPair(user); + throw new UnauthorizedException('Invalid refresh token'); } + protected async logout(user: Entity) { this.logger.debug('Logging out user', { userId: user.id }); this.verifyArguments(user); From b07092a519539977ff375d5b4da21815c196ba9e Mon Sep 17 00:00:00 2001 From: Mickael Date: Sun, 31 May 2026 16:47:24 +0000 Subject: [PATCH 3/3] chore(release): 4.15.0 --- CHANGELOG.md | 6 ++++++ libs/dynamic-api/src/version.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d30d7d93..063f2dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ Changelog +## [4.15.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.14.1...v4.15.0) (2026-05-31) + +### auth + +* **auth:** add grace window, rotate=false, and atomic CAS rotation for refresh tokens ([e62b77d](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/e62b77d3539abb3c7c50580a49888b5363bca310)) + ## [4.14.1](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.14.0...v4.14.1) (2026-05-27) ## [4.14.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.13.0...v4.14.0) (2026-05-26) diff --git a/libs/dynamic-api/src/version.json b/libs/dynamic-api/src/version.json index 46a1c035..cfbc037e 100644 --- a/libs/dynamic-api/src/version.json +++ b/libs/dynamic-api/src/version.json @@ -1,3 +1,3 @@ { - "version": "4.14.1" + "version": "4.15.0" } diff --git a/package-lock.json b/package-lock.json index 93c1ea08..855b3e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-dynamic-api", - "version": "4.14.1", + "version": "4.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-dynamic-api", - "version": "4.14.1", + "version": "4.15.0", "license": "MIT", "dependencies": { "@nestjs/cache-manager": "^3.0.1", diff --git a/package.json b/package.json index 5549dff5..205d2078 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mongodb-dynamic-api", - "version": "4.14.1", + "version": "4.15.0", "description": "Auto generated CRUD API for MongoDB using NestJS", "readmeFilename": "README.md", "main": "index.js",