@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.
- package/dist/auth/account.controller.d.ts +245 -0
- package/dist/auth/account.controller.d.ts.map +1 -0
- package/dist/auth/account.controller.js +673 -0
- package/dist/auth/account.controller.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +316 -0
- package/dist/auth/auth.controller.d.ts.map +1 -0
- package/dist/auth/auth.controller.js +1091 -0
- package/dist/auth/auth.controller.js.map +1 -0
- package/dist/auth/auth.module.d.ts +202 -0
- package/dist/auth/auth.module.d.ts.map +1 -0
- package/dist/auth/auth.module.js +610 -0
- package/dist/auth/auth.module.js.map +1 -0
- package/dist/auth/connections.d.ts +61 -0
- package/dist/auth/connections.d.ts.map +1 -0
- package/dist/auth/connections.js +116 -0
- package/dist/auth/connections.js.map +1 -0
- package/dist/auth/dto.d.ts +154 -0
- package/dist/auth/dto.d.ts.map +1 -0
- package/dist/auth/dto.js +29 -0
- package/dist/auth/dto.js.map +1 -0
- package/dist/auth/pending-mfa-store.d.ts +72 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -0
- package/dist/auth/pending-mfa-store.js +106 -0
- package/dist/auth/pending-mfa-store.js.map +1 -0
- package/dist/auth/persistence.module.d.ts +42 -0
- package/dist/auth/persistence.module.d.ts.map +1 -0
- package/dist/auth/persistence.module.js +94 -0
- package/dist/auth/persistence.module.js.map +1 -0
- package/dist/auth/ports/audit-sink.d.ts +47 -0
- package/dist/auth/ports/audit-sink.d.ts.map +1 -0
- package/dist/auth/ports/audit-sink.js +20 -0
- package/dist/auth/ports/audit-sink.js.map +1 -0
- package/dist/auth/ports/credential-writer.d.ts +23 -0
- package/dist/auth/ports/credential-writer.d.ts.map +1 -0
- package/dist/auth/ports/credential-writer.js +2 -0
- package/dist/auth/ports/credential-writer.js.map +1 -0
- package/dist/auth/ports/email-sender.d.ts +14 -0
- package/dist/auth/ports/email-sender.d.ts.map +1 -0
- package/dist/auth/ports/email-sender.js +2 -0
- package/dist/auth/ports/email-sender.js.map +1 -0
- package/dist/auth/ports/mfa-device-store.d.ts +45 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
- package/dist/auth/ports/mfa-device-store.js +13 -0
- package/dist/auth/ports/mfa-device-store.js.map +1 -0
- package/dist/auth/ports/profile-writer.d.ts +12 -0
- package/dist/auth/ports/profile-writer.d.ts.map +1 -0
- package/dist/auth/ports/profile-writer.js +2 -0
- package/dist/auth/ports/profile-writer.js.map +1 -0
- package/dist/auth/ports/recovery-code-store.d.ts +10 -0
- package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
- package/dist/auth/ports/recovery-code-store.js +2 -0
- package/dist/auth/ports/recovery-code-store.js.map +1 -0
- package/dist/auth/ports/reset-token-store.d.ts +12 -0
- package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
- package/dist/auth/ports/reset-token-store.js +2 -0
- package/dist/auth/ports/reset-token-store.js.map +1 -0
- package/dist/auth/ports/session-facts-projector.d.ts +44 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
- package/dist/auth/ports/session-facts-projector.js +30 -0
- package/dist/auth/ports/session-facts-projector.js.map +1 -0
- package/dist/auth/ports/session-sweeper.d.ts +12 -0
- package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
- package/dist/auth/ports/session-sweeper.js +2 -0
- package/dist/auth/ports/session-sweeper.js.map +1 -0
- package/dist/auth/profile-resolver.d.ts +73 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -0
- package/dist/auth/profile-resolver.js +28 -0
- package/dist/auth/profile-resolver.js.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.js +133 -0
- package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
- package/dist/auth/tokens.d.ts +236 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +254 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp-enrollment.d.ts +42 -0
- package/dist/auth/totp-enrollment.d.ts.map +1 -0
- package/dist/auth/totp-enrollment.js +96 -0
- package/dist/auth/totp-enrollment.js.map +1 -0
- package/dist/authentication/argon2id-password-hasher.js.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +110 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.js +170 -0
- package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.js +397 -0
- package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.js +108 -0
- package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.js +154 -0
- package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.js +106 -0
- package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
- package/dist/authz/record-share-cascade.d.ts +52 -0
- package/dist/authz/record-share-cascade.d.ts.map +1 -0
- package/dist/authz/record-share-cascade.js +49 -0
- package/dist/authz/record-share-cascade.js.map +1 -0
- package/dist/authz/record-share-writer.d.ts +175 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -0
- package/dist/authz/record-share-writer.js +225 -0
- package/dist/authz/record-share-writer.js.map +1 -0
- package/dist/authz/wire-record-authorization.d.ts +66 -0
- package/dist/authz/wire-record-authorization.d.ts.map +1 -0
- package/dist/authz/wire-record-authorization.js +34 -0
- package/dist/authz/wire-record-authorization.js.map +1 -0
- package/dist/boot/boot-sequence.js.map +1 -0
- package/dist/boot/boot.module.js.map +1 -0
- package/dist/completeness/catalog-reconciler.d.ts +21 -0
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
- package/dist/completeness/catalog-reconciler.js +74 -0
- package/dist/completeness/catalog-reconciler.js.map +1 -0
- package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
- package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
- package/dist/completeness/operation-inventory-scanner.js +79 -0
- package/dist/completeness/operation-inventory-scanner.js.map +1 -0
- package/dist/completeness/ports/catalog-store.d.ts +33 -0
- package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
- package/dist/completeness/ports/catalog-store.js +2 -0
- package/dist/completeness/ports/catalog-store.js.map +1 -0
- package/dist/config/app-config.d.ts +134 -1
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +144 -2
- package/dist/config/app-config.js.map +1 -0
- package/dist/config/config.module.js.map +1 -0
- package/dist/enforcement/classification.decorator.d.ts +33 -14
- package/dist/enforcement/classification.decorator.d.ts.map +1 -1
- package/dist/enforcement/classification.decorator.js +34 -14
- package/dist/enforcement/classification.decorator.js.map +1 -0
- package/dist/enforcement/core-seam.js.map +1 -0
- package/dist/enforcement/enforcement.module.js.map +1 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +108 -25
- package/dist/enforcement/global-enforcement.guard.js.map +1 -0
- package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
- package/dist/enforcement/reenroll-allowlist.js +77 -0
- package/dist/enforcement/reenroll-allowlist.js.map +1 -0
- package/dist/index.d.ts +43 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -1
- package/dist/index.js.map +1 -0
- package/dist/module/shield-tokens.d.ts +15 -0
- package/dist/module/shield-tokens.d.ts.map +1 -0
- package/dist/module/shield-tokens.js +17 -0
- package/dist/module/shield-tokens.js.map +1 -0
- package/dist/module/shield.module.d.ts +203 -8
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +232 -14
- package/dist/module/shield.module.js.map +1 -0
- package/dist/request-auth/request-principal.d.ts +42 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -0
- package/dist/request-auth/request-principal.js +87 -0
- package/dist/request-auth/request-principal.js.map +1 -0
- package/dist/request-auth/session-facts-als.d.ts +65 -0
- package/dist/request-auth/session-facts-als.d.ts.map +1 -0
- package/dist/request-auth/session-facts-als.js +57 -0
- package/dist/request-auth/session-facts-als.js.map +1 -0
- package/dist/request-auth/session-token-binder.d.ts +130 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -0
- package/dist/request-auth/session-token-binder.js +277 -0
- package/dist/request-auth/session-token-binder.js.map +1 -0
- package/dist/request-auth/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- package/dist/request-auth/tokens.js.map +1 -0
- package/dist/session/crypto-adapters.js.map +1 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/mariadb-session-record-store.js.map +1 -0
- package/dist/session/redis-hot-session-store.js.map +1 -0
- 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":"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"}
|