@dotpress/auth 1.3.1 → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,429 +1,455 @@
1
- # Dotpress Auth
1
+ # @dotpress/auth
2
2
 
3
- Authentication plugin for [dotpress](https://www.npmjs.com/package/dotpress) library using JWT tokens.
4
- You can use Prisma or any other ORM and easily connect it using the plugin DataProvider interface.
3
+ JWT authentication plugin for [Dotpress](https://dotpress.dev): login, token rotation, cookie sessions, and typed `ctx.user`. Works with any ORM through a small data provider, and on any adapter.
5
4
 
5
+ > 🚧 Dotpress V2 is under active development and not published yet.
6
6
 
7
- [Full documentation here](https://dotpress.dev)
8
-
9
- ---
10
-
11
- ## Quick Start
12
-
13
- ### 1. Install
14
-
15
- ```bash
16
- npm install @dotpress/auth cookie-parser
17
- npm install --save-dev @types/cookie-parser
7
+ ```sh
8
+ pnpm add @dotpress/auth @dotpress/core zod
18
9
  ```
19
10
 
20
- **Note:** `cookie-parser` is required as a peer dependency if you want to use cookie-based authentication.
21
-
22
- ### 2. Register your plugin
11
+ ## Quick start
23
12
 
24
13
  ```ts
25
- // index.ts
26
- import { createApp, defineRoute } from 'dotpress'
27
- import { configurePlugin } from '@dotpress/auth'
14
+ import { createApp, defineRoute } from '@dotpress/core'
15
+ import { expressAdapter } from '@dotpress/adapter-express'
16
+ import { authPlugin, createAuth, requireAuth } from '@dotpress/auth'
17
+
18
+ declare module '@dotpress/auth' {
19
+ interface AuthUser {
20
+ id: string
21
+ email: string
22
+ roles: string[]
23
+ }
24
+ }
28
25
 
29
- const authPlugin = configurePlugin({
30
- // See configuration object below
26
+ const auth = createAuth({
27
+ keys: {
28
+ secretKey: process.env.JWT_PRIVATE_KEY!, // PKCS#8 PEM ("BEGIN PRIVATE KEY")
29
+ publicKey: process.env.JWT_PUBLIC_KEY!, // SPKI PEM ("BEGIN PUBLIC KEY")
30
+ },
31
+ issuer: 'https://api.example.com', // required, checked on every request
32
+ dataProvider, // see below
31
33
  })
32
34
 
33
- const app = await createApp({
34
- plugins: [authPlugin]
35
+ defineRoute({
36
+ path: '/profile',
37
+ middlewares: [requireAuth],
38
+ handler: ({ user }) => user, // AuthUser
35
39
  })
36
40
 
37
- app.listen(3000, () => {
38
- console.log('Server listening on port 3000')
39
- })
41
+ const app = await createApp({ adapter: expressAdapter(), plugins: [authPlugin(auth)] })
42
+ await app.listen(3000)
40
43
  ```
41
44
 
42
- ### 3. Usage
45
+ The plugin reads the access token of every request: the `Authorization: Bearer` header, then the access token cookie when enabled. When it is valid, `ctx.user` and `ctx.auth` are set; otherwise the request stays anonymous.
46
+
47
+ The request body is never read, so an app route can receive an `accessToken` field of its own (a third-party token, for example) without side effects. Only `POST /auth/refresh` reads tokens from the body (see the table below).
43
48
 
44
- The plugin will check and parse bearer token from Authorization header and will register the following routes (full API below)
49
+ ## Routes
45
50
 
46
- - `POST /auth/login`
47
- - `POST /auth/refresh`
48
- - `POST /auth/restore`
49
- - `GET /auth/me`
50
- - `POST /auth/logout`
51
+ | Route | Description |
52
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `POST /auth/login` | `{ username, password, clientId?, mode? }` → token pair. `mode: 'cookie'` sets the cookies instead (see below). |
54
+ | `POST /auth/refresh` | Rotates a token pair, sent in the body or as cookies. |
55
+ | `GET /auth/me` | Authenticated user. |
56
+ | `POST /auth/restore` | Restores a cookie session (requires `cookies.accessTokenInCookie`). |
57
+ | `POST /auth/logout` | `{ refreshToken?, scope? }` → revokes the current session (`scope: 'all'`: every session of the user) and clears the cookies. See [Logout](#logout). |
51
58
 
52
- ---
59
+ In cookie mode, tokens set as cookies are never returned in the response body, so that the JavaScript of the page cannot read them:
53
60
 
54
- ## Configuration object properties
61
+ | Configuration | Cookies | Body |
62
+ | ------------------------------------------------------- | --------------------------- | ----------------------------------------------------- |
63
+ | Body mode | — | `accessToken`, `refreshToken` |
64
+ | `cookies: { enabled: true }` | refresh token | `accessToken` (send it in the `Authorization` header) |
65
+ | `cookies: { enabled: true, accessTokenInCookie: true }` | refresh token, access token | no token |
55
66
 
56
- - **keys.publicKey** (string, required): public RSA key to validate JWT tokens
57
- - **keys.secretKey** (string, required): private RSA key to generate JWT tokens
58
- - **dataProvider** (DataProvider, required): set of functions to store and retrieve data (see below)
59
- - **accessTokenTTL** (optional, default: 3600): Time-to-live, in seconds, for access tokens
60
- - **refreshTokenTTLInDays** (optional, default: 14): Time-to-live, in days, for refresh tokens
61
- - **issuer** (string, optional but recommended): Domain of your app or auth service
62
- - **cookies** (CookieSettings, optional): Cookie configuration for web authentication
63
- - **onLoginFailed** (optional): Event handler triggered on failed logins
64
- - **onLoginSuccess** (optional): Event handler triggered on success logins
67
+ Every route still returns `userId`, `expiresIn` and `expiresAt` (access token expiration), plus `sessionExpiresAt` when the session has an absolute timeout (see [Sessions](#sessions)).
65
68
 
66
- ### Cookie Settings
69
+ ### Cookie names and paths
67
70
 
68
- The `cookies` object supports the following properties:
71
+ | Cookie | Default name | Path | Lifetime |
72
+ | ------------- | --------------------------------- | --------------------------------- | ---------------------- |
73
+ | Access token | `__Host-dotpress_access_token` | `cookies.path` (`/`) | refresh token lifetime |
74
+ | Refresh token | `__Secure-dotpress_refresh_token` | path of the auth routes (`/auth`) | refresh token lifetime |
75
+
76
+ The refresh token cookie is only sent to the auth routes. `authPlugin()` sets its path to the browser path of its routes: the plugin `prefix`, after the `basePath` or `publicBasePath` of the app (see the `@dotpress/core` README):
69
77
 
70
78
  ```ts
71
- type CookieSettings = {
72
- enabled?: boolean // Enable cookie mode (default: false)
73
- httpOnly?: boolean // HttpOnly flag (default: true)
74
- secure?: boolean // Secure flag (default: true in production)
75
- sameSite?: 'strict' | 'lax' | 'none' // SameSite policy (default: 'lax')
76
- domain?: string // Cookie domain for cross-subdomain auth
77
- path?: string // Cookie path (default: '/')
78
- accessTokenName?: string // Access token cookie name (default: 'dotpress_access_token')
79
- refreshTokenName?: string // Refresh token cookie name (default: 'dotpress_refresh_token')
80
- accessTokenInCookie?: boolean // Store access token in cookie (default: false)
81
- }
79
+ await createApp({ adapter, basePath: '/my-app/api', plugins: [authPlugin(auth)] })
80
+ // Refresh token cookie path: /my-app/api/auth
82
81
  ```
83
82
 
84
- **Example configuration for cross-subdomain authentication:**
83
+ `cookies.refreshTokenPath` forces another path. With a wrong path, the browser does not send the cookie, and refreshes fail with `401 NO_TOKENS_PROVIDED`. An auth service serves a single path: create one per app if several apps serve the auth routes under different paths. Without the plugin, the default path is `/auth`.
84
+
85
+ The default names get the strongest prefix their attributes allow, like Auth.js: `__Host-` (secure, without `domain`, path `/`) prevents a subdomain or an insecure page from overwriting the cookie; `__Secure-` (secure) prevents an insecure page from overwriting it. Without `secure`, the names have no prefix. Custom names are kept as is, and checked at startup: a `__Host-` or `__Secure-` name without the required attributes would be silently rejected by browsers.
86
+
87
+ The access token cookie lives as long as the refresh token, and not `accessTokenTTL`: refreshing requires the access token, even expired.
88
+
89
+ ### Cross-site request forgery
90
+
91
+ The browser attaches cookies to every request, including the ones forged by another site. In cookie mode, the plugin checks the origin of the requests authenticated by a cookie, with the headers set by the browser (`Sec-Fetch-Site`, then `Origin` for older browsers), like the OWASP recommendations and Go's `http.CrossOriginProtection`. Clients need no CSRF token.
92
+
93
+ - Requests from the API origin are accepted, as well as requests without these headers (non-browser clients).
94
+ - Requests from another origin, including another subdomain of the same site, are accepted only from trusted origins. By default, these are the origins allowed by the [`cors` option](../core/README.md#cors) of the app when it allows credentials, which a front-end on another origin needs anyway to use the cookies:
95
+
96
+ ```ts
97
+ await createApp({
98
+ adapter,
99
+ cors: { origin: ['https://app.example.com'], credentials: true },
100
+ plugins: [authPlugin(auth)],
101
+ })
102
+ ```
103
+
104
+ `cookies.trustedOrigins` replaces them (`[]` trusts no other origin):
105
+
106
+ ```ts
107
+ cookies: { enabled: true, accessTokenInCookie: true, trustedOrigins: ['https://app.example.com'] }
108
+ ```
109
+
110
+ - Otherwise, the access token cookie is ignored (the request is anonymous, and a warning is logged), and `/auth/refresh` and `/auth/restore` answer `403 CROSS_ORIGIN_REQUEST`.
111
+ - `GET`, `HEAD` and `OPTIONS` requests are not checked: they must not change data.
112
+ - Requests authenticated by the `Authorization` header are not checked: another site cannot set it.
113
+ - `auth.isCookieAuthenticationAllowed(req)` applies the same check, for routes of the app that read the cookies themselves.
114
+
115
+ For older browsers without `Sec-Fetch-Site`, the `Origin` is compared with the `Host` header: behind a proxy, forward the original `Host`, or list the origin in `trustedOrigins`.
116
+
117
+ The refresh token cookie is `SameSite=Strict`: it never accompanies a navigation from another site, while the requests of the app keep sending it.
118
+
119
+ `authPlugin(auth, { prefix: '/api/auth' })` changes the prefix; `{ routes: false }` keeps only the authentication middleware; `{ jwks: true }` publishes the public keys (see [Keys and rotation](#keys-and-rotation)).
120
+
121
+ ## Guards
85
122
 
86
123
  ```ts
87
- const authPlugin = configurePlugin({
88
- keys: { /* ... */ },
89
- dataProvider: { /* ... */ },
90
- cookies: {
91
- enabled: true,
92
- domain: '.myapp.com', // Works for app.myapp.com, www.myapp.com, etc.
93
- secure: true,
94
- sameSite: 'lax',
95
- }
124
+ import { requireAuth, requireRole } from '@dotpress/auth'
125
+
126
+ defineRoute({ path: '/me', middlewares: [requireAuth], handler: ({ user }) => user })
127
+
128
+ createRouteGroup('/admin', [requireRole('admin')]).defineRoute({
129
+ path: '/stats',
130
+ handler: ({ user, auth }) => ({ admin: user.id, roles: auth.roles }),
96
131
  })
97
132
  ```
98
133
 
99
- ### DataProvider type
134
+ `requireAuth` answers `401` with a `WWW-Authenticate: Bearer` header (RFC 6750), plus `error="invalid_token"` when the `Authorization` header carried an expired, revoked or invalid token; every `401` of the app gets this header, unless it already has one. `requireRole()` answers `401` without user and `403` without one of the roles; `requireClient()` answers `401` without user and `403` when the token was issued for another client (see [Clients](#clients)). In the handlers of guarded routes, `ctx.user` and `ctx.auth` are typed as defined. Roles come from the user `roles` array (or `role` string), or from the `getUserRoles` setting.
135
+
136
+ The guards declare the `bearerAuth` security scheme (`AUTH_SECURITY_SCHEMES`, exposed by the plugin as `securitySchemes`): documentation generators such as `@dotpress/openapi` mark the routes using them as secured, without `meta.security`.
137
+
138
+ ## Settings
139
+
140
+ | Setting | Default | Description |
141
+ | ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
142
+ | `keys` | — | RSA key pair: `secretKey` (PKCS#8 PEM) and `publicKey` (SPKI PEM), plus optional `kid` and `additionalPublicKeys`. Tokens are signed with RS256. See [Keys and rotation](#keys-and-rotation). |
143
+ | `dataProvider` | — | Storage of users and refresh tokens. |
144
+ | `accessTokenTTL` | `900` | Access token lifetime, in seconds (15 minutes). See [Revocation](#revocation). |
145
+ | `session` | `{ idleTimeout: 14 days }` | Session lifetimes, in seconds: `idleTimeout` (inactivity) and `absoluteTimeout` (none by default). See [Sessions](#sessions). |
146
+ | `issuer` | — | **Required.** `iss` claim, checked on every request: for example the URL of the API. Use one value per environment and application, especially when they share a key pair. |
147
+ | `audience` | `issuer` | `aud` claim (the API that accepts the tokens), checked on every request. |
148
+ | `clients` | — | `{ allowed, required?, isUserRoleAllowed?(role, clientId), sessions? }`: client applications (see [Clients](#clients)). |
149
+ | `cookies` | disabled | Cookie mode: `enabled`, `httpOnly` (`true`), `secure` (`true`), `sameSite` (`lax`), `refreshTokenSameSite` (`strict`, or `none` when `sameSite` is `none`), `trustedOrigins` (origins allowed by `cors` with credentials, else `[]`, see [Cross-site request forgery](#cross-site-request-forgery)), `domain`, `path` (`/`, access token cookie), `refreshTokenPath` (path of the auth routes), `accessTokenName`, `refreshTokenName` (see [Cookie names and paths](#cookie-names-and-paths)), `accessTokenInCookie` (`false`). |
150
+ | `onLoginSuccess` / `onLoginFailed` | — | Login events (user, `clientId`, IP address). |
151
+ | `loginThrottle` | enabled, in memory | Brute force protection: `{ store?, limits? }`, or `false`. See [Brute force protection](#brute-force-protection). |
152
+ | `beforeLogin` | — | Called before checking the password: return an error to reject the attempt. |
153
+ | `passwordHashRounds` | `12` | bcrypt cost of `hashPassword()` and of the dummy hash used for unknown users. See [Passwords](#passwords). |
154
+ | `usernameClaim` | `false` | Writes the `username` claim in the access tokens (personal data, often an email address). |
155
+ | `getUserRoles` | `user.roles` / `user.role` | Roles checked by `requireRole()`. |
156
+ | `refreshTokenReuseInterval` | `10` | Seconds after its first use during which a refresh token may be presented again and get a new token pair in the same session (concurrent refreshes, retries). `0` disables it. |
157
+ | `onRefreshTokenReuse` | — | Called with `{ userId, familyId, jwtId }` when a session is revoked because a used refresh token was reused. |
158
+
159
+ ## Keys and rotation
160
+
161
+ To generate a key pair:
162
+
163
+ ```sh
164
+ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem # PKCS#8
165
+ openssl pkey -in private.pem -pubout -out public.pem # SPKI
166
+ ```
100
167
 
101
- ```ts
102
- type DataProvider = {
103
- storeRefreshToken: (payload: StoreRefreshTokenDto) => Promise<void>
104
- findRefreshToken: (tokenHash: string) => Promise<RefreshTokenData | undefined>
105
- markRefreshTokenAsUsed: (tokenHash: string) => Promise<void>
106
- revokeTokens: (userId: string) => Promise<void>
107
- findUserById: <TUser = Record<string, unknown>>(
108
- userId: string
109
- ) => Promise<TUser | undefined>
110
- findUserIdentifiers: (
111
- username: string
112
- ) => Promise<{ userId: string; passwordHash: string } | undefined>
113
- }
168
+ To convert PKCS#1 keys (`BEGIN RSA PRIVATE KEY`):
114
169
 
170
+ ```sh
171
+ openssl pkcs8 -topk8 -nocrypt -in private.pem -out private.pkcs8.pem
172
+ openssl rsa -RSAPublicKey_in -in public.pem -pubout -out public.spki.pem # BEGIN RSA PUBLIC KEY only
173
+ ```
115
174
 
116
- type StoreRefreshTokenDto = {
117
- token: string
118
- tokenHash: string
119
- userId: string
120
- jwtId: string
121
- expiresAt: Date
122
- }
175
+ `createApp()` fails when `publicKey` does not match `secretKey`.
176
+
177
+ Tokens carry a `kid` header: the RFC 7638 thumbprint of the public key, or `keys.kid`. It designates the verification key, among the configured keys only; tokens without `kid` (issued by V1) are tried with every key.
178
+
179
+ To rotate keys without logging users out, accept other public keys with `additionalPublicKeys`:
123
180
 
124
- type RefreshTokenData = {
125
- userId: string
126
- jwtId: string
127
- expiresAt: Date
128
- isUsed: boolean
129
- revokedAt?: Date | null
181
+ | Step | Configuration |
182
+ | --------------------------------------------- | --------------------------------------------------------- |
183
+ | 1. Announce the new key (rolling deployments) | old pair active, new public key in `additionalPublicKeys` |
184
+ | 2. Switch | new pair active, old public key in `additionalPublicKeys` |
185
+ | 3. After `session.idleTimeout` | remove the old public key |
186
+
187
+ ```ts
188
+ keys: {
189
+ secretKey: process.env.JWT_PRIVATE_KEY!, // new pair
190
+ publicKey: process.env.JWT_PUBLIC_KEY!,
191
+ additionalPublicKeys: [process.env.JWT_PREVIOUS_PUBLIC_KEY!],
130
192
  }
131
193
  ```
132
194
 
133
- ## Authentication Modes
134
-
135
- This plugin supports two authentication modes that can coexist:
195
+ Keep the old public key for the inactivity timeout of the sessions (14 days by default), not only for the access token lifetime: a refresh verifies the signature of the (expired) access token, so an inactive client still presents a token of the old key. After a compromise, configure the new pair alone at once: the tokens of the old key, including forged ones, are rejected, and every user logs in again.
136
196
 
137
- ### Body Mode (Default)
138
- Tokens are sent in request body. Suitable for mobile apps (Expo/React Native) and desktop apps (Electron).
197
+ `authPlugin(auth, { jwks: true })` publishes the public keys at `GET /.well-known/jwks.json` (cached 5 minutes), so that other services can verify the tokens and follow rotations, for example with `createRemoteJWKSet()` of jose. `auth.getJwks()` returns the same key set.
139
198
 
140
- ### Cookie Mode
141
- Tokens are stored in httpOnly cookies. Suitable for web applications (SPAs, SSR) for enhanced security against XSS attacks.
199
+ ## Brute force protection
142
200
 
143
- The plugin automatically detects which mode to use based on the request.
201
+ Login attempts are counted per username, whether the account exists or not (a `429` does not reveal it), before the password is checked:
144
202
 
145
- ---
203
+ | Counter | Default | Against |
204
+ | --------------------- | --------------------------- | --------------------------------------------------------------------- |
205
+ | username + IP address | 5 per minute | brute force from one client, without letting others block the account |
206
+ | username | 100 per hour (NIST 800-63B) | distributed brute force on an account |
146
207
 
147
- ## Endpoints
208
+ Over a limit, the login answers `429 TOO_MANY_LOGIN_ATTEMPTS` with a `Retry-After` header, and `onLoginFailed` gets `errorType: 'too_many_attempts'`. A successful login clears the counter of the client. Usernames are normalized (letter case, spaces), and the counter keys are hashed.
148
209
 
149
- ### `POST /auth/login`
210
+ ```ts
211
+ createAuth({
212
+ loginThrottle: {
213
+ store: redisStore, // see below
214
+ limits: { usernameAndIp: { max: 10, window: 60 }, username: false },
215
+ },
216
+ })
217
+ ```
150
218
 
151
- Authenticate a user and generate tokens.
219
+ `loginThrottle: false` disables it, when the infrastructure (gateway, WAF) already limits `/auth/login`. The client IP address comes from the HTTP server: behind a proxy, configure it to trust the proxy headers (Express `trust proxy`), or every client shares the proxy address.
152
220
 
153
- **Request Body:**
154
- - `username` (string, required): Username
155
- - `password` (string, required): Password
156
- - `client` (string, optional): User agent string for tracking
157
- - `aud` (string, optional): Audience for role-based access control
158
- - `mode` (enum: 'body' | 'cookie', optional): Authentication mode
221
+ The default store keeps the counters in memory, for a single instance (with several instances, each one counts on its own). Use a shared store, which increments a counter atomically:
159
222
 
160
- **Response:**
161
- ```json
162
- {
163
- "userId": "usr-123",
164
- "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
165
- "refreshToken": "3f7b2a8c1e...",
166
- "expiresIn": 3600,
167
- "expiresAt": "2025-11-02T15:30:00.000Z"
223
+ ```ts
224
+ import type { RateLimitStore } from '@dotpress/auth'
225
+
226
+ // Redis (ioredis)
227
+ const redisStore: RateLimitStore = {
228
+ async increment(key, windowSeconds) {
229
+ const results = await redis
230
+ .multi()
231
+ .set(key, 0, 'EX', windowSeconds, 'NX') // starts a window
232
+ .incr(key)
233
+ .pttl(key)
234
+ .exec()
235
+ return { count: Number(results![1]![1]), resetAt: new Date(Date.now() + Number(results![2]![1])) }
236
+ },
237
+ reset: async (key) => void (await redis.del(key)),
168
238
  }
169
239
  ```
170
240
 
171
- **Behavior:**
172
- - If `mode: 'cookie'` is provided, tokens are also set as httpOnly cookies
173
- - `accessToken` and `refreshToken` are always returned in the response body for flexibility
174
- - Returns 401 on failed login
241
+ ```sql
242
+ -- PostgreSQL: rate_limits (key TEXT PRIMARY KEY, count INT, reset_at TIMESTAMPTZ)
243
+ INSERT INTO rate_limits (key, count, reset_at) VALUES ($1, 1, now() + make_interval(secs => $2))
244
+ ON CONFLICT (key) DO UPDATE SET
245
+ count = CASE WHEN rate_limits.reset_at <= now() THEN 1 ELSE rate_limits.count + 1 END,
246
+ reset_at = CASE WHEN rate_limits.reset_at <= now() THEN EXCLUDED.reset_at ELSE rate_limits.reset_at END
247
+ RETURNING count, reset_at;
248
+ ```
175
249
 
176
- **Cookie Mode Example:**
250
+ `beforeLogin` adds custom policies (CAPTCHA after some failures, blocked addresses…). It is called after the throttle, before the password check:
177
251
 
178
252
  ```ts
179
- // Client login request with cookie mode
180
- const response = await fetch('/auth/login', {
181
- method: 'POST',
182
- headers: { 'Content-Type': 'application/json' },
183
- credentials: 'include', // Important for cookies!
184
- body: JSON.stringify({
185
- username: 'john@example.com',
186
- password: 'secret123',
187
- mode: 'cookie'
188
- })
253
+ createAuth({
254
+ beforeLogin: async ({ username, ipAddress, clientId, req }) => {
255
+ if (await isBlocked(ipAddress)) return forbiddenError('LOGIN_BLOCKED')
256
+ },
189
257
  })
190
258
  ```
191
259
 
192
- ### `POST /auth/refresh`
260
+ ## Access tokens
193
261
 
194
- Refresh tokens using previous token pair. The plugin automatically detects the authentication mode.
262
+ Access tokens follow [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068): `typ: at+jwt` header, RS256 signature, and the `iss`, `aud`, `sub`, `jti`, `iat`, `exp` and `auth_time` claims, plus `sid` (session, exposed as `ctx.auth.sessionId`) when the token was issued with a refresh token, and `client_id` when it was issued for a client. The type, issuer, audience and client are checked on every request.
195
263
 
196
- **Request Body (Body Mode):**
197
- ```json
198
- {
199
- "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
200
- "refreshToken": "3f7b2a8c1e..."
201
- }
202
- ```
264
+ The username is not written in the token by default: it is often an email address, readable by anyone holding the token (it is only base64 encoded), in logs and in the browser. `sub` identifies the user, and `ctx.user` holds the profile. `usernameClaim: true` writes it (`ctx.auth.username`).
265
+
266
+ ## Clients
203
267
 
204
- **Request Body (Cookie Mode):**
205
- ```json
206
- {}
268
+ When one API serves several applications (an app and a backoffice, for example), declare them:
269
+
270
+ ```ts
271
+ const auth = createAuth({
272
+ // …
273
+ clients: {
274
+ allowed: ['app', 'backoffice'],
275
+ required: true, // clientId required at login
276
+ isUserRoleAllowed: (role, clientId) => clientId !== 'backoffice' || role === 'admin',
277
+ },
278
+ })
279
+
280
+ createRouteGroup('/backoffice', [requireClient('backoffice'), requireRole('admin')])
207
281
  ```
208
- *Tokens are automatically extracted from cookies.*
209
-
210
- **Response:**
211
- ```json
212
- {
213
- "userId": "usr-123",
214
- "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
215
- "refreshToken": "9a1b3c5d7f...",
216
- "expiresIn": 3600,
217
- "expiresAt": "2025-11-02T16:30:00.000Z"
282
+
283
+ The login `clientId` must be one of `allowed` (`400` otherwise), and `isUserRoleAllowed` checks that the user may get a token for it. The token carries a `client_id` claim, exposed as `ctx.auth.clientId`.
284
+
285
+ > The client identifier is declared by the caller: anyone can send `clientId: 'backoffice'`. A `client_id` claim does not prove which application sends a request; it means that the token was issued under the policy of that client (`isUserRoleAllowed`). `requireClient()` keeps the tokens of one application away from the routes of another; combine it with `requireRole()`.
286
+
287
+ Without `clients` setting, the login `clientId` is only reported in the login events, and not written in the token.
288
+
289
+ ## Data provider
290
+
291
+ ```ts
292
+ import type { DataProvider } from '@dotpress/auth'
293
+
294
+ const dataProvider: DataProvider = {
295
+ storeRefreshToken: ({ tokenHash, userId, jwtId, familyId, expiresAt }) => db.refreshToken.create({ … }),
296
+ findRefreshToken: (tokenHash) => db.refreshToken.find({ tokenHash }), // { userId, jwtId, familyId, expiresAt, isUsed, usedAt, revokedAt }
297
+ markRefreshTokenAsUsed: (tokenHash, usedAt) => db.refreshToken.update({ tokenHash }, { isUsed: true, usedAt }),
298
+ revokeTokens: (userId) => db.refreshToken.updateMany({ userId }, { revokedAt: new Date() }),
299
+ revokeTokenFamily: (familyId) => db.refreshToken.updateMany({ familyId }, { revokedAt: new Date() }),
300
+ findUserById: (userId) => db.user.findActivePublicProfile(userId), // undefined for deleted or disabled accounts
301
+ findUserIdentifiers: (username) => db.user.findCredentials(username), // { userId, passwordHash (null without password), role? }
302
+ isSessionRevoked: (sessionId) => …, // optional, see Revocation
303
+ updatePasswordHash: (userId, passwordHash) => db.user.update(userId, { passwordHash }), // optional, see Passwords
218
304
  }
219
305
  ```
220
306
 
221
- **Behavior:**
222
- - Automatically detects mode: checks cookies first, then falls back to body
223
- - If cookies are present, new tokens are automatically set as cookies
224
- - Old refresh token is marked as used (one-time use with rotation)
225
- - Returns 401 if tokens are not valid
226
-
227
- **Error Codes:**
228
- - `NO_TOKENS_PROVIDED`: No tokens found in cookies or body
229
- - `INVALID_REFRESH_TOKEN`: Refresh token is invalid or already used
230
- - `EXPIRED_REFRESH_TOKEN`: Refresh token has expired
231
-
232
- ### `POST /auth/restore`
233
-
234
- Restore a user session from cookies. This endpoint validates the access token from cookies and automatically refreshes it if expired.
235
-
236
- **Use Case:**
237
- This route is designed for web applications to restore user sessions after page reload or when returning after inactivity. Instead of making multiple API calls (`/auth/me` → 401 → `/auth/refresh` → `/auth/me`), you can call `/auth/restore` once to validate or refresh the session in a single request.
238
-
239
- **Requirements:**
240
- - Cookie mode must be enabled (`cookies.enabled: true`)
241
- - Access token must be stored in cookies (`cookies.accessTokenInCookie: true`)
242
- - Both `accessToken` and `refreshToken` must be present in cookies
243
-
244
- **Request:**
245
- No body required. Tokens are automatically extracted from cookies.
246
-
247
- **Response:**
248
- ```json
249
- {
250
- "userId": "usr-123",
251
- "accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
252
- "refreshToken": "3f7b2a8c1e...",
253
- "expiresIn": 3600,
254
- "expiresAt": "2025-11-02T16:30:00.000Z",
255
- "user": {
256
- "id": "usr-123",
257
- "username": "JohnDoe",
258
- "email": "john@example.com"
259
- }
260
- }
307
+ Refresh tokens are random 128-byte values; only their SHA-256 hash is stored.
308
+
309
+ ## Account enumeration
310
+
311
+ A login fails with the same `401 INVALID_LOGIN` response, in the same time, whether the username is unknown, the account has no password (`passwordHash: null`, for SSO accounts), or the password is wrong: unknown users are checked against a dummy bcrypt hash, computed at startup with `passwordHashRounds`. Two things keep it effective:
312
+
313
+ - `passwordHashRounds` must match the cost of the hashes stored in your database (see [Passwords](#passwords));
314
+ - `onLoginFailed` must do the same work whatever the `errorType` (for example, do not update the user row only on `invalid_password`).
315
+
316
+ ## Passwords
317
+
318
+ Passwords are hashed with bcrypt, with a cost of 12 by default (`passwordHashRounds`), like Rails and Laravel: about 200 ms per login on a recent laptop with bcryptjs (50 ms with a cost of 10). Measure it on your servers.
319
+
320
+ **Hash upgrade.** Implement the optional `updatePasswordHash(userId, passwordHash)` method of the data provider: at each successful login, a hash with another cost than `passwordHashRounds` is replaced, computed from the password just verified. Raising the cost then migrates the users as they log in. An error of the method is logged, and does not fail the login.
321
+
322
+ > While hashes with the previous cost remain, their accounts answer faster than unknown usernames, which reveals that they exist (see [Account enumeration](#account-enumeration)). Either accept it during the migration, or keep `passwordHashRounds` at the cost of your hashes (`10` for hashes created by Dotpress V1).
323
+
324
+ **72-byte limit.** bcrypt reads the first 72 bytes (UTF-8) of a password, and ignores the rest. `hashPassword()` and `generatePasswordHash()` throw a `RangeError` for longer passwords instead of truncating them. Check the length in the sign-up and password change schemas, so that users get a validation error:
325
+
326
+ ```ts
327
+ import { isPasswordTooLong } from '@dotpress/auth'
328
+
329
+ schema: (z) => ({
330
+ body: z.object({
331
+ password: z
332
+ .string()
333
+ .min(12)
334
+ .refine((password) => !isPasswordTooLong(password), 'Password too long'),
335
+ }),
336
+ })
261
337
  ```
262
338
 
263
- **Behavior:**
264
- 1. If `accessToken` is valid: Returns existing tokens with user data
265
- 2. If `accessToken` is expired but `refreshToken` is valid: Generates new token pair, updates cookies, and returns new tokens with user data
266
- 3. If both tokens are invalid: Returns 401
339
+ 72 bytes are 72 ASCII characters, but fewer with accents or emojis (`é` takes 2 bytes). Logins are not limited: passwords created before the check keep working, as they are compared with the same truncation.
340
+
341
+ ## Sessions
342
+
343
+ A session starts at login and is extended by each refresh. It ends after one of two timeouts, in seconds:
267
344
 
268
- **Error Codes:**
269
- - `COOKIES_NOT_ENABLED`: Cookie mode is not enabled in plugin configuration
270
- - `ACCESS_TOKEN_NOT_IN_COOKIE`: `accessTokenInCookie` is not enabled
271
- - `NO_REFRESH_TOKEN`: Refresh token cookie is missing
272
- - `NO_ACCESS_TOKEN`: Access token cookie is missing
273
- - `INVALID_REFRESH_TOKEN`: Refresh token is invalid or already used
274
- - `EXPIRED_REFRESH_TOKEN`: Refresh token has expired
275
- - `USER_NOT_FOUND`: User ID from token not found in database
345
+ | Setting | Default | The session ends… |
346
+ | ------------------------- | ------- | --------------------------------------------------------------------------- |
347
+ | `session.idleTimeout` | 14 days | when it is not refreshed during this delay (lifetime of each refresh token) |
348
+ | `session.absoluteTimeout` | none | this delay after login, even when it is active |
276
349
 
277
- **Example Usage:**
350
+ Without absolute timeout, an active session never ends, like on most consumer apps. Examples:
278
351
 
279
352
  ```ts
280
- // React SPA: Restore session on app initialization
281
- useEffect(() => {
282
- const restoreSession = async () => {
283
- try {
284
- const response = await fetch('/auth/restore', {
285
- method: 'POST',
286
- credentials: 'include', // Important: include cookies
287
- })
288
-
289
- if (response.ok) {
290
- const { user, accessToken, expiresAt } = await response.json()
291
- dispatch(setUser(user))
292
- dispatch(setAccessToken(accessToken))
293
- dispatch(setExpiresAt(expiresAt))
294
- } else {
295
- // Session invalid, redirect to login
296
- navigate('/login')
297
- }
298
- } catch (error) {
299
- console.error('Failed to restore session:', error)
300
- }
301
- }
353
+ const DAY = 24 * 60 * 60
302
354
 
303
- restoreSession()
304
- }, [])
355
+ session: { idleTimeout: 30 * DAY } // consumer app: never logged out while active
356
+ session: { idleTimeout: DAY, absoluteTimeout: DAY } // backoffice: log in every day
357
+ session: { idleTimeout: 7 * DAY, absoluteTimeout: 28 * DAY } // log in every 4 weeks
305
358
  ```
306
359
 
307
- ### `GET /auth/me`
308
-
309
- Get current authenticated user.
360
+ With an absolute timeout, the access tokens, refresh tokens and cookies never outlive the session, and responses return `sessionExpiresAt`, so that the client can warn the user. The session start is the `auth_time` claim of the access token. Adding an absolute timeout also ends the sessions opened before.
310
361
 
311
- **Request:**
312
- No body required. Token is extracted from:
313
- 1. `Authorization: Bearer <token>` header (priority)
314
- 2. Cookie (if cookie mode is enabled and `accessTokenInCookie: true`)
362
+ Each client can have its own lifetimes (an explicit `absoluteTimeout: undefined` removes the global one):
315
363
 
316
- **Response:**
317
- ```json
318
- {
319
- "id": "usr-123",
320
- "username": "john@example.com",
321
- "email": "john@example.com",
322
- // ... other user fields
364
+ ```ts
365
+ clients: {
366
+ allowed: ['app', 'backoffice'],
367
+ sessions: { backoffice: { idleTimeout: DAY, absoluteTimeout: DAY } },
323
368
  }
324
369
  ```
325
370
 
326
- Returns 401 if user is not authenticated.
371
+ An ended session makes `/auth/refresh` answer `401 EXPIRED_REFRESH_TOKEN`: the user must log in again.
327
372
 
328
- ### `POST /auth/logout`
373
+ ## Logout
329
374
 
330
- Revoke tokens for current authenticated user.
375
+ `POST /auth/logout` revokes the **current session**: the other devices of the user stay logged in. With `{ "scope": "all" }`, it revokes every session of the user, on every device.
331
376
 
332
- **Response:**
333
- ```json
334
- {
335
- "success": true
336
- }
337
- ```
377
+ The session is identified by the refresh token (cookie, then `refreshToken` body field), or by the access token (`sid` claim). An expired access token does not prevent a logout: the refresh token is enough. The route is idempotent (RFC 7009): an unknown, revoked or missing token still answers `200` and clears the cookies, so that a client can always log out. Only `scope: 'all'` requires a proof, a valid access token or an active refresh token (`401` otherwise). In cookie mode, the refresh token cookie of a cross-origin request gets `403 CROSS_ORIGIN_REQUEST`, like `/auth/refresh`.
378
+
379
+ Outside the routes, `auth.revokeSession(refreshToken, { scope })` does the same, and returns `{ userId, sessionId }` (or `undefined` when nothing was revoked). `ctx.auth.sessionId` lets an app list the sessions of a user and log out one device (`dataProvider.revokeTokenFamily(sessionId)`).
380
+
381
+ > **Revoke every session when the password changes or is reset**: call `dataProvider.revokeTokens(userId)`. A stolen session must not survive a password change.
338
382
 
339
- **Behavior:**
340
- - Revokes all refresh tokens for the user in the database
341
- - Clears authentication cookies if cookie mode was used
342
- - Returns 401 if user is not authenticated
383
+ The access tokens of a revoked session stay valid until they expire, unless the data provider implements `isSessionRevoked` (see [Revocation](#revocation)).
343
384
 
385
+ ## Revocation
344
386
 
345
- ## Multi-Platform Support
387
+ Access tokens are verified without a database lookup of the token itself, so revoking a session does not invalidate the access tokens already issued:
346
388
 
347
- The plugin is designed to support multiple client types simultaneously:
389
+ | Event | Refresh | Access tokens already issued |
390
+ | ---------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------- |
391
+ | Account deleted or disabled (`findUserById` returns `undefined`) | rejected, the session is revoked | rejected at once |
392
+ | Logout, `scope: 'all'`, refresh token reuse, `revokeTokens()` | rejected | valid until they expire (`accessTokenTTL`, 15 minutes), or rejected at once with `isSessionRevoked` |
348
393
 
349
- | Client Type | Recommended Mode | Configuration |
350
- |-------------|-----------------|---------------|
351
- | React SPA | Cookie | `mode: 'cookie'` in login |
352
- | Next.js SSR | Cookie | `mode: 'cookie'` + `domain: '.yourapp.com'` |
353
- | React Native / Expo | Body | No `mode` parameter (default) |
354
- | Electron | Body | No `mode` parameter (default) |
394
+ **`findUserById` must return `undefined` for a deleted, disabled or banned account**: it is called on every authenticated request and every refresh.
355
395
 
356
- **Cross-subdomain authentication example:**
396
+ ### Immediate revocation
397
+
398
+ Implement the optional `isSessionRevoked(sessionId)` method of the data provider: it is called on every authenticated request carrying a session (`sid` claim), and the tokens of a revoked session get `SESSION_REVOKED` (the request stays anonymous). It costs one lookup per request, besides `findUserById`, and needs a store shared by every instance of the API.
399
+
400
+ The revocation state is already stored: a revoked session has revoked refresh tokens. With PostgreSQL, query the refresh token table:
357
401
 
358
402
  ```ts
359
- // API configuration (api.myapp.com)
360
- const authPlugin = configurePlugin({
361
- cookies: {
362
- enabled: true,
363
- domain: '.myapp.com', // Share cookies across subdomains
364
- secure: true,
365
- sameSite: 'lax',
366
- },
367
- // ... other settings
368
- })
403
+ isSessionRevoked: async (sessionId) => {
404
+ const { rowCount } = await pool.query('SELECT 1 FROM refresh_tokens WHERE family_id = $1 AND revoked_at IS NOT NULL LIMIT 1', [sessionId])
405
+ return rowCount > 0
406
+ }
407
+ ```
369
408
 
370
- // CORS configuration
371
- const app = await createApp({
372
- cors: {
373
- origin: [
374
- 'https://app.myapp.com',
375
- 'https://www.myapp.com',
376
- ],
377
- credentials: true, // Required for cookies!
378
- },
379
- plugins: [authPlugin]
380
- })
409
+ ```sql
410
+ CREATE INDEX refresh_tokens_family_id ON refresh_tokens (family_id);
381
411
  ```
382
412
 
383
- **Client-side fetch configuration:**
413
+ For faster lookups, record the revoked sessions in Redis when revoking. They only need to be kept for `accessTokenTTL`: afterwards, every access token of the session has expired.
384
414
 
385
415
  ```ts
386
- // Always include credentials for cookie mode
387
- fetch('https://api.myapp.com/auth/login', {
388
- method: 'POST',
389
- credentials: 'include', // Essential for cookies
390
- headers: { 'Content-Type': 'application/json' },
391
- body: JSON.stringify({ username, password, mode: 'cookie' })
392
- })
416
+ const ACCESS_TOKEN_TTL = 900 // same as accessTokenTTL
417
+ const markRevoked = (familyIds: string[]) => Promise.all(familyIds.map((id) => redis.set(`dotpress:revoked-session:${id}`, '1', { EX: ACCESS_TOKEN_TTL })))
418
+
419
+ const dataProvider: DataProvider = {
420
+ // …
421
+ revokeTokenFamily: async (familyId) => {
422
+ await db.refreshToken.updateMany({ familyId }, { revokedAt: new Date() })
423
+ await markRevoked([familyId])
424
+ },
425
+ revokeTokens: async (userId) => {
426
+ const familyIds = await db.refreshToken.findActiveFamilyIds(userId)
427
+ await db.refreshToken.updateMany({ userId }, { revokedAt: new Date() })
428
+ await markRevoked(familyIds)
429
+ },
430
+ isSessionRevoked: async (sessionId) => (await redis.exists(`dotpress:revoked-session:${sessionId}`)) === 1,
431
+ }
393
432
  ```
394
433
 
395
- ---
434
+ An error thrown by `isSessionRevoked` fails closed: the request stays anonymous, and a warning is logged. Avoid caching the answer in the memory of each instance: a revocation would only be seen by the instance that made it, after the cache delay.
396
435
 
397
- ## Prisma schema examples
436
+ ## Refresh token rotation
398
437
 
399
- You will find below recommended schema for User and RefreshToken entities. Feel free to adapt them according to your needs.
438
+ Each refresh returns a new refresh token and invalidates the previous one. All the refresh tokens of a session share a `familyId`, created at login: it is the `sid` claim of the access tokens.
400
439
 
401
- ```text
402
- model User {
403
- id String @id @default(cuid())
404
- firstName String? @db.VarChar(100)
405
- lastName String? @db.VarChar(100)
406
- username String @unique @db.VarChar(50)
407
- pwdHash String @db.VarChar(200)
408
- email String @db.VarChar(200)
409
- role String @db.VarChar(50)
410
- createdAt DateTime @default(now())
411
- updatedAt DateTime @updatedAt
440
+ A refresh token can only be used once. When a used token is presented again, it was probably stolen (the attacker and the legitimate client both hold it): the whole session is revoked, `onRefreshTokenReuse` is called, and the request gets `401 INVALID_REFRESH_TOKEN`. Within `refreshTokenReuseInterval` (10 seconds by default) after its first use, a token presented again gets a new token pair in the same session, like with Auth0 or Okta: concurrent refreshes (several tabs) and retries after a lost response keep working. The interval is counted from the first use and is not extended by later presentations. Reusing the token after the interval revokes the whole session, including the pairs issued within the interval.
412
441
 
413
- @@map("users")
414
- }
442
+ ## Service
415
443
 
416
- model RefreshToken {
417
- id String @id @default(cuid())
418
- userId String @db.VarChar(50)
419
- jwtId String @db.VarChar(100)
420
- tokenHash String @unique @db.VarChar(100)
421
- expiresAt DateTime
422
- revokedAt DateTime?
423
- isUsed Boolean
424
- createdAt DateTime @default(now())
425
- updatedAt DateTime @updatedAt
426
-
427
- @@map("refresh_tokens")
428
- }
444
+ `createAuth()` also works without an app (scripts, workers):
445
+
446
+ ```ts
447
+ const { accessToken, refreshToken } = await auth.generateTokensForUser({ userId, username })
448
+ const session = await auth.verifyAccessToken(accessToken)
449
+ const hash = await auth.hashPassword('secret') // or generatePasswordHash()
450
+ auth.setAuthCookies(ctx.res, { accessToken, refreshToken })
429
451
  ```
452
+
453
+ ## License
454
+
455
+ MIT