@nestjs/authentication 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  ## Description
20
20
 
21
- Authentication module for [Nest](https://github.com/nestjs/nest): a global guard with `@Public()` and `@Authenticate()`, credential providers as ordinary injectable classes (bearer JWT, cookie sessions, your own), server-side sessions with rotation, refresh tokens, TOTP second factor, magic links, OIDC sign-in, email verification and password reset, across HTTP, GraphQL, WebSockets and microservices, with no third-party dependencies.
21
+ Authentication module for [Nest](https://github.com/nestjs/nest): a global guard with `@Public()` and `@Authenticate()`, credential providers as ordinary injectable classes (bearer JWT, cookie sessions, your own), server-side sessions with rotation, refresh tokens, TOTP second factor, magic links, OIDC sign-in, email verification and password reset, across HTTP, GraphQL, WebSockets, microservices and MCP tool calls (`@nestjs/mcp`), with no third-party dependencies.
22
22
 
23
23
  ## Installation
24
24
 
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)),
@@ -62,11 +62,18 @@ export declare class PasswordResetService implements OnModuleDestroy {
62
62
  * another origin than the app's own and `session.trustedOrigins` gets a
63
63
  * `ForbiddenException` first, as `SignInService.signIn()` does, so the link
64
64
  * and the password stay as they were. Without `signIn`, the origin is not
65
- * checked.
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.
66
68
  */
67
69
  reset(token: string, password: string, { signIn }?: ResetPasswordOptions): Promise<PasswordResetResult | null>;
68
70
  onModuleDestroy(): Promise<void>;
69
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;
70
77
  /** Ends every session and refresh-token family (with refresh tokens on) of the user. */
71
78
  private revokeSignIns;
72
79
  /** The options and the handler; the module refuses to start with one and not the other. */
@@ -93,7 +93,9 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
93
93
  * another origin than the app's own and `session.trustedOrigins` gets a
94
94
  * `ForbiddenException` first, as `SignInService.signIn()` does, so the link
95
95
  * and the password stay as they were. Without `signIn`, the origin is not
96
- * checked.
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.
97
99
  */
98
100
  async reset(token, password, { signIn = false } = {}) {
99
101
  const { handler } = this.feature();
@@ -109,20 +111,21 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
109
111
  if (!record || hasExpired(record.expiresAt, this.now())) {
110
112
  return null;
111
113
  }
112
- const found = await handler.findUser(normalizeEmail(record.email));
113
- const account = found && checked(found);
114
- if (!account ||
115
- account.id !== record.userId ||
116
- account.email !== record.email ||
117
- !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) {
118
127
  return null;
119
128
  }
120
- const passwordHash = await this.hasher.hash(password);
121
- // Sign-ins end before the new password is stored, so a store that fails leaves the old password
122
- // without its sessions rather than the new one next to them; and again after, for any sign-in
123
- // that started in between.
124
- await this.revokeSignIns(account.id);
125
- await handler.updatePassword(account.id, passwordHash);
126
129
  await this.storage.emailTokens.deleteUserEmailTokens(account.id, 'password-reset');
127
130
  await this.revokeSignIns(account.id);
128
131
  // The link reached the address, and the password is the owner's now.
@@ -161,6 +164,27 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
161
164
  url.searchParams.set('token', token);
162
165
  await handler.send({ userId: account.id, email: account.email, url: url.toString(), expiresAt });
163
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
+ }
164
188
  /** Ends every session and refresh-token family (with refresh tokens on) of the user. */
165
189
  async revokeSignIns(userId) {
166
190
  await this.sessions.revokeAll(userId);
@@ -32,8 +32,11 @@ export declare class AuthenticationScope {
32
32
  get current(): Scope | undefined;
33
33
  run<R>(scope: Scope, fn: () => R): R;
34
34
  /**
35
- * The HTTP request and response of the current call: an HTTP handler, or
36
- * a GraphQL resolver whose context carries `req` (and `res`).
35
+ * The HTTP request and response of the current call: an HTTP handler, a
36
+ * GraphQL resolver whose context carries `req` (and `res`), or an
37
+ * `@nestjs/mcp` call that arrived over HTTP (its context, the second of
38
+ * its arguments, carries `request`). An MCP call has no response of its own
39
+ * to set cookies on: the MCP SDK writes the HTTP response.
37
40
  */
38
41
  exchange(): HttpExchange | undefined;
39
42
  }
@@ -39,8 +39,11 @@ let AuthenticationScope = class AuthenticationScope {
39
39
  return this.storage.run(scope, fn);
40
40
  }
41
41
  /**
42
- * The HTTP request and response of the current call: an HTTP handler, or
43
- * a GraphQL resolver whose context carries `req` (and `res`).
42
+ * The HTTP request and response of the current call: an HTTP handler, a
43
+ * GraphQL resolver whose context carries `req` (and `res`), or an
44
+ * `@nestjs/mcp` call that arrived over HTTP (its context, the second of
45
+ * its arguments, carries `request`). An MCP call has no response of its own
46
+ * to set cookies on: the MCP SDK writes the HTTP response.
44
47
  */
45
48
  exchange() {
46
49
  const context = this.current?.context;
@@ -53,6 +56,10 @@ let AuthenticationScope = class AuthenticationScope {
53
56
  const gql = context.getArgByIndex(2);
54
57
  return gql?.req ? { request: gql.req, response: gql.res } : undefined;
55
58
  }
59
+ case 'mcp': {
60
+ const request = context.getArgByIndex(1)?.request;
61
+ return request?.headers ? { request } : undefined;
62
+ }
56
63
  default:
57
64
  return undefined;
58
65
  }
@@ -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
  /**
@@ -55,7 +55,27 @@ export interface OidcOptions extends OidcClientOptions {
55
55
  * domain where it started.
56
56
  */
57
57
  callbackUrl: string | ((request: OidcRequest) => string | Promise<string>);
58
- providers: Record<string, OidcProviderConfig>;
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>);
59
79
  /** Where to land after sign-in when the login had no `redirectTo`. Default `/`. */
60
80
  redirectAfterLogin?: string;
61
81
  /** Lifetime of a login between the redirect to the provider and the callback. Default `'10m'`. */
@@ -64,7 +64,10 @@ export declare class MagicLinkService {
64
64
  * instead, before anything is looked up or burned: the module answers it
65
65
  * with a 401 that tells the customer where to open the link. Each
66
66
  * refusal is a `magic-link-refused` event with its reason. Outside an
67
- * 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.
68
71
  */
69
72
  consume(token: string, { request }?: {
70
73
  request?: MagicLinkRequest;
@@ -146,10 +146,16 @@ let MagicLinkService = class MagicLinkService {
146
146
  * instead, before anything is looked up or burned: the module answers it
147
147
  * with a 401 that tells the customer where to open the link. Each
148
148
  * refusal is a `magic-link-refused` event with its reason. Outside an
149
- * 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.
150
153
  */
151
154
  async consume(token, { request } = {}) {
152
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);
153
159
  if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
154
160
  return this.refuse('unknown');
155
161
  }
@@ -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,13 @@ 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;
85
90
  /** `oidc.callbackUrl` for this request, with the provider's name in it. */
86
91
  private callbackUrl;
87
92
  private txCookie;
@@ -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,30 +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
81
  if (typeof this.options.callbackUrl !== 'function') {
83
82
  assertCallbackUrl(this.options.callbackUrl);
84
83
  }
85
- assertProviderCredentials(this.options.providers);
84
+ if (typeof this.options.providers !== 'function') {
85
+ assertProviderCredentials(this.options.providers);
86
+ }
86
87
  }
88
+ this.metadata = new OidcMetadataCache(this.options);
87
89
  this.transactionTtl = durationOr(this.options?.transactionTtl, '10m');
88
90
  this.txCookieName = defaultCookieName('oidc_tx', this.options?.cookie);
89
91
  }
90
- client(name) {
91
- const { options } = this.feature();
92
- const config = Object.hasOwn(options.providers, name) ? options.providers[name] : undefined;
93
- if (!config) {
94
- throw new NotFoundException();
95
- }
96
- let client = this.clients.get(name);
97
- if (!client) {
98
- client = new OidcClient(name, config, options);
99
- this.clients.set(name, client);
100
- }
101
- return client;
102
- }
103
92
  /**
104
93
  * Starts a login: stores the transaction, sets its cookie, and returns
105
94
  * the provider's authorization URL. 404 for a provider that isn't
@@ -121,7 +110,8 @@ let OidcService = class OidcService {
121
110
  }
122
111
  linking = { userId: session.userId, sessionId: session.id };
123
112
  }
124
- const { url, transaction, binding } = await this.client(provider)
113
+ const client = await this.client(provider);
114
+ const { url, transaction, binding } = await client
125
115
  .authorizationRequest(await this.callbackUrl(provider, req), safeRedirectPath(redirectTo), this.transactionTtl)
126
116
  .catch(toHttp);
127
117
  await this.storage.oidcStates.saveOidcState({ ...transaction, ...(linking && { link: linking }) });
@@ -151,7 +141,8 @@ let OidcService = class OidcService {
151
141
  // Asked again rather than stored with the login: the cookie keeps the callback on the login's host,
152
142
  // and a function that answers otherwise gets the code refused by the provider, not accepted.
153
143
  const callbackUrl = await this.callbackUrl(provider, req);
154
- const result = await this.client(provider).callback(query, transaction, callbackUrl).catch(toHttp);
144
+ const client = await this.client(provider);
145
+ const result = await client.callback(query, transaction, callbackUrl).catch(toHttp);
155
146
  const location = transaction.redirectTo ?? options.redirectAfterLogin ?? '/';
156
147
  if (transaction.link) {
157
148
  // The session that asked to link must still be the one on this browser.
@@ -199,6 +190,29 @@ let OidcService = class OidcService {
199
190
  const session = await this.sessions.validate(this.sessions.tokenFrom(request));
200
191
  return session && session.mfa !== 'pending' ? session : null;
201
192
  }
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
+ }
202
216
  /** `oidc.callbackUrl` for this request, with the provider's name in it. */
203
217
  async callbackUrl(provider, request) {
204
218
  const { callbackUrl } = this.feature().options;
@@ -244,6 +258,10 @@ function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
244
258
  `provider's name goes, e.g. 'https://app.example.com/auth/oidc/:provider/callback'. Got ${JSON.stringify(value)}.`);
245
259
  }
246
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
+ };
247
265
  /**
248
266
  * `oidc.providers.<name>.clientId`, and `clientSecret` unless the provider
249
267
  * is a public client (`tokenEndpointAuthMethod: 'none'`), checked at
@@ -251,11 +269,20 @@ function assertCallbackUrl(value, must = '`oidc.callbackUrl` must be') {
251
269
  * confidential client into a public one, and fail at the token exchange.
252
270
  */
253
271
  function assertProviderCredentials(providers) {
254
- 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');
255
273
  for (const [name, config] of Object.entries(providers)) {
256
- requireOption(config?.clientId, `oidc.providers.${name}.clientId`, 'the client id the provider issued');
274
+ requireOption(config?.clientId, `oidc.providers.${name}.clientId`, CREDENTIALS.clientId);
257
275
  if (config.tokenEndpointAuthMethod !== 'none') {
258
- 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]}).`);
259
286
  }
260
287
  }
261
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. */
@@ -113,16 +113,15 @@ export function authenticationStoreContract(create, options = {}) {
113
113
  // Two instances: one clock a little behind the other.
114
114
  const behind = new SessionService(storage, { session: { ...options, now: () => clock - 2_000 } });
115
115
  const ahead = new SessionService(storage, { session: { ...options, now: () => clock } });
116
- for (let round = 0; round < 3; round++) {
117
- const { token } = await ahead.create(uid());
118
- clock += 61_000; // idle for 61 s on `ahead`, 59 s on `behind`
119
- const [seenIdle, touched] = await Promise.all([ahead.validate(token), behind.validate(token)]);
120
- same(seenIdle, null, 'the request that saw the session idle is refused');
121
- assert.ok(touched, 'the other instance, within the idle timeout, touches the session');
122
- clock += 1_000;
123
- assert.ok(await ahead.validate(token), 'the touched session is still there');
124
- }
125
- }, concurrent);
116
+ const { token } = await ahead.create(uid());
117
+ clock += 61_000; // idle for 61 s on `ahead`, 59 s on `behind`
118
+ // One after the other: run together, `behind` may read and touch first, and then `ahead` rightly
119
+ // finds the session alive. In this order, a `validate()` that deleted what it saw idle fails below.
120
+ same(await ahead.validate(token), null, 'the request that saw the session idle is refused');
121
+ assert.ok(await behind.validate(token), 'the other instance, within the idle timeout, touches the session');
122
+ clock += 1_000;
123
+ assert.ok(await ahead.validate(token), 'the touched session is still there');
124
+ });
126
125
  add('sessions', 'a request racing a sign-out or a rotation never brings the session back (SessionService)', async (sources) => {
127
126
  let clock = T0;
128
127
  const sessions = new SessionService(storageOf(sources, 'sessions'), {
@@ -414,6 +413,9 @@ export function authenticationStoreContract(create, options = {}) {
414
413
  }
415
414
  same(await store.consumeEmailToken(reset.id, 'email-verification'), undefined, 'a token of another purpose is refused');
416
415
  same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, '…and left in place');
416
+ // What the services do when a step after the consume fails.
417
+ await store.saveEmailToken(reset);
418
+ same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, 'a consumed token can be saved back, and works once more');
417
419
  const second = emailToken({ userId: u1 });
418
420
  await store.saveEmailToken(second);
419
421
  await store.deleteUserEmailTokens(u1, 'password-reset');
@@ -38,13 +38,16 @@ export declare function getRawResult(context: ExecutionContext): State | undefin
38
38
  * (`WsAuthenticator.authenticateConnection()` stores the upgrade request
39
39
  * there for the `ws` library)
40
40
  * - `graphql`: `context.req.headers`
41
+ * - `mcp`: the headers of the HTTP request the call arrived in; `undefined`
42
+ * over stdio, where the MCP spec takes credentials from the environment
41
43
  * - `rpc`: `undefined`; metadata is transport specific
42
44
  */
43
45
  export declare function getRequestHeaders(context: ExecutionContext): Headers | undefined;
44
46
  /**
45
47
  * The request behind a call, as far as authentication needs it: headers,
46
- * and the method for HTTP and GraphQL (a WebSocket handshake has none, so
47
- * it counts as a safe request). `undefined` for RPC.
48
+ * and the method for HTTP, GraphQL and MCP (a WebSocket handshake has none,
49
+ * so it counts as a safe request). `undefined` for RPC, and for MCP over
50
+ * stdio.
48
51
  */
49
52
  export declare function requestOf(context: ExecutionContext): {
50
53
  headers: Headers;
@@ -28,12 +28,27 @@ function callKeys(context) {
28
28
  }
29
29
  return keys;
30
30
  }
31
+ /**
32
+ * The context of an `@nestjs/mcp` call (a tool, resource or prompt), read from
33
+ * its arguments, `[input, context]`, so this package needs no `@nestjs/mcp`
34
+ * dependency. Its `request` is the HTTP platform's request (a plain
35
+ * `{ method, url, headers }` object for `@nestjs/mcp/testing`'s in-memory
36
+ * client), absent over stdio.
37
+ */
38
+ function mcpCallOf(context) {
39
+ const call = context.getArgByIndex(1);
40
+ return typeof call === 'object' && call !== null ? call : undefined;
41
+ }
31
42
  /**
32
43
  * The object that carries the result for one call, mirrored as `user` /
33
44
  * `session` where Passport-era code and `@nestjs/authorization` look:
34
45
  * the request (http), the client socket (ws), the transport context (rpc),
35
46
  * `context.req` (graphql; over graphql-ws, the socket's upgrade request, which
36
- * every operation of the socket shares).
47
+ * every operation of the socket shares), and for an MCP call (`mcp`) the HTTP
48
+ * request it arrived in, else (over stdio) its own context object. Each MCP
49
+ * request travels in an HTTP request of its own, so the request is per call,
50
+ * and it is the one the endpoint's route was authenticated on: a guard there
51
+ * and on the call authenticate once.
37
52
  */
38
53
  function carrierOf(context) {
39
54
  switch (context.getType()) {
@@ -47,6 +62,10 @@ function carrierOf(context) {
47
62
  }
48
63
  case 'graphql':
49
64
  return context.getArgByIndex(2)?.req;
65
+ case 'mcp': {
66
+ const call = mcpCallOf(context);
67
+ return call?.request ?? call;
68
+ }
50
69
  default:
51
70
  return undefined;
52
71
  }
@@ -178,6 +197,8 @@ function stateCarrierOf(context) {
178
197
  * (`WsAuthenticator.authenticateConnection()` stores the upgrade request
179
198
  * there for the `ws` library)
180
199
  * - `graphql`: `context.req.headers`
200
+ * - `mcp`: the headers of the HTTP request the call arrived in; `undefined`
201
+ * over stdio, where the MCP spec takes credentials from the environment
181
202
  * - `rpc`: `undefined`; metadata is transport specific
182
203
  */
183
204
  export function getRequestHeaders(context) {
@@ -185,8 +206,9 @@ export function getRequestHeaders(context) {
185
206
  }
186
207
  /**
187
208
  * The request behind a call, as far as authentication needs it: headers,
188
- * and the method for HTTP and GraphQL (a WebSocket handshake has none, so
189
- * it counts as a safe request). `undefined` for RPC.
209
+ * and the method for HTTP, GraphQL and MCP (a WebSocket handshake has none,
210
+ * so it counts as a safe request). `undefined` for RPC, and for MCP over
211
+ * stdio.
190
212
  */
191
213
  export function requestOf(context) {
192
214
  switch (context.getType()) {
@@ -199,6 +221,10 @@ export function requestOf(context) {
199
221
  }
200
222
  case 'graphql':
201
223
  return context.getArgByIndex(2)?.req;
224
+ case 'mcp': {
225
+ const request = mcpCallOf(context)?.request;
226
+ return request?.headers ? request : undefined;
227
+ }
202
228
  default:
203
229
  return undefined;
204
230
  }
@@ -23,13 +23,15 @@ export declare function refusalOf(error: AuthenticationError): Refusal;
23
23
  * for `AuthenticationError`s thrown by handlers: the error each transport's
24
24
  * exception filter understands, for the refusal's status.
25
25
  *
26
- * - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
26
+ * - HTTP, GraphQL and MCP: Nest's own `UnauthorizedException` (or
27
27
  * `ForbiddenException` for a 403, `ConflictException` for a 409), with
28
28
  * Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
29
29
  * message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
30
30
  * A `code` is Nest's `errorCode` (on the exception, and in the body), and
31
31
  * the description in `error` when there is a message; `details` follow.
32
32
  * Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
33
+ * `@nestjs/mcp` answers a tool call's exception with a tool error carrying
34
+ * its message, and a resource's or prompt's with a JSON-RPC error.
33
35
  * - ws and rpc: a `WsException` / `RpcException` carrying
34
36
  * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
35
37
  * `errorCode` and `details` as above). Those filters report
@@ -21,13 +21,15 @@ export function refusalOf(error) {
21
21
  * for `AuthenticationError`s thrown by handlers: the error each transport's
22
22
  * exception filter understands, for the refusal's status.
23
23
  *
24
- * - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
24
+ * - HTTP, GraphQL and MCP: Nest's own `UnauthorizedException` (or
25
25
  * `ForbiddenException` for a 403, `ConflictException` for a 409), with
26
26
  * Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
27
27
  * message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
28
28
  * A `code` is Nest's `errorCode` (on the exception, and in the body), and
29
29
  * the description in `error` when there is a message; `details` follow.
30
30
  * Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
31
+ * `@nestjs/mcp` answers a tool call's exception with a tool error carrying
32
+ * its message, and a resource's or prompt's with a JSON-RPC error.
31
33
  * - ws and rpc: a `WsException` / `RpcException` carrying
32
34
  * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
33
35
  * `errorCode` and `details` as above). Those filters report
@@ -66,6 +68,12 @@ export async function refusal(context, { status = 401, message, code, details, c
66
68
  adapterHost?.httpAdapter?.setHeader(context.switchToHttp().getResponse(), 'WWW-Authenticate', challenge);
67
69
  }
68
70
  break;
71
+ case 'mcp':
72
+ // Nest's own exception, as for HTTP, which `@nestjs/mcp` answers with: a tool result with `isError: true` and
73
+ // the message, for a tool; a JSON-RPC error with the message and the status, for a resource or a prompt. The
74
+ // MCP SDK writes the HTTP response, so no challenge goes with it: an MCP client learns where to sign in from
75
+ // the endpoint's own 401 (`@nestjs/mcp`'s `auth` option).
76
+ break;
69
77
  }
70
78
  const Exception = status === 409 ? ConflictException : status === 403 ? ForbiddenException : UnauthorizedException;
71
79
  // Nest builds the body: `error` is the code (the description) or the status phrase, then `errorCode`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/authentication",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Nest - modern, fast, powerful node.js web framework (@authentication)",
5
5
  "author": "Kamil Mysliwiec",
6
6
  "license": "MIT",
@@ -41,26 +41,26 @@
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
49
  "@nestjs/authorization": "0.1.0",
50
- "@nestjs/common": "12.1.2",
51
- "@nestjs/core": "12.1.2",
52
- "@nestjs/drizzle": "0.0.1",
50
+ "@nestjs/common": "12.3.2",
51
+ "@nestjs/core": "12.3.2",
52
+ "@nestjs/drizzle": "0.0.2",
53
53
  "@nestjs/graphql": "14.0.1",
54
- "@nestjs/idempotency": "0.0.2",
54
+ "@nestjs/idempotency": "0.0.3",
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.2",
61
- "@nestjs/testing": "12.1.2",
56
+ "@nestjs/microservices": "12.3.2",
57
+ "@nestjs/platform-express": "12.3.2",
58
+ "@nestjs/platform-fastify": "12.3.2",
59
+ "@nestjs/platform-ws": "12.3.2",
60
+ "@nestjs/resilience": "0.0.3",
61
+ "@nestjs/testing": "12.3.2",
62
62
  "@nestjs/throttler": "6.7.1",
63
- "@nestjs/websockets": "12.1.2",
63
+ "@nestjs/websockets": "12.3.2",
64
64
  "@types/node": "24.19.2",
65
65
  "@types/pg": "8.23.1",
66
66
  "@types/supertest": "7.2.1",
@@ -69,18 +69,18 @@
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.91.0",
73
73
  "pg": "8.23.1",
74
74
  "prettier": "3.9.10",
75
75
  "reflect-metadata": "0.2.2",
76
- "release-it": "21.1.1",
76
+ "release-it": "21.1.2",
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.4",
82
- "vitest": "5.0.3",
83
- "ws": "8.22.0"
81
+ "vite": "8.3.5",
82
+ "vitest": "5.1.0",
83
+ "ws": "8.22.1"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "@nestjs/common": "^12.0.0",