@astram/guard 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +3 -1
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +12 -5
- package/dist/auth/auth.controller.d.ts +130 -21
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +537 -143
- package/dist/auth/auth.module.d.ts +15 -1
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +212 -30
- package/dist/auth/connections.d.ts +1 -1
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +14 -3
- package/dist/auth/default-session-facts-projector.d.ts +26 -0
- package/dist/auth/default-session-facts-projector.d.ts.map +1 -0
- package/dist/auth/default-session-facts-projector.js +21 -0
- package/dist/auth/mfa-step-up.d.ts +32 -0
- package/dist/auth/mfa-step-up.d.ts.map +1 -0
- package/dist/auth/mfa-step-up.js +109 -0
- package/dist/auth/pending-mfa-store.d.ts +50 -52
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +103 -61
- package/dist/auth/ports/session-facts-projector.d.ts +5 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -1
- package/dist/auth/rate-limit/canonical-hint.d.ts +24 -0
- package/dist/auth/rate-limit/canonical-hint.d.ts.map +1 -0
- package/dist/auth/rate-limit/canonical-hint.js +63 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts +46 -0
- package/dist/auth/rate-limit/trust-proxy-check.d.ts.map +1 -0
- package/dist/auth/rate-limit/trust-proxy-check.js +94 -0
- package/dist/auth/tokens.d.ts +70 -11
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +71 -12
- package/dist/authentication/log-email-sender.d.ts +9 -4
- package/dist/authentication/log-email-sender.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.d.ts +4 -1
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.js +31 -7
- package/dist/authentication/mariadb-credential-resolver.d.ts +79 -65
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +116 -73
- package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.d.ts +21 -3
- package/dist/authentication/mariadb-or-totp-verifier.d.ts.map +1 -1
- package/dist/authentication/mariadb-or-totp-verifier.js +37 -6
- package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts +14 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.js +29 -11
- package/dist/authentication/noop-email-sender.d.ts +12 -0
- package/dist/authentication/noop-email-sender.d.ts.map +1 -0
- package/dist/authentication/noop-email-sender.js +9 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +3 -1
- package/dist/authz/mariadb-policy-store.js +1 -1
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +1 -4
- package/dist/authz/permission-version-cache.d.ts +38 -0
- package/dist/authz/permission-version-cache.d.ts.map +1 -0
- package/dist/authz/permission-version-cache.js +81 -0
- package/dist/authz/wire-record-authorization.d.ts +25 -1
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +55 -2
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -1
- package/dist/config/app-config.d.ts +36 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +44 -4
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.d.ts.map +1 -1
- package/dist/enforcement/enforcement.module.js +9 -0
- package/dist/enforcement/engine-error.filter.d.ts +57 -0
- package/dist/enforcement/engine-error.filter.d.ts.map +1 -0
- package/dist/enforcement/engine-error.filter.js +118 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -2
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +143 -5
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +2 -3
- package/dist/index.d.ts +12 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -25
- package/dist/module/shield.module.d.ts +9 -2
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +47 -38
- package/dist/request-auth/session-facts-als.d.ts +16 -1
- package/dist/request-auth/session-facts-als.d.ts.map +1 -1
- package/dist/request-auth/session-facts-als.js +23 -0
- package/dist/request-auth/session-token-binder.d.ts +166 -10
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +408 -28
- package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -1
- package/dist/secrets/encrypted-file-secrets.js +9 -3
- package/dist/session/hot-revoking-session-record-store.d.ts +46 -0
- package/dist/session/hot-revoking-session-record-store.d.ts.map +1 -0
- package/dist/session/hot-revoking-session-record-store.js +58 -0
- package/dist/session/index.d.ts +3 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +8 -1
- package/dist/session/mariadb-session-record-store.d.ts +6 -0
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
- package/dist/session/mariadb-session-record-store.js +16 -1
- package/dist/session/session-lifetimes.d.ts +30 -0
- package/dist/session/session-lifetimes.d.ts.map +1 -0
- package/dist/session/session-lifetimes.js +21 -0
- package/dist/session/session-restriction-store.d.ts +43 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -0
- package/dist/session/session-restriction-store.js +35 -0
- package/package.json +3 -3
- package/dist/auth/account.controller.js.map +0 -1
- package/dist/auth/auth.controller.js.map +0 -1
- package/dist/auth/auth.module.js.map +0 -1
- package/dist/auth/connections.js.map +0 -1
- package/dist/auth/dto.js.map +0 -1
- package/dist/auth/pending-mfa-store.js.map +0 -1
- package/dist/auth/persistence.module.js.map +0 -1
- package/dist/auth/ports/audit-sink.js.map +0 -1
- package/dist/auth/ports/credential-writer.js.map +0 -1
- package/dist/auth/ports/email-sender.js.map +0 -1
- package/dist/auth/ports/membership-checker.js.map +0 -1
- package/dist/auth/ports/mfa-device-store.js.map +0 -1
- package/dist/auth/ports/profile-writer.js.map +0 -1
- package/dist/auth/ports/recovery-code-store.js.map +0 -1
- package/dist/auth/ports/reset-token-store.js.map +0 -1
- package/dist/auth/ports/scope-subtree-resolver.js.map +0 -1
- package/dist/auth/ports/session-facts-projector.js.map +0 -1
- package/dist/auth/ports/session-sweeper.js.map +0 -1
- package/dist/auth/profile-resolver.js.map +0 -1
- package/dist/auth/rate-limit/attempt-limiter.js.map +0 -1
- package/dist/auth/tokens.js.map +0 -1
- package/dist/auth/totp-enrollment.js.map +0 -1
- package/dist/authentication/argon2id-password-hasher.js.map +0 -1
- package/dist/authentication/log-email-sender.js.map +0 -1
- package/dist/authentication/mariadb-credential-resolver.js.map +0 -1
- package/dist/authentication/mariadb-credential-writer.js.map +0 -1
- package/dist/authentication/mariadb-mfa-device-store.js.map +0 -1
- package/dist/authentication/mariadb-or-totp-verifier.js.map +0 -1
- package/dist/authentication/mariadb-profile-resolver.js.map +0 -1
- package/dist/authentication/mariadb-profile-writer.js.map +0 -1
- package/dist/authentication/mariadb-recovery-code-store.js.map +0 -1
- package/dist/authentication/mariadb-reset-token-store.js.map +0 -1
- package/dist/authentication/mariadb-session-sweeper.js.map +0 -1
- package/dist/authz/cross-tenant-grant-writer.js.map +0 -1
- package/dist/authz/list-scope-sql.js.map +0 -1
- package/dist/authz/manifest-operation-authority-adapter.js.map +0 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +0 -1
- package/dist/authz/mariadb-list-scope-adapter.js.map +0 -1
- package/dist/authz/mariadb-policy-store.js.map +0 -1
- package/dist/authz/mariadb-record-rule-adapter.js.map +0 -1
- package/dist/authz/mariadb-record-share-adapter.js.map +0 -1
- package/dist/authz/record-share-cascade.js.map +0 -1
- package/dist/authz/record-share-writer.js.map +0 -1
- package/dist/authz/wire-record-authorization.js.map +0 -1
- package/dist/boot/boot-sequence.js.map +0 -1
- package/dist/boot/boot.module.js.map +0 -1
- package/dist/completeness/catalog-reconciler.js.map +0 -1
- package/dist/completeness/operation-inventory-scanner.js.map +0 -1
- package/dist/completeness/ports/catalog-store.js.map +0 -1
- package/dist/config/app-config.js.map +0 -1
- package/dist/config/config.module.js.map +0 -1
- package/dist/enforcement/classification.decorator.js.map +0 -1
- package/dist/enforcement/core-seam.js.map +0 -1
- package/dist/enforcement/enforcement.module.js.map +0 -1
- package/dist/enforcement/global-enforcement.guard.js.map +0 -1
- package/dist/enforcement/reenroll-allowlist.js.map +0 -1
- package/dist/identity/auth-facts.controller.js.map +0 -1
- package/dist/identity/identity-actions.js.map +0 -1
- package/dist/identity/module-registry.bridge.js.map +0 -1
- package/dist/identity/permission-facts.adapter.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/migrations-dir.js.map +0 -1
- package/dist/module/shield-tokens.js.map +0 -1
- package/dist/module/shield.module.js.map +0 -1
- package/dist/request-auth/request-principal.js.map +0 -1
- package/dist/request-auth/session-facts-als.js.map +0 -1
- package/dist/request-auth/session-token-binder.js.map +0 -1
- package/dist/request-auth/tokens.js.map +0 -1
- package/dist/scope/closure-builder.js.map +0 -1
- package/dist/session/crypto-adapters.js.map +0 -1
- package/dist/session/index.js.map +0 -1
- package/dist/session/mariadb-session-record-store.js.map +0 -1
- package/dist/session/redis-hot-session-store.js.map +0 -1
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* establish/rotate time and reads it per request. Zero Core changes.
|
|
13
13
|
*
|
|
14
14
|
* The binding: `shield:at:<accessToken>` → JSON {@link BoundPrincipalClaims}
|
|
15
|
-
* ({ id, tenantId, roleIds, kind }), TTL
|
|
15
|
+
* ({ id, tenantId, roleIds, kind }), TTL ≤ the access-token lifetime (security hardening
|
|
16
|
+
* #8). Alongside it: `shield:sessclaims:<sessionId>` (the liveness entry, TTL = the refresh
|
|
17
|
+
* idle window when configured) and `shield:sessat:<sessionId>` (the session's current
|
|
18
|
+
* access token, so a rotation can grace-expire the old one and a revoke can drop it). The token
|
|
16
19
|
* stays fully opaque (carries no data itself); the mapping is server-side, so the
|
|
17
20
|
* session is server-REVOCABLE (drop the key) and the read is FAIL-CLOSED (absent or
|
|
18
21
|
* Redis-unavailable → no principal → the guard denies).
|
|
@@ -22,6 +25,19 @@
|
|
|
22
25
|
*/
|
|
23
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
27
|
exports.SessionTokenBinder = void 0;
|
|
28
|
+
exports.factsSlot = factsSlot;
|
|
29
|
+
exports.liveRoleIds = liveRoleIds;
|
|
30
|
+
/** A valid stamp: a non-negative safe integer. */
|
|
31
|
+
function isSubtreeVersion(v) {
|
|
32
|
+
return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
33
|
+
}
|
|
34
|
+
/** Return the claims with any `scopeSubtreeVersion` stamp removed. */
|
|
35
|
+
function stripSubtreeVersion(claims) {
|
|
36
|
+
if (!('scopeSubtreeVersion' in claims))
|
|
37
|
+
return claims;
|
|
38
|
+
const { scopeSubtreeVersion: _drop, ...rest } = claims;
|
|
39
|
+
return rest;
|
|
40
|
+
}
|
|
25
41
|
/**
|
|
26
42
|
* Structurally validate a value pulled from the (untrusted) Redis payload as
|
|
27
43
|
* {@link CoarseGrants}. Returns the parsed facts or `undefined` — a `null`/junk/legacy
|
|
@@ -40,11 +56,52 @@ function parseCachedFacts(slot) {
|
|
|
40
56
|
for (const k of c.grantedPermissionKeys)
|
|
41
57
|
if (typeof k !== 'string')
|
|
42
58
|
return undefined;
|
|
59
|
+
// The OPTIONAL time bound (ADR-0035): kept only when it is a finite number. A junk bound is
|
|
60
|
+
// dropped together with the facts (fail-closed: unusable facts → reload), never ignored.
|
|
61
|
+
if (c.validUntilMs !== undefined && !Number.isFinite(c.validUntilMs))
|
|
62
|
+
return undefined;
|
|
43
63
|
return {
|
|
44
64
|
permissionsVersion: c.permissionsVersion,
|
|
45
65
|
grantedPermissionKeys: [...c.grantedPermissionKeys],
|
|
66
|
+
...(typeof c.validUntilMs === 'number' ? { validUntilMs: c.validUntilMs } : {}),
|
|
46
67
|
};
|
|
47
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The `cachedFacts` slot a binding stores (ADR-0035): the version, the keys and the optional time
|
|
71
|
+
* bound. The projection's `roleIds` are NOT kept here — they are the claims' own `roleIds`, written
|
|
72
|
+
* in the same payload, so there is one copy of the roles.
|
|
73
|
+
*/
|
|
74
|
+
function factsSlot(grants) {
|
|
75
|
+
return {
|
|
76
|
+
permissionsVersion: grants.permissionsVersion,
|
|
77
|
+
grantedPermissionKeys: [...grants.grantedPermissionKeys],
|
|
78
|
+
...(typeof grants.validUntilMs === 'number' && Number.isFinite(grants.validUntilMs)
|
|
79
|
+
? { validUntilMs: grants.validUntilMs }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The projection's live roles when it reported them (a string array), else `undefined`. */
|
|
84
|
+
function liveRoleIds(grants) {
|
|
85
|
+
const roles = grants?.roleIds;
|
|
86
|
+
if (!Array.isArray(roles))
|
|
87
|
+
return undefined;
|
|
88
|
+
return roles.filter((r) => typeof r === 'string' && r.length > 0);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether `next` is at least as fresh as the binding's current facts `current` (ADR-0035): a newer
|
|
92
|
+
* version, or the same version with a later (or no) time bound. A slower request that reloaded
|
|
93
|
+
* older facts must never overwrite fresher ones.
|
|
94
|
+
*/
|
|
95
|
+
function isFresher(next, current) {
|
|
96
|
+
if (current === undefined)
|
|
97
|
+
return true;
|
|
98
|
+
if (next.permissionsVersion !== current.permissionsVersion) {
|
|
99
|
+
return next.permissionsVersion > current.permissionsVersion;
|
|
100
|
+
}
|
|
101
|
+
const a = next.validUntilMs ?? Number.POSITIVE_INFINITY;
|
|
102
|
+
const b = current.validUntilMs ?? Number.POSITIVE_INFINITY;
|
|
103
|
+
return a > b;
|
|
104
|
+
}
|
|
48
105
|
/**
|
|
49
106
|
* Structurally validate the OPTIONAL `scopeSubtreeNodeIds` slot from the (untrusted) Redis
|
|
50
107
|
* payload. Returns a fresh string[] of the non-empty node ids, or `undefined` when the slot is
|
|
@@ -80,16 +137,34 @@ function stripCachedFacts(claims) {
|
|
|
80
137
|
const AT_KEY_PREFIX = 'shield:at:';
|
|
81
138
|
/** Redis key prefix for the session-id → claims binding (survives token rotation). */
|
|
82
139
|
const SESS_CLAIMS_PREFIX = 'shield:sessclaims:';
|
|
140
|
+
/** Redis key prefix for the session-id → its CURRENT access token (for grace/unbind). */
|
|
141
|
+
const SESS_AT_PREFIX = 'shield:sessat:';
|
|
83
142
|
function atKey(accessToken) {
|
|
84
143
|
return `${AT_KEY_PREFIX}${accessToken}`;
|
|
85
144
|
}
|
|
86
145
|
function sessClaimsKey(sessionId) {
|
|
87
146
|
return `${SESS_CLAIMS_PREFIX}${sessionId}`;
|
|
88
147
|
}
|
|
148
|
+
function sessAtKey(sessionId) {
|
|
149
|
+
return `${SESS_AT_PREFIX}${sessionId}`;
|
|
150
|
+
}
|
|
151
|
+
/** Default grace for the previous access token after a rotation (plan decision D7). */
|
|
152
|
+
const DEFAULT_ROTATE_GRACE_MS = 30_000;
|
|
153
|
+
/** How long refresh waits for Redis to answer PING before answering 503 (ADR-0035, F1). */
|
|
154
|
+
const DEFAULT_PING_TIMEOUT_MS = 1_000;
|
|
89
155
|
class SessionTokenBinder {
|
|
90
156
|
redis;
|
|
91
|
-
|
|
157
|
+
options;
|
|
158
|
+
constructor(redis, options = {}) {
|
|
92
159
|
this.redis = redis;
|
|
160
|
+
this.options = options;
|
|
161
|
+
}
|
|
162
|
+
/** TTL for the session-claims entry + current-token pointer. */
|
|
163
|
+
sessionTtl(accessTtlMs) {
|
|
164
|
+
const configured = this.options.sessionClaimsTtlMs;
|
|
165
|
+
return configured !== undefined && configured > 0
|
|
166
|
+
? configured
|
|
167
|
+
: Math.max(accessTtlMs * 4, accessTtlMs);
|
|
93
168
|
}
|
|
94
169
|
/**
|
|
95
170
|
* Bind an access token to its principal claims for `ttlMs` (the access-token
|
|
@@ -97,22 +172,47 @@ class SessionTokenBinder {
|
|
|
97
172
|
* controller (it holds both the freshly-minted access token and the Principal).
|
|
98
173
|
* ALSO writes a session-id → claims entry so a later `rebindOnRotate` can carry the
|
|
99
174
|
* full claims (roles/kind/root) forward across a token rotation, where the
|
|
100
|
-
* durable session row alone lacks them
|
|
175
|
+
* durable session row alone lacks them, and records the session's CURRENT access
|
|
176
|
+
* token so a rotation can grace-expire it and a revoke can drop it.
|
|
101
177
|
*/
|
|
102
178
|
async bind(accessToken, claims, ttlMs) {
|
|
103
179
|
const payload = JSON.stringify(claims);
|
|
180
|
+
const sessTtl = this.sessionTtl(ttlMs);
|
|
104
181
|
await this.redis.set(atKey(accessToken), payload, 'PX', ttlMs);
|
|
105
|
-
// The session-claims entry
|
|
106
|
-
//
|
|
107
|
-
await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX',
|
|
182
|
+
// The session-claims entry lives for the refresh idle window (configured), so it
|
|
183
|
+
// survives across the session's short-lived access tokens — and no longer.
|
|
184
|
+
await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', sessTtl);
|
|
185
|
+
await this.redis.set(sessAtKey(claims.sessionId), accessToken, 'PX', sessTtl);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Grace-expire the session's previous access token after a rotation (plan D7): it stays
|
|
189
|
+
* usable for at most `rotateGraceMs` (never longer than it already had), then expires.
|
|
190
|
+
* Best-effort — a failure leaves the old binding to its own (≤ access TTL) expiry.
|
|
191
|
+
*/
|
|
192
|
+
async graceExpire(previousToken, newToken) {
|
|
193
|
+
if (previousToken === null || previousToken === '' || previousToken === newToken)
|
|
194
|
+
return;
|
|
195
|
+
const grace = this.options.rotateGraceMs ?? DEFAULT_ROTATE_GRACE_MS;
|
|
196
|
+
try {
|
|
197
|
+
const remaining = await this.redis.pttl(atKey(previousToken));
|
|
198
|
+
if (remaining === -1 || remaining > grace) {
|
|
199
|
+
await this.redis.pexpire(atKey(previousToken), grace);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// best-effort; the old binding still expires at its own access TTL.
|
|
204
|
+
}
|
|
108
205
|
}
|
|
109
206
|
/**
|
|
110
207
|
* On refresh/rotate the controller has the NEW access token + the session, but not
|
|
111
208
|
* the full Principal (roles/kind/root live only in the binding, not the
|
|
112
209
|
* durable session row). Re-bind by reading the session-claims entry and writing the
|
|
113
210
|
* new access-token binding with the SAME claims. Returns true if it carried claims
|
|
114
|
-
* forward, false if the session-claims entry was gone
|
|
115
|
-
*
|
|
211
|
+
* forward, false if the session-claims entry was gone or unreadable — the caller MUST
|
|
212
|
+
* then refuse the refresh (the auth controller answers 401: an unbound token is never
|
|
213
|
+
* handed out). A Redis error on the writes propagates (the caller fails closed too).
|
|
214
|
+
* The new binding's TTL is `ttlMs` (≤ the access TTL); the session's previous access
|
|
215
|
+
* token is grace-expired (≤ 30 s by default, plan D7).
|
|
116
216
|
*
|
|
117
217
|
* `freshFacts` (ADR-0011 eager projection at REFRESH):
|
|
118
218
|
* - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
|
|
@@ -124,7 +224,27 @@ class SessionTokenBinder {
|
|
|
124
224
|
* - `null` → STRIP `cachedFacts` (a projection that yielded nothing → the next
|
|
125
225
|
* request DB-reloads; fail-closed).
|
|
126
226
|
*/
|
|
127
|
-
async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts, freshSubtree
|
|
227
|
+
async rebindOnRotate(newAccessToken, sessionId, ttlMs, freshFacts, freshSubtree,
|
|
228
|
+
/**
|
|
229
|
+
* The permission version read just before `freshSubtree` was resolved (Phase 1C). Stamped
|
|
230
|
+
* when the fresh subtree is applied; absent → the stamp is dropped (unknown → re-derive once
|
|
231
|
+
* at the next reload). Ignored when no fresh subtree is applied (the carried subtree keeps its
|
|
232
|
+
* own stamp).
|
|
233
|
+
*/
|
|
234
|
+
freshSubtreeVersion,
|
|
235
|
+
/**
|
|
236
|
+
* The principal's LIVE role ids from the same projection as `freshFacts` (ADR-0035, GAPS #4).
|
|
237
|
+
* Given → they REPLACE the carried roles (roles and facts are written together). Absent → the
|
|
238
|
+
* carried roles stay (a projector that does not report roles, or no projection at all).
|
|
239
|
+
*/
|
|
240
|
+
freshRoleIds,
|
|
241
|
+
/**
|
|
242
|
+
* The node the fresh facts/roles/subtree were projected for (review follow-up: a scope-switch
|
|
243
|
+
* moves the claims off the login tenant). Given and the claims are now on a DIFFERENT node (a
|
|
244
|
+
* switch raced the refresh) → nothing fresh is applied and the cached facts are stripped (the
|
|
245
|
+
* next request reloads for the right node). Absent → applied as before.
|
|
246
|
+
*/
|
|
247
|
+
freshForTenantId) {
|
|
128
248
|
let raw;
|
|
129
249
|
try {
|
|
130
250
|
raw = await this.redis.get(sessClaimsKey(sessionId));
|
|
@@ -134,9 +254,23 @@ class SessionTokenBinder {
|
|
|
134
254
|
}
|
|
135
255
|
if (raw === null)
|
|
136
256
|
return false;
|
|
257
|
+
if (freshForTenantId !== undefined) {
|
|
258
|
+
let onNode = false;
|
|
259
|
+
try {
|
|
260
|
+
onNode = JSON.parse(raw)?.tenantId === freshForTenantId;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
onNode = false;
|
|
264
|
+
}
|
|
265
|
+
if (!onNode) {
|
|
266
|
+
freshFacts = null;
|
|
267
|
+
freshSubtree = undefined;
|
|
268
|
+
freshRoleIds = undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
137
271
|
// Default: carry the existing payload forward byte-for-byte (facts + subtree unchanged).
|
|
138
272
|
let payload = raw;
|
|
139
|
-
if (freshFacts !== undefined || freshSubtree !== undefined) {
|
|
273
|
+
if (freshFacts !== undefined || freshSubtree !== undefined || freshRoleIds !== undefined) {
|
|
140
274
|
// A refresh-time projection and/or subtree was supplied → rewrite those slots on the
|
|
141
275
|
// carried-forward claims. A parse failure here is treated as "nothing to update" and we
|
|
142
276
|
// carry the original payload forward (fail-safe: never drop the whole binding on a
|
|
@@ -146,7 +280,12 @@ class SessionTokenBinder {
|
|
|
146
280
|
let next = parsed;
|
|
147
281
|
if (freshFacts !== undefined) {
|
|
148
282
|
next =
|
|
149
|
-
freshFacts === null
|
|
283
|
+
freshFacts === null
|
|
284
|
+
? stripCachedFacts(next)
|
|
285
|
+
: { ...next, cachedFacts: factsSlot(freshFacts) };
|
|
286
|
+
}
|
|
287
|
+
if (freshRoleIds !== undefined) {
|
|
288
|
+
next = { ...next, roleIds: [...freshRoleIds] };
|
|
150
289
|
}
|
|
151
290
|
// The subtree (the stale-subtree-after-move fix): a non-empty fresh set REPLACES the
|
|
152
291
|
// carried-forward one (a re-parented session gets its correct, possibly-smaller subtree).
|
|
@@ -154,7 +293,10 @@ class SessionTokenBinder {
|
|
|
154
293
|
// forward (fail-safe, non-widening — never narrow to self-node on a transient resolver
|
|
155
294
|
// hiccup, never fail the refresh).
|
|
156
295
|
if (freshSubtree !== undefined && freshSubtree.length > 0) {
|
|
157
|
-
next = { ...next, scopeSubtreeNodeIds: [...freshSubtree] };
|
|
296
|
+
next = stripSubtreeVersion({ ...next, scopeSubtreeNodeIds: [...freshSubtree] });
|
|
297
|
+
if (isSubtreeVersion(freshSubtreeVersion)) {
|
|
298
|
+
next = { ...next, scopeSubtreeVersion: freshSubtreeVersion };
|
|
299
|
+
}
|
|
158
300
|
}
|
|
159
301
|
payload = JSON.stringify(next);
|
|
160
302
|
}
|
|
@@ -162,11 +304,140 @@ class SessionTokenBinder {
|
|
|
162
304
|
payload = raw;
|
|
163
305
|
}
|
|
164
306
|
}
|
|
307
|
+
// The session's previous access token (best-effort read — a miss only skips the grace).
|
|
308
|
+
let previousToken = null;
|
|
309
|
+
try {
|
|
310
|
+
previousToken = await this.redis.get(sessAtKey(sessionId));
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
previousToken = null;
|
|
314
|
+
}
|
|
315
|
+
const sessTtl = this.sessionTtl(ttlMs);
|
|
165
316
|
await this.redis.set(atKey(newAccessToken), payload, 'PX', ttlMs);
|
|
166
|
-
// Refresh the session-claims TTL
|
|
167
|
-
|
|
317
|
+
// Refresh the session-claims TTL (with the same, possibly-updated payload) + pointer.
|
|
318
|
+
// `XX` (Phase 2 review F2): only while the entry still exists. A logout that deleted it
|
|
319
|
+
// between our read and this write wins — the claims are NOT re-created, the new token's
|
|
320
|
+
// binding is dropped, and the caller refuses the refresh (no resurrected session).
|
|
321
|
+
const kept = await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl, 'XX');
|
|
322
|
+
if (kept !== 'OK') {
|
|
323
|
+
await this.unbind(newAccessToken);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
await this.redis.set(sessAtKey(sessionId), newAccessToken, 'PX', sessTtl);
|
|
327
|
+
await this.graceExpire(previousToken, newAccessToken);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* SILENT RECOVERY (ADR-0013 / ADR-0035, GAPS #5): write a session binding REBUILT from durable
|
|
332
|
+
* state after the session-claims entry was lost (Redis restart/flush). The caller (refresh) has
|
|
333
|
+
* already rotated the durable refresh token and assembled `claims` from the durable session
|
|
334
|
+
* record + live roles + the root allowlist + the subtree resolver.
|
|
335
|
+
*
|
|
336
|
+
* The claims entry is written with `NX`: if one appeared meanwhile, nothing is overwritten and
|
|
337
|
+
* the rebuild is refused (`false`). The caller MUST then re-read the durable session and call
|
|
338
|
+
* {@link unbindSession} if it is no longer active (a logout racing the rebuild). A Redis error
|
|
339
|
+
* propagates (the caller fails closed).
|
|
340
|
+
*/
|
|
341
|
+
async bindRecovered(accessToken, claims, ttlMs) {
|
|
342
|
+
const payload = JSON.stringify(claims);
|
|
343
|
+
const sessTtl = this.sessionTtl(ttlMs);
|
|
344
|
+
const created = await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', sessTtl, 'NX');
|
|
345
|
+
if (created !== 'OK')
|
|
346
|
+
return false;
|
|
347
|
+
await this.redis.set(atKey(accessToken), payload, 'PX', ttlMs);
|
|
348
|
+
await this.redis.set(sessAtKey(claims.sessionId), accessToken, 'PX', sessTtl);
|
|
168
349
|
return true;
|
|
169
350
|
}
|
|
351
|
+
/**
|
|
352
|
+
* The node a live session currently acts under (its claims' `tenantId` — the scope-switch target
|
|
353
|
+
* when it switched), or `null` when the claims are gone, unreadable, or Redis errors. Refresh uses
|
|
354
|
+
* it to project for the ACTIVE node; `null` → the durable login tenant.
|
|
355
|
+
*/
|
|
356
|
+
async sessionTenant(sessionId) {
|
|
357
|
+
try {
|
|
358
|
+
const raw = await this.redis.get(sessClaimsKey(sessionId));
|
|
359
|
+
if (raw === null)
|
|
360
|
+
return null;
|
|
361
|
+
const tenantId = JSON.parse(raw)?.tenantId;
|
|
362
|
+
return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : null;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Whether Redis answers right now (Phase 2 review F1). Refresh asks BEFORE the durable rotate
|
|
370
|
+
* spends the refresh token, so a Redis outage answers a retryable 503 instead of burning the
|
|
371
|
+
* token on a binding that cannot be written. A client without `ping` (a test double) counts as
|
|
372
|
+
* reachable.
|
|
373
|
+
*
|
|
374
|
+
* Bounded by `timeoutMs` (default 1 s): the framework's client queues commands while
|
|
375
|
+
* disconnected (`maxRetriesPerRequest: null`), so an unbounded PING would wait for Redis to come
|
|
376
|
+
* back and the refresh would then spend the token for a client that already gave up.
|
|
377
|
+
*/
|
|
378
|
+
async isAvailable(timeoutMs = DEFAULT_PING_TIMEOUT_MS) {
|
|
379
|
+
const client = this.redis;
|
|
380
|
+
if (typeof client.ping !== 'function')
|
|
381
|
+
return true;
|
|
382
|
+
let timer;
|
|
383
|
+
try {
|
|
384
|
+
const timedOut = new Promise((resolve) => {
|
|
385
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
386
|
+
});
|
|
387
|
+
const answer = await Promise.race([
|
|
388
|
+
client.ping().then(() => 'ok', () => 'error'),
|
|
389
|
+
timedOut,
|
|
390
|
+
]);
|
|
391
|
+
return answer === 'ok';
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
if (timer !== undefined)
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* LIVE GRANTS re-bind (ADR-0035, GAPS #4/#6). Gate 5 reloaded this session's coarse grants (a
|
|
400
|
+
* version bump, an expired time bound, or no cache yet); write them, and the live roles from the
|
|
401
|
+
* same projection, onto the access-token entry AND the session-claims entry, so the next requests
|
|
402
|
+
* are served from the cache and every role-keyed path sees the new roles. Nothing else changes.
|
|
403
|
+
*
|
|
404
|
+
* Each entry is rewritten only when it still belongs to `sessionId` + `tenantId` (a concurrent
|
|
405
|
+
* scope-switch moved it → left alone) and `facts` is fresher than what it holds (a slower request
|
|
406
|
+
* never overwrites newer facts). Each keeps its remaining TTL and is written with `XX` (a logout
|
|
407
|
+
* that deleted it wins). Best-effort: any Redis error or miss → that entry is skipped; the caller
|
|
408
|
+
* still uses the reloaded roles for the current request. Returns `true` when the access-token
|
|
409
|
+
* entry was rewritten.
|
|
410
|
+
*/
|
|
411
|
+
async refreshGrants(accessToken, sessionId, tenantId, facts, roleIds) {
|
|
412
|
+
const slot = factsSlot(facts);
|
|
413
|
+
const rewrite = async (key) => {
|
|
414
|
+
try {
|
|
415
|
+
const raw = await this.redis.get(key);
|
|
416
|
+
if (raw === null)
|
|
417
|
+
return false;
|
|
418
|
+
const current = JSON.parse(raw);
|
|
419
|
+
if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
|
|
420
|
+
return false;
|
|
421
|
+
if (!isFresher(slot, parseCachedFacts(current.cachedFacts)))
|
|
422
|
+
return false;
|
|
423
|
+
const ttl = await this.redis.pttl(key);
|
|
424
|
+
if (ttl <= 0)
|
|
425
|
+
return false;
|
|
426
|
+
const next = {
|
|
427
|
+
...current,
|
|
428
|
+
cachedFacts: slot,
|
|
429
|
+
...(roleIds !== undefined ? { roleIds: [...roleIds] } : {}),
|
|
430
|
+
};
|
|
431
|
+
return (await this.redis.set(key, JSON.stringify(next), 'PX', ttl, 'XX')) === 'OK';
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const tokenRewritten = await rewrite(atKey(accessToken));
|
|
438
|
+
await rewrite(sessClaimsKey(sessionId));
|
|
439
|
+
return tokenRewritten;
|
|
440
|
+
}
|
|
170
441
|
/**
|
|
171
442
|
* SCOPE-SWITCH re-bind (Task 5.2, ADR-0027 / ADR-0024). A principal is switching which
|
|
172
443
|
* active node it acts under; re-bind its CURRENT session (the same access token — no
|
|
@@ -186,7 +457,25 @@ class SessionTokenBinder {
|
|
|
186
457
|
* Returns `true` on a successful re-bind, `false` when the binding is gone (logged out /
|
|
187
458
|
* expired) or Redis is unavailable (the caller then denies the switch — fail-closed).
|
|
188
459
|
*/
|
|
189
|
-
async rebindActiveScope(accessToken, sessionId, targetNodeId, subtree
|
|
460
|
+
async rebindActiveScope(accessToken, sessionId, targetNodeId, subtree,
|
|
461
|
+
/**
|
|
462
|
+
* The TARGET node's permission version read just before `subtree` was resolved (Phase 1C).
|
|
463
|
+
* The old node's stamp is always dropped (versions are per tenant); this one is stamped when
|
|
464
|
+
* given, else the stamp stays absent (→ one re-derive at the next reload).
|
|
465
|
+
*/
|
|
466
|
+
subtreeVersion,
|
|
467
|
+
/**
|
|
468
|
+
* The coarse grants projected for the TARGET node (ADR-0035). Given → cached on the re-scoped
|
|
469
|
+
* binding instead of stripped (the first request under the new scope is served from cache).
|
|
470
|
+
* Absent/null → stripped (the next request reloads).
|
|
471
|
+
*/
|
|
472
|
+
targetFacts,
|
|
473
|
+
/**
|
|
474
|
+
* The principal's LIVE roles at the TARGET node (ADR-0035, GAPS #4), from the same projection
|
|
475
|
+
* as `targetFacts`. Given → they replace the old node's roles (roles are per node). Absent →
|
|
476
|
+
* the carried roles stay until the first request's reload replaces them.
|
|
477
|
+
*/
|
|
478
|
+
targetRoleIds) {
|
|
190
479
|
// Read the current claims (from the per-access-token entry) + confirm the session is
|
|
191
480
|
// still live (the session-claims entry exists). Any miss / Redis error → fail closed.
|
|
192
481
|
let rawAt;
|
|
@@ -210,12 +499,21 @@ class SessionTokenBinder {
|
|
|
210
499
|
// Build the re-scoped claims: new active node (tenantId) + new subtree, facts stripped.
|
|
211
500
|
// An empty subtree is stored as absent (→ per-request self-node fallback, non-widening) —
|
|
212
501
|
// but the switch caller always passes at least `[targetNodeId]`, so this is defensive.
|
|
213
|
-
const
|
|
214
|
-
...stripScopeSubtree(current),
|
|
502
|
+
const rescoped = stripCachedFacts({
|
|
503
|
+
...stripSubtreeVersion(stripScopeSubtree(current)),
|
|
215
504
|
tenantId: targetNodeId,
|
|
216
505
|
sessionId,
|
|
217
506
|
...(subtree.length > 0 ? { scopeSubtreeNodeIds: [...subtree] } : {}),
|
|
507
|
+
...(subtree.length > 0 && isSubtreeVersion(subtreeVersion)
|
|
508
|
+
? { scopeSubtreeVersion: subtreeVersion }
|
|
509
|
+
: {}),
|
|
218
510
|
});
|
|
511
|
+
// ADR-0035: the target node's facts + live roles, written together, when projected.
|
|
512
|
+
const next = {
|
|
513
|
+
...rescoped,
|
|
514
|
+
...(targetFacts ? { cachedFacts: factsSlot(targetFacts) } : {}),
|
|
515
|
+
...(targetRoleIds !== undefined ? { roleIds: [...targetRoleIds] } : {}),
|
|
516
|
+
};
|
|
219
517
|
const payload = JSON.stringify(next);
|
|
220
518
|
// Preserve each key's remaining TTL — a scope-switch must never extend the session.
|
|
221
519
|
let atTtl;
|
|
@@ -230,14 +528,66 @@ class SessionTokenBinder {
|
|
|
230
528
|
if (atTtl <= 0 || sessTtl <= 0)
|
|
231
529
|
return false; // expired between reads → fail closed.
|
|
232
530
|
try {
|
|
233
|
-
|
|
234
|
-
await this.redis.set(
|
|
531
|
+
// `XX` on both: a logout that deleted either entry since the reads wins (never re-created).
|
|
532
|
+
const at = await this.redis.set(atKey(accessToken), payload, 'PX', atTtl, 'XX');
|
|
533
|
+
const sess = await this.redis.set(sessClaimsKey(sessionId), payload, 'PX', sessTtl, 'XX');
|
|
534
|
+
if (at !== 'OK' || sess !== 'OK') {
|
|
535
|
+
await this.unbind(accessToken);
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
235
538
|
}
|
|
236
539
|
catch {
|
|
237
540
|
return false;
|
|
238
541
|
}
|
|
239
542
|
return true;
|
|
240
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* RE-DERIVED SUBTREE re-bind (security hardening Phase 1C). The enforcement guard saw gate 5
|
|
546
|
+
* reload the facts at a permission version NEWER than the bound subtree's stamp and resolved
|
|
547
|
+
* the subtree again; write it (and its stamp) onto the access-token entry AND the session-claims
|
|
548
|
+
* entry, so this token and any later rotation carry it. Nothing else in the claims changes.
|
|
549
|
+
*
|
|
550
|
+
* Each entry is rewritten only when it still belongs to `sessionId` + `tenantId` (a concurrent
|
|
551
|
+
* scope-switch moved it elsewhere → left alone) and its stamp is absent or OLDER than `version`
|
|
552
|
+
* (a concurrent refresh already wrote something at least as fresh → left alone). Each keeps its
|
|
553
|
+
* remaining TTL (a re-derive never extends a session). Best-effort: any Redis error or miss →
|
|
554
|
+
* `false`; the caller still uses the fresh subtree for the current request and a later reload
|
|
555
|
+
* retries. Returns `true` when the access-token entry was rewritten.
|
|
556
|
+
*/
|
|
557
|
+
async rederiveScopeSubtree(accessToken, sessionId, tenantId, subtree, version) {
|
|
558
|
+
if (subtree.length === 0 || !isSubtreeVersion(version))
|
|
559
|
+
return false;
|
|
560
|
+
const rewrite = async (key) => {
|
|
561
|
+
try {
|
|
562
|
+
const raw = await this.redis.get(key);
|
|
563
|
+
if (raw === null)
|
|
564
|
+
return false;
|
|
565
|
+
const current = JSON.parse(raw);
|
|
566
|
+
if (current?.sessionId !== sessionId || current?.tenantId !== tenantId)
|
|
567
|
+
return false;
|
|
568
|
+
if (isSubtreeVersion(current.scopeSubtreeVersion) &&
|
|
569
|
+
current.scopeSubtreeVersion >= version) {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
const ttl = await this.redis.pttl(key);
|
|
573
|
+
if (ttl <= 0)
|
|
574
|
+
return false;
|
|
575
|
+
const next = {
|
|
576
|
+
...current,
|
|
577
|
+
scopeSubtreeNodeIds: [...subtree],
|
|
578
|
+
scopeSubtreeVersion: version,
|
|
579
|
+
};
|
|
580
|
+
await this.redis.set(key, JSON.stringify(next), 'PX', ttl);
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const tokenRewritten = await rewrite(atKey(accessToken));
|
|
588
|
+
await rewrite(sessClaimsKey(sessionId));
|
|
589
|
+
return tokenRewritten;
|
|
590
|
+
}
|
|
241
591
|
/**
|
|
242
592
|
* Resolve an access token to its bound claims, or `null` if the binding is
|
|
243
593
|
* absent/expired. Fail-closed: any Redis error → `null` (the caller denies), never
|
|
@@ -275,17 +625,22 @@ class SessionTokenBinder {
|
|
|
275
625
|
// null` → gate 5 DB-reloads). Present + well-formed → the validated CoarseGrants.
|
|
276
626
|
// Never fabricated. Rebuild the object so a malformed slot cannot ride through.
|
|
277
627
|
const cachedFacts = parseCachedFacts(parsed.cachedFacts);
|
|
278
|
-
parsed = cachedFacts === undefined
|
|
279
|
-
? stripCachedFacts(parsed)
|
|
280
|
-
: { ...parsed, cachedFacts };
|
|
628
|
+
parsed = cachedFacts === undefined ? stripCachedFacts(parsed) : { ...parsed, cachedFacts };
|
|
281
629
|
// Normalize the OPTIONAL scope-subtree slot (ADR-0027 / Task 2.2): an absent, empty, or
|
|
282
630
|
// malformed slot is scrubbed to ABSENT (→ `principalFromClaims` falls back to the self-node,
|
|
283
631
|
// non-widening); a well-formed non-empty array is the validated cached subtree. Never
|
|
284
632
|
// fabricated — rebuild the object so a junk slot cannot ride through into a Principal.
|
|
285
633
|
const scopeSubtreeNodeIds = parseScopeSubtree(parsed.scopeSubtreeNodeIds);
|
|
286
|
-
parsed =
|
|
287
|
-
|
|
288
|
-
|
|
634
|
+
parsed =
|
|
635
|
+
scopeSubtreeNodeIds === undefined
|
|
636
|
+
? stripScopeSubtree(parsed)
|
|
637
|
+
: { ...parsed, scopeSubtreeNodeIds };
|
|
638
|
+
// The subtree's version stamp (Phase 1C) rides only beside a validated subtree; a junk or
|
|
639
|
+
// orphaned stamp is dropped (→ treated as unknown → one re-derive at the next reload).
|
|
640
|
+
if (scopeSubtreeNodeIds === undefined ||
|
|
641
|
+
!isSubtreeVersion(parsed.scopeSubtreeVersion)) {
|
|
642
|
+
parsed = stripSubtreeVersion(parsed);
|
|
643
|
+
}
|
|
289
644
|
// SESSION-LIVENESS GATE: the per-token binding alone is not enough — a logout
|
|
290
645
|
// revokes the session but cannot reach every outstanding access-token key. So we
|
|
291
646
|
// ALSO require the session-claims entry (which `unbindSession` deletes on logout)
|
|
@@ -376,9 +731,25 @@ class SessionTokenBinder {
|
|
|
376
731
|
}
|
|
377
732
|
}
|
|
378
733
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
734
|
+
* The THROWING variant of {@link unbindSession} (Phase 3 review P1): the same keys, but a
|
|
735
|
+
* Redis error PROPAGATES. An administrative revoke (an account disable through the session
|
|
736
|
+
* sweeper) must report a failed unbind so the admin can retry, instead of leaving the
|
|
737
|
+
* session's live access tokens valid until their TTL. Idempotent (absent keys are fine).
|
|
738
|
+
*/
|
|
739
|
+
async unbindSessionStrict(sessionId) {
|
|
740
|
+
await this.redis.del(sessClaimsKey(sessionId));
|
|
741
|
+
const current = await this.redis.get(sessAtKey(sessionId));
|
|
742
|
+
if (current !== null && current !== '')
|
|
743
|
+
await this.redis.del(atKey(current));
|
|
744
|
+
await this.redis.del(sessAtKey(sessionId));
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Revoke a session in Redis (logout, password reset, refresh reuse-kill, admin revoke):
|
|
748
|
+
* drop the session-claims entry — which the {@link resolve} liveness gate requires, so
|
|
749
|
+
* EVERY access token of the session is denied on its next request — and also the
|
|
750
|
+
* session's current access-token binding and pointer. A grace-period token from an
|
|
751
|
+
* earlier rotation is covered by the liveness gate and expires within the grace.
|
|
752
|
+
* Best-effort per key; TTLs clean up regardless ({@link unbindSessionStrict} throws instead).
|
|
382
753
|
*/
|
|
383
754
|
async unbindSession(sessionId) {
|
|
384
755
|
try {
|
|
@@ -387,6 +758,15 @@ class SessionTokenBinder {
|
|
|
387
758
|
catch {
|
|
388
759
|
// best-effort; TTL cleans up regardless.
|
|
389
760
|
}
|
|
761
|
+
try {
|
|
762
|
+
const current = await this.redis.get(sessAtKey(sessionId));
|
|
763
|
+
if (current !== null && current !== '')
|
|
764
|
+
await this.redis.del(atKey(current));
|
|
765
|
+
await this.redis.del(sessAtKey(sessionId));
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
// best-effort; the liveness gate already denies every token of this session.
|
|
769
|
+
}
|
|
390
770
|
}
|
|
391
771
|
}
|
|
392
772
|
exports.SessionTokenBinder = SessionTokenBinder;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"encrypted-file-secrets.d.ts","sourceRoot":"","sources":["../../src/secrets/encrypted-file-secrets.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"encrypted-file-secrets.d.ts","sourceRoot":"","sources":["../../src/secrets/encrypted-file-secrets.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAYjF,qBAAa,4BAA6B,YAAW,eAAe;;IAKlE;;;;;OAKG;gBACS,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG;IAMhD,OAAO,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;IAS1C,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIvD,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI3D,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC;CAmCxD"}
|
|
@@ -84,9 +84,15 @@ class EncryptedFileSecretsProvider {
|
|
|
84
84
|
try {
|
|
85
85
|
(0, node_fs_1.chmodSync)(this.#file, 0o600);
|
|
86
86
|
}
|
|
87
|
-
catch {
|
|
87
|
+
catch {
|
|
88
|
+
/* best-effort on platforms without chmod */
|
|
89
|
+
}
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
exports.EncryptedFileSecretsProvider = EncryptedFileSecretsProvider;
|
|
91
|
-
function toB64(b) {
|
|
92
|
-
|
|
93
|
+
function toB64(b) {
|
|
94
|
+
return Buffer.from(b).toString('base64');
|
|
95
|
+
}
|
|
96
|
+
function fromB64(s) {
|
|
97
|
+
return new Uint8Array(Buffer.from(s, 'base64'));
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HotRevokingSessionRecordStore` — a decorator on `SHIELD_SESSION_RECORD_STORE`
|
|
3
|
+
* (security hardening M-A).
|
|
4
|
+
*
|
|
5
|
+
* WHY: core's `SessionAuthority` revokes through the durable store only — the refresh
|
|
6
|
+
* reuse-kill (`revokeLineage`) and the absolute-cap / logout path (`revokeSession`) mark
|
|
7
|
+
* MariaDB rows revoked, but the guard's per-request path reads Redis alone (invariant #12)
|
|
8
|
+
* and never consults MariaDB. Without this decorator a stolen-and-replayed refresh token
|
|
9
|
+
* kills the lineage durably while the live access token keeps working until its TTL.
|
|
10
|
+
*
|
|
11
|
+
* WHAT: after a durable revoke SUCCEEDS, unbind the affected sessions in Redis
|
|
12
|
+
* (`SessionTokenBinder.unbindSession` drops the session-claims liveness entry, so every
|
|
13
|
+
* access token of the session is denied on its next request). Durable first; a durable
|
|
14
|
+
* failure propagates and nothing is unbound. The Redis step is best-effort: a failure there
|
|
15
|
+
* never fails the durable revoke, and the exposure is bounded by the access TTL (#8).
|
|
16
|
+
*
|
|
17
|
+
* No core change: the port is decorated, not extended. The lineage → session lookup is a
|
|
18
|
+
* guard-side read on the durable adapter ({@link LineageSessionLookup}).
|
|
19
|
+
*/
|
|
20
|
+
import type { CreateSessionInput, InsertRefreshInput, RefreshTokenRecord, SessionRecord, SessionRecordStore } from '@astram/core';
|
|
21
|
+
/** The Redis side of a revoke (the token binder implements it). */
|
|
22
|
+
export interface SessionHotRevoker {
|
|
23
|
+
unbindSession(sessionId: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** Resolve the sessions a refresh lineage belongs to (a lifecycle-time durable read). */
|
|
26
|
+
export interface LineageSessionLookup {
|
|
27
|
+
sessionIdsForLineage(lineageId: string): Promise<string[]>;
|
|
28
|
+
}
|
|
29
|
+
export declare class HotRevokingSessionRecordStore implements SessionRecordStore {
|
|
30
|
+
private readonly inner;
|
|
31
|
+
private readonly hot;
|
|
32
|
+
private readonly lineages;
|
|
33
|
+
constructor(inner: SessionRecordStore, hot: SessionHotRevoker, lineages: LineageSessionLookup);
|
|
34
|
+
createSession(input: CreateSessionInput): Promise<SessionRecord>;
|
|
35
|
+
insertRefreshRecord(input: InsertRefreshInput): Promise<RefreshTokenRecord>;
|
|
36
|
+
findRefreshByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
|
37
|
+
findSessionById(sessionId: string): Promise<SessionRecord | null>;
|
|
38
|
+
markRotatedIfActive(refreshId: string): Promise<boolean>;
|
|
39
|
+
markRevoked(refreshId: string): Promise<void>;
|
|
40
|
+
/** Durable lineage kill, then unbind every session of the lineage in Redis. */
|
|
41
|
+
revokeLineage(lineageId: string): Promise<void>;
|
|
42
|
+
/** Durable session revoke, then unbind it in Redis. */
|
|
43
|
+
revokeSession(sessionId: string): Promise<void>;
|
|
44
|
+
private unbind;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=hot-revoking-session-record-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hot-revoking-session-record-store.d.ts","sourceRoot":"","sources":["../../src/session/hot-revoking-session-record-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,kBAAkB,EACnB,MAAM,cAAc,CAAC;AAEtB,mEAAmE;AACnE,MAAM,WAAW,iBAAiB;IAChC,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD;AAED,yFAAyF;AACzF,MAAM,WAAW,oBAAoB;IACnC,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC5D;AAED,qBAAa,6BAA8B,YAAW,kBAAkB;IAEpE,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAFR,KAAK,EAAE,kBAAkB,EACzB,GAAG,EAAE,iBAAiB,EACtB,QAAQ,EAAE,oBAAoB;IAGjD,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC;IAIhE,mBAAmB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAI3E,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IAIxE,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAIjE,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIxD,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI7C,+EAA+E;IACzE,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWrD,uDAAuD;IACjD,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAKvC,MAAM;CAOrB"}
|