@maximemrf/adonisjs-jwt 1.0.0-rc.3 → 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.
@@ -1,56 +1,81 @@
1
1
  import { JwtGuard } from './guard.js';
2
- import { validateGuardOptions } from './validation.js';
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
- const asymmetricKeys = {
9
- privateKey: config.privateKey,
10
- publicKey: config.publicKey,
11
- algorithm: config.algorithm,
12
- };
13
- const usesAsymmetric = Object.values(asymmetricKeys).some((value) => value !== undefined);
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
- if (!rawAppKey || rawAppKey.length < 32) {
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
+ }
@@ -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
  /**
@@ -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, {
@@ -293,7 +302,14 @@ export class JwtGuard {
293
302
  });
294
303
  }
295
304
  /**
296
- * 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.
297
313
  */
298
314
  async revoke(refreshToken) {
299
315
  if (!this.#refreshTokenUserProvider) {
@@ -302,24 +318,21 @@ export class JwtGuard {
302
318
  });
303
319
  }
304
320
  this.#clearCookies();
305
- if (!refreshToken) {
306
- try {
307
- refreshToken = await this.#findRefreshToken();
308
- }
309
- catch {
310
- return;
311
- }
312
- }
321
+ refreshToken = refreshToken ?? this.#issuedRefreshToken ?? (await this.#findRefreshToken());
313
322
  /**
314
323
  * Only delete a refresh token whose secret is valid. The tokens
315
324
  * provider deletes by id alone, so a forged token with a guessed
316
325
  * id would otherwise revoke another user's refresh token.
317
326
  */
318
327
  const secret = new Secret(refreshToken);
319
- if (!(await this.#refreshTokenUserProvider.verifyToken(secret))) {
320
- return;
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
+ });
321
333
  }
322
- await this.#refreshTokenUserProvider.invalidateToken(secret);
334
+ if (refreshToken === this.#issuedRefreshToken)
335
+ this.#issuedRefreshToken = undefined;
323
336
  }
324
337
  /**
325
338
  * Remove the access and refresh token cookies set by the guard
@@ -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;
@@ -1,11 +1,50 @@
1
+ import ms from 'ms';
1
2
  export function validateGuardOptions(options, prefix = 'JwtGuard') {
2
- if (typeof options.tokenExpiresIn === 'number' &&
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.3",
4
+ "version": "1.0.0-rc.4",
5
5
  "engines": {
6
6
  "node": ">=24.0.0"
7
7
  },