@astram/guard 0.18.0 → 0.20.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/audit/audit-chain-sealer.d.ts +90 -0
- package/dist/audit/audit-chain-sealer.d.ts.map +1 -0
- package/dist/audit/audit-chain-sealer.js +520 -0
- package/dist/audit/audit-seal-config.d.ts +22 -0
- package/dist/audit/audit-seal-config.d.ts.map +1 -0
- package/dist/audit/audit-seal-config.js +35 -0
- package/dist/audit/audit-sealer.service.d.ts +62 -0
- package/dist/audit/audit-sealer.service.d.ts.map +1 -0
- package/dist/audit/audit-sealer.service.js +197 -0
- package/dist/audit/node-sha256-hash.d.ts +6 -0
- package/dist/audit/node-sha256-hash.d.ts.map +1 -0
- package/dist/audit/node-sha256-hash.js +16 -0
- 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 +79 -6
- package/dist/auth/admin-signin-controls.d.ts +80 -0
- package/dist/auth/admin-signin-controls.d.ts.map +1 -0
- package/dist/auth/admin-signin-controls.js +91 -0
- package/dist/auth/auth-audit.d.ts +59 -0
- package/dist/auth/auth-audit.d.ts.map +1 -0
- package/dist/auth/auth-audit.js +93 -0
- package/dist/auth/auth.controller.d.ts +53 -6
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +330 -44
- package/dist/auth/auth.module.d.ts +49 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +149 -4
- package/dist/auth/captcha-gate.d.ts +103 -0
- package/dist/auth/captcha-gate.d.ts.map +1 -0
- package/dist/auth/captcha-gate.js +245 -0
- package/dist/auth/dto.d.ts +22 -1
- package/dist/auth/dto.d.ts.map +1 -1
- package/dist/auth/login-name-count-boot.d.ts +24 -0
- package/dist/auth/login-name-count-boot.d.ts.map +1 -0
- package/dist/auth/login-name-count-boot.js +47 -0
- package/dist/auth/logout-proof.d.ts +55 -0
- package/dist/auth/logout-proof.d.ts.map +1 -0
- package/dist/auth/logout-proof.js +127 -0
- package/dist/auth/pending-mfa-store.d.ts +5 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -1
- package/dist/auth/pending-mfa-store.js +2 -0
- package/dist/auth/persistence.module.d.ts +2 -2
- package/dist/auth/persistence.module.d.ts.map +1 -1
- package/dist/auth/persistence.module.js +19 -3
- package/dist/auth/ports/audit-sink.d.ts +5 -2
- package/dist/auth/ports/audit-sink.d.ts.map +1 -1
- package/dist/auth/ports/mfa-device-store.d.ts +7 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -1
- package/dist/auth/profile-resolver.d.ts +6 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts +6 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.js +14 -0
- package/dist/auth/tokens.d.ts +59 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +61 -1
- package/dist/authentication/credential-provisioner.d.ts +7 -0
- package/dist/authentication/credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.d.ts +1 -0
- package/dist/authentication/mariadb-credential-provisioner.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-provisioner.js +7 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +23 -2
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +45 -1
- package/dist/authentication/mariadb-mfa-device-store.d.ts +7 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +32 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts +55 -0
- package/dist/authentication/mariadb-mfa-requirement-source.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-requirement-source.js +164 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts +42 -0
- package/dist/authentication/mariadb-mfa-resetter.d.ts.map +1 -0
- package/dist/authentication/mariadb-mfa-resetter.js +59 -0
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +5 -0
- package/dist/authentication/sign-in-with.d.ts +14 -0
- package/dist/authentication/sign-in-with.d.ts.map +1 -0
- package/dist/authentication/sign-in-with.js +36 -0
- package/dist/authz/core-access.d.ts +11 -0
- package/dist/authz/core-access.d.ts.map +1 -0
- package/dist/authz/core-access.js +14 -0
- package/dist/authz/list-scope-sql.d.ts.map +1 -1
- package/dist/authz/list-scope-sql.js +15 -5
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +21 -3
- package/dist/captcha/captcha.providers.d.ts +18 -0
- package/dist/captcha/captcha.providers.d.ts.map +1 -0
- package/dist/captcha/captcha.providers.js +78 -0
- package/dist/captcha/recaptcha-verifier.d.ts +20 -0
- package/dist/captcha/recaptcha-verifier.d.ts.map +1 -0
- package/dist/captcha/recaptcha-verifier.js +48 -0
- package/dist/captcha/siteverify.d.ts +47 -0
- package/dist/captcha/siteverify.d.ts.map +1 -0
- package/dist/captcha/siteverify.js +98 -0
- package/dist/captcha/turnstile-verifier.d.ts +18 -0
- package/dist/captcha/turnstile-verifier.d.ts.map +1 -0
- package/dist/captcha/turnstile-verifier.js +41 -0
- package/dist/config/captcha-config.d.ts +47 -0
- package/dist/config/captcha-config.d.ts.map +1 -0
- package/dist/config/captcha-config.js +163 -0
- package/dist/config/reserved-principal-ids.d.ts +15 -0
- package/dist/config/reserved-principal-ids.d.ts.map +1 -1
- package/dist/config/reserved-principal-ids.js +20 -3
- package/dist/enforcement/global-enforcement.guard.d.ts +9 -1
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +52 -10
- package/dist/enforcement/reenroll-allowlist.d.ts +5 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -1
- package/dist/enforcement/reenroll-allowlist.js +5 -0
- package/dist/identity/auth-facts.controller.d.ts +31 -4
- package/dist/identity/auth-facts.controller.d.ts.map +1 -1
- package/dist/identity/auth-facts.controller.js +46 -11
- package/dist/identity/identity-actions.d.ts +2 -2
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.js +3 -6
- package/dist/identity/permission-facts.adapter.d.ts +22 -0
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +26 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -11
- package/dist/internal.d.ts +27 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +36 -1
- package/dist/module/shield.module.d.ts +27 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +10 -0
- package/dist/request-auth/request-principal.d.ts +8 -1
- package/dist/request-auth/request-principal.d.ts.map +1 -1
- package/dist/request-auth/request-principal.js +11 -4
- package/dist/request-auth/session-token-binder.d.ts +82 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -1
- package/dist/request-auth/session-token-binder.js +203 -1
- package/dist/session/mfa-requirement-service.d.ts +168 -0
- package/dist/session/mfa-requirement-service.d.ts.map +1 -0
- package/dist/session/mfa-requirement-service.js +461 -0
- package/dist/session/session-restriction-store.d.ts +36 -0
- package/dist/session/session-restriction-store.d.ts.map +1 -1
- package/dist/session/session-restriction-store.js +57 -6
- package/dist/session/session-restriction.d.ts +22 -0
- package/dist/session/session-restriction.d.ts.map +1 -0
- package/dist/session/session-restriction.js +23 -0
- package/package.json +10 -5
|
@@ -85,8 +85,11 @@ const tokens_js_2 = require("../request-auth/tokens.js");
|
|
|
85
85
|
const session_token_binder_js_1 = require("../request-auth/session-token-binder.js");
|
|
86
86
|
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
87
87
|
const tokens_js_3 = require("./tokens.js");
|
|
88
|
+
const session_restriction_js_1 = require("../session/session-restriction.js");
|
|
89
|
+
const logout_proof_js_1 = require("./logout-proof.js");
|
|
88
90
|
const deliver_email_js_1 = require("../messaging/deliver-email.js");
|
|
89
91
|
const password_reset_message_js_1 = require("./password-reset-message.js");
|
|
92
|
+
const auth_audit_js_1 = require("./auth-audit.js");
|
|
90
93
|
/**
|
|
91
94
|
* The core `Authenticator`'s generic transient reason (see authenticator.ts —
|
|
92
95
|
* `REASON_AUTH_UNAVAILABLE`). A `rejected` result carrying THIS reason is the
|
|
@@ -118,6 +121,26 @@ function sha256(value) {
|
|
|
118
121
|
function genericUnauthorized() {
|
|
119
122
|
throw new common_1.HttpException(GENERIC_AUTH_FAILURE, common_1.HttpStatus.UNAUTHORIZED);
|
|
120
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Auth capabilities R7.3: the generic 401 plus the challenge flag, identical for known and unknown
|
|
126
|
+
* names (X2). Returned when a challenge is required and was missing or failed, and on a failure
|
|
127
|
+
* after which the next attempt needs one.
|
|
128
|
+
*/
|
|
129
|
+
const CAPTCHA_REQUIRED_FAILURE = Object.freeze({
|
|
130
|
+
error: 'invalid_credentials',
|
|
131
|
+
captcha: 'required',
|
|
132
|
+
});
|
|
133
|
+
/** The login 401: with the challenge flag when the next attempt needs a CAPTCHA token. */
|
|
134
|
+
function loginUnauthorized(captchaRequired) {
|
|
135
|
+
if (captchaRequired) {
|
|
136
|
+
throw new common_1.HttpException(CAPTCHA_REQUIRED_FAILURE, common_1.HttpStatus.UNAUTHORIZED);
|
|
137
|
+
}
|
|
138
|
+
genericUnauthorized();
|
|
139
|
+
}
|
|
140
|
+
/** The reset-password bad-token 400, with the challenge flag when one is (now) required. */
|
|
141
|
+
function invalidResetToken(captchaRequired) {
|
|
142
|
+
throw new common_1.HttpException(captchaRequired ? { error: 'invalid_token', captcha: 'required' } : { error: 'invalid_token' }, common_1.HttpStatus.BAD_REQUEST);
|
|
143
|
+
}
|
|
121
144
|
/** Throw the retryable 503 (transient — NOT a deny; the client may retry). */
|
|
122
145
|
function retryableUnavailable() {
|
|
123
146
|
throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
|
|
@@ -221,7 +244,12 @@ let AuthController = class AuthController {
|
|
|
221
244
|
sessionRestrictions;
|
|
222
245
|
emailChangeTokens;
|
|
223
246
|
messageDispatcher;
|
|
224
|
-
|
|
247
|
+
logoutProof;
|
|
248
|
+
logoutRequiresProof;
|
|
249
|
+
mfaRequirement;
|
|
250
|
+
captchaGate;
|
|
251
|
+
signInWith;
|
|
252
|
+
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, emailChangeTokens, messageDispatcher, logoutProof, logoutRequiresProof, mfaRequirement, captchaGate, signInWith) {
|
|
225
253
|
this.authenticator = authenticator;
|
|
226
254
|
this.sessionAuthority = sessionAuthority;
|
|
227
255
|
this.pendingMfa = pendingMfa;
|
|
@@ -256,6 +284,25 @@ let AuthController = class AuthController {
|
|
|
256
284
|
this.sessionRestrictions = sessionRestrictions;
|
|
257
285
|
this.emailChangeTokens = emailChangeTokens;
|
|
258
286
|
this.messageDispatcher = messageDispatcher;
|
|
287
|
+
this.logoutProof = logoutProof;
|
|
288
|
+
this.logoutRequiresProof = logoutRequiresProof;
|
|
289
|
+
this.mfaRequirement = mfaRequirement;
|
|
290
|
+
this.captchaGate = captchaGate;
|
|
291
|
+
this.signInWith = signInWith;
|
|
292
|
+
}
|
|
293
|
+
/** True when bootstrap confirmed an explicit `trust proxy` (per-IP limits and CAPTCHA IP keys). */
|
|
294
|
+
perIpEnabled() {
|
|
295
|
+
return this.trustProxyCheck?.perIpLimitingEnabled === true;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* R7.3: count one failure toward the CAPTCHA counters and say whether the next attempt needs a
|
|
299
|
+
* challenge. No gate (or endpoint not covered) ⇒ `false`.
|
|
300
|
+
*/
|
|
301
|
+
async captchaAfterFailure(endpoint, subject) {
|
|
302
|
+
const gate = this.captchaGate;
|
|
303
|
+
if (gate === undefined || gate === null)
|
|
304
|
+
return false;
|
|
305
|
+
return gate.recordFailure(endpoint, subject);
|
|
259
306
|
}
|
|
260
307
|
/**
|
|
261
308
|
* The access-token binding TTL for an establishment (security hardening #8):
|
|
@@ -275,7 +322,7 @@ let AuthController = class AuthController {
|
|
|
275
322
|
*/
|
|
276
323
|
async bindSession(est, principal, mustReenroll = false) {
|
|
277
324
|
if (this.tokenBinder === undefined)
|
|
278
|
-
return;
|
|
325
|
+
return (0, session_restriction_js_1.restrictionOf)({ mustReenroll }, Date.now());
|
|
279
326
|
const ttlMs = this.accessTtlFor(est);
|
|
280
327
|
// EAGER session-facts projection (ADR-0011): compute the principal's coarse grants
|
|
281
328
|
// NOW so the guard can serve gate 5 from the request's `cachedFacts`. Absent projector
|
|
@@ -301,6 +348,11 @@ let AuthController = class AuthController {
|
|
|
301
348
|
// - a projection without `roleIds` (a port that does not report roles) or no projector at all
|
|
302
349
|
// → the authenticated principal's roles (back-compat for apps that own their roles).
|
|
303
350
|
const roleIds = projected === null ? [] : ((0, session_token_binder_js_1.liveRoleIds)(projected) ?? [...principal.roleIds]);
|
|
351
|
+
// MANDATORY 2FA (auth capabilities R2.3, R2.4): evaluated at sign-in (a lifecycle event; DB
|
|
352
|
+
// reads allowed) over the same projection. FAIL CLOSED: an evaluation that cannot complete, or
|
|
353
|
+
// a restriction that cannot be made durable, revokes the new session and answers 503 — never an
|
|
354
|
+
// unrestricted session. No policy feature ⇒ nothing here runs (the binding is unchanged).
|
|
355
|
+
const mfa = await this.evaluateMfaAtLogin(est, principal, projected);
|
|
304
356
|
try {
|
|
305
357
|
await this.tokenBinder.bind(est.tokens.accessToken, {
|
|
306
358
|
id: principal.id,
|
|
@@ -324,12 +376,46 @@ let AuthController = class AuthController {
|
|
|
324
376
|
...(scopeSubtreeNodeIds && scopeSubtreeVersion !== undefined
|
|
325
377
|
? { scopeSubtreeVersion }
|
|
326
378
|
: {}),
|
|
379
|
+
// R2.3 / R2.4: the requirement as evaluated at sign-in, and the version it is valid for.
|
|
380
|
+
...(mfa ?? {}),
|
|
327
381
|
}, ttlMs);
|
|
328
382
|
}
|
|
329
383
|
catch {
|
|
330
384
|
// Best-effort: never fail a login on a binding hiccup. The guard denies if the
|
|
331
385
|
// binding is missing, so this degrades safely (the user re-logs in).
|
|
332
386
|
}
|
|
387
|
+
// The restriction the session was bound with (review round 1, item C): a restricted sign-in
|
|
388
|
+
// gets no inline grants.
|
|
389
|
+
return (0, session_restriction_js_1.restrictionOf)({ mustReenroll, ...(mfa ?? {}) }, Date.now());
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* The mandatory-2FA state to bind at sign-in (R2.3, R2.4), or `undefined` when the policy feature
|
|
393
|
+
* is not wired. `'required'` is also marked on the durable session BEFORE any token leaves, so a
|
|
394
|
+
* binding rebuilt after a Redis loss stays restricted. Any failure revokes the session and
|
|
395
|
+
* answers the generic 503 (fail closed).
|
|
396
|
+
*/
|
|
397
|
+
async evaluateMfaAtLogin(est, principal, projected) {
|
|
398
|
+
const service = this.mfaRequirement;
|
|
399
|
+
if (service === undefined || service === null)
|
|
400
|
+
return undefined;
|
|
401
|
+
let state;
|
|
402
|
+
try {
|
|
403
|
+
state = await service.atLogin(principal, projected);
|
|
404
|
+
if (state.restriction === 'mfa_enrollment') {
|
|
405
|
+
const restrictions = this.sessionRestrictions;
|
|
406
|
+
if (restrictions !== undefined && restrictions !== null) {
|
|
407
|
+
if (typeof restrictions.markRestriction !== 'function') {
|
|
408
|
+
throw new Error('restriction store cannot record mfa_enrollment');
|
|
409
|
+
}
|
|
410
|
+
await restrictions.markRestriction(est.session.sessionId, 'mfa_enrollment');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
await this.sessionAuthority.revoke(est.session.sessionId).catch(() => undefined);
|
|
416
|
+
retryableUnavailable();
|
|
417
|
+
}
|
|
418
|
+
return state;
|
|
333
419
|
}
|
|
334
420
|
/**
|
|
335
421
|
* Project coarse grants for a principal id + tenant (ADR-0011), distinguishing THREE
|
|
@@ -454,8 +540,10 @@ let AuthController = class AuthController {
|
|
|
454
540
|
* exactOptionalPropertyTypes an explicit `undefined` is not assignable to the optional
|
|
455
541
|
* field, so a spread keeps it truly absent.
|
|
456
542
|
*/
|
|
457
|
-
async sessionSuccessWithFacts(est, principal, extra) {
|
|
458
|
-
|
|
543
|
+
async sessionSuccessWithFacts(est, principal, restricted, extra) {
|
|
544
|
+
// A restricted session (re-enrol or mandatory 2FA, review round 1 item C) gets NO inline
|
|
545
|
+
// grants: the frontend fetches `/identity/auth-facts`, which answers the restricted shape.
|
|
546
|
+
const authFacts = restricted === null ? await this.projectAuthFacts(principal) : undefined;
|
|
459
547
|
return {
|
|
460
548
|
...toSessionSuccess(est),
|
|
461
549
|
...(extra?.mfaRecoveryUsed ? { mfaRecoveryUsed: true } : {}),
|
|
@@ -546,8 +634,10 @@ let AuthController = class AuthController {
|
|
|
546
634
|
const ipLimiter = this.perIp(this.loginIpLimiter);
|
|
547
635
|
const ipKey = (0, canonical_hint_js_1.loginIpKey)(ip);
|
|
548
636
|
const ipVerdict = await consumeOrUnavailable(ipLimiter, ipKey);
|
|
549
|
-
|
|
550
|
-
|
|
637
|
+
// R7.3: an IP over its login budget is far over the CAPTCHA threshold too.
|
|
638
|
+
if (ipVerdict !== null && !ipVerdict.allowed) {
|
|
639
|
+
loginUnauthorized(this.captchaGate?.covers('login') === true);
|
|
640
|
+
}
|
|
551
641
|
/** Give back this attempt's IP count (it was not a password failure). */
|
|
552
642
|
const refundIp = async () => {
|
|
553
643
|
if (ipVerdict !== null)
|
|
@@ -565,7 +655,26 @@ let AuthController = class AuthController {
|
|
|
565
655
|
retryableUnavailable();
|
|
566
656
|
}
|
|
567
657
|
const accountId = (0, canonical_hint_js_1.principalIdOf)(account);
|
|
658
|
+
const accountTenant = (0, auth_audit_js_1.tenantOfClaims)(account?.claims);
|
|
659
|
+
const accountLabel = accountId !== null ? 'known' : 'unknown';
|
|
568
660
|
const loginKey = (0, canonical_hint_js_1.loginHintKey)(identityHint, accountId);
|
|
661
|
+
// ── CAPTCHA (auth capabilities R7.3, R7.4) ── decided on the resolved account, the typed text
|
|
662
|
+
// and the client IP, and verified BEFORE the account budget is spent and before any password
|
|
663
|
+
// hash. A missing or failed challenge answers the same 401 + `captcha: 'required'` for known
|
|
664
|
+
// and unknown names (X2). This attempt stays counted against the client IP (a failed
|
|
665
|
+
// challenge is a failed attempt from that client); the account budget is untouched, so a
|
|
666
|
+
// stranger without a token cannot lock the account through this gate.
|
|
667
|
+
const captchaSubject = {
|
|
668
|
+
accountId,
|
|
669
|
+
tenantId: accountTenant,
|
|
670
|
+
typedText: identityHint,
|
|
671
|
+
ip: this.perIpEnabled() ? ip : null,
|
|
672
|
+
};
|
|
673
|
+
if (this.captchaGate?.covers('login') === true) {
|
|
674
|
+
const verdict = await this.captchaGate.check('login', captchaSubject, body?.captchaToken, ip);
|
|
675
|
+
if (verdict.kind === 'failed')
|
|
676
|
+
loginUnauthorized(true);
|
|
677
|
+
}
|
|
569
678
|
let hintVerdict;
|
|
570
679
|
try {
|
|
571
680
|
hintVerdict = await consumeOrUnavailable(this.loginLimiter, loginKey);
|
|
@@ -580,8 +689,10 @@ let AuthController = class AuthController {
|
|
|
580
689
|
// is audited ONCE on the TRANSITION instead — in the wrong-password branch below,
|
|
581
690
|
// when the attempt's count equals the threshold (feature #140 audit-on-the-edge).
|
|
582
691
|
// An attempt refused by the account lock is not an IP failure (as before #10).
|
|
692
|
+
// R12.4: still a failed sign-in for the counter (no row: the lockout row was written once).
|
|
583
693
|
await refundIp();
|
|
584
|
-
|
|
694
|
+
(0, auth_audit_js_1.countLoginFailure)('password', accountLabel);
|
|
695
|
+
loginUnauthorized(await this.captchaAfterFailure('login', captchaSubject));
|
|
585
696
|
}
|
|
586
697
|
let result;
|
|
587
698
|
try {
|
|
@@ -602,12 +713,14 @@ let AuthController = class AuthController {
|
|
|
602
713
|
if (result.outcome === 'authenticated') {
|
|
603
714
|
// Correct password → clear the login counter (do not carry earlier failures).
|
|
604
715
|
await this.loginLimiter?.reset(loginKey);
|
|
716
|
+
await this.captchaGate?.recordSuccess('login', captchaSubject);
|
|
605
717
|
const principal = requirePrincipal(result);
|
|
606
718
|
const est = await this.establishOrReject(principal);
|
|
607
|
-
await this.bindSession(est, principal);
|
|
719
|
+
const restricted = await this.bindSession(est, principal);
|
|
720
|
+
this.auditSignIn(principal, 'password');
|
|
608
721
|
// Inline the caller's auth-facts so the permission-gated nav is ready the instant
|
|
609
722
|
// login returns (one round-trip). Best-effort — omitted on any projection failure.
|
|
610
|
-
return this.sessionSuccessWithFacts(est, principal);
|
|
723
|
+
return this.sessionSuccessWithFacts(est, principal, restricted);
|
|
611
724
|
}
|
|
612
725
|
if (result.outcome === 'mfa_required') {
|
|
613
726
|
// Password verified, a second factor is outstanding. NOT a failure — but NOT a
|
|
@@ -615,6 +728,8 @@ let AuthController = class AuthController {
|
|
|
615
728
|
// so a stolen password cannot loop login→mfa to guess TOTP codes for free. This
|
|
616
729
|
// attempt's own count is given back (Phase 9 #10), leaving earlier failures intact.
|
|
617
730
|
await this.loginLimiter?.refund(loginKey);
|
|
731
|
+
// R7.3: the password was right; the CAPTCHA counters track password failures only.
|
|
732
|
+
await this.captchaGate?.recordSuccess('login', captchaSubject);
|
|
618
733
|
//
|
|
619
734
|
// M6: record the password step's SUCCESS — the resolved principal, the typed hint,
|
|
620
735
|
// and a fingerprint of the credential it was checked against — NEVER the password.
|
|
@@ -626,6 +741,8 @@ let AuthController = class AuthController {
|
|
|
626
741
|
principalId: accountId,
|
|
627
742
|
identityHint,
|
|
628
743
|
credentialFingerprint: (0, mfa_step_up_js_1.credentialFingerprint)(account),
|
|
744
|
+
// R12.2: the tenant of a failed second-factor audit row (resolved server-side).
|
|
745
|
+
...(accountTenant !== null ? { tenantId: accountTenant } : {}),
|
|
629
746
|
};
|
|
630
747
|
let challengeToken;
|
|
631
748
|
try {
|
|
@@ -649,19 +766,31 @@ let AuthController = class AuthController {
|
|
|
649
766
|
retryableUnavailable();
|
|
650
767
|
}
|
|
651
768
|
// A genuine wrong-password deny: the attempt was already counted by `consume` and
|
|
652
|
-
// stays counted.
|
|
653
|
-
//
|
|
654
|
-
//
|
|
769
|
+
// stays counted (R12.4 counter, known/unknown). R12.3: a resolved account writes ONE
|
|
770
|
+
// `auth.login.failed` row per limiter window (the attempt that opened it); an unknown name
|
|
771
|
+
// writes none.
|
|
772
|
+
(0, auth_audit_js_1.countLoginFailure)('password', accountLabel);
|
|
773
|
+
if (accountId !== null && accountTenant !== null && (0, auth_audit_js_1.opensFailureWindow)(hintVerdict)) {
|
|
774
|
+
this.auditFailure(accountId, accountTenant, 'password');
|
|
775
|
+
}
|
|
776
|
+
// When its count equals the limiter's threshold this attempt is the one that TRIPPED the
|
|
777
|
+
// lockout — audit `auth.login.locked` exactly ONCE here (the edge), never on the
|
|
778
|
+
// already-locked gate above (which would flood). R12.2/R12.3: the row names the RESOLVED
|
|
779
|
+
// account, never the typed hint; an unknown name only counts.
|
|
655
780
|
// NO notify: this is pre-auth (the caller has not proven ownership), and mailing the
|
|
656
781
|
// account on every lockout would let anyone email-bomb it / confirm it exists
|
|
657
782
|
// (recordSecurityEvent enforces this too).
|
|
658
783
|
if (this.loginLimiter != null && hintVerdict?.count === this.loginLimiter.maxAttempts) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
784
|
+
(0, auth_audit_js_1.countLockout)('password', accountLabel);
|
|
785
|
+
if (accountId !== null && accountTenant !== null) {
|
|
786
|
+
this.recordSecurityEvent({
|
|
787
|
+
action: 'auth.login.locked',
|
|
788
|
+
actorId: accountId,
|
|
789
|
+
tenantId: accountTenant,
|
|
790
|
+
attributes: { reason: 'rate_limit' },
|
|
791
|
+
notify: false,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
665
794
|
}
|
|
666
795
|
// #5: the failure also stays counted against the client IP (failures only — a success
|
|
667
796
|
// gives its attempt back and never resets this counter, so an attacker's own valid
|
|
@@ -669,6 +798,7 @@ let AuthController = class AuthController {
|
|
|
669
798
|
// spans many accounts).
|
|
670
799
|
if (ipLimiter != null && ipVerdict !== null) {
|
|
671
800
|
if (ipVerdict.count === ipLimiter.maxAttempts) {
|
|
801
|
+
(0, auth_audit_js_1.countLockout)('password', 'ip');
|
|
672
802
|
this.recordSecurityEvent({
|
|
673
803
|
action: 'auth.login.ip_locked',
|
|
674
804
|
identityHint: null,
|
|
@@ -677,7 +807,22 @@ let AuthController = class AuthController {
|
|
|
677
807
|
});
|
|
678
808
|
}
|
|
679
809
|
}
|
|
680
|
-
|
|
810
|
+
// R7.3: count the failure toward the CAPTCHA counters; flag the 401 once the next attempt
|
|
811
|
+
// needs a challenge (same for known and unknown names).
|
|
812
|
+
loginUnauthorized(await this.captchaAfterFailure('login', captchaSubject));
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* `GET /auth/login-options` (auth capabilities R7.3, R13.2, AC-23) — what the sign-in page needs
|
|
816
|
+
* before a session exists: the accepted sign-in forms and the public CAPTCHA settings
|
|
817
|
+
* (`{ provider, siteKey, mode }`, or `null` when CAPTCHA is off). Never the secret. `@Public()`:
|
|
818
|
+
* pre-session, reveals nothing about any account.
|
|
819
|
+
*/
|
|
820
|
+
loginOptions() {
|
|
821
|
+
const signInWith = this.signInWith ?? ['email'];
|
|
822
|
+
return {
|
|
823
|
+
signInWith: [...signInWith],
|
|
824
|
+
captcha: this.captchaGate ? { ...this.captchaGate.publicOptions } : null,
|
|
825
|
+
};
|
|
681
826
|
}
|
|
682
827
|
/**
|
|
683
828
|
* `GET /auth/me` — the signed-in user's **display profile** (name, email, active
|
|
@@ -706,10 +851,12 @@ let AuthController = class AuthController {
|
|
|
706
851
|
// Auth surface is up but request-auth isn't wired — cannot authenticate.
|
|
707
852
|
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
708
853
|
}
|
|
709
|
-
|
|
710
|
-
|
|
854
|
+
// Auth capabilities R2.5: the mandatory-2FA recompute runs here too (a version move only).
|
|
855
|
+
const resolved = await (0, request_principal_js_1.resolveRequestAuth)(authorization, this.tokenBinder, undefined, this.mfaRequirement);
|
|
856
|
+
if (resolved === null) {
|
|
711
857
|
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
712
858
|
}
|
|
859
|
+
const { principal, claims } = resolved;
|
|
713
860
|
// 2) Resolve the display profile via the consumer seam. No seam → honest 501.
|
|
714
861
|
// (The provider binds `null` when the consumer supplied none, so check nullish.)
|
|
715
862
|
if (this.profileResolver === undefined || this.profileResolver === null) {
|
|
@@ -729,6 +876,12 @@ let AuthController = class AuthController {
|
|
|
729
876
|
// login). 404 — never fall back to returning the raw ids.
|
|
730
877
|
throw new common_1.HttpException({ error: 'profile_not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
731
878
|
}
|
|
879
|
+
// R2.4: the grace deadline for the enrolment banner, as an ISO-8601 instant (UTC), only while
|
|
880
|
+
// one is bound (a passed one is still returned: the session is restricted from then on).
|
|
881
|
+
const deadline = claims.mfaEnrollmentDeadlineMs;
|
|
882
|
+
if (typeof deadline === 'number' && Number.isFinite(deadline)) {
|
|
883
|
+
return { ...profile, mfaEnrollmentDeadline: new Date(deadline).toISOString() };
|
|
884
|
+
}
|
|
732
885
|
return profile;
|
|
733
886
|
}
|
|
734
887
|
/**
|
|
@@ -772,10 +925,20 @@ let AuthController = class AuthController {
|
|
|
772
925
|
if (token === null) {
|
|
773
926
|
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
774
927
|
}
|
|
775
|
-
const
|
|
776
|
-
if (
|
|
928
|
+
const resolvedClaims = await this.tokenBinder.resolve(token);
|
|
929
|
+
if (resolvedClaims === null) {
|
|
777
930
|
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
778
931
|
}
|
|
932
|
+
// Auth capabilities R2.5: the mandatory-2FA recompute (a version move only; 503 on failure).
|
|
933
|
+
const claims = this.mfaRequirement !== undefined && this.mfaRequirement !== null
|
|
934
|
+
? await this.mfaRequirement.refresh(token, resolvedClaims)
|
|
935
|
+
: resolvedClaims;
|
|
936
|
+
// R15.2 (F9): a restricted session (re-enrol, or mandatory 2FA: D-2) may not change its scope.
|
|
937
|
+
// `@Public()` routes are not covered by the guard's gate, so the check lives here (same bodies).
|
|
938
|
+
const restriction = (0, session_restriction_js_1.restrictionOf)(claims, Date.now());
|
|
939
|
+
if (restriction !== null) {
|
|
940
|
+
throw new common_1.HttpException({ error: (0, session_restriction_js_1.restrictionErrorCode)(restriction) }, common_1.HttpStatus.FORBIDDEN);
|
|
941
|
+
}
|
|
779
942
|
// Phase 11 #1 / KI #79: only a canonical uuid is checked and BOUND. The membership check is SQL
|
|
780
943
|
// (`utf8mb4_unicode_ci` ignores case, trailing spaces and width), so any other spelling would
|
|
781
944
|
// pass the gate and be bound as the session's tenant in the caller's spelling — its
|
|
@@ -913,7 +1076,8 @@ let AuthController = class AuthController {
|
|
|
913
1076
|
// the account and minting/sending must never leak into the response timing. Only when
|
|
914
1077
|
// there IS a hint (an empty hint never sends, and must not burn a shared key).
|
|
915
1078
|
if (hint !== '') {
|
|
916
|
-
|
|
1079
|
+
const captchaToken = body?.captchaToken;
|
|
1080
|
+
void this.meterAndDeliverReset(hint, ipAllowed, ip, captchaToken).catch(() => {
|
|
917
1081
|
/* swallow — anti-enumeration: never let a store/email failure be observable */
|
|
918
1082
|
});
|
|
919
1083
|
}
|
|
@@ -930,7 +1094,7 @@ let AuthController = class AuthController {
|
|
|
930
1094
|
* 3. send only when both budgets allow, the account resolved, and the reset seams exist.
|
|
931
1095
|
* A lookup error still meters (fallback key) and skips the send (still 204).
|
|
932
1096
|
*/
|
|
933
|
-
async meterAndDeliverReset(hint, ipAllowed) {
|
|
1097
|
+
async meterAndDeliverReset(hint, ipAllowed, ip, captchaToken) {
|
|
934
1098
|
const resolver = this.credentialResolver;
|
|
935
1099
|
let resolved = null;
|
|
936
1100
|
let lookupFailed = false;
|
|
@@ -959,6 +1123,23 @@ let AuthController = class AuthController {
|
|
|
959
1123
|
});
|
|
960
1124
|
}
|
|
961
1125
|
}
|
|
1126
|
+
// R7.2–R7.4: the CAPTCHA gate runs here, detached like the rest of the found-path work, so
|
|
1127
|
+
// neither the challenge decision nor the provider call shows in the 204's timing. Every
|
|
1128
|
+
// request counts toward the forgot counters (a request-rate cap); a missing or failed
|
|
1129
|
+
// challenge does no work (still 204, counted).
|
|
1130
|
+
const gate = this.captchaGate;
|
|
1131
|
+
if (gate !== undefined && gate !== null && gate.covers('forgot_password')) {
|
|
1132
|
+
const subject = {
|
|
1133
|
+
accountId: principalId,
|
|
1134
|
+
tenantId: (0, auth_audit_js_1.tenantOfClaims)(resolved?.claims),
|
|
1135
|
+
typedText: hint,
|
|
1136
|
+
ip: this.perIpEnabled() ? (ip ?? null) : null,
|
|
1137
|
+
};
|
|
1138
|
+
const verdict = await gate.check('forgot_password', subject, captchaToken, ip);
|
|
1139
|
+
await gate.recordFailure('forgot_password', subject);
|
|
1140
|
+
if (verdict.kind === 'failed')
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
962
1143
|
const store = this.resetTokenStore;
|
|
963
1144
|
const sender = this.emailSender;
|
|
964
1145
|
if (!ipAllowed || !hintAllowed || lookupFailed || resolved === null || !principalId)
|
|
@@ -1057,6 +1238,16 @@ let AuthController = class AuthController {
|
|
|
1057
1238
|
hasher === undefined) {
|
|
1058
1239
|
throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
|
|
1059
1240
|
}
|
|
1241
|
+
// R7.2–R7.4: the CAPTCHA gate runs BEFORE the reset token is used (and before any hash). Its
|
|
1242
|
+
// only counter is the client IP (no account is known before the token is consumed), so
|
|
1243
|
+
// adaptive mode needs an explicit `trust proxy`; `always` works without it. A missing or
|
|
1244
|
+
// failed challenge answers the bad-token 400 plus `captcha: 'required'`.
|
|
1245
|
+
const resetSubject = { ip: this.perIpEnabled() ? ip : null };
|
|
1246
|
+
if (this.captchaGate?.covers('reset_password') === true) {
|
|
1247
|
+
const verdict = await this.captchaGate.check('reset_password', resetSubject, body?.captchaToken, ip);
|
|
1248
|
+
if (verdict.kind === 'failed')
|
|
1249
|
+
invalidResetToken(true);
|
|
1250
|
+
}
|
|
1060
1251
|
// Consume the token (single-use, TTL-checked). Unknown/expired/consumed → 400.
|
|
1061
1252
|
const principalId = await store.consume(sha256(token));
|
|
1062
1253
|
if (principalId === null) {
|
|
@@ -1078,7 +1269,7 @@ let AuthController = class AuthController {
|
|
|
1078
1269
|
});
|
|
1079
1270
|
}
|
|
1080
1271
|
}
|
|
1081
|
-
|
|
1272
|
+
invalidResetToken(await this.captchaAfterFailure('reset_password', resetSubject));
|
|
1082
1273
|
}
|
|
1083
1274
|
const newHash = await hasher.hash(newPassword);
|
|
1084
1275
|
await writer.setPassword(principalId, newHash);
|
|
@@ -1184,8 +1375,15 @@ let AuthController = class AuthController {
|
|
|
1184
1375
|
retryableUnavailable();
|
|
1185
1376
|
}
|
|
1186
1377
|
if (outcome.outcome !== 'authenticated' || outcome.principal.id !== pending.principalId) {
|
|
1378
|
+
// R12.3/R12.4: count it; one `auth.login.failed` row per window (the challenge proves the
|
|
1379
|
+
// password step, so the account is resolved; its tenant came from that step).
|
|
1380
|
+
(0, auth_audit_js_1.countLoginFailure)('totp', 'known');
|
|
1381
|
+
if (pending.tenantId !== undefined && (0, auth_audit_js_1.opensFailureWindow)(verdict)) {
|
|
1382
|
+
this.auditFailure(pending.principalId, pending.tenantId, 'totp');
|
|
1383
|
+
}
|
|
1187
1384
|
// The attempt stays counted; the edge (count === threshold) audits ONCE.
|
|
1188
1385
|
if (this.mfaLimiter != null && verdict?.count === this.mfaLimiter.maxAttempts) {
|
|
1386
|
+
(0, auth_audit_js_1.countLockout)('totp', 'known');
|
|
1189
1387
|
this.recordSecurityEvent({
|
|
1190
1388
|
action: 'auth.mfa.locked',
|
|
1191
1389
|
actorId: pending.principalId,
|
|
@@ -1197,11 +1395,12 @@ let AuthController = class AuthController {
|
|
|
1197
1395
|
}
|
|
1198
1396
|
const principal = outcome.principal;
|
|
1199
1397
|
const est = await this.establishOrReject(principal);
|
|
1200
|
-
await this.bindSession(est, principal);
|
|
1398
|
+
const restricted = await this.bindSession(est, principal);
|
|
1201
1399
|
await this.mfaLimiter?.reset(mfaKey);
|
|
1202
1400
|
await this.loginLimiter?.reset((0, canonical_hint_js_1.loginHintKey)(pending.identityHint, pending.principalId));
|
|
1401
|
+
this.auditSignIn(principal, 'totp');
|
|
1203
1402
|
// Same inline-facts enrichment as the password-login success (one round-trip).
|
|
1204
|
-
return this.sessionSuccessWithFacts(est, principal);
|
|
1403
|
+
return this.sessionSuccessWithFacts(est, principal, restricted);
|
|
1205
1404
|
}
|
|
1206
1405
|
/**
|
|
1207
1406
|
* The `/auth/mfa` RECOVERY-CODE branch (spec §3). The lost-device user completes
|
|
@@ -1253,6 +1452,12 @@ let AuthController = class AuthController {
|
|
|
1253
1452
|
// 401 (never surface a store error as a distinct outcome).
|
|
1254
1453
|
const ok = await this.recoveryCodes.consume(principalId, code).catch(() => false);
|
|
1255
1454
|
if (!ok) {
|
|
1455
|
+
// R12.3/R12.4: count it; one `auth.login.failed` row per window for the resolved account.
|
|
1456
|
+
(0, auth_audit_js_1.countLoginFailure)('recovery_code', 'known');
|
|
1457
|
+
const recoveryTenant = (0, auth_audit_js_1.tenantOfClaims)(claims);
|
|
1458
|
+
if (recoveryTenant !== null && (0, auth_audit_js_1.opensFailureWindow)(verdict)) {
|
|
1459
|
+
this.auditFailure(principalId, recoveryTenant, 'recovery_code');
|
|
1460
|
+
}
|
|
1256
1461
|
// A wrong (or unmatched) recovery code → the attempt stays counted, THEN the
|
|
1257
1462
|
// identical generic 401. When its count equals the threshold this attempt TRIPPED
|
|
1258
1463
|
// the lockout — audit `auth.mfa.recovery_locked` exactly ONCE on that edge (never on
|
|
@@ -1261,6 +1466,7 @@ let AuthController = class AuthController {
|
|
|
1261
1466
|
// emailing on every recovery lockout would let an attacker email-bomb the owner —
|
|
1262
1467
|
// audit-only is safer; the once-per-window edge already dedups it.
|
|
1263
1468
|
if (this.recoveryLimiter != null && verdict?.count === this.recoveryLimiter.maxAttempts) {
|
|
1469
|
+
(0, auth_audit_js_1.countLockout)('recovery_code', 'known');
|
|
1264
1470
|
this.recordSecurityEvent({
|
|
1265
1471
|
action: 'auth.mfa.recovery_locked',
|
|
1266
1472
|
actorId: principalId,
|
|
@@ -1298,7 +1504,7 @@ let AuthController = class AuthController {
|
|
|
1298
1504
|
// Enforced re-enroll (spec §4): a recovery-code login is lower-assurance, so the
|
|
1299
1505
|
// session is bound with mustReenroll:true — the enforcement gate then restricts it
|
|
1300
1506
|
// to the re-secure allowlist until a fresh device is confirmed.
|
|
1301
|
-
await this.bindSession(est, principal, true);
|
|
1507
|
+
const restricted = await this.bindSession(est, principal, true);
|
|
1302
1508
|
// Notify + audit (spec §6) via the REUSABLE security-event seam (feature #140):
|
|
1303
1509
|
// records `mfa.recovery_used` AND notifies the account owner, BOTH fire-and-forget +
|
|
1304
1510
|
// best-effort. A throwing/slow sink or sender NEVER fails the (legitimate) login
|
|
@@ -1311,11 +1517,10 @@ let AuthController = class AuthController {
|
|
|
1311
1517
|
tenantId: principal.tenantId,
|
|
1312
1518
|
notify: true,
|
|
1313
1519
|
});
|
|
1314
|
-
|
|
1315
|
-
// session is enforced-re-enroll-restricted,
|
|
1316
|
-
//
|
|
1317
|
-
|
|
1318
|
-
return this.sessionSuccessWithFacts(est, principal, { mfaRecoveryUsed: true });
|
|
1520
|
+
this.auditSignIn(principal, 'recovery_code');
|
|
1521
|
+
// The session is enforced-re-enroll-restricted, so no inline auth-facts (review round 1,
|
|
1522
|
+
// item C): the frontend reads the restricted shape from `/identity/auth-facts`.
|
|
1523
|
+
return this.sessionSuccessWithFacts(est, principal, restricted, { mfaRecoveryUsed: true });
|
|
1319
1524
|
}
|
|
1320
1525
|
/**
|
|
1321
1526
|
* Fire-and-forget security notice for a recovery-code login (spec §6.1). Resolves the
|
|
@@ -1360,6 +1565,34 @@ let AuthController = class AuthController {
|
|
|
1360
1565
|
/* best-effort: a resolver/email failure never fails the login */
|
|
1361
1566
|
});
|
|
1362
1567
|
}
|
|
1568
|
+
/** R12.2: `auth.login.succeeded` for an established session (fire-and-forget). */
|
|
1569
|
+
auditSignIn(principal, method) {
|
|
1570
|
+
(0, auth_audit_js_1.emitAuthAudit)(this.auditSink, (0, auth_audit_js_1.authEvent)(auth_audit_js_1.AUTH_LOGIN_SUCCEEDED, principal.id, principal.tenantId, { method }));
|
|
1571
|
+
}
|
|
1572
|
+
/** R12.2/R12.3: `auth.login.failed` for a RESOLVED account (the caller bounds it per window). */
|
|
1573
|
+
auditFailure(actorId, tenantId, method) {
|
|
1574
|
+
(0, auth_audit_js_1.emitAuthAudit)(this.auditSink, (0, auth_audit_js_1.authEvent)(auth_audit_js_1.AUTH_LOGIN_FAILED, actorId, tenantId, { method }));
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* The durable session `logout` is about to end, when it is ACTIVE and a sink is bound; else
|
|
1578
|
+
* `null` (no row). A store error → `null` (the logout itself is unaffected).
|
|
1579
|
+
*/
|
|
1580
|
+
async activeSessionForAudit(sessionId) {
|
|
1581
|
+
if (this.auditSink === undefined || this.auditSink === null)
|
|
1582
|
+
return null;
|
|
1583
|
+
if (this.sessionRecords === undefined || this.sessionRecords === null || sessionId === '') {
|
|
1584
|
+
return null;
|
|
1585
|
+
}
|
|
1586
|
+
try {
|
|
1587
|
+
const record = await this.sessionRecords.findSessionById(sessionId);
|
|
1588
|
+
return record !== null && record.status === 'active'
|
|
1589
|
+
? { principalId: record.principalId, tenantId: record.tenantId }
|
|
1590
|
+
: null;
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1363
1596
|
/**
|
|
1364
1597
|
* Record an auth-SECURITY event (best-effort, fire-and-forget) and OPTIONALLY notify
|
|
1365
1598
|
* the account owner. The REUSABLE audit+notify primitive (feature #140) — it
|
|
@@ -1570,7 +1803,11 @@ let AuthController = class AuthController {
|
|
|
1570
1803
|
}
|
|
1571
1804
|
if (durableFacts.accountActive !== true)
|
|
1572
1805
|
return false;
|
|
1573
|
-
|
|
1806
|
+
// Decision (1): `s_restriction ?? (s_must_reenroll ? 'reenroll' : null)`.
|
|
1807
|
+
const restricted = durableFacts.mustReenroll !== false || durableFacts.restriction === 'reenroll';
|
|
1808
|
+
// D-2: the mandatory-2FA restriction survives the rebuild. The rebuilt binding carries no
|
|
1809
|
+
// requirement stamp, so its first request re-evaluates (and restores a grace deadline).
|
|
1810
|
+
const mfaRestricted = !restricted && durableFacts.restriction === 'mfa_enrollment';
|
|
1574
1811
|
const claims = {
|
|
1575
1812
|
id: principalId,
|
|
1576
1813
|
tenantId,
|
|
@@ -1579,6 +1816,7 @@ let AuthController = class AuthController {
|
|
|
1579
1816
|
isRoot: !restricted && Array.isArray(this.rootIds) && this.rootIds.includes(principalId),
|
|
1580
1817
|
sessionId,
|
|
1581
1818
|
mustReenroll: restricted,
|
|
1819
|
+
...(mfaRestricted ? { restriction: 'mfa_enrollment' } : {}),
|
|
1582
1820
|
...(fresh.facts ? { cachedFacts: (0, session_token_binder_js_1.factsSlot)(fresh.facts) } : {}),
|
|
1583
1821
|
...(fresh.subtree !== undefined && fresh.subtree.length > 0
|
|
1584
1822
|
? { scopeSubtreeNodeIds: [...fresh.subtree] }
|
|
@@ -1612,17 +1850,47 @@ let AuthController = class AuthController {
|
|
|
1612
1850
|
}
|
|
1613
1851
|
}
|
|
1614
1852
|
/**
|
|
1615
|
-
* `POST /auth/logout` — revoke the session
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1853
|
+
* `POST /auth/logout` — revoke the session the caller proves it owns (R15.1, D-9).
|
|
1854
|
+
* Proof is a bearer access token bound to `sessionId` or a refresh token of it
|
|
1855
|
+
* ({@link LogoutProofVerifier}):
|
|
1856
|
+
* - proven → revoke + unbind;
|
|
1857
|
+
* - proof for another session, or an unknown/invalid token → revoke nothing;
|
|
1858
|
+
* - no proof (an old frontend), or a proof lookup outage → revoke only while
|
|
1859
|
+
* `logoutRequiresProof` is false (counted `auth.logout.unproven`, one deprecation warning);
|
|
1860
|
+
* strict mode revokes nothing (fail closed).
|
|
1861
|
+
* Always 204 — never a 500, never an existence or ownership signal. Tokens are never logged.
|
|
1618
1862
|
*/
|
|
1619
|
-
async logout(body) {
|
|
1863
|
+
async logout(body, authorization) {
|
|
1620
1864
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
|
|
1621
|
-
// `revoke` is void + best-effort-idempotent by contract; it never throws a
|
|
1622
|
-
// resource-existence signal. We still guard against an unexpected throw so a
|
|
1623
|
-
// logout never 500s (which could itself be an existence oracle).
|
|
1624
1865
|
try {
|
|
1866
|
+
const accessToken = (0, request_principal_js_1.extractBearerToken)(authorization);
|
|
1867
|
+
const refreshToken = typeof body?.refreshToken === 'string' && body.refreshToken !== ''
|
|
1868
|
+
? body.refreshToken
|
|
1869
|
+
: null;
|
|
1870
|
+
const proof = this.logoutProof !== undefined && this.logoutProof !== null
|
|
1871
|
+
? await this.logoutProof.verify(sessionId, accessToken, refreshToken)
|
|
1872
|
+
: accessToken === null && refreshToken === null
|
|
1873
|
+
? 'none'
|
|
1874
|
+
: 'unavailable';
|
|
1875
|
+
if (proof === 'mismatch')
|
|
1876
|
+
return;
|
|
1877
|
+
if (proof !== 'proven') {
|
|
1878
|
+
// 'none' or 'unavailable': strict mode fails closed; compatibility mode keeps today's revoke.
|
|
1879
|
+
if (this.logoutRequiresProof === true)
|
|
1880
|
+
return;
|
|
1881
|
+
(0, logout_proof_js_1.recordUnprovenLogout)();
|
|
1882
|
+
}
|
|
1883
|
+
// R12.2: read the session before revoking, so `auth.logout` is written only when an ACTIVE
|
|
1884
|
+
// session ends, attributed to its own principal and tenant (never the request). Only with a
|
|
1885
|
+
// sink bound; logout is a lifecycle event, so this one read is allowed (ADR-0013).
|
|
1886
|
+
const ending = await this.activeSessionForAudit(sessionId);
|
|
1887
|
+
// `revoke` is void + best-effort-idempotent by contract; it never throws a
|
|
1888
|
+
// resource-existence signal. We still guard against an unexpected throw so a
|
|
1889
|
+
// logout never 500s (which could itself be an existence oracle).
|
|
1625
1890
|
await this.sessionAuthority.revoke(sessionId);
|
|
1891
|
+
if (ending !== null) {
|
|
1892
|
+
(0, auth_audit_js_1.emitAuthAudit)(this.auditSink, (0, auth_audit_js_1.logoutEvent)(ending.principalId, ending.tenantId, proof === 'proven'));
|
|
1893
|
+
}
|
|
1626
1894
|
// Drop the session-claims cushion so a post-logout rotate can't re-mint a
|
|
1627
1895
|
// binding (the session is revoked anyway; this is defense-in-depth).
|
|
1628
1896
|
if (this.tokenBinder !== undefined)
|
|
@@ -1658,6 +1926,13 @@ __decorate([
|
|
|
1658
1926
|
__metadata("design:paramtypes", [Object, String]),
|
|
1659
1927
|
__metadata("design:returntype", Promise)
|
|
1660
1928
|
], AuthController.prototype, "login", null);
|
|
1929
|
+
__decorate([
|
|
1930
|
+
(0, classification_decorator_js_1.Public)(),
|
|
1931
|
+
(0, common_1.Get)('login-options'),
|
|
1932
|
+
__metadata("design:type", Function),
|
|
1933
|
+
__metadata("design:paramtypes", []),
|
|
1934
|
+
__metadata("design:returntype", Object)
|
|
1935
|
+
], AuthController.prototype, "loginOptions", null);
|
|
1661
1936
|
__decorate([
|
|
1662
1937
|
(0, classification_decorator_js_1.Public)(),
|
|
1663
1938
|
(0, common_1.Get)('me'),
|
|
@@ -1720,8 +1995,9 @@ __decorate([
|
|
|
1720
1995
|
(0, common_1.Post)('logout'),
|
|
1721
1996
|
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
1722
1997
|
__param(0, (0, common_1.Body)()),
|
|
1998
|
+
__param(1, (0, common_1.Headers)('authorization')),
|
|
1723
1999
|
__metadata("design:type", Function),
|
|
1724
|
-
__metadata("design:paramtypes", [Object]),
|
|
2000
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
1725
2001
|
__metadata("design:returntype", Promise)
|
|
1726
2002
|
], AuthController.prototype, "logout", null);
|
|
1727
2003
|
exports.AuthController = AuthController = __decorate([
|
|
@@ -1790,7 +2066,17 @@ exports.AuthController = AuthController = __decorate([
|
|
|
1790
2066
|
__param(32, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
|
|
1791
2067
|
__param(33, (0, common_2.Optional)()),
|
|
1792
2068
|
__param(33, (0, common_1.Inject)(tokens_js_1.SHIELD_MESSAGE_DISPATCHER)),
|
|
1793
|
-
|
|
2069
|
+
__param(34, (0, common_2.Optional)()),
|
|
2070
|
+
__param(34, (0, common_1.Inject)(logout_proof_js_1.SHIELD_LOGOUT_PROOF_VERIFIER)),
|
|
2071
|
+
__param(35, (0, common_2.Optional)()),
|
|
2072
|
+
__param(35, (0, common_1.Inject)(logout_proof_js_1.SHIELD_LOGOUT_REQUIRES_PROOF)),
|
|
2073
|
+
__param(36, (0, common_2.Optional)()),
|
|
2074
|
+
__param(36, (0, common_1.Inject)(tokens_js_3.SHIELD_MFA_REQUIREMENT)),
|
|
2075
|
+
__param(37, (0, common_2.Optional)()),
|
|
2076
|
+
__param(37, (0, common_1.Inject)(tokens_js_3.SHIELD_CAPTCHA_GATE)),
|
|
2077
|
+
__param(38, (0, common_2.Optional)()),
|
|
2078
|
+
__param(38, (0, common_1.Inject)(tokens_js_3.SHIELD_SIGN_IN_WITH)),
|
|
2079
|
+
__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, Object, Object, Object, Object, Object, Object, Object])
|
|
1794
2080
|
], AuthController);
|
|
1795
2081
|
/**
|
|
1796
2082
|
* Narrow an `authenticated` result to its guaranteed principal. The core engine
|