@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.
- 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/auth.controller.d.ts +219 -5
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +790 -12
- package/dist/auth/auth.module.d.ts +101 -3
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +300 -8
- package/dist/auth/dto.d.ts +16 -6
- package/dist/auth/dto.d.ts.map +1 -1
- 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/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/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/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/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/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/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/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-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/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/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/tokens.d.ts +174 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +190 -1
- 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/authentication/mariadb-credential-resolver.d.ts +38 -24
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +42 -28
- 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/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/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-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-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/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-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/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/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/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/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/config/app-config.d.ts +18 -16
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +68 -35
- 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/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/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/index.d.ts +35 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -7
- 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.module.d.ts +154 -10
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +181 -24
- 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/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-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/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- 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.
|
|
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": "
|
|
26
|
+
"@astram/core": "1.0.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@nestjs/common": "^11",
|