@astram/guard 0.7.0 → 0.8.0
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/dist/auth/account.controller.d.ts +3 -1
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +12 -5
- package/dist/auth/auth.controller.d.ts +130 -21
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +537 -143
- package/dist/auth/auth.module.d.ts +15 -1
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +212 -30
- package/dist/auth/connections.d.ts +1 -1
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +14 -3
- package/dist/auth/default-session-facts-projector.d.ts +26 -0
- package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
- package/dist/auth/default-session-facts-projector.js +21 -0
- package/dist/auth/mfa-step-up.d.ts +32 -0
- package/dist/auth/mfa-step-up.d.ts.map +1 -0
- package/dist/auth/mfa-step-up.js +109 -0
- package/dist/auth/pending-mfa-store.d.ts +50 -52
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +103 -61
- package/dist/auth/ports/session-facts-projector.d.ts +5 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
- package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
- package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
- package/dist/auth/rate-limit/canonical-hint.js +63 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
- package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
- package/dist/auth/tokens.d.ts +70 -11
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +71 -12
- package/dist/authentication/log-email-sender.d.ts +9 -4
- package/dist/authentication/log-email-sender.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.d.ts +4 -1
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.js +31 -7
- package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +116 -73
- package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
- package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
- package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.js +29 -11
- package/dist/authentication/noop-email-sender.d.ts +12 -0
- package/dist/authentication/noop-email-sender.d.ts.map +1 -0
- package/dist/authentication/noop-email-sender.js +9 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
- package/dist/authz/mariadb-policy-store.js +1 -1
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +1 -4
- package/dist/authz/permission-version-cache.d.ts +38 -0
- package/dist/authz/permission-version-cache.d.ts.map +1 -0
- package/dist/authz/permission-version-cache.js +81 -0
- package/dist/authz/wire-record-authorization.d.ts +25 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +55 -2
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
- package/dist/config/app-config.d.ts +36 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +44 -4
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.js +9 -0
- package/dist/enforcement/engine-error.filter.d.ts +57 -0
- package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
- package/dist/enforcement/engine-error.filter.js +118 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +143 -5
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +2 -3
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -25
- package/dist/module/shield.module.d.ts +9 -2
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +47 -38
- package/dist/request-auth/session-facts-als.d.ts +16 -1
- package/dist/request-auth/session-facts-als.d.ts.map +1 -1
- package/dist/request-auth/session-facts-als.js +23 -0
- package/dist/request-auth/session-token-binder.d.ts +166 -10
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +408 -28
- package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
- package/dist/secrets/encrypted-file-secrets.js +9 -3
- package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
- package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
- package/dist/session/hot-revoking-session-record-store.js +58 -0
- package/dist/session/index.d.ts +3 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +8 -1
- package/dist/session/mariadb-session-record-store.d.ts +6 -0
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
- package/dist/session/mariadb-session-record-store.js +16 -1
- package/dist/session/session-lifetimes.d.ts +30 -0
- package/dist/session/session-lifetimes.d.ts.map +1 -0
- package/dist/session/session-lifetimes.js +21 -0
- package/dist/session/session-restriction-store.d.ts +43 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -0
- package/dist/session/session-restriction-store.js +35 -0
- package/package.json +3 -3
- package/dist/auth/account.controller.js.map +0 -1
- package/dist/auth/auth.controller.js.map +0 -1
- package/dist/auth/auth.module.js.map +0 -1
- package/dist/auth/connections.js.map +0 -1
- package/dist/auth/dto.js.map +0 -1
- package/dist/auth/pending-mfa-store.js.map +0 -1
- package/dist/auth/persistence.module.js.map +0 -1
- package/dist/auth/ports/audit-sink.js.map +0 -1
- package/dist/auth/ports/credential-writer.js.map +0 -1
- package/dist/auth/ports/email-sender.js.map +0 -1
- package/dist/auth/ports/membership-checker.js.map +0 -1
- package/dist/auth/ports/mfa-device-store.js.map +0 -1
- package/dist/auth/ports/profile-writer.js.map +0 -1
- package/dist/auth/ports/recovery-code-store.js.map +0 -1
- package/dist/auth/ports/reset-token-store.js.map +0 -1
- package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
- package/dist/auth/ports/session-facts-projector.js.map +0 -1
- package/dist/auth/ports/session-sweeper.js.map +0 -1
- package/dist/auth/profile-resolver.js.map +0 -1
- package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
- package/dist/auth/tokens.js.map +0 -1
- package/dist/auth/totp-enrollment.js.map +0 -1
- package/dist/authentication/argon2id-password-hasher.js.map +0 -1
- package/dist/authentication/log-email-sender.js.map +0 -1
- package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
- package/dist/authentication/mariadb-credential-writer.js.map +0 -1
- package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
- package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
- package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
- package/dist/authentication/mariadb-profile-writer.js.map +0 -1
- package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
- package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
- package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
- package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
- package/dist/authz/list-scope-sql.js.map +0 -1
- package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
- package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
- package/dist/authz/mariadb-policy-store.js.map +0 -1
- package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
- package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
- package/dist/authz/record-share-cascade.js.map +0 -1
- package/dist/authz/record-share-writer.js.map +0 -1
- package/dist/authz/wire-record-authorization.js.map +0 -1
- package/dist/boot/boot-sequence.js.map +0 -1
- package/dist/boot/boot.module.js.map +0 -1
- package/dist/completeness/catalog-reconciler.js.map +0 -1
- package/dist/completeness/operation-inventory-scanner.js.map +0 -1
- package/dist/completeness/ports/catalog-store.js.map +0 -1
- package/dist/config/app-config.js.map +0 -1
- package/dist/config/config.module.js.map +0 -1
- package/dist/enforcement/classification.decorator.js.map +0 -1
- package/dist/enforcement/core-seam.js.map +0 -1
- package/dist/enforcement/enforcement.module.js.map +0 -1
- package/dist/enforcement/global-enforcement.guard.js.map +0 -1
- package/dist/enforcement/reenroll-allowlist.js.map +0 -1
- package/dist/identity/auth-facts.controller.js.map +0 -1
- package/dist/identity/identity-actions.js.map +0 -1
- package/dist/identity/module-registry.bridge.js.map +0 -1
- package/dist/identity/permission-facts.adapter.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/module/shield-tokens.js.map +0 -1
- package/dist/module/shield.module.js.map +0 -1
- package/dist/request-auth/request-principal.js.map +0 -1
- package/dist/request-auth/session-facts-als.js.map +0 -1
- package/dist/request-auth/session-token-binder.js.map +0 -1
- package/dist/request-auth/tokens.js.map +0 -1
- package/dist/scope/closure-builder.js.map +0 -1
- package/dist/session/crypto-adapters.js.map +0 -1
- package/dist/session/index.js.map +0 -1
- package/dist/session/mariadb-session-record-store.js.map +0 -1
- package/dist/session/redis-hot-session-store.js.map +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CredentialMfaStepUp = void 0;
|
|
4
|
+
exports.credentialFingerprint = credentialFingerprint;
|
|
5
|
+
exports.challengeMatches = challengeMatches;
|
|
6
|
+
/**
|
|
7
|
+
* The MFA **step-up** — `/auth/mfa` verifies ONLY the second factor, against the
|
|
8
|
+
* principal whose password step already passed (security hardening M6, plan D8).
|
|
9
|
+
*
|
|
10
|
+
* WHY HERE (no core change): core's `Authenticator.authenticate(hint, credentials)` always
|
|
11
|
+
* verifies the password first, so completing MFA through it required holding the
|
|
12
|
+
* plaintext password between the two requests. Instead, `/auth/login` records the password
|
|
13
|
+
* step's success as a {@link PendingMfaChallenge} (principal + hint + credential
|
|
14
|
+
* fingerprint), and this class finishes the login from that challenge using only existing
|
|
15
|
+
* core ports: the {@link CredentialResolver} (re-resolve), the registered
|
|
16
|
+
* {@link FactorVerifier}s (the same `OrTotpVerifier` the authenticator uses, replay guard
|
|
17
|
+
* included) and the {@link IdentityBroker} (the principal is minted exactly as the
|
|
18
|
+
* authenticator mints it — tenant binding + root allowlist, never fabricated here).
|
|
19
|
+
*
|
|
20
|
+
* The verification mirrors the authenticator's factor loop, fail-closed:
|
|
21
|
+
* - unknown hint, a different principal, or a changed credential (fingerprint) → rejected;
|
|
22
|
+
* - no required factor any more (device removed) → rejected (never a password-less login
|
|
23
|
+
* through the MFA endpoint — the user logs in again);
|
|
24
|
+
* - every required factor must have a registered verifier, be the presented factor, and
|
|
25
|
+
* verify to a STRICT `true`;
|
|
26
|
+
* - any resolver / verifier / broker throw → `unavailable` (503, never counted as a guess).
|
|
27
|
+
*/
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
29
|
+
/** SHA-256 of the stored credential hash — binds a challenge to the credential state. */
|
|
30
|
+
function credentialFingerprint(resolved) {
|
|
31
|
+
return (0, node_crypto_1.createHash)('sha256')
|
|
32
|
+
.update(String(resolved.passwordHash ?? ''))
|
|
33
|
+
.digest('hex');
|
|
34
|
+
}
|
|
35
|
+
/** The principal id a resolved credential carries, or `null`. */
|
|
36
|
+
function principalIdOf(resolved) {
|
|
37
|
+
const id = resolved.claims?.id;
|
|
38
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `true` iff `resolved` is still the credential the challenge was issued for: same
|
|
42
|
+
* principal, same stored credential hash. Shared by the TOTP step-up and the
|
|
43
|
+
* recovery-code branch.
|
|
44
|
+
*/
|
|
45
|
+
function challengeMatches(resolved, challenge) {
|
|
46
|
+
return (principalIdOf(resolved) === challenge.principalId &&
|
|
47
|
+
credentialFingerprint(resolved) === challenge.credentialFingerprint);
|
|
48
|
+
}
|
|
49
|
+
const REJECTED = Object.freeze({ outcome: 'rejected' });
|
|
50
|
+
const UNAVAILABLE = Object.freeze({ outcome: 'unavailable' });
|
|
51
|
+
/** The framework-default {@link MfaStepUp} over the injected core ports. */
|
|
52
|
+
class CredentialMfaStepUp {
|
|
53
|
+
resolver;
|
|
54
|
+
broker;
|
|
55
|
+
verifiers = new Map();
|
|
56
|
+
constructor(resolver, factorVerifiers, broker) {
|
|
57
|
+
this.resolver = resolver;
|
|
58
|
+
this.broker = broker;
|
|
59
|
+
for (const v of factorVerifiers) {
|
|
60
|
+
if (this.verifiers.has(v.factor)) {
|
|
61
|
+
throw new Error(`CredentialMfaStepUp: duplicate verifier for factor "${v.factor}"`);
|
|
62
|
+
}
|
|
63
|
+
this.verifiers.set(v.factor, v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async verify(challenge, credential) {
|
|
67
|
+
let resolved;
|
|
68
|
+
try {
|
|
69
|
+
resolved = await this.resolver.resolve(challenge.identityHint);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return UNAVAILABLE;
|
|
73
|
+
}
|
|
74
|
+
if (resolved === null || resolved === undefined)
|
|
75
|
+
return REJECTED;
|
|
76
|
+
if (!challengeMatches(resolved, challenge))
|
|
77
|
+
return REJECTED;
|
|
78
|
+
const required = resolved.requiredFactors ?? [];
|
|
79
|
+
if (required.length === 0)
|
|
80
|
+
return REJECTED;
|
|
81
|
+
for (const req of required) {
|
|
82
|
+
const verifier = this.verifiers.get(req.factor);
|
|
83
|
+
if (verifier === undefined || credential?.factor !== req.factor)
|
|
84
|
+
return REJECTED;
|
|
85
|
+
let satisfied;
|
|
86
|
+
try {
|
|
87
|
+
satisfied = (await verifier.verify(credential, req.secret))?.satisfied;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return UNAVAILABLE;
|
|
91
|
+
}
|
|
92
|
+
if (satisfied !== true)
|
|
93
|
+
return REJECTED;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const principal = await this.broker.normalize({
|
|
97
|
+
source: 'internal',
|
|
98
|
+
claims: resolved.claims,
|
|
99
|
+
});
|
|
100
|
+
return principal.id === challenge.principalId
|
|
101
|
+
? { outcome: 'authenticated', principal }
|
|
102
|
+
: REJECTED;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return UNAVAILABLE;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
exports.CredentialMfaStepUp = CredentialMfaStepUp;
|
|
@@ -1,72 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* The **pending-MFA challenge store** backing `/auth/login` (mfa branch) → `/auth/mfa`
|
|
3
|
+
* (security hardening M6, plan decision D8).
|
|
4
4
|
*
|
|
5
|
-
* ──
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
5
|
+
* ── WHAT A CHALLENGE HOLDS (never the password) ─────────────────────────────────
|
|
6
|
+
* When the password step succeeds but a second factor is outstanding, the controller
|
|
7
|
+
* records THAT SUCCESS as a {@link PendingMfaChallenge}: the resolved principal id, the
|
|
8
|
+
* typed identity hint, and a fingerprint (SHA-256) of the stored credential hash the
|
|
9
|
+
* password was verified against. `/auth/mfa` then verifies ONLY the second factor against
|
|
10
|
+
* that principal (see `mfa-step-up.ts`); it never re-runs the password. The plaintext
|
|
11
|
+
* password is never stored — not in memory, not in Redis. A credential change between the
|
|
12
|
+
* two steps (password reset) changes the fingerprint and voids the challenge.
|
|
13
13
|
*
|
|
14
|
-
* ── THE
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* • FORGERY-PROOF BY CONSTRUCTION — a hand-crafted token maps to no entry, so it
|
|
20
|
-
* is rejected. There is no signature to strip, no payload to tamper with, and
|
|
21
|
-
* the client is never given the credentials (even encrypted) to hold.
|
|
22
|
-
* • SHORT-LIVED — each entry has an expiry (default 5 min). An expired handle is
|
|
23
|
-
* treated as unknown (same generic 401 as a forged one — anti-enumeration).
|
|
24
|
-
* • SINGLE-USE — `consume()` DELETES the entry as it reads it, so a
|
|
25
|
-
* challengeToken cannot be replayed to brute-force TOTP codes indefinitely on
|
|
26
|
-
* one handle. (A caller that fails still burns the handle → must re-login. This
|
|
27
|
-
* is the built-in anti-brute-force cooldown for THIS layer; the resolver/engine
|
|
28
|
-
* add no separate one.)
|
|
29
|
-
* • NEVER LOGGED — the stored password + the token are never logged.
|
|
14
|
+
* ── THE HANDLE ── the `challengeToken` returned to the client is a cryptographically
|
|
15
|
+
* random opaque handle (256-bit, hex). The state lives server-side under
|
|
16
|
+
* `sha256(token)`; a hand-crafted token maps to nothing (forgery-proof by construction).
|
|
17
|
+
* SHORT-LIVED (default 5 min) and SINGLE-USE (`consume` deletes as it reads, so a failed
|
|
18
|
+
* attempt burns the handle and forces a fresh login).
|
|
30
19
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* ADR-0003 note: this file DOES use `node:crypto` (guard owns node builtins; core
|
|
38
|
-
* does not). It holds NO framework/HTTP coupling — a plain injectable class.
|
|
20
|
+
* ── TWO BACKINGS, ONE INTERFACE ─────────────────────────────────────────────────
|
|
21
|
+
* - {@link RedisPendingMfaStore} — the `AuthModule` default: `SET … PX` + `GETDEL`, so
|
|
22
|
+
* the MFA step works on ANY instance (PM2 cluster, blue/green). A Redis error
|
|
23
|
+
* propagates; the controller answers a retryable 503 (never a challenge it cannot keep).
|
|
24
|
+
* - {@link PendingMfaStore} — the in-process base (single-instance / tests).
|
|
25
|
+
* The controller depends on the {@link PendingMfaStore} class token only.
|
|
39
26
|
*/
|
|
27
|
+
import type Redis from 'ioredis';
|
|
28
|
+
/** What the password step proved — everything `/auth/mfa` needs, and nothing secret. */
|
|
29
|
+
export interface PendingMfaChallenge {
|
|
30
|
+
/** The principal the password was verified for (from the resolved credential). */
|
|
31
|
+
readonly principalId: string;
|
|
32
|
+
/** The hint exactly as typed (the step-up re-resolves the credential through it). */
|
|
33
|
+
readonly identityHint: string;
|
|
34
|
+
/** SHA-256 of the stored credential hash at password time (credential-change guard). */
|
|
35
|
+
readonly credentialFingerprint: string;
|
|
36
|
+
}
|
|
40
37
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* The in-process challenge store (single instance / tests) and the class token the
|
|
39
|
+
* controller injects. {@link RedisPendingMfaStore} overrides both methods.
|
|
43
40
|
*/
|
|
44
41
|
export declare class PendingMfaStore {
|
|
45
|
-
|
|
42
|
+
protected readonly ttlMs: number;
|
|
46
43
|
/** Clock (UTC epoch ms) — injectable for deterministic tests. */
|
|
47
44
|
private readonly nowMs;
|
|
48
45
|
private readonly pending;
|
|
49
46
|
constructor(ttlMs?: number,
|
|
50
47
|
/** Clock (UTC epoch ms) — injectable for deterministic tests. */
|
|
51
48
|
nowMs?: () => number);
|
|
49
|
+
/** Record a passed password step; returns the opaque handle the client re-presents. */
|
|
50
|
+
store(challenge: PendingMfaChallenge): Promise<string>;
|
|
52
51
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* information about the user — it is a random handle only.
|
|
56
|
-
*/
|
|
57
|
-
store(identityHint: string, password: string): string;
|
|
58
|
-
/**
|
|
59
|
-
* Look up + SINGLE-USE consume a pending challenge. Returns the held credentials
|
|
60
|
-
* for a live, known token; `null` for an unknown, forged, or expired token (the
|
|
61
|
-
* controller maps `null` to the SAME generic 401 as a wrong code — no leak of
|
|
62
|
-
* WHY it failed). The entry is DELETED whether or not it was live, so a handle
|
|
63
|
-
* is never replayable.
|
|
52
|
+
* SINGLE-USE consume: the challenge for a live, known handle, else `null` (unknown,
|
|
53
|
+
* forged, expired). The entry is deleted whether or not it was live.
|
|
64
54
|
*/
|
|
65
|
-
consume(token: string):
|
|
66
|
-
identityHint: string;
|
|
67
|
-
password: string;
|
|
68
|
-
} | null;
|
|
55
|
+
consume(token: string): Promise<PendingMfaChallenge | null>;
|
|
69
56
|
/** Drop expired entries so the map cannot grow unbounded from abandoned logins. */
|
|
70
57
|
private sweep;
|
|
71
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* The Redis-backed challenge store — the `AuthModule` default (M6). `SET key value PX ttl`
|
|
61
|
+
* on store, `GETDEL` on consume (atomic single use across instances). Redis errors
|
|
62
|
+
* propagate to the controller (→ 503).
|
|
63
|
+
*/
|
|
64
|
+
export declare class RedisPendingMfaStore extends PendingMfaStore {
|
|
65
|
+
private readonly redis;
|
|
66
|
+
constructor(redis: Pick<Redis, 'set' | 'getdel'>, ttlMs?: number);
|
|
67
|
+
store(challenge: PendingMfaChallenge): Promise<string>;
|
|
68
|
+
consume(token: string): Promise<PendingMfaChallenge | null>;
|
|
69
|
+
}
|
|
72
70
|
//# sourceMappingURL=pending-mfa-store.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pending-mfa-store.d.ts","sourceRoot":"","sources":["../../src/auth/pending-mfa-store.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"pending-mfa-store.d.ts","sourceRoot":"","sources":["../../src/auth/pending-mfa-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,kFAAkF;IAClF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,qFAAqF;IACrF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,wFAAwF;IACxF,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;CACxC;AA2CD;;;GAGG;AACH,qBAAa,eAAe;IAIxB,SAAS,CAAC,QAAQ,CAAC,KAAK,EAAE,MAAM;IAChC,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,KAAK;IALxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;gBAGrC,KAAK,GAAE,MAAuB;IACjD,iEAAiE;IAChD,KAAK,GAAE,MAAM,MAAiB;IAGjD,uFAAuF;IACjF,KAAK,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IAU5D;;;OAGG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IAUjE,mFAAmF;IACnF,OAAO,CAAC,KAAK;CAMd;AAKD;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,eAAe;IAErD,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,KAAK,GAAG,QAAQ,CAAC,EACrD,KAAK,GAAE,MAAuB;IAKjB,KAAK,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;IAWtD,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;CAU3E"}
|
|
@@ -1,53 +1,66 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* The **pending-MFA challenge store** backing `/auth/login` (mfa branch) → `/auth/mfa`
|
|
4
|
+
* (security hardening M6, plan decision D8).
|
|
5
5
|
*
|
|
6
|
-
* ──
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* ── WHAT A CHALLENGE HOLDS (never the password) ─────────────────────────────────
|
|
7
|
+
* When the password step succeeds but a second factor is outstanding, the controller
|
|
8
|
+
* records THAT SUCCESS as a {@link PendingMfaChallenge}: the resolved principal id, the
|
|
9
|
+
* typed identity hint, and a fingerprint (SHA-256) of the stored credential hash the
|
|
10
|
+
* password was verified against. `/auth/mfa` then verifies ONLY the second factor against
|
|
11
|
+
* that principal (see `mfa-step-up.ts`); it never re-runs the password. The plaintext
|
|
12
|
+
* password is never stored — not in memory, not in Redis. A credential change between the
|
|
13
|
+
* two steps (password reset) changes the fingerprint and voids the challenge.
|
|
14
14
|
*
|
|
15
|
-
* ── THE
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* • FORGERY-PROOF BY CONSTRUCTION — a hand-crafted token maps to no entry, so it
|
|
21
|
-
* is rejected. There is no signature to strip, no payload to tamper with, and
|
|
22
|
-
* the client is never given the credentials (even encrypted) to hold.
|
|
23
|
-
* • SHORT-LIVED — each entry has an expiry (default 5 min). An expired handle is
|
|
24
|
-
* treated as unknown (same generic 401 as a forged one — anti-enumeration).
|
|
25
|
-
* • SINGLE-USE — `consume()` DELETES the entry as it reads it, so a
|
|
26
|
-
* challengeToken cannot be replayed to brute-force TOTP codes indefinitely on
|
|
27
|
-
* one handle. (A caller that fails still burns the handle → must re-login. This
|
|
28
|
-
* is the built-in anti-brute-force cooldown for THIS layer; the resolver/engine
|
|
29
|
-
* add no separate one.)
|
|
30
|
-
* • NEVER LOGGED — the stored password + the token are never logged.
|
|
15
|
+
* ── THE HANDLE ── the `challengeToken` returned to the client is a cryptographically
|
|
16
|
+
* random opaque handle (256-bit, hex). The state lives server-side under
|
|
17
|
+
* `sha256(token)`; a hand-crafted token maps to nothing (forgery-proof by construction).
|
|
18
|
+
* SHORT-LIVED (default 5 min) and SINGLE-USE (`consume` deletes as it reads, so a failed
|
|
19
|
+
* attempt burns the handle and forces a fresh login).
|
|
31
20
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* ADR-0003 note: this file DOES use `node:crypto` (guard owns node builtins; core
|
|
39
|
-
* does not). It holds NO framework/HTTP coupling — a plain injectable class.
|
|
21
|
+
* ── TWO BACKINGS, ONE INTERFACE ─────────────────────────────────────────────────
|
|
22
|
+
* - {@link RedisPendingMfaStore} — the `AuthModule` default: `SET … PX` + `GETDEL`, so
|
|
23
|
+
* the MFA step works on ANY instance (PM2 cluster, blue/green). A Redis error
|
|
24
|
+
* propagates; the controller answers a retryable 503 (never a challenge it cannot keep).
|
|
25
|
+
* - {@link PendingMfaStore} — the in-process base (single-instance / tests).
|
|
26
|
+
* The controller depends on the {@link PendingMfaStore} class token only.
|
|
40
27
|
*/
|
|
41
28
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
-
exports.PendingMfaStore = void 0;
|
|
29
|
+
exports.RedisPendingMfaStore = exports.PendingMfaStore = void 0;
|
|
43
30
|
const node_crypto_1 = require("node:crypto");
|
|
44
31
|
/** Default challenge lifetime — short, per spec §2 ("a short TTL"). */
|
|
45
32
|
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
46
|
-
/** How many random bytes back a challenge handle (256-bit,
|
|
33
|
+
/** How many random bytes back a challenge handle (256-bit, hex → 64 chars). */
|
|
47
34
|
const TOKEN_BYTES = 32;
|
|
35
|
+
/** Mint a fresh opaque handle. */
|
|
36
|
+
function mintToken() {
|
|
37
|
+
return (0, node_crypto_1.randomBytes)(TOKEN_BYTES).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
/** The storage key for a handle: its SHA-256 (the raw handle is never a key name). */
|
|
40
|
+
function tokenDigest(token) {
|
|
41
|
+
return (0, node_crypto_1.createHash)('sha256').update(token).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
/** Structurally validate a stored challenge (an untrusted payload). */
|
|
44
|
+
function parseChallenge(value) {
|
|
45
|
+
if (value === null || typeof value !== 'object')
|
|
46
|
+
return null;
|
|
47
|
+
const v = value;
|
|
48
|
+
if (typeof v.principalId !== 'string' ||
|
|
49
|
+
v.principalId === '' ||
|
|
50
|
+
typeof v.identityHint !== 'string' ||
|
|
51
|
+
typeof v.credentialFingerprint !== 'string' ||
|
|
52
|
+
v.credentialFingerprint === '') {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
principalId: v.principalId,
|
|
57
|
+
identityHint: v.identityHint,
|
|
58
|
+
credentialFingerprint: v.credentialFingerprint,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
48
61
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
62
|
+
* The in-process challenge store (single instance / tests) and the class token the
|
|
63
|
+
* controller injects. {@link RedisPendingMfaStore} overrides both methods.
|
|
51
64
|
*/
|
|
52
65
|
class PendingMfaStore {
|
|
53
66
|
ttlMs;
|
|
@@ -59,48 +72,77 @@ class PendingMfaStore {
|
|
|
59
72
|
this.ttlMs = ttlMs;
|
|
60
73
|
this.nowMs = nowMs;
|
|
61
74
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
* `challengeToken` the client re-presents to `/auth/mfa`. The token carries NO
|
|
65
|
-
* information about the user — it is a random handle only.
|
|
66
|
-
*/
|
|
67
|
-
store(identityHint, password) {
|
|
75
|
+
/** Record a passed password step; returns the opaque handle the client re-presents. */
|
|
76
|
+
async store(challenge) {
|
|
68
77
|
this.sweep();
|
|
69
|
-
const token = (
|
|
70
|
-
this.pending.set(token, {
|
|
71
|
-
|
|
72
|
-
password,
|
|
78
|
+
const token = mintToken();
|
|
79
|
+
this.pending.set(tokenDigest(token), {
|
|
80
|
+
challenge: { ...challenge },
|
|
73
81
|
expiresAtMs: this.nowMs() + this.ttlMs,
|
|
74
82
|
});
|
|
75
83
|
return token;
|
|
76
84
|
}
|
|
77
85
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* controller maps `null` to the SAME generic 401 as a wrong code — no leak of
|
|
81
|
-
* WHY it failed). The entry is DELETED whether or not it was live, so a handle
|
|
82
|
-
* is never replayable.
|
|
86
|
+
* SINGLE-USE consume: the challenge for a live, known handle, else `null` (unknown,
|
|
87
|
+
* forged, expired). The entry is deleted whether or not it was live.
|
|
83
88
|
*/
|
|
84
|
-
consume(token) {
|
|
89
|
+
async consume(token) {
|
|
85
90
|
if (typeof token !== 'string' || token.length === 0)
|
|
86
91
|
return null;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.pending.delete(token);
|
|
92
|
+
const key = tokenDigest(token);
|
|
93
|
+
const entry = this.pending.get(key);
|
|
94
|
+
this.pending.delete(key);
|
|
91
95
|
if (entry === undefined)
|
|
92
96
|
return null;
|
|
93
97
|
if (this.nowMs() >= entry.expiresAtMs)
|
|
94
98
|
return null;
|
|
95
|
-
return {
|
|
99
|
+
return { ...entry.challenge };
|
|
96
100
|
}
|
|
97
101
|
/** Drop expired entries so the map cannot grow unbounded from abandoned logins. */
|
|
98
102
|
sweep() {
|
|
99
103
|
const now = this.nowMs();
|
|
100
|
-
for (const [
|
|
104
|
+
for (const [key, entry] of this.pending) {
|
|
101
105
|
if (now >= entry.expiresAtMs)
|
|
102
|
-
this.pending.delete(
|
|
106
|
+
this.pending.delete(key);
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
exports.PendingMfaStore = PendingMfaStore;
|
|
111
|
+
/** Redis key prefix for pending challenges. */
|
|
112
|
+
const REDIS_PREFIX = 'shield:mfa:challenge:';
|
|
113
|
+
/**
|
|
114
|
+
* The Redis-backed challenge store — the `AuthModule` default (M6). `SET key value PX ttl`
|
|
115
|
+
* on store, `GETDEL` on consume (atomic single use across instances). Redis errors
|
|
116
|
+
* propagate to the controller (→ 503).
|
|
117
|
+
*/
|
|
118
|
+
class RedisPendingMfaStore extends PendingMfaStore {
|
|
119
|
+
redis;
|
|
120
|
+
constructor(redis, ttlMs = DEFAULT_TTL_MS) {
|
|
121
|
+
super(ttlMs);
|
|
122
|
+
this.redis = redis;
|
|
123
|
+
}
|
|
124
|
+
async store(challenge) {
|
|
125
|
+
const token = mintToken();
|
|
126
|
+
const value = JSON.stringify({
|
|
127
|
+
principalId: challenge.principalId,
|
|
128
|
+
identityHint: challenge.identityHint,
|
|
129
|
+
credentialFingerprint: challenge.credentialFingerprint,
|
|
130
|
+
});
|
|
131
|
+
await this.redis.set(`${REDIS_PREFIX}${tokenDigest(token)}`, value, 'PX', this.ttlMs);
|
|
132
|
+
return token;
|
|
133
|
+
}
|
|
134
|
+
async consume(token) {
|
|
135
|
+
if (typeof token !== 'string' || token.length === 0)
|
|
136
|
+
return null;
|
|
137
|
+
const raw = await this.redis.getdel(`${REDIS_PREFIX}${tokenDigest(token)}`);
|
|
138
|
+
if (raw === null)
|
|
139
|
+
return null;
|
|
140
|
+
try {
|
|
141
|
+
return parseChallenge(JSON.parse(raw));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
exports.RedisPendingMfaStore = RedisPendingMfaStore;
|
|
@@ -40,5 +40,10 @@ export interface SessionFactsProjector {
|
|
|
40
40
|
* authority uses.
|
|
41
41
|
*/
|
|
42
42
|
project(principalId: string, tenantId: string): Promise<CoarseGrants | null>;
|
|
43
|
+
/**
|
|
44
|
+
* OPTIONAL (ADR-0035): `false` ⇒ treat this projector as absent for now (the framework's
|
|
45
|
+
* deferred default before its facts port is attached). Omitted ⇒ always active.
|
|
46
|
+
*/
|
|
47
|
+
isActive?(): boolean;
|
|
43
48
|
}
|
|
44
49
|
//# sourceMappingURL=session-facts-projector.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-facts-projector.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/session-facts-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"session-facts-projector.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/session-facts-projector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC7E;;;OAGG;IACH,QAAQ,CAAC,IAAI,OAAO,CAAC;CACtB"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The canonical identity hint (`NFKC → trim → lowercase`) — fallback-key material only. */
|
|
2
|
+
export declare function canonicalHint(hint: string): string;
|
|
3
|
+
/** `sha256(canonicalHint(hint))` as hex — the PII-free unknown-account key component. */
|
|
4
|
+
export declare function hintKeyDigest(hint: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The per-account password-login limiter key: `login:pid:<principalId>` when the store
|
|
7
|
+
* resolved an account, else `login:<sha256(canonical hint)>`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function loginHintKey(hint: string, principalId?: string | null): string;
|
|
10
|
+
/**
|
|
11
|
+
* The per-account forgot-password limiter key: `forgot:pid:<principalId>` when resolved,
|
|
12
|
+
* else `forgot:<sha256(canonical hint)>`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function forgotHintKey(hint: string, principalId?: string | null): string;
|
|
15
|
+
/**
|
|
16
|
+
* The per-client-IP password-login FAILURE limiter key: `login:ip:<ip>`. A blank ip
|
|
17
|
+
* (non-standard adapter/harness) gets its own sentinel bucket, never a shared one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loginIpKey(ip: string | undefined): string;
|
|
20
|
+
/** The principal id a resolved credential carries (`claims.id`), or `null`. */
|
|
21
|
+
export declare function principalIdOf(resolved: {
|
|
22
|
+
claims?: unknown;
|
|
23
|
+
} | null | undefined): string | null;
|
|
24
|
+
//# sourceMappingURL=canonical-hint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canonical-hint.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/canonical-hint.ts"],"names":[],"mappings":"AAsBA,4FAA4F;AAC5F,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAE9E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAE/E;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAEzD;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,QAAQ,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAG9F"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonicalHint = canonicalHint;
|
|
4
|
+
exports.hintKeyDigest = hintKeyDigest;
|
|
5
|
+
exports.loginHintKey = loginHintKey;
|
|
6
|
+
exports.forgotHintKey = forgotHintKey;
|
|
7
|
+
exports.loginIpKey = loginIpKey;
|
|
8
|
+
exports.principalIdOf = principalIdOf;
|
|
9
|
+
/**
|
|
10
|
+
* PII-free limiter keys for the login / forgot-password budgets (security hardening #5,
|
|
11
|
+
* review F1/F5).
|
|
12
|
+
*
|
|
13
|
+
* WHY per ACCOUNT: the credential lookup runs `WHERE ic_identity_hint = ?` on a
|
|
14
|
+
* `utf8mb4_unicode_ci` column, which is case-, accent-, ignorable-character- and
|
|
15
|
+
* trailing-space-insensitive. `Alice@x`, `alicé@x`, `ali<ZWSP>ce@x` and `alice@x ` all
|
|
16
|
+
* resolve to the SAME row, and no client-side normalisation reproduces that collation.
|
|
17
|
+
* So the per-hint budget is keyed on the account the store resolved
|
|
18
|
+
* (`login:pid:<principalId>` / `forgot:pid:<principalId>`): every spelling the DB maps
|
|
19
|
+
* to one account shares one counter.
|
|
20
|
+
*
|
|
21
|
+
* When NO account resolves, the key falls back to the canonical-hint hash
|
|
22
|
+
* (`login:<sha256(NFKC → trim → lowercase)>`): no account means nothing to take over, but
|
|
23
|
+
* enumeration is still rate-limited. The canonical form is used ONLY for this fallback
|
|
24
|
+
* key — the lookup itself always receives the RAW typed hint so the DB collation decides
|
|
25
|
+
* equality (F5).
|
|
26
|
+
*
|
|
27
|
+
* No email/username is ever written into a Redis key name (no PII in the hot tier).
|
|
28
|
+
*/
|
|
29
|
+
const node_crypto_1 = require("node:crypto");
|
|
30
|
+
/** The canonical identity hint (`NFKC → trim → lowercase`) — fallback-key material only. */
|
|
31
|
+
function canonicalHint(hint) {
|
|
32
|
+
return hint.normalize('NFKC').trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
/** `sha256(canonicalHint(hint))` as hex — the PII-free unknown-account key component. */
|
|
35
|
+
function hintKeyDigest(hint) {
|
|
36
|
+
return (0, node_crypto_1.createHash)('sha256').update(canonicalHint(hint)).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The per-account password-login limiter key: `login:pid:<principalId>` when the store
|
|
40
|
+
* resolved an account, else `login:<sha256(canonical hint)>`.
|
|
41
|
+
*/
|
|
42
|
+
function loginHintKey(hint, principalId) {
|
|
43
|
+
return principalId ? `login:pid:${principalId}` : `login:${hintKeyDigest(hint)}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The per-account forgot-password limiter key: `forgot:pid:<principalId>` when resolved,
|
|
47
|
+
* else `forgot:<sha256(canonical hint)>`.
|
|
48
|
+
*/
|
|
49
|
+
function forgotHintKey(hint, principalId) {
|
|
50
|
+
return principalId ? `forgot:pid:${principalId}` : `forgot:${hintKeyDigest(hint)}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The per-client-IP password-login FAILURE limiter key: `login:ip:<ip>`. A blank ip
|
|
54
|
+
* (non-standard adapter/harness) gets its own sentinel bucket, never a shared one.
|
|
55
|
+
*/
|
|
56
|
+
function loginIpKey(ip) {
|
|
57
|
+
return `login:ip:${ip || 'unknown-ip'}`;
|
|
58
|
+
}
|
|
59
|
+
/** The principal id a resolved credential carries (`claims.id`), or `null`. */
|
|
60
|
+
function principalIdOf(resolved) {
|
|
61
|
+
const claims = resolved?.claims;
|
|
62
|
+
return typeof claims?.id === 'string' && claims.id !== '' ? claims.id : null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time `trust proxy` decision for ALL per-IP limiters (security hardening #5,
|
|
3
|
+
* review F2/F3): the login failure limiter (`login:ip:`), the forgot-password request cap
|
|
4
|
+
* (`forgot:ip:`) and the reset-password abuse cap (`reset:ip:`).
|
|
5
|
+
*
|
|
6
|
+
* Each keys on `req.ip`. Behind a reverse proxy (Traefik in the Astram stack) `req.ip` is
|
|
7
|
+
* the PROXY's address unless the app sets `app.set('trust proxy', <hop count>)`. With it
|
|
8
|
+
* unset every client shares one bucket, so a per-IP limit becomes an unauthenticated
|
|
9
|
+
* global DoS: 50 failed logins lock ALL logins, 10 bad reset tokens 429 EVERY reset, 5
|
|
10
|
+
* forgot requests silently drop EVERY reset email. Setting it too high lets a client spoof
|
|
11
|
+
* `X-Forwarded-For`, so the framework cannot pick a value.
|
|
12
|
+
*
|
|
13
|
+
* Decision (made ONCE at bootstrap, never per request):
|
|
14
|
+
* - Express `trust proxy` explicitly configured (`app.get('trust proxy') !== false`, so an
|
|
15
|
+
* explicit `0` = "no proxy" counts) → {@link TrustProxyBootCheck.perIpLimitingEnabled}
|
|
16
|
+
* is `true` and AuthController enforces the per-IP limiters;
|
|
17
|
+
* - otherwise (unset, a non-Express adapter, or before bootstrap) → `false`: every per-IP
|
|
18
|
+
* limiter is OFF and one boot warning says so. The per-account limiters (login,
|
|
19
|
+
* forgot-password) and the recovery-code limiter are unaffected.
|
|
20
|
+
*
|
|
21
|
+
* Runs on `onApplicationBootstrap` (inside `app.init()` / `app.listen()`), i.e. after a
|
|
22
|
+
* typical `main.ts` has called `app.set('trust proxy', …)` on the created app.
|
|
23
|
+
*/
|
|
24
|
+
import { type OnApplicationBootstrap } from '@nestjs/common';
|
|
25
|
+
import { HttpAdapterHost } from '@nestjs/core';
|
|
26
|
+
/**
|
|
27
|
+
* `true` when the Express app has an explicit `trust proxy` value. Express's default is
|
|
28
|
+
* `false`; an explicit `0`/`'loopback'`/hop count/subnet list counts as configured.
|
|
29
|
+
* Returns `null` when the instance is not Express-like (cannot tell).
|
|
30
|
+
*/
|
|
31
|
+
export declare function isTrustProxyConfigured(instance: unknown): boolean | null;
|
|
32
|
+
export declare const TRUST_PROXY_WARNING: string;
|
|
33
|
+
export declare class TrustProxyBootCheck implements OnApplicationBootstrap {
|
|
34
|
+
private readonly adapterHost?;
|
|
35
|
+
private static readonly logger;
|
|
36
|
+
private enabled;
|
|
37
|
+
constructor(adapterHost?: HttpAdapterHost | undefined);
|
|
38
|
+
/**
|
|
39
|
+
* Whether AuthController enforces the per-IP limiters (login failures, forgot-password,
|
|
40
|
+
* reset-password). `false` until bootstrap has confirmed an explicit Express
|
|
41
|
+
* `trust proxy` (fail-safe: never a global lockout from a shared proxy IP).
|
|
42
|
+
*/
|
|
43
|
+
get perIpLimitingEnabled(): boolean;
|
|
44
|
+
onApplicationBootstrap(): void;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=trust-proxy-check.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trust-proxy-check.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/trust-proxy-check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAgC,KAAK,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAC3F,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAO/C;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAOxE;AAED,eAAO,MAAM,mBAAmB,QAMG,CAAC;AAEpC,qBACa,mBAAoB,YAAW,sBAAsB;IAIxC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAHrD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAA4B;IAC1D,OAAO,CAAC,OAAO,CAAS;gBAEiB,WAAW,CAAC,EAAE,eAAe,YAAA;IAEtE;;;;OAIG;IACH,IAAI,oBAAoB,IAAI,OAAO,CAElC;IAED,sBAAsB,IAAI,IAAI;CAQ/B"}
|