@nestjs/authentication 0.1.0 → 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.
@@ -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,7 +58,11 @@ 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.
60
66
  */
61
67
  reset(token: string, password: string, { signIn }?: ResetPasswordOptions): Promise<PasswordResetResult | null>;
62
68
  onModuleDestroy(): Promise<void>;
@@ -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,19 @@ 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.
91
97
  */
92
98
  async reset(token, password, { signIn = false } = {}) {
93
99
  const { handler } = this.feature();
100
+ // Signing in would refuse this request at the end, after the link is spent and the password
101
+ // changed: refuse it before, whatever the token is.
102
+ if (signIn) {
103
+ this.signInService.refuseCrossOrigin(this.scope.exchange()?.request);
104
+ }
94
105
  if (typeof token !== 'string' || !TOKEN_PATTERN.test(token) || !isHashablePassword(password)) {
95
106
  return null;
96
107
  }
@@ -178,7 +189,8 @@ PasswordResetService = PasswordResetService_1 = __decorate([
178
189
  PasswordHasher,
179
190
  SessionService,
180
191
  TokenService,
181
- SignInService, Object, AuthenticationEvents])
192
+ SignInService, Object, AuthenticationEvents,
193
+ AuthenticationScope])
182
194
  ], PasswordResetService);
183
195
  export { PasswordResetService };
184
196
  /**
@@ -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';
@@ -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,8 +45,16 @@ 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;
57
+ callbackUrl: string | ((request: OidcRequest) => string | Promise<string>);
50
58
  providers: Record<string, OidcProviderConfig>;
51
59
  /** Where to land after sign-in when the login had no `redirectTo`. Default `/`. */
52
60
  redirectAfterLogin?: string;
@@ -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
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/authentication",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
5
5
  "author": "Kamil Mysliwiec",
6
6
  "license": "MIT",
@@ -46,39 +46,39 @@
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",
49
+ "@nestjs/authorization": "0.1.0",
50
50
  "@nestjs/common": "12.1.2",
51
51
  "@nestjs/core": "12.1.2",
52
52
  "@nestjs/drizzle": "0.0.1",
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
56
  "@nestjs/microservices": "12.1.2",
57
57
  "@nestjs/platform-express": "12.1.2",
58
58
  "@nestjs/platform-fastify": "12.1.2",
59
59
  "@nestjs/platform-ws": "12.1.2",
60
- "@nestjs/resilience": "0.0.1",
60
+ "@nestjs/resilience": "0.0.2",
61
61
  "@nestjs/testing": "12.1.2",
62
62
  "@nestjs/throttler": "6.7.1",
63
63
  "@nestjs/websockets": "12.1.2",
64
- "@types/node": "24.19.1",
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
72
  "oxlint": "1.87.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",
81
+ "vite": "8.3.4",
82
82
  "vitest": "5.0.3",
83
83
  "ws": "8.22.0"
84
84
  },