@nestjs/authentication 0.0.1 → 0.1.1

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