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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
73 changes: 73 additions & 0 deletions README/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
},
})
Expand All @@ -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.

---

Expand Down
2 changes: 2 additions & 0 deletions libs/dynamic-api/src/modules/auth/auth.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ function createAuthServiceProvider<Entity extends BaseEntity>(
protected passwordField = passwordField;
protected refreshTokenField = refreshToken?.refreshTokenField;
protected refreshTokenOnUpdate = DynamicApiModule.state.get<boolean>('refreshTokenOnUpdate') ?? false;
protected rotate = refreshToken?.rotate ?? true;
protected reuseWindowMs = refreshToken?.reuseWindowMs ?? 0;

protected beforeRegisterCallback = register?.beforeSaveCallback;
protected registerCallback = register?.callback;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,25 @@ type DynamicApiRefreshTokenOptions<Entity extends BaseEntity = any> = {
* 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<Entity extends BaseEntity = any> = {
Expand Down
Loading