@astram/guard 0.6.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.
Files changed (124) hide show
  1. package/dist/auth/account.controller.d.ts +3 -1
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +12 -5
  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 +241 -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 +46 -0
  36. package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -0
  37. package/dist/authentication/mariadb-api-key-store.js +111 -0
  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 +52 -0
  68. package/dist/config/app-config.d.ts.map +1 -1
  69. package/dist/config/app-config.js +66 -4
  70. package/dist/encryption/local-kek-crypto.d.ts +15 -0
  71. package/dist/encryption/local-kek-crypto.d.ts.map +1 -0
  72. package/dist/encryption/local-kek-crypto.js +79 -0
  73. package/dist/enforcement/enforcement.module.d.ts.map +1 -1
  74. package/dist/enforcement/enforcement.module.js +9 -0
  75. package/dist/enforcement/engine-error.filter.d.ts +57 -0
  76. package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
  77. package/dist/enforcement/engine-error.filter.js +118 -0
  78. package/dist/enforcement/global-enforcement.guard.d.ts +31 -2
  79. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  80. package/dist/enforcement/global-enforcement.guard.js +148 -6
  81. package/dist/identity/auth-facts.controller.d.ts.map +1 -1
  82. package/dist/identity/identity-actions.d.ts +9 -0
  83. package/dist/identity/identity-actions.d.ts.map +1 -1
  84. package/dist/identity/identity-actions.js +16 -1
  85. package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
  86. package/dist/identity/permission-facts.adapter.js +2 -3
  87. package/dist/index.d.ts +17 -7
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +82 -25
  90. package/dist/module/shield.module.d.ts +9 -2
  91. package/dist/module/shield.module.d.ts.map +1 -1
  92. package/dist/module/shield.module.js +47 -38
  93. package/dist/request-auth/request-principal.d.ts +11 -3
  94. package/dist/request-auth/request-principal.d.ts.map +1 -1
  95. package/dist/request-auth/request-principal.js +58 -2
  96. package/dist/request-auth/session-facts-als.d.ts +16 -1
  97. package/dist/request-auth/session-facts-als.d.ts.map +1 -1
  98. package/dist/request-auth/session-facts-als.js +23 -0
  99. package/dist/request-auth/session-token-binder.d.ts +166 -10
  100. package/dist/request-auth/session-token-binder.d.ts.map +1 -1
  101. package/dist/request-auth/session-token-binder.js +408 -28
  102. package/dist/request-auth/tokens.d.ts +10 -0
  103. package/dist/request-auth/tokens.d.ts.map +1 -1
  104. package/dist/request-auth/tokens.js +11 -1
  105. package/dist/secrets/encrypted-file-secrets.d.ts +16 -0
  106. package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -0
  107. package/dist/secrets/encrypted-file-secrets.js +98 -0
  108. package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
  109. package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
  110. package/dist/session/hot-revoking-session-record-store.js +58 -0
  111. package/dist/session/index.d.ts +3 -1
  112. package/dist/session/index.d.ts.map +1 -1
  113. package/dist/session/index.js +8 -1
  114. package/dist/session/mariadb-session-record-store.d.ts +6 -0
  115. package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
  116. package/dist/session/mariadb-session-record-store.js +16 -1
  117. package/dist/session/session-lifetimes.d.ts +30 -0
  118. package/dist/session/session-lifetimes.d.ts.map +1 -0
  119. package/dist/session/session-lifetimes.js +21 -0
  120. package/dist/session/session-restriction-store.d.ts +43 -0
  121. package/dist/session/session-restriction-store.d.ts.map +1 -0
  122. package/dist/session/session-restriction-store.js +35 -0
  123. package/migrations/1710000000017-CreateServiceAccountKey.cjs +41 -0
  124. package/package.json +3 -3
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ 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;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var TrustProxyBootCheck_1;
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.TrustProxyBootCheck = exports.TRUST_PROXY_WARNING = void 0;
17
+ exports.isTrustProxyConfigured = isTrustProxyConfigured;
18
+ /**
19
+ * Boot-time `trust proxy` decision for ALL per-IP limiters (security hardening #5,
20
+ * review F2/F3): the login failure limiter (`login:ip:`), the forgot-password request cap
21
+ * (`forgot:ip:`) and the reset-password abuse cap (`reset:ip:`).
22
+ *
23
+ * Each keys on `req.ip`. Behind a reverse proxy (Traefik in the Astram stack) `req.ip` is
24
+ * the PROXY's address unless the app sets `app.set('trust proxy', <hop count>)`. With it
25
+ * unset every client shares one bucket, so a per-IP limit becomes an unauthenticated
26
+ * global DoS: 50 failed logins lock ALL logins, 10 bad reset tokens 429 EVERY reset, 5
27
+ * forgot requests silently drop EVERY reset email. Setting it too high lets a client spoof
28
+ * `X-Forwarded-For`, so the framework cannot pick a value.
29
+ *
30
+ * Decision (made ONCE at bootstrap, never per request):
31
+ * - Express `trust proxy` explicitly configured (`app.get('trust proxy') !== false`, so an
32
+ * explicit `0` = "no proxy" counts) → {@link TrustProxyBootCheck.perIpLimitingEnabled}
33
+ * is `true` and AuthController enforces the per-IP limiters;
34
+ * - otherwise (unset, a non-Express adapter, or before bootstrap) → `false`: every per-IP
35
+ * limiter is OFF and one boot warning says so. The per-account limiters (login,
36
+ * forgot-password) and the recovery-code limiter are unaffected.
37
+ *
38
+ * Runs on `onApplicationBootstrap` (inside `app.init()` / `app.listen()`), i.e. after a
39
+ * typical `main.ts` has called `app.set('trust proxy', …)` on the created app.
40
+ */
41
+ const common_1 = require("@nestjs/common");
42
+ const core_1 = require("@nestjs/core");
43
+ /**
44
+ * `true` when the Express app has an explicit `trust proxy` value. Express's default is
45
+ * `false`; an explicit `0`/`'loopback'`/hop count/subnet list counts as configured.
46
+ * Returns `null` when the instance is not Express-like (cannot tell).
47
+ */
48
+ function isTrustProxyConfigured(instance) {
49
+ if (instance === null || (typeof instance !== 'function' && typeof instance !== 'object'))
50
+ return null;
51
+ const get = instance.get;
52
+ if (typeof get !== 'function')
53
+ return null;
54
+ const value = get.call(instance, 'trust proxy');
55
+ return value !== undefined && value !== false;
56
+ }
57
+ exports.TRUST_PROXY_WARNING = 'per-IP limiting (login, forgot-password, reset-password) is OFF: set trust proxy ' +
58
+ '(0 if no proxy, else the hop count). Without an explicit Express ' +
59
+ "'trust proxy' every client behind a reverse proxy shares the proxy's IP, so a per-IP " +
60
+ "limit would block everyone. In main.ts call app.set('trust proxy', <number of proxy hops " +
61
+ 'in front of this app>) — never more than the real count. The per-account login and ' +
62
+ 'forgot-password limits stay on.';
63
+ let TrustProxyBootCheck = class TrustProxyBootCheck {
64
+ static { TrustProxyBootCheck_1 = this; }
65
+ adapterHost;
66
+ static logger = new common_1.Logger('ShieldAuth');
67
+ enabled = false;
68
+ constructor(adapterHost) {
69
+ this.adapterHost = adapterHost;
70
+ }
71
+ /**
72
+ * Whether AuthController enforces the per-IP limiters (login failures, forgot-password,
73
+ * reset-password). `false` until bootstrap has confirmed an explicit Express
74
+ * `trust proxy` (fail-safe: never a global lockout from a shared proxy IP).
75
+ */
76
+ get perIpLimitingEnabled() {
77
+ return this.enabled;
78
+ }
79
+ onApplicationBootstrap() {
80
+ const adapter = this.adapterHost?.httpAdapter;
81
+ if (!adapter)
82
+ return; // no HTTP server (e.g. a bare module) → no auth routes; stays off
83
+ const configured = adapter.getType() === 'express' ? isTrustProxyConfigured(adapter.getInstance()) : null;
84
+ this.enabled = configured === true;
85
+ if (!this.enabled)
86
+ TrustProxyBootCheck_1.logger.warn(exports.TRUST_PROXY_WARNING);
87
+ }
88
+ };
89
+ exports.TrustProxyBootCheck = TrustProxyBootCheck;
90
+ exports.TrustProxyBootCheck = TrustProxyBootCheck = TrustProxyBootCheck_1 = __decorate([
91
+ (0, common_1.Injectable)(),
92
+ __param(0, (0, common_1.Optional)()),
93
+ __metadata("design:paramtypes", [core_1.HttpAdapterHost])
94
+ ], TrustProxyBootCheck);
@@ -211,13 +211,29 @@ export declare const SHIELD_SCOPE_SUBTREE_RESOLVER: unique symbol;
211
211
  */
212
212
  export declare const SHIELD_MEMBERSHIP_CHECKER: unique symbol;
213
213
  /**
214
- * The password-login {@link AttemptLimiter} (spec §5 / IdP-hardening H1) — keyed
215
- * `login:<identityHint>`, default 10 failures / 15-min window. Consumed at the TOP
216
- * of `POST /auth/login` (`check` → generic 401 when locked) and on the outcome
217
- * (`recordFailure` on a rejected wrong-password; `reset` on `authenticated` OR
218
- * `mfa_required` — a correct password that merely needs MFA is NOT a failure).
214
+ * The password-login {@link AttemptLimiter} (spec §5 / IdP-hardening H1) — keyed per
215
+ * ACCOUNT: `login:pid:<principalId>` for the account the credential store resolves for the
216
+ * typed hint (the DB collation decides which spellings are one account), else
217
+ * `login:<sha256(canonical hint)>` for an unknown hint — no PII in the key; see
218
+ * `rate-limit/canonical-hint.ts`. Default 10 failures / 15-min window.
219
+ * Consumed at the TOP of `POST /auth/login` (`check` → generic 401 when locked) and on
220
+ * the outcome (`recordFailure` on a rejected wrong-password; `reset` on `authenticated`
221
+ * OR `mfa_required` — a correct password that merely needs MFA is NOT a failure).
219
222
  */
220
223
  export declare const SHIELD_LOGIN_LIMITER: unique symbol;
224
+ /**
225
+ * The password-login PER-CLIENT-IP {@link AttemptLimiter} (security hardening #5) —
226
+ * keyed `login:ip:<req.ip>`, default 50 failures / 15-min window (configurable via
227
+ * `LOGIN_IP_MAX_FAILURES` / `LOGIN_IP_WINDOW_MS`, see `AppConfig.loginIpLimit`). Caps
228
+ * password spraying across MANY distinct hints from one client. Counts wrong-password
229
+ * FAILURES only and is NEVER reset by a success (an attacker's own valid login must not
230
+ * wipe the budget). Checked before the account lookup and the authenticator. Fails open
231
+ * on a Redis blip (the limiter's posture). ENFORCED ONLY when the app set Express
232
+ * `trust proxy` explicitly (0 = no proxy, else the hop count): otherwise every client
233
+ * behind a proxy shares one `req.ip`, so the limiter is switched OFF at boot with a
234
+ * warning (`TrustProxyBootCheck`) rather than becoming a global login DoS.
235
+ */
236
+ export declare const SHIELD_LOGIN_IP_LIMITER: unique symbol;
221
237
  /**
222
238
  * The recovery-code {@link AttemptLimiter} (spec §5) — keyed `recovery:<principalId>`,
223
239
  * default 5 failures / 15-min window (recovery codes are higher-value than a rotating
@@ -226,6 +242,29 @@ export declare const SHIELD_LOGIN_LIMITER: unique symbol;
226
242
  * code, `reset` on success.
227
243
  */
228
244
  export declare const SHIELD_RECOVERY_LIMITER: unique symbol;
245
+ /**
246
+ * The TOTP-step {@link AttemptLimiter} (security hardening #9) — keyed per account,
247
+ * `mfa:pid:<principalId>`, default 5 failures / 15-min window. Consumed in the `/auth/mfa`
248
+ * TOTP branch: `check` BEFORE the factor is verified (locked → generic 401 without
249
+ * consulting the step-up), `recordFailure` on a rejected factor (the threshold edge audits
250
+ * `auth.mfa.locked` once), `reset` on success.
251
+ */
252
+ export declare const SHIELD_MFA_LIMITER: unique symbol;
253
+ /**
254
+ * The MFA step-up (security hardening M6) — `MfaStepUp.verify(challenge, credential)`
255
+ * verifies ONLY the second factor for the principal recorded by the password step (no
256
+ * password is held between the two requests). Default: `CredentialMfaStepUp` over the
257
+ * credential resolver, the login TOTP verifier and the identity broker. Absent → the
258
+ * `/auth/mfa` TOTP branch fails closed (generic 401).
259
+ */
260
+ export declare const SHIELD_MFA_STEP_UP: unique symbol;
261
+ /**
262
+ * The LOGIN `totp` {@link FactorVerifier}: the OR-across-devices `OrTotpVerifier` (with the
263
+ * Redis replay guard, M-B) wrapping the base verifier (the consumer `totpVerifier` override
264
+ * or the framework RFC-6238 one). ONE instance shared by the composed authenticator and the
265
+ * MFA step-up, so both paths verify — and replay-guard — identically.
266
+ */
267
+ export declare const SHIELD_LOGIN_TOTP_VERIFIER: unique symbol;
229
268
  /**
230
269
  * The forgot-password IP {@link AttemptLimiter} (feature #141) — keyed
231
270
  * `forgot:ip:<clientIp>`, default 5 per 15-min window. Consumed at the TOP of
@@ -233,14 +272,16 @@ export declare const SHIELD_RECOVERY_LIMITER: unique symbol;
233
272
  * would itself be an enumeration/probing signal), so this is a request-rate cap: the
234
273
  * handler counts EVERY forgot request against the IP budget and, once the budget is
235
274
  * exceeded, SILENTLY skips the email-send work while STILL returning 204 (the existing
236
- * always-204 anti-enumeration posture is preserved). Fail-open on a Redis blip. The
237
- * client IP is read from the TRUSTED `req.ip` (the consumer sets `trust proxy` so
238
- * X-Forwarded-For can't be spoofed).
275
+ * always-204 anti-enumeration posture is preserved). Fail-open on a Redis blip. Keyed on
276
+ * `req.ip`, so ENFORCED ONLY when the app set Express `trust proxy` explicitly; otherwise
277
+ * it is OFF (TrustProxyBootCheck), since a shared proxy IP would drop every reset email.
239
278
  */
240
279
  export declare const SHIELD_FORGOT_LIMITER: unique symbol;
241
280
  /**
242
281
  * The forgot-password PER-HINT {@link AttemptLimiter} (feature #141) — keyed
243
- * `forgot:<identityHint>`, default 3 per 60-min window (generous). Caps how often ANY
282
+ * per ACCOUNT, `forgot:pid:<principalId>` (unknown hint → `forgot:<sha256(canonical)>`),
283
+ * same PII-free scheme as the login key,
284
+ * default 3 per 60-min window (generous). Caps how often ANY
244
285
  * caller (across IPs) can trigger a reset email for ONE account, so a single victim
245
286
  * can't be email-bombed from a rotating IP pool. Same request-rate-cap + silent-skip-
246
287
  * still-204 semantics as {@link SHIELD_FORGOT_LIMITER}; consumed alongside it in
@@ -254,8 +295,9 @@ export declare const SHIELD_FORGOT_HINT_LIMITER: unique symbol;
254
295
  * 256-bit single-use token), so this is DoS/abuse throttling, NOT anti-brute-force —
255
296
  * and it MAY return a GENERIC 429 on lockout (a token-gated endpoint is not
256
297
  * enumeration-sensitive). `recordFailure` on a bad/invalid token (the abuse signal);
257
- * `reset` on a successful reset (204). Fail-open on a Redis blip. IP is the trusted
258
- * `req.ip`.
298
+ * `reset` on a successful reset (204). Fail-open on a Redis blip. Keyed on `req.ip`, so
299
+ * ENFORCED ONLY when the app set Express `trust proxy` explicitly; otherwise it is OFF
300
+ * (TrustProxyBootCheck), since a shared proxy IP would 429 every reset.
259
301
  */
260
302
  export declare const SHIELD_RESET_LIMITER: unique symbol;
261
303
  /**
@@ -267,4 +309,21 @@ export declare const SHIELD_RESET_LIMITER: unique symbol;
267
309
  * or version, which would fail-OPEN).
268
310
  */
269
311
  export declare const SHIELD_PERMISSION_FACTS: unique symbol;
312
+ /**
313
+ * The Redis permission-version cache (ADR-0035, GAPS #6) — a
314
+ * {@link import('../authz/permission-version-cache.js').RedisPermissionVersionCache} over
315
+ * {@link SHIELD_REDIS}. Provided and exported by `AuthModule` (`auth: true`). Consumed by the
316
+ * framework-built decision authority (gate 5 reads the version from Redis) and, through the
317
+ * `@Global` ShieldModule, by `@astram/identity`'s permission-version bumper (publish after commit).
318
+ * Absent (`auth: false`) → gate 5 keeps the app port's own version read.
319
+ */
320
+ export declare const SHIELD_PERMISSION_VERSION_CACHE: unique symbol;
321
+ /**
322
+ * The durable session-restriction store (Phase 3B review P1, ADR-0035 §3) — a
323
+ * {@link import('../session/session-restriction-store.js').SessionRestrictionStore}. Persists the
324
+ * recovery-login re-enroll restriction on the durable session and answers the refresh rebuild
325
+ * (restriction + account status). Provided by `AuthModule` over {@link SHIELD_DB_POOL}. Absent →
326
+ * the rebuild is refused (401) and recovery logins cannot be marked (they fail closed).
327
+ */
328
+ export declare const SHIELD_SESSION_RESTRICTIONS: unique symbol;
270
329
  //# sourceMappingURL=tokens.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC"}
@@ -23,7 +23,7 @@
23
23
  * ADR-0003: no framework/DB/crypto imports here — just Symbols (+ one re-export).
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
26
+ exports.SHIELD_SESSION_RESTRICTIONS = exports.SHIELD_PERMISSION_VERSION_CACHE = exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_LOGIN_TOTP_VERIFIER = exports.SHIELD_MFA_STEP_UP = exports.SHIELD_MFA_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_IP_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
27
27
  var argon2id_password_hasher_js_1 = require("../authentication/argon2id-password-hasher.js");
28
28
  Object.defineProperty(exports, "SHIELD_PASSWORD_HASHER", { enumerable: true, get: function () { return argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER; } });
29
29
  /**
@@ -230,13 +230,29 @@ exports.SHIELD_MEMBERSHIP_CHECKER = Symbol('SHIELD_MEMBERSHIP_CHECKER');
230
230
  // limiter degrades to "allow" (never a lock-out from missing wiring).
231
231
  // ---------------------------------------------------------------------------
232
232
  /**
233
- * The password-login {@link AttemptLimiter} (spec §5 / IdP-hardening H1) — keyed
234
- * `login:<identityHint>`, default 10 failures / 15-min window. Consumed at the TOP
235
- * of `POST /auth/login` (`check` → generic 401 when locked) and on the outcome
236
- * (`recordFailure` on a rejected wrong-password; `reset` on `authenticated` OR
237
- * `mfa_required` — a correct password that merely needs MFA is NOT a failure).
233
+ * The password-login {@link AttemptLimiter} (spec §5 / IdP-hardening H1) — keyed per
234
+ * ACCOUNT: `login:pid:<principalId>` for the account the credential store resolves for the
235
+ * typed hint (the DB collation decides which spellings are one account), else
236
+ * `login:<sha256(canonical hint)>` for an unknown hint — no PII in the key; see
237
+ * `rate-limit/canonical-hint.ts`. Default 10 failures / 15-min window.
238
+ * Consumed at the TOP of `POST /auth/login` (`check` → generic 401 when locked) and on
239
+ * the outcome (`recordFailure` on a rejected wrong-password; `reset` on `authenticated`
240
+ * OR `mfa_required` — a correct password that merely needs MFA is NOT a failure).
238
241
  */
239
242
  exports.SHIELD_LOGIN_LIMITER = Symbol('SHIELD_LOGIN_LIMITER');
243
+ /**
244
+ * The password-login PER-CLIENT-IP {@link AttemptLimiter} (security hardening #5) —
245
+ * keyed `login:ip:<req.ip>`, default 50 failures / 15-min window (configurable via
246
+ * `LOGIN_IP_MAX_FAILURES` / `LOGIN_IP_WINDOW_MS`, see `AppConfig.loginIpLimit`). Caps
247
+ * password spraying across MANY distinct hints from one client. Counts wrong-password
248
+ * FAILURES only and is NEVER reset by a success (an attacker's own valid login must not
249
+ * wipe the budget). Checked before the account lookup and the authenticator. Fails open
250
+ * on a Redis blip (the limiter's posture). ENFORCED ONLY when the app set Express
251
+ * `trust proxy` explicitly (0 = no proxy, else the hop count): otherwise every client
252
+ * behind a proxy shares one `req.ip`, so the limiter is switched OFF at boot with a
253
+ * warning (`TrustProxyBootCheck`) rather than becoming a global login DoS.
254
+ */
255
+ exports.SHIELD_LOGIN_IP_LIMITER = Symbol('SHIELD_LOGIN_IP_LIMITER');
240
256
  /**
241
257
  * The recovery-code {@link AttemptLimiter} (spec §5) — keyed `recovery:<principalId>`,
242
258
  * default 5 failures / 15-min window (recovery codes are higher-value than a rotating
@@ -245,6 +261,29 @@ exports.SHIELD_LOGIN_LIMITER = Symbol('SHIELD_LOGIN_LIMITER');
245
261
  * code, `reset` on success.
246
262
  */
247
263
  exports.SHIELD_RECOVERY_LIMITER = Symbol('SHIELD_RECOVERY_LIMITER');
264
+ /**
265
+ * The TOTP-step {@link AttemptLimiter} (security hardening #9) — keyed per account,
266
+ * `mfa:pid:<principalId>`, default 5 failures / 15-min window. Consumed in the `/auth/mfa`
267
+ * TOTP branch: `check` BEFORE the factor is verified (locked → generic 401 without
268
+ * consulting the step-up), `recordFailure` on a rejected factor (the threshold edge audits
269
+ * `auth.mfa.locked` once), `reset` on success.
270
+ */
271
+ exports.SHIELD_MFA_LIMITER = Symbol('SHIELD_MFA_LIMITER');
272
+ /**
273
+ * The MFA step-up (security hardening M6) — `MfaStepUp.verify(challenge, credential)`
274
+ * verifies ONLY the second factor for the principal recorded by the password step (no
275
+ * password is held between the two requests). Default: `CredentialMfaStepUp` over the
276
+ * credential resolver, the login TOTP verifier and the identity broker. Absent → the
277
+ * `/auth/mfa` TOTP branch fails closed (generic 401).
278
+ */
279
+ exports.SHIELD_MFA_STEP_UP = Symbol('SHIELD_MFA_STEP_UP');
280
+ /**
281
+ * The LOGIN `totp` {@link FactorVerifier}: the OR-across-devices `OrTotpVerifier` (with the
282
+ * Redis replay guard, M-B) wrapping the base verifier (the consumer `totpVerifier` override
283
+ * or the framework RFC-6238 one). ONE instance shared by the composed authenticator and the
284
+ * MFA step-up, so both paths verify — and replay-guard — identically.
285
+ */
286
+ exports.SHIELD_LOGIN_TOTP_VERIFIER = Symbol('SHIELD_LOGIN_TOTP_VERIFIER');
248
287
  /**
249
288
  * The forgot-password IP {@link AttemptLimiter} (feature #141) — keyed
250
289
  * `forgot:ip:<clientIp>`, default 5 per 15-min window. Consumed at the TOP of
@@ -252,14 +291,16 @@ exports.SHIELD_RECOVERY_LIMITER = Symbol('SHIELD_RECOVERY_LIMITER');
252
291
  * would itself be an enumeration/probing signal), so this is a request-rate cap: the
253
292
  * handler counts EVERY forgot request against the IP budget and, once the budget is
254
293
  * exceeded, SILENTLY skips the email-send work while STILL returning 204 (the existing
255
- * always-204 anti-enumeration posture is preserved). Fail-open on a Redis blip. The
256
- * client IP is read from the TRUSTED `req.ip` (the consumer sets `trust proxy` so
257
- * X-Forwarded-For can't be spoofed).
294
+ * always-204 anti-enumeration posture is preserved). Fail-open on a Redis blip. Keyed on
295
+ * `req.ip`, so ENFORCED ONLY when the app set Express `trust proxy` explicitly; otherwise
296
+ * it is OFF (TrustProxyBootCheck), since a shared proxy IP would drop every reset email.
258
297
  */
259
298
  exports.SHIELD_FORGOT_LIMITER = Symbol('SHIELD_FORGOT_LIMITER');
260
299
  /**
261
300
  * The forgot-password PER-HINT {@link AttemptLimiter} (feature #141) — keyed
262
- * `forgot:<identityHint>`, default 3 per 60-min window (generous). Caps how often ANY
301
+ * per ACCOUNT, `forgot:pid:<principalId>` (unknown hint → `forgot:<sha256(canonical)>`),
302
+ * same PII-free scheme as the login key,
303
+ * default 3 per 60-min window (generous). Caps how often ANY
263
304
  * caller (across IPs) can trigger a reset email for ONE account, so a single victim
264
305
  * can't be email-bombed from a rotating IP pool. Same request-rate-cap + silent-skip-
265
306
  * still-204 semantics as {@link SHIELD_FORGOT_LIMITER}; consumed alongside it in
@@ -273,8 +314,9 @@ exports.SHIELD_FORGOT_HINT_LIMITER = Symbol('SHIELD_FORGOT_HINT_LIMITER');
273
314
  * 256-bit single-use token), so this is DoS/abuse throttling, NOT anti-brute-force —
274
315
  * and it MAY return a GENERIC 429 on lockout (a token-gated endpoint is not
275
316
  * enumeration-sensitive). `recordFailure` on a bad/invalid token (the abuse signal);
276
- * `reset` on a successful reset (204). Fail-open on a Redis blip. IP is the trusted
277
- * `req.ip`.
317
+ * `reset` on a successful reset (204). Fail-open on a Redis blip. Keyed on `req.ip`, so
318
+ * ENFORCED ONLY when the app set Express `trust proxy` explicitly; otherwise it is OFF
319
+ * (TrustProxyBootCheck), since a shared proxy IP would 429 every reset.
278
320
  */
279
321
  exports.SHIELD_RESET_LIMITER = Symbol('SHIELD_RESET_LIMITER');
280
322
  /**
@@ -286,3 +328,20 @@ exports.SHIELD_RESET_LIMITER = Symbol('SHIELD_RESET_LIMITER');
286
328
  * or version, which would fail-OPEN).
287
329
  */
288
330
  exports.SHIELD_PERMISSION_FACTS = Symbol('SHIELD_PERMISSION_FACTS');
331
+ /**
332
+ * The Redis permission-version cache (ADR-0035, GAPS #6) — a
333
+ * {@link import('../authz/permission-version-cache.js').RedisPermissionVersionCache} over
334
+ * {@link SHIELD_REDIS}. Provided and exported by `AuthModule` (`auth: true`). Consumed by the
335
+ * framework-built decision authority (gate 5 reads the version from Redis) and, through the
336
+ * `@Global` ShieldModule, by `@astram/identity`'s permission-version bumper (publish after commit).
337
+ * Absent (`auth: false`) → gate 5 keeps the app port's own version read.
338
+ */
339
+ exports.SHIELD_PERMISSION_VERSION_CACHE = Symbol('SHIELD_PERMISSION_VERSION_CACHE');
340
+ /**
341
+ * The durable session-restriction store (Phase 3B review P1, ADR-0035 §3) — a
342
+ * {@link import('../session/session-restriction-store.js').SessionRestrictionStore}. Persists the
343
+ * recovery-login re-enroll restriction on the durable session and answers the refresh rebuild
344
+ * (restriction + account status). Provided by `AuthModule` over {@link SHIELD_DB_POOL}. Absent →
345
+ * the rebuild is refused (401) and recovery logins cannot be marked (they fail closed).
346
+ */
347
+ exports.SHIELD_SESSION_RESTRICTIONS = Symbol('SHIELD_SESSION_RESTRICTIONS');
@@ -1,8 +1,13 @@
1
1
  /**
2
- * LogEmailSender — the framework DEV-DEFAULT EmailSender. Does NOT deliver mail; it LOGS the outgoing
3
- * message (recipient + subject + link) so forgot/reset-password works end-to-end in development with no
4
- * external provider. A real SMTP/SendGrid sender stays a consumer override for production. Never throws
5
- * (a logging failure must not fail the forgot-password flow, which is fail-closed/no-op-safe by design).
2
+ * LogEmailSender — the framework DEV-ONLY EmailSender. Does NOT deliver mail; it LOGS the outgoing
3
+ * message (recipient + subject + the FULL text, i.e. the live `?token=` reset link) to stdout so
4
+ * forgot/reset-password works end-to-end in development with no external provider.
5
+ *
6
+ * ⛔ It prints usable reset tokens. AuthModule binds it by default ONLY when the RAW `NODE_ENV` is
7
+ * explicitly `development` or `test` (with a startup warning); in any other environment — including
8
+ * an UNSET `NODE_ENV` — auth composition fails unless the app supplies a real `emailSender` or
9
+ * `emailSender: 'disabled'` (security hardening #6). Never throws (a logging failure must not fail
10
+ * the forgot-password flow, which is fail-closed/no-op-safe by design).
6
11
  */
7
12
  import type { EmailSender, EmailMessage } from '../auth/ports/email-sender.js';
8
13
  export declare class LogEmailSender implements EmailSender {
@@ -1 +1 @@
1
- {"version":3,"file":"log-email-sender.d.ts","sourceRoot":"","sources":["../../src/authentication/log-email-sender.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAE/E,qBAAa,cAAe,YAAW,WAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAA4B;IACzE,IAAI,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;CAGjD"}
1
+ {"version":3,"file":"log-email-sender.d.ts","sourceRoot":"","sources":["../../src/authentication/log-email-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAE/E,qBAAa,cAAe,YAAW,WAAW;IACpC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAA4B;IACzE,IAAI,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;CAKjD"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * MariaDbApiKeyStore — the framework-DEFAULT {@link ApiKeyStore} (ADR-0033), backing
3
+ * service-account API-key authentication. Persisted in `shield_authz`.`service_account_key`.
4
+ *
5
+ * HASH-ONLY (like the reset-token / refresh-token stores): the raw secret is hashed (Argon2id, via
6
+ * the PasswordHasher) BEFORE it reaches this store — the store holds only `sak_secret_hash`, never
7
+ * the plaintext. A store breach yields no usable key.
8
+ *
9
+ * `getByAccountId` returns the CURRENT (highest-version, non-superseded) key record, or `null` for
10
+ * an unknown account (anti-enumeration — the authenticator maps null → a uniform failure). Its
11
+ * scope is the account's LIVE `shield_app.service_account.sa_scope_subtree` (JOINed), never the
12
+ * copy stored on the key at issue time; a disabled account, a missing account row, or an account
13
+ * whose tenant disagrees with the key's also yields `null` (security hardening Phase 1C). A genuine
14
+ * store OUTAGE (the `query` rejecting) PROPAGATES (the authenticator catches it → fail-closed), never
15
+ * swallowed to null. Every query is parameterized; the hash is never logged.
16
+ *
17
+ * The `issue`/`rotate`/`revoke` writer methods are provided for the admin lifecycle (mint + show the
18
+ * key once elsewhere; here we only persist the HASH + metadata).
19
+ */
20
+ import type { Pool } from 'mariadb';
21
+ import type { ApiKeyStore, ApiKeyRecord } from '@astram/core';
22
+ export declare class MariaDbApiKeyStore implements ApiKeyStore {
23
+ private readonly pool;
24
+ constructor(pool: Pool);
25
+ getByAccountId(accountId: string): Promise<ApiKeyRecord | null>;
26
+ /**
27
+ * Persist a NEW key version for an account (issue or rotate). Only the HASH is stored. `version`
28
+ * is monotonic per account; the caller mints the secret + computes the hash. Idempotency is on
29
+ * the natural key `(account, version)`.
30
+ */
31
+ putVersion(input: {
32
+ accountId: string;
33
+ tenantId: string;
34
+ scopeSubtreeNodeIds: readonly string[];
35
+ roleIds: readonly string[];
36
+ secretHash: string;
37
+ version: number;
38
+ expiresAtMs?: number;
39
+ createdAtMs: number;
40
+ }): Promise<void>;
41
+ /** The current highest version for an account (0 if none) — for computing the next on rotate. */
42
+ currentVersion(accountId: string): Promise<number>;
43
+ /** Revoke ALL of an account's keys (immediate fail-closed on the next request). Idempotent. */
44
+ revoke(accountId: string, atMs: number): Promise<void>;
45
+ }
46
+ //# sourceMappingURL=mariadb-api-key-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mariadb-api-key-store.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-api-key-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA6C9D,qBAAa,kBAAmB,YAAW,WAAW;IACxC,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IA8CrE;;;;OAIG;IACG,UAAU,CAAC,KAAK,EAAE;QACtB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;QACvC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;QAC3B,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,WAAW,EAAE,MAAM,CAAC;KACrB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBjB,iGAAiG;IAC3F,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQxD,+FAA+F;IACzF,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAQ7D"}
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MariaDbApiKeyStore = void 0;
4
+ const AUTHZ = 'shield_authz';
5
+ /** The account rows (`service_account`, created by the identity migration `…018`). */
6
+ const APP = 'shield_app';
7
+ /**
8
+ * Read a JSON-array column into a `string[]`. The mariadb driver returns a JSON/`longtext` column
9
+ * EITHER already-parsed (an array/object — its default for JSON columns) OR as a raw string,
10
+ * depending on driver/column config — so we handle BOTH: an array is filtered to its string
11
+ * elements directly; a string is `JSON.parse`d first. Anything else (or a parse failure) → `[]`
12
+ * (fail-safe: an unreadable scope/roles slot yields the empty set, never a crash or a wrong value).
13
+ */
14
+ function parseJsonArray(v) {
15
+ const arr = Array.isArray(v)
16
+ ? v
17
+ : typeof v === 'string'
18
+ ? (() => {
19
+ try {
20
+ return JSON.parse(v);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ })()
26
+ : [];
27
+ return Array.isArray(arr) ? arr.filter((x) => typeof x === 'string') : [];
28
+ }
29
+ class MariaDbApiKeyStore {
30
+ pool;
31
+ constructor(pool) {
32
+ this.pool = pool;
33
+ }
34
+ async getByAccountId(accountId) {
35
+ // The CURRENT key = highest version for the account. (Rotation inserts a new version; the old
36
+ // may linger for an overlap window but only the current is returned for auth.)
37
+ //
38
+ // LIVE SCOPE (security hardening Phase 1C): the key row keeps the scope COPIED at issue time,
39
+ // which goes stale the moment an admin narrows the account. So the account row is JOINed and
40
+ // its CURRENT `sa_scope_subtree` is the scope returned. An INNER JOIN: a key whose account row
41
+ // is gone matches nothing → null (deny). This is the same single per-request read as before
42
+ // (the machine path already reads this store on every `ask_…` request; it is not the session
43
+ // hot path of invariant #12), now one indexed PK join wider. `shield_core` holds SELECT on
44
+ // `shield_app.*`, so no grant change.
45
+ //
46
+ // LIVE ROLES (security hardening Phase 3, KI #22, ADR-0035): same reasoning for the roles — the
47
+ // key row's `sak_role_ids` is the issue-time copy; the account's CURRENT `sa_role_ids` (the same
48
+ // column gate 5's `readRoleIdsForTenant` reads) is what the principal carries, so role-keyed
49
+ // record rules, list scope and shares see a role change on the next request.
50
+ const rows = (await this.pool.query(`SELECT k.sak_account_id, k.sak_tenant_id, k.sak_role_ids,
51
+ k.sak_secret_hash, k.sak_version, k.sak_expires_at_ms, k.sak_revoked_at_ms,
52
+ sa.sa_scope_subtree, sa.sa_role_ids, sa.sa_status, sa.sa_tenant_id
53
+ FROM \`${AUTHZ}\`.\`service_account_key\` k
54
+ JOIN \`${APP}\`.\`service_account\` sa ON sa.sa_id = k.sak_account_id
55
+ WHERE k.sak_account_id = ?
56
+ ORDER BY k.sak_version DESC
57
+ LIMIT 1`, [accountId]));
58
+ const row = rows[0];
59
+ if (!row)
60
+ return null;
61
+ // Deny (the authenticator's uniform failure) when the account is not active, or when the
62
+ // account's tenant disagrees with the tenant the key was bound to — the key never carries a
63
+ // principal into a tenant its account is no longer in.
64
+ if (row.sa_status !== 'active')
65
+ return null;
66
+ if (row.sa_tenant_id !== row.sak_tenant_id)
67
+ return null;
68
+ return {
69
+ accountId: row.sak_account_id,
70
+ tenantId: row.sak_tenant_id,
71
+ scopeSubtreeNodeIds: parseJsonArray(row.sa_scope_subtree),
72
+ roleIds: parseJsonArray(row.sa_role_ids),
73
+ secretHash: row.sak_secret_hash,
74
+ version: Number(row.sak_version),
75
+ ...(row.sak_expires_at_ms !== null ? { expiresAtMs: Number(row.sak_expires_at_ms) } : {}),
76
+ ...(row.sak_revoked_at_ms !== null ? { revokedAtMs: Number(row.sak_revoked_at_ms) } : {}),
77
+ };
78
+ }
79
+ /**
80
+ * Persist a NEW key version for an account (issue or rotate). Only the HASH is stored. `version`
81
+ * is monotonic per account; the caller mints the secret + computes the hash. Idempotency is on
82
+ * the natural key `(account, version)`.
83
+ */
84
+ async putVersion(input) {
85
+ await this.pool.query(`INSERT INTO \`${AUTHZ}\`.\`service_account_key\`
86
+ (sak_account_id, sak_tenant_id, sak_scope_subtree, sak_role_ids,
87
+ sak_secret_hash, sak_version, sak_expires_at_ms, sak_revoked_at_ms, sak_created_at_ms)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`, [
89
+ input.accountId,
90
+ input.tenantId,
91
+ JSON.stringify([...input.scopeSubtreeNodeIds]),
92
+ JSON.stringify([...input.roleIds]),
93
+ input.secretHash,
94
+ input.version,
95
+ input.expiresAtMs ?? null,
96
+ input.createdAtMs,
97
+ ]);
98
+ }
99
+ /** The current highest version for an account (0 if none) — for computing the next on rotate. */
100
+ async currentVersion(accountId) {
101
+ const rows = (await this.pool.query(`SELECT MAX(sak_version) AS v FROM \`${AUTHZ}\`.\`service_account_key\` WHERE sak_account_id = ?`, [accountId]));
102
+ return rows[0]?.v != null ? Number(rows[0].v) : 0;
103
+ }
104
+ /** Revoke ALL of an account's keys (immediate fail-closed on the next request). Idempotent. */
105
+ async revoke(accountId, atMs) {
106
+ await this.pool.query(`UPDATE \`${AUTHZ}\`.\`service_account_key\`
107
+ SET sak_revoked_at_ms = ?
108
+ WHERE sak_account_id = ? AND sak_revoked_at_ms IS NULL`, [atMs, accountId]);
109
+ }
110
+ }
111
+ exports.MariaDbApiKeyStore = MariaDbApiKeyStore;