@maximemrf/adonisjs-jwt 1.0.0-rc.6 → 1.0.0-rc.8

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.
@@ -43,6 +43,7 @@ export function jwtGuard(config) {
43
43
  * the first request, when the app would already be serving traffic.
44
44
  */
45
45
  validateLifetimes(options, PREFIX);
46
+ validateSigningMethod(options, PREFIX, { usesAppKey });
46
47
  let driver;
47
48
  if (usesAppKey) {
48
49
  /**
@@ -53,7 +54,6 @@ export function jwtGuard(config) {
53
54
  assertAppKey(process.env.APP_KEY);
54
55
  }
55
56
  else {
56
- validateSigningMethod(options, PREFIX);
57
57
  driver = resolveDriver(options, PREFIX);
58
58
  }
59
59
  return {
@@ -27,8 +27,8 @@ export class AsymmetricDriver {
27
27
  if (!expectedKeyType) {
28
28
  throw new Error(`Unsupported asymmetric algorithm "${algorithm}"`);
29
29
  }
30
- const privateKey = createPrivateKey(this.#privateKey);
31
- const publicKey = createPublicKey(this.#publicKey);
30
+ const privateKey = parseKey(createPrivateKey, this.#privateKey, 'privateKey');
31
+ const publicKey = parseKey(createPublicKey, this.#publicKey, 'publicKey');
32
32
  if (privateKey.asymmetricKeyType !== expectedKeyType) {
33
33
  throw new Error(`privateKey type "${privateKey.asymmetricKeyType}" does not match algorithm "${algorithm}"`);
34
34
  }
@@ -42,6 +42,12 @@ export class AsymmetricDriver {
42
42
  if (!createPublicKey(privateKey).equals(publicKey)) {
43
43
  throw new Error('publicKey does not belong to the same key pair as privateKey');
44
44
  }
45
+ /**
46
+ * The checks above do not cover everything jsonwebtoken requires (the
47
+ * curve of ES256/384/512, RSA keys of at least 2048 bits). Sign a token
48
+ * now, so a bad key fails at boot instead of every login.
49
+ */
50
+ jwt.sign({}, this.#privateKey, { algorithm, expiresIn: 60 });
45
51
  }
46
52
  sign(payload, options) {
47
53
  const signOptions = {
@@ -62,3 +68,11 @@ export class AsymmetricDriver {
62
68
  return jwt.verify(token, this.#publicKey, verifyOptions);
63
69
  }
64
70
  }
71
+ function parseKey(parse, key, name) {
72
+ try {
73
+ return parse(key);
74
+ }
75
+ catch (error) {
76
+ throw new Error(`${name} is not a valid PEM key (${error.message}). If it comes from an env variable, replace the literal "\\n" with line breaks`);
77
+ }
78
+ }
@@ -49,10 +49,12 @@ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unkno
49
49
  * during this request, or else the one sent by the client (cookie, body
50
50
  * or Authorization header).
51
51
  *
52
- * The cookies are always cleared. When no valid refresh token is found,
53
- * nothing is revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client
54
- * that sends the wrong token (e.g. its access token) does not believe it
55
- * has logged out while its refresh token stays valid.
52
+ * The cookies are cleared when the refresh token is revoked, and when
53
+ * there is no valid refresh token to revoke. In that case nothing is
54
+ * revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client that sends
55
+ * the wrong token (e.g. its access token) does not believe it has logged
56
+ * out while its refresh token stays valid. On any other error (e.g. the
57
+ * database is down), the cookies are kept so the client can retry.
56
58
  */
57
59
  revoke(refreshToken?: string): Promise<void>;
58
60
  /**
@@ -107,6 +107,14 @@ export class JwtGuard {
107
107
  }
108
108
  const providerUser = await this.#userProvider.createUserForGuard(user);
109
109
  const signPayload = this.#options.content(providerUser);
110
+ assertNoGuardClaims(signPayload, this.#options);
111
+ /**
112
+ * Without `userId`, the login succeeds but every request then gets a 401
113
+ * with no log: fail when issuing the token instead
114
+ */
115
+ if (!this.#options.getUserId && !isUserId(signPayload.userId)) {
116
+ throw new RuntimeException('JWT guard `content` must return `userId`, or set `getUserId` to read the user id from the payload');
117
+ }
110
118
  const token = this.#signAccessToken(signPayload);
111
119
  let refreshToken;
112
120
  if (this.#refreshTokenUserProvider) {
@@ -327,25 +335,44 @@ export class JwtGuard {
327
335
  * during this request, or else the one sent by the client (cookie, body
328
336
  * or Authorization header).
329
337
  *
330
- * The cookies are always cleared. When no valid refresh token is found,
331
- * nothing is revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client
332
- * that sends the wrong token (e.g. its access token) does not believe it
333
- * has logged out while its refresh token stays valid.
338
+ * The cookies are cleared when the refresh token is revoked, and when
339
+ * there is no valid refresh token to revoke. In that case nothing is
340
+ * revoked and `E_UNAUTHORIZED_ACCESS` is raised, so a client that sends
341
+ * the wrong token (e.g. its access token) does not believe it has logged
342
+ * out while its refresh token stays valid. On any other error (e.g. the
343
+ * database is down), the cookies are kept so the client can retry.
334
344
  */
335
345
  async revoke(refreshToken) {
336
346
  if (!this.#refreshTokenUserProvider) {
337
347
  throw new RuntimeException(REFRESH_TOKENS_NOT_CONFIGURED);
338
348
  }
349
+ try {
350
+ await this.#revokeRefreshToken(this.#refreshTokenUserProvider, refreshToken);
351
+ }
352
+ catch (error) {
353
+ if (error instanceof errors.E_UNAUTHORIZED_ACCESS)
354
+ this.#clearCookies();
355
+ throw error;
356
+ }
339
357
  this.#clearCookies();
358
+ }
359
+ async #revokeRefreshToken(provider, refreshToken) {
340
360
  refreshToken = refreshToken ?? this.#issuedRefreshToken ?? (await this.#findRefreshToken());
341
361
  /**
342
362
  * Only delete a refresh token whose secret is valid. The tokens
343
363
  * provider deletes by id alone, so a forged token with a guessed
344
364
  * id would otherwise revoke another user's refresh token.
365
+ *
366
+ * Like the refresh, require the configured abilities: with two guards
367
+ * that share a tokens provider, the logout of one guard must not
368
+ * revoke the refresh tokens of the other.
345
369
  */
346
370
  const secret = new Secret(refreshToken);
347
- if (!(await this.#refreshTokenUserProvider.verifyToken(secret)) ||
348
- !(await this.#refreshTokenUserProvider.invalidateToken(secret))) {
371
+ const token = await provider.verifyToken(secret);
372
+ const requiredAbilities = this.#options.refreshTokenAbilities ?? [];
373
+ if (!token ||
374
+ !requiredAbilities.every((ability) => token.allows(ability)) ||
375
+ !(await provider.invalidateToken(secret))) {
349
376
  throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
350
377
  guardDriverName: this.driverName,
351
378
  });
@@ -407,6 +434,23 @@ export class JwtGuard {
407
434
  }
408
435
  }
409
436
  const REFRESH_TOKENS_NOT_CONFIGURED = 'JWT guard refresh tokens are not configured: set `refreshTokenUserProvider` in the guard config';
437
+ /**
438
+ * The guard sets `exp`, and `iss` / `aud` when `issuer` / `audience` are
439
+ * configured. jsonwebtoken refuses a payload that sets them too, with a
440
+ * message about its own options (`options.expiresIn`) that the app never set.
441
+ */
442
+ function assertNoGuardClaims(payload, options) {
443
+ const conflicts = [['exp', 'tokenExpiresIn']];
444
+ if (options.issuer)
445
+ conflicts.push(['iss', 'issuer']);
446
+ if (options.audience)
447
+ conflicts.push(['aud', 'audience']);
448
+ for (const [claim, option] of conflicts) {
449
+ if (payload[claim] !== undefined) {
450
+ throw new RuntimeException(`JWT guard \`content\` must not set \`${claim}\`: the guard sets it from \`${option}\``);
451
+ }
452
+ }
453
+ }
410
454
  function isUserId(value) {
411
455
  return ((typeof value === 'string' && value !== '') ||
412
456
  (typeof value === 'number' && Number.isFinite(value)) ||
@@ -135,6 +135,13 @@ type JwtSharedOptions = {
135
135
  */
136
136
  verifyPayload?: JwtVerifyPayload;
137
137
  };
138
+ /**
139
+ * Claims set by the guard: `exp` comes from `tokenExpiresIn`, so `content`
140
+ * must not set it
141
+ */
142
+ type GuardClaims = {
143
+ exp?: never;
144
+ };
138
145
  /**
139
146
  * `content` builds the payload of the access tokens. Without `getUserId`,
140
147
  * the guard reads the user id from the `userId` claim, so `content` must
@@ -146,14 +153,14 @@ export type JwtContentOptions<RealUser> = {
146
153
  * Defaults to `payload.userId`. Use e.g. `(payload) => payload.sub` for external providers.
147
154
  */
148
155
  getUserId?: undefined;
149
- content?: (user: JwtGuardUser<RealUser>) => Record<string, any> & BaseJwtContent;
156
+ content?: (user: JwtGuardUser<RealUser>) => Record<string, any> & BaseJwtContent & GuardClaims;
150
157
  } | {
151
158
  /**
152
159
  * Extract the user id from a verified token payload.
153
160
  * Defaults to `payload.userId`. Use e.g. `(payload) => payload.sub` for external providers.
154
161
  */
155
162
  getUserId: JwtGetUserId;
156
- content?: (user: JwtGuardUser<RealUser>) => Record<string, any>;
163
+ content?: (user: JwtGuardUser<RealUser>) => Record<string, any> & GuardClaims;
157
164
  };
158
165
  /**
159
166
  * Options of the `JwtGuard` constructor
@@ -10,7 +10,9 @@ export declare function validateLifetimes(options: Pick<JwtGuardOptions, 'tokenE
10
10
  /**
11
11
  * Exactly one signing method: a secret, an asymmetric key pair, or a JWKS endpoint
12
12
  */
13
- export declare function validateSigningMethod(options: ReleasedSecrets<JwtGuardOptions>, prefix: string): void;
13
+ export declare function validateSigningMethod(options: ReleasedSecrets<JwtGuardOptions>, prefix: string, { usesAppKey }?: {
14
+ usesAppKey?: boolean;
15
+ }): void;
14
16
  /**
15
17
  * Accept `Secret` values (`Env.schema.secret()` in AdonisJS v7) for the
16
18
  * signing keys, and work with their string value
@@ -44,7 +44,7 @@ function assertDuration(value, name, prefix) {
44
44
  /**
45
45
  * Exactly one signing method: a secret, an asymmetric key pair, or a JWKS endpoint
46
46
  */
47
- export function validateSigningMethod(options, prefix) {
47
+ export function validateSigningMethod(options, prefix, { usesAppKey = false } = {}) {
48
48
  const asymmetricPartial = options.privateKey !== undefined ||
49
49
  options.publicKey !== undefined ||
50
50
  options.algorithm !== undefined;
@@ -89,7 +89,11 @@ export function validateSigningMethod(options, prefix) {
89
89
  !(typeof options.jwks.jwksUri === 'string' && options.jwks.jwksUri.trim() !== '')) {
90
90
  throw new Error(`${prefix} \`jwks.jwksUri\` is required to fetch the identity provider keys`);
91
91
  }
92
- if (!options.jwks && !usesAsymmetric && !options.secret) {
92
+ /**
93
+ * Without a signing method, `jwtGuard()` derives the secret from the app
94
+ * key in the resolver
95
+ */
96
+ if (!usesAppKey && !options.jwks && !usesAsymmetric && !options.secret) {
93
97
  throw new Error(`${prefix} requires \`secret\` (symmetric), or \`privateKey\` + \`publicKey\` + \`algorithm\` (asymmetric), or \`jwks\``);
94
98
  }
95
99
  }
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.6",
4
+ "version": "1.0.0-rc.8",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
7
7
  },