@nestjs/authentication 0.0.1 → 0.1.0
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 +64 -6
- package/dist/account/password-reset.service.d.ts +1 -1
- package/dist/account/password-reset.service.js +1 -1
- package/dist/authentication.constants.d.ts +9 -0
- package/dist/authentication.constants.js +9 -0
- package/dist/authentication.module-definition.js +13 -4
- package/dist/decorators/authenticate.decorator.js +4 -1
- package/dist/decorators/current-user.decorator.d.ts +3 -1
- package/dist/decorators/current-user.decorator.js +2 -0
- package/dist/decorators/public.decorator.d.ts +3 -0
- package/dist/decorators/public.decorator.js +3 -0
- package/dist/errors/authentication.error.d.ts +4 -2
- package/dist/errors/authentication.error.js +6 -2
- package/dist/errors/magic-link.error.d.ts +1 -1
- package/dist/errors/magic-link.error.js +1 -1
- package/dist/events/authentication-events.interface.d.ts +16 -1
- package/dist/events/authentication-events.service.d.ts +2 -1
- package/dist/events/authentication-events.service.js +2 -1
- package/dist/guards/authentication.guard.d.ts +4 -2
- package/dist/guards/authentication.guard.js +9 -6
- package/dist/index.d.ts +7 -2
- package/dist/interceptors/authentication-scope.interceptor.js +2 -2
- package/dist/interfaces/authentication-error-options.interface.d.ts +12 -2
- package/dist/interfaces/authentication-module-options.interface.d.ts +13 -1
- package/dist/interfaces/authentication-result.interface.d.ts +7 -0
- package/dist/interfaces/jwt-options.interface.d.ts +13 -7
- package/dist/interfaces/jwt.interface.d.ts +4 -3
- package/dist/interfaces/session-store.interface.d.ts +29 -4
- package/dist/interfaces/token.interface.d.ts +31 -4
- package/dist/jwt/jwks.client.js +10 -3
- package/dist/jwt/jws.util.d.ts +10 -1
- package/dist/jwt/jws.util.js +108 -16
- package/dist/jwt/jwt-signer.service.js +2 -2
- package/dist/jwt/jwt-verifier.service.js +6 -4
- package/dist/jwt/token.service.d.ts +29 -8
- package/dist/jwt/token.service.js +48 -8
- package/dist/mfa/mfa.service.d.ts +11 -1
- package/dist/mfa/mfa.service.js +19 -9
- package/dist/oidc/oidc.client.d.ts +1 -1
- package/dist/oidc/oidc.client.js +1 -1
- package/dist/session/in-memory-session.store.d.ts +2 -0
- package/dist/session/in-memory-session.store.js +3 -1
- package/dist/session/session-cookie.provider.d.ts +17 -1
- package/dist/session/session-cookie.provider.js +16 -1
- package/dist/session/session.service.d.ts +18 -2
- package/dist/session/session.service.js +43 -8
- package/dist/session/sign-in.service.d.ts +6 -5
- package/dist/session/sign-in.service.js +6 -5
- package/dist/storage/authentication.storage.d.ts +25 -16
- package/dist/storage/authentication.storage.js +25 -16
- package/dist/testing/index.js +13 -5
- package/dist/utils/transport-errors.util.d.ts +23 -12
- package/dist/utils/transport-errors.util.js +34 -22
- package/package.json +25 -25
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MfaState } from './authentication-result.interface.js';
|
|
1
|
+
import type { MfaState, SessionExtra } from './authentication-result.interface.js';
|
|
2
2
|
export interface SessionRecord {
|
|
3
3
|
/** SHA-256 of the cookie token. The token itself is never stored. */
|
|
4
4
|
id: string;
|
|
@@ -11,6 +11,19 @@ export interface SessionRecord {
|
|
|
11
11
|
mfa?: MfaState;
|
|
12
12
|
/** App data: user agent, IP, device name, for "your sessions" pages. */
|
|
13
13
|
metadata?: Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Whatever `getSession()` read along with the session, typically its user
|
|
16
|
+
* from the same query (`JOIN users`), handed to
|
|
17
|
+
* `SessionCookieProvider.validate()` so a cookie request costs one read.
|
|
18
|
+
* Typed by `sessionExtra` on `AuthenticationTypes`.
|
|
19
|
+
*
|
|
20
|
+
* Never stored: `SessionService` leaves it out of every `createSession()`
|
|
21
|
+
* (rotations included), and `@CurrentSession()`, `request.session` and
|
|
22
|
+
* `AuthenticationContext.session` don't carry it. Join fresh data on every
|
|
23
|
+
* read; don't keep a copy of the user on the session row, which goes stale
|
|
24
|
+
* when the user changes.
|
|
25
|
+
*/
|
|
26
|
+
extra?: SessionExtra;
|
|
14
27
|
}
|
|
15
28
|
/**
|
|
16
29
|
* Where sessions live. Implement it on your database or Redis, and register
|
|
@@ -22,12 +35,16 @@ export interface SessionRecord {
|
|
|
22
35
|
* from `SessionService`'s clock, never the store's.
|
|
23
36
|
*/
|
|
24
37
|
export interface SessionStore {
|
|
25
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The session, or `undefined`. Expired sessions may be returned: `SessionService` checks expiry.
|
|
40
|
+
* May set `extra` to what the same query read with it (the user, joined), for
|
|
41
|
+
* `SessionCookieProvider.validate()`.
|
|
42
|
+
*/
|
|
26
43
|
getSession(id: string): Promise<SessionRecord | undefined>;
|
|
27
44
|
/**
|
|
28
45
|
* Saves a new session (a fresh random id: a plain insert). A good place
|
|
29
46
|
* to delete sessions whose `expiresAt` has passed, so the store stays
|
|
30
|
-
* bounded.
|
|
47
|
+
* bounded. `record` never carries `extra`.
|
|
31
48
|
*/
|
|
32
49
|
createSession(record: SessionRecord): Promise<void>;
|
|
33
50
|
/**
|
|
@@ -36,6 +53,11 @@ export interface SessionStore {
|
|
|
36
53
|
* WHERE id = ? AND last_active_at < ?`, `SET … XX` in Redis. A read
|
|
37
54
|
* followed by a write lets a request that read the session before a
|
|
38
55
|
* sign-out or rotation deleted it bring it back. Changes no other field.
|
|
56
|
+
*
|
|
57
|
+
* Best-effort: a rejection is published as `session-touch-failed` (every
|
|
58
|
+
* one) and logged (once per spell of failures), but the request goes on
|
|
59
|
+
* with the session as read. A missed touch only means the session goes idle at its previous
|
|
60
|
+
* deadline, unless a later request records activity.
|
|
39
61
|
*/
|
|
40
62
|
touchSession(id: string, lastActiveAt: Date): Promise<void>;
|
|
41
63
|
/**
|
|
@@ -46,7 +68,10 @@ export interface SessionStore {
|
|
|
46
68
|
* rotations), only one may win.
|
|
47
69
|
*/
|
|
48
70
|
deleteSession(id: string): Promise<boolean>;
|
|
49
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The user's sessions, in any order (`[]` when none), expired ones included.
|
|
73
|
+
* Without `extra`: they feed "your sessions" pages.
|
|
74
|
+
*/
|
|
50
75
|
listUserSessions(userId: string): Promise<SessionRecord[]>;
|
|
51
76
|
/** Deletes every session of the user ("sign out everywhere"). */
|
|
52
77
|
deleteUserSessions(userId: string): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthenticationTypes } from '../index.js';
|
|
1
2
|
export interface IssueTokensOptions {
|
|
2
3
|
/**
|
|
3
4
|
* Claims that describe the sign-in (`amr`, `auth_time`, …): this access
|
|
@@ -19,12 +20,38 @@ export interface IssueTokensOptions {
|
|
|
19
20
|
recoveryCode?: string;
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* What `TokenService.issue()` returns with `refreshToken: false`: an access
|
|
25
|
+
* token alone, which the client replaces by signing in again.
|
|
26
|
+
*/
|
|
27
|
+
export interface AccessTokenResult {
|
|
24
28
|
/** A JWT signed with the `accessToken` options. */
|
|
25
29
|
accessToken: string;
|
|
26
|
-
/** Opaque, single use: `refresh()` exchanges it for a new pair. */
|
|
27
|
-
refreshToken: string;
|
|
28
30
|
/** Seconds until the access token expires (OAuth's `expires_in`). */
|
|
29
31
|
expiresIn: number;
|
|
30
32
|
}
|
|
33
|
+
/** What a token endpoint returns to a client that signed in. */
|
|
34
|
+
export interface TokenPair extends AccessTokenResult {
|
|
35
|
+
/** Opaque, single use: `refresh()` exchanges it for a new pair. */
|
|
36
|
+
refreshToken: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What `TokenService.issue()` returns: a {@link TokenPair}, or, for an app
|
|
40
|
+
* that declared `refreshTokens: false` on `AuthenticationTypes` (to go with
|
|
41
|
+
* the `refreshToken: false` option), an {@link AccessTokenResult}:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* declare module '@nestjs/authentication' {
|
|
45
|
+
* interface AuthenticationTypes {
|
|
46
|
+
* refreshTokens: false;
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* The declaration is what the compiler sees; the option is what runs. An
|
|
52
|
+
* app that sets the option without the declaration gets no `refreshToken`
|
|
53
|
+
* at run time, typed as a string, and `refresh()` throws on it.
|
|
54
|
+
*/
|
|
55
|
+
export type IssuedTokens<Types = AuthenticationTypes> = Types extends {
|
|
56
|
+
refreshTokens: false;
|
|
57
|
+
} ? AccessTokenResult : TokenPair;
|
package/dist/jwt/jwks.client.js
CHANGED
|
@@ -3,12 +3,19 @@ import { durationOr } from '../utils/duration.util.js';
|
|
|
3
3
|
import { JwtError } from '../errors/jwt.error.js';
|
|
4
4
|
import { ASYMMETRIC_ALGORITHMS } from './jws.util.js';
|
|
5
5
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
|
+
/** The algorithm of each curve a JWK can name (`crv`, RFC 7518 §6.2.1.1). Other curves are skipped. */
|
|
7
|
+
const EC_ALGORITHMS = { 'P-256': 'ES256', 'P-384': 'ES384', 'P-521': 'ES512' };
|
|
8
|
+
/**
|
|
9
|
+
* What a JWK can verify, by its type and curve. An RSA key serves every RS and
|
|
10
|
+
* PS algorithm: its `alg`, when published, narrows that, and the verifier's
|
|
11
|
+
* `algorithms` decide which of them are accepted at all.
|
|
12
|
+
*/
|
|
6
13
|
function algorithmsFor(jwk) {
|
|
7
14
|
if (jwk.kty === 'RSA') {
|
|
8
|
-
return ['RS256'];
|
|
15
|
+
return ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'];
|
|
9
16
|
}
|
|
10
|
-
if (jwk.kty === 'EC' && jwk.crv
|
|
11
|
-
return [
|
|
17
|
+
if (jwk.kty === 'EC' && jwk.crv && EC_ALGORITHMS[jwk.crv]) {
|
|
18
|
+
return [EC_ALGORITHMS[jwk.crv]];
|
|
12
19
|
}
|
|
13
20
|
if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') {
|
|
14
21
|
return ['EdDSA'];
|
package/dist/jwt/jws.util.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { KeyObject } from 'node:crypto';
|
|
2
2
|
import type { JwsAlgorithm, JwtClaims, ClaimRules } from '../interfaces/jwt.interface.js';
|
|
3
|
+
/** The algorithms a public key verifies: the only ones a JWKS can serve (HS secrets are never published). */
|
|
3
4
|
export declare const ASYMMETRIC_ALGORITHMS: readonly JwsAlgorithm[];
|
|
4
5
|
export interface JwsHeader {
|
|
5
6
|
alg: string;
|
|
@@ -18,14 +19,22 @@ export interface JwsHeader {
|
|
|
18
19
|
* `createPrivateKey`) and pass the `KeyObject`.
|
|
19
20
|
*/
|
|
20
21
|
export declare function toKeyObject(key: KeyObject | string | Buffer): KeyObject;
|
|
21
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The algorithm a key implies: HS256 for a secret, RS256 for an RSA key (the
|
|
24
|
+
* key does not name a hash or padding: PS and the longer hashes are opted
|
|
25
|
+
* into with `alg`), and the curve's own for an EC key.
|
|
26
|
+
*/
|
|
22
27
|
export declare function algorithmFor(key: KeyObject): JwsAlgorithm | undefined;
|
|
28
|
+
/** The configuration error for a key no algorithm fits: an EC key names its curve, as EC keys of other curves are. */
|
|
29
|
+
export declare function unsupportedKeyError(key: KeyObject, owner: string): TypeError;
|
|
23
30
|
/**
|
|
24
31
|
* Whether `key` is appropriate for `alg`. This is the algorithm/key binding
|
|
25
32
|
* that stops key-confusion attacks: an RSA public key can never be used as
|
|
26
33
|
* an HMAC secret, a P-384 key never verifies ES256, and so on.
|
|
27
34
|
*/
|
|
28
35
|
export declare function keyFits(alg: JwsAlgorithm, key: KeyObject): boolean;
|
|
36
|
+
/** HS256, HS384, HS512: signed and verified with the same secret, so no private key is involved. */
|
|
37
|
+
export declare const isHmac: (alg: JwsAlgorithm) => boolean;
|
|
29
38
|
/** Throws a `TypeError` (configuration, not a token) unless `key` can serve `alg`. */
|
|
30
39
|
export declare function assertKeyFits(alg: JwsAlgorithm, key: KeyObject, owner: string): void;
|
|
31
40
|
/** Throws a `TypeError` (configuration, not a token) unless `key` can sign `alg`. */
|
package/dist/jwt/jws.util.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import { KeyObject, X509Certificate, createHmac, createPrivateKey, createPublicKey, createSecretKey, sign as cryptoSign, verify as cryptoVerify, timingSafeEqual, } from 'node:crypto';
|
|
1
|
+
import { KeyObject, X509Certificate, constants, createHmac, createPrivateKey, createPublicKey, createSecretKey, sign as cryptoSign, verify as cryptoVerify, timingSafeEqual, } from 'node:crypto';
|
|
2
2
|
import { toMs } from '../utils/duration.util.js';
|
|
3
3
|
import { JwtError } from '../errors/jwt.error.js';
|
|
4
|
-
|
|
4
|
+
/** The algorithms a public key verifies: the only ones a JWKS can serve (HS secrets are never published). */
|
|
5
|
+
export const ASYMMETRIC_ALGORITHMS = [
|
|
6
|
+
'RS256',
|
|
7
|
+
'RS384',
|
|
8
|
+
'RS512',
|
|
9
|
+
'PS256',
|
|
10
|
+
'PS384',
|
|
11
|
+
'PS512',
|
|
12
|
+
'ES256',
|
|
13
|
+
'ES384',
|
|
14
|
+
'ES512',
|
|
15
|
+
'EdDSA',
|
|
16
|
+
];
|
|
17
|
+
/** `HS384` → `sha384`: the digest every algorithm but EdDSA names in its last three digits. */
|
|
18
|
+
const hashOf = (alg) => `sha${alg.slice(2)}`;
|
|
19
|
+
/** The curve each ECDSA algorithm is bound to (RFC 7518 §3.4), by its OpenSSL name. */
|
|
20
|
+
const CURVES = { ES256: 'prime256v1', ES384: 'secp384r1', ES512: 'secp521r1' };
|
|
21
|
+
/** The reverse: the one algorithm an EC key of each curve implies. Other curves (secp256k1, …) imply none. */
|
|
22
|
+
const CURVE_ALGORITHMS = { prime256v1: 'ES256', secp384r1: 'ES384', secp521r1: 'ES512' };
|
|
23
|
+
/** Raw r||s length: twice the curve's coordinate size (P-521's is 66 bytes, not 64). */
|
|
24
|
+
const ECDSA_SIGNATURE_BYTES = { ES256: 64, ES384: 96, ES512: 132 };
|
|
5
25
|
const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
6
26
|
const PEM = /-----BEGIN ([A-Z0-9 ]+)-----/g;
|
|
7
27
|
/**
|
|
@@ -40,7 +60,11 @@ export function toKeyObject(key) {
|
|
|
40
60
|
throw new TypeError(`Cannot read the PEM ${label.toLowerCase()}: ${error.message}`);
|
|
41
61
|
}
|
|
42
62
|
}
|
|
43
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* The algorithm a key implies: HS256 for a secret, RS256 for an RSA key (the
|
|
65
|
+
* key does not name a hash or padding: PS and the longer hashes are opted
|
|
66
|
+
* into with `alg`), and the curve's own for an EC key.
|
|
67
|
+
*/
|
|
44
68
|
export function algorithmFor(key) {
|
|
45
69
|
if (key.type === 'secret') {
|
|
46
70
|
return 'HS256';
|
|
@@ -49,13 +73,20 @@ export function algorithmFor(key) {
|
|
|
49
73
|
case 'rsa':
|
|
50
74
|
return 'RS256';
|
|
51
75
|
case 'ec':
|
|
52
|
-
return '
|
|
76
|
+
return CURVE_ALGORITHMS[key.asymmetricKeyDetails?.namedCurve ?? ''];
|
|
53
77
|
case 'ed25519':
|
|
54
78
|
return 'EdDSA';
|
|
55
79
|
default:
|
|
56
80
|
return undefined;
|
|
57
81
|
}
|
|
58
82
|
}
|
|
83
|
+
/** The configuration error for a key no algorithm fits: an EC key names its curve, as EC keys of other curves are. */
|
|
84
|
+
export function unsupportedKeyError(key, owner) {
|
|
85
|
+
const curve = key.asymmetricKeyType === 'ec' ? key.asymmetricKeyDetails?.namedCurve : undefined;
|
|
86
|
+
return new TypeError(curve
|
|
87
|
+
? `${owner}: EC keys on ${curve} are not supported (use P-256, P-384 or P-521).`
|
|
88
|
+
: `${owner}: ${key.asymmetricKeyType} keys are not supported.`);
|
|
89
|
+
}
|
|
59
90
|
/**
|
|
60
91
|
* Whether `key` is appropriate for `alg`. This is the algorithm/key binding
|
|
61
92
|
* that stops key-confusion attacks: an RSA public key can never be used as
|
|
@@ -64,24 +95,53 @@ export function algorithmFor(key) {
|
|
|
64
95
|
export function keyFits(alg, key) {
|
|
65
96
|
const details = key.asymmetricKeyDetails;
|
|
66
97
|
switch (alg) {
|
|
98
|
+
// A secret at least as long as the hash's output (RFC 7518 §3.2).
|
|
67
99
|
case 'HS256':
|
|
68
100
|
return key.type === 'secret' && (key.symmetricKeySize ?? 0) >= 32;
|
|
101
|
+
case 'HS384':
|
|
102
|
+
return key.type === 'secret' && (key.symmetricKeySize ?? 0) >= 48;
|
|
103
|
+
case 'HS512':
|
|
104
|
+
return key.type === 'secret' && (key.symmetricKeySize ?? 0) >= 64;
|
|
105
|
+
// One RSA key serves both paddings and every hash: the header names which, and the verifier's
|
|
106
|
+
// `algorithms` decide which are accepted. Node's `rsa-pss` keys are refused (asymmetricKeyType
|
|
107
|
+
// 'rsa-pss'): they restrict their own hash and salt, which a JWKS cannot even publish.
|
|
69
108
|
case 'RS256':
|
|
109
|
+
case 'RS384':
|
|
110
|
+
case 'RS512':
|
|
111
|
+
case 'PS256':
|
|
112
|
+
case 'PS384':
|
|
113
|
+
case 'PS512':
|
|
70
114
|
return key.asymmetricKeyType === 'rsa' && (details?.modulusLength ?? 0) >= 2048;
|
|
115
|
+
// Each ECDSA algorithm takes its own curve only: an ES256 token never verifies with a P-384 key.
|
|
71
116
|
case 'ES256':
|
|
72
|
-
|
|
117
|
+
case 'ES384':
|
|
118
|
+
case 'ES512':
|
|
119
|
+
return key.asymmetricKeyType === 'ec' && details?.namedCurve === CURVES[alg];
|
|
73
120
|
case 'EdDSA':
|
|
74
121
|
return key.asymmetricKeyType === 'ed25519';
|
|
75
122
|
default:
|
|
76
123
|
return false;
|
|
77
124
|
}
|
|
78
125
|
}
|
|
126
|
+
/** What each algorithm needs, for the configuration error that names it. */
|
|
127
|
+
const RSA_KEY = 'an RSA key of at least 2048 bits';
|
|
79
128
|
const REQUIREMENTS = {
|
|
80
129
|
HS256: 'a secret of at least 32 bytes (`openssl rand -base64 32`)',
|
|
81
|
-
|
|
130
|
+
HS384: 'a secret of at least 48 bytes (`openssl rand -base64 48`)',
|
|
131
|
+
HS512: 'a secret of at least 64 bytes (`openssl rand -base64 64`)',
|
|
132
|
+
RS256: RSA_KEY,
|
|
133
|
+
RS384: RSA_KEY,
|
|
134
|
+
RS512: RSA_KEY,
|
|
135
|
+
PS256: RSA_KEY,
|
|
136
|
+
PS384: RSA_KEY,
|
|
137
|
+
PS512: RSA_KEY,
|
|
82
138
|
ES256: 'a P-256 key',
|
|
139
|
+
ES384: 'a P-384 key',
|
|
140
|
+
ES512: 'a P-521 key',
|
|
83
141
|
EdDSA: 'an Ed25519 key',
|
|
84
142
|
};
|
|
143
|
+
/** HS256, HS384, HS512: signed and verified with the same secret, so no private key is involved. */
|
|
144
|
+
export const isHmac = (alg) => alg.startsWith('HS');
|
|
85
145
|
/** Throws a `TypeError` (configuration, not a token) unless `key` can serve `alg`. */
|
|
86
146
|
export function assertKeyFits(alg, key, owner) {
|
|
87
147
|
if (!keyFits(alg, key)) {
|
|
@@ -91,7 +151,7 @@ export function assertKeyFits(alg, key, owner) {
|
|
|
91
151
|
/** Throws a `TypeError` (configuration, not a token) unless `key` can sign `alg`. */
|
|
92
152
|
export function assertSigningKey(alg, key, owner) {
|
|
93
153
|
assertKeyFits(alg, key, owner);
|
|
94
|
-
if (alg
|
|
154
|
+
if (!isHmac(alg) && key.type !== 'private') {
|
|
95
155
|
throw new TypeError(`${owner}: ${alg} signing needs a private key.`);
|
|
96
156
|
}
|
|
97
157
|
}
|
|
@@ -99,16 +159,28 @@ export function signJws(header, payload, key) {
|
|
|
99
159
|
assertSigningKey(header.alg, key, 'JwtSigner');
|
|
100
160
|
const input = `${encode(header)}.${encode(payload)}`;
|
|
101
161
|
const data = Buffer.from(input);
|
|
162
|
+
const { alg } = header;
|
|
102
163
|
let signature;
|
|
103
|
-
switch (
|
|
164
|
+
switch (alg) {
|
|
104
165
|
case 'HS256':
|
|
105
|
-
|
|
166
|
+
case 'HS384':
|
|
167
|
+
case 'HS512':
|
|
168
|
+
signature = createHmac(hashOf(alg), key).update(data).digest();
|
|
106
169
|
break;
|
|
107
170
|
case 'RS256':
|
|
108
|
-
|
|
171
|
+
case 'RS384':
|
|
172
|
+
case 'RS512':
|
|
173
|
+
signature = cryptoSign(hashOf(alg), data, key); // PKCS#1 v1.5 padding, Node's default for RSA
|
|
174
|
+
break;
|
|
175
|
+
case 'PS256':
|
|
176
|
+
case 'PS384':
|
|
177
|
+
case 'PS512':
|
|
178
|
+
signature = cryptoSign(hashOf(alg), data, pss(key));
|
|
109
179
|
break;
|
|
110
180
|
case 'ES256':
|
|
111
|
-
|
|
181
|
+
case 'ES384':
|
|
182
|
+
case 'ES512':
|
|
183
|
+
signature = cryptoSign(hashOf(alg), data, { key, dsaEncoding: 'ieee-p1363' });
|
|
112
184
|
break;
|
|
113
185
|
case 'EdDSA':
|
|
114
186
|
signature = cryptoSign(null, data, key);
|
|
@@ -147,19 +219,31 @@ export function verifySignature(alg, key, decoded) {
|
|
|
147
219
|
const { signingInput, signature } = decoded;
|
|
148
220
|
let valid;
|
|
149
221
|
switch (alg) {
|
|
150
|
-
case 'HS256':
|
|
151
|
-
|
|
222
|
+
case 'HS256':
|
|
223
|
+
case 'HS384':
|
|
224
|
+
case 'HS512': {
|
|
225
|
+
const expected = createHmac(hashOf(alg), publicKey).update(signingInput).digest();
|
|
152
226
|
valid = signature.length === expected.length && timingSafeEqual(signature, expected);
|
|
153
227
|
break;
|
|
154
228
|
}
|
|
155
229
|
case 'RS256':
|
|
156
|
-
|
|
230
|
+
case 'RS384':
|
|
231
|
+
case 'RS512':
|
|
232
|
+
valid = cryptoVerify(hashOf(alg), signingInput, publicKey, signature);
|
|
233
|
+
break;
|
|
234
|
+
// Same key as RS, other padding: a PKCS#1 v1.5 signature presented as PS (or the reverse) fails here.
|
|
235
|
+
case 'PS256':
|
|
236
|
+
case 'PS384':
|
|
237
|
+
case 'PS512':
|
|
238
|
+
valid = cryptoVerify(hashOf(alg), signingInput, pss(publicKey), signature);
|
|
157
239
|
break;
|
|
158
240
|
case 'ES256':
|
|
241
|
+
case 'ES384':
|
|
242
|
+
case 'ES512':
|
|
159
243
|
// JWS uses the raw r||s form (RFC 7518 §3.4), not DER.
|
|
160
244
|
valid =
|
|
161
|
-
signature.length ===
|
|
162
|
-
cryptoVerify(
|
|
245
|
+
signature.length === ECDSA_SIGNATURE_BYTES[alg] &&
|
|
246
|
+
cryptoVerify(hashOf(alg), signingInput, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature);
|
|
163
247
|
break;
|
|
164
248
|
case 'EdDSA':
|
|
165
249
|
valid = signature.length === 64 && cryptoVerify(null, signingInput, publicKey, signature);
|
|
@@ -171,6 +255,14 @@ export function verifySignature(alg, key, decoded) {
|
|
|
171
255
|
throw new JwtError('invalid signature');
|
|
172
256
|
}
|
|
173
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* RSASSA-PSS options (RFC 7518 §3.5): MGF1 on the same hash (Node's default),
|
|
260
|
+
* and a salt exactly as long as the hash. Verifying with this exact length,
|
|
261
|
+
* not "any", refuses signatures made with another salt length.
|
|
262
|
+
*/
|
|
263
|
+
function pss(key) {
|
|
264
|
+
return { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: constants.RSA_PSS_SALTLEN_DIGEST };
|
|
265
|
+
}
|
|
174
266
|
export function validateClaims(claims, rules) {
|
|
175
267
|
// JWT times are seconds (RFC 7519 §2, NumericDate).
|
|
176
268
|
const now = Math.floor((rules.now?.() ?? Date.now()) / 1000);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomToken } from '../utils/crypto.util.js';
|
|
2
2
|
import { durationOr, toMs } from '../utils/duration.util.js';
|
|
3
|
-
import { algorithmFor, assertSigningKey, signJws, toKeyObject } from './jws.util.js';
|
|
3
|
+
import { algorithmFor, assertSigningKey, signJws, toKeyObject, unsupportedKeyError } from './jws.util.js';
|
|
4
4
|
/**
|
|
5
5
|
* Issues signed JWTs. Adds `iat`, `exp`, `jti` and the configured
|
|
6
6
|
* `iss`/`aud`; callers pass `sub` and custom claims. The module's
|
|
@@ -14,7 +14,7 @@ export class JwtSigner {
|
|
|
14
14
|
this.key = toKeyObject(options.key);
|
|
15
15
|
const alg = options.alg ?? algorithmFor(this.key);
|
|
16
16
|
if (!alg) {
|
|
17
|
-
throw
|
|
17
|
+
throw unsupportedKeyError(this.key, 'JwtSigner');
|
|
18
18
|
}
|
|
19
19
|
assertSigningKey(alg, this.key, 'JwtSigner');
|
|
20
20
|
this.alg = alg;
|
|
@@ -2,7 +2,7 @@ import { createPublicKey } from 'node:crypto';
|
|
|
2
2
|
import { toMs } from '../utils/duration.util.js';
|
|
3
3
|
import { JwtError } from '../errors/jwt.error.js';
|
|
4
4
|
import { JwksClient } from './jwks.client.js';
|
|
5
|
-
import { algorithmFor, assertKeyFits, decodeJws, toKeyObject, validateClaims, verifySignature } from './jws.util.js';
|
|
5
|
+
import { algorithmFor, assertKeyFits, decodeJws, isHmac, toKeyObject, unsupportedKeyError, validateClaims, verifySignature, } from './jws.util.js';
|
|
6
6
|
/**
|
|
7
7
|
* Verifies compact JWS tokens and their registered claims. `verify()`
|
|
8
8
|
* throws {@link JwtError}; a configuration that could not verify anything
|
|
@@ -26,7 +26,7 @@ export class JwtVerifier {
|
|
|
26
26
|
if (this.key) {
|
|
27
27
|
const implied = algorithmFor(this.key);
|
|
28
28
|
if (!implied) {
|
|
29
|
-
throw
|
|
29
|
+
throw unsupportedKeyError(this.key, 'JwtVerifier');
|
|
30
30
|
}
|
|
31
31
|
this.algorithms = options.algorithms ?? [implied];
|
|
32
32
|
for (const alg of this.algorithms) {
|
|
@@ -40,8 +40,10 @@ export class JwtVerifier {
|
|
|
40
40
|
}
|
|
41
41
|
else {
|
|
42
42
|
this.algorithms = options.algorithms ?? ['RS256', 'ES256'];
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// A JWKS publishes keys to everyone: an HMAC secret taken from one would let anyone sign.
|
|
44
|
+
const hmac = this.algorithms.find(isHmac);
|
|
45
|
+
if (hmac) {
|
|
46
|
+
throw new TypeError(`JwtVerifier: ${hmac} cannot be combined with a JWKS.`);
|
|
45
47
|
}
|
|
46
48
|
requireIssuerAndAudience(options, '`jwks`');
|
|
47
49
|
}
|
|
@@ -3,13 +3,13 @@ import { AuthenticationEvents } from '../events/authentication-events.service.js
|
|
|
3
3
|
import { MfaService } from '../mfa/mfa.service.js';
|
|
4
4
|
import type { JwtSignerOptions } from '../interfaces/jwt-options.interface.js';
|
|
5
5
|
import type { RefreshTokenOptions } from '../interfaces/refresh-token-options.interface.js';
|
|
6
|
-
import type { IssueTokensOptions, TokenPair } from '../interfaces/token.interface.js';
|
|
6
|
+
import type { IssueTokensOptions, IssuedTokens, TokenPair } from '../interfaces/token.interface.js';
|
|
7
7
|
/**
|
|
8
8
|
* Tokens for clients that do not keep cookies (mobile apps, CLIs): a
|
|
9
9
|
* short-lived JWT access token, and an opaque refresh token that renews it.
|
|
10
10
|
*
|
|
11
11
|
* - `issue()` is to token clients what `SignInService.signIn()` is to
|
|
12
|
-
* browsers: a user with a confirmed authenticator gets no tokens without
|
|
12
|
+
* browsers: with `mfa` configured, a user with a confirmed authenticator gets no tokens without
|
|
13
13
|
* a second factor.
|
|
14
14
|
* - Access tokens are signed with the module's `accessToken` options, the
|
|
15
15
|
* same ones `JwtBearerProvider` verifies with by default.
|
|
@@ -19,6 +19,12 @@ import type { IssueTokensOptions, TokenPair } from '../interfaces/token.interfac
|
|
|
19
19
|
* concurrent presentations (`markRefreshTokenUsed()` is a compare-and-set), means two
|
|
20
20
|
* parties hold it: the whole family is revoked, and both have to sign in
|
|
21
21
|
* again (OAuth 2.0 Security BCP §4.14.2).
|
|
22
|
+
* - With `refreshToken: false`, there are none: `issue()` returns an access
|
|
23
|
+
* token alone and stores nothing, `refresh()` and `revoke()` throw, and
|
|
24
|
+
* `revokeAll()` does nothing. For a service that signs short-lived access
|
|
25
|
+
* tokens and asks the client to sign in again; a service that only
|
|
26
|
+
* verifies tokens can also leave `accessToken` out and give its
|
|
27
|
+
* `JwtBearerProvider` the key (`super({ key })`).
|
|
22
28
|
*/
|
|
23
29
|
export declare class TokenService {
|
|
24
30
|
private readonly storage;
|
|
@@ -32,14 +38,18 @@ export declare class TokenService {
|
|
|
32
38
|
private readonly ttl;
|
|
33
39
|
private readonly absoluteTtl;
|
|
34
40
|
private readonly clock?;
|
|
41
|
+
/** `refreshToken: false`: access tokens only, no families. */
|
|
42
|
+
private readonly refreshTokensOff;
|
|
35
43
|
constructor(storage: AuthenticationStorage, options?: {
|
|
36
44
|
accessToken?: JwtSignerOptions;
|
|
37
|
-
refreshToken?: RefreshTokenOptions;
|
|
45
|
+
refreshToken?: RefreshTokenOptions | false;
|
|
38
46
|
}, events?: AuthenticationEvents, mfa?: MfaService);
|
|
39
47
|
/**
|
|
40
48
|
* At sign-in: signs an access token and starts a refresh-token family.
|
|
49
|
+
* With `refreshToken: false`, returns the access token alone (typed by
|
|
50
|
+
* {@link IssuedTokens}), and stores nothing.
|
|
41
51
|
*
|
|
42
|
-
*
|
|
52
|
+
* With `mfa` configured, a user with a confirmed authenticator must send a second factor
|
|
43
53
|
* (`secondFactor`). Without one, `issue()` throws an
|
|
44
54
|
* `AuthenticationError` whose body says `mfa_required`, and counts
|
|
45
55
|
* nothing against the user's lockout; with a wrong one, `Invalid code`.
|
|
@@ -47,11 +57,12 @@ export declare class TokenService {
|
|
|
47
57
|
* `@Authenticate({ mfa: true })` accepts; `claims.amr` cannot (see
|
|
48
58
|
* {@link IssueTokensOptions.claims}).
|
|
49
59
|
*/
|
|
50
|
-
issue(userId: string, { claims, method, secondFactor }?: IssueTokensOptions): Promise<
|
|
60
|
+
issue(userId: string, { claims, method, secondFactor }?: IssueTokensOptions): Promise<IssuedTokens>;
|
|
51
61
|
/**
|
|
52
62
|
* Spends a refresh token: returns its successor, and an access token with
|
|
53
63
|
* the family's claims. Throws {@link RefreshTokenError} (`invalid`,
|
|
54
|
-
* `expired`, `reused`), which a route answers with 401.
|
|
64
|
+
* `expired`, `reused`), which a route answers with 401. Throws without
|
|
65
|
+
* `accessToken`, and with `refreshToken: false`.
|
|
55
66
|
*/
|
|
56
67
|
refresh(refreshToken: string): Promise<TokenPair>;
|
|
57
68
|
/** Two parties hold the token: the family is revoked, and the theft reported. */
|
|
@@ -60,14 +71,24 @@ export declare class TokenService {
|
|
|
60
71
|
* Signs out the client holding `refreshToken` (one device): revokes its
|
|
61
72
|
* family. Spent tokens of the family work too. Resolves `false` for
|
|
62
73
|
* unknown or malformed tokens, which a route should not reveal (RFC 7009
|
|
63
|
-
* §2.2).
|
|
74
|
+
* §2.2). Throws without `accessToken`, and with `refreshToken: false`, as
|
|
75
|
+
* `refresh()` does.
|
|
64
76
|
*/
|
|
65
77
|
revoke(refreshToken: string): Promise<boolean>;
|
|
66
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Signs the user out of every token client. Issued access tokens live
|
|
80
|
+
* until they expire. Without `accessToken`, or with `refreshToken: false`,
|
|
81
|
+
* this app issues no refresh tokens, and there is nothing to revoke: the
|
|
82
|
+
* store is not read. Apps that
|
|
83
|
+
* share a user database configure `accessToken` in all of them if any of
|
|
84
|
+
* them issues tokens: otherwise this one's sign-outs everywhere leave the
|
|
85
|
+
* token clients of the others signed in.
|
|
86
|
+
*/
|
|
67
87
|
revokeAll(userId: string): Promise<void>;
|
|
68
88
|
/** `true` when a second factor was required and verified; throws when one is required and missing or wrong. */
|
|
69
89
|
private checkSecondFactor;
|
|
70
90
|
private pair;
|
|
91
|
+
private requireRefreshTokens;
|
|
71
92
|
private requireSigner;
|
|
72
93
|
private create;
|
|
73
94
|
/**
|
|
@@ -28,7 +28,7 @@ import { JwtSigner } from './jwt-signer.service.js';
|
|
|
28
28
|
* short-lived JWT access token, and an opaque refresh token that renews it.
|
|
29
29
|
*
|
|
30
30
|
* - `issue()` is to token clients what `SignInService.signIn()` is to
|
|
31
|
-
* browsers: a user with a confirmed authenticator gets no tokens without
|
|
31
|
+
* browsers: with `mfa` configured, a user with a confirmed authenticator gets no tokens without
|
|
32
32
|
* a second factor.
|
|
33
33
|
* - Access tokens are signed with the module's `accessToken` options, the
|
|
34
34
|
* same ones `JwtBearerProvider` verifies with by default.
|
|
@@ -38,13 +38,20 @@ import { JwtSigner } from './jwt-signer.service.js';
|
|
|
38
38
|
* concurrent presentations (`markRefreshTokenUsed()` is a compare-and-set), means two
|
|
39
39
|
* parties hold it: the whole family is revoked, and both have to sign in
|
|
40
40
|
* again (OAuth 2.0 Security BCP §4.14.2).
|
|
41
|
+
* - With `refreshToken: false`, there are none: `issue()` returns an access
|
|
42
|
+
* token alone and stores nothing, `refresh()` and `revoke()` throw, and
|
|
43
|
+
* `revokeAll()` does nothing. For a service that signs short-lived access
|
|
44
|
+
* tokens and asks the client to sign in again; a service that only
|
|
45
|
+
* verifies tokens can also leave `accessToken` out and give its
|
|
46
|
+
* `JwtBearerProvider` the key (`super({ key })`).
|
|
41
47
|
*/
|
|
42
48
|
let TokenService = TokenService_1 = class TokenService {
|
|
43
49
|
constructor(storage, options, events = new AuthenticationEvents(), mfa = new MfaService(storage)) {
|
|
44
50
|
this.storage = storage;
|
|
45
51
|
this.events = events;
|
|
46
52
|
this.mfa = mfa;
|
|
47
|
-
|
|
53
|
+
this.refreshTokensOff = options?.refreshToken === false;
|
|
54
|
+
const refresh = options?.refreshToken || {};
|
|
48
55
|
this.ttl = durationOr(refresh.ttl, '30d');
|
|
49
56
|
this.absoluteTtl = durationOr(refresh.absoluteTtl, '90d');
|
|
50
57
|
this.clock = refresh.now;
|
|
@@ -62,8 +69,10 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
62
69
|
}
|
|
63
70
|
/**
|
|
64
71
|
* At sign-in: signs an access token and starts a refresh-token family.
|
|
72
|
+
* With `refreshToken: false`, returns the access token alone (typed by
|
|
73
|
+
* {@link IssuedTokens}), and stores nothing.
|
|
65
74
|
*
|
|
66
|
-
*
|
|
75
|
+
* With `mfa` configured, a user with a confirmed authenticator must send a second factor
|
|
67
76
|
* (`secondFactor`). Without one, `issue()` throws an
|
|
68
77
|
* `AuthenticationError` whose body says `mfa_required`, and counts
|
|
69
78
|
* nothing against the user's lockout; with a wrong one, `Invalid code`.
|
|
@@ -76,6 +85,16 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
76
85
|
const verified = await this.checkSecondFactor(userId, secondFactor);
|
|
77
86
|
const own = this.ownClaims(claims);
|
|
78
87
|
const familyClaims = verified ? { ...own, amr: [...(own?.amr ?? []), 'mfa'] } : own;
|
|
88
|
+
if (this.refreshTokensOff) {
|
|
89
|
+
this.events.emit({
|
|
90
|
+
type: 'sign-in',
|
|
91
|
+
userId,
|
|
92
|
+
...(method && { method }),
|
|
93
|
+
...(verified && { mfa: 'verified' }),
|
|
94
|
+
});
|
|
95
|
+
const issued = { accessToken: signer.sign({ ...familyClaims, sub: userId }), expiresIn: this.expiresIn };
|
|
96
|
+
return issued;
|
|
97
|
+
}
|
|
79
98
|
const familyExpiresAt = new Date(this.now() + this.absoluteTtl);
|
|
80
99
|
const { token, record } = await this.create(userId, randomToken(16), familyExpiresAt, familyClaims);
|
|
81
100
|
this.events.emit({
|
|
@@ -90,10 +109,12 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
90
109
|
/**
|
|
91
110
|
* Spends a refresh token: returns its successor, and an access token with
|
|
92
111
|
* the family's claims. Throws {@link RefreshTokenError} (`invalid`,
|
|
93
|
-
* `expired`, `reused`), which a route answers with 401.
|
|
112
|
+
* `expired`, `reused`), which a route answers with 401. Throws without
|
|
113
|
+
* `accessToken`, and with `refreshToken: false`.
|
|
94
114
|
*/
|
|
95
115
|
async refresh(refreshToken) {
|
|
96
116
|
const signer = this.requireSigner();
|
|
117
|
+
this.requireRefreshTokens('refresh');
|
|
97
118
|
if (typeof refreshToken !== 'string' || !TOKEN_PATTERN.test(refreshToken)) {
|
|
98
119
|
throw new RefreshTokenError('invalid');
|
|
99
120
|
}
|
|
@@ -131,9 +152,12 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
131
152
|
* Signs out the client holding `refreshToken` (one device): revokes its
|
|
132
153
|
* family. Spent tokens of the family work too. Resolves `false` for
|
|
133
154
|
* unknown or malformed tokens, which a route should not reveal (RFC 7009
|
|
134
|
-
* §2.2).
|
|
155
|
+
* §2.2). Throws without `accessToken`, and with `refreshToken: false`, as
|
|
156
|
+
* `refresh()` does.
|
|
135
157
|
*/
|
|
136
158
|
async revoke(refreshToken) {
|
|
159
|
+
this.requireSigner();
|
|
160
|
+
this.requireRefreshTokens('revoke');
|
|
137
161
|
if (typeof refreshToken !== 'string' || !TOKEN_PATTERN.test(refreshToken)) {
|
|
138
162
|
return false;
|
|
139
163
|
}
|
|
@@ -146,9 +170,20 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
146
170
|
this.events.emit({ type: 'sign-out', userId: record.userId, tokenFamilyId: record.familyId });
|
|
147
171
|
return true;
|
|
148
172
|
}
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Signs the user out of every token client. Issued access tokens live
|
|
175
|
+
* until they expire. Without `accessToken`, or with `refreshToken: false`,
|
|
176
|
+
* this app issues no refresh tokens, and there is nothing to revoke: the
|
|
177
|
+
* store is not read. Apps that
|
|
178
|
+
* share a user database configure `accessToken` in all of them if any of
|
|
179
|
+
* them issues tokens: otherwise this one's sign-outs everywhere leave the
|
|
180
|
+
* token clients of the others signed in.
|
|
181
|
+
*/
|
|
182
|
+
async revokeAll(userId) {
|
|
183
|
+
if (!this.signer || this.refreshTokensOff) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await this.storage.refreshTokens.revokeUserRefreshTokens(userId);
|
|
152
187
|
}
|
|
153
188
|
/** `true` when a second factor was required and verified; throws when one is required and missing or wrong. */
|
|
154
189
|
async checkSecondFactor(userId, secondFactor) {
|
|
@@ -171,6 +206,11 @@ let TokenService = TokenService_1 = class TokenService {
|
|
|
171
206
|
const accessToken = signer.sign({ ...record.claims, sub: record.userId });
|
|
172
207
|
return { accessToken, refreshToken, expiresIn: this.expiresIn };
|
|
173
208
|
}
|
|
209
|
+
requireRefreshTokens(method) {
|
|
210
|
+
if (this.refreshTokensOff) {
|
|
211
|
+
throw new Error(`TokenService.${method}(): refresh tokens are disabled (\`refreshToken: false\` in the AuthenticationModule options)`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
174
214
|
requireSigner() {
|
|
175
215
|
if (!this.signer) {
|
|
176
216
|
throw new Error('TokenService: configure `accessToken` (the signing key) in the AuthenticationModule options');
|