@nestjs/authentication 0.1.0 → 0.1.2
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/SECURITY.md +13 -0
- package/dist/account/email-verification.service.d.ts +3 -1
- package/dist/account/email-verification.service.js +18 -5
- package/dist/account/password-reset.service.d.ts +15 -2
- package/dist/account/password-reset.service.js +51 -15
- package/dist/errors/magic-link.error.d.ts +4 -3
- package/dist/errors/magic-link.error.js +5 -4
- package/dist/interfaces/email-token-store.interface.d.ts +3 -1
- package/dist/interfaces/magic-link.interface.d.ts +2 -1
- package/dist/interfaces/oidc.interface.d.ts +30 -2
- package/dist/magic-link/magic-link.service.d.ts +8 -2
- package/dist/magic-link/magic-link.service.js +47 -11
- package/dist/oidc/oidc.client.d.ts +30 -4
- package/dist/oidc/oidc.client.js +70 -36
- package/dist/oidc/oidc.service.d.ts +9 -3
- package/dist/oidc/oidc.service.js +69 -28
- package/dist/services/password-hasher.service.d.ts +8 -1
- package/dist/services/password-hasher.service.js +21 -1
- package/dist/testing/index.js +3 -0
- package/package.json +21 -21
package/SECURITY.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a Vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security issues to `support@nestjs.com` rather than in a public issue or pull request.
|
|
6
|
+
|
|
7
|
+
A good report includes:
|
|
8
|
+
|
|
9
|
+
- the version of `@nestjs/authentication` you found it in, and of Node.js,
|
|
10
|
+
- the steps or a short script that reproduce it,
|
|
11
|
+
- what an attacker could do with it.
|
|
12
|
+
|
|
13
|
+
Please give us a chance to release a fix before you disclose the issue publicly.
|
|
@@ -14,6 +14,7 @@ export declare class EmailVerificationService {
|
|
|
14
14
|
private readonly storage;
|
|
15
15
|
private readonly registry;
|
|
16
16
|
private readonly events;
|
|
17
|
+
private static readonly logger;
|
|
17
18
|
private readonly options?;
|
|
18
19
|
private readonly ttl;
|
|
19
20
|
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, options?: {
|
|
@@ -34,7 +35,8 @@ export declare class EmailVerificationService {
|
|
|
34
35
|
* Burns the token and asks `EmailVerificationHandler.markVerified()` to
|
|
35
36
|
* record the address, if it is still the user's. Resolves the verified
|
|
36
37
|
* address, or `null` for unknown, used or expired tokens and for an
|
|
37
|
-
* address the user has since changed.
|
|
38
|
+
* address the user has since changed. When `markVerified()` throws, the
|
|
39
|
+
* link works again until it expires, and the error is rethrown.
|
|
38
40
|
*/
|
|
39
41
|
verify(token: string): Promise<{
|
|
40
42
|
userId: string;
|
|
@@ -10,7 +10,8 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
var EmailVerificationService_1;
|
|
14
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
14
15
|
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
15
16
|
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
16
17
|
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
@@ -26,7 +27,7 @@ import { requireUrlOption } from '../utils/options.util.js';
|
|
|
26
27
|
* Point the link at a page that POSTs the token back: mail scanners and link
|
|
27
28
|
* previews follow GET links, and would use it up.
|
|
28
29
|
*/
|
|
29
|
-
let EmailVerificationService = class EmailVerificationService {
|
|
30
|
+
let EmailVerificationService = EmailVerificationService_1 = class EmailVerificationService {
|
|
30
31
|
constructor(storage, registry, options, events = new AuthenticationEvents()) {
|
|
31
32
|
this.storage = storage;
|
|
32
33
|
this.registry = registry;
|
|
@@ -70,7 +71,8 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
70
71
|
* Burns the token and asks `EmailVerificationHandler.markVerified()` to
|
|
71
72
|
* record the address, if it is still the user's. Resolves the verified
|
|
72
73
|
* address, or `null` for unknown, used or expired tokens and for an
|
|
73
|
-
* address the user has since changed.
|
|
74
|
+
* address the user has since changed. When `markVerified()` throws, the
|
|
75
|
+
* link works again until it expires, and the error is rethrown.
|
|
74
76
|
*/
|
|
75
77
|
async verify(token) {
|
|
76
78
|
const { handler } = this.feature();
|
|
@@ -81,7 +83,17 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
81
83
|
if (!record || hasExpired(record.expiresAt, this.now())) {
|
|
82
84
|
return null;
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
let verified;
|
|
87
|
+
try {
|
|
88
|
+
verified = await handler.markVerified(record.userId, record.email);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Give the link back, so the user can try it again rather than ask for another email. Marking
|
|
92
|
+
// an address verified twice changes nothing, and one the user changed since gets `false`.
|
|
93
|
+
await this.storage.emailTokens.saveEmailToken(record).catch((saveError) => EmailVerificationService_1.logger.error('Could not give back an email verification link', saveError instanceof Error ? saveError.stack : saveError));
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!verified) {
|
|
85
97
|
return null;
|
|
86
98
|
}
|
|
87
99
|
await this.storage.emailTokens.deleteUserEmailTokens(record.userId, 'email-verification');
|
|
@@ -101,7 +113,8 @@ let EmailVerificationService = class EmailVerificationService {
|
|
|
101
113
|
return this.options?.now?.() ?? Date.now();
|
|
102
114
|
}
|
|
103
115
|
};
|
|
104
|
-
EmailVerificationService =
|
|
116
|
+
EmailVerificationService.logger = new Logger('EmailVerificationService');
|
|
117
|
+
EmailVerificationService = EmailVerificationService_1 = __decorate([
|
|
105
118
|
Injectable(),
|
|
106
119
|
__param(2, Optional()),
|
|
107
120
|
__param(2, Inject(AUTHENTICATION_MODULE_OPTIONS)),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { AuthenticationScope } from '../context/authentication-scope.service.js';
|
|
2
3
|
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
3
4
|
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
4
5
|
import { AuthenticationEvents } from '../events/authentication-events.service.js';
|
|
@@ -35,13 +36,14 @@ export declare class PasswordResetService implements OnModuleDestroy {
|
|
|
35
36
|
private readonly tokens;
|
|
36
37
|
private readonly signInService;
|
|
37
38
|
private readonly events;
|
|
39
|
+
private readonly scope;
|
|
38
40
|
private static readonly logger;
|
|
39
41
|
private readonly options?;
|
|
40
42
|
private readonly ttl;
|
|
41
43
|
private readonly inFlight;
|
|
42
44
|
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, hasher: PasswordHasher, sessions: SessionService, tokens: TokenService, signInService: SignInService, options?: {
|
|
43
45
|
passwordReset?: PasswordResetOptions;
|
|
44
|
-
}, events?: AuthenticationEvents);
|
|
46
|
+
}, events?: AuthenticationEvents, scope?: AuthenticationScope);
|
|
45
47
|
/**
|
|
46
48
|
* Starts a reset for `email`, if it belongs to an account: the link goes
|
|
47
49
|
* to `PasswordResetHandler.send()`. Returns at once, before the account is
|
|
@@ -56,11 +58,22 @@ export declare class PasswordResetService implements OnModuleDestroy {
|
|
|
56
58
|
* changed its address or password since the link was sent. `null` too,
|
|
57
59
|
* before the token is looked up (so the link still works), for a password
|
|
58
60
|
* `PasswordHasher` refuses: not a string, empty, or over 4 KiB. Check your
|
|
59
|
-
* own password rules before calling it.
|
|
61
|
+
* own password rules before calling it. With `signIn`, a request from
|
|
62
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
63
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the link
|
|
64
|
+
* and the password stay as they were. Without `signIn`, the origin is not
|
|
65
|
+
* checked. When a step throws before the new password is stored (the
|
|
66
|
+
* lookup, the hash, ending sign-ins, `updatePassword()`), the link works
|
|
67
|
+
* again until it expires, and the error is rethrown.
|
|
60
68
|
*/
|
|
61
69
|
reset(token: string, password: string, { signIn }?: ResetPasswordOptions): Promise<PasswordResetResult | null>;
|
|
62
70
|
onModuleDestroy(): Promise<void>;
|
|
63
71
|
private issue;
|
|
72
|
+
/**
|
|
73
|
+
* Checks the link's account and stores the new password's hash. `null` for an account that is gone
|
|
74
|
+
* or changed its address or password since the link was sent.
|
|
75
|
+
*/
|
|
76
|
+
private setPassword;
|
|
64
77
|
/** Ends every session and refresh-token family (with refresh tokens on) of the user. */
|
|
65
78
|
private revokeSignIns;
|
|
66
79
|
/** The options and the handler; the module refuses to start with one and not the other. */
|
|
@@ -12,6 +12,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
12
12
|
};
|
|
13
13
|
var PasswordResetService_1;
|
|
14
14
|
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
15
|
+
import { AuthenticationScope } from '../context/authentication-scope.service.js';
|
|
15
16
|
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
16
17
|
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
17
18
|
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
@@ -45,7 +46,7 @@ import { isMailableEmail, normalizeEmail } from './email.util.js';
|
|
|
45
46
|
* the token: mail scanners and link previews follow GET links.
|
|
46
47
|
*/
|
|
47
48
|
let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
48
|
-
constructor(storage, registry, hasher, sessions, tokens, signInService, options, events = new AuthenticationEvents()) {
|
|
49
|
+
constructor(storage, registry, hasher, sessions, tokens, signInService, options, events = new AuthenticationEvents(), scope = new AuthenticationScope()) {
|
|
49
50
|
this.storage = storage;
|
|
50
51
|
this.registry = registry;
|
|
51
52
|
this.hasher = hasher;
|
|
@@ -53,6 +54,7 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
53
54
|
this.tokens = tokens;
|
|
54
55
|
this.signInService = signInService;
|
|
55
56
|
this.events = events;
|
|
57
|
+
this.scope = scope;
|
|
56
58
|
this.inFlight = new Set();
|
|
57
59
|
this.options = options?.passwordReset;
|
|
58
60
|
if (this.options) {
|
|
@@ -87,10 +89,21 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
87
89
|
* changed its address or password since the link was sent. `null` too,
|
|
88
90
|
* before the token is looked up (so the link still works), for a password
|
|
89
91
|
* `PasswordHasher` refuses: not a string, empty, or over 4 KiB. Check your
|
|
90
|
-
* own password rules before calling it.
|
|
92
|
+
* own password rules before calling it. With `signIn`, a request from
|
|
93
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
94
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the link
|
|
95
|
+
* and the password stay as they were. Without `signIn`, the origin is not
|
|
96
|
+
* checked. When a step throws before the new password is stored (the
|
|
97
|
+
* lookup, the hash, ending sign-ins, `updatePassword()`), the link works
|
|
98
|
+
* again until it expires, and the error is rethrown.
|
|
91
99
|
*/
|
|
92
100
|
async reset(token, password, { signIn = false } = {}) {
|
|
93
101
|
const { handler } = this.feature();
|
|
102
|
+
// Signing in would refuse this request at the end, after the link is spent and the password
|
|
103
|
+
// changed: refuse it before, whatever the token is.
|
|
104
|
+
if (signIn) {
|
|
105
|
+
this.signInService.refuseCrossOrigin(this.scope.exchange()?.request);
|
|
106
|
+
}
|
|
94
107
|
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token) || !isHashablePassword(password)) {
|
|
95
108
|
return null;
|
|
96
109
|
}
|
|
@@ -98,20 +111,21 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
98
111
|
if (!record || hasExpired(record.expiresAt, this.now())) {
|
|
99
112
|
return null;
|
|
100
113
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
let account;
|
|
115
|
+
try {
|
|
116
|
+
account = await this.setPassword(handler, record, password);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// A step failed before the new password was stored, as far as this request can tell: give the
|
|
120
|
+
// link back, so the customer can try it again rather than ask for another email. Safe even if
|
|
121
|
+
// updatePassword() stored the hash and then threw: the link is bound to the fingerprint of the
|
|
122
|
+
// password it replaces, so once that changed, it sets nothing.
|
|
123
|
+
await this.storage.emailTokens.saveEmailToken(record).catch((saveError) => PasswordResetService_1.logger.error('Could not give back a password reset link', saveError instanceof Error ? saveError.stack : saveError));
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
if (!account) {
|
|
107
127
|
return null;
|
|
108
128
|
}
|
|
109
|
-
const passwordHash = await this.hasher.hash(password);
|
|
110
|
-
// Sign-ins end before the new password is stored, so a store that fails leaves the old password
|
|
111
|
-
// without its sessions rather than the new one next to them; and again after, for any sign-in
|
|
112
|
-
// that started in between.
|
|
113
|
-
await this.revokeSignIns(account.id);
|
|
114
|
-
await handler.updatePassword(account.id, passwordHash);
|
|
115
129
|
await this.storage.emailTokens.deleteUserEmailTokens(account.id, 'password-reset');
|
|
116
130
|
await this.revokeSignIns(account.id);
|
|
117
131
|
// The link reached the address, and the password is the owner's now.
|
|
@@ -150,6 +164,27 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
|
|
|
150
164
|
url.searchParams.set('token', token);
|
|
151
165
|
await handler.send({ userId: account.id, email: account.email, url: url.toString(), expiresAt });
|
|
152
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Checks the link's account and stores the new password's hash. `null` for an account that is gone
|
|
169
|
+
* or changed its address or password since the link was sent.
|
|
170
|
+
*/
|
|
171
|
+
async setPassword(handler, record, password) {
|
|
172
|
+
const found = await handler.findUser(normalizeEmail(record.email));
|
|
173
|
+
const account = found && checked(found);
|
|
174
|
+
if (!account ||
|
|
175
|
+
account.id !== record.userId ||
|
|
176
|
+
account.email !== record.email ||
|
|
177
|
+
!safeEqual(fingerprint(account.passwordHash), record.fingerprint ?? '')) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const passwordHash = await this.hasher.hash(password);
|
|
181
|
+
// Sign-ins end before the new password is stored, so a store that fails leaves the old password
|
|
182
|
+
// without its sessions rather than the new one next to them; and again after, for any sign-in
|
|
183
|
+
// that started in between.
|
|
184
|
+
await this.revokeSignIns(account.id);
|
|
185
|
+
await handler.updatePassword(account.id, passwordHash);
|
|
186
|
+
return account;
|
|
187
|
+
}
|
|
153
188
|
/** Ends every session and refresh-token family (with refresh tokens on) of the user. */
|
|
154
189
|
async revokeSignIns(userId) {
|
|
155
190
|
await this.sessions.revokeAll(userId);
|
|
@@ -178,7 +213,8 @@ PasswordResetService = PasswordResetService_1 = __decorate([
|
|
|
178
213
|
PasswordHasher,
|
|
179
214
|
SessionService,
|
|
180
215
|
TokenService,
|
|
181
|
-
SignInService, Object, AuthenticationEvents
|
|
216
|
+
SignInService, Object, AuthenticationEvents,
|
|
217
|
+
AuthenticationScope])
|
|
182
218
|
], PasswordResetService);
|
|
183
219
|
export { PasswordResetService };
|
|
184
220
|
/**
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AuthenticationError } from './authentication.error.js';
|
|
2
2
|
/**
|
|
3
3
|
* A link was presented in a browser other than the one that requested it
|
|
4
|
-
* (see `magicLink.bindToBrowser`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* (see `magicLink.bindToBrowser`), or in that browser after it requested
|
|
5
|
+
* five newer ones. The one refusal `consume()` tells apart: the customer
|
|
6
|
+
* can act on it (open the latest link where they asked for it, or request
|
|
7
|
+
* a new one here), and it is decided before the link is
|
|
7
8
|
* looked up, so it reveals nothing about the token. Unknown, used, expired
|
|
8
9
|
* and refused links share one answer, `null`.
|
|
9
10
|
*
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AuthenticationError } from './authentication.error.js';
|
|
2
2
|
/**
|
|
3
3
|
* A link was presented in a browser other than the one that requested it
|
|
4
|
-
* (see `magicLink.bindToBrowser`)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* (see `magicLink.bindToBrowser`), or in that browser after it requested
|
|
5
|
+
* five newer ones. The one refusal `consume()` tells apart: the customer
|
|
6
|
+
* can act on it (open the latest link where they asked for it, or request
|
|
7
|
+
* a new one here), and it is decided before the link is
|
|
7
8
|
* looked up, so it reveals nothing about the token. Unknown, used, expired
|
|
8
9
|
* and refused links share one answer, `null`.
|
|
9
10
|
*
|
|
@@ -13,7 +14,7 @@ import { AuthenticationError } from './authentication.error.js';
|
|
|
13
14
|
*/
|
|
14
15
|
export class MagicLinkError extends AuthenticationError {
|
|
15
16
|
constructor() {
|
|
16
|
-
super('Open the link in the browser you requested it from, or request a new one here', { code: 'not_this_browser' });
|
|
17
|
+
super('Open the latest link in the browser you requested it from, or request a new one here', { code: 'not_this_browser' });
|
|
17
18
|
/** Why the link was refused: the only reason apps are told. */
|
|
18
19
|
this.reason = 'not-this-browser';
|
|
19
20
|
this.name = 'MagicLinkError';
|
|
@@ -37,7 +37,9 @@ export interface EmailTokenStore {
|
|
|
37
37
|
/**
|
|
38
38
|
* Saves a new token (a fresh random id: a plain insert), then keeps the
|
|
39
39
|
* store bounded: deletes tokens whose `expiresAt` is at or before this
|
|
40
|
-
* one's `createdAt`, and past a cap, those that expire first.
|
|
40
|
+
* one's `createdAt`, and past a cap, those that expire first. The services
|
|
41
|
+
* also save back a record `consumeEmailToken()` just returned, when a step
|
|
42
|
+
* after it failed: its id is free again, so that is a plain insert too.
|
|
41
43
|
*/
|
|
42
44
|
saveEmailToken(record: EmailTokenRecord): Promise<void>;
|
|
43
45
|
/**
|
|
@@ -23,7 +23,8 @@ export interface MagicLinkOptions {
|
|
|
23
23
|
* Whether a link works only in the browser that requested it. Default
|
|
24
24
|
* `true`: `create()` sets a transaction cookie, and `consume()` refuses a
|
|
25
25
|
* link the browser holds no cookie for (`magic-link-refused`,
|
|
26
|
-
* `not-this-browser`).
|
|
26
|
+
* `not-this-browser`). The cookie keeps the last five links the browser
|
|
27
|
+
* requested: a "resend" does not void the link before it. Without it, anyone can request a link for their
|
|
27
28
|
* own address and get someone else to open it, which signs that person's
|
|
28
29
|
* browser in to the attacker's account (login CSRF). `false` trades that
|
|
29
30
|
* for links that work on another device than the one that asked for them
|
|
@@ -45,9 +45,37 @@ export interface OidcOptions extends OidcClientOptions {
|
|
|
45
45
|
* The public URL of your callback route, the redirect URI you register
|
|
46
46
|
* with each provider. `:provider` is replaced by the provider's name:
|
|
47
47
|
* `https://app.example.com/auth/oidc/:provider/callback`.
|
|
48
|
+
*
|
|
49
|
+
* An app served under several domains passes a function of the request
|
|
50
|
+
* instead, called on the login and again on the callback: both must
|
|
51
|
+
* answer the same URL, or the provider refuses the code. Check the host
|
|
52
|
+
* against your own list of domains rather than echoing the header, and
|
|
53
|
+
* register every URL it can answer with each provider. The transaction
|
|
54
|
+
* cookie is bound to the host that set it, so a login finishes on the
|
|
55
|
+
* domain where it started.
|
|
48
56
|
*/
|
|
49
|
-
callbackUrl: string;
|
|
50
|
-
|
|
57
|
+
callbackUrl: string | ((request: OidcRequest) => string | Promise<string>);
|
|
58
|
+
/**
|
|
59
|
+
* The providers users can sign in with, by name: the `:provider` of your
|
|
60
|
+
* routes, and the `provider` of each profile they sign in with.
|
|
61
|
+
*
|
|
62
|
+
* Providers that aren't known at startup (one OIDC client per customer,
|
|
63
|
+
* created at runtime) come from a function of the name instead, called on
|
|
64
|
+
* the login and again on the callback: return the provider's config, or
|
|
65
|
+
* `undefined` (or `null`) for a name you don't serve, which answers 404.
|
|
66
|
+
* A rotated secret is used from the next call on. Each config it returns
|
|
67
|
+
* gets the checks the object form gets at startup. Caching your lookup is
|
|
68
|
+
* yours to do; discovery documents and key sets are cached by issuer and
|
|
69
|
+
* by URI, whatever the name.
|
|
70
|
+
*
|
|
71
|
+
* Identities are linked by (provider, subject), so a name must stand for
|
|
72
|
+
* one client of one provider: put the customer in the name (`acme`),
|
|
73
|
+
* rather than reusing one name for every customer's own provider. A
|
|
74
|
+
* provider a customer brings vouches for whatever addresses it likes, as
|
|
75
|
+
* verified ones too: link by email only to that customer's accounts, and
|
|
76
|
+
* check an issuer they enter against the providers you support.
|
|
77
|
+
*/
|
|
78
|
+
providers: Record<string, OidcProviderConfig> | ((name: string) => OidcProviderConfig | null | undefined | Promise<OidcProviderConfig | null | undefined>);
|
|
51
79
|
/** Where to land after sign-in when the login had no `redirectTo`. Default `/`. */
|
|
52
80
|
redirectAfterLogin?: string;
|
|
53
81
|
/** Lifetime of a login between the redirect to the provider and the callback. Default `'10m'`. */
|
|
@@ -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
|
|
@@ -62,7 +64,10 @@ export declare class MagicLinkService {
|
|
|
62
64
|
* instead, before anything is looked up or burned: the module answers it
|
|
63
65
|
* with a 401 that tells the customer where to open the link. Each
|
|
64
66
|
* refusal is a `magic-link-refused` event with its reason. Outside an
|
|
65
|
-
* HTTP handler, pass the `request` that carries the cookie.
|
|
67
|
+
* HTTP handler, pass the `request` that carries the cookie. A request from
|
|
68
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
69
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the
|
|
70
|
+
* link and the cookie stay as they were.
|
|
66
71
|
*/
|
|
67
72
|
consume(token: string, { request }?: {
|
|
68
73
|
request?: MagicLinkRequest;
|
|
@@ -73,6 +78,7 @@ export declare class MagicLinkService {
|
|
|
73
78
|
private feature;
|
|
74
79
|
/** The generic answer: which refusal it was is told only by the event. */
|
|
75
80
|
private refuse;
|
|
81
|
+
private pendingCookie;
|
|
76
82
|
private txCookie;
|
|
77
83
|
private setCookie;
|
|
78
84
|
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);
|
|
@@ -123,31 +146,41 @@ let MagicLinkService = class MagicLinkService {
|
|
|
123
146
|
* instead, before anything is looked up or burned: the module answers it
|
|
124
147
|
* with a 401 that tells the customer where to open the link. Each
|
|
125
148
|
* refusal is a `magic-link-refused` event with its reason. Outside an
|
|
126
|
-
* HTTP handler, pass the `request` that carries the cookie.
|
|
149
|
+
* HTTP handler, pass the `request` that carries the cookie. A request from
|
|
150
|
+
* another origin than the app's own and `session.trustedOrigins` gets a
|
|
151
|
+
* `ForbiddenException` first, as `SignInService.signIn()` does, so the
|
|
152
|
+
* link and the cookie stay as they were.
|
|
127
153
|
*/
|
|
128
154
|
async consume(token, { request } = {}) {
|
|
129
155
|
const { handler } = this.feature();
|
|
156
|
+
// Signing in would refuse this request at the end, after the link is spent and the cookie
|
|
157
|
+
// settled: refuse it before, whatever the token is.
|
|
158
|
+
this.signIn.refuseCrossOrigin(this.scope.exchange()?.request);
|
|
130
159
|
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
|
|
131
160
|
return this.refuse('unknown');
|
|
132
161
|
}
|
|
133
162
|
let binding;
|
|
163
|
+
let pending = [];
|
|
164
|
+
let link;
|
|
134
165
|
if (this.bindToBrowser) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// The hint names the link this browser requested; anyone holding the link can compute it, so it
|
|
166
|
+
pending = readPending(this.pendingCookie(request ?? this.scope.exchange()?.request));
|
|
167
|
+
// The hint names a link this browser requested; anyone holding the link can compute it, so it
|
|
138
168
|
// only tells a link opened elsewhere from a bad one. The secret, which no link carries, is what
|
|
139
169
|
// the link is stored under: a leaked or forwarded link finds nothing without it. On a refusal
|
|
140
|
-
// here the cookie stays (it
|
|
170
|
+
// here the cookie stays (it holds the links this browser did request), and nothing is looked
|
|
141
171
|
// up: a link was presented, not proved.
|
|
142
|
-
|
|
172
|
+
const hint = hintOf(token);
|
|
173
|
+
link = pending.find((candidate) => safeEqual(candidate.hint, hint));
|
|
174
|
+
if (!link) {
|
|
143
175
|
this.events.emit({ type: 'magic-link-refused', reason: 'not-this-browser' });
|
|
144
176
|
throw new MagicLinkError();
|
|
145
177
|
}
|
|
146
|
-
binding = secret;
|
|
178
|
+
binding = link.secret;
|
|
147
179
|
}
|
|
148
180
|
const record = await this.storage.magicLinks.consumeMagicLink(linkId(token, binding));
|
|
149
|
-
// This
|
|
150
|
-
|
|
181
|
+
// This link is settled either way: its secret goes, and the cookie with it when it held no other.
|
|
182
|
+
const rest = writePending(pending.filter((candidate) => candidate !== link));
|
|
183
|
+
this.setCookie(this.txCookie(rest, rest ? this.ttl / 1000 : 0));
|
|
151
184
|
if (!record) {
|
|
152
185
|
return this.refuse('unknown');
|
|
153
186
|
}
|
|
@@ -185,6 +218,9 @@ let MagicLinkService = class MagicLinkService {
|
|
|
185
218
|
this.events.emit({ type: 'magic-link-refused', reason, ...(email && { email }) });
|
|
186
219
|
return null;
|
|
187
220
|
}
|
|
221
|
+
pendingCookie(request) {
|
|
222
|
+
return readCookie(firstHeader(request?.headers, 'cookie'), this.txCookieName);
|
|
223
|
+
}
|
|
188
224
|
txCookie(value, maxAge) {
|
|
189
225
|
return serializeCookie(this.txCookieName, value, { ...this.options?.cookie, sameSite: 'lax', maxAge });
|
|
190
226
|
}
|
|
@@ -1,5 +1,32 @@
|
|
|
1
|
+
import { JwksClient } from '../jwt/jwks.client.js';
|
|
1
2
|
import type { OAuthTokens, OidcProfile, OidcProviderConfig } from '../interfaces/oidc-provider.interface.js';
|
|
2
3
|
import type { OidcTransaction, OidcClientOptions } from '../interfaces/oidc.interface.js';
|
|
4
|
+
interface Endpoints {
|
|
5
|
+
issuer?: string;
|
|
6
|
+
authorization_endpoint: string;
|
|
7
|
+
token_endpoint: string;
|
|
8
|
+
userinfo_endpoint?: string;
|
|
9
|
+
jwks_uri?: string;
|
|
10
|
+
authorization_response_iss_parameter_supported?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What the clients of one `OidcService` share: discovery documents, by
|
|
14
|
+
* issuer, and key sets, by URI. Neither depends on the client id or secret,
|
|
15
|
+
* so a client made anew for each login (`oidc.providers` as a function)
|
|
16
|
+
* still fetches them once per TTL, and the key set's cooldown still holds.
|
|
17
|
+
*/
|
|
18
|
+
export declare class OidcMetadataCache {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private readonly documents;
|
|
21
|
+
private readonly keySets;
|
|
22
|
+
constructor(options?: OidcClientOptions);
|
|
23
|
+
/** The discovery document of `issuer`, as published: the endpoints a config overrides are its own to merge. */
|
|
24
|
+
document(issuer: string): Promise<Endpoints>;
|
|
25
|
+
/** The key set at `uri`, an https URL. */
|
|
26
|
+
jwks(uri: string): JwksClient;
|
|
27
|
+
private discover;
|
|
28
|
+
private now;
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
5
32
|
* PKCE (S256), `state` and `nonce`.
|
|
@@ -13,10 +40,9 @@ export declare class OidcClient {
|
|
|
13
40
|
readonly name: string;
|
|
14
41
|
private readonly config;
|
|
15
42
|
private readonly options;
|
|
16
|
-
private
|
|
17
|
-
private jwks?;
|
|
43
|
+
private readonly metadata;
|
|
18
44
|
readonly kind: 'oidc' | 'oauth2';
|
|
19
|
-
constructor(name: string, config: OidcProviderConfig, options?: OidcClientOptions);
|
|
45
|
+
constructor(name: string, config: OidcProviderConfig, options?: OidcClientOptions, metadata?: OidcMetadataCache);
|
|
20
46
|
/**
|
|
21
47
|
* The provider's authorization URL, the transaction to store, and the
|
|
22
48
|
* browser binding for the transaction cookie. `state` is the SHA-256 of
|
|
@@ -37,8 +63,8 @@ export declare class OidcClient {
|
|
|
37
63
|
private verifyIdToken;
|
|
38
64
|
private exchange;
|
|
39
65
|
private endpoints;
|
|
40
|
-
private discover;
|
|
41
66
|
private getJson;
|
|
42
67
|
private request;
|
|
43
68
|
private now;
|
|
44
69
|
}
|
|
70
|
+
export {};
|
package/dist/oidc/oidc.client.js
CHANGED
|
@@ -8,8 +8,10 @@ import { OidcError } from '../errors/oidc.error.js';
|
|
|
8
8
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
9
9
|
/**
|
|
10
10
|
* Only https, except loopback http (local IdPs and tests). The URLs come from the provider's
|
|
11
|
-
* discovery document or the app's configuration, never from the
|
|
12
|
-
* misconfiguration (`unavailable`, a 502), not a failed sign-in.
|
|
11
|
+
* discovery document or the app's configuration, never from the request: a bad one is a
|
|
12
|
+
* misconfiguration (`unavailable`, a 502), not a failed sign-in. Nor does this keep a fetch off
|
|
13
|
+
* the app's own network: an issuer that customers enter themselves is the app's to check
|
|
14
|
+
* against the providers it supports, before it stores it.
|
|
13
15
|
*/
|
|
14
16
|
function assertSecureUrl(value, what) {
|
|
15
17
|
let url;
|
|
@@ -24,6 +26,66 @@ function assertSecureUrl(value, what) {
|
|
|
24
26
|
}
|
|
25
27
|
return url.toString();
|
|
26
28
|
}
|
|
29
|
+
/** A request to the provider: no redirects, a 10 s timeout, and a network failure as an outage. */
|
|
30
|
+
async function send(options, url, init) {
|
|
31
|
+
try {
|
|
32
|
+
return await (options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new OidcError(`${new URL(url).host} is unreachable (${error.message})`, 'unavailable');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What the clients of one `OidcService` share: discovery documents, by
|
|
40
|
+
* issuer, and key sets, by URI. Neither depends on the client id or secret,
|
|
41
|
+
* so a client made anew for each login (`oidc.providers` as a function)
|
|
42
|
+
* still fetches them once per TTL, and the key set's cooldown still holds.
|
|
43
|
+
*/
|
|
44
|
+
export class OidcMetadataCache {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
this.documents = new Map();
|
|
48
|
+
this.keySets = new Map();
|
|
49
|
+
}
|
|
50
|
+
/** The discovery document of `issuer`, as published: the endpoints a config overrides are its own to merge. */
|
|
51
|
+
document(issuer) {
|
|
52
|
+
const ttl = durationOr(this.options.discoveryTtl, '1h');
|
|
53
|
+
let cached = this.documents.get(issuer);
|
|
54
|
+
if (!cached || this.now() - cached.at >= ttl) {
|
|
55
|
+
const entry = { value: this.discover(issuer), at: this.now() };
|
|
56
|
+
this.documents.set(issuer, entry);
|
|
57
|
+
// Retry next time, unless a later fetch has taken its place.
|
|
58
|
+
entry.value.catch(() => this.documents.get(issuer) === entry && this.documents.delete(issuer));
|
|
59
|
+
cached = entry;
|
|
60
|
+
}
|
|
61
|
+
return cached.value;
|
|
62
|
+
}
|
|
63
|
+
/** The key set at `uri`, an https URL. */
|
|
64
|
+
jwks(uri) {
|
|
65
|
+
let jwks = this.keySets.get(uri);
|
|
66
|
+
if (!jwks) {
|
|
67
|
+
jwks = new JwksClient(uri, { ...this.options.jwks, fetch: this.options.fetch, now: this.options.now });
|
|
68
|
+
this.keySets.set(uri, jwks);
|
|
69
|
+
}
|
|
70
|
+
return jwks;
|
|
71
|
+
}
|
|
72
|
+
async discover(issuer) {
|
|
73
|
+
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
74
|
+
const response = await send(this.options, assertSecureUrl(url, 'issuer'), { headers: { accept: 'application/json' } });
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new OidcError(`discovery failed (${response.status})`, 'unavailable');
|
|
77
|
+
}
|
|
78
|
+
const doc = (await response.json().catch(() => ({})));
|
|
79
|
+
// OIDC Discovery §4.3: the document must name exactly the issuer we asked.
|
|
80
|
+
if (doc.issuer !== issuer) {
|
|
81
|
+
throw new OidcError('discovery issuer mismatch', 'unavailable');
|
|
82
|
+
}
|
|
83
|
+
return doc;
|
|
84
|
+
}
|
|
85
|
+
now() {
|
|
86
|
+
return this.options.now?.() ?? Date.now();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
27
89
|
/**
|
|
28
90
|
* One OAuth 2.0 / OpenID Connect provider: authorization code flow with
|
|
29
91
|
* PKCE (S256), `state` and `nonce`.
|
|
@@ -34,10 +96,11 @@ function assertSecureUrl(value, what) {
|
|
|
34
96
|
* userinfo's `sub` matches the ID token's (OIDC Core §5.3.2).
|
|
35
97
|
*/
|
|
36
98
|
export class OidcClient {
|
|
37
|
-
constructor(name, config, options = {}) {
|
|
99
|
+
constructor(name, config, options = {}, metadata = new OidcMetadataCache(options)) {
|
|
38
100
|
this.name = name;
|
|
39
101
|
this.config = config;
|
|
40
102
|
this.options = options;
|
|
103
|
+
this.metadata = metadata;
|
|
41
104
|
this.kind = config.kind ?? 'oidc';
|
|
42
105
|
if (this.kind === 'oidc' && !config.issuer) {
|
|
43
106
|
throw new Error(`OIDC provider '${name}' needs an issuer`);
|
|
@@ -138,13 +201,8 @@ export class OidcClient {
|
|
|
138
201
|
};
|
|
139
202
|
}
|
|
140
203
|
async verifyIdToken(endpoints, idToken, nonce) {
|
|
141
|
-
this.jwks ??= new JwksClient(assertSecureUrl(endpoints.jwks_uri, 'jwks_uri'), {
|
|
142
|
-
...this.options.jwks,
|
|
143
|
-
fetch: this.options.fetch,
|
|
144
|
-
now: this.options.now,
|
|
145
|
-
});
|
|
146
204
|
const verifier = new JwtVerifier({
|
|
147
|
-
jwks: this.jwks,
|
|
205
|
+
jwks: this.metadata.jwks(assertSecureUrl(endpoints.jwks_uri, 'jwks_uri')),
|
|
148
206
|
algorithms: this.config.idTokenAlgorithms ?? ['RS256', 'ES256'],
|
|
149
207
|
issuer: endpoints.issuer,
|
|
150
208
|
audience: this.config.clientId,
|
|
@@ -229,26 +287,7 @@ export class OidcClient {
|
|
|
229
287
|
}),
|
|
230
288
|
};
|
|
231
289
|
}
|
|
232
|
-
const
|
|
233
|
-
if (!this.discovery || this.now() - this.discovery.at >= ttl) {
|
|
234
|
-
const value = this.discover();
|
|
235
|
-
this.discovery = { value, at: this.now() };
|
|
236
|
-
value.catch(() => (this.discovery = undefined)); // retry next time
|
|
237
|
-
}
|
|
238
|
-
return this.discovery.value;
|
|
239
|
-
}
|
|
240
|
-
async discover() {
|
|
241
|
-
const issuer = this.config.issuer;
|
|
242
|
-
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
243
|
-
const response = await this.request(assertSecureUrl(url, 'issuer'), { headers: { accept: 'application/json' } });
|
|
244
|
-
if (!response.ok) {
|
|
245
|
-
throw new OidcError(`discovery failed (${response.status})`, 'unavailable');
|
|
246
|
-
}
|
|
247
|
-
const doc = (await response.json().catch(() => ({})));
|
|
248
|
-
// OIDC Discovery §4.3: the document must name exactly the issuer we asked.
|
|
249
|
-
if (doc.issuer !== issuer) {
|
|
250
|
-
throw new OidcError('discovery issuer mismatch', 'unavailable');
|
|
251
|
-
}
|
|
290
|
+
const doc = await this.metadata.document(this.config.issuer);
|
|
252
291
|
return {
|
|
253
292
|
...doc,
|
|
254
293
|
authorization_endpoint: assertSecureUrl(this.config.authorizationEndpoint ?? doc.authorization_endpoint, 'authorization_endpoint'),
|
|
@@ -272,13 +311,8 @@ export class OidcClient {
|
|
|
272
311
|
}
|
|
273
312
|
return response.json();
|
|
274
313
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return await (this.options.fetch ?? fetch)(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
278
|
-
}
|
|
279
|
-
catch (error) {
|
|
280
|
-
throw new OidcError(`${new URL(url).host} is unreachable (${error.message})`, 'unavailable');
|
|
281
|
-
}
|
|
314
|
+
request(url, init) {
|
|
315
|
+
return send(this.options, url, init);
|
|
282
316
|
}
|
|
283
317
|
now() {
|
|
284
318
|
return this.options.now?.() ?? Date.now();
|
|
@@ -5,7 +5,6 @@ import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
|
5
5
|
import { SessionService } from '../session/session.service.js';
|
|
6
6
|
import { SignInService } from '../session/sign-in.service.js';
|
|
7
7
|
import type { OidcOptions, OidcRequest, OidcRedirect, OidcStartOptions } from '../interfaces/oidc.interface.js';
|
|
8
|
-
import { OidcClient } from './oidc.client.js';
|
|
9
8
|
/**
|
|
10
9
|
* OpenID Connect sign-in, behind two routes of your own: a login route that
|
|
11
10
|
* redirects to the provider, and the callback route it sends the browser
|
|
@@ -53,14 +52,13 @@ export declare class OidcService {
|
|
|
53
52
|
private readonly sessions;
|
|
54
53
|
private readonly scope;
|
|
55
54
|
private readonly adapterHost?;
|
|
56
|
-
private readonly
|
|
55
|
+
private readonly metadata;
|
|
57
56
|
private readonly options?;
|
|
58
57
|
private readonly transactionTtl;
|
|
59
58
|
private readonly txCookieName;
|
|
60
59
|
constructor(storage: AuthenticationStorage, registry: AuthenticationRegistry, signIn: SignInService, sessions: SessionService, options?: {
|
|
61
60
|
oidc?: OidcOptions;
|
|
62
61
|
}, scope?: AuthenticationScope, adapterHost?: HttpAdapterHost | undefined);
|
|
63
|
-
client(name: string): OidcClient;
|
|
64
62
|
/**
|
|
65
63
|
* Starts a login: stores the transaction, sets its cookie, and returns
|
|
66
64
|
* the provider's authorization URL. 404 for a provider that isn't
|
|
@@ -82,6 +80,14 @@ export declare class OidcService {
|
|
|
82
80
|
private feature;
|
|
83
81
|
/** A fully signed-in session (second factor done, if any) on this request. */
|
|
84
82
|
private currentSession;
|
|
83
|
+
/**
|
|
84
|
+
* The client of the provider named `name`, made for this call from its
|
|
85
|
+
* config: a secret the `oidc.providers` function rotates is the one the
|
|
86
|
+
* next call uses. 404 for a provider that isn't configured, or that the
|
|
87
|
+
* function doesn't serve.
|
|
88
|
+
*/
|
|
89
|
+
private client;
|
|
90
|
+
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
85
91
|
private callbackUrl;
|
|
86
92
|
private txCookie;
|
|
87
93
|
private now;
|
|
@@ -23,7 +23,7 @@ import { defaultCookieName, readCookie, serializeCookie } from '../session/cooki
|
|
|
23
23
|
import { SessionService } from '../session/session.service.js';
|
|
24
24
|
import { SignInService } from '../session/sign-in.service.js';
|
|
25
25
|
import { OidcError } from '../errors/oidc.error.js';
|
|
26
|
-
import { OidcClient } from './oidc.client.js';
|
|
26
|
+
import { OidcClient, OidcMetadataCache } from './oidc.client.js';
|
|
27
27
|
const header = (headers, name) => {
|
|
28
28
|
const value = headers[name];
|
|
29
29
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -76,28 +76,19 @@ let OidcService = class OidcService {
|
|
|
76
76
|
this.sessions = sessions;
|
|
77
77
|
this.scope = scope;
|
|
78
78
|
this.adapterHost = adapterHost;
|
|
79
|
-
this.clients = new Map();
|
|
80
79
|
this.options = options?.oidc;
|
|
81
80
|
if (this.options) {
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
if (typeof this.options.callbackUrl !== 'function') {
|
|
82
|
+
assertCallbackUrl(this.options.callbackUrl);
|
|
83
|
+
}
|
|
84
|
+
if (typeof this.options.providers !== 'function') {
|
|
85
|
+
assertProviderCredentials(this.options.providers);
|
|
86
|
+
}
|
|
84
87
|
}
|
|
88
|
+
this.metadata = new OidcMetadataCache(this.options);
|
|
85
89
|
this.transactionTtl = durationOr(this.options?.transactionTtl, '10m');
|
|
86
90
|
this.txCookieName = defaultCookieName('oidc_tx', this.options?.cookie);
|
|
87
91
|
}
|
|
88
|
-
client(name) {
|
|
89
|
-
const { options } = this.feature();
|
|
90
|
-
const config = Object.hasOwn(options.providers, name) ? options.providers[name] : undefined;
|
|
91
|
-
if (!config) {
|
|
92
|
-
throw new NotFoundException();
|
|
93
|
-
}
|
|
94
|
-
let client = this.clients.get(name);
|
|
95
|
-
if (!client) {
|
|
96
|
-
client = new OidcClient(name, config, options);
|
|
97
|
-
this.clients.set(name, client);
|
|
98
|
-
}
|
|
99
|
-
return client;
|
|
100
|
-
}
|
|
101
92
|
/**
|
|
102
93
|
* Starts a login: stores the transaction, sets its cookie, and returns
|
|
103
94
|
* the provider's authorization URL. 404 for a provider that isn't
|
|
@@ -119,8 +110,9 @@ let OidcService = class OidcService {
|
|
|
119
110
|
}
|
|
120
111
|
linking = { userId: session.userId, sessionId: session.id };
|
|
121
112
|
}
|
|
122
|
-
const
|
|
123
|
-
|
|
113
|
+
const client = await this.client(provider);
|
|
114
|
+
const { url, transaction, binding } = await client
|
|
115
|
+
.authorizationRequest(await this.callbackUrl(provider, req), safeRedirectPath(redirectTo), this.transactionTtl)
|
|
124
116
|
.catch(toHttp);
|
|
125
117
|
await this.storage.oidcStates.saveOidcState({ ...transaction, ...(linking && { link: linking }) });
|
|
126
118
|
return this.redirect(exchange?.response, url, [this.txCookie(binding, this.transactionTtl / 1000)]);
|
|
@@ -146,7 +138,11 @@ let OidcService = class OidcService {
|
|
|
146
138
|
if (!transaction || transaction.provider !== provider || hasExpired(transaction.expiresAt, this.now())) {
|
|
147
139
|
throw new BadRequestException('unknown or expired login');
|
|
148
140
|
}
|
|
149
|
-
|
|
141
|
+
// Asked again rather than stored with the login: the cookie keeps the callback on the login's host,
|
|
142
|
+
// and a function that answers otherwise gets the code refused by the provider, not accepted.
|
|
143
|
+
const callbackUrl = await this.callbackUrl(provider, req);
|
|
144
|
+
const client = await this.client(provider);
|
|
145
|
+
const result = await client.callback(query, transaction, callbackUrl).catch(toHttp);
|
|
150
146
|
const location = transaction.redirectTo ?? options.redirectAfterLogin ?? '/';
|
|
151
147
|
if (transaction.link) {
|
|
152
148
|
// The session that asked to link must still be the one on this browser.
|
|
@@ -194,8 +190,37 @@ let OidcService = class OidcService {
|
|
|
194
190
|
const session = await this.sessions.validate(this.sessions.tokenFrom(request));
|
|
195
191
|
return session && session.mfa !== 'pending' ? session : null;
|
|
196
192
|
}
|
|
197
|
-
|
|
198
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The client of the provider named `name`, made for this call from its
|
|
195
|
+
* config: a secret the `oidc.providers` function rotates is the one the
|
|
196
|
+
* next call uses. 404 for a provider that isn't configured, or that the
|
|
197
|
+
* function doesn't serve.
|
|
198
|
+
*/
|
|
199
|
+
async client(name) {
|
|
200
|
+
const { providers } = this.feature().options;
|
|
201
|
+
let config;
|
|
202
|
+
if (typeof providers === 'function') {
|
|
203
|
+
config = await providers(name);
|
|
204
|
+
if (config) {
|
|
205
|
+
assertResolvedCredentials(name, config);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
config = Object.hasOwn(providers, name) ? providers[name] : undefined;
|
|
210
|
+
}
|
|
211
|
+
if (!config) {
|
|
212
|
+
throw new NotFoundException();
|
|
213
|
+
}
|
|
214
|
+
return new OidcClient(name, config, this.options, this.metadata);
|
|
215
|
+
}
|
|
216
|
+
/** `oidc.callbackUrl` for this request, with the provider's name in it. */
|
|
217
|
+
async callbackUrl(provider, request) {
|
|
218
|
+
const { callbackUrl } = this.feature().options;
|
|
219
|
+
const url = typeof callbackUrl === 'function' ? await callbackUrl(request) : callbackUrl;
|
|
220
|
+
if (typeof callbackUrl === 'function') {
|
|
221
|
+
assertCallbackUrl(url, 'the `oidc.callbackUrl` function must return');
|
|
222
|
+
}
|
|
223
|
+
return url.replaceAll(':provider', encodeURIComponent(provider));
|
|
199
224
|
}
|
|
200
225
|
txCookie(value, maxAge) {
|
|
201
226
|
return serializeCookie(this.txCookieName, value, { ...this.options?.cookie, sameSite: 'lax', maxAge });
|
|
@@ -216,8 +241,11 @@ OidcService = __decorate([
|
|
|
216
241
|
HttpAdapterHost])
|
|
217
242
|
], OidcService);
|
|
218
243
|
export { OidcService };
|
|
219
|
-
/**
|
|
220
|
-
|
|
244
|
+
/**
|
|
245
|
+
* `oidc.callbackUrl`, checked at startup (or each URL its function returns,
|
|
246
|
+
* when it returns it): an absolute http(s) URL.
|
|
247
|
+
*/
|
|
248
|
+
function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
|
|
221
249
|
let url;
|
|
222
250
|
try {
|
|
223
251
|
url = typeof value === 'string' ? new URL(value.replaceAll(':provider', 'provider')) : undefined;
|
|
@@ -226,10 +254,14 @@ function assertCallbackUrl(value) {
|
|
|
226
254
|
url = undefined;
|
|
227
255
|
}
|
|
228
256
|
if (!url || (url.protocol !== 'https:' && url.protocol !== 'http:')) {
|
|
229
|
-
throw new Error(
|
|
257
|
+
throw new Error(`OidcService: ${must} the absolute URL of your callback route, with \`:provider\` where the ` +
|
|
230
258
|
`provider's name goes, e.g. 'https://app.example.com/auth/oidc/:provider/callback'. Got ${JSON.stringify(value)}.`);
|
|
231
259
|
}
|
|
232
260
|
}
|
|
261
|
+
const CREDENTIALS = {
|
|
262
|
+
clientId: 'the client id the provider issued',
|
|
263
|
+
clientSecret: "the client secret the provider issued; a public client sets `tokenEndpointAuthMethod: 'none'` instead",
|
|
264
|
+
};
|
|
233
265
|
/**
|
|
234
266
|
* `oidc.providers.<name>.clientId`, and `clientSecret` unless the provider
|
|
235
267
|
* is a public client (`tokenEndpointAuthMethod: 'none'`), checked at
|
|
@@ -237,11 +269,20 @@ function assertCallbackUrl(value) {
|
|
|
237
269
|
* confidential client into a public one, and fail at the token exchange.
|
|
238
270
|
*/
|
|
239
271
|
function assertProviderCredentials(providers) {
|
|
240
|
-
requireOption(providers, 'oidc.providers', 'the providers users can sign in with, by name');
|
|
272
|
+
requireOption(providers, 'oidc.providers', 'the providers users can sign in with, by name, or a function of the name');
|
|
241
273
|
for (const [name, config] of Object.entries(providers)) {
|
|
242
|
-
requireOption(config?.clientId, `oidc.providers.${name}.clientId`,
|
|
274
|
+
requireOption(config?.clientId, `oidc.providers.${name}.clientId`, CREDENTIALS.clientId);
|
|
243
275
|
if (config.tokenEndpointAuthMethod !== 'none') {
|
|
244
|
-
requireOption(config.clientSecret, `oidc.providers.${name}.clientSecret`,
|
|
276
|
+
requireOption(config.clientSecret, `oidc.providers.${name}.clientSecret`, CREDENTIALS.clientSecret);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** The same checks, on each config the `oidc.providers` function returns. */
|
|
281
|
+
function assertResolvedCredentials(name, config) {
|
|
282
|
+
for (const key of ['clientId', 'clientSecret']) {
|
|
283
|
+
if (!config[key] && (key === 'clientId' || config.tokenEndpointAuthMethod !== 'none')) {
|
|
284
|
+
throw new Error(`OidcService: the \`oidc.providers\` function returned the provider '${name}' without its \`${key}\` ` +
|
|
285
|
+
`(${CREDENTIALS[key]}).`);
|
|
245
286
|
}
|
|
246
287
|
}
|
|
247
288
|
}
|
|
@@ -14,7 +14,9 @@ export declare function isHashablePassword(password: unknown): password is strin
|
|
|
14
14
|
* The encoded hash is self-describing (`$scrypt$ln=17,r=8,p=1$salt$hash`),
|
|
15
15
|
* so parameters can be raised later: `verify()` reads the parameters from
|
|
16
16
|
* the stored hash and `needsRehash()` tells the sign-in code to store a
|
|
17
|
-
* fresh hash while it has the plaintext.
|
|
17
|
+
* fresh hash while it has the plaintext. Until then, `verify()` makes up the
|
|
18
|
+
* work an older, cheaper hash saves, so it answers no sooner than for an
|
|
19
|
+
* unknown user.
|
|
18
20
|
*
|
|
19
21
|
* Passwords are NFKC-normalised (NIST SP 800-63B §5.1.1.2) so the same
|
|
20
22
|
* password typed on different keyboards verifies. `hash()` refuses an empty
|
|
@@ -37,6 +39,11 @@ export declare class PasswordHasher {
|
|
|
37
39
|
* is checked instead, so response time does not reveal which accounts
|
|
38
40
|
* exist. The first such call makes the dummy hash, which costs the same
|
|
39
41
|
* one derivation as every later check.
|
|
42
|
+
*
|
|
43
|
+
* A hash stored before the cost was raised is cheaper to check than the
|
|
44
|
+
* dummy, so its check is topped up to the configured work, right password
|
|
45
|
+
* or wrong. One stored before the cost was lowered takes its longer time
|
|
46
|
+
* until it is rehashed: that can't be hidden.
|
|
40
47
|
*/
|
|
41
48
|
verify(password: string, encoded: string | null | undefined): Promise<boolean>;
|
|
42
49
|
/** `true` when the stored hash uses weaker or different parameters than configured. */
|
|
@@ -8,6 +8,8 @@ function scrypt(password, salt, keyLength, { logN, r, p }) {
|
|
|
8
8
|
return new Promise((resolve, reject) => scryptCb(password, salt, keyLength, options, (err, key) => (err ? reject(err) : resolve(key))));
|
|
9
9
|
}
|
|
10
10
|
const b64 = (buf) => buf.toString('base64').replace(/=+$/, '');
|
|
11
|
+
/** What a derivation costs: N·r·p, since OpenSSL runs the `p` lanes one after another. */
|
|
12
|
+
const work = ({ logN, r, p }) => 2 ** logN * r * p;
|
|
11
13
|
/**
|
|
12
14
|
* @internal The bytes `PasswordHasher` derives from: the password NFKC-normalised, as UTF-8. Throws
|
|
13
15
|
* for anything but a string, for an empty one (which no account should have), and past 4 KiB,
|
|
@@ -48,7 +50,9 @@ export function isHashablePassword(password) {
|
|
|
48
50
|
* The encoded hash is self-describing (`$scrypt$ln=17,r=8,p=1$salt$hash`),
|
|
49
51
|
* so parameters can be raised later: `verify()` reads the parameters from
|
|
50
52
|
* the stored hash and `needsRehash()` tells the sign-in code to store a
|
|
51
|
-
* fresh hash while it has the plaintext.
|
|
53
|
+
* fresh hash while it has the plaintext. Until then, `verify()` makes up the
|
|
54
|
+
* work an older, cheaper hash saves, so it answers no sooner than for an
|
|
55
|
+
* unknown user.
|
|
52
56
|
*
|
|
53
57
|
* Passwords are NFKC-normalised (NIST SP 800-63B §5.1.1.2) so the same
|
|
54
58
|
* password typed on different keyboards verifies. `hash()` refuses an empty
|
|
@@ -86,6 +90,11 @@ export class PasswordHasher {
|
|
|
86
90
|
* is checked instead, so response time does not reveal which accounts
|
|
87
91
|
* exist. The first such call makes the dummy hash, which costs the same
|
|
88
92
|
* one derivation as every later check.
|
|
93
|
+
*
|
|
94
|
+
* A hash stored before the cost was raised is cheaper to check than the
|
|
95
|
+
* dummy, so its check is topped up to the configured work, right password
|
|
96
|
+
* or wrong. One stored before the cost was lowered takes its longer time
|
|
97
|
+
* until it is rehashed: that can't be hidden.
|
|
89
98
|
*/
|
|
90
99
|
async verify(password, encoded) {
|
|
91
100
|
if (!encoded) {
|
|
@@ -116,6 +125,17 @@ export class PasswordHasher {
|
|
|
116
125
|
return false;
|
|
117
126
|
}
|
|
118
127
|
const actual = await this.derive(input, parsed.salt, parsed.hash.length, parsed.params);
|
|
128
|
+
// A hash stored before the cost was raised takes less work than the dummy, which would tell an
|
|
129
|
+
// account from an unknown email: make up the difference in N·r·p with derivations at the
|
|
130
|
+
// configured r and p, halving N each time. Exact when only N changed, within 2·r·p otherwise.
|
|
131
|
+
let missing = work(this.params) - work(parsed.params);
|
|
132
|
+
for (let logN = this.params.logN - 1; logN >= 1 && missing > 0; logN--) {
|
|
133
|
+
const params = { ...this.params, logN };
|
|
134
|
+
if (work(params) <= missing) {
|
|
135
|
+
await this.derive(input, parsed.salt, this.keyLength, params);
|
|
136
|
+
missing -= work(params);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
119
139
|
return timingSafeEqual(actual, parsed.hash);
|
|
120
140
|
}
|
|
121
141
|
/** `true` when the stored hash uses weaker or different parameters than configured. */
|
package/dist/testing/index.js
CHANGED
|
@@ -414,6 +414,9 @@ export function authenticationStoreContract(create, options = {}) {
|
|
|
414
414
|
}
|
|
415
415
|
same(await store.consumeEmailToken(reset.id, 'email-verification'), undefined, 'a token of another purpose is refused');
|
|
416
416
|
same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, '…and left in place');
|
|
417
|
+
// What the services do when a step after the consume fails.
|
|
418
|
+
await store.saveEmailToken(reset);
|
|
419
|
+
same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, 'a consumed token can be saved back, and works once more');
|
|
417
420
|
const second = emailToken({ userId: u1 });
|
|
418
421
|
await store.saveEmailToken(second);
|
|
419
422
|
await store.deleteUserEmailTokens(u1, 'password-reset');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestjs/authentication",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
|
|
5
5
|
"author": "Kamil Mysliwiec",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,45 +41,45 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@apollo/server": "5.5.1",
|
|
43
43
|
"@as-integrations/express5": "1.1.2",
|
|
44
|
-
"@commitlint/cli": "21.2.
|
|
45
|
-
"@commitlint/config-angular": "21.2.
|
|
44
|
+
"@commitlint/cli": "21.2.4",
|
|
45
|
+
"@commitlint/config-angular": "21.2.4",
|
|
46
46
|
"@electric-sql/pglite": "0.5.8",
|
|
47
47
|
"@electric-sql/pglite-socket": "0.2.11",
|
|
48
48
|
"@nestjs/apollo": "14.0.1",
|
|
49
|
-
"@nestjs/authorization": "0.0
|
|
50
|
-
"@nestjs/common": "12.
|
|
51
|
-
"@nestjs/core": "12.
|
|
52
|
-
"@nestjs/drizzle": "0.0.
|
|
49
|
+
"@nestjs/authorization": "0.1.0",
|
|
50
|
+
"@nestjs/common": "12.2.0",
|
|
51
|
+
"@nestjs/core": "12.2.0",
|
|
52
|
+
"@nestjs/drizzle": "0.0.2",
|
|
53
53
|
"@nestjs/graphql": "14.0.1",
|
|
54
|
-
"@nestjs/idempotency": "0.0.
|
|
54
|
+
"@nestjs/idempotency": "0.0.2",
|
|
55
55
|
"@nestjs/mail": "0.0.1",
|
|
56
|
-
"@nestjs/microservices": "12.
|
|
57
|
-
"@nestjs/platform-express": "12.
|
|
58
|
-
"@nestjs/platform-fastify": "12.
|
|
59
|
-
"@nestjs/platform-ws": "12.
|
|
60
|
-
"@nestjs/resilience": "0.0.
|
|
61
|
-
"@nestjs/testing": "12.
|
|
56
|
+
"@nestjs/microservices": "12.2.0",
|
|
57
|
+
"@nestjs/platform-express": "12.2.0",
|
|
58
|
+
"@nestjs/platform-fastify": "12.2.0",
|
|
59
|
+
"@nestjs/platform-ws": "12.2.0",
|
|
60
|
+
"@nestjs/resilience": "0.0.2",
|
|
61
|
+
"@nestjs/testing": "12.2.0",
|
|
62
62
|
"@nestjs/throttler": "6.7.1",
|
|
63
|
-
"@nestjs/websockets": "12.
|
|
64
|
-
"@types/node": "24.19.
|
|
63
|
+
"@nestjs/websockets": "12.2.0",
|
|
64
|
+
"@types/node": "24.19.2",
|
|
65
65
|
"@types/pg": "8.23.1",
|
|
66
66
|
"@types/supertest": "7.2.1",
|
|
67
67
|
"@types/ws": "8.18.2",
|
|
68
|
-
"drizzle-orm": "0.45.
|
|
68
|
+
"drizzle-orm": "0.45.4",
|
|
69
69
|
"graphql": "16.14.2",
|
|
70
70
|
"husky": "9.1.7",
|
|
71
71
|
"lint-staged": "17.6.0",
|
|
72
|
-
"oxlint": "1.
|
|
72
|
+
"oxlint": "1.89.0",
|
|
73
73
|
"pg": "8.23.1",
|
|
74
|
-
"prettier": "3.9.
|
|
74
|
+
"prettier": "3.9.10",
|
|
75
75
|
"reflect-metadata": "0.2.2",
|
|
76
76
|
"release-it": "21.1.1",
|
|
77
77
|
"rxjs": "7.8.2",
|
|
78
78
|
"supertest": "7.3.1",
|
|
79
79
|
"typeorm": "1.1.2",
|
|
80
80
|
"typescript": "7.0.2",
|
|
81
|
-
"vite": "8.3.
|
|
82
|
-
"vitest": "5.0.
|
|
81
|
+
"vite": "8.3.5",
|
|
82
|
+
"vitest": "5.0.5",
|
|
83
83
|
"ws": "8.22.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|