@astram/guard 0.7.0 → 0.9.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.
Files changed (187) hide show
  1. package/dist/auth/account.controller.d.ts +27 -5
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +99 -26
  4. package/dist/auth/auth.controller.d.ts +130 -21
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +537 -143
  7. package/dist/auth/auth.module.d.ts +15 -1
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +212 -30
  10. package/dist/auth/connections.d.ts +1 -1
  11. package/dist/auth/connections.d.ts.map +1 -1
  12. package/dist/auth/connections.js +14 -3
  13. package/dist/auth/default-session-facts-projector.d.ts +26 -0
  14. package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
  15. package/dist/auth/default-session-facts-projector.js +21 -0
  16. package/dist/auth/mfa-step-up.d.ts +32 -0
  17. package/dist/auth/mfa-step-up.d.ts.map +1 -0
  18. package/dist/auth/mfa-step-up.js +109 -0
  19. package/dist/auth/pending-mfa-store.d.ts +50 -52
  20. package/dist/auth/pending-mfa-store.d.ts.map +1 -1
  21. package/dist/auth/pending-mfa-store.js +103 -61
  22. package/dist/auth/ports/session-facts-projector.d.ts +5 -0
  23. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
  24. package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
  25. package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
  26. package/dist/auth/rate-limit/canonical-hint.js +63 -0
  27. package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
  28. package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
  29. package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
  30. package/dist/auth/tokens.d.ts +70 -11
  31. package/dist/auth/tokens.d.ts.map +1 -1
  32. package/dist/auth/tokens.js +71 -12
  33. package/dist/authentication/log-email-sender.d.ts +9 -4
  34. package/dist/authentication/log-email-sender.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-api-key-store.d.ts +5 -1
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
  37. package/dist/authentication/mariadb-api-key-store.js +43 -7
  38. package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
  39. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  40. package/dist/authentication/mariadb-credential-resolver.js +116 -73
  41. package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
  42. package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
  43. package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
  44. package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
  45. package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
  46. package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
  47. package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
  48. package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
  49. package/dist/authentication/mariadb-session-sweeper.js +29 -11
  50. package/dist/authentication/noop-email-sender.d.ts +12 -0
  51. package/dist/authentication/noop-email-sender.d.ts.map +1 -0
  52. package/dist/authentication/noop-email-sender.js +9 -0
  53. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
  54. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
  55. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
  56. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
  57. package/dist/authz/mariadb-policy-store.js +1 -1
  58. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
  59. package/dist/authz/mariadb-record-rule-adapter.js +1 -4
  60. package/dist/authz/permission-version-cache.d.ts +38 -0
  61. package/dist/authz/permission-version-cache.d.ts.map +1 -0
  62. package/dist/authz/permission-version-cache.js +81 -0
  63. package/dist/authz/wire-record-authorization.d.ts +25 -1
  64. package/dist/authz/wire-record-authorization.d.ts.map +1 -1
  65. package/dist/authz/wire-record-authorization.js +55 -2
  66. package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
  67. package/dist/config/app-config.d.ts +36 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +44 -4
  70. package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
  71. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  72. package/dist/enforcement/enforcement.module.js +9 -0
  73. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  74. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  75. package/dist/enforcement/engine-error.filter.js +118 -0
  76. package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
  77. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  78. package/dist/enforcement/global-enforcement.guard.js +143 -5
  79. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  80. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  81. package/dist/identity/permission-facts.adapter.js +2 -3
  82. package/dist/index.d.ts +13 -7
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +68 -28
  85. package/dist/internal.d.ts +12 -0
  86. package/dist/internal.d.ts.map +1 -0
  87. package/dist/internal.js +16 -0
  88. package/dist/module/shield.module.d.ts +9 -2
  89. package/dist/module/shield.module.d.ts.map +1 -1
  90. package/dist/module/shield.module.js +47 -38
  91. package/dist/request-auth/session-facts-als.d.ts +16 -1
  92. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  93. package/dist/request-auth/session-facts-als.js +23 -0
  94. package/dist/request-auth/session-token-binder.d.ts +166 -10
  95. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  96. package/dist/request-auth/session-token-binder.js +408 -28
  97. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
  98. package/dist/secrets/encrypted-file-secrets.js +9 -3
  99. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  100. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  101. package/dist/session/hot-revoking-session-record-store.js +58 -0
  102. package/dist/session/index.d.ts +3 -1
  103. package/dist/session/index.d.ts.map +1 -1
  104. package/dist/session/index.js +8 -1
  105. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  106. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  107. package/dist/session/mariadb-session-record-store.js +16 -1
  108. package/dist/session/session-lifetimes.d.ts +30 -0
  109. package/dist/session/session-lifetimes.d.ts.map +1 -0
  110. package/dist/session/session-lifetimes.js +21 -0
  111. package/dist/session/session-restriction-store.d.ts +43 -0
  112. package/dist/session/session-restriction-store.d.ts.map +1 -0
  113. package/dist/session/session-restriction-store.js +35 -0
  114. package/package.json +14 -3
  115. package/dist/auth/account.controller.js.map +0 -1
  116. package/dist/auth/auth.controller.js.map +0 -1
  117. package/dist/auth/auth.module.js.map +0 -1
  118. package/dist/auth/connections.js.map +0 -1
  119. package/dist/auth/dto.js.map +0 -1
  120. package/dist/auth/pending-mfa-store.js.map +0 -1
  121. package/dist/auth/persistence.module.js.map +0 -1
  122. package/dist/auth/ports/audit-sink.js.map +0 -1
  123. package/dist/auth/ports/credential-writer.js.map +0 -1
  124. package/dist/auth/ports/email-sender.js.map +0 -1
  125. package/dist/auth/ports/membership-checker.js.map +0 -1
  126. package/dist/auth/ports/mfa-device-store.js.map +0 -1
  127. package/dist/auth/ports/profile-writer.js.map +0 -1
  128. package/dist/auth/ports/recovery-code-store.js.map +0 -1
  129. package/dist/auth/ports/reset-token-store.js.map +0 -1
  130. package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
  131. package/dist/auth/ports/session-facts-projector.js.map +0 -1
  132. package/dist/auth/ports/session-sweeper.js.map +0 -1
  133. package/dist/auth/profile-resolver.js.map +0 -1
  134. package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
  135. package/dist/auth/tokens.js.map +0 -1
  136. package/dist/auth/totp-enrollment.js.map +0 -1
  137. package/dist/authentication/argon2id-password-hasher.js.map +0 -1
  138. package/dist/authentication/log-email-sender.js.map +0 -1
  139. package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
  140. package/dist/authentication/mariadb-credential-writer.js.map +0 -1
  141. package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
  142. package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
  143. package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
  144. package/dist/authentication/mariadb-profile-writer.js.map +0 -1
  145. package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
  146. package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
  147. package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
  148. package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
  149. package/dist/authz/list-scope-sql.js.map +0 -1
  150. package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
  151. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
  152. package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
  153. package/dist/authz/mariadb-policy-store.js.map +0 -1
  154. package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
  155. package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
  156. package/dist/authz/record-share-cascade.js.map +0 -1
  157. package/dist/authz/record-share-writer.js.map +0 -1
  158. package/dist/authz/wire-record-authorization.js.map +0 -1
  159. package/dist/boot/boot-sequence.js.map +0 -1
  160. package/dist/boot/boot.module.js.map +0 -1
  161. package/dist/completeness/catalog-reconciler.js.map +0 -1
  162. package/dist/completeness/operation-inventory-scanner.js.map +0 -1
  163. package/dist/completeness/ports/catalog-store.js.map +0 -1
  164. package/dist/config/app-config.js.map +0 -1
  165. package/dist/config/config.module.js.map +0 -1
  166. package/dist/enforcement/classification.decorator.js.map +0 -1
  167. package/dist/enforcement/core-seam.js.map +0 -1
  168. package/dist/enforcement/enforcement.module.js.map +0 -1
  169. package/dist/enforcement/global-enforcement.guard.js.map +0 -1
  170. package/dist/enforcement/reenroll-allowlist.js.map +0 -1
  171. package/dist/identity/auth-facts.controller.js.map +0 -1
  172. package/dist/identity/identity-actions.js.map +0 -1
  173. package/dist/identity/module-registry.bridge.js.map +0 -1
  174. package/dist/identity/permission-facts.adapter.js.map +0 -1
  175. package/dist/index.js.map +0 -1
  176. package/dist/migrations-dir.js.map +0 -1
  177. package/dist/module/shield-tokens.js.map +0 -1
  178. package/dist/module/shield.module.js.map +0 -1
  179. package/dist/request-auth/request-principal.js.map +0 -1
  180. package/dist/request-auth/session-facts-als.js.map +0 -1
  181. package/dist/request-auth/session-token-binder.js.map +0 -1
  182. package/dist/request-auth/tokens.js.map +0 -1
  183. package/dist/scope/closure-builder.js.map +0 -1
  184. package/dist/session/crypto-adapters.js.map +0 -1
  185. package/dist/session/index.js.map +0 -1
  186. package/dist/session/mariadb-session-record-store.js.map +0 -1
  187. 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
- * Phase 0b Auth-surface — the **pending-MFA challenge store** backing
3
- * `/auth/login` (mfa branch) → `/auth/mfa`.
2
+ * The **pending-MFA challenge store** backing `/auth/login` (mfa branch) → `/auth/mfa`
3
+ * (security hardening M6, plan decision D8).
4
4
  *
5
- * ── WHY THIS EXISTS ──────────────────────────────────────────────────────────
6
- * The core `Authenticator` has NO separate "verify one more factor" entry point:
7
- * `authenticate(hint, credentials[])` is the ONLY seam (see
8
- * `../../core/authentication/authenticator.ts`). To COMPLETE an MFA login the
9
- * controller must RE-CALL `authenticate(hint, [password, totp])` with BOTH the
10
- * original password AND the freshly-presented TOTP code. So between the login that
11
- * returned `mfa_required` and the `/auth/mfa` that finishes it, the server must
12
- * hold the ORIGINAL `identityHint` + `password` (the "pending authentication").
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 NON-FORGEABILITY DESIGN (the security decision) ──────────────────────
15
- * The `challengeToken` handed to the client is a **cryptographically-random opaque
16
- * handle** (`node:crypto.randomBytes`), NOT a signed/encrypted blob carrying the
17
- * credentials. The pending `{ identityHint, password }` state lives ONLY
18
- * server-side, keyed by that random token. Consequences:
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
- * TRADE-OFF (documented): server-side state is process-local (an in-memory `Map`).
32
- * For the single-instance framework scope of Phase 0b this is correct and the
33
- * safest option (the password never reaches the client). A multi-instance
34
- * deployment would swap this `Map` for a shared TTL store (Redis) behind the SAME
35
- * interface — the controller depends only on `store/consume`, not on the backing.
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
- * A minimal server-side, single-use, TTL'd store of pending MFA challenges.
42
- * Injected into the auth controller. Deliberately depends on nothing framework-y.
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
- private readonly ttlMs;
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
- * Store a pending authentication and return the opaque, unguessable
54
- * `challengeToken` the client re-presents to `/auth/mfa`. The token carries NO
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAmBH;;;GAGG;AACH,qBAAa,eAAe;IAIxB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,KAAK;IALxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;gBAGxC,KAAK,GAAE,MAAuB;IAC/C,iEAAiE;IAChD,KAAK,GAAE,MAAM,MAAiB;IAGjD;;;;OAIG;IACH,KAAK,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAWrD;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAWzE,mFAAmF;IACnF,OAAO,CAAC,KAAK;CAMd"}
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
- * Phase 0b Auth-surface — the **pending-MFA challenge store** backing
4
- * `/auth/login` (mfa branch) → `/auth/mfa`.
3
+ * The **pending-MFA challenge store** backing `/auth/login` (mfa branch) → `/auth/mfa`
4
+ * (security hardening M6, plan decision D8).
5
5
  *
6
- * ── WHY THIS EXISTS ──────────────────────────────────────────────────────────
7
- * The core `Authenticator` has NO separate "verify one more factor" entry point:
8
- * `authenticate(hint, credentials[])` is the ONLY seam (see
9
- * `../../core/authentication/authenticator.ts`). To COMPLETE an MFA login the
10
- * controller must RE-CALL `authenticate(hint, [password, totp])` with BOTH the
11
- * original password AND the freshly-presented TOTP code. So between the login that
12
- * returned `mfa_required` and the `/auth/mfa` that finishes it, the server must
13
- * hold the ORIGINAL `identityHint` + `password` (the "pending authentication").
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 NON-FORGEABILITY DESIGN (the security decision) ──────────────────────
16
- * The `challengeToken` handed to the client is a **cryptographically-random opaque
17
- * handle** (`node:crypto.randomBytes`), NOT a signed/encrypted blob carrying the
18
- * credentials. The pending `{ identityHint, password }` state lives ONLY
19
- * server-side, keyed by that random token. Consequences:
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
- * TRADE-OFF (documented): server-side state is process-local (an in-memory `Map`).
33
- * For the single-instance framework scope of Phase 0b this is correct and the
34
- * safest option (the password never reaches the client). A multi-instance
35
- * deployment would swap this `Map` for a shared TTL store (Redis) behind the SAME
36
- * interface — the controller depends only on `store/consume`, not on the backing.
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, url-safe hex → 64 chars). */
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
- * A minimal server-side, single-use, TTL'd store of pending MFA challenges.
50
- * Injected into the auth controller. Deliberately depends on nothing framework-y.
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
- * Store a pending authentication and return the opaque, unguessable
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 = (0, node_crypto_1.randomBytes)(TOKEN_BYTES).toString('hex');
70
- this.pending.set(token, {
71
- identityHint,
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
- * Look up + SINGLE-USE consume a pending challenge. Returns the held credentials
79
- * for a live, known token; `null` for an unknown, forged, or expired token (the
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 entry = this.pending.get(token);
88
- // Single-use: remove on read regardless of validity (a spent/expired handle
89
- // can never be retried → forces a fresh login, the anti-brute-force cooldown).
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 { identityHint: entry.identityHint, password: entry.password };
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 [token, entry] of this.pending) {
104
+ for (const [key, entry] of this.pending) {
101
105
  if (now >= entry.expiresAtMs)
102
- this.pending.delete(token);
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;CAC9E"}
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"}