@astram/guard 0.12.0 → 0.12.1
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/auth.controller.d.ts +2 -0
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +19 -2
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +5 -10
- package/dist/auth/email-address.d.ts +4 -2
- package/dist/auth/email-address.d.ts.map +1 -1
- package/dist/auth/email-address.js +11 -2
- package/dist/request-auth/session-token-binder.d.ts +31 -8
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +74 -43
- package/dist/session/replay-outcome.d.ts +12 -0
- package/dist/session/replay-outcome.d.ts.map +1 -0
- package/dist/session/replay-outcome.js +42 -0
- package/package.json +3 -3
|
@@ -472,6 +472,8 @@ export declare class AuthController {
|
|
|
472
472
|
* this seam today (flagged core-API gap).
|
|
473
473
|
*/
|
|
474
474
|
refresh(body: RefreshRequest): Promise<SessionSuccess>;
|
|
475
|
+
/** KI #104: audit a tolerated repeat once its re-bind is decided (best-effort, never blocks). */
|
|
476
|
+
private recordReplay;
|
|
475
477
|
/**
|
|
476
478
|
* The refresh-time projection for one node: coarse grants + live roles (ADR-0035) and the
|
|
477
479
|
* subtree with its version stamp (Phase 1C: the version is read FIRST). Each part is best-effort
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.controller.d.ts","sourceRoot":"","sources":["../../src/auth/auth.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAeH,OAAO,KAAK,EACV,aAAa,EAIb,cAAc,EAGd,gBAAgB,EAEjB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.controller.d.ts","sourceRoot":"","sources":["../../src/auth/auth.controller.ts"],"names":[],"mappings":"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"}
|
|
@@ -69,6 +69,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
69
69
|
exports.AuthController = void 0;
|
|
70
70
|
const common_1 = require("@nestjs/common");
|
|
71
71
|
const node_crypto_1 = require("node:crypto");
|
|
72
|
+
const replay_outcome_js_1 = require("../session/replay-outcome.js");
|
|
72
73
|
const common_2 = require("@nestjs/common");
|
|
73
74
|
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
74
75
|
const tokens_js_1 = require("./tokens.js");
|
|
@@ -1451,8 +1452,12 @@ let AuthController = class AuthController {
|
|
|
1451
1452
|
if (typeof probe?.isAvailable === 'function' && !(await probe.isAvailable())) {
|
|
1452
1453
|
retryableUnavailable();
|
|
1453
1454
|
}
|
|
1455
|
+
// KI #104: a tolerated repeat is audited after the re-bind, with its real outcome.
|
|
1456
|
+
let replay;
|
|
1454
1457
|
try {
|
|
1455
|
-
const
|
|
1458
|
+
const rotated = await (0, replay_outcome_js_1.captureReplayDuring)(() => this.sessionAuthority.rotate(refreshToken));
|
|
1459
|
+
const est = rotated.result;
|
|
1460
|
+
replay = rotated.replay;
|
|
1456
1461
|
// Carry the principal claims forward to the NEW access token (the durable
|
|
1457
1462
|
// session row lacks roles/kind — the session-claims entry has them).
|
|
1458
1463
|
if (this.tokenBinder !== undefined) {
|
|
@@ -1477,7 +1482,9 @@ let AuthController = class AuthController {
|
|
|
1477
1482
|
? await peek.sessionTenant(est.session.sessionId)
|
|
1478
1483
|
: null) ?? est.session.tenantId;
|
|
1479
1484
|
const fresh = await this.projectForNode(est.session.principalId, activeTenant);
|
|
1480
|
-
const rebound = await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, fresh.facts, fresh.subtree, fresh.subtreeVersion, fresh.roles, activeTenant
|
|
1485
|
+
const rebound = await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, fresh.facts, fresh.subtree, fresh.subtreeVersion, fresh.roles, activeTenant,
|
|
1486
|
+
// KI #110: a repeat never moves the pointer; its original takes the full bind.
|
|
1487
|
+
{ repeat: replay !== undefined });
|
|
1481
1488
|
// The claims were gone (Redis loss) or a logout won the race (`SET XX` refused):
|
|
1482
1489
|
// SILENT RECOVERY (invariant #12, ADR-0013/0035) rebuilds them from durable state; a
|
|
1483
1490
|
// rebuild that cannot be proven safe is a generic 401 — never an unbound token.
|
|
@@ -1490,15 +1497,25 @@ let AuthController = class AuthController {
|
|
|
1490
1497
|
genericUnauthorized();
|
|
1491
1498
|
}
|
|
1492
1499
|
}
|
|
1500
|
+
this.recordReplay(replay, 'tolerated');
|
|
1493
1501
|
return toSessionSuccess(est);
|
|
1494
1502
|
}
|
|
1495
1503
|
catch {
|
|
1496
1504
|
// The engine throws only its generic SessionRejectedError (reuse, invalid,
|
|
1497
1505
|
// expiry, or a swallowed store outage — all indistinguishable here). Map to a
|
|
1498
1506
|
// generic 401; never leak which, never echo the token.
|
|
1507
|
+
this.recordReplay(replay, 'refused');
|
|
1499
1508
|
genericUnauthorized();
|
|
1500
1509
|
}
|
|
1501
1510
|
}
|
|
1511
|
+
/** KI #104: audit a tolerated repeat once its re-bind is decided (best-effort, never blocks). */
|
|
1512
|
+
recordReplay(replay, outcome) {
|
|
1513
|
+
if (replay === undefined)
|
|
1514
|
+
return;
|
|
1515
|
+
void this.auditSink?.record((0, replay_outcome_js_1.replayAuditEvent)(replay, outcome, Date.now())).catch(() => {
|
|
1516
|
+
/* best-effort: an audit failure never changes the response */
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1502
1519
|
/**
|
|
1503
1520
|
* The refresh-time projection for one node: coarse grants + live roles (ADR-0035) and the
|
|
1504
1521
|
* subtree with its version stamp (Phase 1C: the version is read FIRST). Each part is best-effort
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAEH,OAAO,EAAkB,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAanF,OAAO,KAAK,EAGV,kBAAkB,EAOlB,QAAQ,EACR,cAAc,EAIf,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"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"}
|
package/dist/auth/auth.module.js
CHANGED
|
@@ -130,6 +130,7 @@ const mariadb_or_totp_verifier_js_1 = require("../authentication/mariadb-or-totp
|
|
|
130
130
|
const permission_facts_adapter_js_1 = require("../identity/permission-facts.adapter.js");
|
|
131
131
|
const auth_facts_controller_js_1 = require("../identity/auth-facts.controller.js");
|
|
132
132
|
const index_js_1 = require("../session/index.js");
|
|
133
|
+
const replay_outcome_js_1 = require("../session/replay-outcome.js");
|
|
133
134
|
const persistence_module_js_1 = require("./persistence.module.js");
|
|
134
135
|
const auth_controller_js_1 = require("./auth.controller.js");
|
|
135
136
|
const account_controller_js_1 = require("./account.controller.js");
|
|
@@ -512,16 +513,10 @@ const sessionAuthorityProvider = {
|
|
|
512
513
|
? {
|
|
513
514
|
recentRotations,
|
|
514
515
|
onReplayTolerated: (event) => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
actorId: event.principalId,
|
|
520
|
-
tenantId: event.tenantId,
|
|
521
|
-
action: 'session.refresh_replay_tolerated',
|
|
522
|
-
attributes: { sessionId: event.sessionId },
|
|
523
|
-
})
|
|
524
|
-
.catch(() => {
|
|
516
|
+
// KI #104: inside the refresh route the event is recorded after the re-bind.
|
|
517
|
+
if ((0, replay_outcome_js_1.noteReplayTolerated)(event))
|
|
518
|
+
return;
|
|
519
|
+
void auditSink?.record((0, replay_outcome_js_1.replayAuditEvent)(event, 'tolerated', Date.now())).catch(() => {
|
|
525
520
|
/* best-effort: an audit failure never changes the response */
|
|
526
521
|
});
|
|
527
522
|
},
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
export declare function canonicalEmail(raw: string): string;
|
|
18
18
|
/**
|
|
19
19
|
* A deliberately minimal email shape check (non-empty, a single `@`, a `.` in the domain, no
|
|
20
|
-
* whitespace). A full RFC-5322 validator adds no security value here, only
|
|
21
|
-
* authoritative check is the confirm link reaching the inbox.
|
|
20
|
+
* whitespace, at most 255 characters). A full RFC-5322 validator adds no security value here, only
|
|
21
|
+
* false rejections; the authoritative check is the confirm link reaching the inbox. The length cap
|
|
22
|
+
* keeps every path (create, administrator change, self-service change) from reaching the store with
|
|
23
|
+
* a value it would refuse as errno 1406.
|
|
22
24
|
*/
|
|
23
25
|
export declare function isEmailShaped(value: string): boolean;
|
|
24
26
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-address.d.ts","sourceRoot":"","sources":["../../src/auth/email-address.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD;
|
|
1
|
+
{"version":3,"file":"email-address.d.ts","sourceRoot":"","sources":["../../src/auth/email-address.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD;AAQD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAOpD;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C"}
|
|
@@ -22,14 +22,23 @@ exports.maskEmail = maskEmail;
|
|
|
22
22
|
function canonicalEmail(raw) {
|
|
23
23
|
return raw.trim().normalize('NFKC').trim().toLowerCase();
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The longest address any store takes: `users.u_email` and `email_change_token.ect_new_email` are
|
|
27
|
+
* VARCHAR(255), and MariaDB counts characters (code points), not UTF-16 units (KI #107).
|
|
28
|
+
*/
|
|
29
|
+
const MAX_EMAIL_CHARS = 255;
|
|
25
30
|
/**
|
|
26
31
|
* A deliberately minimal email shape check (non-empty, a single `@`, a `.` in the domain, no
|
|
27
|
-
* whitespace). A full RFC-5322 validator adds no security value here, only
|
|
28
|
-
* authoritative check is the confirm link reaching the inbox.
|
|
32
|
+
* whitespace, at most 255 characters). A full RFC-5322 validator adds no security value here, only
|
|
33
|
+
* false rejections; the authoritative check is the confirm link reaching the inbox. The length cap
|
|
34
|
+
* keeps every path (create, administrator change, self-service change) from reaching the store with
|
|
35
|
+
* a value it would refuse as errno 1406.
|
|
29
36
|
*/
|
|
30
37
|
function isEmailShaped(value) {
|
|
31
38
|
if (/\s/.test(value))
|
|
32
39
|
return false;
|
|
40
|
+
if ([...value].length > MAX_EMAIL_CHARS)
|
|
41
|
+
return false;
|
|
33
42
|
const at = value.indexOf('@');
|
|
34
43
|
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
|
|
35
44
|
return false;
|
|
@@ -118,6 +118,25 @@ export interface SessionTokenBinderOptions {
|
|
|
118
118
|
*/
|
|
119
119
|
readonly rotateGraceMs?: number;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The rotate re-bind as ONE script (KI #105, KI #110). KEYS: 1 the new token's binding, 2 the session
|
|
123
|
+
* claims, 3 the current-token pointer, 4 the previous token's binding (= KEYS[1] when there is none),
|
|
124
|
+
* 5 the pending marker (the token a repeat bound for the grace only). ARGV: 1 payload, 2 access TTL,
|
|
125
|
+
* 3 session TTL, 4 grace-only TTL (min(access TTL, grace)), 5 the pointer value read before the
|
|
126
|
+
* rebind ('' = none), 6 the new token, 7 grace, 8 '1' when core answered a tolerated repeat.
|
|
127
|
+
*
|
|
128
|
+
* 0 the claims are gone (a logout won): nothing is written, the caller refuses.
|
|
129
|
+
* 2 nothing changes. A repeat whose token is bound (its original, or an earlier repeat, bound it);
|
|
130
|
+
* or an original whose token is bound but not pending (the session rotated on past it, and the
|
|
131
|
+
* binding keeps the grace it has).
|
|
132
|
+
* 3 bound for the grace TTL only; the claims, the pointer and the current token are left alone. A
|
|
133
|
+
* repeat whose token is unbound (its original has not re-bound yet: the token is marked pending
|
|
134
|
+
* so the original can still take the full bind), or an original whose pointer moved since it
|
|
135
|
+
* was read (the session rotated on).
|
|
136
|
+
* 1 the normal rotate (an original; its token unbound, or pending): bind for the access TTL,
|
|
137
|
+
* refresh the claims, move the pointer, grace-expire the previous token, clear the marker.
|
|
138
|
+
*/
|
|
139
|
+
export declare const REBIND_ON_ROTATE_SCRIPT = "if redis.call('EXISTS', KEYS[2]) == 0 then return 0 end\nlocal bound = redis.call('EXISTS', KEYS[1]) == 1\nif ARGV[8] == '1' then\n if bound then return 2 end\n redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[4])\n redis.call('SET', KEYS[5], ARGV[6], 'PX', ARGV[4])\n return 3\nend\nif bound and redis.call('GET', KEYS[5]) ~= ARGV[6] then return 2 end\nlocal current = redis.call('GET', KEYS[3])\nif current == false then current = '' end\nif current ~= ARGV[5] then\n if bound then return 2 end\n redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[4])\n return 3\nend\nredis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\nredis.call('SET', KEYS[2], ARGV[1], 'PX', ARGV[3], 'XX')\nredis.call('SET', KEYS[3], ARGV[6], 'PX', ARGV[3])\nif ARGV[5] ~= '' and KEYS[4] ~= KEYS[1] then\n local remaining = redis.call('PTTL', KEYS[4])\n if remaining == -1 or remaining > tonumber(ARGV[7]) then\n redis.call('PEXPIRE', KEYS[4], ARGV[7])\n end\nend\nredis.call('DEL', KEYS[5])\nreturn 1";
|
|
121
140
|
export declare class SessionTokenBinder {
|
|
122
141
|
private readonly redis;
|
|
123
142
|
private readonly options;
|
|
@@ -134,12 +153,6 @@ export declare class SessionTokenBinder {
|
|
|
134
153
|
* token so a rotation can grace-expire it and a revoke can drop it.
|
|
135
154
|
*/
|
|
136
155
|
bind(accessToken: string, claims: BoundPrincipalClaims, ttlMs: number): Promise<void>;
|
|
137
|
-
/**
|
|
138
|
-
* Grace-expire the session's previous access token after a rotation (plan D7): it stays
|
|
139
|
-
* usable for at most `rotateGraceMs` (never longer than it already had), then expires.
|
|
140
|
-
* Best-effort — a failure leaves the old binding to its own (≤ access TTL) expiry.
|
|
141
|
-
*/
|
|
142
|
-
private graceExpire;
|
|
143
156
|
/**
|
|
144
157
|
* On refresh/rotate the controller has the NEW access token + the session, but not
|
|
145
158
|
* the full Principal (roles/kind/root live only in the binding, not the
|
|
@@ -149,7 +162,13 @@ export declare class SessionTokenBinder {
|
|
|
149
162
|
* then refuse the refresh (the auth controller answers 401: an unbound token is never
|
|
150
163
|
* handed out). A Redis error on the writes propagates (the caller fails closed too).
|
|
151
164
|
* The new binding's TTL is `ttlMs` (≤ the access TTL); the session's previous access
|
|
152
|
-
* token is grace-expired (≤ 30 s by default, plan D7).
|
|
165
|
+
* token is grace-expired (≤ 30 s by default, plan D7). All writes are one script
|
|
166
|
+
* ({@link REBIND_ON_ROTATE_SCRIPT}, KI #105, KI #110). `opts.repeat` (core answered the rotation
|
|
167
|
+
* from the replay window) never moves the pointer: a bound token changes nothing, an unbound one is
|
|
168
|
+
* bound for the grace only and marked pending. An original rotation takes the full bind when its
|
|
169
|
+
* token is unbound or pending and the pointer has not moved since it was read; otherwise its token
|
|
170
|
+
* is left as it is (or bound for the grace only), so no rotation can take the session back from a
|
|
171
|
+
* newer token.
|
|
153
172
|
*
|
|
154
173
|
* `freshFacts` (ADR-0011 eager projection at REFRESH):
|
|
155
174
|
* - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
|
|
@@ -181,7 +200,11 @@ export declare class SessionTokenBinder {
|
|
|
181
200
|
* switch raced the refresh) → nothing fresh is applied and the cached facts are stripped (the
|
|
182
201
|
* next request reloads for the right node). Absent → applied as before.
|
|
183
202
|
*/
|
|
184
|
-
freshForTenantId?: string
|
|
203
|
+
freshForTenantId?: string,
|
|
204
|
+
/** `repeat`: core answered this rotation from the replay window (KI #110). Absent → an original. */
|
|
205
|
+
opts?: {
|
|
206
|
+
readonly repeat?: boolean;
|
|
207
|
+
}): Promise<boolean>;
|
|
185
208
|
/**
|
|
186
209
|
* SILENT RECOVERY (ADR-0013 / ADR-0035, GAPS #5): write a session binding REBUILT from durable
|
|
187
210
|
* state after the session-claims entry was lost (Redis restart/flush). The caller (refresh) has
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-token-binder.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;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;IACpC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjD;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;CACvC;AA2CD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAQ5D;AAED,4FAA4F;AAC5F,wBAAgB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAIzF;
|
|
1
|
+
{"version":3,"file":"session-token-binder.d.ts","sourceRoot":"","sources":["../../src/request-auth/session-token-binder.ts"],"names":[],"mappings":"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"}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* and the stored value carries no secret (claims only — ids, not credentials).
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
-
exports.SessionTokenBinder = void 0;
|
|
27
|
+
exports.SessionTokenBinder = exports.REBIND_ON_ROTATE_SCRIPT = void 0;
|
|
28
28
|
exports.factsSlot = factsSlot;
|
|
29
29
|
exports.liveRoleIds = liveRoleIds;
|
|
30
30
|
/** A valid stamp: a non-negative safe integer. */
|
|
@@ -148,8 +148,62 @@ function sessClaimsKey(sessionId) {
|
|
|
148
148
|
function sessAtKey(sessionId) {
|
|
149
149
|
return `${SESS_AT_PREFIX}${sessionId}`;
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Redis key prefix for the session-id → the token a tolerated repeat bound for the grace only
|
|
153
|
+
* (KI #110): the one token the original rotation may still upgrade to the full bind.
|
|
154
|
+
*/
|
|
155
|
+
const SESS_GRACE_PREFIX = 'shield:sessgrace:';
|
|
156
|
+
function sessGraceKey(sessionId) {
|
|
157
|
+
return `${SESS_GRACE_PREFIX}${sessionId}`;
|
|
158
|
+
}
|
|
151
159
|
/** Default grace for the previous access token after a rotation (plan decision D7). */
|
|
152
160
|
const DEFAULT_ROTATE_GRACE_MS = 30_000;
|
|
161
|
+
/**
|
|
162
|
+
* The rotate re-bind as ONE script (KI #105, KI #110). KEYS: 1 the new token's binding, 2 the session
|
|
163
|
+
* claims, 3 the current-token pointer, 4 the previous token's binding (= KEYS[1] when there is none),
|
|
164
|
+
* 5 the pending marker (the token a repeat bound for the grace only). ARGV: 1 payload, 2 access TTL,
|
|
165
|
+
* 3 session TTL, 4 grace-only TTL (min(access TTL, grace)), 5 the pointer value read before the
|
|
166
|
+
* rebind ('' = none), 6 the new token, 7 grace, 8 '1' when core answered a tolerated repeat.
|
|
167
|
+
*
|
|
168
|
+
* 0 the claims are gone (a logout won): nothing is written, the caller refuses.
|
|
169
|
+
* 2 nothing changes. A repeat whose token is bound (its original, or an earlier repeat, bound it);
|
|
170
|
+
* or an original whose token is bound but not pending (the session rotated on past it, and the
|
|
171
|
+
* binding keeps the grace it has).
|
|
172
|
+
* 3 bound for the grace TTL only; the claims, the pointer and the current token are left alone. A
|
|
173
|
+
* repeat whose token is unbound (its original has not re-bound yet: the token is marked pending
|
|
174
|
+
* so the original can still take the full bind), or an original whose pointer moved since it
|
|
175
|
+
* was read (the session rotated on).
|
|
176
|
+
* 1 the normal rotate (an original; its token unbound, or pending): bind for the access TTL,
|
|
177
|
+
* refresh the claims, move the pointer, grace-expire the previous token, clear the marker.
|
|
178
|
+
*/
|
|
179
|
+
exports.REBIND_ON_ROTATE_SCRIPT = `if redis.call('EXISTS', KEYS[2]) == 0 then return 0 end
|
|
180
|
+
local bound = redis.call('EXISTS', KEYS[1]) == 1
|
|
181
|
+
if ARGV[8] == '1' then
|
|
182
|
+
if bound then return 2 end
|
|
183
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[4])
|
|
184
|
+
redis.call('SET', KEYS[5], ARGV[6], 'PX', ARGV[4])
|
|
185
|
+
return 3
|
|
186
|
+
end
|
|
187
|
+
if bound and redis.call('GET', KEYS[5]) ~= ARGV[6] then return 2 end
|
|
188
|
+
local current = redis.call('GET', KEYS[3])
|
|
189
|
+
if current == false then current = '' end
|
|
190
|
+
if current ~= ARGV[5] then
|
|
191
|
+
if bound then return 2 end
|
|
192
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[4])
|
|
193
|
+
return 3
|
|
194
|
+
end
|
|
195
|
+
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
|
196
|
+
redis.call('SET', KEYS[2], ARGV[1], 'PX', ARGV[3], 'XX')
|
|
197
|
+
redis.call('SET', KEYS[3], ARGV[6], 'PX', ARGV[3])
|
|
198
|
+
if ARGV[5] ~= '' and KEYS[4] ~= KEYS[1] then
|
|
199
|
+
local remaining = redis.call('PTTL', KEYS[4])
|
|
200
|
+
if remaining == -1 or remaining > tonumber(ARGV[7]) then
|
|
201
|
+
redis.call('PEXPIRE', KEYS[4], ARGV[7])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
redis.call('DEL', KEYS[5])
|
|
205
|
+
return 1`;
|
|
206
|
+
const REBIND_REFUSED = 0;
|
|
153
207
|
/** How long refresh waits for Redis to answer PING before answering 503 (ADR-0035, F1). */
|
|
154
208
|
const DEFAULT_PING_TIMEOUT_MS = 1_000;
|
|
155
209
|
class SessionTokenBinder {
|
|
@@ -184,25 +238,6 @@ class SessionTokenBinder {
|
|
|
184
238
|
await this.redis.set(sessClaimsKey(claims.sessionId), payload, 'PX', sessTtl);
|
|
185
239
|
await this.redis.set(sessAtKey(claims.sessionId), accessToken, 'PX', sessTtl);
|
|
186
240
|
}
|
|
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
|
-
}
|
|
205
|
-
}
|
|
206
241
|
/**
|
|
207
242
|
* On refresh/rotate the controller has the NEW access token + the session, but not
|
|
208
243
|
* the full Principal (roles/kind/root live only in the binding, not the
|
|
@@ -212,7 +247,13 @@ class SessionTokenBinder {
|
|
|
212
247
|
* then refuse the refresh (the auth controller answers 401: an unbound token is never
|
|
213
248
|
* handed out). A Redis error on the writes propagates (the caller fails closed too).
|
|
214
249
|
* 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).
|
|
250
|
+
* token is grace-expired (≤ 30 s by default, plan D7). All writes are one script
|
|
251
|
+
* ({@link REBIND_ON_ROTATE_SCRIPT}, KI #105, KI #110). `opts.repeat` (core answered the rotation
|
|
252
|
+
* from the replay window) never moves the pointer: a bound token changes nothing, an unbound one is
|
|
253
|
+
* bound for the grace only and marked pending. An original rotation takes the full bind when its
|
|
254
|
+
* token is unbound or pending and the pointer has not moved since it was read; otherwise its token
|
|
255
|
+
* is left as it is (or bound for the grace only), so no rotation can take the session back from a
|
|
256
|
+
* newer token.
|
|
216
257
|
*
|
|
217
258
|
* `freshFacts` (ADR-0011 eager projection at REFRESH):
|
|
218
259
|
* - `undefined` (omitted, the default) → carry whatever `cachedFacts` the session
|
|
@@ -244,9 +285,15 @@ class SessionTokenBinder {
|
|
|
244
285
|
* switch raced the refresh) → nothing fresh is applied and the cached facts are stripped (the
|
|
245
286
|
* next request reloads for the right node). Absent → applied as before.
|
|
246
287
|
*/
|
|
247
|
-
freshForTenantId
|
|
288
|
+
freshForTenantId,
|
|
289
|
+
/** `repeat`: core answered this rotation from the replay window (KI #110). Absent → an original. */
|
|
290
|
+
opts) {
|
|
291
|
+
// The session's current access token, read FIRST: the script moves the pointer only if it still
|
|
292
|
+
// holds this value (KI #105).
|
|
293
|
+
let previousToken;
|
|
248
294
|
let raw;
|
|
249
295
|
try {
|
|
296
|
+
previousToken = await this.redis.get(sessAtKey(sessionId));
|
|
250
297
|
raw = await this.redis.get(sessClaimsKey(sessionId));
|
|
251
298
|
}
|
|
252
299
|
catch {
|
|
@@ -304,28 +351,12 @@ class SessionTokenBinder {
|
|
|
304
351
|
payload = raw;
|
|
305
352
|
}
|
|
306
353
|
}
|
|
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
354
|
const sessTtl = this.sessionTtl(ttlMs);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
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;
|
|
355
|
+
const grace = this.options.rotateGraceMs ?? DEFAULT_ROTATE_GRACE_MS;
|
|
356
|
+
const expected = previousToken ?? '';
|
|
357
|
+
// KI #105: one script, so nothing can land between the checks and the writes.
|
|
358
|
+
const outcome = await this.redis.eval(exports.REBIND_ON_ROTATE_SCRIPT, 5, atKey(newAccessToken), sessClaimsKey(sessionId), sessAtKey(sessionId), expected === '' ? atKey(newAccessToken) : atKey(expected), sessGraceKey(sessionId), payload, String(ttlMs), String(sessTtl), String(Math.max(1, Math.min(ttlMs, grace))), expected, newAccessToken, String(grace), opts?.repeat === true ? '1' : '0');
|
|
359
|
+
return Number(outcome) !== REBIND_REFUSED;
|
|
329
360
|
}
|
|
330
361
|
/**
|
|
331
362
|
* SILENT RECOVERY (ADR-0013 / ADR-0035, GAPS #5): write a session binding REBUILT from durable
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReplayToleratedEvent } from '@astram/core';
|
|
2
|
+
import type { AuthAuditEvent } from '../auth/ports/audit-sink.js';
|
|
3
|
+
/** Runs `fn` and returns its result with the tolerated-repeat event core reported during it, if any. */
|
|
4
|
+
export declare function captureReplayDuring<T>(fn: () => Promise<T>): Promise<{
|
|
5
|
+
result: T;
|
|
6
|
+
replay: ReplayToleratedEvent | undefined;
|
|
7
|
+
}>;
|
|
8
|
+
/** Notes the event for the enclosing {@link captureReplayDuring}; false when there is none. */
|
|
9
|
+
export declare function noteReplayTolerated(event: ReplayToleratedEvent): boolean;
|
|
10
|
+
/** The audit event for a tolerated repeat: answered (`tolerated`) or refused at the re-bind. */
|
|
11
|
+
export declare function replayAuditEvent(event: ReplayToleratedEvent, outcome: 'tolerated' | 'refused', occurredAtMs: number): AuthAuditEvent;
|
|
12
|
+
//# sourceMappingURL=replay-outcome.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay-outcome.d.ts","sourceRoot":"","sources":["../../src/session/replay-outcome.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAQlE,wGAAwG;AACxG,wBAAsB,mBAAmB,CAAC,CAAC,EACzC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC;IAAE,MAAM,EAAE,CAAC,CAAC;IAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,CAAA;CAAE,CAAC,CAIlE;AAED,+FAA+F;AAC/F,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAKxE;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,oBAAoB,EAC3B,OAAO,EAAE,WAAW,GAAG,SAAS,EAChC,YAAY,EAAE,MAAM,GACnB,cAAc,CAYhB"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.captureReplayDuring = captureReplayDuring;
|
|
4
|
+
exports.noteReplayTolerated = noteReplayTolerated;
|
|
5
|
+
exports.replayAuditEvent = replayAuditEvent;
|
|
6
|
+
/**
|
|
7
|
+
* KI #104: core reports a tolerated refresh repeat (`onReplayTolerated`) from inside `rotate`, before
|
|
8
|
+
* the controller has re-bound the access token. The refresh route runs `rotate` inside
|
|
9
|
+
* {@link captureReplayDuring}; the callback then only notes the event, and the route records the
|
|
10
|
+
* audit AFTER the re-bind with the real outcome (`session.refresh_replay_tolerated`, or
|
|
11
|
+
* `session.refresh_replay_refused` when the re-bind refuses). A rotate outside that scope (another
|
|
12
|
+
* caller) is recorded at once, as before.
|
|
13
|
+
*/
|
|
14
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
15
|
+
const capture = new node_async_hooks_1.AsyncLocalStorage();
|
|
16
|
+
/** Runs `fn` and returns its result with the tolerated-repeat event core reported during it, if any. */
|
|
17
|
+
async function captureReplayDuring(fn) {
|
|
18
|
+
const slot = {};
|
|
19
|
+
const result = await capture.run(slot, fn);
|
|
20
|
+
return { result, replay: slot.event };
|
|
21
|
+
}
|
|
22
|
+
/** Notes the event for the enclosing {@link captureReplayDuring}; false when there is none. */
|
|
23
|
+
function noteReplayTolerated(event) {
|
|
24
|
+
const slot = capture.getStore();
|
|
25
|
+
if (slot === undefined)
|
|
26
|
+
return false;
|
|
27
|
+
slot.event = event;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
/** The audit event for a tolerated repeat: answered (`tolerated`) or refused at the re-bind. */
|
|
31
|
+
function replayAuditEvent(event, outcome, occurredAtMs) {
|
|
32
|
+
return {
|
|
33
|
+
type: 'authentication',
|
|
34
|
+
occurredAtMs,
|
|
35
|
+
actorId: event.principalId,
|
|
36
|
+
tenantId: event.tenantId,
|
|
37
|
+
action: outcome === 'tolerated'
|
|
38
|
+
? 'session.refresh_replay_tolerated'
|
|
39
|
+
: 'session.refresh_replay_refused',
|
|
40
|
+
attributes: { sessionId: event.sessionId },
|
|
41
|
+
};
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astram/guard",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"argon2": "^0.41.1",
|
|
36
36
|
"ioredis": "^5.4.1",
|
|
37
37
|
"mariadb": "^3.5.4",
|
|
38
|
-
"@astram/
|
|
39
|
-
"@astram/
|
|
38
|
+
"@astram/core": "1.5.0",
|
|
39
|
+
"@astram/engine": "0.5.3"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@nestjs/common": "^11",
|