@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
@@ -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 SessionService_1;
14
+ import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
14
15
  import { firstHeader } from '../utils/auth-state.util.js';
15
16
  import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
16
17
  import { isCrossOriginWrite, normalizeOrigin } from '../utils/cross-origin.util.js';
@@ -29,10 +30,12 @@ import { assertCookieAttributes, defaultCookieName, readCookie, serializeCookie
29
30
  * - `create()`, `validate()` and `rotate()` are the low-level operations
30
31
  * behind `SignInService`, which also reads and writes the cookie.
31
32
  */
32
- let SessionService = class SessionService {
33
+ let SessionService = SessionService_1 = class SessionService {
33
34
  constructor(storage, options, events = new AuthenticationEvents()) {
34
35
  this.storage = storage;
35
36
  this.events = events;
37
+ /** Touches failed since the last successful one: a spell of failures logs one warning, not one per request. */
38
+ this.failedTouches = 0;
36
39
  this.options = options?.session ?? {};
37
40
  this.absoluteTtl = durationOr(this.options.absoluteTtl, '7d');
38
41
  // A pending session never outlives a verified one would.
@@ -70,7 +73,11 @@ let SessionService = class SessionService {
70
73
  ...(metadata && { metadata }),
71
74
  });
72
75
  }
73
- /** The live session for a token, sliding its idle timeout. */
76
+ /**
77
+ * The live session for a token, sliding its idle timeout. Sliding is
78
+ * best-effort: when the store fails to record the activity, the session
79
+ * is still returned, as read (see `SessionStore.touchSession()`).
80
+ */
74
81
  async validate(token) {
75
82
  if (!token || !TOKEN_PATTERN.test(token)) {
76
83
  return null;
@@ -90,8 +97,7 @@ let SessionService = class SessionService {
90
97
  return null;
91
98
  }
92
99
  if (now - record.lastActiveAt.getTime() >= this.touchInterval) {
93
- record.lastActiveAt = new Date(now);
94
- await this.storage.sessions.touchSession(record.id, record.lastActiveAt);
100
+ await this.touch(record, new Date(now));
95
101
  }
96
102
  return record;
97
103
  }
@@ -103,10 +109,11 @@ let SessionService = class SessionService {
103
109
  * `absoluteTtl` from its creation, in place of its `mfa.pendingTtl`.
104
110
  * Resolves `null` when the session was gone by the time it was replaced:
105
111
  * revoked (a sign-out everywhere, a password reset) or rotated by another
106
- * request, which leaves no new session behind.
112
+ * request, which leaves no new session behind. The session's `extra` is
113
+ * not carried over.
107
114
  */
108
115
  async rotate(session, changes = {}) {
109
- const { id: _, ...rest } = session;
116
+ const { id: _, extra: _extra, ...rest } = session;
110
117
  const verifying = session.mfa === 'pending' && changes.mfa === 'verified';
111
118
  // The new session first, then the old one, which must still be there to delete: if a revocation
112
119
  // took it meanwhile (deleting the user's sessions, this new one included, or not yet), or another
@@ -182,6 +189,33 @@ let SessionService = class SessionService {
182
189
  async discard(sessionId) {
183
190
  await this.storage.sessions.deleteSession(sessionId);
184
191
  }
192
+ /**
193
+ * Records activity, best-effort: the session was read live, so a failed write (a lock timeout,
194
+ * a read-only replica) does not fail the request, and the session keeps the idle deadline the
195
+ * store still has. Every failure is published as `session-touch-failed`; the log gets one warning
196
+ * per spell of failures (the first), then one line once a touch succeeds again, with how many
197
+ * failed in between.
198
+ */
199
+ async touch(record, lastActiveAt) {
200
+ try {
201
+ await this.storage.sessions.touchSession(record.id, lastActiveAt);
202
+ }
203
+ catch (error) {
204
+ if (this.failedTouches++ === 0) {
205
+ SessionService_1.logger.warn(`Recording activity on a session of user ${record.userId} failed, so its idle timeout did not move: ` +
206
+ (error instanceof Error ? error.message : String(error)) +
207
+ '. Further failures are not logged until a touch succeeds again.');
208
+ }
209
+ // Published on every failure, logged or not, so metrics and alerting count them all.
210
+ this.events.emit({ type: 'session-touch-failed', userId: record.userId, sessionId: record.id, error });
211
+ return;
212
+ }
213
+ record.lastActiveAt = lastActiveAt;
214
+ if (this.failedTouches > 0) {
215
+ SessionService_1.logger.log(`Recording session activity works again; ${this.failedTouches} ${this.failedTouches === 1 ? 'touch' : 'touches'} failed meanwhile.`);
216
+ this.failedTouches = 0;
217
+ }
218
+ }
185
219
  isLive(record, now) {
186
220
  if (hasExpired(record.expiresAt, now)) {
187
221
  return false;
@@ -199,7 +233,8 @@ let SessionService = class SessionService {
199
233
  return this.options.now?.() ?? Date.now();
200
234
  }
201
235
  };
202
- SessionService = __decorate([
236
+ SessionService.logger = new Logger('SessionService');
237
+ SessionService = SessionService_1 = __decorate([
203
238
  Injectable(),
204
239
  __param(1, Optional()),
205
240
  __param(1, Inject(AUTHENTICATION_MODULE_OPTIONS)),
@@ -19,8 +19,9 @@ import { SessionService } from './session.service.js';
19
19
  * (login CSRF: signing the victim's browser in to the attacker's
20
20
  * account), by the same rule as `app.enableCsrfProtection()`;
21
21
  * - the browser's previous session is deleted (session fixation defence);
22
- * - users with a confirmed authenticator get an `mfa: 'pending'` session,
23
- * which is not signed in anywhere until `completeMfa()` verifies a code.
22
+ * - with `mfa` configured, users with a confirmed authenticator get an
23
+ * `mfa: 'pending'` session, which is not signed in anywhere until
24
+ * `completeMfa()` verifies a code.
24
25
  */
25
26
  export declare class SignInService {
26
27
  private readonly sessions;
@@ -71,9 +72,9 @@ export declare class SignInService {
71
72
  */
72
73
  signOut(): Promise<boolean>;
73
74
  /**
74
- * Ends every session and refresh-token family of the user, and clears
75
- * this browser's cookie if it was one of them. API keys live in your own
76
- * table: revoke those yourself.
75
+ * Ends every session and, with refresh tokens on (`accessToken`), every
76
+ * refresh-token family of the user, and clears this browser's cookie if it
77
+ * was one of them. API keys live in your own table: revoke those yourself.
77
78
  */
78
79
  signOutEverywhere(userId: string): Promise<void>;
79
80
  /**
@@ -31,8 +31,9 @@ import { SessionService } from './session.service.js';
31
31
  * (login CSRF: signing the victim's browser in to the attacker's
32
32
  * account), by the same rule as `app.enableCsrfProtection()`;
33
33
  * - the browser's previous session is deleted (session fixation defence);
34
- * - users with a confirmed authenticator get an `mfa: 'pending'` session,
35
- * which is not signed in anywhere until `completeMfa()` verifies a code.
34
+ * - with `mfa` configured, users with a confirmed authenticator get an
35
+ * `mfa: 'pending'` session, which is not signed in anywhere until
36
+ * `completeMfa()` verifies a code.
36
37
  */
37
38
  let SignInService = class SignInService {
38
39
  constructor(sessions, mfa, tokens, scope = new AuthenticationScope(), adapterHost, events = new AuthenticationEvents()) {
@@ -162,9 +163,9 @@ let SignInService = class SignInService {
162
163
  return true;
163
164
  }
164
165
  /**
165
- * Ends every session and refresh-token family of the user, and clears
166
- * this browser's cookie if it was one of them. API keys live in your own
167
- * table: revoke those yourself.
166
+ * Ends every session and, with refresh tokens on (`accessToken`), every
167
+ * refresh-token family of the user, and clears this browser's cookie if it
168
+ * was one of them. API keys live in your own table: revoke those yourself.
168
169
  */
169
170
  async signOutEverywhere(userId) {
170
171
  const exchange = this.scope.exchange();
@@ -8,32 +8,41 @@ import type { AuthenticationStorageSources, AuthenticationStorageContract, Authe
8
8
  /**
9
9
  * @internal What each feature reads or writes, audited against the code
10
10
  * paths: the production guard's rule.
11
- * Any feature that signs someone in reads `mfa`, configured or not: a user
12
- * may have enrolled an authenticator through another instance, and
13
- * `MfaService.isEnrolled()` is how a sign-in finds out. Any feature that
14
- * revokes a user's sign-ins reaches both `sessions` and `refreshTokens`.
11
+ * MFA is off unless `mfa` is configured: without it, `MfaService.isEnrolled()`
12
+ * answers `false` without reading the store, and its other methods throw.
13
+ * Refresh tokens exist only with `accessToken`, and not with
14
+ * `refreshToken: false`: otherwise `TokenService.issue()` starts no family
15
+ * and `revokeAll()` has nothing to revoke and reads nothing. Apps
16
+ * that sign users in from a shared user database must therefore agree: if
17
+ * one configures `mfa` (or `accessToken`), every one of them must, or a user
18
+ * who enrolled an authenticator through one signs in to another with a
19
+ * password alone (and a sign-out everywhere leaves token clients signed in).
20
+ * Removing `mfa` turns MFA off for everyone.
15
21
  */
16
22
  export declare const CONTRACTS_BY_FEATURE: {
17
23
  /**
18
24
  * A `SessionCookieProvider` in `providers`: `SessionService` (every
19
- * request), `SignInService.signIn()` (`isEnrolled()`), `completeMfa()`,
20
- * and `signOutEverywhere()`, which also revokes the user's refresh-token
21
- * families.
25
+ * request), `SignInService.signIn()` and `signOutEverywhere()`.
22
26
  */
23
- readonly sessionCookie: readonly ["sessions", "refreshTokens", "mfa"];
24
- /** `accessToken`: `TokenService` (refresh-token families), whose `issue()` checks for an authenticator. */
25
- readonly accessToken: readonly ["refreshTokens", "mfa"];
26
- /** `mfa`: enrollment, codes, recovery codes and the lockout. */
27
+ readonly sessionCookie: readonly ["sessions"];
28
+ /**
29
+ * `accessToken` without `refreshToken: false`: the refresh-token families
30
+ * `TokenService` starts, which every sign-out everywhere revokes too.
31
+ * Access tokens alone keep no state.
32
+ */
33
+ readonly refreshToken: readonly ["refreshTokens"];
34
+ /** `mfa`: enrollment, codes, recovery codes and the lockout; every sign-in checks for an authenticator. */
27
35
  readonly mfa: readonly ["mfa"];
28
36
  /** `magicLinkHandler`: pending links, then `SignInService.signIn()`. */
29
- readonly magicLink: readonly ["sessions", "mfa", "magicLinks"];
37
+ readonly magicLink: readonly ["sessions", "magicLinks"];
30
38
  /** `oidcAccountResolver`: logins in progress, the session that links an account, then `SignInService.signIn()`. */
31
- readonly oidc: readonly ["sessions", "mfa", "oidcStates"];
39
+ readonly oidc: readonly ["sessions", "oidcStates"];
32
40
  /**
33
- * `passwordResetHandler`: the links; `reset()` revokes every session and
34
- * refresh-token family of the user, and with `signIn: true` signs in.
41
+ * `passwordResetHandler`: the links; `reset()` revokes every session (and
42
+ * refresh-token family, with refresh tokens on) of the user, and with
43
+ * `signIn: true` signs in.
35
44
  */
36
- readonly passwordReset: readonly ["sessions", "refreshTokens", "mfa", "emailTokens"];
45
+ readonly passwordReset: readonly ["sessions", "emailTokens"];
37
46
  /** `emailVerificationHandler`: the links. */
38
47
  readonly emailVerification: readonly ["emailTokens"];
39
48
  };
@@ -22,32 +22,41 @@ import { AUTHENTICATION_STORAGE_REQUIREMENTS } from '../authentication.constants
22
22
  /**
23
23
  * @internal What each feature reads or writes, audited against the code
24
24
  * paths: the production guard's rule.
25
- * Any feature that signs someone in reads `mfa`, configured or not: a user
26
- * may have enrolled an authenticator through another instance, and
27
- * `MfaService.isEnrolled()` is how a sign-in finds out. Any feature that
28
- * revokes a user's sign-ins reaches both `sessions` and `refreshTokens`.
25
+ * MFA is off unless `mfa` is configured: without it, `MfaService.isEnrolled()`
26
+ * answers `false` without reading the store, and its other methods throw.
27
+ * Refresh tokens exist only with `accessToken`, and not with
28
+ * `refreshToken: false`: otherwise `TokenService.issue()` starts no family
29
+ * and `revokeAll()` has nothing to revoke and reads nothing. Apps
30
+ * that sign users in from a shared user database must therefore agree: if
31
+ * one configures `mfa` (or `accessToken`), every one of them must, or a user
32
+ * who enrolled an authenticator through one signs in to another with a
33
+ * password alone (and a sign-out everywhere leaves token clients signed in).
34
+ * Removing `mfa` turns MFA off for everyone.
29
35
  */
30
36
  export const CONTRACTS_BY_FEATURE = {
31
37
  /**
32
38
  * A `SessionCookieProvider` in `providers`: `SessionService` (every
33
- * request), `SignInService.signIn()` (`isEnrolled()`), `completeMfa()`,
34
- * and `signOutEverywhere()`, which also revokes the user's refresh-token
35
- * families.
39
+ * request), `SignInService.signIn()` and `signOutEverywhere()`.
36
40
  */
37
- sessionCookie: ['sessions', 'refreshTokens', 'mfa'],
38
- /** `accessToken`: `TokenService` (refresh-token families), whose `issue()` checks for an authenticator. */
39
- accessToken: ['refreshTokens', 'mfa'],
40
- /** `mfa`: enrollment, codes, recovery codes and the lockout. */
41
+ sessionCookie: ['sessions'],
42
+ /**
43
+ * `accessToken` without `refreshToken: false`: the refresh-token families
44
+ * `TokenService` starts, which every sign-out everywhere revokes too.
45
+ * Access tokens alone keep no state.
46
+ */
47
+ refreshToken: ['refreshTokens'],
48
+ /** `mfa`: enrollment, codes, recovery codes and the lockout; every sign-in checks for an authenticator. */
41
49
  mfa: ['mfa'],
42
50
  /** `magicLinkHandler`: pending links, then `SignInService.signIn()`. */
43
- magicLink: ['sessions', 'mfa', 'magicLinks'],
51
+ magicLink: ['sessions', 'magicLinks'],
44
52
  /** `oidcAccountResolver`: logins in progress, the session that links an account, then `SignInService.signIn()`. */
45
- oidc: ['sessions', 'mfa', 'oidcStates'],
53
+ oidc: ['sessions', 'oidcStates'],
46
54
  /**
47
- * `passwordResetHandler`: the links; `reset()` revokes every session and
48
- * refresh-token family of the user, and with `signIn: true` signs in.
55
+ * `passwordResetHandler`: the links; `reset()` revokes every session (and
56
+ * refresh-token family, with refresh tokens on) of the user, and with
57
+ * `signIn: true` signs in.
49
58
  */
50
- passwordReset: ['sessions', 'refreshTokens', 'mfa', 'emailTokens'],
59
+ passwordReset: ['sessions', 'emailTokens'],
51
60
  /** `emailVerificationHandler`: the links. */
52
61
  emailVerification: ['emailTokens'],
53
62
  };
@@ -70,8 +70,8 @@ export function authenticationStoreContract(create, options = {}) {
70
70
  for (const record of [full, bare, other]) {
71
71
  await store.createSession(record);
72
72
  }
73
- same(await store.getSession(full.id), full, 'getSession() returns every field as saved');
74
- same(await store.getSession(bare.id), bare, 'optional fields come back absent, not null');
73
+ same(stored(await store.getSession(full.id)), full, 'getSession() returns every field as saved');
74
+ same(stored(await store.getSession(bare.id)), bare, 'optional fields come back absent, not null');
75
75
  same(await store.getSession(id()), undefined, 'getSession() of an unknown id');
76
76
  same(sortIds(await store.listUserSessions(u1)), [full.id, bare.id].sort(), "listUserSessions() returns the user's sessions");
77
77
  same(await store.listUserSessions(uid()), [], 'listUserSessions() of a user without sessions');
@@ -81,13 +81,13 @@ export function authenticationStoreContract(create, options = {}) {
81
81
  same(await store.deleteSession(id()), false, 'deleteSession() of an unknown id resolves false');
82
82
  await store.deleteUserSessions(u1);
83
83
  same(await store.listUserSessions(u1), [], "deleteUserSessions() deletes the user's sessions");
84
- same(await store.getSession(other.id), other, "deleteUserSessions() leaves other users' sessions alone");
84
+ same(stored(await store.getSession(other.id)), other, "deleteUserSessions() leaves other users' sessions alone");
85
85
  });
86
86
  add('sessions', 'touchSession() moves lastActiveAt forward only, and never recreates a deleted session', async ({ sessions: store }) => {
87
87
  const record = session(uid(), { mfa: 'verified', metadata: { device: 'phone' } });
88
88
  await store.createSession(record);
89
89
  await store.touchSession(record.id, at(5_000));
90
- same(await store.getSession(record.id), { ...record, lastActiveAt: at(5_000) }, 'touchSession() changes lastActiveAt, nothing else');
90
+ same(stored(await store.getSession(record.id)), { ...record, lastActiveAt: at(5_000) }, 'touchSession() changes lastActiveAt, nothing else');
91
91
  await store.touchSession(record.id, at(1_000));
92
92
  same((await store.getSession(record.id))?.lastActiveAt, at(5_000), 'touchSession() never moves lastActiveAt back');
93
93
  await store.deleteSession(record.id);
@@ -102,7 +102,7 @@ export function authenticationStoreContract(create, options = {}) {
102
102
  await store.createSession(idle);
103
103
  await store.createSession(expired);
104
104
  await store.createSession(session(user, { createdAt: at(2_000), lastActiveAt: at(2_000) }));
105
- same(await store.getSession(idle.id), idle, 'a session idle for a long time, but within its absolute expiry, is kept');
105
+ same(stored(await store.getSession(idle.id)), idle, 'a session idle for a long time, but within its absolute expiry, is kept');
106
106
  const left = await store.getSession(expired.id);
107
107
  assert.ok(left === undefined || left.expiresAt <= at(2_000), 'a pruned session is gone, or still past its expiry');
108
108
  });
@@ -514,6 +514,14 @@ function emailToken(overrides = {}) {
514
514
  return { id: id(), purpose: 'password-reset', userId: uid(), email: `${uid()}@example.com`, createdAt: at(0), expiresAt: at(60_000), ...overrides };
515
515
  }
516
516
  const sortIds = (records) => records.map((record) => record.id).sort();
517
+ /** A session as stored: without the `extra` a store may read along with it. */
518
+ function stored(record) {
519
+ if (!record) {
520
+ return record;
521
+ }
522
+ const { extra: _, ...rest } = record;
523
+ return rest;
524
+ }
517
525
  /** Deep equality where a missing key and an `undefined` one are the same, and `null` is not `undefined`. */
518
526
  function same(actual, expected, message) {
519
527
  assert.deepStrictEqual(normalize(actual), normalize(expected), message);
@@ -1,33 +1,44 @@
1
1
  import { type ExecutionContext } from '@nestjs/common';
2
2
  import type { HttpAdapterHost } from '@nestjs/core';
3
+ import type { AuthenticationError } from '../errors/authentication.error.js';
3
4
  export interface Refusal {
4
5
  /** Default 401. 403 for a user the route refuses (an unverified address), 409 for a conflict (`MfaAlreadyEnrolledError`). */
5
6
  status?: 401 | 403 | 409;
6
- /** Default `Unauthorized`. */
7
+ /**
8
+ * Without one, Nest's bare body (`new UnauthorizedException()`): the status
9
+ * phrase as `message`, and no `error`.
10
+ */
7
11
  message?: string;
8
- /** Machine-readable reason (`mfa_required`), in the body's `error` field. */
12
+ /** Machine-readable reason (`mfa_required`): Nest's `errorCode`, and the body's `error` when there is a message. */
9
13
  code?: string;
14
+ /** Machine-readable data the app chose to send (`AuthenticationError`'s `details`): the body's `details`. */
15
+ details?: Record<string, unknown>;
10
16
  /** The error behind the refusal, kept as the exception's `cause` for logs. */
11
17
  cause?: unknown;
12
18
  }
19
+ /** The refusal for an `AuthenticationError` a provider or a handler threw. */
20
+ export declare function refusalOf(error: AuthenticationError): Refusal;
13
21
  /**
14
22
  * The one place this package's refusals become responses, for the guard and
15
23
  * for `AuthenticationError`s thrown by handlers: the error each transport's
16
24
  * exception filter understands, for the refusal's status.
17
25
  *
18
26
  * - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
19
- * `ForbiddenException` for a 403, `ConflictException` for a 409), so the body is Nest's own:
20
- * `{"message":"Unauthorized","statusCode":401}`, or with a message,
21
- * `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
22
- * A `code` replaces `error`. Over HTTP, `challenge` becomes the
23
- * `WWW-Authenticate` header of a 401.
27
+ * `ForbiddenException` for a 403, `ConflictException` for a 409), with
28
+ * Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
29
+ * message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
30
+ * A `code` is Nest's `errorCode` (on the exception, and in the body), and
31
+ * the description in `error` when there is a message; `details` follow.
32
+ * Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
24
33
  * - ws and rpc: a `WsException` / `RpcException` carrying
25
- * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`
26
- * with a `code`). Those filters report `HttpException`s as "Internal
27
- * server error", hence their own classes, loaded only when the packages
28
- * are installed.
34
+ * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
35
+ * `errorCode` and `details` as above). Those filters report
36
+ * `HttpException`s as "Internal server error", hence their own classes,
37
+ * loaded only when the packages are installed.
38
+ *
39
+ * Nothing else reaches the body: `cause` stays on the exception, for logs.
29
40
  */
30
- export declare function refusal(context: ExecutionContext, { status, message, code, cause }?: Refusal, { challenge, adapterHost }?: {
41
+ export declare function refusal(context: ExecutionContext, { status, message, code, details, cause }?: Refusal, { challenge, adapterHost }?: {
31
42
  challenge?: string;
32
43
  adapterHost?: HttpAdapterHost;
33
44
  }): Promise<Error>;
@@ -10,25 +10,42 @@ function load(pkg, name) {
10
10
  return pending;
11
11
  }
12
12
  const DEFAULT_MESSAGE = 'Unauthorized';
13
+ const PHRASES = { 401: 'Unauthorized', 403: 'Forbidden', 409: 'Conflict' };
14
+ /** The refusal for an `AuthenticationError` a provider or a handler threw. */
15
+ export function refusalOf(error) {
16
+ const { status, message, code, details } = error;
17
+ return { status, message, code, details, cause: error };
18
+ }
13
19
  /**
14
20
  * The one place this package's refusals become responses, for the guard and
15
21
  * for `AuthenticationError`s thrown by handlers: the error each transport's
16
22
  * exception filter understands, for the refusal's status.
17
23
  *
18
24
  * - HTTP and GraphQL: Nest's own `UnauthorizedException` (or
19
- * `ForbiddenException` for a 403, `ConflictException` for a 409), so the body is Nest's own:
20
- * `{"message":"Unauthorized","statusCode":401}`, or with a message,
21
- * `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
22
- * A `code` replaces `error`. Over HTTP, `challenge` becomes the
23
- * `WWW-Authenticate` header of a 401.
25
+ * `ForbiddenException` for a 403, `ConflictException` for a 409), with
26
+ * Nest's own body: `{"message":"Unauthorized","statusCode":401}`, or with a
27
+ * message, `{"message":"Refresh token reused","error":"Unauthorized","statusCode":401}`.
28
+ * A `code` is Nest's `errorCode` (on the exception, and in the body), and
29
+ * the description in `error` when there is a message; `details` follow.
30
+ * Over HTTP, `challenge` becomes the `WWW-Authenticate` header of a 401.
24
31
  * - ws and rpc: a `WsException` / `RpcException` carrying
25
- * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`
26
- * with a `code`). Those filters report `HttpException`s as "Internal
27
- * server error", hence their own classes, loaded only when the packages
28
- * are installed.
32
+ * `{ statusCode, message }` (plus `status: 'error'` for ws, and `error`,
33
+ * `errorCode` and `details` as above). Those filters report
34
+ * `HttpException`s as "Internal server error", hence their own classes,
35
+ * loaded only when the packages are installed.
36
+ *
37
+ * Nothing else reaches the body: `cause` stays on the exception, for logs.
29
38
  */
30
- export async function refusal(context, { status = 401, message = DEFAULT_MESSAGE, code, cause } = {}, { challenge, adapterHost } = {}) {
31
- const payload = { statusCode: status, ...(code && { error: code }), message };
39
+ export async function refusal(context, { status = 401, message, code, details, cause } = {}, { challenge, adapterHost } = {}) {
40
+ // The default message without a code gives the body of a bare `new UnauthorizedException()`.
41
+ const bare = message === undefined || (status === 401 && message === DEFAULT_MESSAGE && !code);
42
+ const payload = {
43
+ statusCode: status,
44
+ ...(!bare && code && { error: code }),
45
+ message: message ?? PHRASES[status],
46
+ ...(code && { errorCode: code }),
47
+ ...(details !== undefined && { details }),
48
+ };
32
49
  switch (context.getType()) {
33
50
  case 'ws': {
34
51
  const WsException = await load('@nestjs/websockets', 'WsException');
@@ -50,17 +67,12 @@ export async function refusal(context, { status = 401, message = DEFAULT_MESSAGE
50
67
  }
51
68
  break;
52
69
  }
53
- const options = { cause, ...(code && { description: code }) };
54
- if (status === 409) {
55
- return new ConflictException(message, options);
56
- }
57
- if (status === 403) {
58
- return new ForbiddenException(message, options);
59
- }
60
- // The default message gives the body of a bare `new UnauthorizedException()`.
61
- return message === DEFAULT_MESSAGE && !code
62
- ? new UnauthorizedException(undefined, options)
63
- : new UnauthorizedException(message, options);
70
+ const Exception = status === 409 ? ConflictException : status === 403 ? ForbiddenException : UnauthorizedException;
71
+ // Nest builds the body: `error` is the code (the description) or the status phrase, then `errorCode`.
72
+ const options = { cause, ...(code && { errorCode: code, ...(!bare && { description: code }) }) };
73
+ const exception = new Exception(bare ? undefined : message, options);
74
+ // `details` are not Nest's: its body, with them.
75
+ return details === undefined ? exception : new Exception({ ...exception.getResponse(), details }, options);
64
76
  }
65
77
  function withCause(error, cause) {
66
78
  if (cause !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/authentication",
3
- "version": "0.0.1",
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,41 +46,41 @@
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": "file:../authorization/nestjs-authorization-0.0.1.tgz",
50
- "@nestjs/common": "12.1.0",
51
- "@nestjs/core": "12.1.0",
49
+ "@nestjs/authorization": "0.1.0",
50
+ "@nestjs/common": "12.1.2",
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",
55
- "@nestjs/mail": "file:../mail/nestjs-mail-0.0.1.tgz",
56
- "@nestjs/microservices": "12.1.0",
57
- "@nestjs/platform-express": "12.1.0",
58
- "@nestjs/platform-fastify": "12.1.0",
59
- "@nestjs/platform-ws": "12.1.0",
60
- "@nestjs/resilience": "0.0.1",
61
- "@nestjs/testing": "12.1.0",
62
- "@nestjs/throttler": "6.7.0",
63
- "@nestjs/websockets": "12.1.0",
64
- "@types/node": "24.13.6",
54
+ "@nestjs/idempotency": "0.0.2",
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",
62
+ "@nestjs/throttler": "6.7.1",
63
+ "@nestjs/websockets": "12.1.2",
64
+ "@types/node": "24.19.2",
65
65
  "@types/pg": "8.23.1",
66
- "@types/supertest": "6.0.3",
67
- "@types/ws": "8.18.1",
68
- "drizzle-orm": "0.45.3",
66
+ "@types/supertest": "7.2.1",
67
+ "@types/ws": "8.18.2",
68
+ "drizzle-orm": "0.45.4",
69
69
  "graphql": "16.14.2",
70
70
  "husky": "9.1.7",
71
- "lint-staged": "17.5.1",
72
- "oxlint": "1.85.0",
73
- "pg": "8.23.0",
74
- "prettier": "3.9.8",
71
+ "lint-staged": "17.6.0",
72
+ "oxlint": "1.87.0",
73
+ "pg": "8.23.1",
74
+ "prettier": "3.9.10",
75
75
  "reflect-metadata": "0.2.2",
76
- "release-it": "21.1.0",
76
+ "release-it": "21.1.1",
77
77
  "rxjs": "7.8.2",
78
- "supertest": "7.2.2",
79
- "typeorm": "1.1.1",
78
+ "supertest": "7.3.1",
79
+ "typeorm": "1.1.2",
80
80
  "typescript": "7.0.2",
81
- "vite": "8.3.0",
82
- "vitest": "5.0.1",
83
- "ws": "8.21.3"
81
+ "vite": "8.3.4",
82
+ "vitest": "5.0.3",
83
+ "ws": "8.22.0"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "@nestjs/common": "^12.0.0",