@dotpress/auth 2.0.0-alpha.1 β 2.0.0-alpha.3
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 +14 -417
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
# @dotpress/auth
|
|
2
2
|
|
|
3
|
-
JWT authentication
|
|
3
|
+
JWT authentication for [Dotpress](https://dotpress.dev): login, refresh token rotation, `httpOnly` cookie sessions protected against cross-site request forgery, brute force protection, and a typed `ctx.user`. Works with any database through a small data provider, and on any adapter.
|
|
4
4
|
|
|
5
5
|
> π§ Dotpress V2 is in alpha (`2.0.0-alpha`): the API may still change before 2.0.0. Until then, install this package with the `alpha` tag: without it, `@dotpress/auth` is the 1.x.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
|
|
8
|
+
npm install @dotpress/auth@alpha @dotpress/core zod
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
## Quick start
|
|
12
|
-
|
|
13
11
|
```ts
|
|
14
12
|
import { createApp, defineRoute } from '@dotpress/core'
|
|
15
|
-
import { expressAdapter } from '@dotpress/adapter-express'
|
|
16
13
|
import { authPlugin, createAuth, requireAuth } from '@dotpress/auth'
|
|
17
14
|
|
|
18
15
|
declare module '@dotpress/auth' {
|
|
@@ -25,11 +22,12 @@ declare module '@dotpress/auth' {
|
|
|
25
22
|
|
|
26
23
|
const auth = createAuth({
|
|
27
24
|
keys: {
|
|
28
|
-
secretKey: process.env.JWT_PRIVATE_KEY!, // PKCS#8 PEM
|
|
29
|
-
publicKey: process.env.JWT_PUBLIC_KEY!, // SPKI PEM
|
|
25
|
+
secretKey: process.env.JWT_PRIVATE_KEY!, // PKCS#8 PEM
|
|
26
|
+
publicKey: process.env.JWT_PUBLIC_KEY!, // SPKI PEM
|
|
30
27
|
},
|
|
31
|
-
issuer: 'https://api.example.com', //
|
|
32
|
-
dataProvider, //
|
|
28
|
+
issuer: 'https://api.example.com', // checked on every request
|
|
29
|
+
dataProvider, // your users and refresh tokens
|
|
30
|
+
cookies: { enabled: true, accessTokenInCookie: true }, // for browsers
|
|
33
31
|
})
|
|
34
32
|
|
|
35
33
|
defineRoute({
|
|
@@ -38,417 +36,16 @@ defineRoute({
|
|
|
38
36
|
handler: ({ user }) => user, // AuthUser
|
|
39
37
|
})
|
|
40
38
|
|
|
41
|
-
const app = await createApp({ adapter
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
|
|
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).
|
|
48
|
-
|
|
49
|
-
## Routes
|
|
50
|
-
|
|
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). |
|
|
58
|
-
|
|
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:
|
|
60
|
-
|
|
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 |
|
|
66
|
-
|
|
67
|
-
Every route still returns `userId`, `expiresIn` and `expiresAt` (access token expiration), plus `sessionExpiresAt` when the session has an absolute timeout (see [Sessions](#sessions)).
|
|
68
|
-
|
|
69
|
-
### Cookie names and paths
|
|
70
|
-
|
|
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):
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
await createApp({ adapter, basePath: '/my-app/api', plugins: [authPlugin(auth)] })
|
|
80
|
-
// Refresh token cookie path: /my-app/api/auth
|
|
81
|
-
```
|
|
82
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
```ts
|
|
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 }),
|
|
131
|
-
})
|
|
132
|
-
```
|
|
133
|
-
|
|
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
|
-
```
|
|
167
|
-
|
|
168
|
-
To convert PKCS#1 keys (`BEGIN RSA PRIVATE KEY`):
|
|
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
|
-
```
|
|
174
|
-
|
|
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`:
|
|
180
|
-
|
|
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!],
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
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.
|
|
196
|
-
|
|
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.
|
|
198
|
-
|
|
199
|
-
## Brute force protection
|
|
200
|
-
|
|
201
|
-
Login attempts are counted per username, whether the account exists or not (a `429` does not reveal it), before the password is checked:
|
|
202
|
-
|
|
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 |
|
|
207
|
-
|
|
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.
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
createAuth({
|
|
212
|
-
loginThrottle: {
|
|
213
|
-
store: redisStore, // see below
|
|
214
|
-
limits: { usernameAndIp: { max: 10, window: 60 }, username: false },
|
|
215
|
-
},
|
|
216
|
-
})
|
|
217
|
-
```
|
|
218
|
-
|
|
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.
|
|
220
|
-
|
|
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:
|
|
222
|
-
|
|
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)),
|
|
238
|
-
}
|
|
39
|
+
const app = await createApp({ adapter, plugins: [authPlugin(auth)] })
|
|
40
|
+
// POST /auth/login, /auth/refresh, /auth/restore, /auth/logout, GET /auth/me
|
|
239
41
|
```
|
|
240
42
|
|
|
241
|
-
|
|
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
|
-
```
|
|
249
|
-
|
|
250
|
-
`beforeLogin` adds custom policies (CAPTCHA after some failures, blocked addressesβ¦). It is called after the throttle, before the password check:
|
|
251
|
-
|
|
252
|
-
```ts
|
|
253
|
-
createAuth({
|
|
254
|
-
beforeLogin: async ({ username, ipAddress, clientId, req }) => {
|
|
255
|
-
if (await isBlocked(ipAddress)) return forbiddenError('LOGIN_BLOCKED')
|
|
256
|
-
},
|
|
257
|
-
})
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
## Access tokens
|
|
261
|
-
|
|
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.
|
|
263
|
-
|
|
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`).
|
|
43
|
+
## Documentation
|
|
265
44
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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')])
|
|
281
|
-
```
|
|
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
|
|
304
|
-
}
|
|
305
|
-
```
|
|
306
|
-
|
|
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
|
-
})
|
|
337
|
-
```
|
|
338
|
-
|
|
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:
|
|
344
|
-
|
|
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 |
|
|
349
|
-
|
|
350
|
-
Without absolute timeout, an active session never ends, like on most consumer apps. Examples:
|
|
351
|
-
|
|
352
|
-
```ts
|
|
353
|
-
const DAY = 24 * 60 * 60
|
|
354
|
-
|
|
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
|
|
358
|
-
```
|
|
359
|
-
|
|
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.
|
|
361
|
-
|
|
362
|
-
Each client can have its own lifetimes (an explicit `absoluteTimeout: undefined` removes the global one):
|
|
363
|
-
|
|
364
|
-
```ts
|
|
365
|
-
clients: {
|
|
366
|
-
allowed: ['app', 'backoffice'],
|
|
367
|
-
sessions: { backoffice: { idleTimeout: DAY, absoluteTimeout: DAY } },
|
|
368
|
-
}
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
An ended session makes `/auth/refresh` answer `401 EXPIRED_REFRESH_TOKEN`: the user must log in again.
|
|
372
|
-
|
|
373
|
-
## Logout
|
|
374
|
-
|
|
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.
|
|
376
|
-
|
|
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.
|
|
382
|
-
|
|
383
|
-
The access tokens of a revoked session stay valid until they expire, unless the data provider implements `isSessionRevoked` (see [Revocation](#revocation)).
|
|
384
|
-
|
|
385
|
-
## Revocation
|
|
386
|
-
|
|
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:
|
|
388
|
-
|
|
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` |
|
|
393
|
-
|
|
394
|
-
**`findUserById` must return `undefined` for a deleted, disabled or banned account**: it is called on every authenticated request and every refresh.
|
|
395
|
-
|
|
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:
|
|
401
|
-
|
|
402
|
-
```ts
|
|
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
|
-
```
|
|
408
|
-
|
|
409
|
-
```sql
|
|
410
|
-
CREATE INDEX refresh_tokens_family_id ON refresh_tokens (family_id);
|
|
411
|
-
```
|
|
412
|
-
|
|
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.
|
|
414
|
-
|
|
415
|
-
```ts
|
|
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
|
-
}
|
|
432
|
-
```
|
|
433
|
-
|
|
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.
|
|
435
|
-
|
|
436
|
-
## Refresh token rotation
|
|
437
|
-
|
|
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.
|
|
439
|
-
|
|
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.
|
|
441
|
-
|
|
442
|
-
## Service
|
|
443
|
-
|
|
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 })
|
|
451
|
-
```
|
|
45
|
+
- [Authentication](https://dotpress.dev/auth/): the data provider, protecting routes, sessions and tokens, cookie mode and CSRF, clients, passwords and brute force, keys and rotation
|
|
46
|
+
- [Security checklist](https://dotpress.dev/auth/security)
|
|
47
|
+
- [Auth reference](https://dotpress.dev/reference/auth): every setting, route, error and contract
|
|
48
|
+
- [Migrate from v1](https://dotpress.dev/reference/migrate-from-v1#authentication)
|
|
452
49
|
|
|
453
50
|
## License
|
|
454
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotpress/auth",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.3",
|
|
4
4
|
"description": "JWT authentication plugin for Dotpress",
|
|
5
5
|
"author": "Renaud Guyon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22.12"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@dotpress/core": "^2.0.0-alpha.
|
|
32
|
+
"@dotpress/core": "^2.0.0-alpha.3",
|
|
33
33
|
"zod": "^4.6.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"jose": "^6.2.15"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@dotpress/core": "2.0.0-alpha.
|
|
41
|
-
"@dotpress/testing": "2.0.0-alpha.
|
|
40
|
+
"@dotpress/core": "2.0.0-alpha.3",
|
|
41
|
+
"@dotpress/testing": "2.0.0-alpha.3",
|
|
42
42
|
"@types/jsonwebtoken": "^9.0.10",
|
|
43
43
|
"jsonwebtoken": "^9.0.3",
|
|
44
44
|
"zod": "^4.6.7"
|