@astram/guard 0.10.1 → 0.11.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.
Files changed (47) hide show
  1. package/dist/auth/account.controller.d.ts +73 -19
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +251 -56
  4. package/dist/auth/auth.controller.d.ts +3 -1
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +17 -4
  7. package/dist/auth/auth.module.d.ts +6 -0
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +53 -1
  10. package/dist/auth/confirm-email.controller.d.ts +33 -0
  11. package/dist/auth/confirm-email.controller.d.ts.map +1 -0
  12. package/dist/auth/confirm-email.controller.js +205 -0
  13. package/dist/auth/email-address.d.ts +30 -0
  14. package/dist/auth/email-address.d.ts.map +1 -0
  15. package/dist/auth/email-address.js +49 -0
  16. package/dist/auth/email-change-messages.d.ts +21 -0
  17. package/dist/auth/email-change-messages.d.ts.map +1 -0
  18. package/dist/auth/email-change-messages.js +50 -0
  19. package/dist/auth/ports/email-address-changer.d.ts +65 -0
  20. package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
  21. package/dist/auth/ports/email-address-changer.js +8 -0
  22. package/dist/auth/ports/email-change-token-store.d.ts +39 -0
  23. package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
  24. package/dist/auth/ports/email-change-token-store.js +12 -0
  25. package/dist/auth/tokens.d.ts +36 -0
  26. package/dist/auth/tokens.d.ts.map +1 -1
  27. package/dist/auth/tokens.js +37 -1
  28. package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
  29. package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
  30. package/dist/authentication/mariadb-email-address-changer.js +97 -0
  31. package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
  32. package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
  33. package/dist/authentication/mariadb-email-change-token-store.js +72 -0
  34. package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-mfa-device-store.js +8 -1
  36. package/dist/authz/permission-version-cache.d.ts.map +1 -1
  37. package/dist/authz/permission-version-cache.js +12 -2
  38. package/dist/enforcement/uuid.pipe.d.ts +35 -0
  39. package/dist/enforcement/uuid.pipe.d.ts.map +1 -0
  40. package/dist/enforcement/uuid.pipe.js +67 -0
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +26 -3
  44. package/dist/module/shield.module.d.ts +9 -0
  45. package/dist/module/shield.module.d.ts.map +1 -1
  46. package/dist/module/shield.module.js +6 -0
  47. package/package.json +3 -3
@@ -44,6 +44,10 @@
44
44
  */
45
45
  import type { FactorVerifier, PasswordHasher } from '@astram/core';
46
46
  import type { SessionTokenBinder } from '../request-auth/session-token-binder.js';
47
+ import type { AuditSink } from './ports/audit-sink.js';
48
+ import type { EmailSender } from './ports/email-sender.js';
49
+ import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
50
+ import type { EmailAddressChanger } from './ports/email-address-changer.js';
47
51
  import type { SessionRestrictionStore } from '../session/session-restriction-store.js';
48
52
  import type { AttemptLimiter } from './rate-limit/attempt-limiter.js';
49
53
  import type { CredentialWriter } from './ports/credential-writer.js';
@@ -52,6 +56,15 @@ import type { CurrentUserProfile, ProfileResolver } from './profile-resolver.js'
52
56
  import type { ProfileWriter } from './ports/profile-writer.js';
53
57
  import type { RecoveryCodeStore } from './ports/recovery-code-store.js';
54
58
  import type { SessionSweeper } from './ports/session-sweeper.js';
59
+ /** The minimal response surface the 202 answer needs (Express-compatible). */
60
+ interface StatusSettable {
61
+ status(code: number): unknown;
62
+ }
63
+ /** The 202 body of an accepted email change (the address changes only after confirmation). */
64
+ export interface PendingEmailChange {
65
+ readonly pendingEmail: true;
66
+ readonly profile: CurrentUserProfile;
67
+ }
55
68
  export declare class AccountController {
56
69
  private readonly binder?;
57
70
  private readonly hasher?;
@@ -66,7 +79,13 @@ export declare class AccountController {
66
79
  private readonly loginTotpVerifier?;
67
80
  private readonly mfaLimiter?;
68
81
  private readonly loginLimiter?;
69
- constructor(binder?: SessionTokenBinder | undefined, hasher?: PasswordHasher | undefined, writer?: (CredentialWriter | null) | undefined, sweeper?: (SessionSweeper | null) | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, totpVerifier?: (FactorVerifier | null) | undefined, profileResolver?: (ProfileResolver | null) | undefined, profileWriter?: (ProfileWriter | null) | undefined, mfaDevices?: (MfaDeviceStore | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, loginTotpVerifier?: (FactorVerifier | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined);
82
+ private readonly emailChangeTokens?;
83
+ private readonly emailAddresses?;
84
+ private readonly emailSender?;
85
+ private readonly emailChangeLinkBaseUrl?;
86
+ private readonly emailChangeLimiter?;
87
+ private readonly auditSink?;
88
+ constructor(binder?: SessionTokenBinder | undefined, hasher?: PasswordHasher | undefined, writer?: (CredentialWriter | null) | undefined, sweeper?: (SessionSweeper | null) | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, totpVerifier?: (FactorVerifier | null) | undefined, profileResolver?: (ProfileResolver | null) | undefined, profileWriter?: (ProfileWriter | null) | undefined, mfaDevices?: (MfaDeviceStore | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, loginTotpVerifier?: (FactorVerifier | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, emailChangeTokens?: (EmailChangeTokenStore | null) | undefined, emailAddresses?: (EmailAddressChanger | null) | undefined, emailSender?: (EmailSender | null) | undefined, emailChangeLinkBaseUrl?: string | null | undefined, emailChangeLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined);
70
89
  /**
71
90
  * Resolve a human-readable otpauth account LABEL for the caller (email, else display
72
91
  * name) via the profile resolver. Best-effort: no resolver / null profile / a thrown
@@ -138,27 +157,42 @@ export declare class AccountController {
138
157
  */
139
158
  private verifyTotp;
140
159
  /**
141
- * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN display profile
142
- * (name and/or email). Self-scoped: acts only on `claims.id`. On success returns the
143
- * refreshed {@link CurrentUserProfile} (display names, never UUIDs — the bare `id`
144
- * fields are machine-only), so the UI can update the header/user card in place.
145
- *
146
- * Only the fields actually supplied are written (partial edit). At least one of
147
- * `name`/`email` must be present and non-empty; a supplied email must look like an
148
- * email. These are semantic validations on the caller's OWN profile (no credential
149
- * oracle), so they return precise 422 reasons rather than a generic 4xx.
160
+ * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN profile. Self-scoped: acts only
161
+ * on `claims.id`. A `name` is applied at once. An `email` is NEVER written here (security
162
+ * hardening Phase 12, KI #14): the request is verified and a confirm link goes to the NEW
163
+ * address; `POST /auth/confirm-email` applies it.
150
164
  *
151
165
  * Outcomes:
152
- * • no/invalid token → 401 (generic)
153
- * • profileWriter seam absent → 501 `{ error: 'not_available' }`
154
- * • neither name nor email supplied → 422 `{ error: 'no_changes' }`
155
- * • email supplied but malformed → 422 `{ error: 'invalid_email' }`
156
- * • success → 200 (the refreshed CurrentUserProfile)
166
+ * • no/invalid token → 401 (generic); re-enroll session → 403
167
+ * • neither name nor email supplied → 422 `no_changes`
168
+ * • email malformed → 422 `invalid_email`
169
+ * • email collation-equal to the current → 422 `no_changes`
170
+ * • a seam unplugged → 501 `not_available`
171
+ * • email without / with a wrong `currentPassword` → 400 `invalid_current_password`
172
+ * (counted on the account's `login:pid` budget, like change-password); locked → 429
173
+ * • a confirmed MFA device and no `code` → 400 `mfa_required`; wrong code → 400
174
+ * `invalid_request` (`mfa:pid` budget, replay-guarded TOTP or a recovery code, A1); locked → 429
175
+ * • `email-change:pid` budget exhausted → 429 `too_many_requests`
176
+ * • a limiter / token store / mail outage → 503 `authentication_unavailable`
177
+ * • name only → 200 (the refreshed CurrentUserProfile)
178
+ * • email accepted → 202 `{ pendingEmail: true, profile }`
179
+ *
180
+ * The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
181
+ * the current email and this session (A3), and reaches the user only inside the mailed link's
182
+ * fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
183
+ * only) is the one place the link is printed, exactly like a reset link.
157
184
  */
158
185
  updateProfile(authorization: string | undefined, body: {
159
186
  name?: string;
160
187
  email?: string;
161
- }): Promise<CurrentUserProfile>;
188
+ currentPassword?: string;
189
+ code?: string;
190
+ }, res: StatusSettable): Promise<CurrentUserProfile | PendingEmailChange>;
191
+ /**
192
+ * The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
193
+ * audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
194
+ */
195
+ private requestEmailChange;
162
196
  /**
163
197
  * Mint a fresh recovery-code batch, hash each (the SAME Argon2 hasher recovery-code
164
198
  * consume verifies against), and REPLACE the caller's prior codes. Returns the RAW
@@ -206,10 +240,20 @@ export declare class AccountController {
206
240
  * material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
207
241
  * `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
208
242
  *
209
- * Outcomes: 401; store absent → 501; success → 200 `{ deviceId, otpauthUri, secret }`.
243
+ * KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
244
+ * second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
245
+ * recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
246
+ * could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
247
+ * recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
248
+ * proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
249
+ *
250
+ * Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
251
+ * 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
252
+ * success → 200 `{ deviceId, otpauthUri, secret }`.
210
253
  */
211
254
  beginDevice(authorization: string | undefined, body: {
212
255
  name?: string;
256
+ code?: string;
213
257
  }): Promise<{
214
258
  deviceId: string;
215
259
  otpauthUri: string;
@@ -222,8 +266,17 @@ export declare class AccountController {
222
266
  * codes are minted (shown once) and returned; subsequent devices return `{}` and do
223
267
  * NOT touch recovery codes.
224
268
  *
225
- * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned → 404;
226
- * wrong/expired code → 400 (device stays pending); success → 200 `{ recoveryCodes? }`.
269
+ * KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
270
+ * the session is not a re-enroll session, only a pending device that `begin` created behind its
271
+ * step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
272
+ * while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
273
+ * younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
274
+ * TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
275
+ * still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
276
+ *
277
+ * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
278
+ * bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
279
+ * 200 `{ recoveryCodes? }`.
227
280
  */
228
281
  confirmDevice(authorization: string | undefined, body: {
229
282
  deviceId?: string;
@@ -272,4 +325,5 @@ export declare class AccountController {
272
325
  recoveryCodes: string[];
273
326
  }>;
274
327
  }
328
+ export {};
275
329
  //# sourceMappingURL=account.controller.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"account.controller.d.ts","sourceRoot":"","sources":["../../src/auth/account.controller.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"account.controller.d.ts","sourceRoot":"","sources":["../../src/auth/account.controller.ts"],"names":[],"mappings":"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"}
@@ -58,14 +58,23 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
58
58
  Object.defineProperty(exports, "__esModule", { value: true });
59
59
  exports.AccountController = void 0;
60
60
  const common_1 = require("@nestjs/common");
61
+ const node_crypto_1 = require("node:crypto");
62
+ const engine_1 = require("@astram/engine");
61
63
  const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
62
64
  const request_principal_js_1 = require("../request-auth/request-principal.js");
63
65
  const tokens_js_1 = require("../request-auth/tokens.js");
64
66
  const tokens_js_2 = require("./tokens.js");
67
+ const email_address_js_1 = require("./email-address.js");
68
+ const email_change_messages_js_1 = require("./email-change-messages.js");
65
69
  const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
66
70
  const totp_enrollment_js_1 = require("./totp-enrollment.js");
67
71
  /** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
68
72
  const MAX_DEVICE_NAME_LEN = 120;
73
+ /**
74
+ * How long a pending (begun, unconfirmed) device may still be confirmed once MFA is on — the same
75
+ * 15 minutes after which `MariaDbMfaDeviceStore.begin` prunes it (KI #14 re-review P1-1).
76
+ */
77
+ const PENDING_DEVICE_MAX_AGE_MS = 15 * 60 * 1000;
69
78
  /** Minimum acceptable new-password length (the only policy this route enforces). */
70
79
  const MIN_PASSWORD_LEN = 8;
71
80
  /**
@@ -153,6 +162,18 @@ async function consumeAttempt(limiter, key) {
153
162
  function unprocessable(error) {
154
163
  throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
155
164
  }
165
+ /**
166
+ * Phase 12 (A1): an email change by a user with a confirmed MFA device must carry a second-factor
167
+ * `code`. Missing → this 400 (the client then asks for the code); a wrong code is the generic
168
+ * {@link badRequest}.
169
+ */
170
+ function mfaRequired() {
171
+ throw new common_1.HttpException({ error: 'mfa_required' }, common_1.HttpStatus.BAD_REQUEST);
172
+ }
173
+ /** SHA-256 hex of a raw token: stores only ever see this hash. */
174
+ function sha256Hex(value) {
175
+ return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
176
+ }
156
177
  let AccountController = class AccountController {
157
178
  binder;
158
179
  hasher;
@@ -167,7 +188,13 @@ let AccountController = class AccountController {
167
188
  loginTotpVerifier;
168
189
  mfaLimiter;
169
190
  loginLimiter;
170
- constructor(binder, hasher, writer, sweeper, recoveryCodes, totpVerifier, profileResolver, profileWriter, mfaDevices, sessionRestrictions, loginTotpVerifier, mfaLimiter, loginLimiter) {
191
+ emailChangeTokens;
192
+ emailAddresses;
193
+ emailSender;
194
+ emailChangeLinkBaseUrl;
195
+ emailChangeLimiter;
196
+ auditSink;
197
+ constructor(binder, hasher, writer, sweeper, recoveryCodes, totpVerifier, profileResolver, profileWriter, mfaDevices, sessionRestrictions, loginTotpVerifier, mfaLimiter, loginLimiter, emailChangeTokens, emailAddresses, emailSender, emailChangeLinkBaseUrl, emailChangeLimiter, auditSink) {
171
198
  this.binder = binder;
172
199
  this.hasher = hasher;
173
200
  this.writer = writer;
@@ -181,6 +208,12 @@ let AccountController = class AccountController {
181
208
  this.loginTotpVerifier = loginTotpVerifier;
182
209
  this.mfaLimiter = mfaLimiter;
183
210
  this.loginLimiter = loginLimiter;
211
+ this.emailChangeTokens = emailChangeTokens;
212
+ this.emailAddresses = emailAddresses;
213
+ this.emailSender = emailSender;
214
+ this.emailChangeLinkBaseUrl = emailChangeLinkBaseUrl;
215
+ this.emailChangeLimiter = emailChangeLimiter;
216
+ this.auditSink = auditSink;
184
217
  }
185
218
  /**
186
219
  * Resolve a human-readable otpauth account LABEL for the caller (email, else display
@@ -329,11 +362,20 @@ let AccountController = class AccountController {
329
362
  await this.loginLimiter?.reset(loginKey);
330
363
  const newHash = await hasher.hash(newPassword);
331
364
  await writer.setPassword(claims.id, newHash);
365
+ // Phase 12 (A8): a pending email change requested with the OLD password dies with it. Burned
366
+ // after the write (a new request then needs the new password); a failure is surfaced after the
367
+ // session sweep below ran.
368
+ let burnError;
369
+ await this.emailChangeTokens?.invalidateAllForPrincipal(claims.id).catch((err) => {
370
+ burnError = err;
371
+ });
332
372
  // Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
333
373
  // absent seam → skip (the password is already changed).
334
374
  if (this.sweeper !== undefined && this.sweeper !== null) {
335
375
  await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
336
376
  }
377
+ if (burnError !== undefined)
378
+ throw burnError;
337
379
  }
338
380
  /**
339
381
  * Verify a presented TOTP `code` against a base32 `secret` using the injected core
@@ -348,53 +390,166 @@ let AccountController = class AccountController {
348
390
  return verdict.satisfied;
349
391
  }
350
392
  /**
351
- * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN display profile
352
- * (name and/or email). Self-scoped: acts only on `claims.id`. On success returns the
353
- * refreshed {@link CurrentUserProfile} (display names, never UUIDs — the bare `id`
354
- * fields are machine-only), so the UI can update the header/user card in place.
355
- *
356
- * Only the fields actually supplied are written (partial edit). At least one of
357
- * `name`/`email` must be present and non-empty; a supplied email must look like an
358
- * email. These are semantic validations on the caller's OWN profile (no credential
359
- * oracle), so they return precise 422 reasons rather than a generic 4xx.
393
+ * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN profile. Self-scoped: acts only
394
+ * on `claims.id`. A `name` is applied at once. An `email` is NEVER written here (security
395
+ * hardening Phase 12, KI #14): the request is verified and a confirm link goes to the NEW
396
+ * address; `POST /auth/confirm-email` applies it.
360
397
  *
361
398
  * Outcomes:
362
- * • no/invalid token → 401 (generic)
363
- * • profileWriter seam absent → 501 `{ error: 'not_available' }`
364
- * • neither name nor email supplied → 422 `{ error: 'no_changes' }`
365
- * • email supplied but malformed → 422 `{ error: 'invalid_email' }`
366
- * • success → 200 (the refreshed CurrentUserProfile)
399
+ * • no/invalid token → 401 (generic); re-enroll session → 403
400
+ * • neither name nor email supplied → 422 `no_changes`
401
+ * • email malformed → 422 `invalid_email`
402
+ * • email collation-equal to the current → 422 `no_changes`
403
+ * • a seam unplugged → 501 `not_available`
404
+ * • email without / with a wrong `currentPassword` → 400 `invalid_current_password`
405
+ * (counted on the account's `login:pid` budget, like change-password); locked → 429
406
+ * • a confirmed MFA device and no `code` → 400 `mfa_required`; wrong code → 400
407
+ * `invalid_request` (`mfa:pid` budget, replay-guarded TOTP or a recovery code, A1); locked → 429
408
+ * • `email-change:pid` budget exhausted → 429 `too_many_requests`
409
+ * • a limiter / token store / mail outage → 503 `authentication_unavailable`
410
+ * • name only → 200 (the refreshed CurrentUserProfile)
411
+ * • email accepted → 202 `{ pendingEmail: true, profile }`
412
+ *
413
+ * The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
414
+ * the current email and this session (A3), and reaches the user only inside the mailed link's
415
+ * fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
416
+ * only) is the one place the link is printed, exactly like a reset link.
367
417
  */
368
- async updateProfile(authorization, body) {
418
+ async updateProfile(authorization, body, res) {
369
419
  const claims = await this.requireClaims(authorization);
370
420
  // BLOCKED during re-enroll: profile edits are not part of re-securing the account.
371
421
  this.enforceReenroll(claims, false);
372
422
  // Normalize: treat only non-empty string fields as a real change (trim so a
373
423
  // whitespace-only value is not a change).
374
424
  const name = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined;
375
- const email = typeof body?.email === 'string' && body.email.trim().length > 0
376
- ? body.email.trim()
377
- : undefined;
425
+ const rawEmail = typeof body?.email === 'string' && body.email.trim().length > 0 ? body.email : undefined;
378
426
  // Nothing to update → explicit 422 (rather than a silent no-op re-read).
379
- if (name === undefined && email === undefined) {
427
+ if (name === undefined && rawEmail === undefined) {
380
428
  unprocessable('no_changes');
381
429
  }
382
- // Basic email shape check when an email is supplied (non-empty already ensured).
383
- if (email !== undefined && !isEmailShaped(email)) {
430
+ // A5: canonicalise ONCE; this value is the token row, the collision check and the write.
431
+ const email = rawEmail === undefined ? undefined : (0, email_address_js_1.canonicalEmail)(rawEmail);
432
+ if (email !== undefined && !(0, email_address_js_1.isEmailShaped)(email)) {
384
433
  unprocessable('invalid_email');
385
434
  }
386
435
  // Fail-closed when the write seam is unplugged — an honest "not available".
387
436
  if (this.profileWriter === undefined || this.profileWriter === null) {
388
437
  notAvailable();
389
438
  }
390
- // Build the change with ONLY the supplied fields (partial edit), then return the
391
- // refreshed display profile the writer re-reads (names, never UUIDs).
392
- const changes = {};
393
- if (name !== undefined)
394
- changes.name = name;
395
- if (email !== undefined)
396
- changes.email = email;
397
- return this.profileWriter.update(claims.id, changes);
439
+ const profileWriter = this.profileWriter;
440
+ if (email === undefined) {
441
+ return profileWriter.update(claims.id, { name: name });
442
+ }
443
+ await this.requestEmailChange(claims, email, body);
444
+ // A name in the same body still applies at once; the email waits for confirmation.
445
+ const profile = await profileWriter.update(claims.id, name === undefined ? {} : { name });
446
+ res.status(common_1.HttpStatus.ACCEPTED);
447
+ return { pendingEmail: true, profile };
448
+ }
449
+ /**
450
+ * The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
451
+ * audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
452
+ */
453
+ async requestEmailChange(claims, email, body) {
454
+ const tokens = this.emailChangeTokens;
455
+ const addresses = this.emailAddresses;
456
+ const sender = this.emailSender;
457
+ const writer = this.writer;
458
+ const hasher = this.hasher;
459
+ const devices = this.mfaDevices;
460
+ if (tokens == null ||
461
+ addresses == null ||
462
+ sender == null ||
463
+ writer == null ||
464
+ hasher === undefined ||
465
+ devices == null) {
466
+ notAvailable();
467
+ }
468
+ let current;
469
+ try {
470
+ current = await addresses.readCurrent(claims.id, email);
471
+ }
472
+ catch {
473
+ retryableUnavailable();
474
+ }
475
+ if (current === null || !current.active)
476
+ unauthorized();
477
+ if (current.sameAsCandidate)
478
+ unprocessable('no_changes');
479
+ // The current password, on the account's shared `login:pid` budget (as change-password): the
480
+ // attempt is counted first; a missing password is the same counted 400 as a wrong one.
481
+ const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '';
482
+ const loginKey = (0, canonical_hint_js_1.loginHintKey)('', claims.id);
483
+ await consumeAttempt(this.loginLimiter, loginKey);
484
+ let matches;
485
+ try {
486
+ const stored = await writer.getPasswordHash(claims.id);
487
+ matches = stored !== null && (await hasher.verify(stored, currentPassword));
488
+ }
489
+ catch {
490
+ await this.loginLimiter?.refund(loginKey);
491
+ retryableUnavailable();
492
+ }
493
+ if (!matches)
494
+ invalidCurrentPassword();
495
+ await this.loginLimiter?.reset(loginKey);
496
+ // The per-account request budget, counted only once the password proved the caller (a typo
497
+ // never spends it); given back when the second factor is missing or wrong.
498
+ const requestKey = `email-change:pid:${claims.id}`;
499
+ await consumeAttempt(this.emailChangeLimiter, requestKey);
500
+ // A1: a confirmed MFA device ⇒ a second factor (TOTP via the replay-guarded verifier, or a
501
+ // recovery code) on the shared `mfa:pid` budget, exactly like the code-gated MFA routes.
502
+ try {
503
+ if ((await devices.countConfirmed(claims.id)) > 0) {
504
+ const code = typeof body?.code === 'string' ? body.code.trim() : '';
505
+ if (code === '')
506
+ mfaRequired();
507
+ await this.requireMfaProof(devices, claims.id, code);
508
+ }
509
+ }
510
+ catch (err) {
511
+ await this.emailChangeLimiter?.refund(requestKey);
512
+ throw err;
513
+ }
514
+ // A2/A3/A4: one pending change; 32 random bytes, stored as sha256, bound to the canonical new
515
+ // address, the current email and this session; 30-minute TTL.
516
+ const rawToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
517
+ try {
518
+ await tokens.issue({
519
+ tokenHash: sha256Hex(rawToken),
520
+ principalId: claims.id,
521
+ newEmail: email,
522
+ oldEmail: current.email,
523
+ sessionId: claims.sessionId,
524
+ tenantId: claims.tenantId,
525
+ expiresAtMs: Date.now() + email_change_messages_js_1.EMAIL_CHANGE_TTL_MS,
526
+ });
527
+ }
528
+ catch {
529
+ retryableUnavailable();
530
+ }
531
+ const link = (0, email_change_messages_js_1.emailChangeLink)(this.emailChangeLinkBaseUrl ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL, rawToken);
532
+ try {
533
+ await sender.send((0, email_change_messages_js_1.confirmEmailChangeMessage)(email, link));
534
+ }
535
+ catch {
536
+ // Undeliverable: burn the token so no orphan link can be used later, and say "retry".
537
+ await tokens.invalidateAllForPrincipal(claims.id).catch(() => undefined);
538
+ retryableUnavailable();
539
+ }
540
+ // A9: audit the request with the new address masked (no addresses in full, no token).
541
+ void this.auditSink
542
+ ?.record({
543
+ type: 'authentication',
544
+ occurredAtMs: Date.now(),
545
+ actorId: claims.id,
546
+ tenantId: claims.tenantId,
547
+ action: 'account.email_change_requested',
548
+ attributes: { newEmail: (0, email_address_js_1.maskEmail)(email) },
549
+ })
550
+ .catch(() => {
551
+ /* best-effort: an audit failure never changes the response */
552
+ });
398
553
  }
399
554
  // ───────────────────────────────────────────────────────────────────────────
400
555
  // Multi-device MFA — self-scoped device management. Every route
@@ -494,7 +649,16 @@ let AccountController = class AccountController {
494
649
  * material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
495
650
  * `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
496
651
  *
497
- * Outcomes: 401; store absent → 501; success → 200 `{ deviceId, otpauthUri, secret }`.
652
+ * KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
653
+ * second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
654
+ * recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
655
+ * could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
656
+ * recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
657
+ * proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
658
+ *
659
+ * Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
660
+ * 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
661
+ * success → 200 `{ deviceId, otpauthUri, secret }`.
498
662
  */
499
663
  async beginDevice(authorization, body) {
500
664
  const claims = await this.requireClaims(authorization);
@@ -503,10 +667,17 @@ let AccountController = class AccountController {
503
667
  if (this.mfaDevices === undefined || this.mfaDevices === null)
504
668
  notAvailable();
505
669
  const store = this.mfaDevices;
670
+ const confirmedCount = await store.countConfirmed(claims.id);
671
+ if (confirmedCount > 0 && claims.mustReenroll !== true) {
672
+ const code = typeof body?.code === 'string' ? body.code.trim() : '';
673
+ if (code === '')
674
+ mfaRequired();
675
+ await this.requireMfaProof(store, claims.id, code);
676
+ }
506
677
  const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
507
678
  const name = rawName.length > 0
508
679
  ? rawName.slice(0, MAX_DEVICE_NAME_LEN)
509
- : `Authenticator ${(await store.countConfirmed(claims.id)) + 1}`;
680
+ : `Authenticator ${confirmedCount + 1}`;
510
681
  const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
511
682
  const deviceId = await store.begin(claims.id, name, secret);
512
683
  const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
@@ -519,8 +690,17 @@ let AccountController = class AccountController {
519
690
  * codes are minted (shown once) and returned; subsequent devices return `{}` and do
520
691
  * NOT touch recovery codes.
521
692
  *
522
- * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned → 404;
523
- * wrong/expired code → 400 (device stays pending); success → 200 `{ recoveryCodes? }`.
693
+ * KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
694
+ * the session is not a re-enroll session, only a pending device that `begin` created behind its
695
+ * step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
696
+ * while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
697
+ * younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
698
+ * TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
699
+ * still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
700
+ *
701
+ * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
702
+ * bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
703
+ * 200 `{ recoveryCodes? }`.
524
704
  */
525
705
  async confirmDevice(authorization, body) {
526
706
  const claims = await this.requireClaims(authorization);
@@ -537,7 +717,8 @@ let AccountController = class AccountController {
537
717
  const store = this.mfaDevices;
538
718
  const recoveryStore = this.recoveryCodes;
539
719
  const hasher = this.hasher;
540
- const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : '';
720
+ // Phase 11 #1: device ids are uuids; only the canonical form reaches the store (400 otherwise).
721
+ const deviceId = (0, engine_1.canonicalUuid)(body?.deviceId) ?? badRequest();
541
722
  const code = typeof body?.code === 'string' ? body.code : '';
542
723
  // Load the pending device's OWN secret (owned by the caller) — else 404. The secret
543
724
  // never leaves the server; it is only used to check the presented code here.
@@ -545,11 +726,22 @@ let AccountController = class AccountController {
545
726
  if (secret === null) {
546
727
  throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
547
728
  }
729
+ // First-device check BEFORE confirming (so the just-confirmed device isn't counted).
730
+ const wasFirst = (await store.countConfirmed(claims.id)) === 0;
731
+ // MFA already on: the pending device must come from a step-up `begin` (see the doc above).
732
+ if (!wasFirst && claims.mustReenroll !== true) {
733
+ const all = await store.listAll(claims.id);
734
+ const pending = all.find((d) => d.id === deviceId && !d.confirmed);
735
+ const newestConfirmed = Math.max(...all.filter((d) => d.confirmed).map((d) => d.createdAtMs));
736
+ if (pending === undefined ||
737
+ Date.now() - pending.createdAtMs > PENDING_DEVICE_MAX_AGE_MS ||
738
+ pending.createdAtMs < newestConfirmed) {
739
+ throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
740
+ }
741
+ }
548
742
  // Wrong/expired code → generic 400; the device stays pending (retry or GC later).
549
743
  if (!(await this.verifyTotp(secret, code)))
550
744
  badRequest();
551
- // First-device check BEFORE confirming (so the just-confirmed device isn't counted).
552
- const wasFirst = (await store.countConfirmed(claims.id)) === 0;
553
745
  const confirmed = await store.confirm(claims.id, deviceId);
554
746
  // A race (device removed / already confirmed between load and confirm) → 404.
555
747
  if (!confirmed) {
@@ -592,10 +784,13 @@ let AccountController = class AccountController {
592
784
  if (this.mfaDevices === undefined || this.mfaDevices === null)
593
785
  notAvailable();
594
786
  const store = this.mfaDevices;
787
+ // Phase 11 #1: the canonical device id or 400 — checked in the handler, after the session
788
+ // (a self-authenticated route answers 401 before anything about its input).
789
+ const deviceId = (0, engine_1.canonicalUuid)(id) ?? badRequest();
595
790
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
596
791
  if (name.length === 0)
597
792
  unprocessable('invalid_name');
598
- const device = await store.rename(claims.id, id, name.slice(0, MAX_DEVICE_NAME_LEN));
793
+ const device = await store.rename(claims.id, deviceId, name.slice(0, MAX_DEVICE_NAME_LEN));
599
794
  if (device === null) {
600
795
  throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
601
796
  }
@@ -621,12 +816,14 @@ let AccountController = class AccountController {
621
816
  if (this.mfaDevices === undefined || this.mfaDevices === null)
622
817
  notAvailable();
623
818
  const store = this.mfaDevices;
819
+ // Phase 11 #1: the canonical device id or 400 (after the session, before any proof is spent).
820
+ const deviceId = (0, engine_1.canonicalUuid)(id) ?? badRequest();
624
821
  const code = typeof body?.code === 'string' ? body.code : '';
625
822
  // Proof BEFORE the delete: a TOTP from any confirmed device (incl. the one being
626
823
  // removed — legit for the last-device disable) OR a valid recovery code. Rate-limited
627
824
  // and replay-guarded like `/auth/mfa` (Phase 8 #1).
628
825
  await this.requireMfaProof(store, claims.id, code);
629
- const removed = await store.remove(claims.id, id);
826
+ const removed = await store.remove(claims.id, deviceId);
630
827
  if (!removed) {
631
828
  throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
632
829
  }
@@ -690,8 +887,9 @@ __decorate([
690
887
  (0, common_1.HttpCode)(common_1.HttpStatus.OK),
691
888
  __param(0, (0, common_1.Headers)('authorization')),
692
889
  __param(1, (0, common_1.Body)()),
890
+ __param(2, (0, common_1.Res)({ passthrough: true })),
693
891
  __metadata("design:type", Function),
694
- __metadata("design:paramtypes", [Object, Object]),
892
+ __metadata("design:paramtypes", [Object, Object, Object]),
695
893
  __metadata("design:returntype", Promise)
696
894
  ], AccountController.prototype, "updateProfile", null);
697
895
  __decorate([
@@ -783,20 +981,17 @@ exports.AccountController = AccountController = __decorate([
783
981
  __param(11, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_LIMITER)),
784
982
  __param(12, (0, common_1.Optional)()),
785
983
  __param(12, (0, common_1.Inject)(tokens_js_2.SHIELD_LOGIN_LIMITER)),
786
- __metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
984
+ __param(13, (0, common_1.Optional)()),
985
+ __param(13, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
986
+ __param(14, (0, common_1.Optional)()),
987
+ __param(14, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_ADDRESS_CHANGER)),
988
+ __param(15, (0, common_1.Optional)()),
989
+ __param(15, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_SENDER)),
990
+ __param(16, (0, common_1.Optional)()),
991
+ __param(16, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LINK_BASE_URL)),
992
+ __param(17, (0, common_1.Optional)()),
993
+ __param(17, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LIMITER)),
994
+ __param(18, (0, common_1.Optional)()),
995
+ __param(18, (0, common_1.Inject)(tokens_js_2.SHIELD_AUDIT_SINK)),
996
+ __metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
787
997
  ], AccountController);
788
- /**
789
- * A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
790
- * domain, no whitespace). This is a self-service edit of the caller's own profile —
791
- * a full RFC-5322 validator adds no security value here, only false rejections. The
792
- * authoritative check is delivery; this just catches obvious typos client-adjacent.
793
- */
794
- function isEmailShaped(value) {
795
- if (/\s/.test(value))
796
- return false;
797
- const at = value.indexOf('@');
798
- if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
799
- return false;
800
- const domain = value.slice(at + 1);
801
- return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
802
- }