@maximemrf/adonisjs-jwt 1.0.0-rc.1 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,498 +13,79 @@
13
13
 
14
14
  > AdonisJS package to authenticate users using JWT tokens.
15
15
 
16
- ## Compatibility & Versions
16
+ **📖 Documentation: [maximemrf.github.io/adonisjs-jwt](https://maximemrf.github.io/adonisjs-jwt/)**
17
17
 
18
- | Package Version | AdonisJS Version | Node.js Required |
19
- | --------------- | ----------------- | ---------------- |
20
- | `v0.7.x` | `AdonisJS v6` | `>= 20.6.0` |
21
- | **`>= v0.8.x`** | **`AdonisJS v7`** | **`>= 24.0.0`** |
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
- ## Upgrading to 1.0
23
+ ## Compatibility
24
24
 
25
- Version 1.0 contains breaking changes. Check this list before upgrading from `0.9.x`:
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
- - **Signing key**: without a `secret`, tokens are now signed with a key derived from the application key instead of the raw application key, and the application key must be at least 32 characters. Access tokens issued by `0.9.x` are rejected, so users have to log in again or use their refresh token, which stays valid. See [Security](#security).
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
- Imports can now come from the main entrypoint: `import { jwtGuard } from '@maximemrf/adonisjs-jwt'`. The `@maximemrf/adonisjs-jwt/jwt_config` path still works. `@adonisjs/auth` is now a peer dependency.
32
+ ## Installation
37
33
 
38
- ## Prerequisites
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
- ## Setup for AdonisJS v6
40
+ ## Quick start
55
41
 
56
- If you are using AdonisJS v6, you have to install the `v0.7.x` version of the package:
57
-
58
- ```bash
59
- npm i @maximemrf/adonisjs-jwt@0.7.1
60
- ```
42
+ Register the guard in `config/auth.ts`:
61
43
 
62
- ## Usage
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 { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
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
- ```typescript
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
- ## Asymmetric signing (RSA / ECDSA)
63
+ Issue a token and protect routes:
172
64
 
173
- You can sign access tokens with a **private key** and verify them with a **public key** only. Other services can validate JWTs using the public key without receiving your signing secret.
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.all()
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
- // if the jwt guard is the default guard
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
- // Sufficient entropy
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
- Generate a strong secret with:
81
+ ## Contributing
496
82
 
497
- ```bash
498
- openssl rand -hex 32
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
- ### Access token revocation & Statelessness
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
@@ -10,7 +10,7 @@
10
10
  | Flags: --refresh-tokens / --no-refresh-tokens to skip the question.
11
11
  |
12
12
  */
13
- import { existsSync, readdirSync } from 'node:fs';
13
+ import { appendFileSync, existsSync, readdirSync } from 'node:fs';
14
14
  import { stubsRoot } from './stubs/main.js';
15
15
  const DOCS_URL = 'https://maximemrf.github.io/adonisjs-jwt';
16
16
  const MIGRATION_SUFFIX = '_create_jwt_refresh_tokens_table.ts';
@@ -127,11 +127,17 @@ async function createMigration(command, codemods) {
127
127
  .skipped('the migration already exists');
128
128
  return;
129
129
  }
130
- await codemods.makeUsingStub(stubsRoot, 'migration.stub', {
130
+ const output = await codemods.makeUsingStub(stubsRoot, 'migration.stub', {
131
131
  entity: {
132
132
  filename: `${Date.now()}${MIGRATION_SUFFIX}`,
133
133
  },
134
134
  });
135
+ /**
136
+ * The stub renderer trims the final newline, which prettier reports
137
+ */
138
+ if (output.status !== 'skipped' && !output.contents.endsWith('\n')) {
139
+ appendFileSync(output.destination, '\n');
140
+ }
135
141
  }
136
142
  function getSourceFile(project, path) {
137
143
  return project.getSourceFile(path) ?? project.addSourceFileAtPath(path);
@@ -6,9 +6,9 @@ import { JwtGuard } from './guard.js';
6
6
  import type { StringValue } from 'ms';
7
7
  import type { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens';
8
8
  import type { Options } from 'jwks-rsa';
9
- export declare function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>>(config: {
9
+ export declare function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>, RefreshTokenUserProvider extends AccessTokensUserProviderContract<unknown> | undefined = undefined>(config: {
10
10
  provider: UserProvider;
11
- refreshTokenUserProvider?: AccessTokensUserProviderContract<unknown>;
11
+ refreshTokenUserProvider?: RefreshTokenUserProvider;
12
12
  tokenName?: string;
13
13
  refreshTokenName?: string;
14
14
  tokenExpiresIn?: number | StringValue;
@@ -29,4 +29,4 @@ export declare function jwtGuard<UserProvider extends JwtUserProviderContract<un
29
29
  clockTolerance?: number;
30
30
  getUserId?: JwtGetUserId;
31
31
  verifyPayload?: JwtVerifyPayload;
32
- }): GuardConfigProvider<(ctx: HttpContext) => JwtGuard<UserProvider>>;
32
+ }): GuardConfigProvider<(ctx: HttpContext) => JwtGuard<UserProvider, [RefreshTokenUserProvider] extends [undefined] ? false : true>>;
@@ -2,7 +2,7 @@ import { symbols } from '@adonisjs/auth';
2
2
  import type { AuthClientResponse, GuardContract } from '@adonisjs/auth/types';
3
3
  import type { HttpContext } from '@adonisjs/core/http';
4
4
  import { type JwtUserProviderContract, type JwtGuardOptions, type JwtGenerateResult } from './types.js';
5
- export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>> implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
5
+ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>, WithRefreshToken extends boolean = boolean> implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
6
6
  #private;
7
7
  constructor(ctx: HttpContext, userProvider: UserProvider, option: JwtGuardOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]>);
8
8
  /**
@@ -33,7 +33,7 @@ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unkno
33
33
  /**
34
34
  * Generate a JWT token for a given user.
35
35
  */
36
- generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]): Promise<JwtGenerateResult>;
36
+ generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]): Promise<JwtGenerateResult<WithRefreshToken>>;
37
37
  /**
38
38
  * Authenticate the current HTTP request and return
39
39
  * the user instance if there is a valid JWT token
@@ -42,7 +42,7 @@ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unkno
42
42
  authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER] & {
43
43
  currentToken: string;
44
44
  }>;
45
- generateWithRefreshToken(refreshToken?: string): Promise<JwtGenerateResult>;
45
+ generateWithRefreshToken(refreshToken?: string): Promise<JwtGenerateResult<true>>;
46
46
  /**
47
47
  * Revoke the refresh token
48
48
  */
@@ -159,10 +159,22 @@ export class JwtGuard {
159
159
  guardDriverName: this.driverName,
160
160
  });
161
161
  }
162
- if (this.#options.verifyPayload && !(await this.#options.verifyPayload(payload))) {
163
- throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
164
- guardDriverName: this.driverName,
165
- });
162
+ if (this.#options.verifyPayload) {
163
+ /**
164
+ * Only an explicit `true` accepts the token: a truthy value such as
165
+ * a claim or an error message must not let it through. A callback
166
+ * that throws rejects the token instead of failing the request.
167
+ */
168
+ let isValid = false;
169
+ try {
170
+ isValid = await this.#options.verifyPayload(payload);
171
+ }
172
+ catch { }
173
+ if (isValid !== true) {
174
+ throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
175
+ guardDriverName: this.driverName,
176
+ });
177
+ }
166
178
  }
167
179
  const getUserId = this.#options.getUserId ?? ((claims) => claims.userId);
168
180
  const userId = getUserId(payload);
@@ -244,6 +256,9 @@ export class JwtGuard {
244
256
  this.isAuthenticated = true;
245
257
  this.user = user;
246
258
  this.user.currentToken = result.token;
259
+ /**
260
+ * The guard has a refresh token provider, so `generate()` issued a refresh token
261
+ */
247
262
  return result;
248
263
  }
249
264
  async #findRefreshToken() {
@@ -295,7 +310,16 @@ export class JwtGuard {
295
310
  return;
296
311
  }
297
312
  }
298
- await this.#refreshTokenUserProvider.invalidateToken(new Secret(refreshToken));
313
+ /**
314
+ * Only delete a refresh token whose secret is valid. The tokens
315
+ * provider deletes by id alone, so a forged token with a guessed
316
+ * id would otherwise revoke another user's refresh token.
317
+ */
318
+ const secret = new Secret(refreshToken);
319
+ if (!(await this.#refreshTokenUserProvider.verifyToken(secret))) {
320
+ return;
321
+ }
322
+ await this.#refreshTokenUserProvider.invalidateToken(secret);
299
323
  }
300
324
  /**
301
325
  * Remove the access and refresh token cookies set by the guard
@@ -56,13 +56,19 @@ export declare const ALLOWED_ASYMMETRIC_ALGORITHMS: readonly ["RS256", "RS384",
56
56
  export type JwtAsymmetricAlgorithm = (typeof ALLOWED_ASYMMETRIC_ALGORITHMS)[number];
57
57
  export declare const ALLOWED_JWKS_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"];
58
58
  export type JwtJwksAlgorithm = (typeof ALLOWED_JWKS_ALGORITHMS)[number];
59
- export type JwtGenerateResult = {
59
+ /**
60
+ * `refreshToken` is a string when the guard has a `refreshTokenUserProvider`
61
+ */
62
+ export type JwtGenerateResult<WithRefreshToken extends boolean = boolean> = {
60
63
  type: string;
61
64
  token: string;
62
65
  expiresIn?: number | StringValue;
63
- refreshToken?: string;
64
66
  refreshTokenExpiresIn?: number | StringValue;
65
- };
67
+ } & ([WithRefreshToken] extends [true] ? {
68
+ refreshToken: string;
69
+ } : {
70
+ refreshToken?: string;
71
+ });
66
72
  export type JwtGuardOptions<RealUser extends any = unknown> = {
67
73
  /**
68
74
  * Driver resolved by `jwtGuard()`. Not part of the public API: custom
@@ -133,7 +139,8 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
133
139
  getUserId?: JwtGetUserId;
134
140
  /**
135
141
  * Extra check on a verified token payload, run before looking up the user.
136
- * Return `false` to reject the token. Use it to check claims other than
142
+ * Only an explicit `true` accepts the token: any other value, or an
143
+ * exception, rejects it. Use it to check claims other than
137
144
  * `iss` and `aud`, e.g. `client_id` and `token_use` for Amazon Cognito.
138
145
  */
139
146
  verifyPayload?: JwtVerifyPayload;
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.1",
4
+ "version": "1.0.0-rc.3",
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
  }