@nestjs/authentication 0.0.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.
- package/LICENSE +23 -0
- package/README.md +50 -0
- package/dist/account/email-verification.handler.d.ts +23 -0
- package/dist/account/email-verification.handler.js +17 -0
- package/dist/account/email-verification.service.d.ts +46 -0
- package/dist/account/email-verification.service.js +111 -0
- package/dist/account/email.util.d.ts +14 -0
- package/dist/account/email.util.js +28 -0
- package/dist/account/in-memory-email-token.store.d.ts +12 -0
- package/dist/account/in-memory-email-token.store.js +60 -0
- package/dist/account/password-reset.handler.d.ts +19 -0
- package/dist/account/password-reset.handler.js +9 -0
- package/dist/account/password-reset.service.d.ts +69 -0
- package/dist/account/password-reset.service.js +204 -0
- package/dist/authentication.constants.d.ts +13 -0
- package/dist/authentication.constants.js +13 -0
- package/dist/authentication.module-definition.d.ts +21 -0
- package/dist/authentication.module-definition.js +147 -0
- package/dist/authentication.module.d.ts +34 -0
- package/dist/authentication.module.js +139 -0
- package/dist/context/authentication-scope.service.d.ts +39 -0
- package/dist/context/authentication-scope.service.js +64 -0
- package/dist/context/authentication.context.d.ts +28 -0
- package/dist/context/authentication.context.js +58 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/decorators/authenticate.decorator.d.ts +10 -0
- package/dist/decorators/authenticate.decorator.js +18 -0
- package/dist/decorators/current-session.decorator.d.ts +2 -0
- package/dist/decorators/current-session.decorator.js +6 -0
- package/dist/decorators/current-user.decorator.d.ts +6 -0
- package/dist/decorators/current-user.decorator.js +11 -0
- package/dist/decorators/index.d.ts +4 -0
- package/dist/decorators/index.js +4 -0
- package/dist/decorators/public.decorator.d.ts +5 -0
- package/dist/decorators/public.decorator.js +6 -0
- package/dist/errors/authentication.error.d.ts +27 -0
- package/dist/errors/authentication.error.js +33 -0
- package/dist/errors/index.d.ts +7 -0
- package/dist/errors/index.js +7 -0
- package/dist/errors/jwt.error.d.ts +9 -0
- package/dist/errors/jwt.error.js +12 -0
- package/dist/errors/magic-link.error.d.ts +18 -0
- package/dist/errors/magic-link.error.js +21 -0
- package/dist/errors/mfa-already-enrolled.error.d.ts +14 -0
- package/dist/errors/mfa-already-enrolled.error.js +17 -0
- package/dist/errors/oidc.error.d.ts +15 -0
- package/dist/errors/oidc.error.js +13 -0
- package/dist/errors/refresh-token.error.d.ts +10 -0
- package/dist/errors/refresh-token.error.js +12 -0
- package/dist/errors/secret-decryption.error.d.ts +3 -0
- package/dist/errors/secret-decryption.error.js +6 -0
- package/dist/events/authentication-events.interface.d.ts +121 -0
- package/dist/events/authentication-events.interface.js +1 -0
- package/dist/events/authentication-events.service.d.ts +19 -0
- package/dist/events/authentication-events.service.js +39 -0
- package/dist/events/authentication.channels.d.ts +4 -0
- package/dist/events/authentication.channels.js +11 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.js +3 -0
- package/dist/guards/authentication.guard.d.ts +57 -0
- package/dist/guards/authentication.guard.js +171 -0
- package/dist/guards/index.d.ts +1 -0
- package/dist/guards/index.js +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +52 -0
- package/dist/interceptors/authentication-scope.interceptor.d.ts +20 -0
- package/dist/interceptors/authentication-scope.interceptor.js +48 -0
- package/dist/interceptors/index.d.ts +1 -0
- package/dist/interceptors/index.js +1 -0
- package/dist/interfaces/api-key.interface.d.ts +41 -0
- package/dist/interfaces/api-key.interface.js +1 -0
- package/dist/interfaces/authenticate-options.interface.d.ts +31 -0
- package/dist/interfaces/authenticate-options.interface.js +1 -0
- package/dist/interfaces/authentication-error-options.interface.d.ts +8 -0
- package/dist/interfaces/authentication-error-options.interface.js +1 -0
- package/dist/interfaces/authentication-module-options.interface.d.ts +58 -0
- package/dist/interfaces/authentication-module-options.interface.js +1 -0
- package/dist/interfaces/authentication-registry.interface.d.ts +41 -0
- package/dist/interfaces/authentication-registry.interface.js +1 -0
- package/dist/interfaces/authentication-result.interface.d.ts +25 -0
- package/dist/interfaces/authentication-result.interface.js +1 -0
- package/dist/interfaces/authentication-storage.interface.d.ts +29 -0
- package/dist/interfaces/authentication-storage.interface.js +1 -0
- package/dist/interfaces/duration.interface.d.ts +2 -0
- package/dist/interfaces/duration.interface.js +1 -0
- package/dist/interfaces/email-token-store.interface.d.ts +52 -0
- package/dist/interfaces/email-token-store.interface.js +1 -0
- package/dist/interfaces/email-verification.interface.d.ts +18 -0
- package/dist/interfaces/email-verification.interface.js +1 -0
- package/dist/interfaces/index.d.ts +27 -0
- package/dist/interfaces/index.js +27 -0
- package/dist/interfaces/jwt-options.interface.d.ts +68 -0
- package/dist/interfaces/jwt-options.interface.js +1 -0
- package/dist/interfaces/jwt.interface.d.ts +36 -0
- package/dist/interfaces/jwt.interface.js +1 -0
- package/dist/interfaces/magic-link-store.interface.d.ts +36 -0
- package/dist/interfaces/magic-link-store.interface.js +1 -0
- package/dist/interfaces/magic-link.interface.d.ts +55 -0
- package/dist/interfaces/magic-link.interface.js +1 -0
- package/dist/interfaces/mfa-options.interface.d.ts +44 -0
- package/dist/interfaces/mfa-options.interface.js +1 -0
- package/dist/interfaces/mfa-store.interface.d.ts +70 -0
- package/dist/interfaces/mfa-store.interface.js +1 -0
- package/dist/interfaces/oidc-provider.interface.d.ts +56 -0
- package/dist/interfaces/oidc-provider.interface.js +1 -0
- package/dist/interfaces/oidc-state-store.interface.d.ts +25 -0
- package/dist/interfaces/oidc-state-store.interface.js +1 -0
- package/dist/interfaces/oidc.interface.d.ts +84 -0
- package/dist/interfaces/oidc.interface.js +1 -0
- package/dist/interfaces/password-hasher-options.interface.d.ts +17 -0
- package/dist/interfaces/password-hasher-options.interface.js +1 -0
- package/dist/interfaces/password-reset.interface.d.ts +49 -0
- package/dist/interfaces/password-reset.interface.js +1 -0
- package/dist/interfaces/refresh-token-options.interface.d.ts +10 -0
- package/dist/interfaces/refresh-token-options.interface.js +1 -0
- package/dist/interfaces/refresh-token-store.interface.d.ts +61 -0
- package/dist/interfaces/refresh-token-store.interface.js +1 -0
- package/dist/interfaces/session-store.interface.d.ts +53 -0
- package/dist/interfaces/session-store.interface.js +1 -0
- package/dist/interfaces/session.interface.d.ts +50 -0
- package/dist/interfaces/session.interface.js +1 -0
- package/dist/interfaces/sign-in-options.interface.d.ts +9 -0
- package/dist/interfaces/sign-in-options.interface.js +1 -0
- package/dist/interfaces/token.interface.d.ts +30 -0
- package/dist/interfaces/token.interface.js +1 -0
- package/dist/jwt/amr.util.d.ts +9 -0
- package/dist/jwt/amr.util.js +11 -0
- package/dist/jwt/in-memory-refresh-token.store.d.ts +29 -0
- package/dist/jwt/in-memory-refresh-token.store.js +67 -0
- package/dist/jwt/jwks.client.d.ts +34 -0
- package/dist/jwt/jwks.client.js +154 -0
- package/dist/jwt/jws.util.d.ts +45 -0
- package/dist/jwt/jws.util.js +282 -0
- package/dist/jwt/jwt-bearer.provider.d.ts +48 -0
- package/dist/jwt/jwt-bearer.provider.js +126 -0
- package/dist/jwt/jwt-signer.service.d.ts +19 -0
- package/dist/jwt/jwt-signer.service.js +36 -0
- package/dist/jwt/jwt-verifier.service.d.ts +22 -0
- package/dist/jwt/jwt-verifier.service.js +109 -0
- package/dist/jwt/token.service.d.ts +81 -0
- package/dist/jwt/token.service.js +230 -0
- package/dist/magic-link/in-memory-magic-link.store.d.ts +12 -0
- package/dist/magic-link/in-memory-magic-link.store.js +45 -0
- package/dist/magic-link/magic-link.handler.d.ts +25 -0
- package/dist/magic-link/magic-link.handler.js +14 -0
- package/dist/magic-link/magic-link.service.d.ts +79 -0
- package/dist/magic-link/magic-link.service.js +215 -0
- package/dist/mfa/in-memory-mfa.store.d.ts +25 -0
- package/dist/mfa/in-memory-mfa.store.js +73 -0
- package/dist/mfa/mfa.service.d.ts +104 -0
- package/dist/mfa/mfa.service.js +346 -0
- package/dist/mfa/otp.util.d.ts +7 -0
- package/dist/mfa/otp.util.js +52 -0
- package/dist/mfa/secret-cipher.service.d.ts +28 -0
- package/dist/mfa/secret-cipher.service.js +106 -0
- package/dist/oidc/in-memory-oidc-state.store.d.ts +13 -0
- package/dist/oidc/in-memory-oidc-state.store.js +45 -0
- package/dist/oidc/oidc-account.resolver.d.ts +23 -0
- package/dist/oidc/oidc-account.resolver.js +16 -0
- package/dist/oidc/oidc.client.d.ts +44 -0
- package/dist/oidc/oidc.client.js +286 -0
- package/dist/oidc/oidc.presets.d.ts +24 -0
- package/dist/oidc/oidc.presets.js +53 -0
- package/dist/oidc/oidc.service.d.ts +88 -0
- package/dist/oidc/oidc.service.js +259 -0
- package/dist/providers/api-key.provider.d.ts +51 -0
- package/dist/providers/api-key.provider.js +93 -0
- package/dist/providers/authentication.provider.d.ts +40 -0
- package/dist/providers/authentication.provider.js +33 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.js +2 -0
- package/dist/services/authentication-registry.service.d.ts +75 -0
- package/dist/services/authentication-registry.service.js +235 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +3 -0
- package/dist/services/password-hasher.service.d.ts +47 -0
- package/dist/services/password-hasher.service.js +170 -0
- package/dist/services/ws-authenticator.service.d.ts +38 -0
- package/dist/services/ws-authenticator.service.js +92 -0
- package/dist/session/cookies.util.d.ts +34 -0
- package/dist/session/cookies.util.js +82 -0
- package/dist/session/in-memory-session.store.d.ts +33 -0
- package/dist/session/in-memory-session.store.js +60 -0
- package/dist/session/session-cookie.provider.d.ts +36 -0
- package/dist/session/session-cookie.provider.js +58 -0
- package/dist/session/session.service.d.ts +97 -0
- package/dist/session/session.service.js +208 -0
- package/dist/session/sign-in.service.d.ts +87 -0
- package/dist/session/sign-in.service.js +206 -0
- package/dist/storage/authentication.storage.d.ts +130 -0
- package/dist/storage/authentication.storage.js +350 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/testing/index.d.ts +39 -0
- package/dist/testing/index.js +531 -0
- package/dist/utils/api-key.util.d.ts +10 -0
- package/dist/utils/api-key.util.js +10 -0
- package/dist/utils/auth-state.util.d.ts +54 -0
- package/dist/utils/auth-state.util.js +209 -0
- package/dist/utils/bearer.util.d.ts +9 -0
- package/dist/utils/bearer.util.js +21 -0
- package/dist/utils/cross-origin.util.d.ts +29 -0
- package/dist/utils/cross-origin.util.js +90 -0
- package/dist/utils/crypto.util.d.ts +19 -0
- package/dist/utils/crypto.util.js +38 -0
- package/dist/utils/duration.util.d.ts +9 -0
- package/dist/utils/duration.util.js +44 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/options.util.d.ts +20 -0
- package/dist/utils/options.util.js +44 -0
- package/dist/utils/transport-errors.util.d.ts +33 -0
- package/dist/utils/transport-errors.util.js +70 -0
- package/package.json +108 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
|
+
};
|
|
13
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
14
|
+
import { HttpAdapterHost } from '@nestjs/core';
|
|
15
|
+
import { firstHeader } from '../utils/auth-state.util.js';
|
|
16
|
+
import { AuthenticationScope } from '../context/authentication-scope.service.js';
|
|
17
|
+
import { AuthenticationRegistry } from '../services/authentication-registry.service.js';
|
|
18
|
+
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
19
|
+
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
20
|
+
import { TOKEN_PATTERN, randomToken, safeEqual, safeRedirectPath, sha256 } from '../utils/crypto.util.js';
|
|
21
|
+
import { durationOr, hasExpired } from '../utils/duration.util.js';
|
|
22
|
+
import { AuthenticationEvents } from '../events/authentication-events.service.js';
|
|
23
|
+
import { requireUrlOption } from '../utils/options.util.js';
|
|
24
|
+
import { defaultCookieName, readCookie, serializeCookie } from '../session/cookies.util.js';
|
|
25
|
+
import { SignInService } from '../session/sign-in.service.js';
|
|
26
|
+
import { MagicLinkError } from '../errors/magic-link.error.js';
|
|
27
|
+
import { isMailableEmail, normalizeEmail } from '../account/email.util.js';
|
|
28
|
+
/**
|
|
29
|
+
* What the store keys a link by: the SHA-256 of its token, and of the secret
|
|
30
|
+
* that binds it to a browser when it is bound (`bindToBrowser`), so the link
|
|
31
|
+
* alone finds nothing.
|
|
32
|
+
*/
|
|
33
|
+
function linkId(token, binding) {
|
|
34
|
+
return sha256(binding === undefined ? token : `${token}.${binding}`);
|
|
35
|
+
}
|
|
36
|
+
/** Which link a browser's cookie is for: a public hint, computable from the link. */
|
|
37
|
+
function hintOf(token) {
|
|
38
|
+
return sha256(`nestjs-authentication magic-link hint ${token}`).slice(0, 12);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Passwordless sign-in links: 256-bit single-use tokens, stored hashed,
|
|
42
|
+
* short-lived, and bound to the browser that requested them.
|
|
43
|
+
*
|
|
44
|
+
* Point the link at a page that POSTs the token back: mail scanners and
|
|
45
|
+
* link previews follow GET links, and would burn (or use) the token.
|
|
46
|
+
*
|
|
47
|
+
* Like `OidcService`, the service binds each link to the browser that
|
|
48
|
+
* requested it: `create()` refuses a request from another origin (another
|
|
49
|
+
* site's page must not start a link here), and sets an `HttpOnly`,
|
|
50
|
+
* `SameSite=Lax` cookie, `__Host-magic_link_tx`, holding a secret that no
|
|
51
|
+
* link carries; the store keys the link by the token and that secret
|
|
52
|
+
* together, so `consume()` finds it only with the cookie. The `__Host-`
|
|
53
|
+
* prefix keeps sibling subdomains from planting one. So the link's page
|
|
54
|
+
* must be on the same site as the API, as the session cookie requires
|
|
55
|
+
* anyway, and a link forwarded to another browser or device, or read from
|
|
56
|
+
* a log, is refused there while it stays usable where it was requested.
|
|
57
|
+
* See `magicLink.bindToBrowser`.
|
|
58
|
+
*/
|
|
59
|
+
let MagicLinkService = class MagicLinkService {
|
|
60
|
+
constructor(storage, registry, signIn, options, scope = new AuthenticationScope(), adapterHost, events = new AuthenticationEvents()) {
|
|
61
|
+
this.storage = storage;
|
|
62
|
+
this.registry = registry;
|
|
63
|
+
this.signIn = signIn;
|
|
64
|
+
this.scope = scope;
|
|
65
|
+
this.adapterHost = adapterHost;
|
|
66
|
+
this.events = events;
|
|
67
|
+
this.options = options?.magicLink;
|
|
68
|
+
if (this.options) {
|
|
69
|
+
requireUrlOption(this.options.url, 'magicLink.url', 'the page that receives the link');
|
|
70
|
+
}
|
|
71
|
+
this.ttl = durationOr(this.options?.ttl, '15m');
|
|
72
|
+
this.bindToBrowser = this.options?.bindToBrowser !== false;
|
|
73
|
+
this.txCookieName = defaultCookieName('magic_link_tx', this.options?.cookie);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Creates a link and hands it to `MagicLinkHandler.send()`. The token is
|
|
77
|
+
* not returned. Called from an HTTP handler, it sets the transaction
|
|
78
|
+
* cookie on the response (see `bindToBrowser`); the returned `cookie` is
|
|
79
|
+
* that `Set-Cookie` value, for callers outside HTTP. A request from another
|
|
80
|
+
* origin than the app's own and `session.trustedOrigins` gets a
|
|
81
|
+
* `ForbiddenException`, as `SignInService.signIn()` does. An `email` no
|
|
82
|
+
* mail should go to (missing, blank, without `@`, over 254 characters, or
|
|
83
|
+
* with whitespace or control characters) creates and sends nothing, and
|
|
84
|
+
* gets the same answer, like `PasswordResetService.request()`.
|
|
85
|
+
*/
|
|
86
|
+
async create(email, { redirectTo } = {}) {
|
|
87
|
+
const { options, handler } = this.feature();
|
|
88
|
+
// The cookie binds the link to this browser: another site's page must not get one set here for
|
|
89
|
+
// an address its author reads (login CSRF), whatever the request asks for.
|
|
90
|
+
this.signIn.refuseCrossOrigin(this.scope.exchange()?.request);
|
|
91
|
+
const now = this.now();
|
|
92
|
+
const expiresAt = new Date(now + this.ttl);
|
|
93
|
+
const normalized = typeof email === 'string' ? normalizeEmail(email) : '';
|
|
94
|
+
if (!isMailableEmail(normalized)) {
|
|
95
|
+
return { expiresAt };
|
|
96
|
+
}
|
|
97
|
+
const token = randomToken();
|
|
98
|
+
const binding = this.bindToBrowser ? randomToken() : undefined;
|
|
99
|
+
const safe = safeRedirectPath(redirectTo);
|
|
100
|
+
await this.storage.magicLinks.saveMagicLink({
|
|
101
|
+
id: linkId(token, binding),
|
|
102
|
+
email: normalized,
|
|
103
|
+
createdAt: new Date(now),
|
|
104
|
+
expiresAt,
|
|
105
|
+
...(safe && { redirectTo: safe }),
|
|
106
|
+
});
|
|
107
|
+
let cookie;
|
|
108
|
+
if (binding) {
|
|
109
|
+
cookie = this.txCookie(`${hintOf(token)}.${binding}`, this.ttl / 1000);
|
|
110
|
+
this.setCookie(cookie);
|
|
111
|
+
}
|
|
112
|
+
const url = new URL(options.url);
|
|
113
|
+
url.searchParams.set('token', token);
|
|
114
|
+
await handler.send({ email: normalized, url: url.toString(), expiresAt });
|
|
115
|
+
return { expiresAt, ...(cookie && { cookie }) };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Burns the token, asks `MagicLinkHandler.resolveUser()` for the user, and
|
|
119
|
+
* signs them in on this browser through `SignInService`, which sets the
|
|
120
|
+
* cookie. `null` for unknown, used or expired tokens and for addresses
|
|
121
|
+
* the handler refuses, one answer for all of them. A link this browser
|
|
122
|
+
* did not request (see `bindToBrowser`) throws {@link MagicLinkError}
|
|
123
|
+
* instead, before anything is looked up or burned: the module answers it
|
|
124
|
+
* with a 401 that tells the customer where to open the link. Each
|
|
125
|
+
* refusal is a `magic-link-refused` event with its reason. Outside an
|
|
126
|
+
* HTTP handler, pass the `request` that carries the cookie.
|
|
127
|
+
*/
|
|
128
|
+
async consume(token, { request } = {}) {
|
|
129
|
+
const { handler } = this.feature();
|
|
130
|
+
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) {
|
|
131
|
+
return this.refuse('unknown');
|
|
132
|
+
}
|
|
133
|
+
let binding;
|
|
134
|
+
if (this.bindToBrowser) {
|
|
135
|
+
const req = request ?? this.scope.exchange()?.request;
|
|
136
|
+
const [hint, secret] = (readCookie(firstHeader(req?.headers, 'cookie'), this.txCookieName) ?? '').split('.');
|
|
137
|
+
// The hint names the link this browser requested; anyone holding the link can compute it, so it
|
|
138
|
+
// only tells a link opened elsewhere from a bad one. The secret, which no link carries, is what
|
|
139
|
+
// the link is stored under: a leaked or forwarded link finds nothing without it. On a refusal
|
|
140
|
+
// here the cookie stays (it may be for a link this browser did request), and nothing is looked
|
|
141
|
+
// up: a link was presented, not proved.
|
|
142
|
+
if (!hint || !secret || !TOKEN_PATTERN.test(secret) || !safeEqual(hint, hintOf(token))) {
|
|
143
|
+
this.events.emit({ type: 'magic-link-refused', reason: 'not-this-browser' });
|
|
144
|
+
throw new MagicLinkError();
|
|
145
|
+
}
|
|
146
|
+
binding = secret;
|
|
147
|
+
}
|
|
148
|
+
const record = await this.storage.magicLinks.consumeMagicLink(linkId(token, binding));
|
|
149
|
+
// This browser's link is settled either way: the cookie goes.
|
|
150
|
+
this.setCookie(this.txCookie('', 0));
|
|
151
|
+
if (!record) {
|
|
152
|
+
return this.refuse('unknown');
|
|
153
|
+
}
|
|
154
|
+
if (hasExpired(record.expiresAt, this.now())) {
|
|
155
|
+
return this.refuse('expired', record.email);
|
|
156
|
+
}
|
|
157
|
+
const user = await handler.resolveUser(record.email);
|
|
158
|
+
if (!user) {
|
|
159
|
+
return this.refuse('refused', record.email);
|
|
160
|
+
}
|
|
161
|
+
// The link proved the address it was sent to, and signs in only the account whose address that
|
|
162
|
+
// is: a lookup that folds accents or ignores dots would otherwise hand `victim@exämple.com`'s
|
|
163
|
+
// mailbox the account of `victim@example.com`.
|
|
164
|
+
if (typeof user.email !== 'string') {
|
|
165
|
+
throw new TypeError('MagicLinkHandler.resolveUser() must return the account’s stored `email` with its `id`: the link signs in ' +
|
|
166
|
+
'only the account whose address it was sent to.');
|
|
167
|
+
}
|
|
168
|
+
if (normalizeEmail(user.email) !== record.email) {
|
|
169
|
+
return this.refuse('refused', record.email);
|
|
170
|
+
}
|
|
171
|
+
const issued = await this.signIn.signIn(user.id, { method: 'magic-link' });
|
|
172
|
+
return { ...issued, redirectTo: record.redirectTo };
|
|
173
|
+
}
|
|
174
|
+
/** The options and the handler; the module refuses to start with one and not the other. */
|
|
175
|
+
feature() {
|
|
176
|
+
const handler = this.registry.handler('magicLink');
|
|
177
|
+
if (!this.options || !handler) {
|
|
178
|
+
throw new Error('MagicLinkService: magic links are not enabled. Configure `magicLink` in the AuthenticationModule options, and ' +
|
|
179
|
+
"register a MagicLinkHandler: `registry.registerHandler('magicLink', this)`.");
|
|
180
|
+
}
|
|
181
|
+
return { options: this.options, handler };
|
|
182
|
+
}
|
|
183
|
+
/** The generic answer: which refusal it was is told only by the event. */
|
|
184
|
+
refuse(reason, email) {
|
|
185
|
+
this.events.emit({ type: 'magic-link-refused', reason, ...(email && { email }) });
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
txCookie(value, maxAge) {
|
|
189
|
+
return serializeCookie(this.txCookieName, value, { ...this.options?.cookie, sameSite: 'lax', maxAge });
|
|
190
|
+
}
|
|
191
|
+
setCookie(cookie) {
|
|
192
|
+
if (!this.bindToBrowser) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const response = this.scope.exchange()?.response;
|
|
196
|
+
if (response) {
|
|
197
|
+
this.adapterHost?.httpAdapter?.appendHeader(response, 'Set-Cookie', cookie);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
now() {
|
|
201
|
+
return this.options?.now?.() ?? Date.now();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
MagicLinkService = __decorate([
|
|
205
|
+
Injectable(),
|
|
206
|
+
__param(3, Optional()),
|
|
207
|
+
__param(3, Inject(AUTHENTICATION_MODULE_OPTIONS)),
|
|
208
|
+
__param(5, Optional()),
|
|
209
|
+
__metadata("design:paramtypes", [AuthenticationStorage,
|
|
210
|
+
AuthenticationRegistry,
|
|
211
|
+
SignInService, Object, AuthenticationScope,
|
|
212
|
+
HttpAdapterHost,
|
|
213
|
+
AuthenticationEvents])
|
|
214
|
+
], MagicLinkService);
|
|
215
|
+
export { MagicLinkService };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TotpRecord, MfaStore } from '../interfaces/mfa-store.interface.js';
|
|
2
|
+
/**
|
|
3
|
+
* Process-local store: the default when no source is registered, and a
|
|
4
|
+
* test double. Atomic because it is synchronous.
|
|
5
|
+
*/
|
|
6
|
+
export declare class InMemoryMfaStore implements MfaStore {
|
|
7
|
+
private readonly totp;
|
|
8
|
+
private readonly codes;
|
|
9
|
+
private readonly failuresAt;
|
|
10
|
+
getTotp(userId: string): Promise<{
|
|
11
|
+
secret: string;
|
|
12
|
+
confirmed: boolean;
|
|
13
|
+
pendingSecret?: string;
|
|
14
|
+
lastUsedStep?: number;
|
|
15
|
+
} | undefined>;
|
|
16
|
+
saveTotp(userId: string, record: TotpRecord | null): Promise<void>;
|
|
17
|
+
claimTotpStep(userId: string, step: number): Promise<boolean>;
|
|
18
|
+
saveRecoveryCodes(userId: string, hashes: string[]): Promise<void>;
|
|
19
|
+
consumeRecoveryCode(userId: string, hash: string): Promise<boolean>;
|
|
20
|
+
countRecoveryCodes(userId: string): Promise<number>;
|
|
21
|
+
recordMfaFailure(userId: string, windowMs: number, now: number): Promise<number>;
|
|
22
|
+
countMfaFailures(userId: string, windowMs: number, now: number): Promise<number>;
|
|
23
|
+
clearMfaFailures(userId: string): Promise<void>;
|
|
24
|
+
private recent;
|
|
25
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { Injectable } from '@nestjs/common';
|
|
8
|
+
/**
|
|
9
|
+
* Process-local store: the default when no source is registered, and a
|
|
10
|
+
* test double. Atomic because it is synchronous.
|
|
11
|
+
*/
|
|
12
|
+
let InMemoryMfaStore = class InMemoryMfaStore {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.totp = new Map();
|
|
15
|
+
this.codes = new Map();
|
|
16
|
+
this.failuresAt = new Map();
|
|
17
|
+
}
|
|
18
|
+
async getTotp(userId) {
|
|
19
|
+
const record = this.totp.get(userId);
|
|
20
|
+
return record && { ...record };
|
|
21
|
+
}
|
|
22
|
+
async saveTotp(userId, record) {
|
|
23
|
+
if (!record) {
|
|
24
|
+
this.totp.delete(userId);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const claimed = this.totp.get(userId)?.lastUsedStep;
|
|
28
|
+
const lastUsedStep = Math.max(claimed ?? -Infinity, record.lastUsedStep ?? -Infinity);
|
|
29
|
+
const { lastUsedStep: _, ...rest } = record;
|
|
30
|
+
this.totp.set(userId, { ...rest, ...(Number.isFinite(lastUsedStep) && { lastUsedStep }) });
|
|
31
|
+
}
|
|
32
|
+
async claimTotpStep(userId, step) {
|
|
33
|
+
const record = this.totp.get(userId);
|
|
34
|
+
if (!record || (record.lastUsedStep !== undefined && step <= record.lastUsedStep)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
record.lastUsedStep = step;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
async saveRecoveryCodes(userId, hashes) {
|
|
41
|
+
if (hashes.length === 0) {
|
|
42
|
+
this.codes.delete(userId);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
this.codes.set(userId, new Set(hashes));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async consumeRecoveryCode(userId, hash) {
|
|
49
|
+
return this.codes.get(userId)?.delete(hash) ?? false;
|
|
50
|
+
}
|
|
51
|
+
async countRecoveryCodes(userId) {
|
|
52
|
+
return this.codes.get(userId)?.size ?? 0;
|
|
53
|
+
}
|
|
54
|
+
async recordMfaFailure(userId, windowMs, now) {
|
|
55
|
+
const recent = this.recent(userId, windowMs, now);
|
|
56
|
+
recent.push(now);
|
|
57
|
+
this.failuresAt.set(userId, recent);
|
|
58
|
+
return recent.length;
|
|
59
|
+
}
|
|
60
|
+
async countMfaFailures(userId, windowMs, now) {
|
|
61
|
+
return this.recent(userId, windowMs, now).length;
|
|
62
|
+
}
|
|
63
|
+
async clearMfaFailures(userId) {
|
|
64
|
+
this.failuresAt.delete(userId);
|
|
65
|
+
}
|
|
66
|
+
recent(userId, windowMs, now) {
|
|
67
|
+
return (this.failuresAt.get(userId) ?? []).filter((t) => t > now - windowMs);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
InMemoryMfaStore = __decorate([
|
|
71
|
+
Injectable()
|
|
72
|
+
], InMemoryMfaStore);
|
|
73
|
+
export { InMemoryMfaStore };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
2
|
+
import { AuthenticationEvents } from '../events/authentication-events.service.js';
|
|
3
|
+
import type { MfaOptions } from '../interfaces/mfa-options.interface.js';
|
|
4
|
+
/**
|
|
5
|
+
* TOTP second factor (RFC 6238 over RFC 4226 HOTP, SHA-1, 6 digits, 30 s:
|
|
6
|
+
* the parameters every authenticator app supports) plus recovery codes.
|
|
7
|
+
*
|
|
8
|
+
* - `enroll()` stores an unconfirmed 160-bit secret and returns the
|
|
9
|
+
* `otpauth://` URI for the QR code; `confirm()` activates it once a
|
|
10
|
+
* code verifies. It refuses to touch a confirmed authenticator
|
|
11
|
+
* ({@link MfaAlreadyEnrolledError}): replacing one takes
|
|
12
|
+
* `{ replace: true }`, which stages the new secret while the current one
|
|
13
|
+
* keeps working until `confirm()`. Call that only from a route that
|
|
14
|
+
* requires a verified second factor (`@Authenticate({ mfa: true })`), or
|
|
15
|
+
* remove the authenticator with `disable()` first.
|
|
16
|
+
* - Secrets are encrypted before they reach the store (AES-256-GCM, bound to
|
|
17
|
+
* the user id) and decrypted only to compute codes. A secret encrypted with
|
|
18
|
+
* an older key is re-encrypted with the current one after its next
|
|
19
|
+
* successful verification, or by `reencrypt(userId)`.
|
|
20
|
+
* - Each time step is accepted once per user (replay protection), across
|
|
21
|
+
* the ±`window` range.
|
|
22
|
+
* - After `maxAttempts` failures in `lockoutWindow` every check fails;
|
|
23
|
+
* 6 digits are otherwise brute-forceable. Each attempt is counted before
|
|
24
|
+
* it is checked, so a burst of parallel guesses cannot outrun the limit.
|
|
25
|
+
* This is a per-account control on the verifier, not request rate
|
|
26
|
+
* limiting.
|
|
27
|
+
* - Recovery codes are shown once, and stored under a slow hash salted
|
|
28
|
+
* with the user id (~49 bits of entropy each, plus the attempt limit).
|
|
29
|
+
*/
|
|
30
|
+
export declare class MfaService {
|
|
31
|
+
private readonly storage;
|
|
32
|
+
private readonly events;
|
|
33
|
+
private static readonly logger;
|
|
34
|
+
private readonly options?;
|
|
35
|
+
private readonly cipher?;
|
|
36
|
+
private readonly lockoutWindow;
|
|
37
|
+
constructor(storage: AuthenticationStorage, options?: {
|
|
38
|
+
mfa?: MfaOptions;
|
|
39
|
+
}, events?: AuthenticationEvents);
|
|
40
|
+
isEnrolled(userId: string): Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Starts an enrollment. Throws {@link MfaAlreadyEnrolledError} when the user
|
|
43
|
+
* has a confirmed authenticator, unless `replace` is set: then the new
|
|
44
|
+
* secret is staged and the current authenticator stays active until
|
|
45
|
+
* `confirm()` succeeds with a code from the new one.
|
|
46
|
+
*/
|
|
47
|
+
enroll(userId: string, accountName: string, { replace }?: {
|
|
48
|
+
replace?: boolean;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
secret: string;
|
|
51
|
+
uri: string;
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* Activates a pending enrollment (or a staged replacement, which then
|
|
55
|
+
* takes the place of the current authenticator).
|
|
56
|
+
*/
|
|
57
|
+
confirm(userId: string, code: string): Promise<boolean>;
|
|
58
|
+
verifyTotp(userId: string, code: string): Promise<boolean>;
|
|
59
|
+
/**
|
|
60
|
+
* Re-encrypts the stored secret with the current key if it was sealed
|
|
61
|
+
* with an older one (or is legacy plaintext under `migratePlaintext`).
|
|
62
|
+
* For key-rotation jobs; returns whether anything was rewritten.
|
|
63
|
+
*/
|
|
64
|
+
reencrypt(userId: string): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Returns fresh plaintext codes (show once); replaces any previous batch.
|
|
67
|
+
* Codes are a second factor: for a user with a confirmed authenticator,
|
|
68
|
+
* call it right after `confirm()` succeeded, or from a route that requires
|
|
69
|
+
* a verified second factor (`@Authenticate({ mfa: true })`). Otherwise an
|
|
70
|
+
* API key, or a token issued before the user enrolled, could mint codes
|
|
71
|
+
* that finish a sign-in with the password alone.
|
|
72
|
+
*/
|
|
73
|
+
generateRecoveryCodes(userId: string): Promise<string[]>;
|
|
74
|
+
verifyRecoveryCode(userId: string, code: string): Promise<boolean>;
|
|
75
|
+
remainingRecoveryCodes(userId: string): Promise<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Removes the authenticator and the recovery codes. Call it only from a
|
|
78
|
+
* route that requires a verified second factor
|
|
79
|
+
* (`@Authenticate({ mfa: true })`): an API key, or a token issued before
|
|
80
|
+
* the user enrolled, is no second factor.
|
|
81
|
+
*/
|
|
82
|
+
disable(userId: string): Promise<void>;
|
|
83
|
+
keyUri(secret: string, accountName: string): string;
|
|
84
|
+
/** `opened` is undefined when the secret could not be decrypted: fail closed, not counted against the user. */
|
|
85
|
+
private check;
|
|
86
|
+
/**
|
|
87
|
+
* Counts an attempt before it is checked, and returns its number within
|
|
88
|
+
* the window, or `undefined` when it must be refused unchecked. Counting
|
|
89
|
+
* first is what stops a burst of parallel guesses: each one gets its own
|
|
90
|
+
* number from the store, and only the first `maxAttempts` are checked.
|
|
91
|
+
* An attempt made while already locked out is not counted, so the lockout
|
|
92
|
+
* ends `lockoutWindow` after it began.
|
|
93
|
+
*/
|
|
94
|
+
private begin;
|
|
95
|
+
/** The attempt was already counted by {@link begin}; this reports it. */
|
|
96
|
+
private failed;
|
|
97
|
+
private seal;
|
|
98
|
+
private open;
|
|
99
|
+
private matchingStep;
|
|
100
|
+
/** Read at each call, never in the constructor: the registry locks once the module starts. */
|
|
101
|
+
private get store();
|
|
102
|
+
private get maxAttempts();
|
|
103
|
+
private now;
|
|
104
|
+
}
|