@maximemrf/adonisjs-jwt 1.0.0-rc.2 → 1.0.0-rc.4
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 +35 -454
- package/build/src/define_config.js +63 -38
- package/build/src/drivers/asymmetric.js +7 -0
- package/build/src/errors.d.ts +23 -0
- package/build/src/errors.js +56 -0
- package/build/src/guard.d.ts +8 -1
- package/build/src/guard.js +47 -13
- package/build/src/types.d.ts +2 -1
- package/build/src/validation.d.ts +10 -0
- package/build/src/validation.js +47 -4
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -13,498 +13,79 @@
|
|
|
13
13
|
|
|
14
14
|
> AdonisJS package to authenticate users using JWT tokens.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**📖 Documentation: [maximemrf.github.io/adonisjs-jwt](https://maximemrf.github.io/adonisjs-jwt/)**
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
- Access tokens signed with HMAC, RSA or ECDSA, or verified with JWKS
|
|
19
|
+
- Rotating refresh tokens stored in the database
|
|
20
|
+
- Bearer header or `HttpOnly` cookies
|
|
21
|
+
- Works with the `auth` middleware and Japa's `loginAs`
|
|
22
22
|
|
|
23
|
-
##
|
|
23
|
+
## Compatibility
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
| Package version | AdonisJS version | Node.js |
|
|
26
|
+
| --------------- | ---------------- | ----------- |
|
|
27
|
+
| `0.7.x` | AdonisJS v6 | `>= 20.6.0` |
|
|
28
|
+
| `>= 0.8.x` | AdonisJS v7 | `>= 24.0.0` |
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
- **Default expiration**: `tokenExpiresIn` defaults to `1h`. A `content` function that sets `exp` itself now fails, use `tokenExpiresIn` instead.
|
|
29
|
-
- **JWKS**: `issuer` is required, along with `audience` or `verifyPayload`. The guard throws at startup if they are missing. See [JWKS](#jwks).
|
|
30
|
-
- **Refresh token in the query string**: it is ignored, send it in the body, a cookie or the `Authorization` header.
|
|
31
|
-
- **Refresh token body field**: the field follows `refreshTokenName`. If you set a custom `refreshTokenName` and your clients send the token in a `refreshToken` body field, rename that field to match `refreshTokenName`.
|
|
32
|
-
- **Refresh token lookup order**: when `useCookiesForRefreshToken` is enabled, the cookie is now read before the request body. See [Refresh token transport](#refresh-token-transport).
|
|
33
|
-
- **Access token lookup order**: the `Authorization` header is now read before the cookie, and the cookie is only read when `useCookies` is enabled. If you set the `token` cookie yourself without `useCookies`, enable `useCookies`.
|
|
34
|
-
- **`JwtGuardOptions.expiresIn`** is renamed to `tokenExpiresIn`, like the `jwtGuard()` option. This only affects code that instantiates `JwtGuard` directly.
|
|
30
|
+
Upgrading from 0.9? Read the [1.0 upgrade guide](https://maximemrf.github.io/adonisjs-jwt/upgrading/1.0).
|
|
35
31
|
|
|
36
|
-
|
|
32
|
+
## Installation
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
You have to install the auth package from AdonisJS
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
node ace add @adonisjs/auth
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Setup (AdonisJS v7)
|
|
47
|
-
|
|
48
|
-
Install the package:
|
|
49
|
-
|
|
50
|
-
```bash
|
|
34
|
+
```sh
|
|
35
|
+
node ace add @adonisjs/auth # if not installed yet
|
|
51
36
|
npm i @maximemrf/adonisjs-jwt
|
|
37
|
+
node ace configure @maximemrf/adonisjs-jwt
|
|
52
38
|
```
|
|
53
39
|
|
|
54
|
-
##
|
|
40
|
+
## Quick start
|
|
55
41
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
npm i @maximemrf/adonisjs-jwt@0.7.1
|
|
60
|
-
```
|
|
42
|
+
Register the guard in `config/auth.ts`:
|
|
61
43
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Go to `config/auth.ts` and add the following configuration:
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
44
|
+
```ts
|
|
67
45
|
import { defineConfig } from '@adonisjs/auth'
|
|
68
|
-
import {
|
|
69
|
-
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
|
|
70
|
-
import { tokensUserProvider } from '@adonisjs/auth/access_tokens'
|
|
46
|
+
import { sessionUserProvider } from '@adonisjs/auth/session'
|
|
71
47
|
import { jwtGuard } from '@maximemrf/adonisjs-jwt'
|
|
72
|
-
import env from '#start/env'
|
|
73
48
|
|
|
74
49
|
const authConfig = defineConfig({
|
|
75
|
-
// define the default authenticator to jwt
|
|
76
50
|
default: 'jwt',
|
|
77
51
|
guards: {
|
|
78
|
-
web: sessionGuard({
|
|
79
|
-
useRememberMeTokens: false,
|
|
80
|
-
provider: sessionUserProvider({
|
|
81
|
-
model: () => import('#models/user'),
|
|
82
|
-
}),
|
|
83
|
-
}),
|
|
84
|
-
// add the jwt guard
|
|
85
52
|
jwt: jwtGuard({
|
|
86
|
-
// tokenName is the name of the token passed as cookie, it can be optional, by default it is 'token'
|
|
87
|
-
tokenName: 'custom-name',
|
|
88
|
-
// tokenExpiresIn can be a string or a number, it can be optional, by default it is '1h'
|
|
89
|
-
tokenExpiresIn: '1h',
|
|
90
|
-
// if you want to use cookies for the authentication instead of the bearer token (optional)
|
|
91
|
-
useCookies: true,
|
|
92
|
-
// secret is the secret used to sign the token, it can be optional, by default a key derived from the application key is used
|
|
93
|
-
// you can use a env variable like JWT_SECRET or set it directly with a string
|
|
94
|
-
// if you don't have specific needs, please discard this option
|
|
95
|
-
secret: env.get('JWT_SECRET'),
|
|
96
53
|
provider: sessionUserProvider({
|
|
97
54
|
model: () => import('#models/user'),
|
|
98
55
|
}),
|
|
99
|
-
// if you want to use refresh tokens, you have to set the refreshTokenUserProvider
|
|
100
|
-
refreshTokenUserProvider: tokensUserProvider({
|
|
101
|
-
tokens: 'refreshTokens',
|
|
102
|
-
model: () => import('#models/user'),
|
|
103
|
-
}),
|
|
104
|
-
// optionally set the expiry for the refresh token
|
|
105
|
-
refreshTokenExpiresIn: '7d',
|
|
106
|
-
// ability to separate cookie usage for refresh token
|
|
107
|
-
useCookiesForRefreshToken: true,
|
|
108
|
-
// ability to configure the cookies options
|
|
109
|
-
cookie: {
|
|
110
|
-
httpOnly: true,
|
|
111
|
-
secure: true,
|
|
112
|
-
},
|
|
113
|
-
// abilities given to refresh tokens, generateWithRefreshToken rejects tokens that don't have all of them
|
|
114
|
-
refreshTokenAbilities: ['refresh_token'],
|
|
115
|
-
// optional issuer (iss) and audience (aud) claims for token verification
|
|
116
|
-
issuer: 'my-app',
|
|
117
|
-
audience: 'my-api',
|
|
118
|
-
// content is a function that takes the user and returns the content of the token, it can be optional, by default it returns only the user id
|
|
119
|
-
// user.getOriginal() is typed after the provider's model, no cast needed
|
|
120
|
-
content: (user) => {
|
|
121
|
-
return {
|
|
122
|
-
userId: user.getId(),
|
|
123
|
-
email: user.getOriginal().email,
|
|
124
|
-
}
|
|
125
|
-
},
|
|
126
56
|
}),
|
|
127
57
|
},
|
|
128
58
|
})
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
`tokenName` is the name of the jwt token passed as a cookie, it can be optional, by default it is `token`.
|
|
132
|
-
`refreshTokenName` is the name of the refresh token cookie and of the request body field the guard reads it from, by default it is `refreshToken`.
|
|
133
|
-
`issuer` and `audience` allow validating the `iss` and `aud` claims on incoming tokens to prevent cross-service token misuse in multi-service architecture.
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
tokenName: 'custom-name'
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
`tokenExpiresIn` is the time before the jwt token expires, it can be a string or a number (in seconds) and it can be optional. It defaults to `1h`, so access tokens always expire. A number must be positive, otherwise the guard throws at startup.
|
|
140
|
-
|
|
141
|
-
```typescript
|
|
142
|
-
// string
|
|
143
|
-
tokenExpiresIn: '1h'
|
|
144
|
-
// number
|
|
145
|
-
tokenExpiresIn: 60 * 60
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
You can also use cookies for the authentication instead of the bearer token by setting `useCookies` to `true`.
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
useCookies: true
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
If you just want to use jwt with the bearer token no need to set `useCookies` to `false` you can just remove it.
|
|
155
|
-
|
|
156
|
-
The guard reads the access token from the `Authorization: Bearer <token>` header first, then from the cookie when `useCookies` is enabled. A stale cookie therefore never shadows a valid bearer token, and the cookie is ignored when `useCookies` is disabled.
|
|
157
|
-
|
|
158
|
-
You can also pass options to the cookies. Note that `maxAge` and `expires` are omitted from the options because they are automatically handled by the package based on the token validity (`tokenExpiresIn` and `refreshTokenExpiresIn`).
|
|
159
|
-
By default, `httpOnly: true` and `secure: true` are enforced for better security, but you can override them using the `cookie` object configuration.
|
|
160
59
|
|
|
161
|
-
|
|
162
|
-
cookie: {
|
|
163
|
-
httpOnly: true, // Default
|
|
164
|
-
secure: true, // Default
|
|
165
|
-
path: '/',
|
|
166
|
-
sameSite: 'lax',
|
|
167
|
-
// maxAge and expires cannot be set here
|
|
168
|
-
}
|
|
60
|
+
export default authConfig
|
|
169
61
|
```
|
|
170
62
|
|
|
171
|
-
|
|
63
|
+
Issue a token and protect routes:
|
|
172
64
|
|
|
173
|
-
|
|
65
|
+
```ts
|
|
66
|
+
import router from '@adonisjs/core/services/router'
|
|
67
|
+
import { middleware } from '#start/kernel'
|
|
68
|
+
import User from '#models/user'
|
|
174
69
|
|
|
175
|
-
Set `privateKey`, `publicKey`, and `algorithm` together on `jwtGuard`. Supported algorithms: `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`.
|
|
176
|
-
|
|
177
|
-
Do not set `secret` for this mode (the guard resolver omits it when asymmetric keys are configured). Asymmetric signing cannot be combined with `jwks`.
|
|
178
|
-
|
|
179
|
-
```typescript
|
|
180
|
-
import env from '#start/env'
|
|
181
|
-
|
|
182
|
-
jwt: jwtGuard({
|
|
183
|
-
tokenExpiresIn: '15m',
|
|
184
|
-
privateKey: env.get('JWT_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
|
185
|
-
publicKey: env.get('JWT_PUBLIC_KEY').replace(/\\n/g, '\n'),
|
|
186
|
-
algorithm: 'RS256',
|
|
187
|
-
content: (user) => ({ userId: user.getId() }),
|
|
188
|
-
provider: sessionUserProvider({
|
|
189
|
-
model: () => import('#models/user'),
|
|
190
|
-
}),
|
|
191
|
-
}),
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
## JWKS
|
|
195
|
-
|
|
196
|
-
You can use JWKS to verify the token by setting the `jwks` option in the guard configuration.
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
// ...
|
|
200
|
-
jwt: jwtGuard({
|
|
201
|
-
// ...
|
|
202
|
-
jwks: {
|
|
203
|
-
jwksUri: 'https://your-auth-server/.well-known/jwks.json',
|
|
204
|
-
// you can pass any options accepted by jwks-rsa package
|
|
205
|
-
},
|
|
206
|
-
// required in JWKS mode
|
|
207
|
-
issuer: 'https://your-auth-server',
|
|
208
|
-
audience: 'my-api',
|
|
209
|
-
}),
|
|
210
|
-
// ...
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
`issuer` is required in JWKS mode, along with `audience` or `verifyPayload` (see [Checking other claims](#check-other-claims)): the guard throws at startup if they are missing.
|
|
214
|
-
|
|
215
|
-
Tokens with an unknown `kid` trigger a JWKS fetch, so the guard applies these jwks-rsa defaults, which you can override in the `jwks` option: `cache: true`, `rateLimit: true`, `jwksRequestsPerMinute: 10` and `timeout: 5000` (ms).
|
|
216
|
-
|
|
217
|
-
> [!WARNING]
|
|
218
|
-
> If you enable JWKS, you cannot use the `auth.use('jwt').generate(user)` and `auth.use('jwt').generateWithRefreshToken()` method because the token is signed by an external provider. You can only use the `authenticate` (or `check` / `getUserOrFail`) method to verify the token.
|
|
219
|
-
|
|
220
|
-
Tokens issued by an external provider usually don't carry a `userId` claim. Use `getUserId` to tell the guard where to find the user id, and `algorithms` to restrict the accepted signing algorithms (default: every asymmetric algorithm supported in JWKS mode; symmetric `HS*` algorithms are never accepted):
|
|
221
|
-
|
|
222
|
-
```typescript
|
|
223
|
-
jwt: jwtGuard({
|
|
224
|
-
// ...
|
|
225
|
-
jwks: {
|
|
226
|
-
jwksUri: 'https://your-auth-server/.well-known/jwks.json',
|
|
227
|
-
},
|
|
228
|
-
issuer: 'https://your-auth-server',
|
|
229
|
-
audience: 'my-api',
|
|
230
|
-
algorithms: ['RS256'],
|
|
231
|
-
// seconds of clock skew tolerated on `exp` / `nbf` (optional, works in every mode)
|
|
232
|
-
clockTolerance: 30,
|
|
233
|
-
// default: (payload) => payload.userId
|
|
234
|
-
getUserId: (payload) => payload.sub,
|
|
235
|
-
}),
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
> [!WARNING]
|
|
239
|
-
> `issuer` and `audience` (or `verifyPayload`) are required in JWKS mode. Without them, **any** token signed by the identity provider would be accepted, including tokens issued for other applications. With Kubernetes, that means every pod's default ServiceAccount token.
|
|
240
|
-
|
|
241
|
-
### Check other claims
|
|
242
|
-
|
|
243
|
-
Some providers don't put your application in the `aud` claim. Amazon Cognito access tokens, for example, carry the app client in `client_id` and have no `aud`. Use `verifyPayload` to check those claims: it receives the verified payload before the user lookup, and the request is rejected when it returns `false`. In JWKS mode, it can replace `audience`:
|
|
244
|
-
|
|
245
|
-
```typescript
|
|
246
|
-
jwt: jwtGuard({
|
|
247
|
-
// ...
|
|
248
|
-
jwks: {
|
|
249
|
-
jwksUri: 'https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/jwks.json',
|
|
250
|
-
},
|
|
251
|
-
issuer: 'https://cognito-idp.<region>.amazonaws.com/<userPoolId>',
|
|
252
|
-
verifyPayload: (payload) => payload.client_id === '<appClientId>' && payload.token_use === 'access',
|
|
253
|
-
getUserId: (payload) => payload.sub,
|
|
254
|
-
}),
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
`verifyPayload` works in every mode and can be async.
|
|
258
|
-
|
|
259
|
-
### Example: Kubernetes ServiceAccount tokens
|
|
260
|
-
|
|
261
|
-
The Kubernetes API server exposes its signing keys at `https://kubernetes.default.svc/openid/v1/jwks`. From inside the cluster, fetching them requires the cluster CA and a ServiceAccount token, which you can provide with a custom jwks-rsa `fetcher` (the token is re-read on each fetch because projected tokens are rotated):
|
|
262
|
-
|
|
263
|
-
```typescript
|
|
264
|
-
import { readFileSync } from 'node:fs'
|
|
265
|
-
import https from 'node:https'
|
|
266
|
-
|
|
267
|
-
const SA_DIR = '/var/run/secrets/kubernetes.io/serviceaccount'
|
|
268
|
-
|
|
269
|
-
jwt: jwtGuard({
|
|
270
|
-
// findById receives the token `sub`, e.g. "system:serviceaccount:my-namespace:my-sa"
|
|
271
|
-
provider: new ServiceAccountUserProvider(),
|
|
272
|
-
jwks: {
|
|
273
|
-
jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks',
|
|
274
|
-
cache: true,
|
|
275
|
-
fetcher: (uri) =>
|
|
276
|
-
new Promise((resolve, reject) => {
|
|
277
|
-
https
|
|
278
|
-
.get(
|
|
279
|
-
uri,
|
|
280
|
-
{
|
|
281
|
-
ca: readFileSync(`${SA_DIR}/ca.crt`),
|
|
282
|
-
headers: { Authorization: `Bearer ${readFileSync(`${SA_DIR}/token`, 'utf8')}` },
|
|
283
|
-
},
|
|
284
|
-
(res) => {
|
|
285
|
-
let body = ''
|
|
286
|
-
res.on('data', (chunk) => (body += chunk))
|
|
287
|
-
res.on('end', () => {
|
|
288
|
-
if (res.statusCode !== 200) {
|
|
289
|
-
return reject(new Error(`JWKS fetch failed with status ${res.statusCode}`))
|
|
290
|
-
}
|
|
291
|
-
resolve(JSON.parse(body))
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
)
|
|
295
|
-
.on('error', reject)
|
|
296
|
-
}),
|
|
297
|
-
},
|
|
298
|
-
// must match the `audiences` of the projected token mounted in the calling pods
|
|
299
|
-
audience: 'my-api',
|
|
300
|
-
// default issuer on kubeadm-based clusters; managed clusters use their own URL (see below)
|
|
301
|
-
issuer: 'https://kubernetes.default.svc.cluster.local',
|
|
302
|
-
getUserId: (payload) => payload.sub,
|
|
303
|
-
}),
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
The issuer depends on your cluster: `https://kubernetes.default.svc.cluster.local` is the kubeadm default, while managed clusters use their own URL (e.g. `https://oidc.eks.<region>.amazonaws.com/id/<id>` on EKS, `https://container.googleapis.com/v1/projects/<project>/locations/<location>/clusters/<cluster>` on GKE). Check yours with:
|
|
307
|
-
|
|
308
|
-
```bash
|
|
309
|
-
kubectl get --raw /.well-known/openid-configuration
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
Legacy Secret-based ServiceAccount tokens (issuer `kubernetes/serviceaccount`, no expiration) are rejected by the `issuer` check.
|
|
313
|
-
|
|
314
|
-
The calling pod mounts a projected token with that audience and sends it as a Bearer token:
|
|
315
|
-
|
|
316
|
-
```yaml
|
|
317
|
-
spec:
|
|
318
|
-
containers:
|
|
319
|
-
- name: my-client
|
|
320
|
-
volumeMounts:
|
|
321
|
-
- name: api-token
|
|
322
|
-
mountPath: /var/run/secrets/my-api
|
|
323
|
-
readOnly: true
|
|
324
|
-
volumes:
|
|
325
|
-
- name: api-token
|
|
326
|
-
projected:
|
|
327
|
-
sources:
|
|
328
|
-
- serviceAccountToken:
|
|
329
|
-
path: token
|
|
330
|
-
audience: my-api
|
|
331
|
-
expirationSeconds: 3600
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
The kubelet rotates this token before it expires, so the client must re-read `/var/run/secrets/my-api/token` on each request (or at least regularly) instead of caching it at startup.
|
|
335
|
-
|
|
336
|
-
> [!NOTE]
|
|
337
|
-
> Tokens are verified offline against the JWKS, not through the Kubernetes `TokenReview` API. A token stays valid until its `exp` even if the pod it was issued for has been deleted (up to `expirationSeconds`, minimum 600). Keep `expirationSeconds` short, or call `TokenReview` from your user provider if you need immediate revocation.
|
|
338
|
-
|
|
339
|
-
## Refresh Tokens
|
|
340
|
-
|
|
341
|
-
To use refresh tokens, you have to set the `refreshTokenUserProvider` in the guard configuration, see the example above.
|
|
342
|
-
|
|
343
|
-
Create a new AdonisJS migration file and run it to create the `jwt_refresh_tokens` table:
|
|
344
|
-
|
|
345
|
-
```typescript
|
|
346
|
-
import { BaseSchema } from '@adonisjs/lucid/schema'
|
|
347
|
-
|
|
348
|
-
export default class extends BaseSchema {
|
|
349
|
-
protected tableName = 'jwt_refresh_tokens'
|
|
350
|
-
|
|
351
|
-
async up() {
|
|
352
|
-
this.schema.createTable(this.tableName, (table) => {
|
|
353
|
-
table.increments()
|
|
354
|
-
table
|
|
355
|
-
.integer('tokenable_id')
|
|
356
|
-
.notNullable()
|
|
357
|
-
.unsigned()
|
|
358
|
-
.references('id')
|
|
359
|
-
.inTable('users')
|
|
360
|
-
.onDelete('CASCADE')
|
|
361
|
-
table.string('type').notNullable()
|
|
362
|
-
table.string('name').nullable()
|
|
363
|
-
table.string('hash', 80).notNullable()
|
|
364
|
-
table.text('abilities').notNullable()
|
|
365
|
-
table.timestamp('created_at', { precision: 6, useTz: true }).notNullable()
|
|
366
|
-
table.timestamp('updated_at', { precision: 6, useTz: true }).notNullable()
|
|
367
|
-
table.timestamp('expires_at', { precision: 6, useTz: true }).nullable()
|
|
368
|
-
table.timestamp('last_used_at', { precision: 6, useTz: true }).nullable()
|
|
369
|
-
})
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async down() {
|
|
373
|
-
this.schema.dropTable(this.tableName)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
And add the `refreshTokens` property to your User model:
|
|
379
|
-
|
|
380
|
-
```typescript
|
|
381
|
-
import { column, BaseModel } from '@adonisjs/lucid/orm'
|
|
382
|
-
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
|
383
|
-
|
|
384
|
-
export default class User extends BaseModel {
|
|
385
|
-
@column({ isPrimary: true })
|
|
386
|
-
declare id: number
|
|
387
|
-
|
|
388
|
-
@column()
|
|
389
|
-
declare username: string
|
|
390
|
-
|
|
391
|
-
@column()
|
|
392
|
-
declare email: string
|
|
393
|
-
|
|
394
|
-
@column()
|
|
395
|
-
declare password: string
|
|
396
|
-
|
|
397
|
-
static refreshTokens = DbAccessTokensProvider.forModel(User, {
|
|
398
|
-
prefix: 'rt_',
|
|
399
|
-
table: 'jwt_refresh_tokens',
|
|
400
|
-
type: 'jwt_refresh_token',
|
|
401
|
-
tokenSecretLength: 40,
|
|
402
|
-
})
|
|
403
|
-
}
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
## Authentication
|
|
407
|
-
|
|
408
|
-
To make a protected route, you have to use the `auth` middleware with the `jwt` guard.
|
|
409
|
-
|
|
410
|
-
```typescript
|
|
411
70
|
router.post('login', async ({ request, auth }) => {
|
|
412
|
-
const { email, password } = request.
|
|
71
|
+
const { email, password } = request.only(['email', 'password'])
|
|
413
72
|
const user = await User.verifyCredentials(email, password)
|
|
414
|
-
|
|
415
|
-
// to generate a token (and refresh token if configured)
|
|
416
|
-
// this returns { type, token, expiresIn, refreshToken, refreshTokenExpiresIn }
|
|
417
|
-
// if useCookies is true, it sets cookies on the response instead
|
|
418
73
|
return await auth.use('jwt').generate(user)
|
|
419
74
|
})
|
|
420
75
|
|
|
421
|
-
|
|
422
|
-
router
|
|
423
|
-
.get('/', async ({ auth }) => {
|
|
424
|
-
return auth.getUserOrFail()
|
|
425
|
-
})
|
|
426
|
-
.use(middleware.auth())
|
|
427
|
-
|
|
428
|
-
// if the jwt guard is not the default guard
|
|
429
|
-
router
|
|
430
|
-
.get('/', async ({ auth }) => {
|
|
431
|
-
return auth.use('jwt').getUserOrFail()
|
|
432
|
-
})
|
|
433
|
-
.use(middleware.auth({ guards: ['jwt'] }))
|
|
434
|
-
|
|
435
|
-
// if you use the refresh token
|
|
436
|
-
router.post('jwt/refresh', async ({ auth }) => {
|
|
437
|
-
// this will authenticate the user using the refresh token
|
|
438
|
-
// it will delete the old refresh token and generate a new one
|
|
439
|
-
// it accepts an optional refresh token, otherwise it looks in:
|
|
440
|
-
// 1. cookies (if useCookiesForRefreshToken is enabled)
|
|
441
|
-
// 2. request body, field named after refreshTokenName (never the query string)
|
|
442
|
-
// 3. Authorization header
|
|
443
|
-
return await auth.use('jwt').generateWithRefreshToken()
|
|
444
|
-
})
|
|
445
|
-
|
|
446
|
-
// to logout (revoke refresh token and clear the guard cookies)
|
|
447
|
-
router.post('logout', async ({ auth }) => {
|
|
448
|
-
await auth.use('jwt').revoke()
|
|
449
|
-
return { message: 'Logged out' }
|
|
450
|
-
})
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
After `authenticate()` or `generateWithRefreshToken()`, the access token of the request is available as `user.currentToken`, like `user.currentAccessToken` with the AdonisJS access tokens guard. After a refresh, it holds the new access token.
|
|
454
|
-
|
|
455
|
-
## Security
|
|
456
|
-
|
|
457
|
-
When no `secret` is configured, the guard signs tokens with a key derived from the AdonisJS application key (HKDF-SHA256, salted with the guard name), so you don't have to manage a secret and [avoid this](https://trufflesecurity.com/blog/stop-recommending-jwts). The raw application key is never used to sign JWTs, so tokens signed elsewhere with the application key (e.g. password reset links) are not accepted as access tokens, and two JWT guards never accept each other's tokens. The application key must be at least 32 characters.
|
|
458
|
-
|
|
459
|
-
> [!NOTE]
|
|
460
|
-
> Tokens issued before this key derivation was introduced (signed with the raw application key) are no longer accepted: users have to log in again, or use their refresh token, which is stored in the database and stays valid.
|
|
461
|
-
|
|
462
|
-
### Refresh token transport
|
|
463
|
-
|
|
464
|
-
The guard reads the refresh token from these sources, in order:
|
|
465
|
-
|
|
466
|
-
1. An `HttpOnly` cookie named after `refreshTokenName`, when `useCookiesForRefreshToken` is enabled.
|
|
467
|
-
2. The request body, in a field named after `refreshTokenName` (`refreshToken` by default). This is how OAuth 2.0 transmits refresh tokens ([RFC 6749 §6](https://datatracker.ietf.org/doc/html/rfc6749#section-6)).
|
|
468
|
-
3. The `Authorization: Bearer <token>` header.
|
|
469
|
-
|
|
470
|
-
A refresh token passed in the query string is ignored, since URLs end up in access logs, browser history and `Referer` headers.
|
|
471
|
-
|
|
472
|
-
> [!IMPORTANT]
|
|
473
|
-
> **Breaking change in 1.0**: the body field used to be `refreshToken` whatever `refreshTokenName` was set to, and the body was read before the cookie. If you set a custom `refreshTokenName`, your clients must now send the token in a body field with that name.
|
|
474
|
-
|
|
475
|
-
Which transport to pick:
|
|
476
|
-
|
|
477
|
-
- **Browsers**: use cookies (`useCookiesForRefreshToken: true`). The token is stored in an `HttpOnly` + `Secure` cookie, out of reach of JavaScript and scoped by the `SameSite` policy.
|
|
478
|
-
- **Mobile apps, CLIs and other API clients**: send the token in the request body or the `Authorization` header.
|
|
479
|
-
|
|
480
|
-
> [!WARNING]
|
|
481
|
-
> Make sure your logging middleware does not record request bodies or `Authorization` headers on authentication routes, otherwise refresh tokens end up in plain text in your logs.
|
|
482
|
-
|
|
483
|
-
### Symmetric secret strength
|
|
484
|
-
|
|
485
|
-
When using symmetric signing (HMAC), the `secret` must be **at least 32 characters** long. A shorter secret can be brute-forced offline once an attacker obtains a signed token.
|
|
486
|
-
|
|
76
|
+
router.get('me', async ({ auth }) => auth.getUserOrFail()).use(middleware.auth())
|
|
487
77
|
```
|
|
488
|
-
// Too short — will throw at startup
|
|
489
|
-
secret: 'mysecret'
|
|
490
78
|
|
|
491
|
-
|
|
492
|
-
secret: env.get('JWT_SECRET') // generate with: openssl rand -hex 32
|
|
493
|
-
```
|
|
79
|
+
Then see the [documentation](https://maximemrf.github.io/adonisjs-jwt/) for refresh tokens, cookies, asymmetric keys, JWKS and security.
|
|
494
80
|
|
|
495
|
-
|
|
81
|
+
## Contributing
|
|
496
82
|
|
|
497
|
-
```
|
|
498
|
-
|
|
83
|
+
```sh
|
|
84
|
+
npm install
|
|
85
|
+
npm test # lint, tests and coverage
|
|
86
|
+
npm run docs:dev # documentation, on http://localhost:5173/adonisjs-jwt/
|
|
499
87
|
```
|
|
500
88
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
JWT access tokens are **stateless** — once issued, they are cryptographically validated without querying the database until their expiration time (`tokenExpiresIn`).
|
|
504
|
-
|
|
505
|
-
Calling `auth.use('jwt').revoke()` invalidates the **refresh token** stored in the database, preventing attackers from generating *new* access tokens, and clears the access and refresh token cookies set by the guard (when `useCookies` / `useCookiesForRefreshToken` are enabled). However, any existing, unexpired access token will remain valid until `tokenExpiresIn` elapses.
|
|
506
|
-
|
|
507
|
-
> [!TIP]
|
|
508
|
-
> Keep `tokenExpiresIn` short (e.g. `15m` or `1h`) to limit the lifetime of issued access tokens, and rely on `generateWithRefreshToken()` to silently rotate tokens.
|
|
509
|
-
|
|
89
|
+
## License
|
|
510
90
|
|
|
91
|
+
MIT
|
|
@@ -1,56 +1,81 @@
|
|
|
1
1
|
import { JwtGuard } from './guard.js';
|
|
2
|
-
import {
|
|
2
|
+
import { validateLifetimes, validateSigningMethod } from './validation.js';
|
|
3
3
|
import { resolveDriver } from './driver_resolver.js';
|
|
4
4
|
import { hkdfSync } from 'node:crypto';
|
|
5
5
|
export function jwtGuard(config) {
|
|
6
|
+
const asymmetricKeys = {
|
|
7
|
+
privateKey: config.privateKey,
|
|
8
|
+
publicKey: config.publicKey,
|
|
9
|
+
algorithm: config.algorithm,
|
|
10
|
+
};
|
|
11
|
+
const usesAsymmetric = Object.values(asymmetricKeys).some((value) => value !== undefined);
|
|
12
|
+
/**
|
|
13
|
+
* Only fall back on the app key when no other signing method is configured
|
|
14
|
+
*/
|
|
15
|
+
const usesAppKey = config.secret === undefined && !usesAsymmetric && !config.jwks;
|
|
16
|
+
const options = {
|
|
17
|
+
...(usesAsymmetric ? asymmetricKeys : { secret: config.secret }),
|
|
18
|
+
refreshTokenUserProvider: config.refreshTokenUserProvider,
|
|
19
|
+
tokenName: config.tokenName,
|
|
20
|
+
refreshTokenName: config.refreshTokenName,
|
|
21
|
+
tokenExpiresIn: config.tokenExpiresIn,
|
|
22
|
+
refreshTokenExpiresIn: config.refreshTokenExpiresIn,
|
|
23
|
+
useCookies: config.useCookies,
|
|
24
|
+
useCookiesForRefreshToken: config.useCookiesForRefreshToken,
|
|
25
|
+
refreshTokenAbilities: config.refreshTokenAbilities,
|
|
26
|
+
content: config.content,
|
|
27
|
+
jwks: config.jwks,
|
|
28
|
+
cookie: config.cookie,
|
|
29
|
+
issuer: config.issuer,
|
|
30
|
+
audience: config.audience,
|
|
31
|
+
algorithms: config.algorithms,
|
|
32
|
+
clockTolerance: config.clockTolerance,
|
|
33
|
+
getUserId: config.getUserId,
|
|
34
|
+
verifyPayload: config.verifyPayload,
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Validate the configuration when config/auth.ts is loaded, so a bad
|
|
38
|
+
* configuration stops the app from booting. The resolver only runs on
|
|
39
|
+
* the first request, when the app would already be serving traffic.
|
|
40
|
+
*/
|
|
41
|
+
validateLifetimes(options, PREFIX);
|
|
42
|
+
let driver;
|
|
43
|
+
if (usesAppKey) {
|
|
44
|
+
/**
|
|
45
|
+
* The derived key depends on the guard name, only known in the resolver,
|
|
46
|
+
* but the app key itself can already be checked when the env has it
|
|
47
|
+
*/
|
|
48
|
+
if (process.env.APP_KEY !== undefined)
|
|
49
|
+
assertAppKey(process.env.APP_KEY);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
validateSigningMethod(options, PREFIX);
|
|
53
|
+
driver = resolveDriver(options, PREFIX);
|
|
54
|
+
}
|
|
6
55
|
return {
|
|
7
56
|
async resolver(name, app) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Only fall back on the app key when no other signing method is configured
|
|
16
|
-
*/
|
|
17
|
-
const resolvedSecret = config.secret ??
|
|
18
|
-
(usesAsymmetric || config.jwks ? undefined : deriveSecretFromAppKey(readAppKey(app), name));
|
|
19
|
-
const options = {
|
|
20
|
-
...(usesAsymmetric ? asymmetricKeys : { secret: resolvedSecret }),
|
|
21
|
-
refreshTokenUserProvider: config.refreshTokenUserProvider,
|
|
22
|
-
tokenName: config.tokenName,
|
|
23
|
-
refreshTokenName: config.refreshTokenName,
|
|
24
|
-
tokenExpiresIn: config.tokenExpiresIn,
|
|
25
|
-
refreshTokenExpiresIn: config.refreshTokenExpiresIn,
|
|
26
|
-
useCookies: config.useCookies,
|
|
27
|
-
useCookiesForRefreshToken: config.useCookiesForRefreshToken,
|
|
28
|
-
refreshTokenAbilities: config.refreshTokenAbilities,
|
|
29
|
-
content: config.content,
|
|
30
|
-
jwks: config.jwks,
|
|
31
|
-
cookie: config.cookie,
|
|
32
|
-
issuer: config.issuer,
|
|
33
|
-
audience: config.audience,
|
|
34
|
-
algorithms: config.algorithms,
|
|
35
|
-
clockTolerance: config.clockTolerance,
|
|
36
|
-
getUserId: config.getUserId,
|
|
37
|
-
verifyPayload: config.verifyPayload,
|
|
38
|
-
};
|
|
39
|
-
validateGuardOptions(options, 'JWT guard');
|
|
40
|
-
const driver = resolveDriver(options, 'JWT guard');
|
|
41
|
-
return (ctx) => new JwtGuard(ctx, config.provider, { ...options, driver });
|
|
57
|
+
if (usesAppKey) {
|
|
58
|
+
const secret = deriveSecretFromAppKey(readAppKey(app), name);
|
|
59
|
+
driver = resolveDriver({ ...options, secret }, PREFIX);
|
|
60
|
+
}
|
|
61
|
+
const resolvedDriver = driver;
|
|
62
|
+
return (ctx) => new JwtGuard(ctx, config.provider, { ...options, driver: resolvedDriver });
|
|
42
63
|
},
|
|
43
64
|
};
|
|
44
65
|
}
|
|
66
|
+
const PREFIX = 'JWT guard';
|
|
67
|
+
function assertAppKey(rawAppKey) {
|
|
68
|
+
if (!rawAppKey || rawAppKey.length < 32) {
|
|
69
|
+
throw new Error('JWT guard requires the application key (APP_KEY) to be at least 32 characters when no `secret` is configured');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
45
72
|
/**
|
|
46
73
|
* Derive a dedicated signing key from the app key, so the guard never signs
|
|
47
74
|
* with the raw app key used elsewhere by the application, and two JWT guards
|
|
48
75
|
* never accept each other's tokens.
|
|
49
76
|
*/
|
|
50
77
|
function deriveSecretFromAppKey(rawAppKey, guardName) {
|
|
51
|
-
|
|
52
|
-
throw new Error('JWT guard requires the application key (APP_KEY) to be at least 32 characters when no `secret` is configured');
|
|
53
|
-
}
|
|
78
|
+
assertAppKey(rawAppKey);
|
|
54
79
|
return Buffer.from(hkdfSync('sha256', rawAppKey, '', `@maximemrf/adonisjs-jwt:${guardName}`, 32)).toString('hex');
|
|
55
80
|
}
|
|
56
81
|
/**
|
|
@@ -35,6 +35,13 @@ export class AsymmetricDriver {
|
|
|
35
35
|
if (publicKey.asymmetricKeyType !== expectedKeyType) {
|
|
36
36
|
throw new Error(`publicKey type "${publicKey.asymmetricKeyType}" does not match algorithm "${algorithm}"`);
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* With keys from two different pairs, every token the guard issues
|
|
40
|
+
* would be rejected by the same guard: all users would be locked out
|
|
41
|
+
*/
|
|
42
|
+
if (!createPublicKey(privateKey).equals(publicKey)) {
|
|
43
|
+
throw new Error('publicKey does not belong to the same key pair as privateKey');
|
|
44
|
+
}
|
|
38
45
|
}
|
|
39
46
|
sign(payload, options) {
|
|
40
47
|
const signOptions = {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { errors } from '@adonisjs/auth';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
type UnauthorizedAccess = InstanceType<typeof errors.E_UNAUTHORIZED_ACCESS>;
|
|
4
|
+
type Renderer = (message: string, error: UnauthorizedAccess, ctx: HttpContext) => any;
|
|
5
|
+
/**
|
|
6
|
+
* Renders the 401 raised by the JWT guard, or by the `auth` middleware when
|
|
7
|
+
* the JWT guard was the last one tried.
|
|
8
|
+
*
|
|
9
|
+
* - JSON and JSON:API requests get the same body as the `access_tokens` guard.
|
|
10
|
+
* - HTML requests (pages, Inertia visits) are redirected to the login route
|
|
11
|
+
* when the `auth` middleware passes one (`loginRoute`), like the `session`
|
|
12
|
+
* guard does. Without a login route, they get a plain text 401.
|
|
13
|
+
*/
|
|
14
|
+
export declare const renderJwtUnauthorizedAccess: Renderer;
|
|
15
|
+
/**
|
|
16
|
+
* @adonisjs/auth only ships renderers for its own guards, and the `auth`
|
|
17
|
+
* middleware re-throws its own `E_UNAUTHORIZED_ACCESS` with
|
|
18
|
+
* `guardDriverName: 'jwt'`, so a subclass would never reach the exception
|
|
19
|
+
* handler. Register the `jwt` renderer on every instance instead, unless
|
|
20
|
+
* the application already registered its own.
|
|
21
|
+
*/
|
|
22
|
+
export declare function installJwtUnauthorizedRenderer(): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { errors } from '@adonisjs/auth';
|
|
2
|
+
/**
|
|
3
|
+
* Renders the 401 raised by the JWT guard, or by the `auth` middleware when
|
|
4
|
+
* the JWT guard was the last one tried.
|
|
5
|
+
*
|
|
6
|
+
* - JSON and JSON:API requests get the same body as the `access_tokens` guard.
|
|
7
|
+
* - HTML requests (pages, Inertia visits) are redirected to the login route
|
|
8
|
+
* when the `auth` middleware passes one (`loginRoute`), like the `session`
|
|
9
|
+
* guard does. Without a login route, they get a plain text 401.
|
|
10
|
+
*/
|
|
11
|
+
export const renderJwtUnauthorizedAccess = (message, error, ctx) => {
|
|
12
|
+
switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) {
|
|
13
|
+
case 'html':
|
|
14
|
+
case null:
|
|
15
|
+
if (error.redirectTo) {
|
|
16
|
+
/**
|
|
17
|
+
* `withIntendedUrl` is added by @adonisjs/session, which is optional
|
|
18
|
+
*/
|
|
19
|
+
const redirect = ctx.response.redirect();
|
|
20
|
+
if (typeof redirect.withIntendedUrl === 'function')
|
|
21
|
+
redirect.withIntendedUrl();
|
|
22
|
+
redirect.withQs().toPath(error.redirectTo);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ctx.response.status(error.status).send(message);
|
|
26
|
+
return;
|
|
27
|
+
case 'json':
|
|
28
|
+
ctx.response.status(error.status).send({ errors: [{ message }] });
|
|
29
|
+
return;
|
|
30
|
+
case 'application/vnd.api+json':
|
|
31
|
+
ctx.response.status(error.status).send({ errors: [{ code: error.code, title: message }] });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const INSTALLED = Symbol.for('@maximemrf/adonisjs-jwt/unauthorized-renderer');
|
|
36
|
+
/**
|
|
37
|
+
* @adonisjs/auth only ships renderers for its own guards, and the `auth`
|
|
38
|
+
* middleware re-throws its own `E_UNAUTHORIZED_ACCESS` with
|
|
39
|
+
* `guardDriverName: 'jwt'`, so a subclass would never reach the exception
|
|
40
|
+
* handler. Register the `jwt` renderer on every instance instead, unless
|
|
41
|
+
* the application already registered its own.
|
|
42
|
+
*/
|
|
43
|
+
export function installJwtUnauthorizedRenderer() {
|
|
44
|
+
const prototype = errors.E_UNAUTHORIZED_ACCESS.prototype;
|
|
45
|
+
if (prototype[INSTALLED])
|
|
46
|
+
return;
|
|
47
|
+
const handle = prototype.handle;
|
|
48
|
+
prototype.handle = function (error, ctx) {
|
|
49
|
+
const renderers = this.renderers;
|
|
50
|
+
if (this.guardDriverName === 'jwt' && !renderers.jwt) {
|
|
51
|
+
renderers.jwt = renderJwtUnauthorizedAccess;
|
|
52
|
+
}
|
|
53
|
+
return handle.call(this, error, ctx);
|
|
54
|
+
};
|
|
55
|
+
prototype[INSTALLED] = true;
|
|
56
|
+
}
|
package/build/src/guard.d.ts
CHANGED
|
@@ -44,7 +44,14 @@ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unkno
|
|
|
44
44
|
}>;
|
|
45
45
|
generateWithRefreshToken(refreshToken?: string): Promise<JwtGenerateResult<true>>;
|
|
46
46
|
/**
|
|
47
|
-
* Revoke the refresh token
|
|
47
|
+
* Revoke the refresh token passed as argument, or else the one issued
|
|
48
|
+
* during this request, or else the one sent by the client (cookie, body
|
|
49
|
+
* or Authorization header).
|
|
50
|
+
*
|
|
51
|
+
* The cookies are always cleared. When no valid refresh token is found,
|
|
52
|
+
* nothing is revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client
|
|
53
|
+
* that sends the wrong token (e.g. its access token) does not believe it
|
|
54
|
+
* has logged out while its refresh token stays valid.
|
|
48
55
|
*/
|
|
49
56
|
revoke(refreshToken?: string): Promise<void>;
|
|
50
57
|
/**
|
package/build/src/guard.js
CHANGED
|
@@ -3,6 +3,8 @@ import { DEFAULT_TOKEN_EXPIRES_IN, } from './types.js';
|
|
|
3
3
|
import { Secret } from '@adonisjs/core/helpers';
|
|
4
4
|
import { validateGuardOptions } from './validation.js';
|
|
5
5
|
import { resolveDriver } from './driver_resolver.js';
|
|
6
|
+
import { installJwtUnauthorizedRenderer } from './errors.js';
|
|
7
|
+
installJwtUnauthorizedRenderer();
|
|
6
8
|
export class JwtGuard {
|
|
7
9
|
#ctx;
|
|
8
10
|
#userProvider;
|
|
@@ -12,6 +14,12 @@ export class JwtGuard {
|
|
|
12
14
|
#refreshTokenName = 'refreshToken';
|
|
13
15
|
#driver;
|
|
14
16
|
#cookieOptions;
|
|
17
|
+
/**
|
|
18
|
+
* Refresh token issued during the current request, by `generate()` or a
|
|
19
|
+
* refresh. A logout in the same request revokes it: the request still
|
|
20
|
+
* carries the previous one, which the refresh has already deleted.
|
|
21
|
+
*/
|
|
22
|
+
#issuedRefreshToken;
|
|
15
23
|
constructor(ctx, userProvider, option) {
|
|
16
24
|
this.#ctx = ctx;
|
|
17
25
|
this.#userProvider = userProvider;
|
|
@@ -98,6 +106,7 @@ export class JwtGuard {
|
|
|
98
106
|
}
|
|
99
107
|
: undefined);
|
|
100
108
|
refreshToken = generatedRefreshToken.value.release();
|
|
109
|
+
this.#issuedRefreshToken = refreshToken;
|
|
101
110
|
}
|
|
102
111
|
if (this.#options.useCookies) {
|
|
103
112
|
this.#ctx.response.cookie(`${this.#tokenName}`, token, {
|
|
@@ -159,10 +168,22 @@ export class JwtGuard {
|
|
|
159
168
|
guardDriverName: this.driverName,
|
|
160
169
|
});
|
|
161
170
|
}
|
|
162
|
-
if (this.#options.verifyPayload
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
if (this.#options.verifyPayload) {
|
|
172
|
+
/**
|
|
173
|
+
* Only an explicit `true` accepts the token: a truthy value such as
|
|
174
|
+
* a claim or an error message must not let it through. A callback
|
|
175
|
+
* that throws rejects the token instead of failing the request.
|
|
176
|
+
*/
|
|
177
|
+
let isValid = false;
|
|
178
|
+
try {
|
|
179
|
+
isValid = await this.#options.verifyPayload(payload);
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
if (isValid !== true) {
|
|
183
|
+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
|
|
184
|
+
guardDriverName: this.driverName,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
166
187
|
}
|
|
167
188
|
const getUserId = this.#options.getUserId ?? ((claims) => claims.userId);
|
|
168
189
|
const userId = getUserId(payload);
|
|
@@ -281,7 +302,14 @@ export class JwtGuard {
|
|
|
281
302
|
});
|
|
282
303
|
}
|
|
283
304
|
/**
|
|
284
|
-
* Revoke the refresh token
|
|
305
|
+
* Revoke the refresh token passed as argument, or else the one issued
|
|
306
|
+
* during this request, or else the one sent by the client (cookie, body
|
|
307
|
+
* or Authorization header).
|
|
308
|
+
*
|
|
309
|
+
* The cookies are always cleared. When no valid refresh token is found,
|
|
310
|
+
* nothing is revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client
|
|
311
|
+
* that sends the wrong token (e.g. its access token) does not believe it
|
|
312
|
+
* has logged out while its refresh token stays valid.
|
|
285
313
|
*/
|
|
286
314
|
async revoke(refreshToken) {
|
|
287
315
|
if (!this.#refreshTokenUserProvider) {
|
|
@@ -290,15 +318,21 @@ export class JwtGuard {
|
|
|
290
318
|
});
|
|
291
319
|
}
|
|
292
320
|
this.#clearCookies();
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
321
|
+
refreshToken = refreshToken ?? this.#issuedRefreshToken ?? (await this.#findRefreshToken());
|
|
322
|
+
/**
|
|
323
|
+
* Only delete a refresh token whose secret is valid. The tokens
|
|
324
|
+
* provider deletes by id alone, so a forged token with a guessed
|
|
325
|
+
* id would otherwise revoke another user's refresh token.
|
|
326
|
+
*/
|
|
327
|
+
const secret = new Secret(refreshToken);
|
|
328
|
+
if (!(await this.#refreshTokenUserProvider.verifyToken(secret)) ||
|
|
329
|
+
!(await this.#refreshTokenUserProvider.invalidateToken(secret))) {
|
|
330
|
+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
|
|
331
|
+
guardDriverName: this.driverName,
|
|
332
|
+
});
|
|
300
333
|
}
|
|
301
|
-
|
|
334
|
+
if (refreshToken === this.#issuedRefreshToken)
|
|
335
|
+
this.#issuedRefreshToken = undefined;
|
|
302
336
|
}
|
|
303
337
|
/**
|
|
304
338
|
* Remove the access and refresh token cookies set by the guard
|
package/build/src/types.d.ts
CHANGED
|
@@ -139,7 +139,8 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
|
|
|
139
139
|
getUserId?: JwtGetUserId;
|
|
140
140
|
/**
|
|
141
141
|
* Extra check on a verified token payload, run before looking up the user.
|
|
142
|
-
*
|
|
142
|
+
* Only an explicit `true` accepts the token: any other value, or an
|
|
143
|
+
* exception, rejects it. Use it to check claims other than
|
|
143
144
|
* `iss` and `aud`, e.g. `client_id` and `token_use` for Amazon Cognito.
|
|
144
145
|
*/
|
|
145
146
|
verifyPayload?: JwtVerifyPayload;
|
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import type { JwtGuardOptions } from './types.js';
|
|
2
2
|
export declare function validateGuardOptions(options: JwtGuardOptions, prefix?: string): void;
|
|
3
|
+
/**
|
|
4
|
+
* Token lifetimes and clock tolerance. A lifetime that is not a positive
|
|
5
|
+
* duration would issue tokens that are already expired (`'-1h'`), or fail
|
|
6
|
+
* on every login (`'abc'`).
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateLifetimes(options: Pick<JwtGuardOptions, 'tokenExpiresIn' | 'refreshTokenExpiresIn' | 'clockTolerance'>, prefix: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Exactly one signing method: a secret, an asymmetric key pair, or a JWKS endpoint
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateSigningMethod(options: JwtGuardOptions, prefix: string): void;
|
package/build/src/validation.js
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
+
import ms from 'ms';
|
|
1
2
|
export function validateGuardOptions(options, prefix = 'JwtGuard') {
|
|
2
|
-
|
|
3
|
-
!(Number.isFinite(options.tokenExpiresIn) && options.tokenExpiresIn > 0)) {
|
|
4
|
-
throw new Error(`${prefix} \`tokenExpiresIn\` must be a positive number of seconds`);
|
|
5
|
-
}
|
|
3
|
+
validateLifetimes(options, prefix);
|
|
6
4
|
if (options.driver) {
|
|
7
5
|
return;
|
|
8
6
|
}
|
|
7
|
+
validateSigningMethod(options, prefix);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Token lifetimes and clock tolerance. A lifetime that is not a positive
|
|
11
|
+
* duration would issue tokens that are already expired (`'-1h'`), or fail
|
|
12
|
+
* on every login (`'abc'`).
|
|
13
|
+
*/
|
|
14
|
+
export function validateLifetimes(options, prefix) {
|
|
15
|
+
assertDuration(options.tokenExpiresIn, 'tokenExpiresIn', prefix);
|
|
16
|
+
assertDuration(options.refreshTokenExpiresIn, 'refreshTokenExpiresIn', prefix);
|
|
17
|
+
if (options.clockTolerance !== undefined &&
|
|
18
|
+
!(Number.isFinite(options.clockTolerance) && options.clockTolerance >= 0)) {
|
|
19
|
+
throw new Error(`${prefix} \`clockTolerance\` must be a number of seconds greater than or equal to 0`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function assertDuration(value, name, prefix) {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'number') {
|
|
27
|
+
if (!(Number.isFinite(value) && value > 0)) {
|
|
28
|
+
throw new Error(`${prefix} \`${name}\` must be a positive number of seconds`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A numeric string such as '3600' (read from an env variable) is parsed as
|
|
34
|
+
* milliseconds by jsonwebtoken, so it is ambiguous: ask for a unit
|
|
35
|
+
*/
|
|
36
|
+
if (typeof value !== 'string' || /^\s*[-+]?\d*\.?\d+\s*$/.test(value)) {
|
|
37
|
+
throw new Error(`${prefix} \`${name}\` must be a number of seconds or a duration with a unit, such as '15m' or '7d' (received ${JSON.stringify(value)})`);
|
|
38
|
+
}
|
|
39
|
+
const milliseconds = ms(value);
|
|
40
|
+
if (milliseconds === undefined || !Number.isFinite(milliseconds) || milliseconds < 1000) {
|
|
41
|
+
throw new Error(`${prefix} \`${name}\` must be a duration of at least 1 second, such as '15m' or '7d' (received ${JSON.stringify(value)})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Exactly one signing method: a secret, an asymmetric key pair, or a JWKS endpoint
|
|
46
|
+
*/
|
|
47
|
+
export function validateSigningMethod(options, prefix) {
|
|
9
48
|
const asymmetricPartial = options.privateKey !== undefined ||
|
|
10
49
|
options.publicKey !== undefined ||
|
|
11
50
|
options.algorithm !== undefined;
|
|
@@ -18,6 +57,10 @@ export function validateGuardOptions(options, prefix = 'JwtGuard') {
|
|
|
18
57
|
if (options.jwks && usesAsymmetric) {
|
|
19
58
|
throw new Error(`${prefix} cannot use \`jwks\` together with asymmetric \`privateKey\` / \`publicKey\``);
|
|
20
59
|
}
|
|
60
|
+
if (options.jwks &&
|
|
61
|
+
!(typeof options.jwks.jwksUri === 'string' && options.jwks.jwksUri.trim() !== '')) {
|
|
62
|
+
throw new Error(`${prefix} \`jwks.jwksUri\` is required to fetch the identity provider keys`);
|
|
63
|
+
}
|
|
21
64
|
if (!options.jwks && !usesAsymmetric && !options.secret) {
|
|
22
65
|
throw new Error(`${prefix} requires \`secret\` (symmetric), or \`privateKey\` + \`publicKey\` + \`algorithm\` (asymmetric), or \`jwks\``);
|
|
23
66
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maximemrf/adonisjs-jwt",
|
|
3
3
|
"description": "AdonisJS package for authentication with JWT tokens",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.4",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.0.0"
|
|
7
7
|
},
|
|
@@ -37,7 +37,10 @@
|
|
|
37
37
|
"postbuild": "npm run copy:templates",
|
|
38
38
|
"release": "np",
|
|
39
39
|
"version": "npm run build",
|
|
40
|
-
"prepublishOnly": "npm run build"
|
|
40
|
+
"prepublishOnly": "npm run build",
|
|
41
|
+
"docs:dev": "vitepress dev docs",
|
|
42
|
+
"docs:build": "vitepress build docs",
|
|
43
|
+
"docs:preview": "vitepress preview docs"
|
|
41
44
|
},
|
|
42
45
|
"devDependencies": {
|
|
43
46
|
"@adonisjs/assembler": "^8.4.0",
|
|
@@ -65,6 +68,7 @@
|
|
|
65
68
|
"sqlite3": "^6.0.1",
|
|
66
69
|
"timekeeper": "^2.3.1",
|
|
67
70
|
"typescript": "~6.0.3",
|
|
71
|
+
"vitepress": "^1.6.4",
|
|
68
72
|
"youch": "^4.1.1"
|
|
69
73
|
},
|
|
70
74
|
"dependencies": {
|
|
@@ -116,5 +120,6 @@
|
|
|
116
120
|
"exclude": [
|
|
117
121
|
"tests/**"
|
|
118
122
|
]
|
|
119
|
-
}
|
|
123
|
+
},
|
|
124
|
+
"homepage": "https://maximemrf.github.io/adonisjs-jwt/"
|
|
120
125
|
}
|