@astram/guard 0.1.1 → 0.3.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 (183) hide show
  1. package/dist/auth/account.controller.d.ts +245 -0
  2. package/dist/auth/account.controller.d.ts.map +1 -0
  3. package/dist/auth/account.controller.js +673 -0
  4. package/dist/auth/account.controller.js.map +1 -0
  5. package/dist/auth/auth.controller.d.ts +316 -0
  6. package/dist/auth/auth.controller.d.ts.map +1 -0
  7. package/dist/auth/auth.controller.js +1091 -0
  8. package/dist/auth/auth.controller.js.map +1 -0
  9. package/dist/auth/auth.module.d.ts +202 -0
  10. package/dist/auth/auth.module.d.ts.map +1 -0
  11. package/dist/auth/auth.module.js +610 -0
  12. package/dist/auth/auth.module.js.map +1 -0
  13. package/dist/auth/connections.d.ts +61 -0
  14. package/dist/auth/connections.d.ts.map +1 -0
  15. package/dist/auth/connections.js +116 -0
  16. package/dist/auth/connections.js.map +1 -0
  17. package/dist/auth/dto.d.ts +154 -0
  18. package/dist/auth/dto.d.ts.map +1 -0
  19. package/dist/auth/dto.js +29 -0
  20. package/dist/auth/dto.js.map +1 -0
  21. package/dist/auth/pending-mfa-store.d.ts +72 -0
  22. package/dist/auth/pending-mfa-store.d.ts.map +1 -0
  23. package/dist/auth/pending-mfa-store.js +106 -0
  24. package/dist/auth/pending-mfa-store.js.map +1 -0
  25. package/dist/auth/persistence.module.d.ts +42 -0
  26. package/dist/auth/persistence.module.d.ts.map +1 -0
  27. package/dist/auth/persistence.module.js +94 -0
  28. package/dist/auth/persistence.module.js.map +1 -0
  29. package/dist/auth/ports/audit-sink.d.ts +47 -0
  30. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  31. package/dist/auth/ports/audit-sink.js +20 -0
  32. package/dist/auth/ports/audit-sink.js.map +1 -0
  33. package/dist/auth/ports/credential-writer.d.ts +23 -0
  34. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  35. package/dist/auth/ports/credential-writer.js +2 -0
  36. package/dist/auth/ports/credential-writer.js.map +1 -0
  37. package/dist/auth/ports/email-sender.d.ts +14 -0
  38. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  39. package/dist/auth/ports/email-sender.js +2 -0
  40. package/dist/auth/ports/email-sender.js.map +1 -0
  41. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  42. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  43. package/dist/auth/ports/mfa-device-store.js +13 -0
  44. package/dist/auth/ports/mfa-device-store.js.map +1 -0
  45. package/dist/auth/ports/profile-writer.d.ts +12 -0
  46. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  47. package/dist/auth/ports/profile-writer.js +2 -0
  48. package/dist/auth/ports/profile-writer.js.map +1 -0
  49. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  50. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  51. package/dist/auth/ports/recovery-code-store.js +2 -0
  52. package/dist/auth/ports/recovery-code-store.js.map +1 -0
  53. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  54. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  55. package/dist/auth/ports/reset-token-store.js +2 -0
  56. package/dist/auth/ports/reset-token-store.js.map +1 -0
  57. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  58. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  59. package/dist/auth/ports/session-facts-projector.js +30 -0
  60. package/dist/auth/ports/session-facts-projector.js.map +1 -0
  61. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  62. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  63. package/dist/auth/ports/session-sweeper.js +2 -0
  64. package/dist/auth/ports/session-sweeper.js.map +1 -0
  65. package/dist/auth/profile-resolver.d.ts +73 -0
  66. package/dist/auth/profile-resolver.d.ts.map +1 -0
  67. package/dist/auth/profile-resolver.js +28 -0
  68. package/dist/auth/profile-resolver.js.map +1 -0
  69. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  70. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  71. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  72. package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
  73. package/dist/auth/tokens.d.ts +236 -0
  74. package/dist/auth/tokens.d.ts.map +1 -0
  75. package/dist/auth/tokens.js +254 -0
  76. package/dist/auth/tokens.js.map +1 -0
  77. package/dist/auth/totp-enrollment.d.ts +42 -0
  78. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  79. package/dist/auth/totp-enrollment.js +96 -0
  80. package/dist/auth/totp-enrollment.js.map +1 -0
  81. package/dist/authentication/argon2id-password-hasher.js.map +1 -0
  82. package/dist/authentication/mariadb-credential-resolver.d.ts +110 -0
  83. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
  84. package/dist/authentication/mariadb-credential-resolver.js +170 -0
  85. package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
  86. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  87. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  88. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  89. package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
  90. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  91. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  92. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  93. package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
  94. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  95. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  96. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  97. package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
  98. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  99. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  100. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  101. package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
  102. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  103. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  104. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  105. package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
  106. package/dist/authz/record-share-cascade.d.ts +52 -0
  107. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  108. package/dist/authz/record-share-cascade.js +49 -0
  109. package/dist/authz/record-share-cascade.js.map +1 -0
  110. package/dist/authz/record-share-writer.d.ts +175 -0
  111. package/dist/authz/record-share-writer.d.ts.map +1 -0
  112. package/dist/authz/record-share-writer.js +225 -0
  113. package/dist/authz/record-share-writer.js.map +1 -0
  114. package/dist/authz/wire-record-authorization.d.ts +66 -0
  115. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  116. package/dist/authz/wire-record-authorization.js +34 -0
  117. package/dist/authz/wire-record-authorization.js.map +1 -0
  118. package/dist/boot/boot-sequence.js.map +1 -0
  119. package/dist/boot/boot.module.js.map +1 -0
  120. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  121. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  122. package/dist/completeness/catalog-reconciler.js +74 -0
  123. package/dist/completeness/catalog-reconciler.js.map +1 -0
  124. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  125. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  126. package/dist/completeness/operation-inventory-scanner.js +79 -0
  127. package/dist/completeness/operation-inventory-scanner.js.map +1 -0
  128. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  129. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  130. package/dist/completeness/ports/catalog-store.js +2 -0
  131. package/dist/completeness/ports/catalog-store.js.map +1 -0
  132. package/dist/config/app-config.d.ts +134 -1
  133. package/dist/config/app-config.d.ts.map +1 -1
  134. package/dist/config/app-config.js +144 -2
  135. package/dist/config/app-config.js.map +1 -0
  136. package/dist/config/config.module.js.map +1 -0
  137. package/dist/enforcement/classification.decorator.d.ts +33 -14
  138. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  139. package/dist/enforcement/classification.decorator.js +34 -14
  140. package/dist/enforcement/classification.decorator.js.map +1 -0
  141. package/dist/enforcement/core-seam.js.map +1 -0
  142. package/dist/enforcement/enforcement.module.js.map +1 -0
  143. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  144. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  145. package/dist/enforcement/global-enforcement.guard.js +108 -25
  146. package/dist/enforcement/global-enforcement.guard.js.map +1 -0
  147. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  148. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  149. package/dist/enforcement/reenroll-allowlist.js +77 -0
  150. package/dist/enforcement/reenroll-allowlist.js.map +1 -0
  151. package/dist/index.d.ts +43 -3
  152. package/dist/index.d.ts.map +1 -1
  153. package/dist/index.js +198 -1
  154. package/dist/index.js.map +1 -0
  155. package/dist/module/shield-tokens.d.ts +15 -0
  156. package/dist/module/shield-tokens.d.ts.map +1 -0
  157. package/dist/module/shield-tokens.js +17 -0
  158. package/dist/module/shield-tokens.js.map +1 -0
  159. package/dist/module/shield.module.d.ts +203 -8
  160. package/dist/module/shield.module.d.ts.map +1 -1
  161. package/dist/module/shield.module.js +232 -14
  162. package/dist/module/shield.module.js.map +1 -0
  163. package/dist/request-auth/request-principal.d.ts +42 -0
  164. package/dist/request-auth/request-principal.d.ts.map +1 -0
  165. package/dist/request-auth/request-principal.js +87 -0
  166. package/dist/request-auth/request-principal.js.map +1 -0
  167. package/dist/request-auth/session-facts-als.d.ts +65 -0
  168. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  169. package/dist/request-auth/session-facts-als.js +57 -0
  170. package/dist/request-auth/session-facts-als.js.map +1 -0
  171. package/dist/request-auth/session-token-binder.d.ts +130 -0
  172. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  173. package/dist/request-auth/session-token-binder.js +277 -0
  174. package/dist/request-auth/session-token-binder.js.map +1 -0
  175. package/dist/request-auth/tokens.d.ts +14 -0
  176. package/dist/request-auth/tokens.d.ts.map +1 -0
  177. package/dist/request-auth/tokens.js +16 -0
  178. package/dist/request-auth/tokens.js.map +1 -0
  179. package/dist/session/crypto-adapters.js.map +1 -0
  180. package/dist/session/index.js.map +1 -0
  181. package/dist/session/mariadb-session-record-store.js.map +1 -0
  182. package/dist/session/redis-hot-session-store.js.map +1 -0
  183. package/package.json +2 -2
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ /**
3
+ * Request-principal resolution — extract the bearer access token from an HTTP
4
+ * request and resolve it (via the {@link SessionTokenBinder}) to a `Principal`,
5
+ * fail-closed. This is the request-auth read path the enforcement guard uses for
6
+ * `@Classified` routes before calling `decide()`.
7
+ *
8
+ * The `Principal` is rebuilt from the bound claims. We deliberately do NOT re-run
9
+ * the Core identity broker here (which would apply the root allowlist and could
10
+ * mint a root from an id) — instead we reconstruct a plain, non-root
11
+ * Principal from the exact claims captured at login. `isRoot` is set to
12
+ * `false` on the request path unless the binding explicitly recorded it (it is
13
+ * captured from the login-time Principal, so a genuinely-root login stays
14
+ * root; a normal login can never become one on a later request).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.extractBearerToken = extractBearerToken;
18
+ exports.principalFromClaims = principalFromClaims;
19
+ exports.resolveRequestPrincipal = resolveRequestPrincipal;
20
+ exports.resolveRequestAuth = resolveRequestAuth;
21
+ /** Valid `Principal.kind` values (mirror of the Core union; validated at rebuild). */
22
+ const PRINCIPAL_KINDS = new Set(['user', 'role_group', 'service_account']);
23
+ /** Extract a bearer token from an `Authorization: Bearer <token>` header. */
24
+ function extractBearerToken(authorizationHeader) {
25
+ if (typeof authorizationHeader !== 'string')
26
+ return null;
27
+ const m = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
28
+ const token = m?.[1]?.trim();
29
+ return token && token.length > 0 ? token : null;
30
+ }
31
+ /**
32
+ * Rebuild a `Principal` from bound claims (validated). `isRoot` is read
33
+ * from the binding (captured at login), NEVER inferred — a normal user cannot become
34
+ * a root on the request path.
35
+ */
36
+ function principalFromClaims(claims) {
37
+ if (typeof claims.id !== 'string' ||
38
+ claims.id.length === 0 ||
39
+ typeof claims.tenantId !== 'string' ||
40
+ claims.tenantId.length === 0) {
41
+ return null;
42
+ }
43
+ const kind = PRINCIPAL_KINDS.has(claims.kind) ? claims.kind : 'user';
44
+ const roleIds = Array.isArray(claims.roleIds)
45
+ ? claims.roleIds.filter((r) => typeof r === 'string')
46
+ : [];
47
+ return Object.freeze({
48
+ kind,
49
+ id: claims.id,
50
+ tenantId: claims.tenantId,
51
+ roleIds: Object.freeze([...roleIds]),
52
+ isRoot: claims.isRoot === true,
53
+ });
54
+ }
55
+ /**
56
+ * Resolve an HTTP request's bearer token to a `Principal`, or `null` (fail-closed)
57
+ * when there is no token, no binding, or the binding is malformed.
58
+ */
59
+ async function resolveRequestPrincipal(authorizationHeader, binder) {
60
+ const token = extractBearerToken(authorizationHeader);
61
+ if (token === null)
62
+ return null;
63
+ const claims = await binder.resolve(token);
64
+ if (claims === null)
65
+ return null;
66
+ return principalFromClaims(claims);
67
+ }
68
+ /**
69
+ * Resolve an HTTP request's bearer token to BOTH a `Principal` AND the raw
70
+ * {@link BoundPrincipalClaims}, or `null` (fail-closed) — the same read path as
71
+ * {@link resolveRequestPrincipal}, but it also surfaces the claims the `Principal`
72
+ * projection drops (notably `mustReenroll` and `sessionId`). The enforcement guard
73
+ * uses this so it can read `claims.mustReenroll` to apply the enforced-re-enroll gate
74
+ * while still attaching the resolved `Principal` for `decide()`.
75
+ */
76
+ async function resolveRequestAuth(authorizationHeader, binder) {
77
+ const token = extractBearerToken(authorizationHeader);
78
+ if (token === null)
79
+ return null;
80
+ const claims = await binder.resolve(token);
81
+ if (claims === null)
82
+ return null;
83
+ const principal = principalFromClaims(claims);
84
+ if (principal === null)
85
+ return null;
86
+ return { principal, claims };
87
+ }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-principal.js","sourceRoot":"","sources":["../../src/request-auth/request-principal.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;;AASH,gDAKC;AAOD,kDAoBC;AAMD,0DASC;AAUD,gDAWC;AAxED,sFAAsF;AACtF,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,YAAY,EAAE,iBAAiB,CAAC,CAAC,CAAC;AAE3E,6EAA6E;AAC7E,SAAgB,kBAAkB,CAAC,mBAA4B;IAC7D,IAAI,OAAO,mBAAmB,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IACzD,MAAM,CAAC,GAAG,kBAAkB,CAAC,IAAI,CAAC,mBAAmB,CAAC,IAAI,EAAE,CAAC,CAAC;IAC9D,MAAM,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IAC7B,OAAO,KAAK,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,mBAAmB,CAAC,MAA4B;IAC9D,IACE,OAAO,MAAM,CAAC,EAAE,KAAK,QAAQ;QAC7B,MAAM,CAAC,EAAE,CAAC,MAAM,KAAK,CAAC;QACtB,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ;QACnC,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK,CAAC,EAC5B,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,IAAI,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,MAAM,CAAC,IAA0B,CAAC,CAAC,CAAC,MAAM,CAAC;IAC5F,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC;QAC3C,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC;QAClE,CAAC,CAAC,EAAE,CAAC;IACP,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,IAAI;QACJ,EAAE,EAAE,MAAM,CAAC,EAAE;QACb,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC;QACpC,MAAM,EAAE,MAAM,CAAC,MAAM,KAAK,IAAI;KAC/B,CAAC,CAAC;AACL,CAAC;AAED;;;GAGG;AACI,KAAK,UAAU,uBAAuB,CAC3C,mBAA4B,EAC5B,MAA0B;IAE1B,MAAM,KAAK,GAAG,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;IACtD,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACjC,OAAO,mBAAmB,CAAC,MAAM,CAAC,CAAC;AACrC,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,kBAAkB,CACtC,mBAA4B,EAC5B,MAA0B;IAE1B,MAAM,KAAK,GAAG,kBAAkB,CAAC,mBAAmB,CAAC,CAAC;IACtD,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,SAAS,GAAG,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAC9C,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACpC,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC;AAC/B,CAAC"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The per-request **session-facts AsyncLocalStorage** (ADR-0011 hot path) — the
3
+ * SINGLE shared instance the enforcement guard ENTERS and the consumer's
4
+ * `SessionFactsReader` READS. This is the seam that lets gate 5 serve a principal's
5
+ * coarse grants from the request's cached facts instead of a DB reload on every
6
+ * request, WITHOUT touching the frozen 4-arg `decide()` signature or the core
7
+ * `SessionFactsReader` dep contract.
8
+ *
9
+ * ── WHY A FRAMEWORK-EXPORTED SINGLETON (not DI-injected) ─────────────────────────
10
+ * The guard (framework) must ENTER the scope; the reader (app-owned — astram3 swaps
11
+ * `nullSessionFacts` for an ALS-backed one) must READ the SAME scope. An
12
+ * `AsyncLocalStorage` only shares state across `run()`/`getStore()` calls on the
13
+ * SAME instance, so both sides MUST hold one identical object. Exporting ONE module
14
+ * singleton and importing it on both sides is the simplest way to guarantee that —
15
+ * no DI token round-trip, no risk of two divergent instances. It is a pure in-memory
16
+ * request-scoping primitive (no I/O, no secret), so a shared module-level singleton
17
+ * is safe and idiomatic (this is exactly how Nest's own `ClsService`-style helpers
18
+ * and request-context libraries share an ALS).
19
+ *
20
+ * ── CROSS-PRINCIPAL ISOLATION (the #1 danger) ────────────────────────────────────
21
+ * `AsyncLocalStorage.run(store, cb)` binds `store` to the async execution context of
22
+ * `cb` and everything it awaits — and ONLY that context. Two concurrent requests each
23
+ * call `run()` with THEIR OWN store, so `getStore()` inside each returns that request's
24
+ * facts and never the other's. The guard enters the scope with THIS request's
25
+ * `claims.cachedFacts` (same bearer token → same principal → same facts) wrapping the
26
+ * `decide()` call, so the facts a gate reads are always the current request's. The
27
+ * reader holds NO per-request state on `this` — only the ALS handle — so nothing leaks
28
+ * across requests through the reader object itself.
29
+ *
30
+ * ── FAIL-CLOSED ──────────────────────────────────────────────────────────────────
31
+ * Outside any `run()` scope (or when the guard entered with `cachedFacts: null` because
32
+ * the binding carried none / was malformed), `getStore()` is `undefined` / the store's
33
+ * `cachedFacts` is `null`. The reader maps BOTH to `{ cachedFacts: null,
34
+ * permissionsVersion: 0 }`, so gate 5 falls back to the authoritative DB reload
35
+ * (`PermissionFactsPort.loadCoarseGrants`). A cache miss NEVER fabricates a permit.
36
+ */
37
+ import { AsyncLocalStorage } from 'node:async_hooks';
38
+ import type { CoarseGrants } from '@astram/core';
39
+ /**
40
+ * The per-request session-facts store carried through the ALS. `cachedFacts` is the
41
+ * principal's coarse grants for THIS request (or `null` when none were projected →
42
+ * gate 5 DB-reloads); `permissionsVersion` is the version those facts were projected
43
+ * at (0 when absent). Mirrors the core `SessionFactsReader` shape 1:1.
44
+ */
45
+ export interface SessionFactsStore {
46
+ /** THIS request's coarse grants (ADR-0011), or `null` → gate 5 reloads via the port. */
47
+ readonly cachedFacts: CoarseGrants | null;
48
+ /** The version `cachedFacts` was projected at (0 when absent). */
49
+ readonly permissionsVersion: number;
50
+ }
51
+ /**
52
+ * The ONE shared session-facts `AsyncLocalStorage` (see the file header). The
53
+ * enforcement guard imports this to `run()` the `decide()` call inside the request's
54
+ * scope; the consumer's ALS-backed `SessionFactsReader` imports the SAME instance to
55
+ * `getStore()`. Exported through the guard barrel so the app reads the identical
56
+ * singleton — never a second, divergent ALS.
57
+ */
58
+ export declare const SHIELD_SESSION_FACTS_ALS: AsyncLocalStorage<SessionFactsStore>;
59
+ /**
60
+ * The fail-closed "no facts" store — what a reader returns when there is no active
61
+ * scope or the scope carried no facts. Kept here as the single source of the
62
+ * fail-closed shape so the guard and the reader agree byte-for-byte.
63
+ */
64
+ export declare const NO_SESSION_FACTS: SessionFactsStore;
65
+ //# sourceMappingURL=session-facts-als.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-facts-als.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-facts-als.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAIrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,EAAE,YAAY,GAAG,IAAI,CAAC;IAC1C,kEAAkE;IAClE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACrC;AAED;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,sCAA6C,CAAC;AAEnF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,EAAE,iBAG7B,CAAC"}
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * The per-request **session-facts AsyncLocalStorage** (ADR-0011 hot path) — the
4
+ * SINGLE shared instance the enforcement guard ENTERS and the consumer's
5
+ * `SessionFactsReader` READS. This is the seam that lets gate 5 serve a principal's
6
+ * coarse grants from the request's cached facts instead of a DB reload on every
7
+ * request, WITHOUT touching the frozen 4-arg `decide()` signature or the core
8
+ * `SessionFactsReader` dep contract.
9
+ *
10
+ * ── WHY A FRAMEWORK-EXPORTED SINGLETON (not DI-injected) ─────────────────────────
11
+ * The guard (framework) must ENTER the scope; the reader (app-owned — astram3 swaps
12
+ * `nullSessionFacts` for an ALS-backed one) must READ the SAME scope. An
13
+ * `AsyncLocalStorage` only shares state across `run()`/`getStore()` calls on the
14
+ * SAME instance, so both sides MUST hold one identical object. Exporting ONE module
15
+ * singleton and importing it on both sides is the simplest way to guarantee that —
16
+ * no DI token round-trip, no risk of two divergent instances. It is a pure in-memory
17
+ * request-scoping primitive (no I/O, no secret), so a shared module-level singleton
18
+ * is safe and idiomatic (this is exactly how Nest's own `ClsService`-style helpers
19
+ * and request-context libraries share an ALS).
20
+ *
21
+ * ── CROSS-PRINCIPAL ISOLATION (the #1 danger) ────────────────────────────────────
22
+ * `AsyncLocalStorage.run(store, cb)` binds `store` to the async execution context of
23
+ * `cb` and everything it awaits — and ONLY that context. Two concurrent requests each
24
+ * call `run()` with THEIR OWN store, so `getStore()` inside each returns that request's
25
+ * facts and never the other's. The guard enters the scope with THIS request's
26
+ * `claims.cachedFacts` (same bearer token → same principal → same facts) wrapping the
27
+ * `decide()` call, so the facts a gate reads are always the current request's. The
28
+ * reader holds NO per-request state on `this` — only the ALS handle — so nothing leaks
29
+ * across requests through the reader object itself.
30
+ *
31
+ * ── FAIL-CLOSED ──────────────────────────────────────────────────────────────────
32
+ * Outside any `run()` scope (or when the guard entered with `cachedFacts: null` because
33
+ * the binding carried none / was malformed), `getStore()` is `undefined` / the store's
34
+ * `cachedFacts` is `null`. The reader maps BOTH to `{ cachedFacts: null,
35
+ * permissionsVersion: 0 }`, so gate 5 falls back to the authoritative DB reload
36
+ * (`PermissionFactsPort.loadCoarseGrants`). A cache miss NEVER fabricates a permit.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.NO_SESSION_FACTS = exports.SHIELD_SESSION_FACTS_ALS = void 0;
40
+ const node_async_hooks_1 = require("node:async_hooks");
41
+ /**
42
+ * The ONE shared session-facts `AsyncLocalStorage` (see the file header). The
43
+ * enforcement guard imports this to `run()` the `decide()` call inside the request's
44
+ * scope; the consumer's ALS-backed `SessionFactsReader` imports the SAME instance to
45
+ * `getStore()`. Exported through the guard barrel so the app reads the identical
46
+ * singleton — never a second, divergent ALS.
47
+ */
48
+ exports.SHIELD_SESSION_FACTS_ALS = new node_async_hooks_1.AsyncLocalStorage();
49
+ /**
50
+ * The fail-closed "no facts" store — what a reader returns when there is no active
51
+ * scope or the scope carried no facts. Kept here as the single source of the
52
+ * fail-closed shape so the guard and the reader agree byte-for-byte.
53
+ */
54
+ exports.NO_SESSION_FACTS = Object.freeze({
55
+ cachedFacts: null,
56
+ permissionsVersion: 0,
57
+ });
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-facts-als.js","sourceRoot":"","sources":["../../src/request-auth/session-facts-als.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;;;AAEH,uDAAqD;AAmBrD;;;;;;GAMG;AACU,QAAA,wBAAwB,GAAG,IAAI,oCAAiB,EAAqB,CAAC;AAEnF;;;;GAIG;AACU,QAAA,gBAAgB,GAAsB,MAAM,CAAC,MAAM,CAAC;IAC/D,WAAW,EAAE,IAAI;IACjB,kBAAkB,EAAE,CAAC;CACtB,CAAC,CAAC"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * SessionTokenBinder — binds an opaque access token to its session's principal
3
+ * claims, so the request-auth layer can turn a bearer token on a later request
4
+ * back into a `Principal`.
5
+ *
6
+ * WHY THIS EXISTS (the flagged framework gap): the Core session layer mints an
7
+ * OPAQUE access token and never stores it — the hot session record is keyed by
8
+ * `sessionId` and carries only `principalId` + `tenantId` (no roles). There is no
9
+ * access-token → session mapping. Rather than change the frozen Core session
10
+ * contract (Article 1), this guard-side adapter writes a SEPARATE binding entry at
11
+ * establish/rotate time and reads it per request. Zero Core changes.
12
+ *
13
+ * The binding: `shield:at:<accessToken>` → JSON {@link BoundPrincipalClaims}
14
+ * ({ id, tenantId, roleIds, kind }), TTL = the access-token lifetime. The token
15
+ * stays fully opaque (carries no data itself); the mapping is server-side, so the
16
+ * session is server-REVOCABLE (drop the key) and the read is FAIL-CLOSED (absent or
17
+ * Redis-unavailable → no principal → the guard denies).
18
+ *
19
+ * ⛔ SECURITY: the access token is used only as a Redis key here; it is never logged
20
+ * and the stored value carries no secret (claims only — ids, not credentials).
21
+ */
22
+ import type Redis from 'ioredis';
23
+ import type { CoarseGrants } from '@astram/core';
24
+ /** The principal claims carried in the access-token binding (enough to re-mint). */
25
+ export interface BoundPrincipalClaims {
26
+ readonly id: string;
27
+ readonly tenantId: string;
28
+ readonly roleIds: readonly string[];
29
+ readonly kind: string;
30
+ /**
31
+ * Root status, captured from the LOGIN-TIME Principal (the identity broker's
32
+ * server-side allowlist decided it at login). Carried here so a genuine root
33
+ * stays root on later requests, and — critically — a normal user can NEVER
34
+ * become one on the request path (it is read from the binding, never inferred).
35
+ */
36
+ readonly isRoot: boolean;
37
+ /** The bound session id (for server-side revoke correlation). */
38
+ readonly sessionId: string;
39
+ /**
40
+ * The ENFORCED-RE-ENROLL flag (spec §4). `true` when the session was established
41
+ * via a lower-assurance recovery-code login: the enforcement gate then restricts
42
+ * the session to the "re-secure" allowlist until a fresh device is confirmed.
43
+ * A normal (TOTP/password) login binds `false`.
44
+ *
45
+ * ⚠ AUTHORITATIVE SOURCE = the SESSION-CLAIMS entry, not the per-access-token
46
+ * entry. `resolve()` reads this field from the session-claims entry (which it
47
+ * already fetches for the liveness gate) so that {@link SessionTokenBinder.clearReenroll}
48
+ * — which can only key by `sessionId` — flips it for EVERY live access token of the
49
+ * session at once (device-confirm re-secures the whole session, not one token). The
50
+ * access token itself is never re-minted; only this bound claim changes.
51
+ */
52
+ readonly mustReenroll: boolean;
53
+ /**
54
+ * OPTIONAL cached coarse grants (ADR-0011 eager-projection hot path). When the auth
55
+ * surface has a {@link import('../auth/ports/session-facts-projector.js').SessionFactsProjector}
56
+ * wired, it computes the principal's {@link CoarseGrants} at login/refresh and stores
57
+ * them HERE, so the enforcement guard can hand them to gate 5 as the request's
58
+ * `cachedFacts` (serving the coarse decision from cache instead of a DB reload when the
59
+ * cached version is still fresh).
60
+ *
61
+ * ⛔ ABSENT (undefined) is the DEFAULT and the fail-closed state: no projector wired,
62
+ * a legacy/old binding, or a malformed slot → `resolve()` returns claims WITHOUT this
63
+ * field → the guard enters the ALS with `cachedFacts: null` → gate 5 DB-reloads (today's
64
+ * behavior). It is NEVER inferred; only a genuine projection sets it. A missing/malformed
65
+ * `cachedFacts` slot is treated as absent (see `resolve()`), never fabricated.
66
+ */
67
+ readonly cachedFacts?: CoarseGrants;
68
+ }
69
+ export declare class SessionTokenBinder {
70
+ private readonly redis;
71
+ constructor(redis: Redis);
72
+ /**
73
+ * Bind an access token to its principal claims for `ttlMs` (the access-token
74
+ * lifetime). Called right after `SessionAuthority.establish` in the auth
75
+ * controller (it holds both the freshly-minted access token and the Principal).
76
+ * ALSO writes a session-id → claims entry so a later `rebindOnRotate` can carry the
77
+ * full claims (roles/kind/root) forward across a token rotation, where the
78
+ * durable session row alone lacks them.
79
+ */
80
+ bind(accessToken: string, claims: BoundPrincipalClaims, ttlMs: number): Promise<void>;
81
+ /**
82
+ * On refresh/rotate the controller has the NEW access token + the session, but not
83
+ * the full Principal (roles/kind/root live only in the binding, not the
84
+ * durable session row). Re-bind by reading the session-claims entry and writing the
85
+ * new access-token binding with the SAME claims. Returns true if it carried claims
86
+ * forward, false if the session-claims entry was gone (then the caller has nothing
87
+ * to bind — a later request will 401 and the user re-logs in; fail-closed).
88
+ *
89
+ * `freshFacts` (ADR-0011 eager projection at REFRESH):
90
+ * - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
91
+ * already had FORWARD unchanged (no re-projection). Existing callers that pass no
92
+ * facts behave EXACTLY as before.
93
+ * - a {@link CoarseGrants} → REPLACE `cachedFacts` with this freshly-projected value
94
+ * (a refresh re-reads the current grants so the cache stays warm past the version
95
+ * the login projected at).
96
+ * - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
97
+ * request DB-reloads; fail-closed).
98
+ */
99
+ rebindOnRotate(newAccessToken: string, sessionId: string, ttlMs: number, freshFacts?: CoarseGrants | null): Promise<boolean>;
100
+ /**
101
+ * Resolve an access token to its bound claims, or `null` if the binding is
102
+ * absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
103
+ * a thrown 500 that could be mistaken for allow. The token is opaque; an unknown
104
+ * token simply has no key.
105
+ */
106
+ resolve(accessToken: string): Promise<BoundPrincipalClaims | null>;
107
+ /**
108
+ * Clear the ENFORCED-RE-ENROLL flag for a session — the re-secure is complete
109
+ * (a fresh device was confirmed, spec §4.3). Rewrites the session-claims entry with
110
+ * `mustReenroll: false`, preserving its remaining TTL, so the LIVE access token's
111
+ * next {@link SessionTokenBinder.resolve} sees `false` (the token is NOT re-minted;
112
+ * only the bound claim flips). Keyed by `sessionId` because the caller (the account
113
+ * controller on device-confirm) has the session id from its own claims, and this must
114
+ * apply to every outstanding access token of the session at once.
115
+ *
116
+ * Best-effort + fail-closed on the LOAD: if the session-claims entry is gone (logged
117
+ * out) or Redis is unavailable, there is nothing to clear (the session is already
118
+ * dead / will re-deny) — we simply return. Idempotent.
119
+ */
120
+ clearReenroll(sessionId: string): Promise<void>;
121
+ /** Drop an access-token binding. Idempotent. */
122
+ unbind(accessToken: string): Promise<void>;
123
+ /**
124
+ * Drop the session-claims entry on logout/revoke, so a rotate after logout cannot
125
+ * re-mint a binding (defense-in-depth: the session is already revoked, but this
126
+ * removes the claims cushion too). The per-access-token bindings expire by TTL.
127
+ */
128
+ unbindSession(sessionId: string): Promise<void>;
129
+ }
130
+ //# sourceMappingURL=session-token-binder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-token-binder.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAKjC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,oFAAoF;AACpF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,iEAAiE;IACjE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,YAAY,CAAC;CACrC;AA0CD,qBAAa,kBAAkB;IACjB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAEzC;;;;;;;OAOG;IACG,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,oBAAoB,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ3F;;;;;;;;;;;;;;;;;OAiBG;IACG,cAAc,CAClB,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,UAAU,CAAC,EAAE,YAAY,GAAG,IAAI,GAC/B,OAAO,CAAC,OAAO,CAAC;IAgCnB;;;;;OAKG;IACG,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA8DxE;;;;;;;;;;;;OAYG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA+BrD,gDAAgD;IAC1C,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQhD;;;;OAIG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOtD"}
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ /**
3
+ * SessionTokenBinder — binds an opaque access token to its session's principal
4
+ * claims, so the request-auth layer can turn a bearer token on a later request
5
+ * back into a `Principal`.
6
+ *
7
+ * WHY THIS EXISTS (the flagged framework gap): the Core session layer mints an
8
+ * OPAQUE access token and never stores it — the hot session record is keyed by
9
+ * `sessionId` and carries only `principalId` + `tenantId` (no roles). There is no
10
+ * access-token → session mapping. Rather than change the frozen Core session
11
+ * contract (Article 1), this guard-side adapter writes a SEPARATE binding entry at
12
+ * establish/rotate time and reads it per request. Zero Core changes.
13
+ *
14
+ * The binding: `shield:at:<accessToken>` → JSON {@link BoundPrincipalClaims}
15
+ * ({ id, tenantId, roleIds, kind }), TTL = the access-token lifetime. The token
16
+ * stays fully opaque (carries no data itself); the mapping is server-side, so the
17
+ * session is server-REVOCABLE (drop the key) and the read is FAIL-CLOSED (absent or
18
+ * Redis-unavailable → no principal → the guard denies).
19
+ *
20
+ * ⛔ SECURITY: the access token is used only as a Redis key here; it is never logged
21
+ * and the stored value carries no secret (claims only — ids, not credentials).
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.SessionTokenBinder = void 0;
25
+ /**
26
+ * Structurally validate a value pulled from the (untrusted) Redis payload as
27
+ * {@link CoarseGrants}. Returns the parsed facts or `undefined` — a `null`/junk/legacy
28
+ * slot is treated as ABSENT (fail-closed → the request path DB-reloads), NEVER fabricated.
29
+ * Mirrors core gate 5's own defensive `asCoarseGrants` probe so a hostile payload can't
30
+ * sneak a non-string key or a non-number version into the cache.
31
+ */
32
+ function parseCachedFacts(slot) {
33
+ if (slot === null || typeof slot !== 'object')
34
+ return undefined;
35
+ const c = slot;
36
+ if (typeof c.permissionsVersion !== 'number')
37
+ return undefined;
38
+ if (!Array.isArray(c.grantedPermissionKeys))
39
+ return undefined;
40
+ for (const k of c.grantedPermissionKeys)
41
+ if (typeof k !== 'string')
42
+ return undefined;
43
+ return {
44
+ permissionsVersion: c.permissionsVersion,
45
+ grantedPermissionKeys: [...c.grantedPermissionKeys],
46
+ };
47
+ }
48
+ /** Return the claims with any `cachedFacts` slot removed (absent → gate 5 DB-reloads). */
49
+ function stripCachedFacts(claims) {
50
+ if (!('cachedFacts' in claims))
51
+ return claims;
52
+ const { cachedFacts: _drop, ...rest } = claims;
53
+ return rest;
54
+ }
55
+ /** Redis key prefix for the access-token → claims binding. */
56
+ const AT_KEY_PREFIX = 'shield:at:';
57
+ /** Redis key prefix for the session-id → claims binding (survives token rotation). */
58
+ const SESS_CLAIMS_PREFIX = 'shield:sessclaims:';
59
+ function atKey(accessToken) {
60
+ return `${AT_KEY_PREFIX}${accessToken}`;
61
+ }
62
+ function sessClaimsKey(sessionId) {
63
+ return `${SESS_CLAIMS_PREFIX}${sessionId}`;
64
+ }
65
+ class SessionTokenBinder {
66
+ redis;
67
+ constructor(redis) {
68
+ this.redis = redis;
69
+ }
70
+ /**
71
+ * Bind an access token to its principal claims for `ttlMs` (the access-token
72
+ * lifetime). Called right after `SessionAuthority.establish` in the auth
73
+ * controller (it holds both the freshly-minted access token and the Principal).
74
+ * ALSO writes a session-id → claims entry so a later `rebindOnRotate` can carry the
75
+ * full claims (roles/kind/root) forward across a token rotation, where the
76
+ * durable session row alone lacks them.
77
+ */
78
+ async bind(accessToken, claims, ttlMs) {
79
+ const payload = JSON.stringify(claims);
80
+ await this.redis.set(atKey(accessToken), payload, 'PX', ttlMs);
81
+ // The session-claims entry gets a longer TTL cushion so it survives across
82
+ // multiple short-lived access tokens within one session's lifetime.
83
+ await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', Math.max(ttlMs * 4, ttlMs));
84
+ }
85
+ /**
86
+ * On refresh/rotate the controller has the NEW access token + the session, but not
87
+ * the full Principal (roles/kind/root live only in the binding, not the
88
+ * durable session row). Re-bind by reading the session-claims entry and writing the
89
+ * new access-token binding with the SAME claims. Returns true if it carried claims
90
+ * forward, false if the session-claims entry was gone (then the caller has nothing
91
+ * to bind — a later request will 401 and the user re-logs in; fail-closed).
92
+ *
93
+ * `freshFacts` (ADR-0011 eager projection at REFRESH):
94
+ * - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
95
+ * already had FORWARD unchanged (no re-projection). Existing callers that pass no
96
+ * facts behave EXACTLY as before.
97
+ * - a {@link CoarseGrants} → REPLACE `cachedFacts` with this freshly-projected value
98
+ * (a refresh re-reads the current grants so the cache stays warm past the version
99
+ * the login projected at).
100
+ * - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
101
+ * request DB-reloads; fail-closed).
102
+ */
103
+ async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts) {
104
+ let raw;
105
+ try {
106
+ raw = await this.redis.get(sessClaimsKey(sessionId));
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ if (raw === null)
112
+ return false;
113
+ // Default: carry the existing payload forward byte-for-byte (facts unchanged).
114
+ let payload = raw;
115
+ if (freshFacts !== undefined) {
116
+ // A refresh-time projection was supplied → rewrite the `cachedFacts` slot on the
117
+ // carried-forward claims. A parse failure here is treated as "nothing to update"
118
+ // and we carry the original payload forward (fail-safe: never drop the whole
119
+ // binding on a transient JSON hiccup — a later request re-projects/DB-reloads).
120
+ try {
121
+ const parsed = JSON.parse(raw);
122
+ const next = freshFacts === null ? stripCachedFacts(parsed) : { ...parsed, cachedFacts: freshFacts };
123
+ payload = JSON.stringify(next);
124
+ }
125
+ catch {
126
+ payload = raw;
127
+ }
128
+ }
129
+ await this.redis.set(atKey(newAccessToken), payload, 'PX', ttlMs);
130
+ // Refresh the session-claims TTL cushion too (with the same, possibly-updated payload).
131
+ await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', Math.max(ttlMs * 4, ttlMs));
132
+ return true;
133
+ }
134
+ /**
135
+ * Resolve an access token to its bound claims, or `null` if the binding is
136
+ * absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
137
+ * a thrown 500 that could be mistaken for allow. The token is opaque; an unknown
138
+ * token simply has no key.
139
+ */
140
+ async resolve(accessToken) {
141
+ let raw;
142
+ try {
143
+ raw = await this.redis.get(atKey(accessToken));
144
+ }
145
+ catch {
146
+ return null; // Redis unavailable → fail closed.
147
+ }
148
+ if (raw === null)
149
+ return null;
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(raw);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ if (typeof parsed?.id !== 'string' ||
158
+ typeof parsed?.tenantId !== 'string' ||
159
+ !Array.isArray(parsed?.roleIds) ||
160
+ typeof parsed?.kind !== 'string' ||
161
+ typeof parsed?.isRoot !== 'boolean' ||
162
+ typeof parsed?.sessionId !== 'string' ||
163
+ typeof parsed?.mustReenroll !== 'boolean') {
164
+ return null; // malformed → fail closed.
165
+ }
166
+ // Normalize the OPTIONAL cached-facts slot (ADR-0011): a malformed/legacy/absent
167
+ // slot is scrubbed to `undefined` (→ the guard enters the ALS with `cachedFacts:
168
+ // null` → gate 5 DB-reloads). Present + well-formed → the validated CoarseGrants.
169
+ // Never fabricated. Rebuild the object so a malformed slot cannot ride through.
170
+ const cachedFacts = parseCachedFacts(parsed.cachedFacts);
171
+ parsed = cachedFacts === undefined
172
+ ? stripCachedFacts(parsed)
173
+ : { ...parsed, cachedFacts };
174
+ // SESSION-LIVENESS GATE: the per-token binding alone is not enough — a logout
175
+ // revokes the session but cannot reach every outstanding access-token key. So we
176
+ // ALSO require the session-claims entry (which `unbindSession` deletes on logout)
177
+ // to still exist. Gone → the session was revoked → deny (fail-closed), even if the
178
+ // token binding lingers until its TTL. This makes logout an IMMEDIATE token kill.
179
+ let liveness;
180
+ try {
181
+ liveness = await this.redis.get(sessClaimsKey(parsed.sessionId));
182
+ }
183
+ catch {
184
+ return null; // Redis unavailable → fail closed.
185
+ }
186
+ if (liveness === null)
187
+ return null; // session revoked (logout) → deny.
188
+ // MUST-REENROLL is read from the SESSION-CLAIMS entry (the one we just fetched for
189
+ // liveness), NOT the per-token entry — so `clearReenroll(sessionId)` (which cannot
190
+ // know each outstanding access token) flips it for the LIVE token on the very next
191
+ // request. The token stays the same; only this bound claim changes. A malformed or
192
+ // legacy session-claims entry that lacks the flag is treated as `false` (default
193
+ // safe: an old binding predating this field must not spuriously lock a session).
194
+ let mustReenroll = parsed.mustReenroll;
195
+ try {
196
+ const sess = JSON.parse(liveness);
197
+ mustReenroll = sess?.mustReenroll === true;
198
+ }
199
+ catch {
200
+ mustReenroll = false; // unparseable session-claims → treat as not-reenroll.
201
+ }
202
+ return mustReenroll === parsed.mustReenroll ? parsed : { ...parsed, mustReenroll };
203
+ }
204
+ /**
205
+ * Clear the ENFORCED-RE-ENROLL flag for a session — the re-secure is complete
206
+ * (a fresh device was confirmed, spec §4.3). Rewrites the session-claims entry with
207
+ * `mustReenroll: false`, preserving its remaining TTL, so the LIVE access token's
208
+ * next {@link SessionTokenBinder.resolve} sees `false` (the token is NOT re-minted;
209
+ * only the bound claim flips). Keyed by `sessionId` because the caller (the account
210
+ * controller on device-confirm) has the session id from its own claims, and this must
211
+ * apply to every outstanding access token of the session at once.
212
+ *
213
+ * Best-effort + fail-closed on the LOAD: if the session-claims entry is gone (logged
214
+ * out) or Redis is unavailable, there is nothing to clear (the session is already
215
+ * dead / will re-deny) — we simply return. Idempotent.
216
+ */
217
+ async clearReenroll(sessionId) {
218
+ let raw;
219
+ try {
220
+ raw = await this.redis.get(sessClaimsKey(sessionId));
221
+ }
222
+ catch {
223
+ return; // Redis unavailable → nothing to do (the gate re-evaluates next request).
224
+ }
225
+ if (raw === null)
226
+ return; // session revoked/expired → nothing to clear.
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(raw);
230
+ }
231
+ catch {
232
+ return; // malformed → leave as-is (resolve() already fails it closed).
233
+ }
234
+ if (parsed.mustReenroll === false)
235
+ return; // already clear — idempotent no-op.
236
+ // Preserve the remaining TTL so clearing the flag never extends the session life.
237
+ let ttlMs;
238
+ try {
239
+ ttlMs = await this.redis.pttl(sessClaimsKey(sessionId));
240
+ }
241
+ catch {
242
+ return;
243
+ }
244
+ if (ttlMs <= 0)
245
+ return; // expired/no-expiry between get and pttl → leave it.
246
+ const updated = { ...parsed, mustReenroll: false };
247
+ try {
248
+ await this.redis.set(sessClaimsKey(sessionId), JSON.stringify(updated), 'PX', ttlMs);
249
+ }
250
+ catch {
251
+ // best-effort; a failed clear just means the next re-secure attempt retries.
252
+ }
253
+ }
254
+ /** Drop an access-token binding. Idempotent. */
255
+ async unbind(accessToken) {
256
+ try {
257
+ await this.redis.del(atKey(accessToken));
258
+ }
259
+ catch {
260
+ // best-effort; the TTL expires the key regardless.
261
+ }
262
+ }
263
+ /**
264
+ * Drop the session-claims entry on logout/revoke, so a rotate after logout cannot
265
+ * re-mint a binding (defense-in-depth: the session is already revoked, but this
266
+ * removes the claims cushion too). The per-access-token bindings expire by TTL.
267
+ */
268
+ async unbindSession(sessionId) {
269
+ try {
270
+ await this.redis.del(sessClaimsKey(sessionId));
271
+ }
272
+ catch {
273
+ // best-effort; TTL cleans up regardless.
274
+ }
275
+ }
276
+ }
277
+ exports.SessionTokenBinder = SessionTokenBinder;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-token-binder.js","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;;;AAuDH;;;;;;GAMG;AACH,SAAS,gBAAgB,CAAC,IAAa;IACrC,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAChE,MAAM,CAAC,GAAG,IAAyE,CAAC;IACpF,IAAI,OAAO,CAAC,CAAC,kBAAkB,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAC/D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC;QAAE,OAAO,SAAS,CAAC;IAC9D,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,qBAAqB;QAAE,IAAI,OAAO,CAAC,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;IACrF,OAAO;QACL,kBAAkB,EAAE,CAAC,CAAC,kBAAkB;QACxC,qBAAqB,EAAE,CAAC,GAAI,CAAC,CAAC,qBAAkC,CAAC;KAClE,CAAC;AACJ,CAAC;AAED,0FAA0F;AAC1F,SAAS,gBAAgB,CAAC,MAA4B;IACpD,IAAI,CAAC,CAAC,aAAa,IAAI,MAAM,CAAC;QAAE,OAAO,MAAM,CAAC;IAC9C,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,IAAI,EAAE,GAAG,MAEvC,CAAC;IACF,OAAO,IAAI,CAAC;AACd,CAAC;AAED,8DAA8D;AAC9D,MAAM,aAAa,GAAG,YAAY,CAAC;AACnC,sFAAsF;AACtF,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEhD,SAAS,KAAK,CAAC,WAAmB;IAChC,OAAO,GAAG,aAAa,GAAG,WAAW,EAAE,CAAC;AAC1C,CAAC;AACD,SAAS,aAAa,CAAC,SAAiB;IACtC,OAAO,GAAG,kBAAkB,GAAG,SAAS,EAAE,CAAC;AAC7C,CAAC;AAED,MAAa,kBAAkB;IACA;IAA7B,YAA6B,KAAY;QAAZ,UAAK,GAAL,KAAK,CAAO;IAAG,CAAC;IAE7C;;;;;;;OAOG;IACH,KAAK,CAAC,IAAI,CAAC,WAAmB,EAAE,MAA4B,EAAE,KAAa;QACzE,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QACvC,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAC/D,2EAA2E;QAC3E,oEAAoE;QACpE,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;IACnG,CAAC;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,CAAC,cAAc,CAClB,cAAsB,EACtB,SAAiB,EACjB,KAAa,EACb,UAAgC;QAEhC,IAAI,GAAkB,CAAC;QACvB,IAAI,CAAC;YACH,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;QACvD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;QACD,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAE/B,+EAA+E;QAC/E,IAAI,OAAO,GAAG,GAAG,CAAC;QAClB,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;YAC7B,iFAAiF;YACjF,iFAAiF;YACjF,6EAA6E;YAC7E,gFAAgF;YAChF,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAyB,CAAC;gBACvD,MAAM,IAAI,GACR,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,CAAC;gBAC1F,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACjC,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,GAAG,GAAG,CAAC;YAChB,CAAC;QACH,CAAC;QAED,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,cAAc,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QAClE,wFAAwF;QACxF,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC;QAC1F,OAAO,IAAI,CAAC;IACd,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,OAAO,CAAC,WAAmB;QAC/B,IAAI,GAAkB,CAAC;QACvB,IAAI,CAAC;YACH,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;QACjD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC,CAAC,mCAAmC;QAClD,CAAC;QACD,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC9B,IAAI,MAA4B,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAyB,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IACE,OAAO,MAAM,EAAE,EAAE,KAAK,QAAQ;YAC9B,OAAO,MAAM,EAAE,QAAQ,KAAK,QAAQ;YACpC,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC;YAC/B,OAAO,MAAM,EAAE,IAAI,KAAK,QAAQ;YAChC,OAAO,MAAM,EAAE,MAAM,KAAK,SAAS;YACnC,OAAO,MAAM,EAAE,SAAS,KAAK,QAAQ;YACrC,OAAO,MAAM,EAAE,YAAY,KAAK,SAAS,EACzC,CAAC;YACD,OAAO,IAAI,CAAC,CAAC,2BAA2B;QAC1C,CAAC;QACD,iFAAiF;QACjF,iFAAiF;QACjF,kFAAkF;QAClF,gFAAgF;QAChF,MAAM,WAAW,GAAG,gBAAgB,CAAE,MAAoC,CAAC,WAAW,CAAC,CAAC;QACxF,MAAM,GAAG,WAAW,KAAK,SAAS;YAChC,CAAC,CAAC,gBAAgB,CAAC,MAAM,CAAC;YAC1B,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,WAAW,EAAE,CAAC;QAC/B,8EAA8E;QAC9E,iFAAiF;QACjF,kFAAkF;QAClF,mFAAmF;QACnF,kFAAkF;QAClF,IAAI,QAAuB,CAAC;QAC5B,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;QACnE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,IAAI,CAAC,CAAC,mCAAmC;QAClD,CAAC;QACD,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,CAAC,mCAAmC;QAEvE,mFAAmF;QACnF,mFAAmF;QACnF,mFAAmF;QACnF,mFAAmF;QACnF,iFAAiF;QACjF,iFAAiF;QACjF,IAAI,YAAY,GAAG,MAAM,CAAC,YAAY,CAAC;QACvC,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAA+B,CAAC;YAChE,YAAY,GAAG,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,YAAY,GAAG,KAAK,CAAC,CAAC,sDAAsD;QAC9E,CAAC;QACD,OAAO,YAAY,KAAK,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,YAAY,EAAE,CAAC;IACrF,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,aAAa,CAAC,SAAiB;QACnC,IAAI,GAAkB,CAAC;QACvB,IAAI,CAAC;YACH,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;QACvD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,0EAA0E;QACpF,CAAC;QACD,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,CAAC,8CAA8C;QACxE,IAAI,MAA4B,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAyB,CAAC;QACnD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,+DAA+D;QACzE,CAAC;QACD,IAAI,MAAM,CAAC,YAAY,KAAK,KAAK;YAAE,OAAO,CAAC,oCAAoC;QAC/E,kFAAkF;QAClF,IAAI,KAAa,CAAC;QAClB,IAAI,CAAC;YACH,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;QAC1D,CAAC;QAAC,MAAM,CAAC;YACP,OAAO;QACT,CAAC;QACD,IAAI,KAAK,IAAI,CAAC;YAAE,OAAO,CAAC,qDAAqD;QAC7E,MAAM,OAAO,GAAyB,EAAE,GAAG,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,CAAC;QACzE,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,SAAS,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QACvF,CAAC;QAAC,MAAM,CAAC;YACP,6EAA6E;QAC/E,CAAC;IACH,CAAC;IAED,gDAAgD;IAChD,KAAK,CAAC,MAAM,CAAC,WAAmB;QAC9B,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;QAC3C,CAAC;QAAC,MAAM,CAAC;YACP,mDAAmD;QACrD,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,aAAa,CAAC,SAAiB;QACnC,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;QACjD,CAAC;QAAC,MAAM,CAAC;YACP,yCAAyC;QAC3C,CAAC;IACH,CAAC;CACF;AA/MD,gDA+MC"}