@maximemrf/adonisjs-jwt 1.0.0-rc.3 → 1.0.0-rc.5
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 +0 -1
- package/build/index.d.ts +1 -1
- package/build/index.js +1 -1
- package/build/src/constants.d.ts +11 -0
- package/build/src/constants.js +23 -0
- package/build/src/define_config.d.ts +2 -28
- package/build/src/define_config.js +67 -38
- package/build/src/driver_resolver.js +1 -1
- package/build/src/drivers/asymmetric.js +7 -0
- package/build/src/drivers/jwks.d.ts +1 -1
- package/build/src/drivers/jwks.js +3 -4
- package/build/src/drivers/symmetric.js +3 -3
- package/build/src/drivers/types.d.ts +11 -0
- package/build/src/errors.d.ts +23 -0
- package/build/src/errors.js +56 -0
- package/build/src/guard.d.ts +11 -3
- package/build/src/guard.js +81 -37
- package/build/src/types.d.ts +53 -29
- package/build/src/types.js +1 -19
- package/build/src/validation.d.ts +29 -1
- package/build/src/validation.js +98 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
<a href="https://www.npmjs.com/package/@maximemrf/adonisjs-jwt"><img src="https://img.shields.io/npm/v/@maximemrf/adonisjs-jwt.svg?style=flat-square" alt="Version"></a>
|
|
8
8
|
<a href="https://www.npmjs.com/package/@maximemrf/adonisjs-jwt"><img src="https://img.shields.io/npm/last-update/@maximemrf/adonisjs-jwt.svg?style=flat-square" alt="NPM Last Update"></a>
|
|
9
9
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/npm/l/@maximemrf/adonisjs-jwt.svg?style=flat-square" alt="License"></a>
|
|
10
|
-
<a href="https://adonisjs.com/"><img src="https://img.shields.io/badge/AdonisJS-v6-5A45FF.svg?style=flat-square" alt="AdonisV6"></a>
|
|
11
10
|
<a href="https://adonisjs.com/"><img src="https://img.shields.io/badge/AdonisJS-v7-5A45FF.svg?style=flat-square" alt="AdonisV7"></a>
|
|
12
11
|
</p>
|
|
13
12
|
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
export { configure } from './configure.js';
|
|
10
10
|
export { jwtGuard } from './src/define_config.js';
|
|
11
11
|
export { JwtGuard } from './src/guard.js';
|
|
12
|
-
export { DEFAULT_TOKEN_EXPIRES_IN } from './src/
|
|
12
|
+
export { DEFAULT_TOKEN_EXPIRES_IN } from './src/constants.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifetime of access tokens when `tokenExpiresIn` is not configured
|
|
3
|
+
*/
|
|
4
|
+
export declare const DEFAULT_TOKEN_EXPIRES_IN = "1h";
|
|
5
|
+
/**
|
|
6
|
+
* Lifetime of refresh tokens when `refreshTokenExpiresIn` is not configured
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_REFRESH_TOKEN_EXPIRES_IN = "7d";
|
|
9
|
+
export declare const ALLOWED_SYMMETRIC_ALGORITHMS: readonly ["HS256", "HS384", "HS512"];
|
|
10
|
+
export declare const ALLOWED_ASYMMETRIC_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
|
11
|
+
export declare const ALLOWED_JWKS_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifetime of access tokens when `tokenExpiresIn` is not configured
|
|
3
|
+
*/
|
|
4
|
+
export const DEFAULT_TOKEN_EXPIRES_IN = '1h';
|
|
5
|
+
/**
|
|
6
|
+
* Lifetime of refresh tokens when `refreshTokenExpiresIn` is not configured
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_REFRESH_TOKEN_EXPIRES_IN = '7d';
|
|
9
|
+
export const ALLOWED_SYMMETRIC_ALGORITHMS = ['HS256', 'HS384', 'HS512'];
|
|
10
|
+
export const ALLOWED_ASYMMETRIC_ALGORITHMS = [
|
|
11
|
+
'RS256',
|
|
12
|
+
'RS384',
|
|
13
|
+
'RS512',
|
|
14
|
+
'ES256',
|
|
15
|
+
'ES384',
|
|
16
|
+
'ES512',
|
|
17
|
+
];
|
|
18
|
+
export const ALLOWED_JWKS_ALGORITHMS = [
|
|
19
|
+
...ALLOWED_ASYMMETRIC_ALGORITHMS,
|
|
20
|
+
'PS256',
|
|
21
|
+
'PS384',
|
|
22
|
+
'PS512',
|
|
23
|
+
];
|
|
@@ -1,32 +1,6 @@
|
|
|
1
|
-
import type { symbols } from '@adonisjs/auth';
|
|
2
1
|
import type { GuardConfigProvider } from '@adonisjs/auth/types';
|
|
3
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
4
|
-
import type {
|
|
3
|
+
import type { JwtUserProviderContract, JwtGuardConfig } from './types.js';
|
|
5
4
|
import { JwtGuard } from './guard.js';
|
|
6
|
-
import type { StringValue } from 'ms';
|
|
7
5
|
import type { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens';
|
|
8
|
-
|
|
9
|
-
export declare function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>, RefreshTokenUserProvider extends AccessTokensUserProviderContract<unknown> | undefined = undefined>(config: {
|
|
10
|
-
provider: UserProvider;
|
|
11
|
-
refreshTokenUserProvider?: RefreshTokenUserProvider;
|
|
12
|
-
tokenName?: string;
|
|
13
|
-
refreshTokenName?: string;
|
|
14
|
-
tokenExpiresIn?: number | StringValue;
|
|
15
|
-
refreshTokenExpiresIn?: number | StringValue;
|
|
16
|
-
useCookies?: boolean;
|
|
17
|
-
useCookiesForRefreshToken?: boolean;
|
|
18
|
-
refreshTokenAbilities?: string[];
|
|
19
|
-
secret?: string;
|
|
20
|
-
privateKey?: string;
|
|
21
|
-
publicKey?: string;
|
|
22
|
-
algorithm?: JwtAsymmetricAlgorithm;
|
|
23
|
-
content?: (user: JwtGuardUser<UserProvider[typeof symbols.PROVIDER_REAL_USER]>) => Record<string, any> & BaseJwtContent;
|
|
24
|
-
jwks?: Options;
|
|
25
|
-
cookie?: JwtCookieOptions;
|
|
26
|
-
issuer?: string;
|
|
27
|
-
audience?: string | string[];
|
|
28
|
-
algorithms?: JwtJwksAlgorithm[];
|
|
29
|
-
clockTolerance?: number;
|
|
30
|
-
getUserId?: JwtGetUserId;
|
|
31
|
-
verifyPayload?: JwtVerifyPayload;
|
|
32
|
-
}): GuardConfigProvider<(ctx: HttpContext) => JwtGuard<UserProvider, [RefreshTokenUserProvider] extends [undefined] ? false : true>>;
|
|
6
|
+
export declare function jwtGuard<UserProvider extends JwtUserProviderContract<unknown>, RefreshTokenUserProvider extends AccessTokensUserProviderContract<unknown> | undefined = undefined>(config: JwtGuardConfig<UserProvider, RefreshTokenUserProvider>): GuardConfigProvider<(ctx: HttpContext) => JwtGuard<UserProvider, [RefreshTokenUserProvider] extends [undefined] ? false : true>>;
|
|
@@ -1,56 +1,85 @@
|
|
|
1
1
|
import { JwtGuard } from './guard.js';
|
|
2
|
-
import {
|
|
2
|
+
import { releaseSecrets, 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 = releaseSecrets({
|
|
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
|
+
* `content` and `getUserId` come from the same config, so they keep
|
|
37
|
+
* the pairing checked by `JwtContentOptions`
|
|
38
|
+
*/
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* Validate the configuration when config/auth.ts is loaded, so a bad
|
|
42
|
+
* configuration stops the app from booting. The resolver only runs on
|
|
43
|
+
* the first request, when the app would already be serving traffic.
|
|
44
|
+
*/
|
|
45
|
+
validateLifetimes(options, PREFIX);
|
|
46
|
+
let driver;
|
|
47
|
+
if (usesAppKey) {
|
|
48
|
+
/**
|
|
49
|
+
* The derived key depends on the guard name, only known in the resolver,
|
|
50
|
+
* but the app key itself can already be checked when the env has it
|
|
51
|
+
*/
|
|
52
|
+
if (process.env.APP_KEY !== undefined)
|
|
53
|
+
assertAppKey(process.env.APP_KEY);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
validateSigningMethod(options, PREFIX);
|
|
57
|
+
driver = resolveDriver(options, PREFIX);
|
|
58
|
+
}
|
|
6
59
|
return {
|
|
7
60
|
async resolver(name, app) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Only fall back on the app key when no other signing method is configured
|
|
16
|
-
*/
|
|
17
|
-
const resolvedSecret = config.secret ??
|
|
18
|
-
(usesAsymmetric || config.jwks ? undefined : deriveSecretFromAppKey(readAppKey(app), name));
|
|
19
|
-
const options = {
|
|
20
|
-
...(usesAsymmetric ? asymmetricKeys : { secret: resolvedSecret }),
|
|
21
|
-
refreshTokenUserProvider: config.refreshTokenUserProvider,
|
|
22
|
-
tokenName: config.tokenName,
|
|
23
|
-
refreshTokenName: config.refreshTokenName,
|
|
24
|
-
tokenExpiresIn: config.tokenExpiresIn,
|
|
25
|
-
refreshTokenExpiresIn: config.refreshTokenExpiresIn,
|
|
26
|
-
useCookies: config.useCookies,
|
|
27
|
-
useCookiesForRefreshToken: config.useCookiesForRefreshToken,
|
|
28
|
-
refreshTokenAbilities: config.refreshTokenAbilities,
|
|
29
|
-
content: config.content,
|
|
30
|
-
jwks: config.jwks,
|
|
31
|
-
cookie: config.cookie,
|
|
32
|
-
issuer: config.issuer,
|
|
33
|
-
audience: config.audience,
|
|
34
|
-
algorithms: config.algorithms,
|
|
35
|
-
clockTolerance: config.clockTolerance,
|
|
36
|
-
getUserId: config.getUserId,
|
|
37
|
-
verifyPayload: config.verifyPayload,
|
|
38
|
-
};
|
|
39
|
-
validateGuardOptions(options, 'JWT guard');
|
|
40
|
-
const driver = resolveDriver(options, 'JWT guard');
|
|
41
|
-
return (ctx) => new JwtGuard(ctx, config.provider, { ...options, driver });
|
|
61
|
+
if (usesAppKey) {
|
|
62
|
+
const secret = deriveSecretFromAppKey(readAppKey(app), name);
|
|
63
|
+
driver = resolveDriver({ ...options, secret }, PREFIX);
|
|
64
|
+
}
|
|
65
|
+
const resolvedDriver = driver;
|
|
66
|
+
return (ctx) => new JwtGuard(ctx, config.provider, { ...options, driver: resolvedDriver });
|
|
42
67
|
},
|
|
43
68
|
};
|
|
44
69
|
}
|
|
70
|
+
const PREFIX = 'JWT guard';
|
|
71
|
+
function assertAppKey(rawAppKey) {
|
|
72
|
+
if (!rawAppKey || rawAppKey.length < 32) {
|
|
73
|
+
throw new Error('JWT guard requires the application key (APP_KEY) to be at least 32 characters when no `secret` is configured');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
45
76
|
/**
|
|
46
77
|
* Derive a dedicated signing key from the app key, so the guard never signs
|
|
47
78
|
* with the raw app key used elsewhere by the application, and two JWT guards
|
|
48
79
|
* never accept each other's tokens.
|
|
49
80
|
*/
|
|
50
81
|
function deriveSecretFromAppKey(rawAppKey, guardName) {
|
|
51
|
-
|
|
52
|
-
throw new Error('JWT guard requires the application key (APP_KEY) to be at least 32 characters when no `secret` is configured');
|
|
53
|
-
}
|
|
82
|
+
assertAppKey(rawAppKey);
|
|
54
83
|
return Buffer.from(hkdfSync('sha256', rawAppKey, '', `@maximemrf/adonisjs-jwt:${guardName}`, 32)).toString('hex');
|
|
55
84
|
}
|
|
56
85
|
/**
|
|
@@ -15,7 +15,7 @@ export function resolveDriver(options, contextName = 'JWT guard') {
|
|
|
15
15
|
audience: options.audience,
|
|
16
16
|
clockTolerance: options.clockTolerance,
|
|
17
17
|
algorithms: options.algorithms,
|
|
18
|
-
verifiesPayload: options.verifyPayload
|
|
18
|
+
verifiesPayload: typeof options.verifyPayload === 'function',
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
catch (error) {
|
|
@@ -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 = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Options } from 'jwks-rsa';
|
|
2
2
|
import type { StringValue } from 'ms';
|
|
3
3
|
import type { JwtDriver } from './types.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { JwtJwksAlgorithm } from '../types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Safe jwks-rsa defaults: tokens with an unknown `kid` trigger a JWKS
|
|
7
7
|
* fetch, so fetches must be cached and rate limited to avoid hammering
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
2
|
import { errors } from '@adonisjs/auth';
|
|
3
|
+
import { RuntimeException } from '@adonisjs/core/exceptions';
|
|
3
4
|
import { JwksManager } from '../jwks.js';
|
|
4
|
-
import { ALLOWED_JWKS_ALGORITHMS } from '../
|
|
5
|
+
import { ALLOWED_JWKS_ALGORITHMS } from '../constants.js';
|
|
5
6
|
/**
|
|
6
7
|
* Safe jwks-rsa defaults: tokens with an unknown `kid` trigger a JWKS
|
|
7
8
|
* fetch, so fetches must be cached and rate limited to avoid hammering
|
|
@@ -40,9 +41,7 @@ export class JwksDriver {
|
|
|
40
41
|
this.#algorithms = algorithms;
|
|
41
42
|
}
|
|
42
43
|
sign(_payload, _options) {
|
|
43
|
-
throw new
|
|
44
|
-
guardDriverName: 'jwt',
|
|
45
|
-
});
|
|
44
|
+
throw new RuntimeException('JWT guard cannot issue tokens in JWKS mode: tokens are issued by the identity provider');
|
|
46
45
|
}
|
|
47
46
|
async verify(token) {
|
|
48
47
|
const decoded = jwt.decode(token, { complete: true });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
|
-
import { ALLOWED_SYMMETRIC_ALGORITHMS } from '../
|
|
2
|
+
import { ALLOWED_SYMMETRIC_ALGORITHMS } from '../constants.js';
|
|
3
3
|
export class SymmetricDriver {
|
|
4
4
|
canSign = true;
|
|
5
5
|
#secret;
|
|
@@ -8,10 +8,10 @@ export class SymmetricDriver {
|
|
|
8
8
|
#clockTolerance;
|
|
9
9
|
constructor(options) {
|
|
10
10
|
if (!options.secret) {
|
|
11
|
-
throw new Error('
|
|
11
|
+
throw new Error('JWT guard requires a `secret`');
|
|
12
12
|
}
|
|
13
13
|
if (options.secret.length < 32) {
|
|
14
|
-
throw new Error('
|
|
14
|
+
throw new Error('JWT guard `secret` must be at least 32 characters to ensure sufficient entropy');
|
|
15
15
|
}
|
|
16
16
|
this.#secret = options.secret;
|
|
17
17
|
this.#issuer = options.issuer;
|
|
@@ -6,3 +6,14 @@ export interface JwtDriver {
|
|
|
6
6
|
}): string;
|
|
7
7
|
verify(token: string): Promise<Record<string, any> | string> | Record<string, any> | string;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Options of the `JwtGuard` constructor that are not part of the public
|
|
11
|
+
* API: `jwtGuard()` resolves the driver once and passes it to every guard.
|
|
12
|
+
* Custom drivers are not supported and the `JwtDriver` interface may change.
|
|
13
|
+
*/
|
|
14
|
+
export type JwtGuardInternalOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
driver?: JwtDriver;
|
|
19
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { errors } from '@adonisjs/auth';
|
|
2
|
+
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
type UnauthorizedAccess = InstanceType<typeof errors.E_UNAUTHORIZED_ACCESS>;
|
|
4
|
+
type Renderer = (message: string, error: UnauthorizedAccess, ctx: HttpContext) => any;
|
|
5
|
+
/**
|
|
6
|
+
* Renders the 401 raised by the JWT guard, or by the `auth` middleware when
|
|
7
|
+
* the JWT guard was the last one tried.
|
|
8
|
+
*
|
|
9
|
+
* - JSON and JSON:API requests get the same body as the `access_tokens` guard.
|
|
10
|
+
* - HTML requests (pages, Inertia visits) are redirected to the login route
|
|
11
|
+
* when the `auth` middleware passes one (`loginRoute`), like the `session`
|
|
12
|
+
* guard does. Without a login route, they get a plain text 401.
|
|
13
|
+
*/
|
|
14
|
+
export declare const renderJwtUnauthorizedAccess: Renderer;
|
|
15
|
+
/**
|
|
16
|
+
* @adonisjs/auth only ships renderers for its own guards, and the `auth`
|
|
17
|
+
* middleware re-throws its own `E_UNAUTHORIZED_ACCESS` with
|
|
18
|
+
* `guardDriverName: 'jwt'`, so a subclass would never reach the exception
|
|
19
|
+
* handler. Register the `jwt` renderer on every instance instead, unless
|
|
20
|
+
* the application already registered its own.
|
|
21
|
+
*/
|
|
22
|
+
export declare function installJwtUnauthorizedRenderer(): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { errors } from '@adonisjs/auth';
|
|
2
|
+
/**
|
|
3
|
+
* Renders the 401 raised by the JWT guard, or by the `auth` middleware when
|
|
4
|
+
* the JWT guard was the last one tried.
|
|
5
|
+
*
|
|
6
|
+
* - JSON and JSON:API requests get the same body as the `access_tokens` guard.
|
|
7
|
+
* - HTML requests (pages, Inertia visits) are redirected to the login route
|
|
8
|
+
* when the `auth` middleware passes one (`loginRoute`), like the `session`
|
|
9
|
+
* guard does. Without a login route, they get a plain text 401.
|
|
10
|
+
*/
|
|
11
|
+
export const renderJwtUnauthorizedAccess = (message, error, ctx) => {
|
|
12
|
+
switch (ctx.request.accepts(['html', 'application/vnd.api+json', 'json'])) {
|
|
13
|
+
case 'html':
|
|
14
|
+
case null:
|
|
15
|
+
if (error.redirectTo) {
|
|
16
|
+
/**
|
|
17
|
+
* `withIntendedUrl` is added by @adonisjs/session, which is optional
|
|
18
|
+
*/
|
|
19
|
+
const redirect = ctx.response.redirect();
|
|
20
|
+
if (typeof redirect.withIntendedUrl === 'function')
|
|
21
|
+
redirect.withIntendedUrl();
|
|
22
|
+
redirect.withQs().toPath(error.redirectTo);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
ctx.response.status(error.status).send(message);
|
|
26
|
+
return;
|
|
27
|
+
case 'json':
|
|
28
|
+
ctx.response.status(error.status).send({ errors: [{ message }] });
|
|
29
|
+
return;
|
|
30
|
+
case 'application/vnd.api+json':
|
|
31
|
+
ctx.response.status(error.status).send({ errors: [{ code: error.code, title: message }] });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const INSTALLED = Symbol.for('@maximemrf/adonisjs-jwt/unauthorized-renderer');
|
|
36
|
+
/**
|
|
37
|
+
* @adonisjs/auth only ships renderers for its own guards, and the `auth`
|
|
38
|
+
* middleware re-throws its own `E_UNAUTHORIZED_ACCESS` with
|
|
39
|
+
* `guardDriverName: 'jwt'`, so a subclass would never reach the exception
|
|
40
|
+
* handler. Register the `jwt` renderer on every instance instead, unless
|
|
41
|
+
* the application already registered its own.
|
|
42
|
+
*/
|
|
43
|
+
export function installJwtUnauthorizedRenderer() {
|
|
44
|
+
const prototype = errors.E_UNAUTHORIZED_ACCESS.prototype;
|
|
45
|
+
if (prototype[INSTALLED])
|
|
46
|
+
return;
|
|
47
|
+
const handle = prototype.handle;
|
|
48
|
+
prototype.handle = function (error, ctx) {
|
|
49
|
+
const renderers = this.renderers;
|
|
50
|
+
if (this.guardDriverName === 'jwt' && !renderers.jwt) {
|
|
51
|
+
renderers.jwt = renderJwtUnauthorizedAccess;
|
|
52
|
+
}
|
|
53
|
+
return handle.call(this, error, ctx);
|
|
54
|
+
};
|
|
55
|
+
prototype[INSTALLED] = true;
|
|
56
|
+
}
|
package/build/src/guard.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
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
|
-
import {
|
|
4
|
+
import type { JwtUserProviderContract, JwtGuardOptions, JwtGenerateResult } from './types.js';
|
|
5
|
+
import type { JwtGuardInternalOptions } from './drivers/types.js';
|
|
5
6
|
export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>, WithRefreshToken extends boolean = boolean> implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
|
|
6
7
|
#private;
|
|
7
|
-
constructor(ctx: HttpContext, userProvider: UserProvider, option: JwtGuardOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]>);
|
|
8
|
+
constructor(ctx: HttpContext, userProvider: UserProvider, option: JwtGuardOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]> & JwtGuardInternalOptions);
|
|
8
9
|
/**
|
|
9
10
|
* A list of events and their types emitted by
|
|
10
11
|
* the guard.
|
|
@@ -44,7 +45,14 @@ export declare class JwtGuard<UserProvider extends JwtUserProviderContract<unkno
|
|
|
44
45
|
}>;
|
|
45
46
|
generateWithRefreshToken(refreshToken?: string): Promise<JwtGenerateResult<true>>;
|
|
46
47
|
/**
|
|
47
|
-
* Revoke the refresh token
|
|
48
|
+
* Revoke the refresh token passed as argument, or else the one issued
|
|
49
|
+
* during this request, or else the one sent by the client (cookie, body
|
|
50
|
+
* or Authorization header).
|
|
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.
|
|
48
56
|
*/
|
|
49
57
|
revoke(refreshToken?: string): Promise<void>;
|
|
50
58
|
/**
|
package/build/src/guard.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { errors } from '@adonisjs/auth';
|
|
2
|
-
import {
|
|
2
|
+
import { RuntimeException } from '@adonisjs/core/exceptions';
|
|
3
|
+
import { DEFAULT_REFRESH_TOKEN_EXPIRES_IN, DEFAULT_TOKEN_EXPIRES_IN } from './constants.js';
|
|
3
4
|
import { Secret } from '@adonisjs/core/helpers';
|
|
4
|
-
import { validateGuardOptions } from './validation.js';
|
|
5
|
+
import { releaseSecrets, toSeconds, validateGuardOptions, } from './validation.js';
|
|
5
6
|
import { resolveDriver } from './driver_resolver.js';
|
|
7
|
+
import { installJwtUnauthorizedRenderer } from './errors.js';
|
|
8
|
+
installJwtUnauthorizedRenderer();
|
|
6
9
|
export class JwtGuard {
|
|
7
10
|
#ctx;
|
|
8
11
|
#userProvider;
|
|
@@ -12,10 +15,19 @@ export class JwtGuard {
|
|
|
12
15
|
#refreshTokenName = 'refreshToken';
|
|
13
16
|
#driver;
|
|
14
17
|
#cookieOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Refresh token issued during the current request, by `generate()` or a
|
|
20
|
+
* refresh. A logout in the same request revokes it: the request still
|
|
21
|
+
* carries the previous one, which the refresh has already deleted.
|
|
22
|
+
*/
|
|
23
|
+
#issuedRefreshToken;
|
|
15
24
|
constructor(ctx, userProvider, option) {
|
|
16
25
|
this.#ctx = ctx;
|
|
17
26
|
this.#userProvider = userProvider;
|
|
18
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Copy the options: the defaults below must not leak into the caller's object
|
|
29
|
+
*/
|
|
30
|
+
this.#options = releaseSecrets({ ...option });
|
|
19
31
|
this.#refreshTokenUserProvider = this.#options.refreshTokenUserProvider;
|
|
20
32
|
if (!this.#options.content)
|
|
21
33
|
this.#options.content = (user) => ({ userId: user.getId() });
|
|
@@ -25,13 +37,21 @@ export class JwtGuard {
|
|
|
25
37
|
* Never issue access tokens without an expiration
|
|
26
38
|
*/
|
|
27
39
|
this.#options.tokenExpiresIn = this.#options.tokenExpiresIn ?? DEFAULT_TOKEN_EXPIRES_IN;
|
|
40
|
+
/**
|
|
41
|
+
* Give refresh tokens a lifetime, so the database row, the cookie and
|
|
42
|
+
* the response always agree
|
|
43
|
+
*/
|
|
44
|
+
if (this.#refreshTokenUserProvider) {
|
|
45
|
+
this.#options.refreshTokenExpiresIn =
|
|
46
|
+
this.#options.refreshTokenExpiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRES_IN;
|
|
47
|
+
}
|
|
28
48
|
this.#cookieOptions = {
|
|
29
49
|
httpOnly: true,
|
|
30
50
|
secure: true,
|
|
31
51
|
...this.#options.cookie,
|
|
32
52
|
};
|
|
33
|
-
validateGuardOptions(this.#options, '
|
|
34
|
-
this.#driver = resolveDriver(this.#options, '
|
|
53
|
+
validateGuardOptions(this.#options, 'JWT guard');
|
|
54
|
+
this.#driver = resolveDriver(this.#options, 'JWT guard');
|
|
35
55
|
}
|
|
36
56
|
#signAccessToken(payload) {
|
|
37
57
|
return this.#driver.sign(payload, { expiresIn: this.#options.tokenExpiresIn });
|
|
@@ -83,9 +103,7 @@ export class JwtGuard {
|
|
|
83
103
|
*/
|
|
84
104
|
async generate(user) {
|
|
85
105
|
if (!this.#driver.canSign) {
|
|
86
|
-
throw new
|
|
87
|
-
guardDriverName: this.driverName,
|
|
88
|
-
});
|
|
106
|
+
throw new RuntimeException('JWT guard cannot issue tokens in JWKS mode: tokens are issued by the identity provider');
|
|
89
107
|
}
|
|
90
108
|
const providerUser = await this.#userProvider.createUserForGuard(user);
|
|
91
109
|
const signPayload = this.#options.content(providerUser);
|
|
@@ -98,6 +116,7 @@ export class JwtGuard {
|
|
|
98
116
|
}
|
|
99
117
|
: undefined);
|
|
100
118
|
refreshToken = generatedRefreshToken.value.release();
|
|
119
|
+
this.#issuedRefreshToken = refreshToken;
|
|
101
120
|
}
|
|
102
121
|
if (this.#options.useCookies) {
|
|
103
122
|
this.#ctx.response.cookie(`${this.#tokenName}`, token, {
|
|
@@ -114,9 +133,11 @@ export class JwtGuard {
|
|
|
114
133
|
return {
|
|
115
134
|
type: 'bearer',
|
|
116
135
|
token: token,
|
|
117
|
-
expiresIn: this.#options.tokenExpiresIn,
|
|
136
|
+
expiresIn: toSeconds(this.#options.tokenExpiresIn),
|
|
118
137
|
refreshToken: refreshToken,
|
|
119
|
-
refreshTokenExpiresIn:
|
|
138
|
+
refreshTokenExpiresIn: refreshToken
|
|
139
|
+
? toSeconds(this.#options.refreshTokenExpiresIn)
|
|
140
|
+
: undefined,
|
|
120
141
|
};
|
|
121
142
|
}
|
|
122
143
|
/**
|
|
@@ -154,7 +175,12 @@ export class JwtGuard {
|
|
|
154
175
|
guardDriverName: this.driverName,
|
|
155
176
|
});
|
|
156
177
|
}
|
|
157
|
-
|
|
178
|
+
/**
|
|
179
|
+
* jsonwebtoken only checks `exp` when the claim is present: a token
|
|
180
|
+
* without it (issued by another service sharing the secret, or by an
|
|
181
|
+
* identity provider) would otherwise be valid forever
|
|
182
|
+
*/
|
|
183
|
+
if (!payload || typeof payload !== 'object' || typeof payload.exp !== 'number') {
|
|
158
184
|
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
|
|
159
185
|
guardDriverName: this.driverName,
|
|
160
186
|
});
|
|
@@ -176,9 +202,17 @@ export class JwtGuard {
|
|
|
176
202
|
});
|
|
177
203
|
}
|
|
178
204
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
/**
|
|
206
|
+
* A `getUserId` that throws, or a claim of an unexpected type (an object
|
|
207
|
+
* or an array from an identity provider), rejects the token instead of
|
|
208
|
+
* failing the request or reaching the database
|
|
209
|
+
*/
|
|
210
|
+
let userId;
|
|
211
|
+
try {
|
|
212
|
+
userId = (this.#options.getUserId ?? ((claims) => claims.userId))(payload);
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
if (!isUserId(userId)) {
|
|
182
216
|
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
|
|
183
217
|
guardDriverName: this.driverName,
|
|
184
218
|
});
|
|
@@ -200,14 +234,10 @@ export class JwtGuard {
|
|
|
200
234
|
async generateWithRefreshToken(refreshToken) {
|
|
201
235
|
this.authenticationAttempted = true;
|
|
202
236
|
if (!this.#driver.canSign) {
|
|
203
|
-
throw new
|
|
204
|
-
guardDriverName: this.driverName,
|
|
205
|
-
});
|
|
237
|
+
throw new RuntimeException('JWT guard cannot issue tokens in JWKS mode: tokens are issued by the identity provider');
|
|
206
238
|
}
|
|
207
239
|
if (!this.#refreshTokenUserProvider) {
|
|
208
|
-
throw new
|
|
209
|
-
guardDriverName: this.driverName,
|
|
210
|
-
});
|
|
240
|
+
throw new RuntimeException(REFRESH_TOKENS_NOT_CONFIGURED);
|
|
211
241
|
}
|
|
212
242
|
if (!refreshToken) {
|
|
213
243
|
refreshToken = await this.#findRefreshToken();
|
|
@@ -293,33 +323,35 @@ export class JwtGuard {
|
|
|
293
323
|
});
|
|
294
324
|
}
|
|
295
325
|
/**
|
|
296
|
-
* Revoke the refresh token
|
|
326
|
+
* Revoke the refresh token passed as argument, or else the one issued
|
|
327
|
+
* during this request, or else the one sent by the client (cookie, body
|
|
328
|
+
* or Authorization header).
|
|
329
|
+
*
|
|
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.
|
|
297
334
|
*/
|
|
298
335
|
async revoke(refreshToken) {
|
|
299
336
|
if (!this.#refreshTokenUserProvider) {
|
|
300
|
-
throw new
|
|
301
|
-
guardDriverName: this.driverName,
|
|
302
|
-
});
|
|
337
|
+
throw new RuntimeException(REFRESH_TOKENS_NOT_CONFIGURED);
|
|
303
338
|
}
|
|
304
339
|
this.#clearCookies();
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
refreshToken = await this.#findRefreshToken();
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
340
|
+
refreshToken = refreshToken ?? this.#issuedRefreshToken ?? (await this.#findRefreshToken());
|
|
313
341
|
/**
|
|
314
342
|
* Only delete a refresh token whose secret is valid. The tokens
|
|
315
343
|
* provider deletes by id alone, so a forged token with a guessed
|
|
316
344
|
* id would otherwise revoke another user's refresh token.
|
|
317
345
|
*/
|
|
318
346
|
const secret = new Secret(refreshToken);
|
|
319
|
-
if (!(await this.#refreshTokenUserProvider.verifyToken(secret))
|
|
320
|
-
|
|
347
|
+
if (!(await this.#refreshTokenUserProvider.verifyToken(secret)) ||
|
|
348
|
+
!(await this.#refreshTokenUserProvider.invalidateToken(secret))) {
|
|
349
|
+
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
|
|
350
|
+
guardDriverName: this.driverName,
|
|
351
|
+
});
|
|
321
352
|
}
|
|
322
|
-
|
|
353
|
+
if (refreshToken === this.#issuedRefreshToken)
|
|
354
|
+
this.#issuedRefreshToken = undefined;
|
|
323
355
|
}
|
|
324
356
|
/**
|
|
325
357
|
* Remove the access and refresh token cookies set by the guard
|
|
@@ -340,8 +372,14 @@ export class JwtGuard {
|
|
|
340
372
|
await this.authenticate();
|
|
341
373
|
return true;
|
|
342
374
|
}
|
|
343
|
-
catch {
|
|
344
|
-
|
|
375
|
+
catch (error) {
|
|
376
|
+
/**
|
|
377
|
+
* Like the official guards, only an authentication failure means
|
|
378
|
+
* "not logged in": a database outage must not look like a guest
|
|
379
|
+
*/
|
|
380
|
+
if (error instanceof errors.E_UNAUTHORIZED_ACCESS)
|
|
381
|
+
return false;
|
|
382
|
+
throw error;
|
|
345
383
|
}
|
|
346
384
|
}
|
|
347
385
|
/**
|
|
@@ -368,3 +406,9 @@ export class JwtGuard {
|
|
|
368
406
|
};
|
|
369
407
|
}
|
|
370
408
|
}
|
|
409
|
+
const REFRESH_TOKENS_NOT_CONFIGURED = 'JWT guard refresh tokens are not configured: set `refreshTokenUserProvider` in the guard config';
|
|
410
|
+
function isUserId(value) {
|
|
411
|
+
return ((typeof value === 'string' && value !== '') ||
|
|
412
|
+
(typeof value === 'number' && Number.isFinite(value)) ||
|
|
413
|
+
typeof value === 'bigint');
|
|
414
|
+
}
|
package/build/src/types.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { symbols } from '@adonisjs/auth';
|
|
2
2
|
import type { StringValue } from 'ms';
|
|
3
3
|
import type { AccessTokensUserProviderContract } from '@adonisjs/auth/types/access_tokens';
|
|
4
4
|
import type { Options } from 'jwks-rsa';
|
|
5
5
|
import type { CookieOptions } from '@adonisjs/core/types/http';
|
|
6
|
-
import type {
|
|
6
|
+
import type { ALLOWED_ASYMMETRIC_ALGORITHMS, ALLOWED_JWKS_ALGORITHMS, ALLOWED_SYMMETRIC_ALGORITHMS } from './constants.js';
|
|
7
|
+
import type { Secret } from '@adonisjs/core/helpers';
|
|
7
8
|
export type JwtCookieOptions = Omit<Partial<CookieOptions>, 'maxAge' | 'expires'>;
|
|
8
9
|
/**
|
|
9
10
|
* The bridge between the User provider and the
|
|
@@ -28,7 +29,7 @@ export interface JwtUserProviderContract<RealUser> {
|
|
|
28
29
|
* A property the guard implementation can use to infer
|
|
29
30
|
* the data type of the actual user (aka RealUser)
|
|
30
31
|
*/
|
|
31
|
-
[symbols.PROVIDER_REAL_USER]
|
|
32
|
+
[symbols.PROVIDER_REAL_USER]: RealUser;
|
|
32
33
|
/**
|
|
33
34
|
* Create a user object that acts as an adapter between
|
|
34
35
|
* the guard and real user value.
|
|
@@ -42,47 +43,43 @@ export interface JwtUserProviderContract<RealUser> {
|
|
|
42
43
|
export type BaseJwtContent = {
|
|
43
44
|
userId: string | number | BigInt;
|
|
44
45
|
};
|
|
45
|
-
/**
|
|
46
|
-
* Lifetime of access tokens when `tokenExpiresIn` is not configured
|
|
47
|
-
*/
|
|
48
|
-
export declare const DEFAULT_TOKEN_EXPIRES_IN = "1h";
|
|
49
|
-
export declare const ALLOWED_SYMMETRIC_ALGORITHMS: readonly ["HS256", "HS384", "HS512"];
|
|
50
46
|
export type JwtSymmetricAlgorithm = (typeof ALLOWED_SYMMETRIC_ALGORITHMS)[number];
|
|
51
|
-
export declare const ALLOWED_ASYMMETRIC_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
|
52
47
|
/**
|
|
53
48
|
* Algorithms supported for asymmetric (RSA / ECDSA) JWT signing and verification.
|
|
54
49
|
* Use with `privateKey` + `publicKey` so access tokens can be verified elsewhere with only the public key.
|
|
55
50
|
*/
|
|
56
51
|
export type JwtAsymmetricAlgorithm = (typeof ALLOWED_ASYMMETRIC_ALGORITHMS)[number];
|
|
57
|
-
export declare const ALLOWED_JWKS_ALGORITHMS: readonly ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"];
|
|
58
52
|
export type JwtJwksAlgorithm = (typeof ALLOWED_JWKS_ALGORITHMS)[number];
|
|
59
53
|
/**
|
|
60
|
-
*
|
|
54
|
+
* Tokens returned by `generate()` and `generateWithRefreshToken()`.
|
|
55
|
+
* Lifetimes are in seconds, like `expires_in` in OAuth 2.0 (RFC 6749),
|
|
56
|
+
* whatever the format used in the config (`'1h'` gives `3600`).
|
|
57
|
+
* `refreshToken` and `refreshTokenExpiresIn` are set when the guard has
|
|
58
|
+
* a `refreshTokenUserProvider`.
|
|
61
59
|
*/
|
|
62
60
|
export type JwtGenerateResult<WithRefreshToken extends boolean = boolean> = {
|
|
63
|
-
type:
|
|
61
|
+
type: 'bearer';
|
|
64
62
|
token: string;
|
|
65
|
-
expiresIn
|
|
66
|
-
refreshTokenExpiresIn?: number | StringValue;
|
|
63
|
+
expiresIn: number;
|
|
67
64
|
} & ([WithRefreshToken] extends [true] ? {
|
|
68
65
|
refreshToken: string;
|
|
66
|
+
refreshTokenExpiresIn: number;
|
|
69
67
|
} : {
|
|
70
68
|
refreshToken?: string;
|
|
69
|
+
refreshTokenExpiresIn?: number;
|
|
71
70
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*/
|
|
77
|
-
driver?: JwtDriver;
|
|
71
|
+
/**
|
|
72
|
+
* Options shared by `jwtGuard()` and the `JwtGuard` constructor
|
|
73
|
+
*/
|
|
74
|
+
type JwtSharedOptions = {
|
|
78
75
|
/**
|
|
79
76
|
* Symmetric signing secret (HMAC). Used when asymmetric keys are not set.
|
|
80
77
|
*/
|
|
81
|
-
secret?: string
|
|
78
|
+
secret?: string | Secret<string>;
|
|
82
79
|
/**
|
|
83
80
|
* PEM-encoded private key for signing access tokens (asymmetric mode).
|
|
84
81
|
*/
|
|
85
|
-
privateKey?: string
|
|
82
|
+
privateKey?: string | Secret<string>;
|
|
86
83
|
/**
|
|
87
84
|
* PEM-encoded public key for verifying access tokens (asymmetric mode).
|
|
88
85
|
*/
|
|
@@ -92,7 +89,6 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
|
|
|
92
89
|
*/
|
|
93
90
|
algorithm?: JwtAsymmetricAlgorithm;
|
|
94
91
|
jwks?: Options;
|
|
95
|
-
refreshTokenUserProvider?: AccessTokensUserProviderContract<RealUser>;
|
|
96
92
|
tokenName?: string;
|
|
97
93
|
/**
|
|
98
94
|
* Name of the refresh token cookie and request body field.
|
|
@@ -112,7 +108,6 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
|
|
|
112
108
|
*/
|
|
113
109
|
refreshTokenAbilities?: string[];
|
|
114
110
|
cookie?: JwtCookieOptions;
|
|
115
|
-
content?: (user: JwtGuardUser<RealUser>) => Record<string, any> & BaseJwtContent;
|
|
116
111
|
/**
|
|
117
112
|
* Expected JWT issuer (iss). Validated during token verification.
|
|
118
113
|
* Required in JWKS mode.
|
|
@@ -132,11 +127,6 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
|
|
|
132
127
|
* Number of seconds of clock skew tolerated when checking `exp` / `nbf`.
|
|
133
128
|
*/
|
|
134
129
|
clockTolerance?: number;
|
|
135
|
-
/**
|
|
136
|
-
* Extract the user id from a verified token payload.
|
|
137
|
-
* Defaults to `payload.userId`. Use e.g. `(payload) => payload.sub` for external providers.
|
|
138
|
-
*/
|
|
139
|
-
getUserId?: JwtGetUserId;
|
|
140
130
|
/**
|
|
141
131
|
* Extra check on a verified token payload, run before looking up the user.
|
|
142
132
|
* Only an explicit `true` accepts the token: any other value, or an
|
|
@@ -145,5 +135,39 @@ export type JwtGuardOptions<RealUser extends any = unknown> = {
|
|
|
145
135
|
*/
|
|
146
136
|
verifyPayload?: JwtVerifyPayload;
|
|
147
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* `content` builds the payload of the access tokens. Without `getUserId`,
|
|
140
|
+
* the guard reads the user id from the `userId` claim, so `content` must
|
|
141
|
+
* set it. With `getUserId`, `content` is free to use another claim.
|
|
142
|
+
*/
|
|
143
|
+
export type JwtContentOptions<RealUser> = {
|
|
144
|
+
/**
|
|
145
|
+
* Extract the user id from a verified token payload.
|
|
146
|
+
* Defaults to `payload.userId`. Use e.g. `(payload) => payload.sub` for external providers.
|
|
147
|
+
*/
|
|
148
|
+
getUserId?: undefined;
|
|
149
|
+
content?: (user: JwtGuardUser<RealUser>) => Record<string, any> & BaseJwtContent;
|
|
150
|
+
} | {
|
|
151
|
+
/**
|
|
152
|
+
* Extract the user id from a verified token payload.
|
|
153
|
+
* Defaults to `payload.userId`. Use e.g. `(payload) => payload.sub` for external providers.
|
|
154
|
+
*/
|
|
155
|
+
getUserId: JwtGetUserId;
|
|
156
|
+
content?: (user: JwtGuardUser<RealUser>) => Record<string, any>;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Options of the `JwtGuard` constructor
|
|
160
|
+
*/
|
|
161
|
+
export type JwtGuardOptions<RealUser = unknown> = JwtSharedOptions & {
|
|
162
|
+
refreshTokenUserProvider?: AccessTokensUserProviderContract<RealUser>;
|
|
163
|
+
} & JwtContentOptions<RealUser>;
|
|
164
|
+
/**
|
|
165
|
+
* Config accepted by `jwtGuard()` in `config/auth.ts`
|
|
166
|
+
*/
|
|
167
|
+
export type JwtGuardConfig<UserProvider extends JwtUserProviderContract<unknown>, RefreshTokenUserProvider extends AccessTokensUserProviderContract<unknown> | undefined = undefined> = JwtSharedOptions & {
|
|
168
|
+
provider: UserProvider;
|
|
169
|
+
refreshTokenUserProvider?: RefreshTokenUserProvider;
|
|
170
|
+
} & JwtContentOptions<UserProvider[typeof symbols.PROVIDER_REAL_USER]>;
|
|
148
171
|
export type JwtVerifyPayload = (payload: Record<string, any>) => boolean | Promise<boolean>;
|
|
149
172
|
export type JwtGetUserId = (payload: Record<string, any>) => string | number | BigInt | undefined | null;
|
|
173
|
+
export {};
|
package/build/src/types.js
CHANGED
|
@@ -1,19 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Lifetime of access tokens when `tokenExpiresIn` is not configured
|
|
3
|
-
*/
|
|
4
|
-
export const DEFAULT_TOKEN_EXPIRES_IN = '1h';
|
|
5
|
-
export const ALLOWED_SYMMETRIC_ALGORITHMS = ['HS256', 'HS384', 'HS512'];
|
|
6
|
-
export const ALLOWED_ASYMMETRIC_ALGORITHMS = [
|
|
7
|
-
'RS256',
|
|
8
|
-
'RS384',
|
|
9
|
-
'RS512',
|
|
10
|
-
'ES256',
|
|
11
|
-
'ES384',
|
|
12
|
-
'ES512',
|
|
13
|
-
];
|
|
14
|
-
export const ALLOWED_JWKS_ALGORITHMS = [
|
|
15
|
-
...ALLOWED_ASYMMETRIC_ALGORITHMS,
|
|
16
|
-
'PS256',
|
|
17
|
-
'PS384',
|
|
18
|
-
'PS512',
|
|
19
|
-
];
|
|
1
|
+
export {};
|
|
@@ -1,2 +1,30 @@
|
|
|
1
1
|
import type { JwtGuardOptions } from './types.js';
|
|
2
|
-
|
|
2
|
+
import type { JwtGuardInternalOptions } from './drivers/types.js';
|
|
3
|
+
export declare function validateGuardOptions(options: ReleasedSecrets<JwtGuardOptions & JwtGuardInternalOptions>, prefix?: string): void;
|
|
4
|
+
/**
|
|
5
|
+
* Token lifetimes and clock tolerance. A lifetime that is not a positive
|
|
6
|
+
* duration would issue tokens that are already expired (`'-1h'`), or fail
|
|
7
|
+
* on every login (`'abc'`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function validateLifetimes(options: Pick<JwtGuardOptions, 'tokenExpiresIn' | 'refreshTokenExpiresIn' | 'clockTolerance'>, prefix: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* Exactly one signing method: a secret, an asymmetric key pair, or a JWKS endpoint
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateSigningMethod(options: ReleasedSecrets<JwtGuardOptions>, prefix: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Accept `Secret` values (`Env.schema.secret()` in AdonisJS v7) for the
|
|
16
|
+
* signing keys, and work with their string value
|
|
17
|
+
*/
|
|
18
|
+
export type ReleasedSecrets<Options> = Options extends any ? Omit<Options, 'secret' | 'privateKey'> & {
|
|
19
|
+
secret?: string;
|
|
20
|
+
privateKey?: string;
|
|
21
|
+
} : never;
|
|
22
|
+
export declare function releaseSecrets<Options extends {
|
|
23
|
+
secret?: unknown;
|
|
24
|
+
privateKey?: unknown;
|
|
25
|
+
}>(options: Options): ReleasedSecrets<Options>;
|
|
26
|
+
/**
|
|
27
|
+
* A lifetime from the config (a number of seconds or a `ms` duration such
|
|
28
|
+
* as `'1h'`), as a whole number of seconds. Lifetimes are validated first.
|
|
29
|
+
*/
|
|
30
|
+
export declare function toSeconds(value: number | string): number;
|
package/build/src/validation.js
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
throw new Error(`${prefix} \`tokenExpiresIn\` must be a positive number of seconds`);
|
|
5
|
-
}
|
|
1
|
+
import ms from 'ms';
|
|
2
|
+
export function validateGuardOptions(options, prefix = 'JWT guard') {
|
|
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;
|
|
@@ -15,10 +54,64 @@ export function validateGuardOptions(options, prefix = 'JwtGuard') {
|
|
|
15
54
|
if (asymmetricPartial && !usesAsymmetric) {
|
|
16
55
|
throw new Error(`${prefix} asymmetric mode requires \`privateKey\`, \`publicKey\`, and \`algorithm\` to be set together`);
|
|
17
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A falsy `verifyPayload` (`null`, `false`) must not count as a payload
|
|
59
|
+
* check: in JWKS mode it would replace the required `audience`
|
|
60
|
+
*/
|
|
61
|
+
if (options.verifyPayload !== undefined && typeof options.verifyPayload !== 'function') {
|
|
62
|
+
throw new Error(`${prefix} \`verifyPayload\` must be a function`);
|
|
63
|
+
}
|
|
64
|
+
if (options.jwks && options.refreshTokenUserProvider) {
|
|
65
|
+
throw new Error(`${prefix} cannot use refresh tokens with \`jwks\`: tokens are issued by the identity provider, remove \`refreshTokenUserProvider\``);
|
|
66
|
+
}
|
|
67
|
+
if (options.secret !== undefined && typeof options.secret !== 'string') {
|
|
68
|
+
throw new Error(`${prefix} \`secret\` must be a string or a Secret`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* In JWKS mode, the driver checks them with its own message. Elsewhere,
|
|
72
|
+
* an empty value is signed into every token, then rejects all of them.
|
|
73
|
+
*/
|
|
74
|
+
if (!options.jwks) {
|
|
75
|
+
if (options.issuer !== undefined && !isNonEmptyString(options.issuer)) {
|
|
76
|
+
throw new Error(`${prefix} \`issuer\` must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
if (options.audience !== undefined &&
|
|
79
|
+
!(Array.isArray(options.audience)
|
|
80
|
+
? options.audience.length > 0 && options.audience.every(isNonEmptyString)
|
|
81
|
+
: isNonEmptyString(options.audience))) {
|
|
82
|
+
throw new Error(`${prefix} \`audience\` must be a non-empty string or array of strings`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
18
85
|
if (options.jwks && usesAsymmetric) {
|
|
19
86
|
throw new Error(`${prefix} cannot use \`jwks\` together with asymmetric \`privateKey\` / \`publicKey\``);
|
|
20
87
|
}
|
|
88
|
+
if (options.jwks &&
|
|
89
|
+
!(typeof options.jwks.jwksUri === 'string' && options.jwks.jwksUri.trim() !== '')) {
|
|
90
|
+
throw new Error(`${prefix} \`jwks.jwksUri\` is required to fetch the identity provider keys`);
|
|
91
|
+
}
|
|
21
92
|
if (!options.jwks && !usesAsymmetric && !options.secret) {
|
|
22
93
|
throw new Error(`${prefix} requires \`secret\` (symmetric), or \`privateKey\` + \`publicKey\` + \`algorithm\` (asymmetric), or \`jwks\``);
|
|
23
94
|
}
|
|
24
95
|
}
|
|
96
|
+
function isNonEmptyString(value) {
|
|
97
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
98
|
+
}
|
|
99
|
+
export function releaseSecrets(options) {
|
|
100
|
+
return {
|
|
101
|
+
...options,
|
|
102
|
+
...(options.secret !== undefined ? { secret: release(options.secret) } : {}),
|
|
103
|
+
...(options.privateKey !== undefined ? { privateKey: release(options.privateKey) } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function release(value) {
|
|
107
|
+
return value && typeof value.release === 'function'
|
|
108
|
+
? value.release()
|
|
109
|
+
: value;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A lifetime from the config (a number of seconds or a `ms` duration such
|
|
113
|
+
* as `'1h'`), as a whole number of seconds. Lifetimes are validated first.
|
|
114
|
+
*/
|
|
115
|
+
export function toSeconds(value) {
|
|
116
|
+
return Math.floor(typeof value === 'number' ? value : ms(value) / 1000);
|
|
117
|
+
}
|