@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
package/README.md CHANGED
@@ -2,11 +2,6 @@
2
2
  <a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
3
3
  </p>
4
4
 
5
- [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master
6
- [travis-url]: https://travis-ci.org/nestjs/nest
7
- [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux
8
- [linux-url]: https://travis-ci.org/nestjs/nest
9
-
10
5
  <p align="center">A progressive <a href="http://nodejs.org" target="blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
11
6
  <p align="center">
12
7
  <a href="https://www.npmjs.com/~nestjscore"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
@@ -33,7 +28,70 @@ $ npm i --save @nestjs/authentication
33
28
 
34
29
  ## Quick Start
35
30
 
36
- [Overview & Tutorial](https://docs.nestjs.com/security/authentication)
31
+ Import the module. Its global guard then requires a signed-in user on every route:
32
+
33
+ ```ts
34
+ @Module({
35
+ imports: [
36
+ AuthenticationModule.forRoot({
37
+ accessToken: {
38
+ key: process.env.JWT_SECRET!,
39
+ issuer: 'https://api.example.com',
40
+ audience: 'web',
41
+ ttl: '15m',
42
+ },
43
+ }),
44
+ ],
45
+ providers: [JwtAuth],
46
+ })
47
+ export class AppModule {}
48
+ ```
49
+
50
+ Credential providers are ordinary injectable classes that register themselves with `AuthenticationRegistry`:
51
+
52
+ ```ts
53
+ @Injectable()
54
+ export class JwtAuth extends JwtBearerProvider<User> {
55
+ constructor(
56
+ private readonly users: UsersRepository,
57
+ registry: AuthenticationRegistry,
58
+ ) {
59
+ super(); // verifies the tokens the module's `accessToken` option signs
60
+ registry.registerProvider(this);
61
+ }
62
+
63
+ validate({ sub }: JwtClaims) {
64
+ return sub ? this.users.findById(sub) : null;
65
+ }
66
+ }
67
+ ```
68
+
69
+ Then use `@Public()` to opt a route out, and `@Authenticate()` to change what a route requires:
70
+
71
+ ```ts
72
+ @Controller()
73
+ export class AppController {
74
+ @Public()
75
+ @Get('health')
76
+ health() {
77
+ return 'ok';
78
+ }
79
+
80
+ @Get('me')
81
+ me(@CurrentUser() user: User) {
82
+ return user;
83
+ }
84
+
85
+ // Anonymous callers get through too: `user` is then `null`.
86
+ @Authenticate({ optional: true })
87
+ @Get('greeting')
88
+ greeting(@CurrentUser() user: User | null) {
89
+ return user ? `Hello, ${user.email}` : 'Hello, guest';
90
+ }
91
+ }
92
+ ```
93
+
94
+ The in-memory stores are fine in development. In production, startup fails until you register real stores with `AuthenticationStorage.registerSource()` for everything your features use (a `SessionCookieProvider` uses the `sessions` store, `accessToken` the `refreshTokens` store unless `refreshToken: false`, and `mfa`, off unless configured, the `mfa` store), or you set `allowInMemoryStorage: true`. Read [Overview & Tutorial](https://docs.nestjs.com/security/authentication) for sessions, refresh tokens, TOTP, magic links, OIDC and account flows.
37
95
 
38
96
  ## Support
39
97
 
@@ -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,12 +58,16 @@ 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>;
63
69
  private issue;
64
- /** Ends every session and refresh-token family of the user. */
70
+ /** Ends every session and refresh-token family (with refresh tokens on) of the user. */
65
71
  private revokeSignIns;
66
72
  /** The options and the handler; the module refuses to start with one and not the other. */
67
73
  private feature;
@@ -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
  }
@@ -150,7 +161,7 @@ let PasswordResetService = PasswordResetService_1 = class PasswordResetService {
150
161
  url.searchParams.set('token', token);
151
162
  await handler.send({ userId: account.id, email: account.email, url: url.toString(), expiresAt });
152
163
  }
153
- /** Ends every session and refresh-token family of the user. */
164
+ /** Ends every session and refresh-token family (with refresh tokens on) of the user. */
154
165
  async revokeSignIns(userId) {
155
166
  await this.sessions.revokeAll(userId);
156
167
  await this.tokens.revokeAll(userId);
@@ -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
  /**
@@ -11,3 +11,12 @@ export declare const AUTHENTICATION_METADATA = "authentication:route";
11
11
  * so every copy of either package agrees on it.
12
12
  */
13
13
  export declare const AUTHENTICATION_GUARD_BRAND: unique symbol;
14
+ /**
15
+ * @internal Whether a route is public, written next to the route metadata by
16
+ * `@Public()` (`true`) and `@Authenticate()` (`false`), on the class or the
17
+ * method: the method's value, when it has one, wins. `@nestjs/authorization`
18
+ * reads it, without importing this package, so that a `@Public()` route
19
+ * needs no `@Can.Anyone()`. A registry symbol, so every copy of either
20
+ * package agrees on it.
21
+ */
22
+ export declare const AUTHENTICATION_PUBLIC: unique symbol;
@@ -11,3 +11,12 @@ export const AUTHENTICATION_METADATA = 'authentication:route';
11
11
  * so every copy of either package agrees on it.
12
12
  */
13
13
  export const AUTHENTICATION_GUARD_BRAND = Symbol.for('@nestjs/authentication:guard');
14
+ /**
15
+ * @internal Whether a route is public, written next to the route metadata by
16
+ * `@Public()` (`true`) and `@Authenticate()` (`false`), on the class or the
17
+ * method: the method's value, when it has one, wins. `@nestjs/authorization`
18
+ * reads it, without importing this package, so that a `@Public()` route
19
+ * needs no `@Can.Anyone()`. A registry symbol, so every copy of either
20
+ * package agrees on it.
21
+ */
22
+ export const AUTHENTICATION_PUBLIC = Symbol.for('@nestjs/authentication:public');
@@ -30,7 +30,9 @@ export const startupChecks = {
30
30
  useFactory: (resolved) => {
31
31
  const options = resolved ?? {};
32
32
  checkKeys(options);
33
- warnShortLifetimes(options, new Logger('AuthenticationModule'));
33
+ const logger = new Logger('AuthenticationModule');
34
+ warnShortLifetimes(options, logger);
35
+ warnIdleSwitches(options, logger);
34
36
  return options;
35
37
  },
36
38
  };
@@ -49,7 +51,8 @@ export const storageRequirements = {
49
51
  registry[LOCK_REGISTRY]();
50
52
  const enabled = {
51
53
  sessionCookie: registry.providers.some((provider) => provider instanceof SessionCookieProvider),
52
- accessToken: options.accessToken !== undefined,
54
+ // With `accessToken`, unless turned off: they are what `issue()` stores.
55
+ refreshToken: options.accessToken !== undefined && options.refreshToken !== false,
53
56
  mfa: options.mfa !== undefined,
54
57
  // With their handlers: the registry refuses one without the other.
55
58
  magicLink: options.magicLink !== undefined,
@@ -106,11 +109,17 @@ function checkKeys(options) {
106
109
  (hint ? `: ${hint}.` : `. The options are ${[...KNOWN_OPTIONS].join(', ')}.`));
107
110
  }
108
111
  }
112
+ /** A switch that turns off what isn't on: harmless, and likely a forgotten option. */
113
+ function warnIdleSwitches(options, logger) {
114
+ if (options.refreshToken === false && options.accessToken === undefined) {
115
+ logger.warn('`refreshToken: false` has no effect without `accessToken`: refresh tokens are only issued with access tokens.');
116
+ }
117
+ }
109
118
  /** The lifetimes: what `@nestjs/jwt` and jsonwebtoken users are used to giving in seconds. */
110
119
  const LIFETIMES = [
111
120
  ['accessToken.ttl', (options) => options.accessToken?.ttl],
112
- ['refreshToken.ttl', (options) => options.refreshToken?.ttl],
113
- ['refreshToken.absoluteTtl', (options) => options.refreshToken?.absoluteTtl],
121
+ ['refreshToken.ttl', (options) => (options.refreshToken || undefined)?.ttl],
122
+ ['refreshToken.absoluteTtl', (options) => (options.refreshToken || undefined)?.absoluteTtl],
114
123
  ['session.absoluteTtl', (options) => options.session?.absoluteTtl],
115
124
  ['session.idleTtl', (options) => options.session?.idleTtl],
116
125
  ['mfa.pendingTtl', (options) => options.mfa?.pendingTtl],
@@ -1,4 +1,4 @@
1
- import { AUTHENTICATION_METADATA } from '../authentication.constants.js';
1
+ import { AUTHENTICATION_METADATA, AUTHENTICATION_PUBLIC } from '../authentication.constants.js';
2
2
  /** Merges into the metadata already on the class or method, so decorators can be stacked. */
3
3
  export function route(value) {
4
4
  const defined = Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined));
@@ -6,6 +6,9 @@ export function route(value) {
6
6
  const holder = descriptor ? descriptor.value : target;
7
7
  const existing = Reflect.getMetadata(AUTHENTICATION_METADATA, holder);
8
8
  Reflect.defineMetadata(AUTHENTICATION_METADATA, { ...existing, ...defined }, holder);
9
+ if (value.public !== undefined) {
10
+ Reflect.defineMetadata(AUTHENTICATION_PUBLIC, value.public, holder);
11
+ }
9
12
  };
10
13
  return decorator;
11
14
  }
@@ -1,6 +1,8 @@
1
+ import { createParamDecorator } from '@nestjs/common';
2
+ import type { AuthenticatedUser } from '../interfaces/authentication-result.interface.js';
1
3
  /**
2
4
  * The authenticated user, or `null` on `@Public()` and anonymous optional
3
5
  * calls. `@CurrentUser('email')` picks one property; the key is checked
4
6
  * against the `AuthenticationTypes` augmentation.
5
7
  */
6
- export declare const CurrentUser: (...dataOrPipes: (string | import("@nestjs/common").ParameterDecoratorOptions | import("@nestjs/common").PipeTransform<any, any> | import("@nestjs/common").Type<import("@nestjs/common").PipeTransform<any, any>> | undefined)[]) => ParameterDecorator;
8
+ export declare const CurrentUser: ReturnType<typeof createParamDecorator<keyof AuthenticatedUser | undefined>>;
@@ -5,6 +5,8 @@ import { resultOf } from '../utils/auth-state.util.js';
5
5
  * calls. `@CurrentUser('email')` picks one property; the key is checked
6
6
  * against the `AuthenticationTypes` augmentation.
7
7
  */
8
+ // Annotated so the emitted .d.ts keeps `keyof AuthenticatedUser`: inferred, it
9
+ // is resolved at build time against the unaugmented user and becomes `string`.
8
10
  export const CurrentUser = createParamDecorator((key, context) => {
9
11
  const user = resultOf(context)?.user ?? null;
10
12
  return key && user ? user[key] : user;
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Skips authentication: no provider runs, and `@CurrentUser()` is `null`.
3
3
  * For health checks, the public catalog, and the routes that sign users in.
4
+ * To `@nestjs/authorization` it means `@Can.Anyone()`: a `@Public()` method
5
+ * lifts its class's `@Can()`, and a `@Can()` on the method itself still
6
+ * applies, to a guest.
4
7
  */
5
8
  export declare const Public: () => ClassDecorator & MethodDecorator;
@@ -2,5 +2,8 @@ import { route } from './authenticate.decorator.js';
2
2
  /**
3
3
  * Skips authentication: no provider runs, and `@CurrentUser()` is `null`.
4
4
  * For health checks, the public catalog, and the routes that sign users in.
5
+ * To `@nestjs/authorization` it means `@Can.Anyone()`: a `@Public()` method
6
+ * lifts its class's `@Can()`, and a `@Can()` on the method itself still
7
+ * applies, to a guest.
5
8
  */
6
9
  export const Public = () => route({ public: true });
@@ -10,7 +10,8 @@ import type { AuthenticationErrorOptions } from '../interfaces/authentication-er
10
10
  * `TokenService.refresh()`), the module's interceptor turns it into the
11
11
  * transport's error for its `status`: an HTTP 401 (409 for
12
12
  * `MfaAlreadyEnrolledError`), GraphQL `UNAUTHENTICATED`, a `WsException`
13
- * or an `RpcException`.
13
+ * or an `RpcException`. Its `code` is sent as the body's `errorCode`
14
+ * (and `error`), its `details` as `details`.
14
15
  * - Elsewhere (a queue worker), it is a plain `Error`.
15
16
  */
16
17
  export declare class AuthenticationError extends Error {
@@ -23,5 +24,6 @@ export declare class AuthenticationError extends Error {
23
24
  readonly status: 401 | 403 | 409;
24
25
  readonly challenge?: string;
25
26
  readonly code?: string;
26
- constructor(message?: string, { challenge, code, cause }?: AuthenticationErrorOptions);
27
+ readonly details?: Record<string, unknown>;
28
+ constructor(message?: string, { challenge, code, details, cause }?: AuthenticationErrorOptions);
27
29
  }
@@ -9,11 +9,12 @@
9
9
  * `TokenService.refresh()`), the module's interceptor turns it into the
10
10
  * transport's error for its `status`: an HTTP 401 (409 for
11
11
  * `MfaAlreadyEnrolledError`), GraphQL `UNAUTHENTICATED`, a `WsException`
12
- * or an `RpcException`.
12
+ * or an `RpcException`. Its `code` is sent as the body's `errorCode`
13
+ * (and `error`), its `details` as `details`.
13
14
  * - Elsewhere (a queue worker), it is a plain `Error`.
14
15
  */
15
16
  export class AuthenticationError extends Error {
16
- constructor(message = 'Unauthorized', { challenge, code, cause } = {}) {
17
+ constructor(message = 'Unauthorized', { challenge, code, details, cause } = {}) {
17
18
  super(message, cause === undefined ? undefined : { cause });
18
19
  /**
19
20
  * 401, or 409 for `MfaAlreadyEnrolledError`: the caller's fault, not an
@@ -29,5 +30,8 @@ export class AuthenticationError extends Error {
29
30
  if (code !== undefined) {
30
31
  this.code = code;
31
32
  }
33
+ if (details !== undefined) {
34
+ this.details = details;
35
+ }
32
36
  }
33
37
  }
@@ -1,14 +1,15 @@
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
  *
10
11
  * An `AuthenticationError`: a route that lets it escape answers 401 on any
11
- * transport, with `not_this_browser` as the body's `error`, the way
12
+ * transport, with `not_this_browser` as the body's `errorCode` (and `error`), the way
12
13
  * `TokenService.issue()` answers `mfa_required`.
13
14
  */
14
15
  export declare class MagicLinkError extends AuthenticationError {
@@ -1,19 +1,20 @@
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
  *
10
11
  * An `AuthenticationError`: a route that lets it escape answers 401 on any
11
- * transport, with `not_this_browser` as the body's `error`, the way
12
+ * transport, with `not_this_browser` as the body's `errorCode` (and `error`), the way
12
13
  * `TokenService.issue()` answers `mfa_required`.
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';
@@ -118,4 +118,19 @@ export interface AuthenticationMagicLinkRefusedEvent {
118
118
  /** The address the link was sent to, once the link was looked up (`expired`, `refused`). */
119
119
  email?: string;
120
120
  }
121
- export type AuthenticationEvent = AuthenticationSignInEvent | AuthenticationSignOutEvent | AuthenticationMfaVerifiedEvent | AuthenticationMfaFailedEvent | AuthenticationMfaEnabledEvent | AuthenticationMfaDisabledEvent | AuthenticationRecoveryCodesGeneratedEvent | AuthenticationRefreshTokenReusedEvent | AuthenticationPasswordResetRequestedEvent | AuthenticationPasswordResetEvent | AuthenticationEmailVerifiedEvent | AuthenticationMagicLinkRefusedEvent;
121
+ /**
122
+ * A request's session was valid, but recording its activity
123
+ * (`SessionStore.touchSession()`) failed: a lock timeout, a read-only
124
+ * replica, an outage. The request went on with the session as read, and
125
+ * its idle timeout did not move: unless a later request records activity,
126
+ * the session ends at its previous idle deadline. Each request in a spell
127
+ * of failures publishes one, though `SessionService` logs only the first.
128
+ */
129
+ export interface AuthenticationSessionTouchFailedEvent {
130
+ type: 'session-touch-failed';
131
+ userId: string;
132
+ sessionId: string;
133
+ /** What `touchSession()` rejected with. */
134
+ error: unknown;
135
+ }
136
+ export type AuthenticationEvent = AuthenticationSignInEvent | AuthenticationSignOutEvent | AuthenticationMfaVerifiedEvent | AuthenticationMfaFailedEvent | AuthenticationMfaEnabledEvent | AuthenticationMfaDisabledEvent | AuthenticationRecoveryCodesGeneratedEvent | AuthenticationRefreshTokenReusedEvent | AuthenticationPasswordResetRequestedEvent | AuthenticationPasswordResetEvent | AuthenticationEmailVerifiedEvent | AuthenticationMagicLinkRefusedEvent | AuthenticationSessionTouchFailedEvent;
@@ -3,7 +3,8 @@ import { type Observable } from 'rxjs';
3
3
  import type { AuthenticationEvent } from './authentication-events.interface.js';
4
4
  /**
5
5
  * The audit trail of sign-ins, sign-outs, second-factor changes, password
6
- * resets, email verifications and refused magic links. Each event is also
6
+ * resets, email verifications and refused magic links, and the session
7
+ * activity a store failed to record. Each event is also
7
8
  * published on its `node:diagnostics_channel` channel
8
9
  * (`nestjs:authentication:<type>`), for tooling that runs outside Nest.
9
10
  * Listeners cannot change the outcome: a throwing channel subscriber is
@@ -9,7 +9,8 @@ import { Subject } from 'rxjs';
9
9
  import { channelFor } from './authentication.channels.js';
10
10
  /**
11
11
  * The audit trail of sign-ins, sign-outs, second-factor changes, password
12
- * resets, email verifications and refused magic links. Each event is also
12
+ * resets, email verifications and refused magic links, and the session
13
+ * activity a store failed to record. Each event is also
13
14
  * published on its `node:diagnostics_channel` channel
14
15
  * (`nestjs:authentication:<type>`), for tooling that runs outside Nest.
15
16
  * Listeners cannot change the outcome: a throwing channel subscriber is
@@ -8,14 +8,16 @@ import { AUTHENTICATION_GUARD_BRAND } from '../authentication.constants.js';
8
8
  *
9
9
  * | Route | No credentials | Valid | Invalid |
10
10
  * | --- | --- | --- | --- |
11
- * | default | 401 | pass | 401 |
11
+ * | default | 401 `missing_credentials` | pass | 401 |
12
12
  * | `@Authenticate({ optional: true })` | pass, user `null` | pass | 401 |
13
13
  * | `@Public()` | providers not called | not called | not called |
14
14
  *
15
15
  * A result with `mfa: 'pending'` (second factor outstanding) does not count
16
16
  * as signed in: 401 `mfa_required` where a user is required, anonymous on
17
17
  * optional routes. `@Authenticate({ mfa: true })` needs `mfa: 'verified'`,
18
- * and `@Authenticate({ verifiedEmail: true })` a verified address (403).
18
+ * and `@Authenticate({ verifiedEmail: true })` a verified address (403
19
+ * `email_unverified`). The code is Nest's `errorCode`; a provider's
20
+ * `AuthenticationError` is answered with its own `code` and `details`.
19
21
  *
20
22
  * Providers run once per call: the raw result is cached on the request, the
21
23
  * GraphQL operation's context, or per ws message. With
@@ -15,25 +15,29 @@ import { getRawResult, setAuthState, setRawResult } from '../utils/auth-state.ut
15
15
  import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
16
16
  import { AUTHENTICATION_GUARD_BRAND, AUTHENTICATION_METADATA } from '../authentication.constants.js';
17
17
  import { AuthenticationError } from '../errors/authentication.error.js';
18
- import { refusal } from '../utils/transport-errors.util.js';
18
+ import { refusal, refusalOf } from '../utils/transport-errors.util.js';
19
19
  /** Which provider produced a result, for `@Authenticate({ providers })` on a cached result. */
20
20
  const producedBy = new WeakMap();
21
21
  const MFA_REQUIRED = { message: 'Second factor required', code: 'mfa_required' };
22
22
  const EMAIL_UNVERIFIED = { status: 403, message: 'Email address not verified', code: 'email_unverified' };
23
+ /** No provider recognised any credentials: `new UnauthorizedException()`'s body, with an `errorCode`. */
24
+ const MISSING_CREDENTIALS = { code: 'missing_credentials' };
23
25
  /**
24
26
  * Runs the registered providers in order and records the first result on
25
27
  * the call.
26
28
  *
27
29
  * | Route | No credentials | Valid | Invalid |
28
30
  * | --- | --- | --- | --- |
29
- * | default | 401 | pass | 401 |
31
+ * | default | 401 `missing_credentials` | pass | 401 |
30
32
  * | `@Authenticate({ optional: true })` | pass, user `null` | pass | 401 |
31
33
  * | `@Public()` | providers not called | not called | not called |
32
34
  *
33
35
  * A result with `mfa: 'pending'` (second factor outstanding) does not count
34
36
  * as signed in: 401 `mfa_required` where a user is required, anonymous on
35
37
  * optional routes. `@Authenticate({ mfa: true })` needs `mfa: 'verified'`,
36
- * and `@Authenticate({ verifiedEmail: true })` a verified address (403).
38
+ * and `@Authenticate({ verifiedEmail: true })` a verified address (403
39
+ * `email_unverified`). The code is Nest's `errorCode`; a provider's
40
+ * `AuthenticationError` is answered with its own `code` and `details`.
37
41
  *
38
42
  * Providers run once per call: the raw result is cached on the request, the
39
43
  * GraphQL operation's context, or per ws message. With
@@ -90,7 +94,7 @@ let AuthenticationGuard = class AuthenticationGuard {
90
94
  if (pending) {
91
95
  return this.fail(context, MFA_REQUIRED);
92
96
  }
93
- return this.fail(context, {}, this.challenges(context, providers));
97
+ return this.fail(context, MISSING_CREDENTIALS, this.challenges(context, providers));
94
98
  }
95
99
  if (route.mfa && result.mfa !== 'verified') {
96
100
  return this.fail(context, MFA_REQUIRED);
@@ -126,8 +130,7 @@ let AuthenticationGuard = class AuthenticationGuard {
126
130
  if (everyProvider || error.status === 401) {
127
131
  this.forgetConnection(context);
128
132
  }
129
- const { status, message, code } = error;
130
- return this.fail(context, { status, message, code, cause: error }, error.challenge);
133
+ return this.fail(context, refusalOf(error), error.challenge);
131
134
  }
132
135
  throw error; // a store outage is a 500, not a 401
133
136
  }
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { AuthenticationProvider } from './providers/index.js';
11
11
  export { ApiKeyProvider } from './providers/index.js';
12
12
  export { AuthenticationError, JwtError, MagicLinkError, MfaAlreadyEnrolledError, RefreshTokenError, } from './errors/index.js';
13
13
  export { AuthenticationEvents, type AuthenticationEmailVerifiedEvent, type AuthenticationEvent, type AuthenticationMagicLinkRefusedEvent, type AuthenticationMfaDisabledEvent, type AuthenticationMfaEnabledEvent, type AuthenticationMfaFailedEvent, type AuthenticationMfaVerifiedEvent, type AuthenticationPasswordResetEvent, type AuthenticationPasswordResetRequestedEvent, type AuthenticationRecoveryCodesGeneratedEvent, type AuthenticationRefreshTokenReusedEvent, type AuthenticationSignInEvent, type AuthenticationSignOutEvent, } from './events/index.js';
14
- export type { ApiKeyProviderOptions, ApiKeyRecord, ApiKeySession, AuthenticateOptions, AuthenticationErrorOptions, AuthenticationHandlerName, AuthenticationHandlers, AuthenticationModuleOptions, AuthenticationOptionsFactory, AuthenticationResult, AuthenticationStorageContract, AuthenticationStorageRegisterOptions, AuthenticationStorageSources, CreatedMagicLink, CredentialProvider, Duration, EmailTokenPurpose, EmailTokenRecord, EmailTokenStore, EmailVerificationLink, EmailVerificationOptions, GeneratedApiKey, IssuedSession, IssueTokensOptions, JwksClientOptions, JwsAlgorithm, JwtBearerProviderOptions, JwtClaims, JwtSignerOptions, JwtVerifierOptions, MagicLink, MagicLinkAccount, MagicLinkOptions, MagicLinkRecord, MagicLinkRequest, MagicLinkStore, MfaOptions, MfaState, MfaStore, OAuthTokens, OidcOptions, OidcProfile, OidcProviderConfig, OidcRedirect, OidcRequest, OidcResolveContext, OidcStartOptions, OidcStateStore, OidcTransaction, PasswordHasherOptions, PasswordResetAccount, PasswordResetLink, PasswordResetOptions, PasswordResetResult, RefreshTokenOptions, RefreshTokenRecord, RefreshTokenStore, RegisterHandlerOptions, RegisterProviderOptions, ResetPasswordOptions, SessionOptions, SessionRecord, SessionStore, SignInOptions, TokenPair, TotpRecord, } from './interfaces/index.js';
14
+ export type { AccessTokenResult, ApiKeyProviderOptions, ApiKeyRecord, ApiKeySession, AuthenticateOptions, AuthenticationErrorOptions, AuthenticationHandlerName, AuthenticationHandlers, AuthenticationModuleOptions, AuthenticationOptionsFactory, AuthenticationResult, AuthenticationStorageContract, AuthenticationStorageRegisterOptions, AuthenticationStorageSources, CreatedMagicLink, CredentialProvider, Duration, EmailTokenPurpose, EmailTokenRecord, EmailTokenStore, EmailVerificationLink, EmailVerificationOptions, GeneratedApiKey, IssuedSession, IssuedTokens, IssueTokensOptions, JwksClientOptions, JwsAlgorithm, JwtBearerProviderOptions, JwtClaims, JwtSignerOptions, JwtVerifierOptions, MagicLink, MagicLinkAccount, MagicLinkOptions, MagicLinkRecord, MagicLinkRequest, MagicLinkStore, MfaOptions, MfaState, MfaStore, OAuthTokens, OidcOptions, OidcProfile, OidcProviderConfig, OidcRedirect, OidcRequest, OidcResolveContext, OidcStartOptions, OidcStateStore, OidcTransaction, PasswordHasherOptions, PasswordResetAccount, PasswordResetLink, PasswordResetOptions, PasswordResetResult, RefreshTokenOptions, RefreshTokenRecord, RefreshTokenStore, RegisterHandlerOptions, RegisterProviderOptions, ResetPasswordOptions, SessionExtra, SessionOptions, SessionRecord, SessionStore, SignInOptions, TokenPair, TotpRecord, } from './interfaces/index.js';
15
15
  export { SessionCookieProvider } from './session/session-cookie.provider.js';
16
16
  export { InMemorySessionStore } from './session/in-memory-session.store.js';
17
17
  export { SessionService } from './session/session.service.js';
@@ -39,12 +39,17 @@ export { InMemoryOidcStateStore } from './oidc/in-memory-oidc-state.store.js';
39
39
  export { github, google, microsoft } from './oidc/oidc.presets.js';
40
40
  /**
41
41
  * Augment once per app to type `@CurrentUser('key')`,
42
- * `AuthenticationContext.user` and `AuthenticationContext.session`:
42
+ * `AuthenticationContext.user` and `AuthenticationContext.session`,
43
+ * (`sessionExtra`) what a `SessionStore` reads with each session for
44
+ * `SessionCookieProvider.validate()` (`SessionRecord.extra`), and
45
+ * (`refreshTokens: false`, with the `refreshToken: false` option) that
46
+ * `TokenService.issue()` returns no refresh token ({@link IssuedTokens}):
43
47
  *
44
48
  * @example
45
49
  * declare module '@nestjs/authentication' {
46
50
  * interface AuthenticationTypes {
47
51
  * user: User;
52
+ * sessionExtra: { user: User };
48
53
  * }
49
54
  * }
50
55
  */
@@ -12,7 +12,7 @@ import { HttpAdapterHost } from '@nestjs/core';
12
12
  import { catchError, from, mergeMap, Observable, throwError } from 'rxjs';
13
13
  import { getAuthState } from '../utils/auth-state.util.js';
14
14
  import { AuthenticationError } from '../errors/authentication.error.js';
15
- import { refusal } from '../utils/transport-errors.util.js';
15
+ import { refusal, refusalOf } from '../utils/transport-errors.util.js';
16
16
  import { AuthenticationScope, runCall } from '../context/authentication-scope.service.js';
17
17
  /**
18
18
  * @internal Opens the scope with what the guard recorded, around the
@@ -35,7 +35,7 @@ let AuthenticationScopeInterceptor = class AuthenticationScopeInterceptor {
35
35
  if (!(error instanceof AuthenticationError)) {
36
36
  return throwError(() => error);
37
37
  }
38
- const mapped = refusal(context, { status: error.status, message: error.message, code: error.code, cause: error }, { challenge: error.challenge, adapterHost: this.adapterHost });
38
+ const mapped = refusal(context, refusalOf(error), { challenge: error.challenge, adapterHost: this.adapterHost });
39
39
  return from(mapped).pipe(mergeMap((exception) => throwError(() => exception)));
40
40
  }));
41
41
  }
@@ -1,8 +1,18 @@
1
1
  export interface AuthenticationErrorOptions {
2
2
  /** `WWW-Authenticate` challenge sent with the 401 over HTTP, e.g. `ApiKey header="x-api-key"`. */
3
3
  challenge?: string;
4
- /** Machine-readable reason, sent as the body's `error` field (`mfa_required`). */
4
+ /**
5
+ * Machine-readable reason (`mfa_required`), sent as Nest's `errorCode`
6
+ * (the HTTP exception's `errorCode`, and the body's on every transport),
7
+ * and as the body's `error` field, which carried it first.
8
+ */
5
9
  code?: string;
6
- /** The error behind this one, for logs. */
10
+ /**
11
+ * Machine-readable data for the client, sent as the body's `details` field
12
+ * as is (`{ retryAfter: 30 }`): keep it JSON-serialisable, and leave out
13
+ * anything the caller should not see. Not sent unless set.
14
+ */
15
+ details?: Record<string, unknown>;
16
+ /** The error behind this one, for logs. Never sent. */
7
17
  cause?: unknown;
8
18
  }
@@ -26,7 +26,19 @@ export interface AuthenticationModuleOptions {
26
26
  * verifies unless it is given its own `key` or `jwks`.
27
27
  */
28
28
  accessToken?: JwtSignerOptions;
29
- refreshToken?: RefreshTokenOptions;
29
+ /**
30
+ * The refresh tokens `TokenService` issues with each access token, on by
31
+ * default with `accessToken` (which then needs a `RefreshTokenStore`).
32
+ * `false` turns them off: `issue()` returns an access token alone and
33
+ * starts no family, `refresh()` and `revoke()` throw, `revokeAll()` does
34
+ * nothing, and no `RefreshTokenStore` is needed. For a service that signs
35
+ * its own short-lived access tokens, or one that only verifies them
36
+ * (which can also leave `accessToken` out and give its
37
+ * `JwtBearerProvider` the key: `super({ key })`). Declare
38
+ * `refreshTokens: false` on `AuthenticationTypes` to type `issue()`
39
+ * accordingly ({@link IssuedTokens}).
40
+ */
41
+ refreshToken?: RefreshTokenOptions | false;
30
42
  /** Goes with a registered `OidcAccountResolver` (`'oidc'`): each fails at startup without the other. */
31
43
  oidc?: OidcOptions;
32
44
  /** Goes with a registered `PasswordResetHandler` (`'passwordReset'`): each fails at startup without the other. */