@astram/guard 0.2.0 → 0.4.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 (120) 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/auth.controller.d.ts +219 -5
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +790 -12
  7. package/dist/auth/auth.module.d.ts +101 -3
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +300 -8
  10. package/dist/auth/dto.d.ts +16 -6
  11. package/dist/auth/dto.d.ts.map +1 -1
  12. package/dist/auth/ports/audit-sink.d.ts +47 -0
  13. package/dist/auth/ports/audit-sink.d.ts.map +1 -0
  14. package/dist/auth/ports/audit-sink.js +20 -0
  15. package/dist/auth/ports/credential-writer.d.ts +23 -0
  16. package/dist/auth/ports/credential-writer.d.ts.map +1 -0
  17. package/dist/auth/ports/credential-writer.js +2 -0
  18. package/dist/auth/ports/email-sender.d.ts +14 -0
  19. package/dist/auth/ports/email-sender.d.ts.map +1 -0
  20. package/dist/auth/ports/email-sender.js +2 -0
  21. package/dist/auth/ports/mfa-device-store.d.ts +45 -0
  22. package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
  23. package/dist/auth/ports/mfa-device-store.js +13 -0
  24. package/dist/auth/ports/profile-writer.d.ts +12 -0
  25. package/dist/auth/ports/profile-writer.d.ts.map +1 -0
  26. package/dist/auth/ports/profile-writer.js +2 -0
  27. package/dist/auth/ports/recovery-code-store.d.ts +10 -0
  28. package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
  29. package/dist/auth/ports/recovery-code-store.js +2 -0
  30. package/dist/auth/ports/reset-token-store.d.ts +12 -0
  31. package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
  32. package/dist/auth/ports/reset-token-store.js +2 -0
  33. package/dist/auth/ports/session-facts-projector.d.ts +44 -0
  34. package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
  35. package/dist/auth/ports/session-facts-projector.js +30 -0
  36. package/dist/auth/ports/session-sweeper.d.ts +12 -0
  37. package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
  38. package/dist/auth/ports/session-sweeper.js +2 -0
  39. package/dist/auth/profile-resolver.d.ts +73 -0
  40. package/dist/auth/profile-resolver.d.ts.map +1 -0
  41. package/dist/auth/profile-resolver.js +28 -0
  42. package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
  43. package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
  44. package/dist/auth/rate-limit/attempt-limiter.js +133 -0
  45. package/dist/auth/tokens.d.ts +174 -0
  46. package/dist/auth/tokens.d.ts.map +1 -1
  47. package/dist/auth/tokens.js +190 -1
  48. package/dist/auth/totp-enrollment.d.ts +42 -0
  49. package/dist/auth/totp-enrollment.d.ts.map +1 -0
  50. package/dist/auth/totp-enrollment.js +96 -0
  51. package/dist/authentication/mariadb-credential-resolver.d.ts +38 -24
  52. package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
  53. package/dist/authentication/mariadb-credential-resolver.js +42 -28
  54. package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
  55. package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
  56. package/dist/authz/cross-tenant-grant-writer.js +397 -0
  57. package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
  58. package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
  59. package/dist/authz/manifest-operation-authority-adapter.js +108 -0
  60. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
  61. package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
  62. package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
  63. package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
  64. package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
  65. package/dist/authz/mariadb-record-rule-adapter.js +154 -0
  66. package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
  67. package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
  68. package/dist/authz/mariadb-record-share-adapter.js +106 -0
  69. package/dist/authz/record-share-cascade.d.ts +52 -0
  70. package/dist/authz/record-share-cascade.d.ts.map +1 -0
  71. package/dist/authz/record-share-cascade.js +49 -0
  72. package/dist/authz/record-share-writer.d.ts +175 -0
  73. package/dist/authz/record-share-writer.d.ts.map +1 -0
  74. package/dist/authz/record-share-writer.js +225 -0
  75. package/dist/authz/wire-record-authorization.d.ts +66 -0
  76. package/dist/authz/wire-record-authorization.d.ts.map +1 -0
  77. package/dist/authz/wire-record-authorization.js +34 -0
  78. package/dist/completeness/catalog-reconciler.d.ts +21 -0
  79. package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
  80. package/dist/completeness/catalog-reconciler.js +74 -0
  81. package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
  82. package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
  83. package/dist/completeness/operation-inventory-scanner.js +79 -0
  84. package/dist/completeness/ports/catalog-store.d.ts +33 -0
  85. package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
  86. package/dist/completeness/ports/catalog-store.js +2 -0
  87. package/dist/config/app-config.d.ts +18 -16
  88. package/dist/config/app-config.d.ts.map +1 -1
  89. package/dist/config/app-config.js +68 -35
  90. package/dist/enforcement/classification.decorator.d.ts +33 -14
  91. package/dist/enforcement/classification.decorator.d.ts.map +1 -1
  92. package/dist/enforcement/classification.decorator.js +34 -14
  93. package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
  94. package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
  95. package/dist/enforcement/global-enforcement.guard.js +108 -25
  96. package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
  97. package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
  98. package/dist/enforcement/reenroll-allowlist.js +77 -0
  99. package/dist/index.d.ts +35 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +159 -7
  102. package/dist/module/shield-tokens.d.ts +15 -0
  103. package/dist/module/shield-tokens.d.ts.map +1 -0
  104. package/dist/module/shield-tokens.js +17 -0
  105. package/dist/module/shield.module.d.ts +154 -10
  106. package/dist/module/shield.module.d.ts.map +1 -1
  107. package/dist/module/shield.module.js +181 -24
  108. package/dist/request-auth/request-principal.d.ts +42 -0
  109. package/dist/request-auth/request-principal.d.ts.map +1 -0
  110. package/dist/request-auth/request-principal.js +87 -0
  111. package/dist/request-auth/session-facts-als.d.ts +65 -0
  112. package/dist/request-auth/session-facts-als.d.ts.map +1 -0
  113. package/dist/request-auth/session-facts-als.js +57 -0
  114. package/dist/request-auth/session-token-binder.d.ts +130 -0
  115. package/dist/request-auth/session-token-binder.d.ts.map +1 -0
  116. package/dist/request-auth/session-token-binder.js +277 -0
  117. package/dist/request-auth/tokens.d.ts +14 -0
  118. package/dist/request-auth/tokens.d.ts.map +1 -0
  119. package/dist/request-auth/tokens.js +16 -0
  120. package/package.json +2 -2
@@ -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,14 @@
1
+ /**
2
+ * DI tokens for the request-auth layer. Declared standalone (no module import) so
3
+ * the enforcement guard and the auth wiring can both depend on them without cycles.
4
+ */
5
+ /**
6
+ * The {@link import('./session-token-binder.js').SessionTokenBinder} — binds an
7
+ * opaque access token to its principal claims (server-side), so the enforcement
8
+ * guard can turn a request's bearer token into a `Principal`. Provided only when
9
+ * `auth: true` (it needs the live Redis client). ABSENT on the enforcement-only
10
+ * boot — the guard injects it `@Optional()` and denies classified routes when it is
11
+ * missing (fail-closed).
12
+ */
13
+ export declare const SHIELD_SESSION_TOKEN_BINDER: unique symbol;
14
+ //# sourceMappingURL=tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/request-auth/tokens.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC"}
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * DI tokens for the request-auth layer. Declared standalone (no module import) so
4
+ * the enforcement guard and the auth wiring can both depend on them without cycles.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.SHIELD_SESSION_TOKEN_BINDER = void 0;
8
+ /**
9
+ * The {@link import('./session-token-binder.js').SessionTokenBinder} — binds an
10
+ * opaque access token to its principal claims (server-side), so the enforcement
11
+ * guard can turn a request's bearer token into a `Principal`. Provided only when
12
+ * `auth: true` (it needs the live Redis client). ABSENT on the enforcement-only
13
+ * boot — the guard injects it `@Optional()` and denies classified routes when it is
14
+ * missing (fail-closed).
15
+ */
16
+ exports.SHIELD_SESSION_TOKEN_BINDER = Symbol('SHIELD_SESSION_TOKEN_BINDER');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guard",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the enforcement guard + real-world adapters (the 'hands'). One global deny-by-default gate every request passes through (ADR-0002), classification decorators, boot-pool assertion (ADR-0018), the forRoot() registration seam, and the DB/Redis/crypto adapters that connect the sealed core to a real backend. Depends on @astram/core (ADR-0003, ADR-0023).",
6
6
  "type": "commonjs",
@@ -23,7 +23,7 @@
23
23
  "argon2": "^0.41.1",
24
24
  "ioredis": "^5.4.1",
25
25
  "mariadb": "^3.4.0",
26
- "@astram/core": "0.1.0"
26
+ "@astram/core": "1.0.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@nestjs/common": "^11",