@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,346 @@
|
|
|
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
|
+
var MfaService_1;
|
|
14
|
+
import { randomBytes, randomInt, scrypt } from 'node:crypto';
|
|
15
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
16
|
+
import { AUTHENTICATION_MODULE_OPTIONS } from '../authentication.constants.js';
|
|
17
|
+
import { safeEqual } from '../utils/crypto.util.js';
|
|
18
|
+
import { durationOr } from '../utils/duration.util.js';
|
|
19
|
+
import { requireIntegerOption } from '../utils/options.util.js';
|
|
20
|
+
import { AuthenticationStorage } from '../storage/authentication.storage.js';
|
|
21
|
+
import { AuthenticationEvents } from '../events/authentication-events.service.js';
|
|
22
|
+
import { SecretDecryptionError } from '../errors/secret-decryption.error.js';
|
|
23
|
+
import { MfaAlreadyEnrolledError } from '../errors/mfa-already-enrolled.error.js';
|
|
24
|
+
import { base32Decode, base32Encode, hotp, totpStep } from './otp.util.js';
|
|
25
|
+
import { SecretCipher } from './secret-cipher.service.js';
|
|
26
|
+
const PERIOD = 30;
|
|
27
|
+
const DIGITS = 6;
|
|
28
|
+
// Crockford-style alphabet: no 0/O or 1/I/L confusion.
|
|
29
|
+
const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
30
|
+
/**
|
|
31
|
+
* A recovery code as stored: scrypt (N=2^14, r=8, p=1, 16 MiB), salted with
|
|
32
|
+
* the user id. The salt is deterministic so the store can still look codes
|
|
33
|
+
* up by hash (`consumeRecoveryCode` is an atomic delete), and per user, so a
|
|
34
|
+
* dump has to be attacked one user at a time. A code carries ~49 bits:
|
|
35
|
+
* cheap to exhaust at SHA-256 speed, decades per user at this cost.
|
|
36
|
+
*/
|
|
37
|
+
function hashRecoveryCode(userId, normalized) {
|
|
38
|
+
const salt = `nestjs-authentication recovery-code v1 ${userId}`;
|
|
39
|
+
return new Promise((resolve, reject) => scrypt(normalized, salt, 32, { N: 2 ** 14, r: 8, p: 1 }, (error, key) => error ? reject(error) : resolve(`rc1.${key.toString('base64url')}`)));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* TOTP second factor (RFC 6238 over RFC 4226 HOTP, SHA-1, 6 digits, 30 s:
|
|
43
|
+
* the parameters every authenticator app supports) plus recovery codes.
|
|
44
|
+
*
|
|
45
|
+
* - `enroll()` stores an unconfirmed 160-bit secret and returns the
|
|
46
|
+
* `otpauth://` URI for the QR code; `confirm()` activates it once a
|
|
47
|
+
* code verifies. It refuses to touch a confirmed authenticator
|
|
48
|
+
* ({@link MfaAlreadyEnrolledError}): replacing one takes
|
|
49
|
+
* `{ replace: true }`, which stages the new secret while the current one
|
|
50
|
+
* keeps working until `confirm()`. Call that only from a route that
|
|
51
|
+
* requires a verified second factor (`@Authenticate({ mfa: true })`), or
|
|
52
|
+
* remove the authenticator with `disable()` first.
|
|
53
|
+
* - Secrets are encrypted before they reach the store (AES-256-GCM, bound to
|
|
54
|
+
* the user id) and decrypted only to compute codes. A secret encrypted with
|
|
55
|
+
* an older key is re-encrypted with the current one after its next
|
|
56
|
+
* successful verification, or by `reencrypt(userId)`.
|
|
57
|
+
* - Each time step is accepted once per user (replay protection), across
|
|
58
|
+
* the ±`window` range.
|
|
59
|
+
* - After `maxAttempts` failures in `lockoutWindow` every check fails;
|
|
60
|
+
* 6 digits are otherwise brute-forceable. Each attempt is counted before
|
|
61
|
+
* it is checked, so a burst of parallel guesses cannot outrun the limit.
|
|
62
|
+
* This is a per-account control on the verifier, not request rate
|
|
63
|
+
* limiting.
|
|
64
|
+
* - Recovery codes are shown once, and stored under a slow hash salted
|
|
65
|
+
* with the user id (~49 bits of entropy each, plus the attempt limit).
|
|
66
|
+
*/
|
|
67
|
+
let MfaService = MfaService_1 = class MfaService {
|
|
68
|
+
constructor(storage, options, events = new AuthenticationEvents()) {
|
|
69
|
+
this.storage = storage;
|
|
70
|
+
this.events = events;
|
|
71
|
+
this.options = options?.mfa;
|
|
72
|
+
this.lockoutWindow = durationOr(this.options?.lockoutWindow, '15m');
|
|
73
|
+
if (!this.options) {
|
|
74
|
+
return; // MFA unused: isEnrolled() works, enrollment throws
|
|
75
|
+
}
|
|
76
|
+
if (this.options.encryption === undefined) {
|
|
77
|
+
throw new Error('MFA: `mfa.encryption` is required. TOTP secrets cannot be hashed, so a store dump would let anyone ' +
|
|
78
|
+
'mint valid codes. Pass `{ keys: [key] }`, or `false` to store them in plaintext deliberately.');
|
|
79
|
+
}
|
|
80
|
+
if (this.options.encryption) {
|
|
81
|
+
this.cipher = new SecretCipher(this.options.encryption);
|
|
82
|
+
}
|
|
83
|
+
// A `NaN` limit would never be reached: every guess would be checked.
|
|
84
|
+
requireIntegerOption(this.options.maxAttempts, 'mfa.maxAttempts', { min: 1 });
|
|
85
|
+
// Each step either side adds two codes that every guess may hit; 30 would be seconds, not steps.
|
|
86
|
+
requireIntegerOption(this.options.window, 'mfa.window', { min: 0, max: 10 });
|
|
87
|
+
requireIntegerOption(this.options.recoveryCodes, 'mfa.recoveryCodes', { min: 1 });
|
|
88
|
+
}
|
|
89
|
+
async isEnrolled(userId) {
|
|
90
|
+
// Truthy, not `=== true`: a store that reads the flag back as `1` must not skip the second factor.
|
|
91
|
+
return !!(await this.store.getTotp(userId))?.confirmed;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Starts an enrollment. Throws {@link MfaAlreadyEnrolledError} when the user
|
|
95
|
+
* has a confirmed authenticator, unless `replace` is set: then the new
|
|
96
|
+
* secret is staged and the current authenticator stays active until
|
|
97
|
+
* `confirm()` succeeds with a code from the new one.
|
|
98
|
+
*/
|
|
99
|
+
async enroll(userId, accountName, { replace = false } = {}) {
|
|
100
|
+
const current = await this.store.getTotp(userId);
|
|
101
|
+
const secret = base32Encode(randomBytes(20));
|
|
102
|
+
const sealed = this.seal(userId, secret);
|
|
103
|
+
if (current?.confirmed) {
|
|
104
|
+
if (!replace) {
|
|
105
|
+
throw new MfaAlreadyEnrolledError(userId);
|
|
106
|
+
}
|
|
107
|
+
await this.store.saveTotp(userId, { ...current, pendingSecret: sealed });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await this.store.saveTotp(userId, { secret: sealed, confirmed: false });
|
|
111
|
+
}
|
|
112
|
+
return { secret, uri: this.keyUri(secret, accountName) };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Activates a pending enrollment (or a staged replacement, which then
|
|
116
|
+
* takes the place of the current authenticator).
|
|
117
|
+
*/
|
|
118
|
+
async confirm(userId, code) {
|
|
119
|
+
const record = await this.store.getTotp(userId);
|
|
120
|
+
const candidate = record?.confirmed ? record.pendingSecret : record?.secret;
|
|
121
|
+
if (!record || !candidate) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const opened = this.open(userId, candidate);
|
|
125
|
+
if (!opened || !(await this.check(userId, opened, code))) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
// Read again: it carries the step just claimed, and a disable() or re-enrollment meanwhile wins.
|
|
129
|
+
const latest = await this.store.getTotp(userId);
|
|
130
|
+
const stillPending = !!latest && (record.confirmed ? latest.pendingSecret === candidate : latest.secret === candidate);
|
|
131
|
+
if (!latest || !stillPending) {
|
|
132
|
+
return false; // re-enrolled or disabled concurrently
|
|
133
|
+
}
|
|
134
|
+
const { pendingSecret: _, ...rest } = latest;
|
|
135
|
+
const secret = opened.needsReencrypt ? this.seal(userId, opened.plaintext) : candidate;
|
|
136
|
+
await this.store.saveTotp(userId, { ...rest, secret, confirmed: true });
|
|
137
|
+
this.events.emit({ type: 'mfa-enabled', userId, replaced: record.confirmed });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
async verifyTotp(userId, code) {
|
|
141
|
+
const record = await this.store.getTotp(userId);
|
|
142
|
+
if (!record?.confirmed) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
const opened = this.open(userId, record.secret);
|
|
146
|
+
if (!opened || !(await this.check(userId, opened, code))) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (opened.needsReencrypt) {
|
|
150
|
+
// Read again: it keeps the claimed step, and an authenticator disabled or replaced meanwhile stays so.
|
|
151
|
+
const latest = await this.store.getTotp(userId);
|
|
152
|
+
if (latest?.secret === record.secret) {
|
|
153
|
+
await this.store.saveTotp(userId, { ...latest, secret: this.seal(userId, opened.plaintext) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
this.events.emit({ type: 'mfa-verified', userId, method: 'totp' });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Re-encrypts the stored secret with the current key if it was sealed
|
|
161
|
+
* with an older one (or is legacy plaintext under `migratePlaintext`).
|
|
162
|
+
* For key-rotation jobs; returns whether anything was rewritten.
|
|
163
|
+
*/
|
|
164
|
+
async reencrypt(userId) {
|
|
165
|
+
const record = await this.store.getTotp(userId);
|
|
166
|
+
if (!record || !this.cipher || !this.cipher.needsReencrypt(record.secret)) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const opened = this.open(userId, record.secret);
|
|
170
|
+
if (!opened) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
// Read again before writing: a disable() or a confirmed replacement meanwhile must stay.
|
|
174
|
+
const latest = await this.store.getTotp(userId);
|
|
175
|
+
if (latest?.secret !== record.secret) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
await this.store.saveTotp(userId, { ...latest, secret: this.seal(userId, opened.plaintext) });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Returns fresh plaintext codes (show once); replaces any previous batch.
|
|
183
|
+
* Codes are a second factor: for a user with a confirmed authenticator,
|
|
184
|
+
* call it right after `confirm()` succeeded, or from a route that requires
|
|
185
|
+
* a verified second factor (`@Authenticate({ mfa: true })`). Otherwise an
|
|
186
|
+
* API key, or a token issued before the user enrolled, could mint codes
|
|
187
|
+
* that finish a sign-in with the password alone.
|
|
188
|
+
*/
|
|
189
|
+
async generateRecoveryCodes(userId) {
|
|
190
|
+
const codes = Array.from({ length: this.options?.recoveryCodes ?? 10 }, () => {
|
|
191
|
+
const chars = Array.from({ length: 10 }, () => CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]).join('');
|
|
192
|
+
return `${chars.slice(0, 5)}-${chars.slice(5)}`;
|
|
193
|
+
});
|
|
194
|
+
const hashes = await Promise.all(codes.map((c) => hashRecoveryCode(userId, normalizeCode(c))));
|
|
195
|
+
await this.store.saveRecoveryCodes(userId, hashes);
|
|
196
|
+
this.events.emit({ type: 'recovery-codes-generated', userId, count: codes.length });
|
|
197
|
+
return codes;
|
|
198
|
+
}
|
|
199
|
+
async verifyRecoveryCode(userId, code) {
|
|
200
|
+
const attempt = await this.begin(userId, 'recovery-code');
|
|
201
|
+
if (attempt === undefined) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const normalized = typeof code === 'string' ? normalizeCode(code) : '';
|
|
205
|
+
if (normalized.length === 10 &&
|
|
206
|
+
(await this.store.consumeRecoveryCode(userId, await hashRecoveryCode(userId, normalized)))) {
|
|
207
|
+
await this.store.clearMfaFailures(userId);
|
|
208
|
+
this.events.emit({ type: 'mfa-verified', userId, method: 'recovery-code' });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
this.failed(userId, 'recovery-code', attempt);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
remainingRecoveryCodes(userId) {
|
|
215
|
+
return this.store.countRecoveryCodes(userId);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Removes the authenticator and the recovery codes. Call it only from a
|
|
219
|
+
* route that requires a verified second factor
|
|
220
|
+
* (`@Authenticate({ mfa: true })`): an API key, or a token issued before
|
|
221
|
+
* the user enrolled, is no second factor.
|
|
222
|
+
*/
|
|
223
|
+
async disable(userId) {
|
|
224
|
+
await this.store.saveTotp(userId, null);
|
|
225
|
+
await this.store.saveRecoveryCodes(userId, []);
|
|
226
|
+
this.events.emit({ type: 'mfa-disabled', userId });
|
|
227
|
+
}
|
|
228
|
+
keyUri(secret, accountName) {
|
|
229
|
+
const issuer = this.options?.issuer ?? 'NestJS';
|
|
230
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`;
|
|
231
|
+
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: String(DIGITS), period: String(PERIOD) });
|
|
232
|
+
return `otpauth://totp/${label}?${params}`;
|
|
233
|
+
}
|
|
234
|
+
/** `opened` is undefined when the secret could not be decrypted: fail closed, not counted against the user. */
|
|
235
|
+
async check(userId, opened, code) {
|
|
236
|
+
const attempt = await this.begin(userId, 'totp');
|
|
237
|
+
if (attempt === undefined) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
const step = this.matchingStep(opened.plaintext, code);
|
|
241
|
+
if (step !== undefined && (await this.store.claimTotpStep(userId, step))) {
|
|
242
|
+
await this.store.clearMfaFailures(userId);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
this.failed(userId, 'totp', attempt);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Counts an attempt before it is checked, and returns its number within
|
|
250
|
+
* the window, or `undefined` when it must be refused unchecked. Counting
|
|
251
|
+
* first is what stops a burst of parallel guesses: each one gets its own
|
|
252
|
+
* number from the store, and only the first `maxAttempts` are checked.
|
|
253
|
+
* An attempt made while already locked out is not counted, so the lockout
|
|
254
|
+
* ends `lockoutWindow` after it began.
|
|
255
|
+
*/
|
|
256
|
+
async begin(userId, method) {
|
|
257
|
+
const failures = await this.store.countMfaFailures(userId, this.lockoutWindow, this.now());
|
|
258
|
+
if (failures >= this.maxAttempts) {
|
|
259
|
+
this.events.emit({ type: 'mfa-failed', userId, method, failures, locked: true });
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
const attempt = await this.store.recordMfaFailure(userId, this.lockoutWindow, this.now());
|
|
263
|
+
if (attempt > this.maxAttempts) {
|
|
264
|
+
this.events.emit({ type: 'mfa-failed', userId, method, failures: attempt, locked: true });
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
return attempt;
|
|
268
|
+
}
|
|
269
|
+
/** The attempt was already counted by {@link begin}; this reports it. */
|
|
270
|
+
failed(userId, method, attempt) {
|
|
271
|
+
this.events.emit({ type: 'mfa-failed', userId, method, failures: attempt, locked: attempt >= this.maxAttempts });
|
|
272
|
+
}
|
|
273
|
+
seal(userId, secret) {
|
|
274
|
+
if (!this.options) {
|
|
275
|
+
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
276
|
+
}
|
|
277
|
+
return this.cipher ? this.cipher.encrypt(secret, `totp.${userId}`) : secret;
|
|
278
|
+
}
|
|
279
|
+
open(userId, stored) {
|
|
280
|
+
if (!this.options) {
|
|
281
|
+
throw new Error('MFA is not configured: add `mfa: { encryption: … }` to the AuthenticationModule options');
|
|
282
|
+
}
|
|
283
|
+
if (!this.cipher) {
|
|
284
|
+
return { plaintext: stored, needsReencrypt: false };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return this.cipher.decrypt(stored, `totp.${userId}`);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (!(error instanceof SecretDecryptionError)) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
// Tampering, a row copied from another user, or a key removed too early.
|
|
294
|
+
MfaService_1.logger.error(`TOTP secret of user ${userId} could not be decrypted: ${error.message}`);
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
matchingStep(secret, code) {
|
|
299
|
+
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
let key;
|
|
303
|
+
try {
|
|
304
|
+
key = base32Decode(secret);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return undefined; // not a base32 secret (e.g. ciphertext stored while `encryption: false`)
|
|
308
|
+
}
|
|
309
|
+
// RFC 4226 §4 asks for 128 bits at least (enroll() makes 160): an empty secret (a NULL column
|
|
310
|
+
// read back as '') would give codes anyone can compute.
|
|
311
|
+
if (key.length < 16) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const current = totpStep(Math.floor(this.now() / 1000), PERIOD);
|
|
315
|
+
const window = this.options?.window ?? 1;
|
|
316
|
+
let found;
|
|
317
|
+
// Check every candidate (no early exit) so timing does not reveal which step matched.
|
|
318
|
+
for (let step = current - window; step <= current + window; step++) {
|
|
319
|
+
if (safeEqual(hotp(key, step, DIGITS), code) && found === undefined) {
|
|
320
|
+
found = step;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return found;
|
|
324
|
+
}
|
|
325
|
+
/** Read at each call, never in the constructor: the registry locks once the module starts. */
|
|
326
|
+
get store() {
|
|
327
|
+
return this.storage.mfa;
|
|
328
|
+
}
|
|
329
|
+
get maxAttempts() {
|
|
330
|
+
return this.options?.maxAttempts ?? 5;
|
|
331
|
+
}
|
|
332
|
+
now() {
|
|
333
|
+
return this.options?.now?.() ?? Date.now();
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
MfaService.logger = new Logger('MfaService');
|
|
337
|
+
MfaService = MfaService_1 = __decorate([
|
|
338
|
+
Injectable(),
|
|
339
|
+
__param(1, Optional()),
|
|
340
|
+
__param(1, Inject(AUTHENTICATION_MODULE_OPTIONS)),
|
|
341
|
+
__metadata("design:paramtypes", [AuthenticationStorage, Object, AuthenticationEvents])
|
|
342
|
+
], MfaService);
|
|
343
|
+
export { MfaService };
|
|
344
|
+
function normalizeCode(code) {
|
|
345
|
+
return code.toUpperCase().replace(/[\s-]/g, '');
|
|
346
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** RFC 4648 base32, no padding (the form authenticator apps expect). */
|
|
2
|
+
export declare function base32Encode(data: Buffer): string;
|
|
3
|
+
export declare function base32Decode(input: string): Buffer;
|
|
4
|
+
/** HOTP (RFC 4226 §5.3) with HMAC-SHA1. */
|
|
5
|
+
export declare function hotp(key: Buffer, counter: number, digits?: number): string;
|
|
6
|
+
/** The RFC 6238 time step for a Unix time in seconds. */
|
|
7
|
+
export declare function totpStep(unixSeconds: number, period?: number): number;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
3
|
+
/** RFC 4648 base32, no padding (the form authenticator apps expect). */
|
|
4
|
+
export function base32Encode(data) {
|
|
5
|
+
let bits = 0;
|
|
6
|
+
let value = 0;
|
|
7
|
+
let out = '';
|
|
8
|
+
for (const byte of data) {
|
|
9
|
+
value = (value << 8) | byte;
|
|
10
|
+
bits += 8;
|
|
11
|
+
while (bits >= 5) {
|
|
12
|
+
out += ALPHABET[(value >>> (bits - 5)) & 31];
|
|
13
|
+
bits -= 5;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (bits > 0) {
|
|
17
|
+
out += ALPHABET[(value << (5 - bits)) & 31];
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export function base32Decode(input) {
|
|
22
|
+
const clean = input.replace(/=+$/, '').toUpperCase();
|
|
23
|
+
const bytes = [];
|
|
24
|
+
let bits = 0;
|
|
25
|
+
let value = 0;
|
|
26
|
+
for (const char of clean) {
|
|
27
|
+
const index = ALPHABET.indexOf(char);
|
|
28
|
+
if (index === -1) {
|
|
29
|
+
throw new Error('invalid base32');
|
|
30
|
+
}
|
|
31
|
+
value = (value << 5) | index;
|
|
32
|
+
bits += 5;
|
|
33
|
+
if (bits >= 8) {
|
|
34
|
+
bytes.push((value >>> (bits - 8)) & 255);
|
|
35
|
+
bits -= 8;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Buffer.from(bytes);
|
|
39
|
+
}
|
|
40
|
+
/** HOTP (RFC 4226 §5.3) with HMAC-SHA1. */
|
|
41
|
+
export function hotp(key, counter, digits = 6) {
|
|
42
|
+
const message = Buffer.alloc(8);
|
|
43
|
+
message.writeBigUInt64BE(BigInt(counter));
|
|
44
|
+
const mac = createHmac('sha1', key).update(message).digest();
|
|
45
|
+
const offset = mac[mac.length - 1] & 0x0f;
|
|
46
|
+
const binary = mac.readUInt32BE(offset) & 0x7fffffff;
|
|
47
|
+
return String(binary % 10 ** digits).padStart(digits, '0');
|
|
48
|
+
}
|
|
49
|
+
/** The RFC 6238 time step for a Unix time in seconds. */
|
|
50
|
+
export function totpStep(unixSeconds, period = 30) {
|
|
51
|
+
return Math.floor(unixSeconds / period);
|
|
52
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SecretEncryptionOptions } from '../interfaces/mfa-options.interface.js';
|
|
2
|
+
/**
|
|
3
|
+
* AES-256-GCM for secrets the server must read back (TOTP seeds cannot be
|
|
4
|
+
* hashed). Format: `v1.<keyId>.<iv>.<ciphertext>.<tag>`, base64url parts.
|
|
5
|
+
*
|
|
6
|
+
* - The key id is derived from the key (8 characters of its SHA-256), so
|
|
7
|
+
* rotation finds the right key without anyone naming keys, and the id
|
|
8
|
+
* reveals nothing about the key.
|
|
9
|
+
* - A fresh 96-bit IV per encryption (NIST SP 800-38D §8.2.2 random IVs;
|
|
10
|
+
* fine far below 2^32 encryptions per key).
|
|
11
|
+
* - Additional authenticated data binds the version, key id, purpose and
|
|
12
|
+
* owner (`v1.<keyId>.<context>`), so a ciphertext copied to another user's
|
|
13
|
+
* row, or with a rewritten header, fails authentication.
|
|
14
|
+
* - Decryption fails closed: any malformed value, unknown key id or bad tag
|
|
15
|
+
* throws {@link SecretDecryptionError}.
|
|
16
|
+
*/
|
|
17
|
+
export declare class SecretCipher {
|
|
18
|
+
private readonly options;
|
|
19
|
+
private readonly keys;
|
|
20
|
+
private readonly currentId;
|
|
21
|
+
constructor(options: SecretEncryptionOptions);
|
|
22
|
+
encrypt(plaintext: string, context: string): string;
|
|
23
|
+
decrypt(stored: string, context: string): {
|
|
24
|
+
plaintext: string;
|
|
25
|
+
needsReencrypt: boolean;
|
|
26
|
+
};
|
|
27
|
+
needsReencrypt(stored: string): boolean;
|
|
28
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto';
|
|
2
|
+
import { requireOption } from '../utils/options.util.js';
|
|
3
|
+
import { SecretDecryptionError } from '../errors/secret-decryption.error.js';
|
|
4
|
+
const VERSION = 'v1';
|
|
5
|
+
const HKDF_INFO = 'nestjs-authentication totp-secret v1';
|
|
6
|
+
const MIN_SECRET_LENGTH = 32;
|
|
7
|
+
const OPTION = 'mfa.encryption.keys';
|
|
8
|
+
/**
|
|
9
|
+
* AES-256-GCM for secrets the server must read back (TOTP seeds cannot be
|
|
10
|
+
* hashed). Format: `v1.<keyId>.<iv>.<ciphertext>.<tag>`, base64url parts.
|
|
11
|
+
*
|
|
12
|
+
* - The key id is derived from the key (8 characters of its SHA-256), so
|
|
13
|
+
* rotation finds the right key without anyone naming keys, and the id
|
|
14
|
+
* reveals nothing about the key.
|
|
15
|
+
* - A fresh 96-bit IV per encryption (NIST SP 800-38D §8.2.2 random IVs;
|
|
16
|
+
* fine far below 2^32 encryptions per key).
|
|
17
|
+
* - Additional authenticated data binds the version, key id, purpose and
|
|
18
|
+
* owner (`v1.<keyId>.<context>`), so a ciphertext copied to another user's
|
|
19
|
+
* row, or with a rewritten header, fails authentication.
|
|
20
|
+
* - Decryption fails closed: any malformed value, unknown key id or bad tag
|
|
21
|
+
* throws {@link SecretDecryptionError}.
|
|
22
|
+
*/
|
|
23
|
+
export class SecretCipher {
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.keys = new Map();
|
|
27
|
+
const list = options?.keys;
|
|
28
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
29
|
+
throw new TypeError(`MFA: \`${OPTION}\` must list at least one key (newest first).`);
|
|
30
|
+
}
|
|
31
|
+
const derived = list.map(deriveKey);
|
|
32
|
+
// A key listed twice has one id: the first entry wins, and both are the same key.
|
|
33
|
+
for (const { id, key } of derived) {
|
|
34
|
+
if (!this.keys.has(id)) {
|
|
35
|
+
this.keys.set(id, key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
this.currentId = derived[0].id;
|
|
39
|
+
}
|
|
40
|
+
encrypt(plaintext, context) {
|
|
41
|
+
const iv = randomBytes(12);
|
|
42
|
+
const cipher = createCipheriv('aes-256-gcm', this.keys.get(this.currentId), iv);
|
|
43
|
+
cipher.setAAD(aad(this.currentId, context));
|
|
44
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
45
|
+
return [VERSION, this.currentId, iv, ciphertext, cipher.getAuthTag()]
|
|
46
|
+
.map((part) => (typeof part === 'string' ? part : part.toString('base64url')))
|
|
47
|
+
.join('.');
|
|
48
|
+
}
|
|
49
|
+
decrypt(stored, context) {
|
|
50
|
+
if (!stored.startsWith(`${VERSION}.`)) {
|
|
51
|
+
if (this.options.migratePlaintext) {
|
|
52
|
+
return { plaintext: stored, needsReencrypt: true };
|
|
53
|
+
}
|
|
54
|
+
throw new SecretDecryptionError('value is not encrypted');
|
|
55
|
+
}
|
|
56
|
+
const parts = stored.split('.');
|
|
57
|
+
if (parts.length !== 5) {
|
|
58
|
+
throw new SecretDecryptionError('malformed ciphertext');
|
|
59
|
+
}
|
|
60
|
+
const [, keyId, iv, ciphertext, tag] = parts.map((p, i) => (i < 2 ? p : Buffer.from(p, 'base64url')));
|
|
61
|
+
const key = this.keys.get(keyId);
|
|
62
|
+
if (!key) {
|
|
63
|
+
throw new SecretDecryptionError(`unknown key id '${keyId}'`);
|
|
64
|
+
}
|
|
65
|
+
if (iv.length !== 12 || tag.length !== 16) {
|
|
66
|
+
throw new SecretDecryptionError('malformed ciphertext');
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
70
|
+
decipher.setAAD(aad(keyId, context));
|
|
71
|
+
decipher.setAuthTag(tag);
|
|
72
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
73
|
+
return { plaintext, needsReencrypt: keyId !== this.currentId };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new SecretDecryptionError('authentication failed');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
needsReencrypt(stored) {
|
|
80
|
+
return !stored.startsWith(`${VERSION}.${this.currentId}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function aad(keyId, context) {
|
|
84
|
+
return Buffer.from(`${VERSION}.${keyId}.${context}`, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
/** The AES key for one `keys` entry, and its id. Errors name the entry, never its value. */
|
|
87
|
+
function deriveKey(material, index) {
|
|
88
|
+
const option = `${OPTION}[${index}]`;
|
|
89
|
+
requireOption(material, option, `32 random bytes, or a random string of at least ${MIN_SECRET_LENGTH} characters`);
|
|
90
|
+
let key;
|
|
91
|
+
if (Buffer.isBuffer(material)) {
|
|
92
|
+
if (material.length !== 32) {
|
|
93
|
+
throw new TypeError(`MFA: \`${option}\` is a Buffer of ${material.length} bytes; it must be 32 bytes.`);
|
|
94
|
+
}
|
|
95
|
+
key = material;
|
|
96
|
+
}
|
|
97
|
+
else if (typeof material === 'string' && material.length >= MIN_SECRET_LENGTH) {
|
|
98
|
+
key = Buffer.from(hkdfSync('sha256', material, Buffer.alloc(0), HKDF_INFO, 32));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
throw new TypeError(`MFA: \`${option}\` must be 32 random bytes, or a random string of at least ${MIN_SECRET_LENGTH} ` +
|
|
102
|
+
'characters (for example `openssl rand -base64 32`), not a password.');
|
|
103
|
+
}
|
|
104
|
+
const id = createHash('sha256').update(key).digest('base64url').slice(0, 8);
|
|
105
|
+
return { id, key };
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OidcTransaction } from '../interfaces/oidc.interface.js';
|
|
2
|
+
import type { OidcStateStore } from '../interfaces/oidc-state-store.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* Process-local store: the default when no source is registered, and a
|
|
5
|
+
* test double. Drops expired logins as new ones are saved, and holds at
|
|
6
|
+
* most 10,000, dropping the oldest: anyone can start a login, so the store
|
|
7
|
+
* must not grow with them.
|
|
8
|
+
*/
|
|
9
|
+
export declare class InMemoryOidcStateStore implements OidcStateStore {
|
|
10
|
+
private readonly pending;
|
|
11
|
+
saveOidcState(transaction: OidcTransaction): Promise<void>;
|
|
12
|
+
consumeOidcState(state: string): Promise<OidcTransaction | undefined>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
const MAX_PENDING = 10_000;
|
|
9
|
+
/**
|
|
10
|
+
* Process-local store: the default when no source is registered, and a
|
|
11
|
+
* test double. Drops expired logins as new ones are saved, and holds at
|
|
12
|
+
* most 10,000, dropping the oldest: anyone can start a login, so the store
|
|
13
|
+
* must not grow with them.
|
|
14
|
+
*/
|
|
15
|
+
let InMemoryOidcStateStore = class InMemoryOidcStateStore {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.pending = new Map();
|
|
18
|
+
}
|
|
19
|
+
async saveOidcState(transaction) {
|
|
20
|
+
const now = transaction.createdAt.getTime();
|
|
21
|
+
// Insertion order is expiry order (one lifetime per service): stop at the first live entry.
|
|
22
|
+
for (const [state, tx] of this.pending) {
|
|
23
|
+
if (tx.expiresAt.getTime() > now) {
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
this.pending.delete(state);
|
|
27
|
+
}
|
|
28
|
+
this.pending.set(transaction.state, { ...transaction });
|
|
29
|
+
for (const state of this.pending.keys()) {
|
|
30
|
+
if (this.pending.size <= MAX_PENDING) {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
this.pending.delete(state); // insertion order: the oldest first
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async consumeOidcState(state) {
|
|
37
|
+
const tx = this.pending.get(state);
|
|
38
|
+
this.pending.delete(state);
|
|
39
|
+
return tx;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
InMemoryOidcStateStore = __decorate([
|
|
43
|
+
Injectable()
|
|
44
|
+
], InMemoryOidcStateStore);
|
|
45
|
+
export { InMemoryOidcStateStore };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OAuthTokens, OidcProfile } from '../interfaces/oidc-provider.interface.js';
|
|
2
|
+
import type { OidcResolveContext } from '../interfaces/oidc.interface.js';
|
|
3
|
+
/**
|
|
4
|
+
* Implemented by the app: maps an external identity to a local user
|
|
5
|
+
* (find by `(provider, subject)`, link, or create). Return `null` to refuse.
|
|
6
|
+
* An ordinary provider of one of your modules, which registers itself from
|
|
7
|
+
* its constructor: `registry.registerHandler('oidc', this)`.
|
|
8
|
+
*
|
|
9
|
+
* Linking a new identity to an existing account by email is only safe when
|
|
10
|
+
* both sides verified the address: `profile.emailVerified` (the provider
|
|
11
|
+
* asserted it) and your own record (the owner proved it to you). Otherwise
|
|
12
|
+
* someone could register the address first, with a password of their
|
|
13
|
+
* choosing, and keep access once the real owner signs in with the provider
|
|
14
|
+
* (account pre-hijacking). Refuse, and let the user sign in and link from
|
|
15
|
+
* their account (`context.linkTo`).
|
|
16
|
+
*/
|
|
17
|
+
export declare abstract class OidcAccountResolver {
|
|
18
|
+
abstract resolveUser(profile: OidcProfile, tokens: OAuthTokens, context: OidcResolveContext): {
|
|
19
|
+
id: string;
|
|
20
|
+
} | null | Promise<{
|
|
21
|
+
id: string;
|
|
22
|
+
} | null>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implemented by the app: maps an external identity to a local user
|
|
3
|
+
* (find by `(provider, subject)`, link, or create). Return `null` to refuse.
|
|
4
|
+
* An ordinary provider of one of your modules, which registers itself from
|
|
5
|
+
* its constructor: `registry.registerHandler('oidc', this)`.
|
|
6
|
+
*
|
|
7
|
+
* Linking a new identity to an existing account by email is only safe when
|
|
8
|
+
* both sides verified the address: `profile.emailVerified` (the provider
|
|
9
|
+
* asserted it) and your own record (the owner proved it to you). Otherwise
|
|
10
|
+
* someone could register the address first, with a password of their
|
|
11
|
+
* choosing, and keep access once the real owner signs in with the provider
|
|
12
|
+
* (account pre-hijacking). Refuse, and let the user sign in and link from
|
|
13
|
+
* their account (`context.linkTo`).
|
|
14
|
+
*/
|
|
15
|
+
export class OidcAccountResolver {
|
|
16
|
+
}
|