@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 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
- import { Inject, Injectable, Optional } from '@nestjs/common';
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
- if (!(await handler.markVerified(record.userId, record.email))) {
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 = __decorate([
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
- const found = await handler.findUser(normalizeEmail(record.email));
102
- const account = found && checked(found);
103
- if (!account ||
104
- account.id !== record.userId ||
105
- account.email !== record.email ||
106
- !safeEqual(fingerprint(account.passwordHash), record.fingerprint ?? '')) {
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`). The one refusal `consume()` tells
5
- * apart: the customer can act on it (open the link where they asked for
6
- * it, or request a new one here), and it is decided before the link is
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`). The one refusal `consume()` tells
5
- * apart: the customer can act on it (open the link where they asked for
6
- * it, or request a new one here), and it is decided before the link is
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`). Without it, anyone can request a link for their
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
- providers: Record<string, OidcProviderConfig>;
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 `__Host-`
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 `__Host-`
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
- cookie = this.txCookie(`${hintOf(token)}.${binding}`, this.ttl / 1000);
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
- const req = request ?? this.scope.exchange()?.request;
136
- const [hint, secret] = (readCookie(firstHeader(req?.headers, 'cookie'), this.txCookieName) ?? '').split('.');
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 may be for a link this browser did request), and nothing is looked
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
- if (!hint || !secret || !TOKEN_PATTERN.test(secret) || !safeEqual(hint, hintOf(token))) {
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 browser's link is settled either way: the cookie goes.
150
- this.setCookie(this.txCookie('', 0));
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 discovery?;
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 {};
@@ -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 user: a bad one is a
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 ttl = durationOr(this.options.discoveryTtl, '1h');
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
- async request(url, init) {
276
- try {
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 clients;
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
- assertCallbackUrl(this.options.callbackUrl);
83
- assertProviderCredentials(this.options.providers);
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 { url, transaction, binding } = await this.client(provider)
123
- .authorizationRequest(this.callbackUrl(provider), safeRedirectPath(redirectTo), this.transactionTtl)
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
- const result = await this.client(provider).callback(query, transaction, this.callbackUrl(provider)).catch(toHttp);
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
- callbackUrl(provider) {
198
- return this.feature().options.callbackUrl.replaceAll(':provider', encodeURIComponent(provider));
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
- /** `oidc.callbackUrl`, checked at startup: an absolute http(s) URL. */
220
- function assertCallbackUrl(value) {
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('OidcService: `oidc.callbackUrl` must be the absolute URL of your callback route, with `:provider` where the ' +
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`, 'the client id the provider issued');
274
+ requireOption(config?.clientId, `oidc.providers.${name}.clientId`, CREDENTIALS.clientId);
243
275
  if (config.tokenEndpointAuthMethod !== 'none') {
244
- requireOption(config.clientSecret, `oidc.providers.${name}.clientSecret`, "the client secret the provider issued; a public client sets `tokenEndpointAuthMethod: 'none'` instead");
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. */
@@ -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.0",
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.3",
45
- "@commitlint/config-angular": "21.2.3",
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.1",
50
- "@nestjs/common": "12.1.2",
51
- "@nestjs/core": "12.1.2",
52
- "@nestjs/drizzle": "0.0.1",
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.1",
54
+ "@nestjs/idempotency": "0.0.2",
55
55
  "@nestjs/mail": "0.0.1",
56
- "@nestjs/microservices": "12.1.2",
57
- "@nestjs/platform-express": "12.1.2",
58
- "@nestjs/platform-fastify": "12.1.2",
59
- "@nestjs/platform-ws": "12.1.2",
60
- "@nestjs/resilience": "0.0.1",
61
- "@nestjs/testing": "12.1.2",
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.1.2",
64
- "@types/node": "24.19.1",
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.3",
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.87.0",
72
+ "oxlint": "1.89.0",
73
73
  "pg": "8.23.1",
74
- "prettier": "3.9.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.3",
82
- "vitest": "5.0.3",
81
+ "vite": "8.3.5",
82
+ "vitest": "5.0.5",
83
83
  "ws": "8.22.0"
84
84
  },
85
85
  "peerDependencies": {