@maximemrf/adonisjs-jwt 0.9.0 → 1.0.0-rc.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/README.md CHANGED
@@ -15,10 +15,25 @@
15
15
 
16
16
  ## Compatibility & Versions
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
+ | 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`** |
22
+
23
+ ## Upgrading to 1.0
24
+
25
+ Version 1.0 contains breaking changes. Check this list before upgrading from `0.9.x`:
26
+
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.
35
+
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.
22
37
 
23
38
  ## Prerequisites
24
39
 
@@ -53,15 +68,9 @@ import { defineConfig } from '@adonisjs/auth'
53
68
  import { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
54
69
  import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
55
70
  import { tokensUserProvider } from '@adonisjs/auth/access_tokens'
56
- import { jwtGuard } from '@maximemrf/adonisjs-jwt/jwt_config'
57
- import { JwtGuardUser, BaseJwtContent } from '@maximemrf/adonisjs-jwt/types'
58
- import User from '#models/user'
71
+ import { jwtGuard } from '@maximemrf/adonisjs-jwt'
59
72
  import env from '#start/env'
60
73
 
61
- interface JwtContent extends BaseJwtContent {
62
- email: string
63
- }
64
-
65
74
  const authConfig = defineConfig({
66
75
  // define the default authenticator to jwt
67
76
  default: 'jwt',
@@ -76,11 +85,11 @@ const authConfig = defineConfig({
76
85
  jwt: jwtGuard({
77
86
  // tokenName is the name of the token passed as cookie, it can be optional, by default it is 'token'
78
87
  tokenName: 'custom-name',
79
- // tokenExpiresIn can be a string or a number, it can be optional
88
+ // tokenExpiresIn can be a string or a number, it can be optional, by default it is '1h'
80
89
  tokenExpiresIn: '1h',
81
90
  // if you want to use cookies for the authentication instead of the bearer token (optional)
82
91
  useCookies: true,
83
- // secret is the secret used to sign the token, it can be optional, by default it uses the application key
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
84
93
  // you can use a env variable like JWT_SECRET or set it directly with a string
85
94
  // if you don't have specific needs, please discard this option
86
95
  secret: env.get('JWT_SECRET'),
@@ -101,13 +110,17 @@ const authConfig = defineConfig({
101
110
  httpOnly: true,
102
111
  secure: true,
103
112
  },
104
- // limit the abilities of the refresh token
113
+ // abilities given to refresh tokens, generateWithRefreshToken rejects tokens that don't have all of them
105
114
  refreshTokenAbilities: ['refresh_token'],
115
+ // optional issuer (iss) and audience (aud) claims for token verification
116
+ issuer: 'my-app',
117
+ audience: 'my-api',
106
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
107
- content: <T>(user: JwtGuardUser<T>): JwtContent => {
119
+ // user.getOriginal() is typed after the provider's model, no cast needed
120
+ content: (user) => {
108
121
  return {
109
122
  userId: user.getId(),
110
- email: (user.getOriginal() as User).email,
123
+ email: user.getOriginal().email,
111
124
  }
112
125
  },
113
126
  }),
@@ -116,12 +129,14 @@ const authConfig = defineConfig({
116
129
  ```
117
130
 
118
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.
119
134
 
120
135
  ```typescript
121
136
  tokenName: 'custom-name'
122
137
  ```
123
138
 
124
- `tokenExpiresIn` is the time before the jwt token expires it can be a string or a number and it can be optional.
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.
125
140
 
126
141
  ```typescript
127
142
  // string
@@ -138,6 +153,8 @@ useCookies: true
138
153
 
139
154
  If you just want to use jwt with the bearer token no need to set `useCookies` to `false` you can just remove it.
140
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
+
141
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`).
142
159
  By default, `httpOnly: true` and `secure: true` are enforced for better security, but you can override them using the `cookie` object configuration.
143
160
 
@@ -185,16 +202,140 @@ jwt: jwtGuard({
185
202
  jwks: {
186
203
  jwksUri: 'https://your-auth-server/.well-known/jwks.json',
187
204
  // you can pass any options accepted by jwks-rsa package
188
- cache: true,
189
- rateLimit: true,
190
205
  },
206
+ // required in JWKS mode
207
+ issuer: 'https://your-auth-server',
208
+ audience: 'my-api',
191
209
  }),
192
210
  // ...
193
211
  ```
194
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
+
195
217
  > [!WARNING]
196
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.
197
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
+
198
339
  ## Refresh Tokens
199
340
 
200
341
  To use refresh tokens, you have to set the `refreshTokenUserProvider` in the guard configuration, see the example above.
@@ -296,19 +437,74 @@ router.post('jwt/refresh', async ({ auth }) => {
296
437
  // this will authenticate the user using the refresh token
297
438
  // it will delete the old refresh token and generate a new one
298
439
  // it accepts an optional refresh token, otherwise it looks in:
299
- // 1. request body 'refreshToken'
300
- // 2. cookies (if enabled)
440
+ // 1. cookies (if useCookiesForRefreshToken is enabled)
441
+ // 2. request body, field named after refreshTokenName (never the query string)
301
442
  // 3. Authorization header
302
443
  return await auth.use('jwt').generateWithRefreshToken()
303
444
  })
304
445
 
305
- // to logout (revoke refresh token)
446
+ // to logout (revoke refresh token and clear the guard cookies)
306
447
  router.post('logout', async ({ auth }) => {
307
448
  await auth.use('jwt').revoke()
308
449
  return { message: 'Logged out' }
309
450
  })
310
451
  ```
311
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
+
312
455
  ## Security
313
456
 
314
- We use natively the AdonisJS application key to sign the token, so you don't have to worry about it and [avoid this](https://trufflesecurity.com/blog/stop-recommending-jwts).
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
+
487
+ ```
488
+ // Too short — will throw at startup
489
+ secret: 'mysecret'
490
+
491
+ // Sufficient entropy
492
+ secret: env.get('JWT_SECRET') // generate with: openssl rand -hex 32
493
+ ```
494
+
495
+ Generate a strong secret with:
496
+
497
+ ```bash
498
+ openssl rand -hex 32
499
+ ```
500
+
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
+
510
+
@@ -0,0 +1,2 @@
1
+ import type ConfigureCommand from '@adonisjs/core/commands/configure';
2
+ export declare function configure(command: ConfigureCommand): Promise<void>;
@@ -0,0 +1,240 @@
1
+ /*
2
+ |--------------------------------------------------------------------------
3
+ | Configure hook
4
+ |--------------------------------------------------------------------------
5
+ |
6
+ | Called by "node ace configure @maximemrf/adonisjs-jwt" (and "node ace add").
7
+ | Adds the jwt guard to config/auth.ts and, with refresh tokens, the
8
+ | refreshTokens provider to the User model and the migration.
9
+ |
10
+ | Flags: --refresh-tokens / --no-refresh-tokens to skip the question.
11
+ |
12
+ */
13
+ import { existsSync, readdirSync } from 'node:fs';
14
+ import { stubsRoot } from './stubs/main.js';
15
+ const DOCS_URL = 'https://maximemrf.github.io/adonisjs-jwt';
16
+ const MIGRATION_SUFFIX = '_create_jwt_refresh_tokens_table.ts';
17
+ const GUARD = `jwtGuard({
18
+ provider: sessionUserProvider({
19
+ model: () => import('#models/user'),
20
+ }),
21
+ })`;
22
+ const GUARD_WITH_REFRESH_TOKENS = `jwtGuard({
23
+ provider: sessionUserProvider({
24
+ model: () => import('#models/user'),
25
+ }),
26
+ refreshTokenUserProvider: tokensUserProvider({
27
+ tokens: 'refreshTokens',
28
+ model: () => import('#models/user'),
29
+ }),
30
+ refreshTokenExpiresIn: '7d',
31
+ })`;
32
+ const refreshTokensProvider = (modelName) => `DbAccessTokensProvider.forModel(${modelName}, {
33
+ prefix: 'rt_',
34
+ table: 'jwt_refresh_tokens',
35
+ type: 'jwt_refresh_token',
36
+ tokenSecretLength: 40,
37
+ })`;
38
+ export async function configure(command) {
39
+ const authConfigPath = command.app.configPath('auth.ts');
40
+ if (!existsSync(authConfigPath)) {
41
+ command.logger.error('Cannot find "config/auth.ts". Run "node ace add @adonisjs/auth" first, then run this command again');
42
+ command.exitCode = 1;
43
+ return;
44
+ }
45
+ const refreshTokens = await useRefreshTokens(command);
46
+ const codemods = await command.createCodemods();
47
+ const project = await codemods.getTsMorphProject();
48
+ await addGuard(command, project, authConfigPath, refreshTokens);
49
+ if (refreshTokens) {
50
+ await addRefreshTokensToModel(command, project);
51
+ await createMigration(command, codemods);
52
+ }
53
+ command.logger.info('Next steps:');
54
+ if (refreshTokens) {
55
+ command.logger.log(' - Run "node ace migration:run" to create the jwt_refresh_tokens table');
56
+ }
57
+ command.logger.log(` - Set default: 'jwt' in config/auth.ts, or use auth.use('jwt') in your routes`);
58
+ command.logger.log(` - Add the login, refresh and logout routes: ${DOCS_URL}/guide/authentication`);
59
+ }
60
+ /**
61
+ * Read the --refresh-tokens / --no-refresh-tokens flag, or ask
62
+ */
63
+ async function useRefreshTokens(command) {
64
+ const flag = command.parsedFlags['refresh-tokens'];
65
+ if (flag !== undefined) {
66
+ return flag === true || flag === 'true';
67
+ }
68
+ return command.prompt.confirm('Do you want to use refresh tokens?', { default: true });
69
+ }
70
+ /**
71
+ * Add the "jwt" guard to the guards of config/auth.ts
72
+ */
73
+ async function addGuard(command, project, authConfigPath, refreshTokens) {
74
+ const action = command.logger.action('update config/auth.ts');
75
+ const guard = refreshTokens ? GUARD_WITH_REFRESH_TOKENS : GUARD;
76
+ const file = project ? getSourceFile(project, authConfigPath) : undefined;
77
+ const guards = file ? findGuardsObject(file) : undefined;
78
+ if (!file || !guards) {
79
+ action.skipped(`add this guard to config/auth.ts manually: jwt: ${guard}`);
80
+ return;
81
+ }
82
+ if (guards.getProperty('jwt')) {
83
+ action.skipped('a "jwt" guard already exists');
84
+ return;
85
+ }
86
+ addProperty(guards, `jwt: ${guard}`);
87
+ ensureNamedImport(file, '@maximemrf/adonisjs-jwt', 'jwtGuard');
88
+ ensureNamedImport(file, '@adonisjs/auth/session', 'sessionUserProvider');
89
+ if (refreshTokens) {
90
+ ensureNamedImport(file, '@adonisjs/auth/access_tokens', 'tokensUserProvider');
91
+ }
92
+ await file.save();
93
+ action.succeeded();
94
+ }
95
+ /**
96
+ * Add the static refreshTokens property to the User model
97
+ */
98
+ async function addRefreshTokensToModel(command, project) {
99
+ const modelPath = command.app.modelsPath('user.ts');
100
+ const action = command.logger.action('update app/models/user.ts');
101
+ const file = project && existsSync(modelPath) ? getSourceFile(project, modelPath) : undefined;
102
+ const model = file ? findModel(file) : undefined;
103
+ if (!file || !model) {
104
+ action.skipped(`add this property to your User model manually: static refreshTokens = ${refreshTokensProvider('User')}`);
105
+ return;
106
+ }
107
+ if (model.getStaticProperty('refreshTokens')) {
108
+ action.skipped('the model already has a "refreshTokens" property');
109
+ return;
110
+ }
111
+ addClassMember(model, `static refreshTokens = ${refreshTokensProvider(model.getName() ?? 'User')}${semicolon(file)}`);
112
+ ensureNamedImport(file, '@adonisjs/auth/access_tokens', 'DbAccessTokensProvider');
113
+ await file.save();
114
+ action.succeeded();
115
+ }
116
+ /**
117
+ * Create the migration of the jwt_refresh_tokens table, unless it exists
118
+ */
119
+ async function createMigration(command, codemods) {
120
+ const migrationsPath = command.app.migrationsPath();
121
+ const existing = existsSync(migrationsPath)
122
+ ? readdirSync(migrationsPath).find((file) => file.endsWith(MIGRATION_SUFFIX))
123
+ : undefined;
124
+ if (existing) {
125
+ command.logger
126
+ .action(`create database/migrations/${existing}`)
127
+ .skipped('the migration already exists');
128
+ return;
129
+ }
130
+ await codemods.makeUsingStub(stubsRoot, 'migration.stub', {
131
+ entity: {
132
+ filename: `${Date.now()}${MIGRATION_SUFFIX}`,
133
+ },
134
+ });
135
+ }
136
+ function getSourceFile(project, path) {
137
+ return project.getSourceFile(path) ?? project.addSourceFileAtPath(path);
138
+ }
139
+ /**
140
+ * Find the object literal of `defineConfig({ guards: { ... } })`
141
+ */
142
+ function findGuardsObject(file) {
143
+ const call = file
144
+ .getDescendants()
145
+ .find((node) => node.getKindName() === 'CallExpression' &&
146
+ node.getExpression().getText() === 'defineConfig');
147
+ const config = call?.getArguments()[0];
148
+ if (config?.getKindName() !== 'ObjectLiteralExpression') {
149
+ return;
150
+ }
151
+ const guards = config.getProperty('guards');
152
+ if (guards?.getKindName() !== 'PropertyAssignment') {
153
+ return;
154
+ }
155
+ const initializer = guards.getInitializer();
156
+ if (initializer?.getKindName() !== 'ObjectLiteralExpression') {
157
+ return;
158
+ }
159
+ return initializer;
160
+ }
161
+ /**
162
+ * The default exported class of the file, or the class named User
163
+ */
164
+ function findModel(file) {
165
+ const classes = file.getClasses();
166
+ return (classes.find((cls) => cls.isDefaultExport()) ?? classes.find((cls) => cls.getName() === 'User'));
167
+ }
168
+ /**
169
+ * The edits below insert text rather than ts-morph structures, to keep the
170
+ * indentation, trailing commas and semicolons style of the user's files
171
+ */
172
+ function semicolon(file) {
173
+ return file.getStatements()[0]?.getText().trimEnd().endsWith(';') ? ';' : '';
174
+ }
175
+ /**
176
+ * Leading whitespace of the line containing the given position
177
+ */
178
+ function lineIndentation(file, position) {
179
+ const text = file.getFullText();
180
+ const lineStart = text.lastIndexOf('\n', position - 1) + 1;
181
+ return text.slice(lineStart).match(/^[ \t]*/)[0];
182
+ }
183
+ /**
184
+ * Indent every line but the first one
185
+ */
186
+ function indent(code, indentation) {
187
+ return code.replaceAll('\n', `\n${indentation}`);
188
+ }
189
+ /**
190
+ * Add a property after the last property of an object literal
191
+ */
192
+ function addProperty(object, property) {
193
+ const file = object.getSourceFile();
194
+ const last = object.getProperties().at(-1);
195
+ if (!last) {
196
+ const baseIndentation = lineIndentation(file, object.getStart());
197
+ const indentation = `${baseIndentation} `;
198
+ file.replaceText([object.getStart(), object.getEnd()], `{\n${indentation}${indent(property, indentation)},\n${baseIndentation}}`);
199
+ return;
200
+ }
201
+ const indentation = lineIndentation(file, last.getStart());
202
+ const text = file.getFullText();
203
+ const hasTrailingComma = text.slice(last.getEnd()).trimStart().startsWith(',');
204
+ const position = hasTrailingComma ? text.indexOf(',', last.getEnd()) + 1 : last.getEnd();
205
+ file.insertText(position, `${hasTrailingComma ? '' : ','}\n${indentation}${indent(property, indentation)},`);
206
+ }
207
+ /**
208
+ * Add a member at the end of a class body
209
+ */
210
+ function addClassMember(cls, member) {
211
+ const file = cls.getSourceFile();
212
+ const last = cls.getMembers().at(-1);
213
+ if (!last) {
214
+ const closingBrace = cls.getEnd() - 1;
215
+ const openingBrace = file.getFullText().lastIndexOf('{', closingBrace);
216
+ const baseIndentation = lineIndentation(file, cls.getStart());
217
+ const indentation = `${baseIndentation} `;
218
+ file.replaceText([openingBrace, closingBrace + 1], `{\n${indentation}${indent(member, indentation)}\n${baseIndentation}}`);
219
+ return;
220
+ }
221
+ const indentation = lineIndentation(file, last.getStart());
222
+ file.insertText(last.getEnd(), `\n\n${indentation}${indent(member, indentation)}`);
223
+ }
224
+ function ensureNamedImport(file, moduleSpecifier, name) {
225
+ const declaration = file.getImportDeclaration((node) => node.getModuleSpecifierValue() === moduleSpecifier && !node.isTypeOnly());
226
+ if (declaration) {
227
+ if (!declaration.getNamedImports().some((namedImport) => namedImport.getName() === name)) {
228
+ declaration.addNamedImport(name);
229
+ }
230
+ return;
231
+ }
232
+ const statement = `import { ${name} } from '${moduleSpecifier}'${semicolon(file)}`;
233
+ const lastImport = file.getImportDeclarations().at(-1);
234
+ if (lastImport) {
235
+ file.insertText(lastImport.getEnd(), `\n${statement}`);
236
+ }
237
+ else {
238
+ file.insertText(0, `${statement}\n\n`);
239
+ }
240
+ }
package/build/index.d.ts CHANGED
@@ -1 +1,4 @@
1
1
  export { configure } from './configure.js';
2
+ export { jwtGuard } from './src/define_config.js';
3
+ export { JwtGuard } from './src/guard.js';
4
+ export { DEFAULT_TOKEN_EXPIRES_IN } from './src/types.js';
package/build/index.js CHANGED
@@ -7,3 +7,6 @@
7
7
  |
8
8
  */
9
9
  export { configure } from './configure.js';
10
+ export { jwtGuard } from './src/define_config.js';
11
+ export { JwtGuard } from './src/guard.js';
12
+ export { DEFAULT_TOKEN_EXPIRES_IN } from './src/types.js';
@@ -1,7 +1,8 @@
1
+ import type { symbols } from '@adonisjs/auth';
1
2
  import type { GuardConfigProvider } from '@adonisjs/auth/types';
2
3
  import type { HttpContext } from '@adonisjs/core/http';
3
- import type { JwtAsymmetricAlgorithm, JwtGuardUser, JwtUserProviderContract, JwtCookieOptions } from './types.js';
4
- import { JwtGuard } from './jwt.js';
4
+ import type { JwtAsymmetricAlgorithm, JwtGuardUser, JwtUserProviderContract, JwtCookieOptions, BaseJwtContent, JwtJwksAlgorithm, JwtGetUserId, JwtVerifyPayload } from './types.js';
5
+ import { JwtGuard } from './guard.js';
5
6
  import type { StringValue } from 'ms';
6
7
  import type { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens';
7
8
  import type { Options } from 'jwks-rsa';
@@ -9,6 +10,7 @@ export declare function jwtGuard<UserProvider extends JwtUserProviderContract<un
9
10
  provider: UserProvider;
10
11
  refreshTokenUserProvider?: AccessTokensUserProviderContract<unknown>;
11
12
  tokenName?: string;
13
+ refreshTokenName?: string;
12
14
  tokenExpiresIn?: number | StringValue;
13
15
  refreshTokenExpiresIn?: number | StringValue;
14
16
  useCookies?: boolean;
@@ -18,7 +20,13 @@ export declare function jwtGuard<UserProvider extends JwtUserProviderContract<un
18
20
  privateKey?: string;
19
21
  publicKey?: string;
20
22
  algorithm?: JwtAsymmetricAlgorithm;
21
- content: <T>(user: JwtGuardUser<T>) => Record<string | number, any>;
23
+ content?: (user: JwtGuardUser<UserProvider[typeof symbols.PROVIDER_REAL_USER]>) => Record<string, any> & BaseJwtContent;
22
24
  jwks?: Options;
23
25
  cookie?: JwtCookieOptions;
26
+ issuer?: string;
27
+ audience?: string | string[];
28
+ algorithms?: JwtJwksAlgorithm[];
29
+ clockTolerance?: number;
30
+ getUserId?: JwtGetUserId;
31
+ verifyPayload?: JwtVerifyPayload;
24
32
  }): GuardConfigProvider<(ctx: HttpContext) => JwtGuard<UserProvider>>;