@astram/guard 0.6.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 +241 -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 +46 -0
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -0
- package/dist/authentication/mariadb-api-key-store.js +111 -0
- 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 +52 -0
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +66 -4
- package/dist/encryption/local-kek-crypto.d.ts +15 -0
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -0
- package/dist/encryption/local-kek-crypto.js +79 -0
- 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 +31 -2
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +148 -6
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/identity-actions.d.ts +9 -0
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +16 -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 +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +82 -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/request-principal.d.ts +11 -3
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +58 -2
- 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/request-auth/tokens.d.ts +10 -0
- package/dist/request-auth/tokens.d.ts.map +1 -1
- package/dist/request-auth/tokens.js +11 -1
- package/dist/secrets/encrypted-file-secrets.d.ts +16 -0
- package/dist/secrets/encrypted-file-secrets.d.ts.map +1 -0
- package/dist/secrets/encrypted-file-secrets.js +98 -0
- 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/migrations/1710000000017-CreateServiceAccountKey.cjs +41 -0
- package/package.json +3 -3
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
* ── OUTCOME → HTTP STATUS MAP (the security contract) ────────────────────────
|
|
16
16
|
* login authenticated → 200 SessionSuccess { session(no-secret), tokens }
|
|
17
17
|
* login mfa_required → 200 MfaChallengeResponse { mfaRequired, challengeToken }
|
|
18
|
+
* (a Redis challenge: principal + hint + credential
|
|
19
|
+
* fingerprint, never the password — M6)
|
|
18
20
|
* login invalid_credentials → 401 GENERIC ← unknown-user AND wrong-password IDENTICAL
|
|
19
21
|
* login auth_unavailable → 503 RETRYABLE ← transient blip is NOT a credential deny
|
|
20
22
|
* mfa success → 200 SessionSuccess
|
|
21
23
|
* mfa wrong/expired/forged → 401 GENERIC ← indistinguishable from a bad code
|
|
24
|
+
* mfa TOTP step locked (#9) → 401 GENERIC (5 wrong codes / 15 min per account)
|
|
22
25
|
* mfa auth_unavailable → 503 RETRYABLE
|
|
23
26
|
* refresh ok → 200 SessionSuccess (new tokens, rotated session)
|
|
24
27
|
* refresh reuse/invalid → 401 GENERIC session_rejected (lineage-kill is the engine's)
|
|
@@ -69,8 +72,15 @@ const node_crypto_1 = require("node:crypto");
|
|
|
69
72
|
const common_2 = require("@nestjs/common");
|
|
70
73
|
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
71
74
|
const tokens_js_1 = require("./tokens.js");
|
|
75
|
+
const mariadb_session_record_store_js_1 = require("../session/mariadb-session-record-store.js");
|
|
76
|
+
const crypto_adapters_js_1 = require("../session/crypto-adapters.js");
|
|
77
|
+
const session_lifetimes_js_1 = require("../session/session-lifetimes.js");
|
|
78
|
+
const mfa_step_up_js_1 = require("./mfa-step-up.js");
|
|
79
|
+
const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
|
|
80
|
+
const trust_proxy_check_js_1 = require("./rate-limit/trust-proxy-check.js");
|
|
72
81
|
const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
|
|
73
82
|
const tokens_js_2 = require("../request-auth/tokens.js");
|
|
83
|
+
const session_token_binder_js_1 = require("../request-auth/session-token-binder.js");
|
|
74
84
|
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
75
85
|
const tokens_js_3 = require("./tokens.js");
|
|
76
86
|
/**
|
|
@@ -172,6 +182,8 @@ let AuthController = class AuthController {
|
|
|
172
182
|
resetLinkBaseUrl;
|
|
173
183
|
recoveryCodes;
|
|
174
184
|
loginLimiter;
|
|
185
|
+
loginIpLimiter;
|
|
186
|
+
trustProxyCheck;
|
|
175
187
|
recoveryLimiter;
|
|
176
188
|
forgotLimiter;
|
|
177
189
|
forgotHintLimiter;
|
|
@@ -181,7 +193,14 @@ let AuthController = class AuthController {
|
|
|
181
193
|
scopeSubtreeResolver;
|
|
182
194
|
membershipChecker;
|
|
183
195
|
permissionFacts;
|
|
184
|
-
|
|
196
|
+
mfaLimiter;
|
|
197
|
+
mfaStepUp;
|
|
198
|
+
clock;
|
|
199
|
+
lifetimes;
|
|
200
|
+
rootIds;
|
|
201
|
+
sessionRecords;
|
|
202
|
+
sessionRestrictions;
|
|
203
|
+
constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, loginIpLimiter, trustProxyCheck, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector, scopeSubtreeResolver, membershipChecker, permissionFacts, mfaLimiter, mfaStepUp, clock, lifetimes, rootIds, sessionRecords, sessionRestrictions) {
|
|
185
204
|
this.authenticator = authenticator;
|
|
186
205
|
this.sessionAuthority = sessionAuthority;
|
|
187
206
|
this.pendingMfa = pendingMfa;
|
|
@@ -196,6 +215,8 @@ let AuthController = class AuthController {
|
|
|
196
215
|
this.resetLinkBaseUrl = resetLinkBaseUrl;
|
|
197
216
|
this.recoveryCodes = recoveryCodes;
|
|
198
217
|
this.loginLimiter = loginLimiter;
|
|
218
|
+
this.loginIpLimiter = loginIpLimiter;
|
|
219
|
+
this.trustProxyCheck = trustProxyCheck;
|
|
199
220
|
this.recoveryLimiter = recoveryLimiter;
|
|
200
221
|
this.forgotLimiter = forgotLimiter;
|
|
201
222
|
this.forgotHintLimiter = forgotHintLimiter;
|
|
@@ -205,34 +226,63 @@ let AuthController = class AuthController {
|
|
|
205
226
|
this.scopeSubtreeResolver = scopeSubtreeResolver;
|
|
206
227
|
this.membershipChecker = membershipChecker;
|
|
207
228
|
this.permissionFacts = permissionFacts;
|
|
229
|
+
this.mfaLimiter = mfaLimiter;
|
|
230
|
+
this.mfaStepUp = mfaStepUp;
|
|
231
|
+
this.clock = clock;
|
|
232
|
+
this.lifetimes = lifetimes;
|
|
233
|
+
this.rootIds = rootIds;
|
|
234
|
+
this.sessionRecords = sessionRecords;
|
|
235
|
+
this.sessionRestrictions = sessionRestrictions;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The access-token binding TTL for an establishment (security hardening #8):
|
|
239
|
+
* `clamp(session.expiresAtMs − now, 1, accessTtlMs)` — on login AND refresh. Never derived
|
|
240
|
+
* from `issuedAtMs` (the original login), which made refresh-minted tokens live for the
|
|
241
|
+
* session's age.
|
|
242
|
+
*/
|
|
243
|
+
accessTtlFor(est) {
|
|
244
|
+
const now = this.clock?.nowMs() ?? Date.now();
|
|
245
|
+
return (0, session_lifetimes_js_1.accessBindingTtlMs)(est.session.expiresAtMs, now, this.lifetimes?.accessTtlMs ?? session_lifetimes_js_1.DEFAULT_ACCESS_TTL_MS);
|
|
208
246
|
}
|
|
209
247
|
/**
|
|
210
248
|
* Bind the established session's access token to the principal's claims (the
|
|
211
|
-
* request-auth layer). The access-token TTL is
|
|
212
|
-
*
|
|
249
|
+
* request-auth layer). The access-token TTL is {@link accessTtlFor} (≤ the access TTL).
|
|
250
|
+
* Best-effort: a binding failure must not fail the login (the caller still
|
|
213
251
|
* gets valid tokens); the guard fails closed if the binding is absent.
|
|
214
252
|
*/
|
|
215
253
|
async bindSession(est, principal, mustReenroll = false) {
|
|
216
254
|
if (this.tokenBinder === undefined)
|
|
217
255
|
return;
|
|
218
|
-
const ttlMs =
|
|
256
|
+
const ttlMs = this.accessTtlFor(est);
|
|
219
257
|
// EAGER session-facts projection (ADR-0011): compute the principal's coarse grants
|
|
220
258
|
// NOW so the guard can serve gate 5 from the request's `cachedFacts`. Absent projector
|
|
221
259
|
// / a null result → `cachedFacts` stays undefined → gate 5 DB-reloads (today's
|
|
222
260
|
// behavior). Best-effort inside projectFacts (a failure → undefined, never throws).
|
|
223
|
-
const
|
|
261
|
+
const projected = await this.projectFactsFor(principal.id, principal.tenantId);
|
|
262
|
+
const cachedFacts = projected ?? undefined;
|
|
224
263
|
// EAGER active-scope-subtree resolution (Task 2.2, ADR-0027 / ADR-0024): resolve the
|
|
225
264
|
// principal's closure subtree (self node + descendants) NOW so the per-request tenant gate
|
|
226
265
|
// reads it from the binding with zero DB (the parity guarantee — the closure query happens
|
|
227
266
|
// ONLY here, at login). Absent resolver / empty result / a thrown resolver → undefined →
|
|
228
267
|
// `scopeSubtreeNodeIds` stays absent → the per-request path falls back to the self-node.
|
|
229
268
|
// Best-effort inside resolveScopeSubtree (a failure → undefined, never throws).
|
|
269
|
+
// The version is read FIRST (Phase 1C) so the stamp can only be older than the subtree.
|
|
270
|
+
const scopeSubtreeVersion = await this.subtreeVersionFor(principal.tenantId);
|
|
230
271
|
const scopeSubtreeNodeIds = await this.resolveScopeSubtree(principal);
|
|
272
|
+
// LIVE ROLES (ADR-0035, GAPS #4): the in-effect posting roles from the SAME projection as the
|
|
273
|
+
// cached facts, not the credential's login-time `ic_roles`.
|
|
274
|
+
// - projection reported roles → those roles;
|
|
275
|
+
// - an active projector failed / yielded nothing (null) → `[]` (review P2-d: never widen to
|
|
276
|
+
// the `ic_roles` snapshot; no facts are cached, so the first request's gate-5 reload binds
|
|
277
|
+
// the live roles before any handler runs);
|
|
278
|
+
// - a projection without `roleIds` (a port that does not report roles) or no projector at all
|
|
279
|
+
// → the authenticated principal's roles (back-compat for apps that own their roles).
|
|
280
|
+
const roleIds = projected === null ? [] : ((0, session_token_binder_js_1.liveRoleIds)(projected) ?? [...principal.roleIds]);
|
|
231
281
|
try {
|
|
232
282
|
await this.tokenBinder.bind(est.tokens.accessToken, {
|
|
233
283
|
id: principal.id,
|
|
234
284
|
tenantId: principal.tenantId,
|
|
235
|
-
roleIds
|
|
285
|
+
roleIds,
|
|
236
286
|
kind: principal.kind,
|
|
237
287
|
isRoot: principal.isRoot === true,
|
|
238
288
|
sessionId: est.session.sessionId,
|
|
@@ -242,11 +292,15 @@ let AuthController = class AuthController {
|
|
|
242
292
|
// Only include the key when a projection produced facts — under
|
|
243
293
|
// exactOptionalPropertyTypes an explicit `undefined` is not assignable to the
|
|
244
294
|
// optional field; a spread keeps it truly absent (→ gate 5 DB-reloads).
|
|
245
|
-
...(cachedFacts ? { cachedFacts } : {}),
|
|
295
|
+
...(cachedFacts ? { cachedFacts: (0, session_token_binder_js_1.factsSlot)(cachedFacts) } : {}),
|
|
246
296
|
// Same spread discipline for the resolved subtree: present only when the resolver
|
|
247
297
|
// produced a non-empty set (→ the per-request tenant gate reads the real subtree);
|
|
248
298
|
// absent otherwise (→ the per-request path falls back to the self-node, non-widening).
|
|
249
299
|
...(scopeSubtreeNodeIds ? { scopeSubtreeNodeIds } : {}),
|
|
300
|
+
// The version the subtree was resolved at (Phase 1C) — only beside a subtree.
|
|
301
|
+
...(scopeSubtreeNodeIds && scopeSubtreeVersion !== undefined
|
|
302
|
+
? { scopeSubtreeVersion }
|
|
303
|
+
: {}),
|
|
250
304
|
}, ttlMs);
|
|
251
305
|
}
|
|
252
306
|
catch {
|
|
@@ -254,21 +308,6 @@ let AuthController = class AuthController {
|
|
|
254
308
|
// binding is missing, so this degrades safely (the user re-logs in).
|
|
255
309
|
}
|
|
256
310
|
}
|
|
257
|
-
/**
|
|
258
|
-
* Project the principal's coarse grants for the session binding (ADR-0011), via the
|
|
259
|
-
* OPTIONAL {@link SessionFactsProjector} seam. Returns the {@link CoarseGrants} to
|
|
260
|
-
* cache, or `undefined` when there is no projector, the projection yielded nothing,
|
|
261
|
-
* OR it threw — every "no facts" path collapses to `undefined` so the caller simply
|
|
262
|
-
* omits `cachedFacts` and the request DB-reloads (fail-closed; a projection failure
|
|
263
|
-
* NEVER fails the login/refresh and never fabricates a grant).
|
|
264
|
-
*/
|
|
265
|
-
async projectFacts(principal) {
|
|
266
|
-
const facts = await this.projectFactsFor(principal.id, principal.tenantId);
|
|
267
|
-
// At LOGIN there is no prior cache, so "no projector" (undefined) and "yielded
|
|
268
|
-
// nothing" (null) BOTH mean "omit cachedFacts" → gate 5 DB-reloads. Collapse to
|
|
269
|
-
// undefined for the bind() spread.
|
|
270
|
-
return facts ?? undefined;
|
|
271
|
-
}
|
|
272
311
|
/**
|
|
273
312
|
* Project coarse grants for a principal id + tenant (ADR-0011), distinguishing THREE
|
|
274
313
|
* outcomes for the REFRESH path (which must know whether to carry the existing cache
|
|
@@ -284,6 +323,9 @@ let AuthController = class AuthController {
|
|
|
284
323
|
const projector = this.sessionFactsProjector;
|
|
285
324
|
if (projector === undefined || projector === null)
|
|
286
325
|
return undefined;
|
|
326
|
+
// The framework's deferred default projector before its port is attached = no projector.
|
|
327
|
+
if (typeof projector.isActive === 'function' && projector.isActive() !== true)
|
|
328
|
+
return undefined;
|
|
287
329
|
try {
|
|
288
330
|
return await projector.project(principalId, tenantId);
|
|
289
331
|
}
|
|
@@ -317,6 +359,30 @@ let AuthController = class AuthController {
|
|
|
317
359
|
* bumped by moveNode/deleteNode) re-derives its CORRECT subtree here, closing the
|
|
318
360
|
* stale-subtree-after-move window (bounded by the access-token lifetime — ADR-0011).
|
|
319
361
|
*/
|
|
362
|
+
/**
|
|
363
|
+
* The permission version of `tenantId`, read just BEFORE its subtree is resolved, to stamp the
|
|
364
|
+
* binding (`scopeSubtreeVersion`, security hardening Phase 1C): when gate 5 later reloads facts
|
|
365
|
+
* at a newer version, the enforcement guard re-derives the subtree. Read before the resolve so a
|
|
366
|
+
* racing bump leaves the stamp too old (one extra re-derive), never too new. Only when a
|
|
367
|
+
* subtree resolver is wired (no subtree → nothing to stamp, no extra read). Best-effort: no
|
|
368
|
+
* facts adapter, a non-integer, or a throw → `undefined` (unstamped → the guard re-derives once
|
|
369
|
+
* at the next reload). One indexed read at login/refresh/scope-switch, never per request.
|
|
370
|
+
*/
|
|
371
|
+
async subtreeVersionFor(tenantId) {
|
|
372
|
+
if (this.scopeSubtreeResolver === undefined || this.scopeSubtreeResolver === null) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const facts = this.permissionFacts;
|
|
376
|
+
if (facts === undefined || facts === null)
|
|
377
|
+
return undefined;
|
|
378
|
+
try {
|
|
379
|
+
const v = await facts.currentVersionFor('', tenantId);
|
|
380
|
+
return Number.isSafeInteger(v) && v >= 0 ? v : undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
320
386
|
async resolveScopeSubtreeFor(tenantId) {
|
|
321
387
|
const resolver = this.scopeSubtreeResolver;
|
|
322
388
|
if (resolver === undefined || resolver === null)
|
|
@@ -403,21 +469,70 @@ let AuthController = class AuthController {
|
|
|
403
469
|
isRoot: false,
|
|
404
470
|
};
|
|
405
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* The account (principal id) a typed hint resolves to, for keying the per-account
|
|
474
|
+
* limiters (review F1). Goes through the SAME injected {@link CredentialResolver} the
|
|
475
|
+
* composed authenticator uses — the default is the indexed
|
|
476
|
+
* `WHERE ic_identity_hint = ? LIMIT 1` on the `utf8mb4_unicode_ci` column — so the DB
|
|
477
|
+
* collation, not client-side normalisation, decides which spellings are one account.
|
|
478
|
+
* No new access path. The RAW hint is passed (never normalised).
|
|
479
|
+
*
|
|
480
|
+
* Returns `null` for an unknown hint, a blank hint, or when no resolver is wired (→ the
|
|
481
|
+
* canonical-hash fallback key). A store ERROR propagates (the caller fails closed).
|
|
482
|
+
*/
|
|
483
|
+
/**
|
|
484
|
+
* The ONE gate for every per-IP limiter (login failures, forgot-password, reset-password):
|
|
485
|
+
* returns the limiter only when bootstrap confirmed an explicit Express `trust proxy`
|
|
486
|
+
* ({@link TrustProxyBootCheck.perIpLimitingEnabled}); otherwise `null` (limiter OFF).
|
|
487
|
+
* Without it every client behind a proxy shares one `req.ip`, so enforcing a per-IP
|
|
488
|
+
* limit would block everyone (an unauthenticated global DoS). Per-account limits are
|
|
489
|
+
* never routed through here.
|
|
490
|
+
*/
|
|
491
|
+
perIp(limiter) {
|
|
492
|
+
return this.trustProxyCheck?.perIpLimitingEnabled === true ? (limiter ?? null) : null;
|
|
493
|
+
}
|
|
494
|
+
async lookupAccount(rawHint) {
|
|
495
|
+
const resolver = this.credentialResolver;
|
|
496
|
+
if (resolver == null || rawHint === '')
|
|
497
|
+
return null;
|
|
498
|
+
return resolver.resolve(rawHint);
|
|
499
|
+
}
|
|
406
500
|
/**
|
|
407
501
|
* `POST /auth/login` — verify a password credential and either establish a
|
|
408
502
|
* session, issue an MFA challenge, deny generically, or signal a transient
|
|
409
503
|
* outage. NEVER logs the request body.
|
|
410
504
|
*/
|
|
411
|
-
async login(body) {
|
|
505
|
+
async login(body, ip) {
|
|
506
|
+
// F5: the hint goes to the store EXACTLY as typed — the DB collation
|
|
507
|
+
// (utf8mb4_unicode_ci) decides which spellings are the same account. Never rewritten.
|
|
412
508
|
const identityHint = typeof body?.identityHint === 'string' ? body.identityHint : '';
|
|
413
509
|
const password = typeof body?.password === 'string' ? body.password : '';
|
|
414
|
-
// Rate-limit gate (spec §5 / H1): after M failed attempts within the window
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
// blip (
|
|
419
|
-
|
|
420
|
-
|
|
510
|
+
// Rate-limit gate (spec §5 / H1 + #5): after M failed attempts within the window —
|
|
511
|
+
// for this ACCOUNT, or from this client IP when per-IP limiting is on — reject BEFORE
|
|
512
|
+
// consulting the authenticator, with the SAME generic 401 as a wrong password
|
|
513
|
+
// (anti-enumeration: a locked-out account is indistinguishable). Each limiter fails
|
|
514
|
+
// OPEN on a Redis blip (its own posture — never a lockout from an outage).
|
|
515
|
+
//
|
|
516
|
+
// Per-IP first (cheap, no DB): a locked IP never reaches the account lookup.
|
|
517
|
+
const ipLimiter = this.perIp(this.loginIpLimiter);
|
|
518
|
+
const ipKey = (0, canonical_hint_js_1.loginIpKey)(ip);
|
|
519
|
+
if (ipLimiter != null && !(await ipLimiter.check(ipKey))) {
|
|
520
|
+
genericUnauthorized();
|
|
521
|
+
}
|
|
522
|
+
// F1: key the per-account budget on the account the store resolves for this hint
|
|
523
|
+
// (`login:pid:<id>`; unknown → canonical-hash fallback). A store ERROR fails CLOSED
|
|
524
|
+
// (503), consistent with the authenticator's treatment of a resolver outage.
|
|
525
|
+
let account;
|
|
526
|
+
try {
|
|
527
|
+
account = await this.lookupAccount(identityHint);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
retryableUnavailable();
|
|
531
|
+
}
|
|
532
|
+
const accountId = (0, canonical_hint_js_1.principalIdOf)(account);
|
|
533
|
+
const loginKey = (0, canonical_hint_js_1.loginHintKey)(identityHint, accountId);
|
|
534
|
+
const hintAllowed = this.loginLimiter == null || (await this.loginLimiter.check(loginKey));
|
|
535
|
+
if (!hintAllowed) {
|
|
421
536
|
// Already locked → generic 401. We do NOT audit here: `check()` returns false on
|
|
422
537
|
// EVERY attempt once locked, so auditing at this gate would flood one event per
|
|
423
538
|
// attempt. The lockout is audited ONCE on the TRANSITION instead — in the
|
|
@@ -425,7 +540,9 @@ let AuthController = class AuthController {
|
|
|
425
540
|
// (feature #140 audit-on-the-edge, anti-flood).
|
|
426
541
|
genericUnauthorized();
|
|
427
542
|
}
|
|
428
|
-
const result = await this.authenticator.authenticate(identityHint, [
|
|
543
|
+
const result = await this.authenticator.authenticate(identityHint, [
|
|
544
|
+
passwordCredential(password),
|
|
545
|
+
]);
|
|
429
546
|
if (result.outcome === 'authenticated') {
|
|
430
547
|
// Correct password → clear the login counter (do not carry earlier failures).
|
|
431
548
|
await this.loginLimiter?.reset(loginKey);
|
|
@@ -437,13 +554,29 @@ let AuthController = class AuthController {
|
|
|
437
554
|
return this.sessionSuccessWithFacts(est, principal);
|
|
438
555
|
}
|
|
439
556
|
if (result.outcome === 'mfa_required') {
|
|
440
|
-
// Password verified, a second factor is outstanding.
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
557
|
+
// Password verified, a second factor is outstanding. NOT a failure — but NOT a
|
|
558
|
+
// success either (#9): the login counter is reset only once `/auth/mfa` completes,
|
|
559
|
+
// so a stolen password cannot loop login→mfa to guess TOTP codes for free.
|
|
560
|
+
//
|
|
561
|
+
// M6: record the password step's SUCCESS — the resolved principal, the typed hint,
|
|
562
|
+
// and a fingerprint of the credential it was checked against — NEVER the password.
|
|
563
|
+
// `/auth/mfa` verifies only the second factor against that principal. No resolved
|
|
564
|
+
// account here (no resolver wired / the account vanished mid-login) → fail closed.
|
|
565
|
+
if (account === null || accountId === null)
|
|
566
|
+
genericUnauthorized();
|
|
567
|
+
const challenge = {
|
|
568
|
+
principalId: accountId,
|
|
569
|
+
identityHint,
|
|
570
|
+
credentialFingerprint: (0, mfa_step_up_js_1.credentialFingerprint)(account),
|
|
571
|
+
};
|
|
572
|
+
let challengeToken;
|
|
573
|
+
try {
|
|
574
|
+
challengeToken = await this.pendingMfa.store(challenge);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// The challenge store (Redis) is down: a transient outage, not a credential deny.
|
|
578
|
+
retryableUnavailable();
|
|
579
|
+
}
|
|
447
580
|
return { mfaRequired: true, challengeToken };
|
|
448
581
|
}
|
|
449
582
|
// outcome === 'rejected'. Distinguish ONLY transient-vs-deny (never which
|
|
@@ -458,9 +591,9 @@ let AuthController = class AuthController {
|
|
|
458
591
|
// returns the NEW count; when it first equals the limiter's threshold this attempt
|
|
459
592
|
// is the one that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here
|
|
460
593
|
// (the edge), never on the already-locked check-gate above (which would flood).
|
|
461
|
-
// NO notify: this is pre-auth (
|
|
462
|
-
//
|
|
463
|
-
//
|
|
594
|
+
// NO notify: this is pre-auth (the caller has not proven ownership), and mailing the
|
|
595
|
+
// account on every lockout would let anyone email-bomb it / confirm it exists
|
|
596
|
+
// (recordSecurityEvent enforces this too).
|
|
464
597
|
if (this.loginLimiter != null) {
|
|
465
598
|
const count = await this.loginLimiter.recordFailure(loginKey);
|
|
466
599
|
if (count === this.loginLimiter.maxAttempts) {
|
|
@@ -472,6 +605,20 @@ let AuthController = class AuthController {
|
|
|
472
605
|
});
|
|
473
606
|
}
|
|
474
607
|
}
|
|
608
|
+
// #5: count the failure against the client IP too (failures only — a success never
|
|
609
|
+
// touches this counter, so an attacker's own valid login cannot reset the budget).
|
|
610
|
+
// Audited once on the edge, with no hint (the IP budget spans many accounts).
|
|
611
|
+
if (ipLimiter != null) {
|
|
612
|
+
const ipCount = await ipLimiter.recordFailure(ipKey);
|
|
613
|
+
if (ipCount === ipLimiter.maxAttempts) {
|
|
614
|
+
this.recordSecurityEvent({
|
|
615
|
+
action: 'auth.login.ip_locked',
|
|
616
|
+
identityHint: null,
|
|
617
|
+
attributes: { reason: 'rate_limit' },
|
|
618
|
+
notify: false,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
475
622
|
genericUnauthorized();
|
|
476
623
|
}
|
|
477
624
|
/**
|
|
@@ -600,6 +747,8 @@ let AuthController = class AuthController {
|
|
|
600
747
|
}
|
|
601
748
|
// 4) Re-resolve the subtree for the NEW active node (self-node fallback if unwired /
|
|
602
749
|
// empty / thrown — non-widening, mirrors login). The new subtree REPLACES the old.
|
|
750
|
+
// The target node's version is read first and stamped beside it (Phase 1C).
|
|
751
|
+
const subtreeVersion = await this.subtreeVersionFor(targetNodeId);
|
|
603
752
|
const resolved = await this.resolveScopeSubtree({
|
|
604
753
|
kind: 'user',
|
|
605
754
|
id: claims.id,
|
|
@@ -610,12 +759,18 @@ let AuthController = class AuthController {
|
|
|
610
759
|
isRoot: false,
|
|
611
760
|
});
|
|
612
761
|
const subtree = resolved ?? [targetNodeId];
|
|
762
|
+
// ADR-0035: the TARGET node's coarse grants + live roles (roles are per node). No projection
|
|
763
|
+
// → the facts are stripped and the first request under the new scope reloads them (and the
|
|
764
|
+
// roles with them).
|
|
765
|
+
const targetFacts = await this.projectFactsFor(claims.id, targetNodeId);
|
|
613
766
|
// 5) RE-BIND the current session in place (same token — no rotate). The bound tenantId
|
|
614
767
|
// becomes targetNodeId (→ activeScopeNodeId) + scopeSubtreeNodeIds becomes the NEW
|
|
615
768
|
// subtree; the OLD node's subtree + cachedFacts are DROPPED (stale authority gone).
|
|
616
769
|
// A failed re-bind (session gone / Redis down) → deny (fail-closed, the old binding
|
|
617
770
|
// already effectively dead).
|
|
618
|
-
const ok = await this.tokenBinder.rebindActiveScope(token, claims.sessionId, targetNodeId, subtree
|
|
771
|
+
const ok = await this.tokenBinder.rebindActiveScope(token, claims.sessionId, targetNodeId, subtree,
|
|
772
|
+
// Stamp only a RESOLVED subtree; the self-node fallback stays unstamped.
|
|
773
|
+
resolved !== undefined ? subtreeVersion : undefined, targetFacts ?? null, (0, session_token_binder_js_1.liveRoleIds)(targetFacts));
|
|
619
774
|
if (!ok) {
|
|
620
775
|
throw new common_1.HttpException({ error: 'scope_switch_denied' }, common_1.HttpStatus.FORBIDDEN);
|
|
621
776
|
}
|
|
@@ -642,29 +797,27 @@ let AuthController = class AuthController {
|
|
|
642
797
|
* 501 here, which would itself be an enumeration signal on hint validity).
|
|
643
798
|
*/
|
|
644
799
|
async forgotPassword(body, ip) {
|
|
800
|
+
// F5: the hint is looked up EXACTLY as typed — the DB collation (utf8mb4_unicode_ci)
|
|
801
|
+
// decides which spellings are one account. Never rewritten.
|
|
645
802
|
const hint = typeof body?.identityHint === 'string' ? body.identityHint : '';
|
|
646
|
-
const resolver = this.credentialResolver;
|
|
647
|
-
const store = this.resetTokenStore;
|
|
648
|
-
const sender = this.emailSender;
|
|
649
803
|
// ── Rate-limit gate (feature #141) — a REQUEST-RATE cap, NOT a failure cap. ──────
|
|
650
804
|
// forgot-password is ALWAYS 204 (a 429 would itself leak account existence), so we
|
|
651
805
|
// COUNT every forgot request against two budgets and, when EITHER is exceeded,
|
|
652
806
|
// SILENTLY skip the email-send while STILL returning 204. Two keys: the client IP
|
|
653
|
-
// (stops one host spraying many hints) and the
|
|
654
|
-
// being email-bombed across a rotating IP pool
|
|
655
|
-
// blip (its own posture) so an outage
|
|
807
|
+
// (stops one host spraying many hints) and the ACCOUNT the hint resolves to (stops one
|
|
808
|
+
// account being email-bombed across a rotating IP pool and across spelling variants —
|
|
809
|
+
// review F1). Each limiter fails OPEN on a Redis blip (its own posture) so an outage
|
|
810
|
+
// never silently drops a legitimate reset email.
|
|
656
811
|
// Defense-in-depth: a normal Express `req.ip` is always populated, but an empty ip
|
|
657
812
|
// (a non-standard adapter/harness) would collapse EVERY client into one shared
|
|
658
813
|
// `forgot:ip:` bucket → a global lockout. Fall back to a fixed sentinel so a blank ip
|
|
659
|
-
// is its own bucket, never a shared one
|
|
814
|
+
// is its own bucket, never a shared one.
|
|
660
815
|
const ipKey = `forgot:ip:${ip || 'unknown-ip'}`;
|
|
661
|
-
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
// path — it never sends anyway, and we must not let a blank hint burn a shared key).
|
|
667
|
-
const hintAllowed = this.forgotHintLimiter == null || hint === '' || (await this.forgotHintLimiter.check(hintKey));
|
|
816
|
+
// The IP budget applies only when bootstrap confirmed an explicit `trust proxy`
|
|
817
|
+
// (otherwise every client shares the proxy's IP and 5 requests would silently drop
|
|
818
|
+
// EVERY reset email). The per-account budget below is unaffected.
|
|
819
|
+
const forgotIpLimiter = this.perIp(this.forgotLimiter);
|
|
820
|
+
const ipAllowed = forgotIpLimiter == null || (await forgotIpLimiter.check(ipKey));
|
|
668
821
|
// Count THIS request toward each budget (there is no pass/fail signal — every forgot
|
|
669
822
|
// call is an "attempt"; the counter is what trips the silent skip). Best-effort +
|
|
670
823
|
// detached from the 204 (never awaited into the response timing; a throw is swallowed
|
|
@@ -681,10 +834,10 @@ let AuthController = class AuthController {
|
|
|
681
834
|
// itself confirm the account exists AND could be used to email-bomb the owner; the
|
|
682
835
|
// event is audit-only. NO secret in attributes (a reason code only; the hint is the
|
|
683
836
|
// actor identity, acceptable in the audit row, added by recordSecurityEvent).
|
|
684
|
-
void
|
|
837
|
+
void forgotIpLimiter
|
|
685
838
|
?.recordFailure(ipKey)
|
|
686
839
|
.then((count) => {
|
|
687
|
-
if (
|
|
840
|
+
if (count === forgotIpLimiter.maxAttempts) {
|
|
688
841
|
this.recordSecurityEvent({
|
|
689
842
|
action: 'auth.forgot_password.throttled',
|
|
690
843
|
identityHint: hint || null,
|
|
@@ -694,53 +847,75 @@ let AuthController = class AuthController {
|
|
|
694
847
|
}
|
|
695
848
|
})
|
|
696
849
|
.catch(() => { });
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
// abuse shapes are distinguishable in the audit log: the per-hint edge catches ONE
|
|
701
|
-
// account being email-bombed across ROTATING IPs (each IP staying under its own
|
|
702
|
-
// budget), which the per-IP audit would NEVER see. Detached + swallowed, 204
|
|
703
|
-
// unchanged, no notify (a hint-throttle notice would confirm the account + could
|
|
704
|
-
// itself be the email-bomb).
|
|
850
|
+
// The per-ACCOUNT budget and the found-path delivery run DETACHED (no await): resolving
|
|
851
|
+
// the account and minting/sending must never leak into the response timing. Only when
|
|
852
|
+
// there IS a hint (an empty hint never sends, and must not burn a shared key).
|
|
705
853
|
if (hint !== '') {
|
|
706
|
-
void this.
|
|
707
|
-
?.recordFailure(hintKey)
|
|
708
|
-
.then((count) => {
|
|
709
|
-
if (this.forgotHintLimiter != null && count === this.forgotHintLimiter.maxAttempts) {
|
|
710
|
-
this.recordSecurityEvent({
|
|
711
|
-
action: 'auth.forgot_password.hint_throttled',
|
|
712
|
-
identityHint: hint,
|
|
713
|
-
attributes: { reason: 'rate_limit' },
|
|
714
|
-
notify: false,
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
})
|
|
718
|
-
.catch(() => { });
|
|
719
|
-
}
|
|
720
|
-
// Only the matching branch does work — but do it DETACHED (no await) so its
|
|
721
|
-
// duration never leaks into the response timing. Absent seams → skip entirely.
|
|
722
|
-
// Throttled (either budget exceeded) → SILENTLY skip the send (still 204 below).
|
|
723
|
-
if (ipAllowed && hintAllowed && hint && resolver && store && sender) {
|
|
724
|
-
// Fire-and-forget: never awaited before the 204. `.catch` swallows any
|
|
725
|
-
// store/email failure (anti-enumeration — a failure must NOT change the
|
|
726
|
-
// response, and must never surface as an unhandled rejection).
|
|
727
|
-
void this.deliverResetLink(hint, resolver, store, sender).catch(() => {
|
|
854
|
+
void this.meterAndDeliverReset(hint, ipAllowed).catch(() => {
|
|
728
855
|
/* swallow — anti-enumeration: never let a store/email failure be observable */
|
|
729
856
|
});
|
|
730
857
|
}
|
|
731
858
|
// ALWAYS 204, immediately, identical body for every branch (found/unknown/throttled).
|
|
732
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* The detached per-account half of {@link forgotPassword} (review F1):
|
|
862
|
+
* 1. resolve the RAW hint through the same {@link CredentialResolver} the authenticator
|
|
863
|
+
* uses (the DB collation decides which spellings are one account);
|
|
864
|
+
* 2. meter the per-account budget — `forgot:pid:<principalId>`, or the canonical-hash
|
|
865
|
+
* fallback key for an unknown hint — and audit `auth.forgot_password.hint_throttled`
|
|
866
|
+
* ONCE on the edge (distinct from the IP site's `throttled`, so "one account bombed
|
|
867
|
+
* across rotating IPs" is visible). No notify (it would itself be the email-bomb);
|
|
868
|
+
* 3. send only when both budgets allow, the account resolved, and the reset seams exist.
|
|
869
|
+
* A lookup error still meters (fallback key) and skips the send (still 204).
|
|
870
|
+
*/
|
|
871
|
+
async meterAndDeliverReset(hint, ipAllowed) {
|
|
872
|
+
const resolver = this.credentialResolver;
|
|
873
|
+
let resolved = null;
|
|
874
|
+
let lookupFailed = false;
|
|
875
|
+
if (resolver != null) {
|
|
876
|
+
try {
|
|
877
|
+
resolved = await resolver.resolve(hint);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
lookupFailed = true;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const principalId = (0, canonical_hint_js_1.principalIdOf)(resolved);
|
|
884
|
+
const hintKey = (0, canonical_hint_js_1.forgotHintKey)(hint, principalId);
|
|
885
|
+
let hintAllowed = true;
|
|
886
|
+
const limiter = this.forgotHintLimiter;
|
|
887
|
+
if (limiter != null) {
|
|
888
|
+
// Fail OPEN on a (consumer) limiter rejection, like AttemptLimiter itself.
|
|
889
|
+
hintAllowed = await limiter.check(hintKey).catch(() => true);
|
|
890
|
+
const count = await limiter.recordFailure(hintKey).catch(() => 0);
|
|
891
|
+
if (count === limiter.maxAttempts) {
|
|
892
|
+
this.recordSecurityEvent({
|
|
893
|
+
action: 'auth.forgot_password.hint_throttled',
|
|
894
|
+
identityHint: hint,
|
|
895
|
+
attributes: { reason: 'rate_limit' },
|
|
896
|
+
notify: false,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const store = this.resetTokenStore;
|
|
901
|
+
const sender = this.emailSender;
|
|
902
|
+
if (!ipAllowed || !hintAllowed || lookupFailed || resolved === null || !principalId)
|
|
903
|
+
return;
|
|
904
|
+
if (!store || !sender)
|
|
905
|
+
return; // absent seams → never sends (still 204)
|
|
906
|
+
await this.deliverResetLink(resolved, store, sender);
|
|
907
|
+
}
|
|
733
908
|
/**
|
|
734
909
|
* The detached found-path work for {@link forgotPassword}: mint a raw token, issue
|
|
735
910
|
* its SHA-256 hash to the store (30-min TTL), resolve the recipient email via the
|
|
736
911
|
* profile resolver, and send the framework-composed reset link. Runs OUTSIDE the
|
|
737
|
-
* request/response timing (fire-and-forget) so it cannot form a timing oracle.
|
|
738
|
-
*
|
|
912
|
+
* request/response timing (fire-and-forget) so it cannot form a timing oracle. This
|
|
913
|
+
* method itself writes no log line — but the raw token and the email ARE handed to the
|
|
914
|
+
* bound {@link EmailSender}, and the dev-only `LogEmailSender` (the default ONLY when
|
|
915
|
+
* NODE_ENV is `development`/`test`) prints the whole message, link included, to stdout.
|
|
916
|
+
* Any other environment must supply a real sender or `emailSender: 'disabled'`.
|
|
739
917
|
*/
|
|
740
|
-
async deliverResetLink(
|
|
741
|
-
const resolved = await resolver.resolve(hint);
|
|
742
|
-
if (resolved === null)
|
|
743
|
-
return; // unknown hint — no token, no email (still 204)
|
|
918
|
+
async deliverResetLink(resolved, store, sender) {
|
|
744
919
|
const claims = resolved.claims;
|
|
745
920
|
const principalId = typeof claims.id === 'string' ? claims.id : '';
|
|
746
921
|
if (!principalId)
|
|
@@ -800,7 +975,10 @@ let AuthController = class AuthController {
|
|
|
800
975
|
// Defense-in-depth empty-ip fallback (see forgotPassword): a blank ip must be its
|
|
801
976
|
// own bucket, never collapse all callers into one shared `reset:ip:` lockout bucket.
|
|
802
977
|
const resetKey = `reset:ip:${ip || 'unknown-ip'}`;
|
|
803
|
-
|
|
978
|
+
// Enforced only when bootstrap confirmed an explicit `trust proxy`; otherwise every
|
|
979
|
+
// client shares the proxy's IP and 10 bad tokens would 429 EVERY reset.
|
|
980
|
+
const resetIpLimiter = this.perIp(this.resetLimiter);
|
|
981
|
+
if (resetIpLimiter != null && !(await resetIpLimiter.check(resetKey))) {
|
|
804
982
|
// Already locked → generic 429. We do NOT audit here: check() returns false on
|
|
805
983
|
// EVERY attempt once locked, so this gate would flood one event per attempt. The
|
|
806
984
|
// lockout is audited ONCE on the TRANSITION instead — in the invalid-token branch
|
|
@@ -814,7 +992,11 @@ let AuthController = class AuthController {
|
|
|
814
992
|
const store = this.resetTokenStore;
|
|
815
993
|
const writer = this.credentialWriter;
|
|
816
994
|
const hasher = this.passwordHasher;
|
|
817
|
-
if (store === undefined ||
|
|
995
|
+
if (store === undefined ||
|
|
996
|
+
store === null ||
|
|
997
|
+
writer === undefined ||
|
|
998
|
+
writer === null ||
|
|
999
|
+
hasher === undefined) {
|
|
818
1000
|
throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
|
|
819
1001
|
}
|
|
820
1002
|
// Consume the token (single-use, TTL-checked). Unknown/expired/consumed → 400.
|
|
@@ -828,9 +1010,9 @@ let AuthController = class AuthController {
|
|
|
828
1010
|
// invalid-token attempt, only the transition). No hint/principal is available from a
|
|
829
1011
|
// bare token — that's fine, the event is audited without one. NO notify (no
|
|
830
1012
|
// resolvable owner from a token) and NO secret in attributes (a reason code only).
|
|
831
|
-
if (
|
|
832
|
-
const count = await
|
|
833
|
-
if (count ===
|
|
1013
|
+
if (resetIpLimiter != null) {
|
|
1014
|
+
const count = await resetIpLimiter.recordFailure(resetKey);
|
|
1015
|
+
if (count === resetIpLimiter.maxAttempts) {
|
|
834
1016
|
this.recordSecurityEvent({
|
|
835
1017
|
action: 'auth.password_reset.locked',
|
|
836
1018
|
attributes: { reason: 'rate_limit' },
|
|
@@ -844,21 +1026,28 @@ let AuthController = class AuthController {
|
|
|
844
1026
|
await writer.setPassword(principalId, newHash);
|
|
845
1027
|
// A SUCCESSFUL reset clears the IP counter (do not carry earlier bad-token attempts
|
|
846
1028
|
// forward — the caller proved a valid single-use token).
|
|
847
|
-
await
|
|
1029
|
+
await resetIpLimiter?.reset(resetKey);
|
|
848
1030
|
// Reset kills ALL sessions (unlike change-password, which keeps the current one).
|
|
1031
|
+
// The sweeper now SURFACES a failed Redis unbind (Phase 3 review P1); the other reset tokens
|
|
1032
|
+
// are still burned before that error propagates.
|
|
1033
|
+
let sweepError;
|
|
849
1034
|
if (this.sessionSweeper !== undefined && this.sessionSweeper !== null) {
|
|
850
|
-
await this.sessionSweeper.revokeAllForPrincipal(principalId)
|
|
1035
|
+
await this.sessionSweeper.revokeAllForPrincipal(principalId).catch((err) => {
|
|
1036
|
+
sweepError = err;
|
|
1037
|
+
});
|
|
851
1038
|
}
|
|
852
1039
|
// Burn any other outstanding reset tokens for the principal (single active reset).
|
|
853
1040
|
await store.invalidateAllForPrincipal(principalId);
|
|
1041
|
+
if (sweepError !== undefined)
|
|
1042
|
+
throw sweepError;
|
|
854
1043
|
}
|
|
855
1044
|
/**
|
|
856
1045
|
* `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
|
|
857
1046
|
* challenge handle (unknown/expired/forged → same generic 401 as a bad code),
|
|
858
1047
|
* then BRANCHES on the presented factor:
|
|
859
|
-
* • `'totp'` →
|
|
860
|
-
*
|
|
861
|
-
*
|
|
1048
|
+
* • `'totp'` → the MFA STEP-UP (M6): verify ONLY the code against the principal the
|
|
1049
|
+
* password step recorded (no password is held or re-run). Rate-limited per account
|
|
1050
|
+
* (#9). See {@link completeViaTotp}.
|
|
862
1051
|
* • `'recovery_code'` → the lost-device escape hatch: verify a one-time
|
|
863
1052
|
* recovery code (a separate branch, NOT the core factor loop — recovery codes
|
|
864
1053
|
* are N single-use Argon2 hashes, incompatible with the pure one-secret
|
|
@@ -866,7 +1055,8 @@ let AuthController = class AuthController {
|
|
|
866
1055
|
* • any other factor → generic 401 (no leak of "unsupported factor").
|
|
867
1056
|
*
|
|
868
1057
|
* Anti-enumeration: unknown/forged/expired challenge, unknown hint, wrong code,
|
|
869
|
-
* and wrong recovery code ALL return the identical generic 401.
|
|
1058
|
+
* a locked TOTP step, and a wrong recovery code ALL return the identical generic 401.
|
|
1059
|
+
* A challenge-store outage is a retryable 503.
|
|
870
1060
|
*/
|
|
871
1061
|
async mfa(body) {
|
|
872
1062
|
const challengeToken = typeof body?.challengeToken === 'string' ? body.challengeToken : '';
|
|
@@ -876,32 +1066,76 @@ let AuthController = class AuthController {
|
|
|
876
1066
|
// is indistinguishable from a wrong code — generic 401, no leak. Consumed ONCE
|
|
877
1067
|
// here, up front, so BOTH branches (totp + recovery) share the single-use
|
|
878
1068
|
// semantics (a spent/forged challenge never reaches either verify path).
|
|
879
|
-
|
|
1069
|
+
let pending;
|
|
1070
|
+
try {
|
|
1071
|
+
pending = await this.pendingMfa.consume(challengeToken);
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
retryableUnavailable(); // challenge store (Redis) unavailable — transient, not a deny
|
|
1075
|
+
}
|
|
880
1076
|
if (pending === null)
|
|
881
1077
|
genericUnauthorized();
|
|
882
1078
|
// Recovery-code branch — the lost-device escape hatch (a separate verify path).
|
|
883
1079
|
if (factor === 'recovery_code') {
|
|
884
1080
|
return this.completeViaRecoveryCode(pending, code);
|
|
885
1081
|
}
|
|
886
|
-
//
|
|
887
|
-
// 401 (no leak of "unsupported factor").
|
|
1082
|
+
// Only 'totp' is a step-up factor. Any other → generic 401 (no "unsupported" leak).
|
|
888
1083
|
if (factor !== 'totp')
|
|
889
1084
|
genericUnauthorized();
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1085
|
+
return this.completeViaTotp(pending, code);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* The `/auth/mfa` TOTP branch (M6 + #9). The consumed challenge proves the password
|
|
1089
|
+
* step passed for `pending.principalId`; this verifies ONLY the code, through the
|
|
1090
|
+
* {@link MfaStepUp} (same `OrTotpVerifier` + replay guard as the authenticator, principal
|
|
1091
|
+
* minted by the identity broker).
|
|
1092
|
+
*
|
|
1093
|
+
* - `mfa:pid:<principalId>` is checked BEFORE verifying: locked → generic 401 and the
|
|
1094
|
+
* step-up is never consulted.
|
|
1095
|
+
* - a rejected code records a failure; the edge where the count first reaches the
|
|
1096
|
+
* threshold audits `auth.mfa.locked` ONCE (no notify — pre-auth, see recovery lockout).
|
|
1097
|
+
* - a transient outage → 503, never counted.
|
|
1098
|
+
* - success → establish + bind, then reset the TOTP counter AND the login counter (the
|
|
1099
|
+
* login counter is reset only here, once authentication fully succeeded).
|
|
1100
|
+
*/
|
|
1101
|
+
async completeViaTotp(pending, code) {
|
|
1102
|
+
const mfaKey = `mfa:pid:${pending.principalId}`;
|
|
1103
|
+
if (this.mfaLimiter != null && !(await this.mfaLimiter.check(mfaKey))) {
|
|
1104
|
+
genericUnauthorized();
|
|
897
1105
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1106
|
+
const stepUp = this.mfaStepUp;
|
|
1107
|
+
if (stepUp == null)
|
|
1108
|
+
genericUnauthorized(); // not wired → fail closed
|
|
1109
|
+
let outcome;
|
|
1110
|
+
try {
|
|
1111
|
+
outcome = await stepUp.verify(pending, totpCredential(code));
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
902
1114
|
retryableUnavailable();
|
|
903
1115
|
}
|
|
904
|
-
|
|
1116
|
+
if (outcome.outcome === 'unavailable')
|
|
1117
|
+
retryableUnavailable();
|
|
1118
|
+
if (outcome.outcome !== 'authenticated' || outcome.principal.id !== pending.principalId) {
|
|
1119
|
+
if (this.mfaLimiter != null) {
|
|
1120
|
+
const count = await this.mfaLimiter.recordFailure(mfaKey);
|
|
1121
|
+
if (count === this.mfaLimiter.maxAttempts) {
|
|
1122
|
+
this.recordSecurityEvent({
|
|
1123
|
+
action: 'auth.mfa.locked',
|
|
1124
|
+
actorId: pending.principalId,
|
|
1125
|
+
attributes: { reason: 'rate_limit' },
|
|
1126
|
+
notify: false,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
genericUnauthorized();
|
|
1131
|
+
}
|
|
1132
|
+
const principal = outcome.principal;
|
|
1133
|
+
const est = await this.establishOrReject(principal);
|
|
1134
|
+
await this.bindSession(est, principal);
|
|
1135
|
+
await this.mfaLimiter?.reset(mfaKey);
|
|
1136
|
+
await this.loginLimiter?.reset((0, canonical_hint_js_1.loginHintKey)(pending.identityHint, pending.principalId));
|
|
1137
|
+
// Same inline-facts enrichment as the password-login success (one round-trip).
|
|
1138
|
+
return this.sessionSuccessWithFacts(est, principal);
|
|
905
1139
|
}
|
|
906
1140
|
/**
|
|
907
1141
|
* The `/auth/mfa` RECOVERY-CODE branch (spec §3). The lost-device user completes
|
|
@@ -930,6 +1164,10 @@ let AuthController = class AuthController {
|
|
|
930
1164
|
const resolved = await this.credentialResolver.resolve(pending.identityHint).catch(() => null);
|
|
931
1165
|
if (resolved == null)
|
|
932
1166
|
genericUnauthorized();
|
|
1167
|
+
// M6: the credential must still be the one the password step was checked against
|
|
1168
|
+
// (same principal, same stored credential hash) — else the challenge is void.
|
|
1169
|
+
if (!(0, mfa_step_up_js_1.challengeMatches)(resolved, pending))
|
|
1170
|
+
genericUnauthorized();
|
|
933
1171
|
const claims = resolved.claims;
|
|
934
1172
|
const principalId = typeof claims.id === 'string' ? claims.id : '';
|
|
935
1173
|
if (!principalId)
|
|
@@ -969,14 +1207,30 @@ let AuthController = class AuthController {
|
|
|
969
1207
|
}
|
|
970
1208
|
genericUnauthorized();
|
|
971
1209
|
}
|
|
972
|
-
// A correct recovery code → clear the counter (do not carry earlier failures)
|
|
1210
|
+
// A correct recovery code → clear the counter (do not carry earlier failures), and —
|
|
1211
|
+
// authentication being complete — the login counter too (#9).
|
|
973
1212
|
await this.recoveryLimiter?.reset(recoveryKey);
|
|
1213
|
+
await this.loginLimiter?.reset((0, canonical_hint_js_1.loginHintKey)(pending.identityHint, principalId));
|
|
974
1214
|
// The pending challenge is proof the password already passed (§3) — no password
|
|
975
1215
|
// re-verify. Mint the Principal directly from the resolved claims.
|
|
976
1216
|
const principal = this.principalFromClaims(claims);
|
|
977
1217
|
if (principal === null)
|
|
978
1218
|
genericUnauthorized();
|
|
979
1219
|
const est = await this.establishOrReject(principal);
|
|
1220
|
+
// Phase 3B review P1: persist the restriction on the DURABLE session BEFORE any token leaves,
|
|
1221
|
+
// so a binding rebuilt after a Redis loss is restricted too. A failed mark → the session is
|
|
1222
|
+
// revoked and the login answers a retryable 503 (fail closed: an unmarked recovery session
|
|
1223
|
+
// could later be rebuilt unrestricted). No store wired → no rebuild is possible either.
|
|
1224
|
+
const restrictions = this.sessionRestrictions;
|
|
1225
|
+
if (restrictions !== undefined && restrictions !== null) {
|
|
1226
|
+
try {
|
|
1227
|
+
await restrictions.markMustReenroll(est.session.sessionId);
|
|
1228
|
+
}
|
|
1229
|
+
catch {
|
|
1230
|
+
await this.sessionAuthority.revoke(est.session.sessionId).catch(() => undefined);
|
|
1231
|
+
retryableUnavailable();
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
980
1234
|
// Enforced re-enroll (spec §4): a recovery-code login is lower-assurance, so the
|
|
981
1235
|
// session is bound with mustReenroll:true — the enforcement gate then restricts it
|
|
982
1236
|
// to the re-secure allowlist until a fresh device is confirmed.
|
|
@@ -1118,18 +1372,31 @@ let AuthController = class AuthController {
|
|
|
1118
1372
|
/**
|
|
1119
1373
|
* `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
|
|
1120
1374
|
* generic 401 (`session_rejected`; the engine already killed the lineage,
|
|
1121
|
-
* ADR-0015
|
|
1375
|
+
* ADR-0015, and `HotRevokingSessionRecordStore` unbinds it in Redis so the live access
|
|
1376
|
+
* token dies on its next request — M-A). The new access token is bound for ≤ the access
|
|
1377
|
+
* TTL; the previous one keeps a 30 s grace (#8, D7). If the session's claims cannot be
|
|
1378
|
+
* carried forward (`rebindOnRotate` → false) the refresh is a generic 401 — never an
|
|
1379
|
+
* unbound token. ⚠ See the class header: the engine's rotate collapses a transient
|
|
1122
1380
|
* store outage into the SAME reject, so refresh cannot emit the ADR-0016 503 from
|
|
1123
1381
|
* this seam today (flagged core-API gap).
|
|
1124
1382
|
*/
|
|
1125
1383
|
async refresh(body) {
|
|
1126
1384
|
const refreshToken = typeof body?.refreshToken === 'string' ? body.refreshToken : '';
|
|
1385
|
+
// Phase 2 review F1 (ADR-0035): ask Redis BEFORE the durable rotate spends the refresh token.
|
|
1386
|
+
// Unreachable → retryable 503 (ADR-0016) and the token stays valid for the client's retry.
|
|
1387
|
+
const probe = this.tokenBinder;
|
|
1388
|
+
if (typeof probe?.isAvailable === 'function' && !(await probe.isAvailable())) {
|
|
1389
|
+
retryableUnavailable();
|
|
1390
|
+
}
|
|
1127
1391
|
try {
|
|
1128
1392
|
const est = await this.sessionAuthority.rotate(refreshToken);
|
|
1129
1393
|
// Carry the principal claims forward to the NEW access token (the durable
|
|
1130
1394
|
// session row lacks roles/kind — the session-claims entry has them).
|
|
1131
1395
|
if (this.tokenBinder !== undefined) {
|
|
1132
|
-
|
|
1396
|
+
// #8: ≤ the access TTL (`clamp(expiresAtMs − now, 1, accessTtlMs)`), NOT
|
|
1397
|
+
// `expiresAtMs − issuedAtMs` — issuedAtMs is the original LOGIN, so that grew with
|
|
1398
|
+
// the session's age (a day-10 refresh minted a ~10-day token).
|
|
1399
|
+
const ttlMs = this.accessTtlFor(est);
|
|
1133
1400
|
// EAGER re-projection at REFRESH (ADR-0011): re-read the principal's coarse grants
|
|
1134
1401
|
// so the cache stays warm past the version the LOGIN projected at (a role change
|
|
1135
1402
|
// between login and this refresh is picked up here). The rotated session carries
|
|
@@ -1137,15 +1404,28 @@ let AuthController = class AuthController {
|
|
|
1137
1404
|
// rebindOnRotate carries the existing `cachedFacts` forward unchanged (today's
|
|
1138
1405
|
// behavior); a fresh CoarseGrants → replaces it; `null` (projection yielded
|
|
1139
1406
|
// nothing) → strips it (→ next request DB-reloads; fail-closed).
|
|
1140
|
-
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1407
|
+
//
|
|
1408
|
+
// ACTIVE NODE (review follow-up): after a scope-switch the session acts under the node in
|
|
1409
|
+
// its claims, not the durable login tenant. Project facts, roles and subtree for THAT node
|
|
1410
|
+
// (rebindOnRotate applies them only if the claims are still on it). No claims (Redis loss)
|
|
1411
|
+
// → the durable tenant, which is what a rebuild binds.
|
|
1412
|
+
const peek = this.tokenBinder;
|
|
1413
|
+
const activeTenant = (typeof peek.sessionTenant === 'function'
|
|
1414
|
+
? await peek.sessionTenant(est.session.sessionId)
|
|
1415
|
+
: null) ?? est.session.tenantId;
|
|
1416
|
+
const fresh = await this.projectForNode(est.session.principalId, activeTenant);
|
|
1417
|
+
const rebound = await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, fresh.facts, fresh.subtree, fresh.subtreeVersion, fresh.roles, activeTenant);
|
|
1418
|
+
// The claims were gone (Redis loss) or a logout won the race (`SET XX` refused):
|
|
1419
|
+
// SILENT RECOVERY (invariant #12, ADR-0013/0035) rebuilds them from durable state; a
|
|
1420
|
+
// rebuild that cannot be proven safe is a generic 401 — never an unbound token.
|
|
1421
|
+
if (rebound !== true) {
|
|
1422
|
+
const durableFresh = activeTenant === est.session.tenantId
|
|
1423
|
+
? fresh
|
|
1424
|
+
: await this.projectForNode(est.session.principalId, est.session.tenantId);
|
|
1425
|
+
const recovered = await this.rebuildBinding(est, ttlMs, durableFresh);
|
|
1426
|
+
if (!recovered)
|
|
1427
|
+
genericUnauthorized();
|
|
1428
|
+
}
|
|
1149
1429
|
}
|
|
1150
1430
|
return toSessionSuccess(est);
|
|
1151
1431
|
}
|
|
@@ -1156,6 +1436,101 @@ let AuthController = class AuthController {
|
|
|
1156
1436
|
genericUnauthorized();
|
|
1157
1437
|
}
|
|
1158
1438
|
}
|
|
1439
|
+
/**
|
|
1440
|
+
* The refresh-time projection for one node: coarse grants + live roles (ADR-0035) and the
|
|
1441
|
+
* subtree with its version stamp (Phase 1C: the version is read FIRST). Each part is best-effort
|
|
1442
|
+
* exactly as at login (`undefined` = carry / omit, `null` facts = strip).
|
|
1443
|
+
*/
|
|
1444
|
+
async projectForNode(principalId, tenantId) {
|
|
1445
|
+
const facts = await this.projectFactsFor(principalId, tenantId);
|
|
1446
|
+
const subtreeVersion = await this.subtreeVersionFor(tenantId);
|
|
1447
|
+
const subtree = await this.resolveScopeSubtreeFor(tenantId);
|
|
1448
|
+
return { facts, roles: (0, session_token_binder_js_1.liveRoleIds)(facts), subtree, subtreeVersion };
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* SILENT RECOVERY of a lost session binding (invariant #12, ADR-0013, ADR-0035, GAPS #5).
|
|
1452
|
+
* `rotate()` has already proved the durable session is active and inside its idle and absolute
|
|
1453
|
+
* windows, and returned its principal + tenant. The rebuilt claims NEVER carry more authority
|
|
1454
|
+
* than the session had (Phase 3B review P1); they come from durable sources only:
|
|
1455
|
+
* - principal + tenant: the durable session record (the LOGIN node; a scope-switch is not
|
|
1456
|
+
* durable, so a rebuilt session returns to the node it was opened on);
|
|
1457
|
+
* - the RE-ENROLL restriction: `shield_identity.session.s_must_reenroll`, set at a
|
|
1458
|
+
* recovery-code login. A restricted session rebuilds `mustReenroll: true` and NEVER root
|
|
1459
|
+
* (exactly how the recovery login bound it);
|
|
1460
|
+
* - the account status: a disabled user gets no rebuild;
|
|
1461
|
+
* - roles: the live projection (ADR-0035) — `[]` when none is available (non-widening; the
|
|
1462
|
+
* first request's gate-5 reload fills them, because no facts are cached then);
|
|
1463
|
+
* - `isRoot` (unrestricted sessions only): the server-side root allowlist, never a claim;
|
|
1464
|
+
* - subtree: the resolver (absent → the self-node fallback, non-widening);
|
|
1465
|
+
* - kind `user` (sessions are human; service accounts authenticate with keys).
|
|
1466
|
+
* No restriction store, or any error reading it → no rebuild (fail closed). The claims entry is
|
|
1467
|
+
* written with `NX`, then the durable session is READ AGAIN: if a logout or revoke landed
|
|
1468
|
+
* meanwhile, the rebuilt binding is dropped and the refresh fails. Returns `true` when the
|
|
1469
|
+
* session is bound and still active. Any error → `false` (the caller answers 401).
|
|
1470
|
+
*/
|
|
1471
|
+
async rebuildBinding(est, ttlMs, fresh) {
|
|
1472
|
+
const binder = this.tokenBinder;
|
|
1473
|
+
const records = this.sessionRecords;
|
|
1474
|
+
const restrictions = this.sessionRestrictions;
|
|
1475
|
+
if (binder === undefined || records === undefined || records === null)
|
|
1476
|
+
return false;
|
|
1477
|
+
if (restrictions === undefined || restrictions === null)
|
|
1478
|
+
return false;
|
|
1479
|
+
if (typeof binder.bindRecovered !== 'function')
|
|
1480
|
+
return false;
|
|
1481
|
+
const { sessionId, principalId, tenantId } = est.session;
|
|
1482
|
+
if (!sessionId || !principalId || !tenantId)
|
|
1483
|
+
return false;
|
|
1484
|
+
let durableFacts;
|
|
1485
|
+
try {
|
|
1486
|
+
durableFacts = await restrictions.rebuildFacts(sessionId, principalId);
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
return false; // cannot prove the session's restriction → no rebuild (fail closed)
|
|
1490
|
+
}
|
|
1491
|
+
if (durableFacts.accountActive !== true)
|
|
1492
|
+
return false;
|
|
1493
|
+
const restricted = durableFacts.mustReenroll !== false;
|
|
1494
|
+
const claims = {
|
|
1495
|
+
id: principalId,
|
|
1496
|
+
tenantId,
|
|
1497
|
+
roleIds: fresh.roles !== undefined ? [...fresh.roles] : [],
|
|
1498
|
+
kind: 'user',
|
|
1499
|
+
isRoot: !restricted && Array.isArray(this.rootIds) && this.rootIds.includes(principalId),
|
|
1500
|
+
sessionId,
|
|
1501
|
+
mustReenroll: restricted,
|
|
1502
|
+
...(fresh.facts ? { cachedFacts: (0, session_token_binder_js_1.factsSlot)(fresh.facts) } : {}),
|
|
1503
|
+
...(fresh.subtree !== undefined && fresh.subtree.length > 0
|
|
1504
|
+
? { scopeSubtreeNodeIds: [...fresh.subtree] }
|
|
1505
|
+
: {}),
|
|
1506
|
+
...(fresh.subtree !== undefined &&
|
|
1507
|
+
fresh.subtree.length > 0 &&
|
|
1508
|
+
fresh.subtreeVersion !== undefined
|
|
1509
|
+
? { scopeSubtreeVersion: fresh.subtreeVersion }
|
|
1510
|
+
: {}),
|
|
1511
|
+
};
|
|
1512
|
+
try {
|
|
1513
|
+
const written = await binder.bindRecovered(est.tokens.accessToken, claims, ttlMs);
|
|
1514
|
+
if (!written)
|
|
1515
|
+
return false;
|
|
1516
|
+
const durable = await records.findSessionById(sessionId);
|
|
1517
|
+
if (durable === null || durable === undefined || durable.status !== 'active') {
|
|
1518
|
+
await binder.unbindSession(sessionId);
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
// Redis or the durable store failed mid-rebuild → leave nothing bound (best-effort), refuse.
|
|
1525
|
+
try {
|
|
1526
|
+
await binder.unbindSession(sessionId);
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
// best-effort
|
|
1530
|
+
}
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1159
1534
|
/**
|
|
1160
1535
|
* `POST /auth/logout` — revoke the session. Idempotent + leak-free: revoking an
|
|
1161
1536
|
* unknown or already-revoked session still returns 204 (the engine's `revoke`
|
|
@@ -1198,8 +1573,9 @@ __decorate([
|
|
|
1198
1573
|
(0, common_1.Post)('login'),
|
|
1199
1574
|
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
1200
1575
|
__param(0, (0, common_1.Body)()),
|
|
1576
|
+
__param(1, (0, common_1.Ip)()),
|
|
1201
1577
|
__metadata("design:type", Function),
|
|
1202
|
-
__metadata("design:paramtypes", [Object]),
|
|
1578
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
1203
1579
|
__metadata("design:returntype", Promise)
|
|
1204
1580
|
], AuthController.prototype, "login", null);
|
|
1205
1581
|
__decorate([
|
|
@@ -1295,24 +1671,42 @@ exports.AuthController = AuthController = __decorate([
|
|
|
1295
1671
|
__param(13, (0, common_2.Optional)()),
|
|
1296
1672
|
__param(13, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_LIMITER)),
|
|
1297
1673
|
__param(14, (0, common_2.Optional)()),
|
|
1298
|
-
__param(14, (0, common_1.Inject)(tokens_js_1.
|
|
1674
|
+
__param(14, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_IP_LIMITER)),
|
|
1299
1675
|
__param(15, (0, common_2.Optional)()),
|
|
1300
|
-
__param(15, (0, common_1.Inject)(
|
|
1676
|
+
__param(15, (0, common_1.Inject)(trust_proxy_check_js_1.TrustProxyBootCheck)),
|
|
1301
1677
|
__param(16, (0, common_2.Optional)()),
|
|
1302
|
-
__param(16, (0, common_1.Inject)(tokens_js_1.
|
|
1678
|
+
__param(16, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_LIMITER)),
|
|
1303
1679
|
__param(17, (0, common_2.Optional)()),
|
|
1304
|
-
__param(17, (0, common_1.Inject)(tokens_js_1.
|
|
1680
|
+
__param(17, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_LIMITER)),
|
|
1305
1681
|
__param(18, (0, common_2.Optional)()),
|
|
1306
|
-
__param(18, (0, common_1.Inject)(tokens_js_1.
|
|
1682
|
+
__param(18, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_HINT_LIMITER)),
|
|
1307
1683
|
__param(19, (0, common_2.Optional)()),
|
|
1308
|
-
__param(19, (0, common_1.Inject)(tokens_js_1.
|
|
1684
|
+
__param(19, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LIMITER)),
|
|
1309
1685
|
__param(20, (0, common_2.Optional)()),
|
|
1310
|
-
__param(20, (0, common_1.Inject)(tokens_js_1.
|
|
1686
|
+
__param(20, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
|
|
1311
1687
|
__param(21, (0, common_2.Optional)()),
|
|
1312
|
-
__param(21, (0, common_1.Inject)(tokens_js_1.
|
|
1688
|
+
__param(21, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_FACTS_PROJECTOR)),
|
|
1313
1689
|
__param(22, (0, common_2.Optional)()),
|
|
1314
|
-
__param(22, (0, common_1.Inject)(tokens_js_1.
|
|
1315
|
-
|
|
1690
|
+
__param(22, (0, common_1.Inject)(tokens_js_1.SHIELD_SCOPE_SUBTREE_RESOLVER)),
|
|
1691
|
+
__param(23, (0, common_2.Optional)()),
|
|
1692
|
+
__param(23, (0, common_1.Inject)(tokens_js_1.SHIELD_MEMBERSHIP_CHECKER)),
|
|
1693
|
+
__param(24, (0, common_2.Optional)()),
|
|
1694
|
+
__param(24, (0, common_1.Inject)(tokens_js_1.SHIELD_PERMISSION_FACTS)),
|
|
1695
|
+
__param(25, (0, common_2.Optional)()),
|
|
1696
|
+
__param(25, (0, common_1.Inject)(tokens_js_1.SHIELD_MFA_LIMITER)),
|
|
1697
|
+
__param(26, (0, common_2.Optional)()),
|
|
1698
|
+
__param(26, (0, common_1.Inject)(tokens_js_1.SHIELD_MFA_STEP_UP)),
|
|
1699
|
+
__param(27, (0, common_2.Optional)()),
|
|
1700
|
+
__param(27, (0, common_1.Inject)(crypto_adapters_js_1.SHIELD_CLOCK)),
|
|
1701
|
+
__param(28, (0, common_2.Optional)()),
|
|
1702
|
+
__param(28, (0, common_1.Inject)(session_lifetimes_js_1.SHIELD_SESSION_LIFETIMES)),
|
|
1703
|
+
__param(29, (0, common_2.Optional)()),
|
|
1704
|
+
__param(29, (0, common_1.Inject)(tokens_js_1.SHIELD_ROOT_IDS)),
|
|
1705
|
+
__param(30, (0, common_2.Optional)()),
|
|
1706
|
+
__param(30, (0, common_1.Inject)(mariadb_session_record_store_js_1.SHIELD_SESSION_RECORD_STORE)),
|
|
1707
|
+
__param(31, (0, common_2.Optional)()),
|
|
1708
|
+
__param(31, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_RESTRICTIONS)),
|
|
1709
|
+
__metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore, Function, Object, Object, Object, Object, Object, Object, Object, String, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
1316
1710
|
], AuthController);
|
|
1317
1711
|
/**
|
|
1318
1712
|
* Narrow an `authenticated` result to its guaranteed principal. The core engine
|