@dotpress/auth 1.3.2 → 2.0.0-alpha.1
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/LICENSE +1 -1
- package/README.md +355 -329
- package/dist/index.d.ts +679 -8
- package/dist/index.js +1226 -20
- package/dist/index.js.map +1 -0
- package/package.json +32 -46
- package/dist/__tests__/functional/cookies.test.d.ts +0 -1
- package/dist/__tests__/functional/cookies.test.js +0 -621
- package/dist/__tests__/functional/handlers.test.d.ts +0 -1
- package/dist/__tests__/functional/handlers.test.js +0 -239
- package/dist/__tests__/functional/middlewares.test.d.ts +0 -1
- package/dist/__tests__/functional/middlewares.test.js +0 -111
- package/dist/__tests__/unit/cookies.test.d.ts +0 -1
- package/dist/__tests__/unit/cookies.test.js +0 -466
- package/dist/__tests__/unit/tokens.test.d.ts +0 -1
- package/dist/__tests__/unit/tokens.test.js +0 -225
- package/dist/lib/cookies.d.ts +0 -42
- package/dist/lib/cookies.js +0 -136
- package/dist/lib/generators.d.ts +0 -1
- package/dist/lib/generators.js +0 -4
- package/dist/lib/handlers.d.ts +0 -2
- package/dist/lib/handlers.js +0 -263
- package/dist/lib/ip.d.ts +0 -2
- package/dist/lib/ip.js +0 -15
- package/dist/lib/middleware.d.ts +0 -2
- package/dist/lib/middleware.js +0 -23
- package/dist/lib/settings.d.ts +0 -3
- package/dist/lib/settings.js +0 -42
- package/dist/lib/tokens.d.ts +0 -38
- package/dist/lib/tokens.js +0 -112
- package/dist/types/types.d.ts +0 -107
- package/dist/types/types.js +0 -1
package/README.md
CHANGED
|
@@ -1,429 +1,455 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dotpress/auth
|
|
2
2
|
|
|
3
|
-
|
|
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 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
|
-
|
|
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@alpha @dotpress/core zod
|
|
18
9
|
```
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
### 2. Register your plugin
|
|
11
|
+
## Quick start
|
|
23
12
|
|
|
24
13
|
```ts
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
+
defineRoute({
|
|
36
|
+
path: '/profile',
|
|
37
|
+
middlewares: [requireAuth],
|
|
38
|
+
handler: ({ user }) => user, // AuthUser
|
|
35
39
|
})
|
|
36
40
|
|
|
37
|
-
app
|
|
38
|
-
|
|
39
|
-
})
|
|
41
|
+
const app = await createApp({ adapter: expressAdapter(), plugins: [authPlugin(auth)] })
|
|
42
|
+
await app.listen(3000)
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
|
|
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
|
-
|
|
49
|
+
## Routes
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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). |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
69
|
+
### Cookie names and paths
|
|
67
70
|
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
260
|
+
## Access tokens
|
|
193
261
|
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
- `
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
350
|
+
Without absolute timeout, an active session never ends, like on most consumer apps. Examples:
|
|
278
351
|
|
|
279
352
|
```ts
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
371
|
+
An ended session makes `/auth/refresh` answer `401 EXPIRED_REFRESH_TOKEN`: the user must log in again.
|
|
327
372
|
|
|
328
|
-
|
|
373
|
+
## Logout
|
|
329
374
|
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
{
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
##
|
|
436
|
+
## Refresh token rotation
|
|
398
437
|
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
}
|
|
442
|
+
## Service
|
|
415
443
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|