@nestjs/authentication 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -6
- package/dist/account/password-reset.service.d.ts +9 -3
- package/dist/account/password-reset.service.js +16 -4
- 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 +5 -4
- package/dist/errors/magic-link.error.js +6 -5
- 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/magic-link.interface.d.ts +2 -1
- package/dist/interfaces/oidc.interface.d.ts +9 -1
- 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/magic-link/magic-link.service.d.ts +4 -1
- package/dist/magic-link/magic-link.service.js +40 -10
- 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/oidc/oidc.service.d.ts +1 -0
- package/dist/oidc/oidc.service.js +22 -8
- 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 +28 -28
|
@@ -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');
|
|
@@ -18,7 +18,9 @@ import type { MagicLinkOptions, MagicLinkRequest, CreatedMagicLink } from '../in
|
|
|
18
18
|
* site's page must not start a link here), and sets an `HttpOnly`,
|
|
19
19
|
* `SameSite=Lax` cookie, `__Host-magic_link_tx`, holding a secret that no
|
|
20
20
|
* link carries; the store keys the link by the token and that secret
|
|
21
|
-
* together, so `consume()` finds it only with the cookie. The
|
|
21
|
+
* together, so `consume()` finds it only with the cookie. The cookie holds
|
|
22
|
+
* the secrets of the last five links the browser requested, so a "resend"
|
|
23
|
+
* leaves the link before it usable there. The `__Host-`
|
|
22
24
|
* prefix keeps sibling subdomains from planting one. So the link's page
|
|
23
25
|
* must be on the same site as the API, as the session cookie requires
|
|
24
26
|
* anyway, and a link forwarded to another browser or device, or read from
|
|
@@ -73,6 +75,7 @@ export declare class MagicLinkService {
|
|
|
73
75
|
private feature;
|
|
74
76
|
/** The generic answer: which refusal it was is told only by the event. */
|
|
75
77
|
private refuse;
|
|
78
|
+
private pendingCookie;
|
|
76
79
|
private txCookie;
|
|
77
80
|
private setCookie;
|
|
78
81
|
private now;
|
|
@@ -37,6 +37,24 @@ function linkId(token, binding) {
|
|
|
37
37
|
function hintOf(token) {
|
|
38
38
|
return sha256(`nestjs-authentication magic-link hint ${token}`).slice(0, 12);
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* How many links a browser holds the secrets of: a "resend" leaves the
|
|
42
|
+
* links it requested before usable there, up to this many.
|
|
43
|
+
*/
|
|
44
|
+
const PENDING_LINKS = 5;
|
|
45
|
+
const HINT_PATTERN = /^[\w-]{12}$/;
|
|
46
|
+
/** The links in the cookie, newest first: `<hint>.<secret>`, joined by `~`. Anything else is dropped. */
|
|
47
|
+
function readPending(value) {
|
|
48
|
+
return (value ?? '')
|
|
49
|
+
.split('~')
|
|
50
|
+
.map((pair) => pair.split('.'))
|
|
51
|
+
.filter(([hint, secret, ...rest]) => HINT_PATTERN.test(hint) && TOKEN_PATTERN.test(secret ?? '') && !rest.length)
|
|
52
|
+
.slice(0, PENDING_LINKS)
|
|
53
|
+
.map(([hint, secret]) => ({ hint, secret }));
|
|
54
|
+
}
|
|
55
|
+
function writePending(links) {
|
|
56
|
+
return links.map(({ hint, secret }) => `${hint}.${secret}`).join('~');
|
|
57
|
+
}
|
|
40
58
|
/**
|
|
41
59
|
* Passwordless sign-in links: 256-bit single-use tokens, stored hashed,
|
|
42
60
|
* short-lived, and bound to the browser that requested them.
|
|
@@ -49,7 +67,9 @@ function hintOf(token) {
|
|
|
49
67
|
* site's page must not start a link here), and sets an `HttpOnly`,
|
|
50
68
|
* `SameSite=Lax` cookie, `__Host-magic_link_tx`, holding a secret that no
|
|
51
69
|
* link carries; the store keys the link by the token and that secret
|
|
52
|
-
* together, so `consume()` finds it only with the cookie. The
|
|
70
|
+
* together, so `consume()` finds it only with the cookie. The cookie holds
|
|
71
|
+
* the secrets of the last five links the browser requested, so a "resend"
|
|
72
|
+
* leaves the link before it usable there. The `__Host-`
|
|
53
73
|
* prefix keeps sibling subdomains from planting one. So the link's page
|
|
54
74
|
* must be on the same site as the API, as the session cookie requires
|
|
55
75
|
* anyway, and a link forwarded to another browser or device, or read from
|
|
@@ -106,7 +126,10 @@ let MagicLinkService = class MagicLinkService {
|
|
|
106
126
|
});
|
|
107
127
|
let cookie;
|
|
108
128
|
if (binding) {
|
|
109
|
-
|
|
129
|
+
// The links this browser requested before stay usable here, the oldest dropped past the cap.
|
|
130
|
+
const hint = hintOf(token);
|
|
131
|
+
const before = readPending(this.pendingCookie(this.scope.exchange()?.request)).filter((link) => link.hint !== hint);
|
|
132
|
+
cookie = this.txCookie(writePending([{ hint, secret: binding }, ...before].slice(0, PENDING_LINKS)), this.ttl / 1000);
|
|
110
133
|
this.setCookie(cookie);
|
|
111
134
|
}
|
|
112
135
|
const url = new URL(options.url);
|
|
@@ -131,23 +154,27 @@ let MagicLinkService = class MagicLinkService {
|
|
|
131
154
|
return this.refuse('unknown');
|
|
132
155
|
}
|
|
133
156
|
let binding;
|
|
157
|
+
let pending = [];
|
|
158
|
+
let link;
|
|
134
159
|
if (this.bindToBrowser) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// The hint names the link this browser requested; anyone holding the link can compute it, so it
|
|
160
|
+
pending = readPending(this.pendingCookie(request ?? this.scope.exchange()?.request));
|
|
161
|
+
// The hint names a link this browser requested; anyone holding the link can compute it, so it
|
|
138
162
|
// only tells a link opened elsewhere from a bad one. The secret, which no link carries, is what
|
|
139
163
|
// the link is stored under: a leaked or forwarded link finds nothing without it. On a refusal
|
|
140
|
-
// here the cookie stays (it
|
|
164
|
+
// here the cookie stays (it holds the links this browser did request), and nothing is looked
|
|
141
165
|
// up: a link was presented, not proved.
|
|
142
|
-
|
|
166
|
+
const hint = hintOf(token);
|
|
167
|
+
link = pending.find((candidate) => safeEqual(candidate.hint, hint));
|
|
168
|
+
if (!link) {
|
|
143
169
|
this.events.emit({ type: 'magic-link-refused', reason: 'not-this-browser' });
|
|
144
170
|
throw new MagicLinkError();
|
|
145
171
|
}
|
|
146
|
-
binding = secret;
|
|
172
|
+
binding = link.secret;
|
|
147
173
|
}
|
|
148
174
|
const record = await this.storage.magicLinks.consumeMagicLink(linkId(token, binding));
|
|
149
|
-
// This
|
|
150
|
-
|
|
175
|
+
// This link is settled either way: its secret goes, and the cookie with it when it held no other.
|
|
176
|
+
const rest = writePending(pending.filter((candidate) => candidate !== link));
|
|
177
|
+
this.setCookie(this.txCookie(rest, rest ? this.ttl / 1000 : 0));
|
|
151
178
|
if (!record) {
|
|
152
179
|
return this.refuse('unknown');
|
|
153
180
|
}
|
|
@@ -185,6 +212,9 @@ let MagicLinkService = class MagicLinkService {
|
|
|
185
212
|
this.events.emit({ type: 'magic-link-refused', reason, ...(email && { email }) });
|
|
186
213
|
return null;
|
|
187
214
|
}
|
|
215
|
+
pendingCookie(request) {
|
|
216
|
+
return readCookie(firstHeader(request?.headers, 'cookie'), this.txCookieName);
|
|
217
|
+
}
|
|
188
218
|
txCookie(value, maxAge) {
|
|
189
219
|
return serializeCookie(this.txCookieName, value, { ...this.options?.cookie, sameSite: 'lax', maxAge });
|
|
190
220
|
}
|
|
@@ -37,6 +37,12 @@ export declare class MfaService {
|
|
|
37
37
|
constructor(storage: AuthenticationStorage, options?: {
|
|
38
38
|
mfa?: MfaOptions;
|
|
39
39
|
}, events?: AuthenticationEvents);
|
|
40
|
+
/**
|
|
41
|
+
* Whether the user has a confirmed authenticator. Without the `mfa`
|
|
42
|
+
* option, MFA is off: `false`, without reading the store, even for a user
|
|
43
|
+
* who enrolled through another app sharing the user database. Every app
|
|
44
|
+
* that signs those users in must configure `mfa` if any of them does.
|
|
45
|
+
*/
|
|
40
46
|
isEnrolled(userId: string): Promise<boolean>;
|
|
41
47
|
/**
|
|
42
48
|
* Starts an enrollment. Throws {@link MfaAlreadyEnrolledError} when the user
|
|
@@ -97,7 +103,11 @@ export declare class MfaService {
|
|
|
97
103
|
private seal;
|
|
98
104
|
private open;
|
|
99
105
|
private matchingStep;
|
|
100
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Read at each call, never in the constructor: the registry locks once the
|
|
108
|
+
* module starts. Without the `mfa` option it throws before reading: MFA is
|
|
109
|
+
* off, and the production guard asks for no `MfaStore`.
|
|
110
|
+
*/
|
|
101
111
|
private get store();
|
|
102
112
|
private get maxAttempts();
|
|
103
113
|
private now;
|
package/dist/mfa/mfa.service.js
CHANGED
|
@@ -71,7 +71,7 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
71
71
|
this.options = options?.mfa;
|
|
72
72
|
this.lockoutWindow = durationOr(this.options?.lockoutWindow, '15m');
|
|
73
73
|
if (!this.options) {
|
|
74
|
-
return; // MFA
|
|
74
|
+
return; // MFA off: isEnrolled() answers false, everything else throws, and the store is never read
|
|
75
75
|
}
|
|
76
76
|
if (this.options.encryption === undefined) {
|
|
77
77
|
throw new Error('MFA: `mfa.encryption` is required. TOTP secrets cannot be hashed, so a store dump would let anyone ' +
|
|
@@ -86,7 +86,16 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
86
86
|
requireIntegerOption(this.options.window, 'mfa.window', { min: 0, max: 10 });
|
|
87
87
|
requireIntegerOption(this.options.recoveryCodes, 'mfa.recoveryCodes', { min: 1 });
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the user has a confirmed authenticator. Without the `mfa`
|
|
91
|
+
* option, MFA is off: `false`, without reading the store, even for a user
|
|
92
|
+
* who enrolled through another app sharing the user database. Every app
|
|
93
|
+
* that signs those users in must configure `mfa` if any of them does.
|
|
94
|
+
*/
|
|
89
95
|
async isEnrolled(userId) {
|
|
96
|
+
if (!this.options) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
90
99
|
// Truthy, not `=== true`: a store that reads the flag back as `1` must not skip the second factor.
|
|
91
100
|
return !!(await this.store.getTotp(userId))?.confirmed;
|
|
92
101
|
}
|
|
@@ -211,7 +220,7 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
211
220
|
this.failed(userId, 'recovery-code', attempt);
|
|
212
221
|
return false;
|
|
213
222
|
}
|
|
214
|
-
remainingRecoveryCodes(userId) {
|
|
223
|
+
async remainingRecoveryCodes(userId) {
|
|
215
224
|
return this.store.countRecoveryCodes(userId);
|
|
216
225
|
}
|
|
217
226
|
/**
|
|
@@ -271,15 +280,9 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
271
280
|
this.events.emit({ type: 'mfa-failed', userId, method, failures: attempt, locked: attempt >= this.maxAttempts });
|
|
272
281
|
}
|
|
273
282
|
seal(userId, secret) {
|
|
274
|
-
if (!this.options) {
|
|
275
|
-
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
276
|
-
}
|
|
277
283
|
return this.cipher ? this.cipher.encrypt(secret, `totp.${userId}`) : secret;
|
|
278
284
|
}
|
|
279
285
|
open(userId, stored) {
|
|
280
|
-
if (!this.options) {
|
|
281
|
-
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
282
|
-
}
|
|
283
286
|
if (!this.cipher) {
|
|
284
287
|
return { plaintext: stored, needsReencrypt: false };
|
|
285
288
|
}
|
|
@@ -322,8 +325,15 @@ let MfaService = MfaService_1 = class MfaService {
|
|
|
322
325
|
}
|
|
323
326
|
return found;
|
|
324
327
|
}
|
|
325
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* Read at each call, never in the constructor: the registry locks once the
|
|
330
|
+
* module starts. Without the `mfa` option it throws before reading: MFA is
|
|
331
|
+
* off, and the production guard asks for no `MfaStore`.
|
|
332
|
+
*/
|
|
326
333
|
get store() {
|
|
334
|
+
if (!this.options) {
|
|
335
|
+
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
336
|
+
}
|
|
327
337
|
return this.storage.mfa;
|
|
328
338
|
}
|
|
329
339
|
get maxAttempts() {
|
|
@@ -5,7 +5,7 @@ import type { OidcTransaction, OidcClientOptions } from '../interfaces/oidc.inte
|
|
|
5
5
|
* PKCE (S256), `state` and `nonce`.
|
|
6
6
|
*
|
|
7
7
|
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
8
|
-
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
8
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256 by default,
|
|
9
9
|
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
10
10
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
11
11
|
*/
|
package/dist/oidc/oidc.client.js
CHANGED
|
@@ -29,7 +29,7 @@ function assertSecureUrl(value, what) {
|
|
|
29
29
|
* PKCE (S256), `state` and `nonce`.
|
|
30
30
|
*
|
|
31
31
|
* Checks, in order: IdP error, `iss` response parameter (RFC 9207, mix-up
|
|
32
|
-
* defence), token response shape, ID token signature (JWKS, RS256/ES256,
|
|
32
|
+
* defence), token response shape, ID token signature (JWKS, RS256/ES256 by default,
|
|
33
33
|
* alg/key binding), `iss`, `aud`, `azp`, `exp`, `iat`, `nonce`, and that
|
|
34
34
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
35
35
|
*/
|
|
@@ -82,6 +82,7 @@ export declare class OidcService {
|
|
|
82
82
|
private feature;
|
|
83
83
|
/** A fully signed-in session (second factor done, if any) on this request. */
|
|
84
84
|
private currentSession;
|
|
85
|
+
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
85
86
|
private callbackUrl;
|
|
86
87
|
private txCookie;
|
|
87
88
|
private now;
|
|
@@ -79,7 +79,9 @@ let OidcService = class OidcService {
|
|
|
79
79
|
this.clients = new Map();
|
|
80
80
|
this.options = options?.oidc;
|
|
81
81
|
if (this.options) {
|
|
82
|
-
|
|
82
|
+
if (typeof this.options.callbackUrl !== 'function') {
|
|
83
|
+
assertCallbackUrl(this.options.callbackUrl);
|
|
84
|
+
}
|
|
83
85
|
assertProviderCredentials(this.options.providers);
|
|
84
86
|
}
|
|
85
87
|
this.transactionTtl = durationOr(this.options?.transactionTtl, '10m');
|
|
@@ -120,7 +122,7 @@ let OidcService = class OidcService {
|
|
|
120
122
|
linking = { userId: session.userId, sessionId: session.id };
|
|
121
123
|
}
|
|
122
124
|
const { url, transaction, binding } = await this.client(provider)
|
|
123
|
-
.authorizationRequest(this.callbackUrl(provider), safeRedirectPath(redirectTo), this.transactionTtl)
|
|
125
|
+
.authorizationRequest(await this.callbackUrl(provider, req), safeRedirectPath(redirectTo), this.transactionTtl)
|
|
124
126
|
.catch(toHttp);
|
|
125
127
|
await this.storage.oidcStates.saveOidcState({ ...transaction, ...(linking && { link: linking }) });
|
|
126
128
|
return this.redirect(exchange?.response, url, [this.txCookie(binding, this.transactionTtl / 1000)]);
|
|
@@ -146,7 +148,10 @@ let OidcService = class OidcService {
|
|
|
146
148
|
if (!transaction || transaction.provider !== provider || hasExpired(transaction.expiresAt, this.now())) {
|
|
147
149
|
throw new BadRequestException('unknown or expired login');
|
|
148
150
|
}
|
|
149
|
-
|
|
151
|
+
// Asked again rather than stored with the login: the cookie keeps the callback on the login's host,
|
|
152
|
+
// and a function that answers otherwise gets the code refused by the provider, not accepted.
|
|
153
|
+
const callbackUrl = await this.callbackUrl(provider, req);
|
|
154
|
+
const result = await this.client(provider).callback(query, transaction, callbackUrl).catch(toHttp);
|
|
150
155
|
const location = transaction.redirectTo ?? options.redirectAfterLogin ?? '/';
|
|
151
156
|
if (transaction.link) {
|
|
152
157
|
// The session that asked to link must still be the one on this browser.
|
|
@@ -194,8 +199,14 @@ let OidcService = class OidcService {
|
|
|
194
199
|
const session = await this.sessions.validate(this.sessions.tokenFrom(request));
|
|
195
200
|
return session && session.mfa !== 'pending' ? session : null;
|
|
196
201
|
}
|
|
197
|
-
callbackUrl
|
|
198
|
-
|
|
202
|
+
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
203
|
+
async callbackUrl(provider, request) {
|
|
204
|
+
const { callbackUrl } = this.feature().options;
|
|
205
|
+
const url = typeof callbackUrl === 'function' ? await callbackUrl(request) : callbackUrl;
|
|
206
|
+
if (typeof callbackUrl === 'function') {
|
|
207
|
+
assertCallbackUrl(url, 'the `oidc.callbackUrl` function must return');
|
|
208
|
+
}
|
|
209
|
+
return url.replaceAll(':provider', encodeURIComponent(provider));
|
|
199
210
|
}
|
|
200
211
|
txCookie(value, maxAge) {
|
|
201
212
|
return serializeCookie(this.txCookieName, value, { ...this.options?.cookie, sameSite: 'lax', maxAge });
|
|
@@ -216,8 +227,11 @@ OidcService = __decorate([
|
|
|
216
227
|
HttpAdapterHost])
|
|
217
228
|
], OidcService);
|
|
218
229
|
export { OidcService };
|
|
219
|
-
/**
|
|
220
|
-
|
|
230
|
+
/**
|
|
231
|
+
* `oidc.callbackUrl`, checked at startup (or each URL its function returns,
|
|
232
|
+
* when it returns it): an absolute http(s) URL.
|
|
233
|
+
*/
|
|
234
|
+
function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
|
|
221
235
|
let url;
|
|
222
236
|
try {
|
|
223
237
|
url = typeof value === 'string' ? new URL(value.replaceAll(':provider', 'provider')) : undefined;
|
|
@@ -226,7 +240,7 @@ function assertCallbackUrl(value) {
|
|
|
226
240
|
url = undefined;
|
|
227
241
|
}
|
|
228
242
|
if (!url || (url.protocol !== 'https:' && url.protocol !== 'http:')) {
|
|
229
|
-
throw new Error(
|
|
243
|
+
throw new Error(`OidcService: ${must} the absolute URL of your callback route, with \`:provider\` where the ` +
|
|
230
244
|
`provider's name goes, e.g. 'https://app.example.com/auth/oidc/:provider/callback'. Got ${JSON.stringify(value)}.`);
|
|
231
245
|
}
|
|
232
246
|
}
|
|
@@ -15,6 +15,7 @@ export declare class InMemorySessionStore implements SessionStore {
|
|
|
15
15
|
lastActiveAt: Date;
|
|
16
16
|
mfa?: import("../index.js").MfaState;
|
|
17
17
|
metadata?: Record<string, unknown>;
|
|
18
|
+
extra?: import("../index.js").SessionExtra;
|
|
18
19
|
} | undefined>;
|
|
19
20
|
createSession(record: SessionRecord): Promise<void>;
|
|
20
21
|
touchSession(id: string, lastActiveAt: Date): Promise<void>;
|
|
@@ -27,6 +28,7 @@ export declare class InMemorySessionStore implements SessionStore {
|
|
|
27
28
|
lastActiveAt: Date;
|
|
28
29
|
mfa?: import("../index.js").MfaState;
|
|
29
30
|
metadata?: Record<string, unknown>;
|
|
31
|
+
extra?: import("../index.js").SessionExtra;
|
|
30
32
|
}[]>;
|
|
31
33
|
deleteUserSessions(userId: string): Promise<void>;
|
|
32
34
|
private sweep;
|
|
@@ -20,7 +20,9 @@ let InMemorySessionStore = class InMemorySessionStore {
|
|
|
20
20
|
return record && { ...record };
|
|
21
21
|
}
|
|
22
22
|
async createSession(record) {
|
|
23
|
-
|
|
23
|
+
// `extra` is read with a session, never stored.
|
|
24
|
+
const { extra: _, ...stored } = record;
|
|
25
|
+
this.sessions.set(record.id, stored);
|
|
24
26
|
// The new session's own clock, so tests with a fake one agree.
|
|
25
27
|
if (this.sessions.size >= this.sweepAt) {
|
|
26
28
|
this.sweep(record.lastActiveAt.getTime());
|
|
@@ -18,6 +18,19 @@ import { SessionService } from './session.service.js';
|
|
|
18
18
|
* }
|
|
19
19
|
* ```
|
|
20
20
|
*
|
|
21
|
+
* A store whose `getSession()` reads the user in the same query (a `JOIN`)
|
|
22
|
+
* can hand it over in `session.extra`, typed by `sessionExtra` on
|
|
23
|
+
* `AuthenticationTypes`, which saves the second read on every request:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* validate(session: SessionRecord) {
|
|
27
|
+
* return session.extra?.user ?? null;
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* `extra` stays out of the result: `@CurrentSession()`, `request.session`
|
|
32
|
+
* and `AuthenticationContext.session` get the session without it.
|
|
33
|
+
*
|
|
21
34
|
* A missing, stale or unknown cookie counts as "no credentials" rather than
|
|
22
35
|
* a 401: browsers keep sending them, and optional routes should keep
|
|
23
36
|
* working. So does the cookie on a cross-origin POST, PUT, PATCH or DELETE
|
|
@@ -28,7 +41,10 @@ import { SessionService } from './session.service.js';
|
|
|
28
41
|
*/
|
|
29
42
|
export declare abstract class SessionCookieProvider<TUser> extends AuthenticationProvider<TUser, SessionRecord> {
|
|
30
43
|
protected readonly sessions: SessionService;
|
|
31
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Loads the user for a live session; `null` for deleted or banned users, which ends the session.
|
|
46
|
+
* `session.extra` is what the store read with the session, if anything.
|
|
47
|
+
*/
|
|
32
48
|
protected abstract validate(session: SessionRecord): TUser | null | undefined | Promise<TUser | null | undefined>;
|
|
33
49
|
authenticate(context: ExecutionContext): Promise<AuthenticationResult<TUser, SessionRecord> | null>;
|
|
34
50
|
/** @internal An instance created with `new` was not property-injected. */
|
|
@@ -26,6 +26,19 @@ import { SessionService } from './session.service.js';
|
|
|
26
26
|
* }
|
|
27
27
|
* ```
|
|
28
28
|
*
|
|
29
|
+
* A store whose `getSession()` reads the user in the same query (a `JOIN`)
|
|
30
|
+
* can hand it over in `session.extra`, typed by `sessionExtra` on
|
|
31
|
+
* `AuthenticationTypes`, which saves the second read on every request:
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* validate(session: SessionRecord) {
|
|
35
|
+
* return session.extra?.user ?? null;
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* `extra` stays out of the result: `@CurrentSession()`, `request.session`
|
|
40
|
+
* and `AuthenticationContext.session` get the session without it.
|
|
41
|
+
*
|
|
29
42
|
* A missing, stale or unknown cookie counts as "no credentials" rather than
|
|
30
43
|
* a 401: browsers keep sending them, and optional routes should keep
|
|
31
44
|
* working. So does the cookie on a cross-origin POST, PUT, PATCH or DELETE
|
|
@@ -45,7 +58,9 @@ export class SessionCookieProvider extends AuthenticationProvider {
|
|
|
45
58
|
await this.sessions.discard(session.id);
|
|
46
59
|
return null;
|
|
47
60
|
}
|
|
48
|
-
|
|
61
|
+
// What the store joined (often the user's row, password hash and all) is for `validate()` only.
|
|
62
|
+
const { extra: _, ...stored } = session;
|
|
63
|
+
return { user, session: stored, mfa: session.mfa };
|
|
49
64
|
}
|
|
50
65
|
/** @internal An instance created with `new` was not property-injected. */
|
|
51
66
|
[PROVIDER_INIT](resolve) {
|
|
@@ -18,6 +18,7 @@ type RequestHeaders = Record<string, string | string[] | undefined>;
|
|
|
18
18
|
export declare class SessionService {
|
|
19
19
|
private readonly storage;
|
|
20
20
|
private readonly events;
|
|
21
|
+
private static readonly logger;
|
|
21
22
|
private readonly options;
|
|
22
23
|
private readonly absoluteTtl;
|
|
23
24
|
private readonly pendingTtl;
|
|
@@ -25,6 +26,8 @@ export declare class SessionService {
|
|
|
25
26
|
private readonly touchInterval;
|
|
26
27
|
private readonly cookieName;
|
|
27
28
|
private readonly trustedOrigins;
|
|
29
|
+
/** Touches failed since the last successful one: a spell of failures logs one warning, not one per request. */
|
|
30
|
+
private failedTouches;
|
|
28
31
|
constructor(storage: AuthenticationStorage, options?: {
|
|
29
32
|
session?: SessionOptions;
|
|
30
33
|
mfa?: {
|
|
@@ -43,7 +46,11 @@ export declare class SessionService {
|
|
|
43
46
|
metadata?: Record<string, unknown>;
|
|
44
47
|
replacing?: string;
|
|
45
48
|
}): Promise<IssuedSession>;
|
|
46
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The live session for a token, sliding its idle timeout. Sliding is
|
|
51
|
+
* best-effort: when the store fails to record the activity, the session
|
|
52
|
+
* is still returned, as read (see `SessionStore.touchSession()`).
|
|
53
|
+
*/
|
|
47
54
|
validate(token: string | undefined): Promise<SessionRecord | null>;
|
|
48
55
|
/**
|
|
49
56
|
* Replaces the session id, keeping its user and absolute expiry. For
|
|
@@ -53,7 +60,8 @@ export declare class SessionService {
|
|
|
53
60
|
* `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
|
|
54
61
|
* Resolves `null` when the session was gone by the time it was replaced:
|
|
55
62
|
* revoked (a sign-out everywhere, a password reset) or rotated by another
|
|
56
|
-
* request, which leaves no new session behind.
|
|
63
|
+
* request, which leaves no new session behind. The session's `extra` is
|
|
64
|
+
* not carried over.
|
|
57
65
|
*/
|
|
58
66
|
rotate(session: SessionRecord, changes?: Pick<Partial<SessionRecord>, 'mfa' | 'metadata'>): Promise<IssuedSession | null>;
|
|
59
67
|
/**
|
|
@@ -90,6 +98,14 @@ export declare class SessionService {
|
|
|
90
98
|
clearCookie(): string;
|
|
91
99
|
/** @internal Deletes a session by id, for sessions whose user is gone. */
|
|
92
100
|
discard(sessionId: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Records activity, best-effort: the session was read live, so a failed write (a lock timeout,
|
|
103
|
+
* a read-only replica) does not fail the request, and the session keeps the idle deadline the
|
|
104
|
+
* store still has. Every failure is published as `session-touch-failed`; the log gets one warning
|
|
105
|
+
* per spell of failures (the first), then one line once a touch succeeds again, with how many
|
|
106
|
+
* failed in between.
|
|
107
|
+
*/
|
|
108
|
+
private touch;
|
|
93
109
|
private isLive;
|
|
94
110
|
private issue;
|
|
95
111
|
private now;
|