@astram/guard 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +245 -0
- package/dist/auth/account.controller.d.ts.map +1 -0
- package/dist/auth/account.controller.js +673 -0
- package/dist/auth/account.controller.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +316 -0
- package/dist/auth/auth.controller.d.ts.map +1 -0
- package/dist/auth/auth.controller.js +1091 -0
- package/dist/auth/auth.controller.js.map +1 -0
- package/dist/auth/auth.module.d.ts +202 -0
- package/dist/auth/auth.module.d.ts.map +1 -0
- package/dist/auth/auth.module.js +610 -0
- package/dist/auth/auth.module.js.map +1 -0
- package/dist/auth/connections.d.ts +61 -0
- package/dist/auth/connections.d.ts.map +1 -0
- package/dist/auth/connections.js +116 -0
- package/dist/auth/connections.js.map +1 -0
- package/dist/auth/dto.d.ts +154 -0
- package/dist/auth/dto.d.ts.map +1 -0
- package/dist/auth/dto.js +29 -0
- package/dist/auth/dto.js.map +1 -0
- package/dist/auth/pending-mfa-store.d.ts +72 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -0
- package/dist/auth/pending-mfa-store.js +106 -0
- package/dist/auth/pending-mfa-store.js.map +1 -0
- package/dist/auth/persistence.module.d.ts +42 -0
- package/dist/auth/persistence.module.d.ts.map +1 -0
- package/dist/auth/persistence.module.js +94 -0
- package/dist/auth/persistence.module.js.map +1 -0
- package/dist/auth/ports/audit-sink.d.ts +47 -0
- package/dist/auth/ports/audit-sink.d.ts.map +1 -0
- package/dist/auth/ports/audit-sink.js +20 -0
- package/dist/auth/ports/audit-sink.js.map +1 -0
- package/dist/auth/ports/credential-writer.d.ts +23 -0
- package/dist/auth/ports/credential-writer.d.ts.map +1 -0
- package/dist/auth/ports/credential-writer.js +2 -0
- package/dist/auth/ports/credential-writer.js.map +1 -0
- package/dist/auth/ports/email-sender.d.ts +14 -0
- package/dist/auth/ports/email-sender.d.ts.map +1 -0
- package/dist/auth/ports/email-sender.js +2 -0
- package/dist/auth/ports/email-sender.js.map +1 -0
- package/dist/auth/ports/mfa-device-store.d.ts +45 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
- package/dist/auth/ports/mfa-device-store.js +13 -0
- package/dist/auth/ports/mfa-device-store.js.map +1 -0
- package/dist/auth/ports/profile-writer.d.ts +12 -0
- package/dist/auth/ports/profile-writer.d.ts.map +1 -0
- package/dist/auth/ports/profile-writer.js +2 -0
- package/dist/auth/ports/profile-writer.js.map +1 -0
- package/dist/auth/ports/recovery-code-store.d.ts +10 -0
- package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
- package/dist/auth/ports/recovery-code-store.js +2 -0
- package/dist/auth/ports/recovery-code-store.js.map +1 -0
- package/dist/auth/ports/reset-token-store.d.ts +12 -0
- package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
- package/dist/auth/ports/reset-token-store.js +2 -0
- package/dist/auth/ports/reset-token-store.js.map +1 -0
- package/dist/auth/ports/session-facts-projector.d.ts +44 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
- package/dist/auth/ports/session-facts-projector.js +30 -0
- package/dist/auth/ports/session-facts-projector.js.map +1 -0
- package/dist/auth/ports/session-sweeper.d.ts +12 -0
- package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
- package/dist/auth/ports/session-sweeper.js +2 -0
- package/dist/auth/ports/session-sweeper.js.map +1 -0
- package/dist/auth/profile-resolver.d.ts +73 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -0
- package/dist/auth/profile-resolver.js +28 -0
- package/dist/auth/profile-resolver.js.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.js +133 -0
- package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
- package/dist/auth/tokens.d.ts +236 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +254 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp-enrollment.d.ts +42 -0
- package/dist/auth/totp-enrollment.d.ts.map +1 -0
- package/dist/auth/totp-enrollment.js +96 -0
- package/dist/auth/totp-enrollment.js.map +1 -0
- package/dist/authentication/argon2id-password-hasher.js.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +110 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.js +170 -0
- package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.js +397 -0
- package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.js +108 -0
- package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.js +154 -0
- package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.js +106 -0
- package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
- package/dist/authz/record-share-cascade.d.ts +52 -0
- package/dist/authz/record-share-cascade.d.ts.map +1 -0
- package/dist/authz/record-share-cascade.js +49 -0
- package/dist/authz/record-share-cascade.js.map +1 -0
- package/dist/authz/record-share-writer.d.ts +175 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -0
- package/dist/authz/record-share-writer.js +225 -0
- package/dist/authz/record-share-writer.js.map +1 -0
- package/dist/authz/wire-record-authorization.d.ts +66 -0
- package/dist/authz/wire-record-authorization.d.ts.map +1 -0
- package/dist/authz/wire-record-authorization.js +34 -0
- package/dist/authz/wire-record-authorization.js.map +1 -0
- package/dist/boot/boot-sequence.js.map +1 -0
- package/dist/boot/boot.module.js.map +1 -0
- package/dist/completeness/catalog-reconciler.d.ts +21 -0
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
- package/dist/completeness/catalog-reconciler.js +74 -0
- package/dist/completeness/catalog-reconciler.js.map +1 -0
- package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
- package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
- package/dist/completeness/operation-inventory-scanner.js +79 -0
- package/dist/completeness/operation-inventory-scanner.js.map +1 -0
- package/dist/completeness/ports/catalog-store.d.ts +33 -0
- package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
- package/dist/completeness/ports/catalog-store.js +2 -0
- package/dist/completeness/ports/catalog-store.js.map +1 -0
- package/dist/config/app-config.d.ts +134 -1
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +144 -2
- package/dist/config/app-config.js.map +1 -0
- package/dist/config/config.module.js.map +1 -0
- package/dist/enforcement/classification.decorator.d.ts +33 -14
- package/dist/enforcement/classification.decorator.d.ts.map +1 -1
- package/dist/enforcement/classification.decorator.js +34 -14
- package/dist/enforcement/classification.decorator.js.map +1 -0
- package/dist/enforcement/core-seam.js.map +1 -0
- package/dist/enforcement/enforcement.module.js.map +1 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +108 -25
- package/dist/enforcement/global-enforcement.guard.js.map +1 -0
- package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
- package/dist/enforcement/reenroll-allowlist.js +77 -0
- package/dist/enforcement/reenroll-allowlist.js.map +1 -0
- package/dist/index.d.ts +43 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -1
- package/dist/index.js.map +1 -0
- package/dist/module/shield-tokens.d.ts +15 -0
- package/dist/module/shield-tokens.d.ts.map +1 -0
- package/dist/module/shield-tokens.js +17 -0
- package/dist/module/shield-tokens.js.map +1 -0
- package/dist/module/shield.module.d.ts +203 -8
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +232 -14
- package/dist/module/shield.module.js.map +1 -0
- package/dist/request-auth/request-principal.d.ts +42 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -0
- package/dist/request-auth/request-principal.js +87 -0
- package/dist/request-auth/request-principal.js.map +1 -0
- package/dist/request-auth/session-facts-als.d.ts +65 -0
- package/dist/request-auth/session-facts-als.d.ts.map +1 -0
- package/dist/request-auth/session-facts-als.js +57 -0
- package/dist/request-auth/session-facts-als.js.map +1 -0
- package/dist/request-auth/session-token-binder.d.ts +130 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -0
- package/dist/request-auth/session-token-binder.js +277 -0
- package/dist/request-auth/session-token-binder.js.map +1 -0
- package/dist/request-auth/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- package/dist/request-auth/tokens.js.map +1 -0
- package/dist/session/crypto-adapters.js.map +1 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/mariadb-session-record-store.js.map +1 -0
- package/dist/session/redis-hot-session-store.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 0b Auth-surface — the framework-owned **`/auth/*` HTTP controllers**
|
|
4
|
+
* (spec §2). A consumer app gets login + MFA + refresh + logout FOR FREE by
|
|
5
|
+
* importing `ShieldModule.forRoot()` (which imports `AuthModule`); it writes no
|
|
6
|
+
* auth code. All four routes are `@Public()` (ADR-0002): they run BEFORE the
|
|
7
|
+
* caller has a session, so the global deny-by-default guard must NOT gate them.
|
|
8
|
+
*
|
|
9
|
+
* This controller is a THIN transport shim over the two composed core engines
|
|
10
|
+
* (`SHIELD_AUTHENTICATOR` / `SHIELD_SESSION_AUTHORITY`, from Team B). It holds NO
|
|
11
|
+
* business logic beyond mapping engine outcomes → HTTP status + the frozen DTO
|
|
12
|
+
* shapes (`./dto.ts`). Every security invariant is enforced here per the DTO
|
|
13
|
+
* header + the spec:
|
|
14
|
+
*
|
|
15
|
+
* ── OUTCOME → HTTP STATUS MAP (the security contract) ────────────────────────
|
|
16
|
+
* login authenticated → 200 SessionSuccess { session(no-secret), tokens }
|
|
17
|
+
* login mfa_required → 200 MfaChallengeResponse { mfaRequired, challengeToken }
|
|
18
|
+
* login invalid_credentials → 401 GENERIC ← unknown-user AND wrong-password IDENTICAL
|
|
19
|
+
* login auth_unavailable → 503 RETRYABLE ← transient blip is NOT a credential deny
|
|
20
|
+
* mfa success → 200 SessionSuccess
|
|
21
|
+
* mfa wrong/expired/forged → 401 GENERIC ← indistinguishable from a bad code
|
|
22
|
+
* mfa auth_unavailable → 503 RETRYABLE
|
|
23
|
+
* refresh ok → 200 SessionSuccess (new tokens, rotated session)
|
|
24
|
+
* refresh reuse/invalid → 401 GENERIC session_rejected (lineage-kill is the engine's)
|
|
25
|
+
* logout → 204 (idempotent; unknown/already-revoked → still 204)
|
|
26
|
+
*
|
|
27
|
+
* ── ANTI-ENUMERATION ── login + mfa failures are a SINGLE generic 401 with an
|
|
28
|
+
* identical body (`{ error: 'invalid_credentials' }`) whether the user is
|
|
29
|
+
* unknown, the password is wrong, or the factor is wrong/expired. The core
|
|
30
|
+
* authenticator's decoy-verify already equalizes timing; this layer never adds a
|
|
31
|
+
* distinguishing field/status.
|
|
32
|
+
*
|
|
33
|
+
* ── NO SECRET LEAK ── no password, hash, TOTP secret, raw token (beyond the two a
|
|
34
|
+
* success legitimately mints), or challenge internal ever appears in a body or a
|
|
35
|
+
* log. Request bodies are NEVER logged. The `SessionView` mapped from the core
|
|
36
|
+
* `Session` carries no secret by construction.
|
|
37
|
+
*
|
|
38
|
+
* ── TRANSIENT ≠ DENY (ADR-0016) ── a transient `authentication_unavailable` from
|
|
39
|
+
* the authenticator becomes a RETRYABLE 503, never a 401 — a dependency blip must
|
|
40
|
+
* not look like (or trigger the client to treat it as) a credential failure.
|
|
41
|
+
* ⚠ ENGINE-SEAM LIMIT on /auth/refresh: the core `SessionAuthority.rotate`
|
|
42
|
+
* collapses BOTH a terminal deny AND a transient store outage into one
|
|
43
|
+
* `SessionRejectedError` (its top-level fail-closed catch — see
|
|
44
|
+
* session-authority.ts). From THIS seam the two are indistinguishable, so refresh
|
|
45
|
+
* maps the generic reject to 401. Surfacing a retryable-503 on a refresh-time
|
|
46
|
+
* store outage would require the engine to expose a transient signal it does not
|
|
47
|
+
* today — flagged to the architect as a possible core-API gap (NOT worked around
|
|
48
|
+
* here; we do not invent a core API).
|
|
49
|
+
*
|
|
50
|
+
* ADR-0023: composition is the framework's job — this controller only CONSUMES the
|
|
51
|
+
* composed engines via DI tokens; it never news-up an engine or an adapter.
|
|
52
|
+
*/
|
|
53
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
54
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
55
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
56
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
57
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
58
|
+
};
|
|
59
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
60
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
61
|
+
};
|
|
62
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
63
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
64
|
+
};
|
|
65
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
66
|
+
exports.AuthController = void 0;
|
|
67
|
+
const common_1 = require("@nestjs/common");
|
|
68
|
+
const node_crypto_1 = require("node:crypto");
|
|
69
|
+
const common_2 = require("@nestjs/common");
|
|
70
|
+
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
71
|
+
const tokens_js_1 = require("./tokens.js");
|
|
72
|
+
const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
|
|
73
|
+
const tokens_js_2 = require("../request-auth/tokens.js");
|
|
74
|
+
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
75
|
+
const tokens_js_3 = require("./tokens.js");
|
|
76
|
+
/**
|
|
77
|
+
* The core `Authenticator`'s generic transient reason (see authenticator.ts —
|
|
78
|
+
* `REASON_AUTH_UNAVAILABLE`). A `rejected` result carrying THIS reason is the
|
|
79
|
+
* ADR-0016 transient signal → 503; any OTHER rejected reason is a credential deny
|
|
80
|
+
* → generic 401.
|
|
81
|
+
*/
|
|
82
|
+
const REASON_AUTH_UNAVAILABLE = 'authentication_unavailable';
|
|
83
|
+
/**
|
|
84
|
+
* The SINGLE generic body for EVERY login/mfa/refresh failure. Deliberately
|
|
85
|
+
* non-distinguishing (anti-enumeration): unknown user, wrong password, wrong/
|
|
86
|
+
* expired/forged factor, and a rejected refresh ALL return this identical body.
|
|
87
|
+
* Carries NO reason detail that could leak which check failed.
|
|
88
|
+
*/
|
|
89
|
+
const GENERIC_AUTH_FAILURE = Object.freeze({ error: 'invalid_credentials' });
|
|
90
|
+
/** The generic retryable body for a transient dependency outage (ADR-0016). */
|
|
91
|
+
const RETRYABLE_BODY = Object.freeze({ error: 'authentication_unavailable' });
|
|
92
|
+
/** Password-reset token time-to-live: 30 minutes (spec §4.1). */
|
|
93
|
+
const RESET_TTL_MS = 30 * 60 * 1000;
|
|
94
|
+
/** Minimum acceptable new-password length on reset (matches change-password). */
|
|
95
|
+
const MIN_PASSWORD_LEN = 8;
|
|
96
|
+
/**
|
|
97
|
+
* SHA-256 of the raw reset token. The token store only ever sees this HASH — the raw
|
|
98
|
+
* token lives only in the emailed link + the reset request (never persisted).
|
|
99
|
+
*/
|
|
100
|
+
function sha256(value) {
|
|
101
|
+
return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
/** Throw the generic 401 (indistinguishable across all credential-failure paths). */
|
|
104
|
+
function genericUnauthorized() {
|
|
105
|
+
throw new common_1.HttpException(GENERIC_AUTH_FAILURE, common_1.HttpStatus.UNAUTHORIZED);
|
|
106
|
+
}
|
|
107
|
+
/** Throw the retryable 503 (transient — NOT a deny; the client may retry). */
|
|
108
|
+
function retryableUnavailable() {
|
|
109
|
+
throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The generic 429 for a rate-limited RESET-password (feature #141). Reset is
|
|
113
|
+
* token-gated (a 256-bit single-use token), NOT enumeration-sensitive, so surfacing
|
|
114
|
+
* "too many requests" is safe (unlike forgot-password, which stays 204). Carries no
|
|
115
|
+
* detail beyond the neutral throttle signal.
|
|
116
|
+
*/
|
|
117
|
+
function tooManyRequests() {
|
|
118
|
+
throw new common_1.HttpException({ error: 'too_many_requests' }, common_1.HttpStatus.TOO_MANY_REQUESTS);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Map a core `Session` → the client-safe {@link SessionView}. Copies ONLY the
|
|
122
|
+
* non-secret lifecycle fields the DTO allows — there is no token/hash on `Session`
|
|
123
|
+
* to begin with, but this explicit projection guarantees no future `Session` field
|
|
124
|
+
* leaks into a response body by accident.
|
|
125
|
+
*/
|
|
126
|
+
function toSessionView(session) {
|
|
127
|
+
return {
|
|
128
|
+
sessionId: session.sessionId,
|
|
129
|
+
principalId: session.principalId,
|
|
130
|
+
tenantId: session.tenantId,
|
|
131
|
+
status: session.status,
|
|
132
|
+
issuedAtMs: session.issuedAtMs,
|
|
133
|
+
expiresAtMs: session.expiresAtMs,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Map a core `SessionEstablishment` → the {@link SessionSuccess} success body. */
|
|
137
|
+
function toSessionSuccess(est) {
|
|
138
|
+
return {
|
|
139
|
+
session: toSessionView(est.session),
|
|
140
|
+
tokens: {
|
|
141
|
+
accessToken: est.tokens.accessToken,
|
|
142
|
+
refreshToken: est.tokens.refreshToken,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Build the password credential the authenticator expects. */
|
|
147
|
+
function passwordCredential(password) {
|
|
148
|
+
return { factor: 'password', payload: { password } };
|
|
149
|
+
}
|
|
150
|
+
/** Build the TOTP credential the authenticator's factor verifier expects (payload.code). */
|
|
151
|
+
function totpCredential(code) {
|
|
152
|
+
return { factor: 'totp', payload: { code } };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The framework-owned auth endpoints. Registered into `AuthModule`'s `controllers`
|
|
156
|
+
* by the integration owner / Team B (this controller is EXPORTED for that purpose;
|
|
157
|
+
* it does NOT edit auth.module.ts itself). Depends on the two composed engines +
|
|
158
|
+
* the pending-MFA store (provided alongside it).
|
|
159
|
+
*/
|
|
160
|
+
let AuthController = class AuthController {
|
|
161
|
+
authenticator;
|
|
162
|
+
sessionAuthority;
|
|
163
|
+
pendingMfa;
|
|
164
|
+
tokenBinder;
|
|
165
|
+
profileResolver;
|
|
166
|
+
credentialResolver;
|
|
167
|
+
resetTokenStore;
|
|
168
|
+
emailSender;
|
|
169
|
+
credentialWriter;
|
|
170
|
+
sessionSweeper;
|
|
171
|
+
passwordHasher;
|
|
172
|
+
resetLinkBaseUrl;
|
|
173
|
+
recoveryCodes;
|
|
174
|
+
loginLimiter;
|
|
175
|
+
recoveryLimiter;
|
|
176
|
+
forgotLimiter;
|
|
177
|
+
forgotHintLimiter;
|
|
178
|
+
resetLimiter;
|
|
179
|
+
auditSink;
|
|
180
|
+
sessionFactsProjector;
|
|
181
|
+
constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector) {
|
|
182
|
+
this.authenticator = authenticator;
|
|
183
|
+
this.sessionAuthority = sessionAuthority;
|
|
184
|
+
this.pendingMfa = pendingMfa;
|
|
185
|
+
this.tokenBinder = tokenBinder;
|
|
186
|
+
this.profileResolver = profileResolver;
|
|
187
|
+
this.credentialResolver = credentialResolver;
|
|
188
|
+
this.resetTokenStore = resetTokenStore;
|
|
189
|
+
this.emailSender = emailSender;
|
|
190
|
+
this.credentialWriter = credentialWriter;
|
|
191
|
+
this.sessionSweeper = sessionSweeper;
|
|
192
|
+
this.passwordHasher = passwordHasher;
|
|
193
|
+
this.resetLinkBaseUrl = resetLinkBaseUrl;
|
|
194
|
+
this.recoveryCodes = recoveryCodes;
|
|
195
|
+
this.loginLimiter = loginLimiter;
|
|
196
|
+
this.recoveryLimiter = recoveryLimiter;
|
|
197
|
+
this.forgotLimiter = forgotLimiter;
|
|
198
|
+
this.forgotHintLimiter = forgotHintLimiter;
|
|
199
|
+
this.resetLimiter = resetLimiter;
|
|
200
|
+
this.auditSink = auditSink;
|
|
201
|
+
this.sessionFactsProjector = sessionFactsProjector;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Bind the established session's access token to the principal's claims (the
|
|
205
|
+
* request-auth layer). The access-token TTL is `expiresAtMs - issuedAtMs` from the
|
|
206
|
+
* session. Best-effort: a binding failure must not fail the login (the caller still
|
|
207
|
+
* gets valid tokens); the guard fails closed if the binding is absent.
|
|
208
|
+
*/
|
|
209
|
+
async bindSession(est, principal, mustReenroll = false) {
|
|
210
|
+
if (this.tokenBinder === undefined)
|
|
211
|
+
return;
|
|
212
|
+
const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
|
|
213
|
+
// EAGER session-facts projection (ADR-0011): compute the principal's coarse grants
|
|
214
|
+
// NOW so the guard can serve gate 5 from the request's `cachedFacts`. Absent projector
|
|
215
|
+
// / a null result → `cachedFacts` stays undefined → gate 5 DB-reloads (today's
|
|
216
|
+
// behavior). Best-effort inside projectFacts (a failure → undefined, never throws).
|
|
217
|
+
const cachedFacts = await this.projectFacts(principal);
|
|
218
|
+
try {
|
|
219
|
+
await this.tokenBinder.bind(est.tokens.accessToken, {
|
|
220
|
+
id: principal.id,
|
|
221
|
+
tenantId: principal.tenantId,
|
|
222
|
+
roleIds: [...principal.roleIds],
|
|
223
|
+
kind: principal.kind,
|
|
224
|
+
isRoot: principal.isRoot === true,
|
|
225
|
+
sessionId: est.session.sessionId,
|
|
226
|
+
// Enforced-re-enroll flag (spec §4): TRUE only on the recovery-code login
|
|
227
|
+
// path (which passes it); all other logins default to FALSE.
|
|
228
|
+
mustReenroll,
|
|
229
|
+
// Only include the key when a projection produced facts — under
|
|
230
|
+
// exactOptionalPropertyTypes an explicit `undefined` is not assignable to the
|
|
231
|
+
// optional field; a spread keeps it truly absent (→ gate 5 DB-reloads).
|
|
232
|
+
...(cachedFacts ? { cachedFacts } : {}),
|
|
233
|
+
}, ttlMs);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Best-effort: never fail a login on a binding hiccup. The guard denies if the
|
|
237
|
+
// binding is missing, so this degrades safely (the user re-logs in).
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Project the principal's coarse grants for the session binding (ADR-0011), via the
|
|
242
|
+
* OPTIONAL {@link SessionFactsProjector} seam. Returns the {@link CoarseGrants} to
|
|
243
|
+
* cache, or `undefined` when there is no projector, the projection yielded nothing,
|
|
244
|
+
* OR it threw — every "no facts" path collapses to `undefined` so the caller simply
|
|
245
|
+
* omits `cachedFacts` and the request DB-reloads (fail-closed; a projection failure
|
|
246
|
+
* NEVER fails the login/refresh and never fabricates a grant).
|
|
247
|
+
*/
|
|
248
|
+
async projectFacts(principal) {
|
|
249
|
+
const facts = await this.projectFactsFor(principal.id, principal.tenantId);
|
|
250
|
+
// At LOGIN there is no prior cache, so "no projector" (undefined) and "yielded
|
|
251
|
+
// nothing" (null) BOTH mean "omit cachedFacts" → gate 5 DB-reloads. Collapse to
|
|
252
|
+
// undefined for the bind() spread.
|
|
253
|
+
return facts ?? undefined;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Project coarse grants for a principal id + tenant (ADR-0011), distinguishing THREE
|
|
257
|
+
* outcomes for the REFRESH path (which must know whether to carry the existing cache
|
|
258
|
+
* forward, replace it, or strip it):
|
|
259
|
+
* - `undefined` → NO projector wired → carry the existing `cachedFacts` forward.
|
|
260
|
+
* - `null` → projector present but yielded nothing (or threw) → STRIP the cache
|
|
261
|
+
* (→ next request DB-reloads; fail-closed).
|
|
262
|
+
* - `CoarseGrants` → the fresh grants to cache.
|
|
263
|
+
* A throw collapses to `null` (fail-closed strip), never a login/refresh failure and
|
|
264
|
+
* never a fabricated grant.
|
|
265
|
+
*/
|
|
266
|
+
async projectFactsFor(principalId, tenantId) {
|
|
267
|
+
const projector = this.sessionFactsProjector;
|
|
268
|
+
if (projector === undefined || projector === null)
|
|
269
|
+
return undefined;
|
|
270
|
+
try {
|
|
271
|
+
return await projector.project(principalId, tenantId);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Fail-closed: a projection outage → strip/omit the cache → gate 5 DB-reloads.
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Build a Principal directly from resolved internal claims (recovery/alt-factor
|
|
280
|
+
* paths that don't run the full authenticate() loop). Equivalent to the identity
|
|
281
|
+
* broker's internal-source normalize for this case — the credential resolver
|
|
282
|
+
* already returns canonical Internal-source claims (`id`, `tenantId`, `roleIds`),
|
|
283
|
+
* the same shape the TOTP success path's `requirePrincipal` yields. Returns
|
|
284
|
+
* `null` when `id`/`tenantId` are missing/malformed (→ the caller fails closed to
|
|
285
|
+
* a generic 401). `isRoot` is FORCED false here — a recovery login is
|
|
286
|
+
* never a source of elevated status (root is a broker/server-side decision,
|
|
287
|
+
* never a claim), matching the internal adapter's root-safety.
|
|
288
|
+
*/
|
|
289
|
+
principalFromClaims(claims) {
|
|
290
|
+
const id = typeof claims.id === 'string' ? claims.id : '';
|
|
291
|
+
const tenantId = typeof claims.tenantId === 'string' ? claims.tenantId : '';
|
|
292
|
+
if (!id || !tenantId)
|
|
293
|
+
return null;
|
|
294
|
+
const roleIds = Array.isArray(claims.roleIds)
|
|
295
|
+
? claims.roleIds.filter((r) => typeof r === 'string')
|
|
296
|
+
: [];
|
|
297
|
+
return { kind: 'user', id, tenantId, roleIds, isRoot: false };
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* `POST /auth/login` — verify a password credential and either establish a
|
|
301
|
+
* session, issue an MFA challenge, deny generically, or signal a transient
|
|
302
|
+
* outage. NEVER logs the request body.
|
|
303
|
+
*/
|
|
304
|
+
async login(body) {
|
|
305
|
+
const identityHint = typeof body?.identityHint === 'string' ? body.identityHint : '';
|
|
306
|
+
const password = typeof body?.password === 'string' ? body.password : '';
|
|
307
|
+
// Rate-limit gate (spec §5 / H1): after M failed attempts within the window,
|
|
308
|
+
// reject BEFORE consulting the authenticator — the SAME generic 401 as a wrong
|
|
309
|
+
// password (anti-enumeration: a locked-out account is indistinguishable). Keyed
|
|
310
|
+
// on the presented hint (the resolver isn't run pre-auth). Fails OPEN on a Redis
|
|
311
|
+
// blip (the limiter's own posture — never a lockout from an outage).
|
|
312
|
+
const loginKey = `login:${identityHint}`;
|
|
313
|
+
if (this.loginLimiter != null && !(await this.loginLimiter.check(loginKey))) {
|
|
314
|
+
// Already locked → generic 401. We do NOT audit here: `check()` returns false on
|
|
315
|
+
// EVERY attempt once locked, so auditing at this gate would flood one event per
|
|
316
|
+
// attempt. The lockout is audited ONCE on the TRANSITION instead — in the
|
|
317
|
+
// wrong-password branch below, when recordFailure's count first hits the threshold
|
|
318
|
+
// (feature #140 audit-on-the-edge, anti-flood).
|
|
319
|
+
genericUnauthorized();
|
|
320
|
+
}
|
|
321
|
+
const result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
|
|
322
|
+
if (result.outcome === 'authenticated') {
|
|
323
|
+
// Correct password → clear the login counter (do not carry earlier failures).
|
|
324
|
+
await this.loginLimiter?.reset(loginKey);
|
|
325
|
+
const principal = requirePrincipal(result);
|
|
326
|
+
const est = await this.establishOrReject(principal);
|
|
327
|
+
await this.bindSession(est, principal);
|
|
328
|
+
return toSessionSuccess(est);
|
|
329
|
+
}
|
|
330
|
+
if (result.outcome === 'mfa_required') {
|
|
331
|
+
// Password verified, a second factor is outstanding. The PASSWORD was correct,
|
|
332
|
+
// so this is NOT a login failure — reset the login limiter (spec §5: reset on
|
|
333
|
+
// authenticated OR mfa_required). Then hold the pending credentials server-side
|
|
334
|
+
// and hand back an OPAQUE, unforgeable challenge handle. NO session yet; the
|
|
335
|
+
// handle carries nothing about the user.
|
|
336
|
+
await this.loginLimiter?.reset(loginKey);
|
|
337
|
+
const challengeToken = this.pendingMfa.store(identityHint, password);
|
|
338
|
+
return { mfaRequired: true, challengeToken };
|
|
339
|
+
}
|
|
340
|
+
// outcome === 'rejected'. Distinguish ONLY transient-vs-deny (never which
|
|
341
|
+
// credential failed): a transient dependency blip is a retryable 503, anything
|
|
342
|
+
// else is the generic 401.
|
|
343
|
+
if (result.reason === REASON_AUTH_UNAVAILABLE) {
|
|
344
|
+
// A transient outage is NOT a credential failure — do NOT count it toward the
|
|
345
|
+
// lockout (avoids a Redis/DB blip storm from locking out everyone).
|
|
346
|
+
retryableUnavailable();
|
|
347
|
+
}
|
|
348
|
+
// A genuine wrong-password deny → count it toward the lockout window. recordFailure
|
|
349
|
+
// returns the NEW count; when it first equals the limiter's threshold this attempt
|
|
350
|
+
// is the one that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here
|
|
351
|
+
// (the edge), never on the already-locked check-gate above (which would flood).
|
|
352
|
+
// NO notify: this is pre-auth (keyed on the typed hint; the resolver hasn't run), so
|
|
353
|
+
// we cannot resolve the real owner's email — mailing a bare hint could reach a
|
|
354
|
+
// non-owner / confirm the account exists (recordSecurityEvent enforces this too).
|
|
355
|
+
if (this.loginLimiter != null) {
|
|
356
|
+
const count = await this.loginLimiter.recordFailure(loginKey);
|
|
357
|
+
if (count === this.loginLimiter.maxAttempts) {
|
|
358
|
+
this.recordSecurityEvent({
|
|
359
|
+
action: 'auth.login.locked',
|
|
360
|
+
identityHint,
|
|
361
|
+
attributes: { reason: 'rate_limit' },
|
|
362
|
+
notify: false,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
genericUnauthorized();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* `GET /auth/me` — the signed-in user's **display profile** (name, email, active
|
|
370
|
+
* company name, role names) for the UI header/card. Fills the flagged gap: login
|
|
371
|
+
* returns only IDs, so without this a UI would either leak a raw UUID or show only
|
|
372
|
+
* the login hint.
|
|
373
|
+
*
|
|
374
|
+
* AUTH: this route is `@Public()` to the CLASSIFICATION guard (it is not an
|
|
375
|
+
* object-type permission — "read my own profile" has no typeKey to `decide()` on),
|
|
376
|
+
* but it authenticates the caller ITSELF: it resolves the bearer access token to a
|
|
377
|
+
* live `Principal` via the request-auth layer (the SAME liveness-gated path the
|
|
378
|
+
* enforcement guard uses). No/invalid/expired/revoked token → 401. So it is public
|
|
379
|
+
* only in the "not object-gated" sense — it is NOT unauthenticated.
|
|
380
|
+
*
|
|
381
|
+
* PROFILE: the framework is org-schema-agnostic, so it delegates id→name
|
|
382
|
+
* resolution to the consumer's {@link ProfileResolver} (the `profileResolver`
|
|
383
|
+
* seam). The resolver returns display NAMES (never raw UUIDs). Absent seam → 501
|
|
384
|
+
* (honest "not available"); resolver returns `null` (deleted user) → 404; resolver
|
|
385
|
+
* throws (store outage) → generic 500 (fail-closed, never a partial profile).
|
|
386
|
+
*/
|
|
387
|
+
async me(authorization) {
|
|
388
|
+
// 1) Authenticate: resolve the bearer token → a live Principal (fail-closed).
|
|
389
|
+
// Reuses the request-auth read path (token binding + session-liveness gate), so a
|
|
390
|
+
// logged-out/expired/revoked token is rejected here exactly as on a gated route.
|
|
391
|
+
if (this.tokenBinder === undefined) {
|
|
392
|
+
// Auth surface is up but request-auth isn't wired — cannot authenticate.
|
|
393
|
+
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
394
|
+
}
|
|
395
|
+
const principal = await (0, request_principal_js_1.resolveRequestPrincipal)(authorization, this.tokenBinder);
|
|
396
|
+
if (principal === null) {
|
|
397
|
+
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
398
|
+
}
|
|
399
|
+
// 2) Resolve the display profile via the consumer seam. No seam → honest 501.
|
|
400
|
+
// (The provider binds `null` when the consumer supplied none, so check nullish.)
|
|
401
|
+
if (this.profileResolver === undefined || this.profileResolver === null) {
|
|
402
|
+
throw new common_1.HttpException({ error: 'profile_unavailable' }, common_1.HttpStatus.NOT_IMPLEMENTED);
|
|
403
|
+
}
|
|
404
|
+
let profile;
|
|
405
|
+
try {
|
|
406
|
+
profile = await this.profileResolver.resolve(principal);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Store outage / resolver error → fail-closed generic 500 (never a partial or
|
|
410
|
+
// id-leaking profile). The UI keeps its interim hint fallback.
|
|
411
|
+
throw new common_1.HttpException({ error: 'profile_unavailable' }, common_1.HttpStatus.INTERNAL_SERVER_ERROR);
|
|
412
|
+
}
|
|
413
|
+
if (profile === null) {
|
|
414
|
+
// The principal has no resolvable profile (e.g. the user row was deleted since
|
|
415
|
+
// login). 404 — never fall back to returning the raw ids.
|
|
416
|
+
throw new common_1.HttpException({ error: 'profile_not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
417
|
+
}
|
|
418
|
+
return profile;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* `POST /auth/forgot-password` — request a password-reset link. ALWAYS returns 204
|
|
422
|
+
* with an identical (empty) body whether or not the hint matches a principal
|
|
423
|
+
* (ANTI-ENUMERATION, spec §4.1): the response must reveal nothing about whether an
|
|
424
|
+
* account exists.
|
|
425
|
+
*
|
|
426
|
+
* ── TIMING ANTI-ENUMERATION ── a MATCHING hint does extra work (mint + hash token,
|
|
427
|
+
* issue it to the store, resolve the email, send it) that an UNKNOWN hint skips —
|
|
428
|
+
* a naive `await` of that work would make the matching branch measurably slower, a
|
|
429
|
+
* timing oracle. So the found-path work is FIRE-AND-FORGET: `deliverResetLink` is
|
|
430
|
+
* invoked WITHOUT `await`, and the handler returns 204 immediately, so BOTH
|
|
431
|
+
* branches return in ~constant time. A `.catch` is attached to the detached
|
|
432
|
+
* promise so a store/email rejection is swallowed (never an unhandled rejection,
|
|
433
|
+
* never a distinguishing error).
|
|
434
|
+
*
|
|
435
|
+
* ── SEAMS ── needs the credentialResolver (hint→principalId), the resetTokenStore,
|
|
436
|
+
* and the emailSender. Any absent → the whole found-path is skipped and we still
|
|
437
|
+
* return 204 (a deployment without the reset seams simply never sends — never a
|
|
438
|
+
* 501 here, which would itself be an enumeration signal on hint validity).
|
|
439
|
+
*/
|
|
440
|
+
async forgotPassword(body, ip) {
|
|
441
|
+
const hint = typeof body?.identityHint === 'string' ? body.identityHint : '';
|
|
442
|
+
const resolver = this.credentialResolver;
|
|
443
|
+
const store = this.resetTokenStore;
|
|
444
|
+
const sender = this.emailSender;
|
|
445
|
+
// ── Rate-limit gate (feature #141) — a REQUEST-RATE cap, NOT a failure cap. ──────
|
|
446
|
+
// forgot-password is ALWAYS 204 (a 429 would itself leak account existence), so we
|
|
447
|
+
// COUNT every forgot request against two budgets and, when EITHER is exceeded,
|
|
448
|
+
// SILENTLY skip the email-send while STILL returning 204. Two keys: the client IP
|
|
449
|
+
// (stops one host spraying many hints) and the presented hint (stops one account
|
|
450
|
+
// being email-bombed across a rotating IP pool). Each limiter fails OPEN on a Redis
|
|
451
|
+
// blip (its own posture) so an outage never silently drops a legitimate reset email.
|
|
452
|
+
// Defense-in-depth: a normal Express `req.ip` is always populated, but an empty ip
|
|
453
|
+
// (a non-standard adapter/harness) would collapse EVERY client into one shared
|
|
454
|
+
// `forgot:ip:` bucket → a global lockout. Fall back to a fixed sentinel so a blank ip
|
|
455
|
+
// is its own bucket, never a shared one (mirrors the `hint === ''` guard below).
|
|
456
|
+
const ipKey = `forgot:ip:${ip || 'unknown-ip'}`;
|
|
457
|
+
const hintKey = `forgot:${hint}`;
|
|
458
|
+
// Check BOTH budgets first (a `false` from either = over budget = skip the send).
|
|
459
|
+
// Both checks run so both counters see this request (below), independent of order.
|
|
460
|
+
const ipAllowed = this.forgotLimiter == null || (await this.forgotLimiter.check(ipKey));
|
|
461
|
+
// Only meter the hint budget when there IS a hint (an empty hint is the unknown-user
|
|
462
|
+
// path — it never sends anyway, and we must not let a blank hint burn a shared key).
|
|
463
|
+
const hintAllowed = this.forgotHintLimiter == null || hint === '' || (await this.forgotHintLimiter.check(hintKey));
|
|
464
|
+
// Count THIS request toward each budget (there is no pass/fail signal — every forgot
|
|
465
|
+
// call is an "attempt"; the counter is what trips the silent skip). Best-effort +
|
|
466
|
+
// detached from the 204 (never awaited into the response timing; a throw is swallowed
|
|
467
|
+
// by the limiter's own best-effort recordFailure). NOT reset on "success" — forgot
|
|
468
|
+
// has no success signal, so the window is a pure rolling request-rate cap.
|
|
469
|
+
// `.catch` guards the DETACHED counter writes: the framework AttemptLimiter already
|
|
470
|
+
// swallows its own errors, but a consumer-injected limiter must never surface an
|
|
471
|
+
// unhandled rejection off this fire-and-forget path (never changes the 204).
|
|
472
|
+
// Count this request toward the IP budget; when the returned count first equals the
|
|
473
|
+
// limiter's threshold this request is the one that TRIPPED the throttle → audit
|
|
474
|
+
// `auth.forgot_password.throttled` exactly ONCE on that edge (feature #140). Detached
|
|
475
|
+
// + swallowed like the counter write itself: the audit is fire-and-forget and never
|
|
476
|
+
// changes the 204 (still returned below). NO notify — a forgot-throttle notice would
|
|
477
|
+
// itself confirm the account exists AND could be used to email-bomb the owner; the
|
|
478
|
+
// event is audit-only. NO secret in attributes (a reason code only; the hint is the
|
|
479
|
+
// actor identity, acceptable in the audit row, added by recordSecurityEvent).
|
|
480
|
+
void this.forgotLimiter
|
|
481
|
+
?.recordFailure(ipKey)
|
|
482
|
+
.then((count) => {
|
|
483
|
+
if (this.forgotLimiter != null && count === this.forgotLimiter.maxAttempts) {
|
|
484
|
+
this.recordSecurityEvent({
|
|
485
|
+
action: 'auth.forgot_password.throttled',
|
|
486
|
+
identityHint: hint || null,
|
|
487
|
+
attributes: { reason: 'rate_limit' },
|
|
488
|
+
notify: false,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
.catch(() => { });
|
|
493
|
+
// Same edge-audit for the per-HINT budget (only when there IS a hint — never audit
|
|
494
|
+
// an empty-hint edge, mirroring the `hint !== ''` gate on the hint limiter itself).
|
|
495
|
+
// A DISTINCT action key (`hint_throttled` vs the IP site's `throttled`) so the two
|
|
496
|
+
// abuse shapes are distinguishable in the audit log: the per-hint edge catches ONE
|
|
497
|
+
// account being email-bombed across ROTATING IPs (each IP staying under its own
|
|
498
|
+
// budget), which the per-IP audit would NEVER see. Detached + swallowed, 204
|
|
499
|
+
// unchanged, no notify (a hint-throttle notice would confirm the account + could
|
|
500
|
+
// itself be the email-bomb).
|
|
501
|
+
if (hint !== '') {
|
|
502
|
+
void this.forgotHintLimiter
|
|
503
|
+
?.recordFailure(hintKey)
|
|
504
|
+
.then((count) => {
|
|
505
|
+
if (this.forgotHintLimiter != null && count === this.forgotHintLimiter.maxAttempts) {
|
|
506
|
+
this.recordSecurityEvent({
|
|
507
|
+
action: 'auth.forgot_password.hint_throttled',
|
|
508
|
+
identityHint: hint,
|
|
509
|
+
attributes: { reason: 'rate_limit' },
|
|
510
|
+
notify: false,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
.catch(() => { });
|
|
515
|
+
}
|
|
516
|
+
// Only the matching branch does work — but do it DETACHED (no await) so its
|
|
517
|
+
// duration never leaks into the response timing. Absent seams → skip entirely.
|
|
518
|
+
// Throttled (either budget exceeded) → SILENTLY skip the send (still 204 below).
|
|
519
|
+
if (ipAllowed && hintAllowed && hint && resolver && store && sender) {
|
|
520
|
+
// Fire-and-forget: never awaited before the 204. `.catch` swallows any
|
|
521
|
+
// store/email failure (anti-enumeration — a failure must NOT change the
|
|
522
|
+
// response, and must never surface as an unhandled rejection).
|
|
523
|
+
void this.deliverResetLink(hint, resolver, store, sender).catch(() => {
|
|
524
|
+
/* swallow — anti-enumeration: never let a store/email failure be observable */
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// ALWAYS 204, immediately, identical body for every branch (found/unknown/throttled).
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* The detached found-path work for {@link forgotPassword}: mint a raw token, issue
|
|
531
|
+
* its SHA-256 hash to the store (30-min TTL), resolve the recipient email via the
|
|
532
|
+
* profile resolver, and send the framework-composed reset link. Runs OUTSIDE the
|
|
533
|
+
* request/response timing (fire-and-forget) so it cannot form a timing oracle. Never
|
|
534
|
+
* logs the token or the email.
|
|
535
|
+
*/
|
|
536
|
+
async deliverResetLink(hint, resolver, store, sender) {
|
|
537
|
+
const resolved = await resolver.resolve(hint);
|
|
538
|
+
if (resolved === null)
|
|
539
|
+
return; // unknown hint — no token, no email (still 204)
|
|
540
|
+
const claims = resolved.claims;
|
|
541
|
+
const principalId = typeof claims.id === 'string' ? claims.id : '';
|
|
542
|
+
if (!principalId)
|
|
543
|
+
return;
|
|
544
|
+
const tenantId = typeof claims.tenantId === 'string' ? claims.tenantId : '';
|
|
545
|
+
// Mint a raw token; the store only ever sees its SHA-256 hash.
|
|
546
|
+
const rawToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
547
|
+
await store.issue(principalId, sha256(rawToken), Date.now() + RESET_TTL_MS);
|
|
548
|
+
// Resolve the recipient email via the profile resolver (returns display + email).
|
|
549
|
+
// No resolver / no email on file → issued the token but cannot deliver (still 204).
|
|
550
|
+
if (this.profileResolver === undefined || this.profileResolver === null)
|
|
551
|
+
return;
|
|
552
|
+
const profile = await this.profileResolver.resolve({
|
|
553
|
+
id: principalId,
|
|
554
|
+
tenantId,
|
|
555
|
+
roleIds: [],
|
|
556
|
+
kind: 'user',
|
|
557
|
+
isRoot: false,
|
|
558
|
+
});
|
|
559
|
+
if (!profile?.email)
|
|
560
|
+
return;
|
|
561
|
+
// The framework composes the link (EmailSender stays URL-agnostic).
|
|
562
|
+
const base = this.resetLinkBaseUrl ?? 'http://localhost:5173/reset-password';
|
|
563
|
+
const link = `${base}?token=${rawToken}`;
|
|
564
|
+
await sender.send({
|
|
565
|
+
to: profile.email,
|
|
566
|
+
subject: 'Reset your password',
|
|
567
|
+
text: `Reset your password: ${link}`,
|
|
568
|
+
html: `<p>Reset your password: <a href="${link}">${link}</a></p>`,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* `POST /auth/reset-password` — complete a password reset with the emailed token.
|
|
573
|
+
*
|
|
574
|
+
* Outcomes:
|
|
575
|
+
* • newPassword shorter than the minimum → 422 `{ error: 'weak_password' }`
|
|
576
|
+
* • resetTokenStore/credentialWriter/hasher absent → 501 `{ error: 'not_available' }`
|
|
577
|
+
* • token unknown / expired / already-consumed → 400 `{ error: 'invalid_token' }`
|
|
578
|
+
* • success → 204 (no body)
|
|
579
|
+
*
|
|
580
|
+
* On success: consume the token (single-use), hash + write the new password, then
|
|
581
|
+
* revoke ALL of the principal's sessions (reset kills every session, spec §5.1/§6)
|
|
582
|
+
* and invalidate any other outstanding reset tokens for the principal.
|
|
583
|
+
*/
|
|
584
|
+
async resetPassword(body, ip) {
|
|
585
|
+
const token = typeof body?.token === 'string' ? body.token : '';
|
|
586
|
+
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : '';
|
|
587
|
+
// ── Rate-limit gate (feature #141) — DoS/abuse throttle, NOT anti-brute-force. ──
|
|
588
|
+
// Reset is token-gated (a 256-bit single-use token), so this is only to cap abusive
|
|
589
|
+
// request volume — and because a token-gated endpoint is NOT enumeration-sensitive,
|
|
590
|
+
// a locked-out caller MAY receive a generic 429 (unlike forgot-password, which stays
|
|
591
|
+
// 204). Checked BEFORE any token/seam work so a locked IP never touches the store.
|
|
592
|
+
// Fails OPEN on a Redis blip (a legitimate reset is never blocked by an outage).
|
|
593
|
+
// Defense-in-depth empty-ip fallback (see forgotPassword): a blank ip must be its
|
|
594
|
+
// own bucket, never collapse all callers into one shared `reset:ip:` lockout bucket.
|
|
595
|
+
const resetKey = `reset:ip:${ip || 'unknown-ip'}`;
|
|
596
|
+
if (this.resetLimiter != null && !(await this.resetLimiter.check(resetKey))) {
|
|
597
|
+
// Already locked → generic 429. We do NOT audit here: check() returns false on
|
|
598
|
+
// EVERY attempt once locked, so this gate would flood one event per attempt. The
|
|
599
|
+
// lockout is audited ONCE on the TRANSITION instead — in the invalid-token branch
|
|
600
|
+
// below, when recordFailure's count first hits the threshold (audit-on-the-edge).
|
|
601
|
+
tooManyRequests();
|
|
602
|
+
}
|
|
603
|
+
if (newPassword.length < MIN_PASSWORD_LEN) {
|
|
604
|
+
throw new common_1.HttpException({ error: 'weak_password' }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
|
|
605
|
+
}
|
|
606
|
+
// Fail-closed when the reset seams are unplugged — honest "not available".
|
|
607
|
+
const store = this.resetTokenStore;
|
|
608
|
+
const writer = this.credentialWriter;
|
|
609
|
+
const hasher = this.passwordHasher;
|
|
610
|
+
if (store === undefined || store === null || writer === undefined || writer === null || hasher === undefined) {
|
|
611
|
+
throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
|
|
612
|
+
}
|
|
613
|
+
// Consume the token (single-use, TTL-checked). Unknown/expired/consumed → 400.
|
|
614
|
+
const principalId = await store.consume(sha256(token));
|
|
615
|
+
if (principalId === null) {
|
|
616
|
+
// A bad/invalid token is the ABUSE signal — count it toward the IP lockout, THEN
|
|
617
|
+
// the existing generic 400. (A valid reset never records a failure.) recordFailure
|
|
618
|
+
// returns the new count; when it first equals the threshold this bad-token attempt
|
|
619
|
+
// TRIPPED the lockout → audit `auth.password_reset.locked` exactly ONCE on that edge
|
|
620
|
+
// (never on the already-locked 429 gate above, which would flood; and NOT on every
|
|
621
|
+
// invalid-token attempt, only the transition). No hint/principal is available from a
|
|
622
|
+
// bare token — that's fine, the event is audited without one. NO notify (no
|
|
623
|
+
// resolvable owner from a token) and NO secret in attributes (a reason code only).
|
|
624
|
+
if (this.resetLimiter != null) {
|
|
625
|
+
const count = await this.resetLimiter.recordFailure(resetKey);
|
|
626
|
+
if (count === this.resetLimiter.maxAttempts) {
|
|
627
|
+
this.recordSecurityEvent({
|
|
628
|
+
action: 'auth.password_reset.locked',
|
|
629
|
+
attributes: { reason: 'rate_limit' },
|
|
630
|
+
notify: false,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
throw new common_1.HttpException({ error: 'invalid_token' }, common_1.HttpStatus.BAD_REQUEST);
|
|
635
|
+
}
|
|
636
|
+
const newHash = await hasher.hash(newPassword);
|
|
637
|
+
await writer.setPassword(principalId, newHash);
|
|
638
|
+
// A SUCCESSFUL reset clears the IP counter (do not carry earlier bad-token attempts
|
|
639
|
+
// forward — the caller proved a valid single-use token).
|
|
640
|
+
await this.resetLimiter?.reset(resetKey);
|
|
641
|
+
// Reset kills ALL sessions (unlike change-password, which keeps the current one).
|
|
642
|
+
if (this.sessionSweeper !== undefined && this.sessionSweeper !== null) {
|
|
643
|
+
await this.sessionSweeper.revokeAllForPrincipal(principalId);
|
|
644
|
+
}
|
|
645
|
+
// Burn any other outstanding reset tokens for the principal (single active reset).
|
|
646
|
+
await store.invalidateAllForPrincipal(principalId);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* `POST /auth/mfa` — complete a pending MFA login. Consumes the single-use
|
|
650
|
+
* challenge handle (unknown/expired/forged → same generic 401 as a bad code),
|
|
651
|
+
* then BRANCHES on the presented factor:
|
|
652
|
+
* • `'totp'` → RE-RUNS `authenticate(hint, [password, totp])` with the code
|
|
653
|
+
* (the authenticator has no standalone factor-verify seam — the full attempt
|
|
654
|
+
* IS the verify). Success → establish + tokens.
|
|
655
|
+
* • `'recovery_code'` → the lost-device escape hatch: verify a one-time
|
|
656
|
+
* recovery code (a separate branch, NOT the core factor loop — recovery codes
|
|
657
|
+
* are N single-use Argon2 hashes, incompatible with the pure one-secret
|
|
658
|
+
* `FactorVerifier`). See {@link completeViaRecoveryCode}.
|
|
659
|
+
* • any other factor → generic 401 (no leak of "unsupported factor").
|
|
660
|
+
*
|
|
661
|
+
* Anti-enumeration: unknown/forged/expired challenge, unknown hint, wrong code,
|
|
662
|
+
* and wrong recovery code ALL return the identical generic 401.
|
|
663
|
+
*/
|
|
664
|
+
async mfa(body) {
|
|
665
|
+
const challengeToken = typeof body?.challengeToken === 'string' ? body.challengeToken : '';
|
|
666
|
+
const code = typeof body?.code === 'string' ? body.code : '';
|
|
667
|
+
const factor = body?.factor;
|
|
668
|
+
// Consume the pending challenge (single-use). A miss (unknown/forged/expired)
|
|
669
|
+
// is indistinguishable from a wrong code — generic 401, no leak. Consumed ONCE
|
|
670
|
+
// here, up front, so BOTH branches (totp + recovery) share the single-use
|
|
671
|
+
// semantics (a spent/forged challenge never reaches either verify path).
|
|
672
|
+
const pending = this.pendingMfa.consume(challengeToken);
|
|
673
|
+
if (pending === null)
|
|
674
|
+
genericUnauthorized();
|
|
675
|
+
// Recovery-code branch — the lost-device escape hatch (a separate verify path).
|
|
676
|
+
if (factor === 'recovery_code') {
|
|
677
|
+
return this.completeViaRecoveryCode(pending, code);
|
|
678
|
+
}
|
|
679
|
+
// Phase 0b: only 'totp' is a real re-authenticate factor. Any other → generic
|
|
680
|
+
// 401 (no leak of "unsupported factor").
|
|
681
|
+
if (factor !== 'totp')
|
|
682
|
+
genericUnauthorized();
|
|
683
|
+
const result = await this.authenticator.authenticate(pending.identityHint, [passwordCredential(pending.password), totpCredential(code)]);
|
|
684
|
+
if (result.outcome === 'authenticated') {
|
|
685
|
+
const principal = requirePrincipal(result);
|
|
686
|
+
const est = await this.establishOrReject(principal);
|
|
687
|
+
await this.bindSession(est, principal);
|
|
688
|
+
return toSessionSuccess(est);
|
|
689
|
+
}
|
|
690
|
+
// Anything else (wrong code → rejected; still-outstanding → mfa_required) is a
|
|
691
|
+
// failure. A transient blip is retryable; everything else is a generic 401.
|
|
692
|
+
// (A repeat mfa_required here means the code did not satisfy the factor.)
|
|
693
|
+
if (result.reason === REASON_AUTH_UNAVAILABLE) {
|
|
694
|
+
retryableUnavailable();
|
|
695
|
+
}
|
|
696
|
+
genericUnauthorized();
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The `/auth/mfa` RECOVERY-CODE branch (spec §3). The lost-device user completes
|
|
700
|
+
* login with a pre-issued single-use recovery code instead of a TOTP.
|
|
701
|
+
*
|
|
702
|
+
* `pending` is the ALREADY-CONSUMED challenge (proof the password passed at
|
|
703
|
+
* `/auth/login` — §3: "the challenge IS proof of password"), so this branch does
|
|
704
|
+
* NOT re-verify the password, mirroring exactly how the TOTP branch trusts the
|
|
705
|
+
* challenge for the password and only checks the code.
|
|
706
|
+
*
|
|
707
|
+
* ── ANTI-ENUMERATION ── seam-absent, unknown hint, malformed claims, and a wrong
|
|
708
|
+
* recovery code ALL raise the identical generic 401 — no path reveals whether
|
|
709
|
+
* the user exists, has recovery codes, or which check failed.
|
|
710
|
+
*
|
|
711
|
+
* ── NO SAFEGUARDS YET ── rate-limit (Phase 3), `mustReenroll` (Phase 2), and
|
|
712
|
+
* notify+audit (Phase 4) attach at the marked seams below.
|
|
713
|
+
*/
|
|
714
|
+
async completeViaRecoveryCode(pending, code) {
|
|
715
|
+
// Seam must be present — no resolver or no recovery store → fail closed.
|
|
716
|
+
if (this.credentialResolver == null || this.recoveryCodes == null) {
|
|
717
|
+
genericUnauthorized();
|
|
718
|
+
}
|
|
719
|
+
// hint → principalId. A resolver that throws (store outage) OR returns null
|
|
720
|
+
// (unknown hint) is the SAME generic 401 (anti-enumeration; the resolver never
|
|
721
|
+
// distinguishes unknown-user from a blip to the caller).
|
|
722
|
+
const resolved = await this.credentialResolver.resolve(pending.identityHint).catch(() => null);
|
|
723
|
+
if (resolved == null)
|
|
724
|
+
genericUnauthorized();
|
|
725
|
+
const claims = resolved.claims;
|
|
726
|
+
const principalId = typeof claims.id === 'string' ? claims.id : '';
|
|
727
|
+
if (!principalId)
|
|
728
|
+
genericUnauthorized();
|
|
729
|
+
// Rate-limit gate (spec §5): after N failures within the window, reject BEFORE
|
|
730
|
+
// consulting the recovery store — the SAME generic 401 as a wrong code (no leak
|
|
731
|
+
// of "rate-limited"; the store is never even touched when locked). Keyed by the
|
|
732
|
+
// RESOLVED principalId (recovery codes don't rotate → a lower ceiling than login).
|
|
733
|
+
// Fails OPEN on a Redis blip (the limiter's own posture — never a lockout).
|
|
734
|
+
const recoveryKey = `recovery:${principalId}`;
|
|
735
|
+
if (this.recoveryLimiter != null && !(await this.recoveryLimiter.check(recoveryKey))) {
|
|
736
|
+
genericUnauthorized();
|
|
737
|
+
}
|
|
738
|
+
// Atomic single-use argon2-verify. A throw is swallowed to `false` → generic
|
|
739
|
+
// 401 (never surface a store error as a distinct outcome).
|
|
740
|
+
const ok = await this.recoveryCodes.consume(principalId, code).catch(() => false);
|
|
741
|
+
if (!ok) {
|
|
742
|
+
// A wrong (or unmatched) recovery code → count it toward the lockout window,
|
|
743
|
+
// THEN the identical generic 401. recordFailure returns the new count; when it
|
|
744
|
+
// first equals the threshold this attempt TRIPPED the lockout — audit
|
|
745
|
+
// `auth.mfa.recovery_locked` exactly ONCE on that edge (never on the already-locked
|
|
746
|
+
// check-gate above, which returns false on every attempt = flood).
|
|
747
|
+
// NO notify: a locked-out attacker is (by assumption) NOT the account owner, so
|
|
748
|
+
// emailing on every recovery lockout would let an attacker email-bomb the owner —
|
|
749
|
+
// audit-only is safer; the once-per-window edge already dedups it.
|
|
750
|
+
if (this.recoveryLimiter != null) {
|
|
751
|
+
const count = await this.recoveryLimiter.recordFailure(recoveryKey);
|
|
752
|
+
if (count === this.recoveryLimiter.maxAttempts) {
|
|
753
|
+
this.recordSecurityEvent({
|
|
754
|
+
action: 'auth.mfa.recovery_locked',
|
|
755
|
+
actorId: principalId,
|
|
756
|
+
tenantId: typeof claims.tenantId === 'string' ? claims.tenantId : null,
|
|
757
|
+
attributes: { reason: 'rate_limit' },
|
|
758
|
+
notify: false,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
genericUnauthorized();
|
|
763
|
+
}
|
|
764
|
+
// A correct recovery code → clear the counter (do not carry earlier failures).
|
|
765
|
+
await this.recoveryLimiter?.reset(recoveryKey);
|
|
766
|
+
// The pending challenge is proof the password already passed (§3) — no password
|
|
767
|
+
// re-verify. Mint the Principal directly from the resolved claims.
|
|
768
|
+
const principal = this.principalFromClaims(claims);
|
|
769
|
+
if (principal === null)
|
|
770
|
+
genericUnauthorized();
|
|
771
|
+
const est = await this.establishOrReject(principal);
|
|
772
|
+
// Enforced re-enroll (spec §4): a recovery-code login is lower-assurance, so the
|
|
773
|
+
// session is bound with mustReenroll:true — the enforcement gate then restricts it
|
|
774
|
+
// to the re-secure allowlist until a fresh device is confirmed.
|
|
775
|
+
await this.bindSession(est, principal, true);
|
|
776
|
+
// Notify + audit (spec §6) via the REUSABLE security-event seam (feature #140):
|
|
777
|
+
// records `mfa.recovery_used` AND notifies the account owner, BOTH fire-and-forget +
|
|
778
|
+
// best-effort. A throwing/slow sink or sender NEVER fails the (legitimate) login
|
|
779
|
+
// (nothing here is awaited into the response, each swallows its own rejection). NO
|
|
780
|
+
// secret / code / token / uuid appears in either body — the audit carries only the
|
|
781
|
+
// actor id, tenant, and action; the notice is a neutral security warning.
|
|
782
|
+
this.recordSecurityEvent({
|
|
783
|
+
action: 'mfa.recovery_used',
|
|
784
|
+
actorId: principal.id,
|
|
785
|
+
tenantId: principal.tenantId,
|
|
786
|
+
notify: true,
|
|
787
|
+
});
|
|
788
|
+
return { ...toSessionSuccess(est), mfaRecoveryUsed: true };
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Fire-and-forget security notice for a recovery-code login (spec §6.1). Resolves the
|
|
792
|
+
* recipient email via the already-injected {@link ProfileResolver} (the same seam
|
|
793
|
+
* forgot-password uses), then delivers a NEUTRAL "a recovery code was used to sign in"
|
|
794
|
+
* warning via {@link EmailSender}. Best-effort: NOT awaited by the caller, and it
|
|
795
|
+
* swallows every rejection so a missing email / resolver throw / send failure never
|
|
796
|
+
* fails the login. NO code / token / principal uuid is ever placed in the body — only
|
|
797
|
+
* the neutral notice the account owner needs to act on it.
|
|
798
|
+
*/
|
|
799
|
+
notifyRecoveryUsed(principalId, tenantId) {
|
|
800
|
+
const resolver = this.profileResolver;
|
|
801
|
+
const sender = this.emailSender;
|
|
802
|
+
if (resolver === undefined || resolver === null || sender === undefined || sender === null) {
|
|
803
|
+
return; // no recipient lookup or no delivery seam → nothing to send (still logged-in)
|
|
804
|
+
}
|
|
805
|
+
void (async () => {
|
|
806
|
+
const profile = await resolver.resolve({
|
|
807
|
+
id: principalId,
|
|
808
|
+
tenantId,
|
|
809
|
+
roleIds: [],
|
|
810
|
+
kind: 'user',
|
|
811
|
+
isRoot: false,
|
|
812
|
+
});
|
|
813
|
+
if (!profile?.email)
|
|
814
|
+
return; // no email on file → cannot notify (login unaffected)
|
|
815
|
+
await sender.send({
|
|
816
|
+
to: profile.email,
|
|
817
|
+
subject: 'A recovery code was used to sign in',
|
|
818
|
+
text: 'A recovery code was just used to sign in to your account. ' +
|
|
819
|
+
'If this was you, you can ignore this message. If this was NOT you, secure ' +
|
|
820
|
+
'your account now: change your password and re-enroll a new authenticator device.',
|
|
821
|
+
html: '<p>A recovery code was just used to sign in to your account.</p>' +
|
|
822
|
+
'<p>If this was you, you can ignore this message. If this was <strong>not</strong> ' +
|
|
823
|
+
'you, secure your account now: change your password and re-enroll a new ' +
|
|
824
|
+
'authenticator device.</p>',
|
|
825
|
+
});
|
|
826
|
+
})().catch(() => {
|
|
827
|
+
/* best-effort: a resolver/email failure never fails the login */
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Record an auth-SECURITY event (best-effort, fire-and-forget) and OPTIONALLY notify
|
|
832
|
+
* the account owner. The REUSABLE audit+notify primitive (feature #140) — it
|
|
833
|
+
* generalizes the two inlined blocks that {@link completeViaRecoveryCode} used to
|
|
834
|
+
* carry (the `auditSink.record(...)` call + the `notifyRecoveryUsed` email) so EVERY
|
|
835
|
+
* lockout/abuse path records a uniform, secret-free security event through ONE seam.
|
|
836
|
+
*
|
|
837
|
+
* ── NEVER THROWS INTO THE REQUEST PATH ── the (already-decided) HTTP response must
|
|
838
|
+
* NOT change because an audit/notify failed or is slow: nothing here is awaited by
|
|
839
|
+
* the caller, the audit `record` is fire-and-forget with a swallowed `.catch`, and
|
|
840
|
+
* the notify runs inside {@link maybeNotifyOwner}'s own detached, swallowed IIFE.
|
|
841
|
+
*
|
|
842
|
+
* ── NO SECRET LEAK ── `attributes` carries only non-secret structured detail (a
|
|
843
|
+
* reason code, never a password / recovery code / TOTP secret / token / hash). The
|
|
844
|
+
* `identityHint` (the email/username the user typed) is acceptable in an AUDIT row
|
|
845
|
+
* — it IS the actor identity the event is about — but it is written ONLY to the
|
|
846
|
+
* audit attributes, NEVER used as a notify recipient (see the notify rule below).
|
|
847
|
+
*
|
|
848
|
+
* ── NOTIFY ONLY WITH A RESOLVED PRINCIPAL ── the owner email is resolved from
|
|
849
|
+
* `actorId` + `tenantId` via the profile resolver. When there is NO resolved
|
|
850
|
+
* principal (a pre-auth login lockout keys on the typed hint only — the resolver
|
|
851
|
+
* has not run), we CANNOT know the real account owner, so we DO NOT notify: mailing
|
|
852
|
+
* a bare hint could email a non-owner (the hint may be someone else's address / a
|
|
853
|
+
* typo) and would confirm the account exists. In that case the AUDIT still fires;
|
|
854
|
+
* only the notify is skipped.
|
|
855
|
+
*/
|
|
856
|
+
recordSecurityEvent(params) {
|
|
857
|
+
// ── Audit (always, if the sink is wired) — fire-and-forget, swallowed. ──────────
|
|
858
|
+
// The identityHint (typed email/username) is the actor identity → acceptable in an
|
|
859
|
+
// AUDIT attribute; it is NEVER placed in a notify body (only the RESOLVED profile
|
|
860
|
+
// email is a recipient). No secret ever enters attributes.
|
|
861
|
+
const attributes = {
|
|
862
|
+
...(params.attributes ?? {}),
|
|
863
|
+
...(params.identityHint ? { identityHint: params.identityHint } : {}),
|
|
864
|
+
};
|
|
865
|
+
void this.auditSink
|
|
866
|
+
?.record({
|
|
867
|
+
type: 'authentication',
|
|
868
|
+
occurredAtMs: Date.now(),
|
|
869
|
+
actorId: params.actorId ?? '',
|
|
870
|
+
...(params.tenantId ? { tenantId: params.tenantId } : {}),
|
|
871
|
+
action: params.action,
|
|
872
|
+
attributes,
|
|
873
|
+
})
|
|
874
|
+
.catch(() => {
|
|
875
|
+
/* best-effort: an audit failure never changes the response */
|
|
876
|
+
});
|
|
877
|
+
// ── Notify (ONLY when asked AND a principal is resolvable) ──────────────────────
|
|
878
|
+
// No actorId/tenantId → pre-auth (login lockout keyed on the typed hint): we cannot
|
|
879
|
+
// reliably resolve the OWNER's email, so we SKIP the notify (never email a bare
|
|
880
|
+
// hint — it could reach a non-owner and would confirm account existence). Audit has
|
|
881
|
+
// already fired above regardless.
|
|
882
|
+
if (params.notify === true && params.actorId && params.tenantId) {
|
|
883
|
+
this.maybeNotifyOwner(params.actorId, params.tenantId);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Fire-and-forget "a security-sensitive event happened on your account" notice to the
|
|
888
|
+
* resolved owner (the generalized {@link notifyRecoveryUsed} path). Resolves the
|
|
889
|
+
* recipient email from `principalId` + `tenantId` via the injected profile resolver,
|
|
890
|
+
* then delivers a NEUTRAL security warning via the email sender. Best-effort: NOT
|
|
891
|
+
* awaited, and every rejection (missing email / resolver throw / send failure) is
|
|
892
|
+
* swallowed so it never changes the response. NO code / token / uuid ever enters the
|
|
893
|
+
* body — only the neutral notice the owner needs to act on it.
|
|
894
|
+
*
|
|
895
|
+
* Today the only notify caller is the recovery-code login (which routes through
|
|
896
|
+
* {@link recordSecurityEvent} with `notify:true`), so this delegates to the existing
|
|
897
|
+
* {@link notifyRecoveryUsed} message — the exact neutral, no-secret email the recovery
|
|
898
|
+
* behavior + its tests already assert (the refactor keeps that email byte-for-byte).
|
|
899
|
+
*/
|
|
900
|
+
maybeNotifyOwner(principalId, tenantId) {
|
|
901
|
+
this.notifyRecoveryUsed(principalId, tenantId);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* `POST /auth/refresh` — rotate the refresh token. Reuse/invalid/expired →
|
|
905
|
+
* generic 401 (`session_rejected`; the engine already killed the lineage,
|
|
906
|
+
* ADR-0015). ⚠ See the class header: the engine's rotate collapses a transient
|
|
907
|
+
* store outage into the SAME reject, so refresh cannot emit the ADR-0016 503 from
|
|
908
|
+
* this seam today (flagged core-API gap).
|
|
909
|
+
*/
|
|
910
|
+
async refresh(body) {
|
|
911
|
+
const refreshToken = typeof body?.refreshToken === 'string' ? body.refreshToken : '';
|
|
912
|
+
try {
|
|
913
|
+
const est = await this.sessionAuthority.rotate(refreshToken);
|
|
914
|
+
// Carry the principal claims forward to the NEW access token (the durable
|
|
915
|
+
// session row lacks roles/kind — the session-claims entry has them).
|
|
916
|
+
if (this.tokenBinder !== undefined) {
|
|
917
|
+
const ttlMs = Math.max(est.session.expiresAtMs - est.session.issuedAtMs, 1);
|
|
918
|
+
// EAGER re-projection at REFRESH (ADR-0011): re-read the principal's coarse grants
|
|
919
|
+
// so the cache stays warm past the version the LOGIN projected at (a role change
|
|
920
|
+
// between login and this refresh is picked up here). The rotated session carries
|
|
921
|
+
// the principal id + tenant; project from those. `undefined` (no projector) →
|
|
922
|
+
// rebindOnRotate carries the existing `cachedFacts` forward unchanged (today's
|
|
923
|
+
// behavior); a fresh CoarseGrants → replaces it; `null` (projection yielded
|
|
924
|
+
// nothing) → strips it (→ next request DB-reloads; fail-closed).
|
|
925
|
+
const freshFacts = await this.projectFactsFor(est.session.principalId, est.session.tenantId);
|
|
926
|
+
await this.tokenBinder.rebindOnRotate(est.tokens.accessToken, est.session.sessionId, ttlMs, freshFacts);
|
|
927
|
+
}
|
|
928
|
+
return toSessionSuccess(est);
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
// The engine throws only its generic SessionRejectedError (reuse, invalid,
|
|
932
|
+
// expiry, or a swallowed store outage — all indistinguishable here). Map to a
|
|
933
|
+
// generic 401; never leak which, never echo the token.
|
|
934
|
+
genericUnauthorized();
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* `POST /auth/logout` — revoke the session. Idempotent + leak-free: revoking an
|
|
939
|
+
* unknown or already-revoked session still returns 204 (the engine's `revoke`
|
|
940
|
+
* swallows and returns void either way), so existence is never revealed.
|
|
941
|
+
*/
|
|
942
|
+
async logout(body) {
|
|
943
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
|
|
944
|
+
// `revoke` is void + best-effort-idempotent by contract; it never throws a
|
|
945
|
+
// resource-existence signal. We still guard against an unexpected throw so a
|
|
946
|
+
// logout never 500s (which could itself be an existence oracle).
|
|
947
|
+
try {
|
|
948
|
+
await this.sessionAuthority.revoke(sessionId);
|
|
949
|
+
// Drop the session-claims cushion so a post-logout rotate can't re-mint a
|
|
950
|
+
// binding (the session is revoked anyway; this is defense-in-depth).
|
|
951
|
+
if (this.tokenBinder !== undefined)
|
|
952
|
+
await this.tokenBinder.unbindSession(sessionId);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// Swallow — logout is idempotent + must not leak. 204 regardless.
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Establish a session for an authenticated principal, mapping the engine's
|
|
960
|
+
* generic `SessionRejectedError` (a malformed principal or a store outage at
|
|
961
|
+
* establish time) to a generic 401. We never surface a session-establish failure
|
|
962
|
+
* as a distinct, enumerable outcome.
|
|
963
|
+
*/
|
|
964
|
+
async establishOrReject(principal) {
|
|
965
|
+
try {
|
|
966
|
+
return await this.sessionAuthority.establish(principal);
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
genericUnauthorized();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
exports.AuthController = AuthController;
|
|
974
|
+
__decorate([
|
|
975
|
+
(0, classification_decorator_js_1.Public)(),
|
|
976
|
+
(0, common_1.Post)('login'),
|
|
977
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
978
|
+
__param(0, (0, common_1.Body)()),
|
|
979
|
+
__metadata("design:type", Function),
|
|
980
|
+
__metadata("design:paramtypes", [Object]),
|
|
981
|
+
__metadata("design:returntype", Promise)
|
|
982
|
+
], AuthController.prototype, "login", null);
|
|
983
|
+
__decorate([
|
|
984
|
+
(0, classification_decorator_js_1.Public)(),
|
|
985
|
+
(0, common_1.Get)('me'),
|
|
986
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
987
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
988
|
+
__metadata("design:type", Function),
|
|
989
|
+
__metadata("design:paramtypes", [String]),
|
|
990
|
+
__metadata("design:returntype", Promise)
|
|
991
|
+
], AuthController.prototype, "me", null);
|
|
992
|
+
__decorate([
|
|
993
|
+
(0, classification_decorator_js_1.Public)(),
|
|
994
|
+
(0, common_1.Post)('forgot-password'),
|
|
995
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
996
|
+
__param(0, (0, common_1.Body)()),
|
|
997
|
+
__param(1, (0, common_1.Ip)()),
|
|
998
|
+
__metadata("design:type", Function),
|
|
999
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
1000
|
+
__metadata("design:returntype", Promise)
|
|
1001
|
+
], AuthController.prototype, "forgotPassword", null);
|
|
1002
|
+
__decorate([
|
|
1003
|
+
(0, classification_decorator_js_1.Public)(),
|
|
1004
|
+
(0, common_1.Post)('reset-password'),
|
|
1005
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
1006
|
+
__param(0, (0, common_1.Body)()),
|
|
1007
|
+
__param(1, (0, common_1.Ip)()),
|
|
1008
|
+
__metadata("design:type", Function),
|
|
1009
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
1010
|
+
__metadata("design:returntype", Promise)
|
|
1011
|
+
], AuthController.prototype, "resetPassword", null);
|
|
1012
|
+
__decorate([
|
|
1013
|
+
(0, classification_decorator_js_1.Public)(),
|
|
1014
|
+
(0, common_1.Post)('mfa'),
|
|
1015
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
1016
|
+
__param(0, (0, common_1.Body)()),
|
|
1017
|
+
__metadata("design:type", Function),
|
|
1018
|
+
__metadata("design:paramtypes", [Object]),
|
|
1019
|
+
__metadata("design:returntype", Promise)
|
|
1020
|
+
], AuthController.prototype, "mfa", null);
|
|
1021
|
+
__decorate([
|
|
1022
|
+
(0, classification_decorator_js_1.Public)(),
|
|
1023
|
+
(0, common_1.Post)('refresh'),
|
|
1024
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
1025
|
+
__param(0, (0, common_1.Body)()),
|
|
1026
|
+
__metadata("design:type", Function),
|
|
1027
|
+
__metadata("design:paramtypes", [Object]),
|
|
1028
|
+
__metadata("design:returntype", Promise)
|
|
1029
|
+
], AuthController.prototype, "refresh", null);
|
|
1030
|
+
__decorate([
|
|
1031
|
+
(0, classification_decorator_js_1.Public)(),
|
|
1032
|
+
(0, common_1.Post)('logout'),
|
|
1033
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
1034
|
+
__param(0, (0, common_1.Body)()),
|
|
1035
|
+
__metadata("design:type", Function),
|
|
1036
|
+
__metadata("design:paramtypes", [Object]),
|
|
1037
|
+
__metadata("design:returntype", Promise)
|
|
1038
|
+
], AuthController.prototype, "logout", null);
|
|
1039
|
+
exports.AuthController = AuthController = __decorate([
|
|
1040
|
+
(0, common_1.Controller)('auth'),
|
|
1041
|
+
__param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_AUTHENTICATOR)),
|
|
1042
|
+
__param(1, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_AUTHORITY)),
|
|
1043
|
+
__param(3, (0, common_2.Optional)()),
|
|
1044
|
+
__param(3, (0, common_1.Inject)(tokens_js_2.SHIELD_SESSION_TOKEN_BINDER)),
|
|
1045
|
+
__param(4, (0, common_2.Optional)()),
|
|
1046
|
+
__param(4, (0, common_1.Inject)(tokens_js_3.SHIELD_PROFILE_RESOLVER)),
|
|
1047
|
+
__param(5, (0, common_2.Optional)()),
|
|
1048
|
+
__param(5, (0, common_1.Inject)(tokens_js_1.SHIELD_CREDENTIAL_RESOLVER)),
|
|
1049
|
+
__param(6, (0, common_2.Optional)()),
|
|
1050
|
+
__param(6, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_TOKEN_STORE)),
|
|
1051
|
+
__param(7, (0, common_2.Optional)()),
|
|
1052
|
+
__param(7, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_SENDER)),
|
|
1053
|
+
__param(8, (0, common_2.Optional)()),
|
|
1054
|
+
__param(8, (0, common_1.Inject)(tokens_js_1.SHIELD_CREDENTIAL_WRITER)),
|
|
1055
|
+
__param(9, (0, common_2.Optional)()),
|
|
1056
|
+
__param(9, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_SWEEPER)),
|
|
1057
|
+
__param(10, (0, common_2.Optional)()),
|
|
1058
|
+
__param(10, (0, common_1.Inject)(tokens_js_1.SHIELD_PASSWORD_HASHER)),
|
|
1059
|
+
__param(11, (0, common_2.Optional)()),
|
|
1060
|
+
__param(11, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LINK_BASE_URL)),
|
|
1061
|
+
__param(12, (0, common_2.Optional)()),
|
|
1062
|
+
__param(12, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_CODE_STORE)),
|
|
1063
|
+
__param(13, (0, common_2.Optional)()),
|
|
1064
|
+
__param(13, (0, common_1.Inject)(tokens_js_1.SHIELD_LOGIN_LIMITER)),
|
|
1065
|
+
__param(14, (0, common_2.Optional)()),
|
|
1066
|
+
__param(14, (0, common_1.Inject)(tokens_js_1.SHIELD_RECOVERY_LIMITER)),
|
|
1067
|
+
__param(15, (0, common_2.Optional)()),
|
|
1068
|
+
__param(15, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_LIMITER)),
|
|
1069
|
+
__param(16, (0, common_2.Optional)()),
|
|
1070
|
+
__param(16, (0, common_1.Inject)(tokens_js_1.SHIELD_FORGOT_HINT_LIMITER)),
|
|
1071
|
+
__param(17, (0, common_2.Optional)()),
|
|
1072
|
+
__param(17, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_LIMITER)),
|
|
1073
|
+
__param(18, (0, common_2.Optional)()),
|
|
1074
|
+
__param(18, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
|
|
1075
|
+
__param(19, (0, common_2.Optional)()),
|
|
1076
|
+
__param(19, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_FACTS_PROJECTOR)),
|
|
1077
|
+
__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])
|
|
1078
|
+
], AuthController);
|
|
1079
|
+
/**
|
|
1080
|
+
* Narrow an `authenticated` result to its guaranteed principal. The core engine
|
|
1081
|
+
* populates `principal` ONLY on `outcome === 'authenticated'`; this asserts that
|
|
1082
|
+
* invariant so a missing principal fails closed (generic 401) rather than
|
|
1083
|
+
* establishing a session for `undefined`.
|
|
1084
|
+
*/
|
|
1085
|
+
function requirePrincipal(result) {
|
|
1086
|
+
const principal = result.principal;
|
|
1087
|
+
if (principal === undefined || principal === null) {
|
|
1088
|
+
genericUnauthorized();
|
|
1089
|
+
}
|
|
1090
|
+
return principal;
|
|
1091
|
+
}
|