@astram/guard 0.10.2 → 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 (42) 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 +241 -53
  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 +10 -2
  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/index.d.ts +6 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +16 -1
  39. package/dist/module/shield.module.d.ts +9 -0
  40. package/dist/module/shield.module.d.ts.map +1 -1
  41. package/dist/module/shield.module.js +6 -0
  42. 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,15 +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");
61
62
  const engine_1 = require("@astram/engine");
62
63
  const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
63
64
  const request_principal_js_1 = require("../request-auth/request-principal.js");
64
65
  const tokens_js_1 = require("../request-auth/tokens.js");
65
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");
66
69
  const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
67
70
  const totp_enrollment_js_1 = require("./totp-enrollment.js");
68
71
  /** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
69
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;
70
78
  /** Minimum acceptable new-password length (the only policy this route enforces). */
71
79
  const MIN_PASSWORD_LEN = 8;
72
80
  /**
@@ -154,6 +162,18 @@ async function consumeAttempt(limiter, key) {
154
162
  function unprocessable(error) {
155
163
  throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
156
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
+ }
157
177
  let AccountController = class AccountController {
158
178
  binder;
159
179
  hasher;
@@ -168,7 +188,13 @@ let AccountController = class AccountController {
168
188
  loginTotpVerifier;
169
189
  mfaLimiter;
170
190
  loginLimiter;
171
- 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) {
172
198
  this.binder = binder;
173
199
  this.hasher = hasher;
174
200
  this.writer = writer;
@@ -182,6 +208,12 @@ let AccountController = class AccountController {
182
208
  this.loginTotpVerifier = loginTotpVerifier;
183
209
  this.mfaLimiter = mfaLimiter;
184
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;
185
217
  }
186
218
  /**
187
219
  * Resolve a human-readable otpauth account LABEL for the caller (email, else display
@@ -330,11 +362,20 @@ let AccountController = class AccountController {
330
362
  await this.loginLimiter?.reset(loginKey);
331
363
  const newHash = await hasher.hash(newPassword);
332
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
+ });
333
372
  // Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
334
373
  // absent seam → skip (the password is already changed).
335
374
  if (this.sweeper !== undefined && this.sweeper !== null) {
336
375
  await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
337
376
  }
377
+ if (burnError !== undefined)
378
+ throw burnError;
338
379
  }
339
380
  /**
340
381
  * Verify a presented TOTP `code` against a base32 `secret` using the injected core
@@ -349,53 +390,166 @@ let AccountController = class AccountController {
349
390
  return verdict.satisfied;
350
391
  }
351
392
  /**
352
- * `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN display profile
353
- * (name and/or email). Self-scoped: acts only on `claims.id`. On success returns the
354
- * refreshed {@link CurrentUserProfile} (display names, never UUIDs — the bare `id`
355
- * fields are machine-only), so the UI can update the header/user card in place.
356
- *
357
- * Only the fields actually supplied are written (partial edit). At least one of
358
- * `name`/`email` must be present and non-empty; a supplied email must look like an
359
- * email. These are semantic validations on the caller's OWN profile (no credential
360
- * 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.
361
397
  *
362
398
  * Outcomes:
363
- * • no/invalid token → 401 (generic)
364
- * • profileWriter seam absent → 501 `{ error: 'not_available' }`
365
- * • neither name nor email supplied → 422 `{ error: 'no_changes' }`
366
- * • email supplied but malformed → 422 `{ error: 'invalid_email' }`
367
- * • 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.
368
417
  */
369
- async updateProfile(authorization, body) {
418
+ async updateProfile(authorization, body, res) {
370
419
  const claims = await this.requireClaims(authorization);
371
420
  // BLOCKED during re-enroll: profile edits are not part of re-securing the account.
372
421
  this.enforceReenroll(claims, false);
373
422
  // Normalize: treat only non-empty string fields as a real change (trim so a
374
423
  // whitespace-only value is not a change).
375
424
  const name = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined;
376
- const email = typeof body?.email === 'string' && body.email.trim().length > 0
377
- ? body.email.trim()
378
- : undefined;
425
+ const rawEmail = typeof body?.email === 'string' && body.email.trim().length > 0 ? body.email : undefined;
379
426
  // Nothing to update → explicit 422 (rather than a silent no-op re-read).
380
- if (name === undefined && email === undefined) {
427
+ if (name === undefined && rawEmail === undefined) {
381
428
  unprocessable('no_changes');
382
429
  }
383
- // Basic email shape check when an email is supplied (non-empty already ensured).
384
- 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)) {
385
433
  unprocessable('invalid_email');
386
434
  }
387
435
  // Fail-closed when the write seam is unplugged — an honest "not available".
388
436
  if (this.profileWriter === undefined || this.profileWriter === null) {
389
437
  notAvailable();
390
438
  }
391
- // Build the change with ONLY the supplied fields (partial edit), then return the
392
- // refreshed display profile the writer re-reads (names, never UUIDs).
393
- const changes = {};
394
- if (name !== undefined)
395
- changes.name = name;
396
- if (email !== undefined)
397
- changes.email = email;
398
- 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
+ });
399
553
  }
400
554
  // ───────────────────────────────────────────────────────────────────────────
401
555
  // Multi-device MFA — self-scoped device management. Every route
@@ -495,7 +649,16 @@ let AccountController = class AccountController {
495
649
  * material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
496
650
  * `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
497
651
  *
498
- * 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 }`.
499
662
  */
500
663
  async beginDevice(authorization, body) {
501
664
  const claims = await this.requireClaims(authorization);
@@ -504,10 +667,17 @@ let AccountController = class AccountController {
504
667
  if (this.mfaDevices === undefined || this.mfaDevices === null)
505
668
  notAvailable();
506
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
+ }
507
677
  const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
508
678
  const name = rawName.length > 0
509
679
  ? rawName.slice(0, MAX_DEVICE_NAME_LEN)
510
- : `Authenticator ${(await store.countConfirmed(claims.id)) + 1}`;
680
+ : `Authenticator ${confirmedCount + 1}`;
511
681
  const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
512
682
  const deviceId = await store.begin(claims.id, name, secret);
513
683
  const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
@@ -520,8 +690,17 @@ let AccountController = class AccountController {
520
690
  * codes are minted (shown once) and returned; subsequent devices return `{}` and do
521
691
  * NOT touch recovery codes.
522
692
  *
523
- * Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned → 404;
524
- * 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? }`.
525
704
  */
526
705
  async confirmDevice(authorization, body) {
527
706
  const claims = await this.requireClaims(authorization);
@@ -547,11 +726,22 @@ let AccountController = class AccountController {
547
726
  if (secret === null) {
548
727
  throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
549
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
+ }
550
742
  // Wrong/expired code → generic 400; the device stays pending (retry or GC later).
551
743
  if (!(await this.verifyTotp(secret, code)))
552
744
  badRequest();
553
- // First-device check BEFORE confirming (so the just-confirmed device isn't counted).
554
- const wasFirst = (await store.countConfirmed(claims.id)) === 0;
555
745
  const confirmed = await store.confirm(claims.id, deviceId);
556
746
  // A race (device removed / already confirmed between load and confirm) → 404.
557
747
  if (!confirmed) {
@@ -697,8 +887,9 @@ __decorate([
697
887
  (0, common_1.HttpCode)(common_1.HttpStatus.OK),
698
888
  __param(0, (0, common_1.Headers)('authorization')),
699
889
  __param(1, (0, common_1.Body)()),
890
+ __param(2, (0, common_1.Res)({ passthrough: true })),
700
891
  __metadata("design:type", Function),
701
- __metadata("design:paramtypes", [Object, Object]),
892
+ __metadata("design:paramtypes", [Object, Object, Object]),
702
893
  __metadata("design:returntype", Promise)
703
894
  ], AccountController.prototype, "updateProfile", null);
704
895
  __decorate([
@@ -790,20 +981,17 @@ exports.AccountController = AccountController = __decorate([
790
981
  __param(11, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_LIMITER)),
791
982
  __param(12, (0, common_1.Optional)()),
792
983
  __param(12, (0, common_1.Inject)(tokens_js_2.SHIELD_LOGIN_LIMITER)),
793
- __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])
794
997
  ], AccountController);
795
- /**
796
- * A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
797
- * domain, no whitespace). This is a self-service edit of the caller's own profile —
798
- * a full RFC-5322 validator adds no security value here, only false rejections. The
799
- * authoritative check is delivery; this just catches obvious typos client-adjacent.
800
- */
801
- function isEmailShaped(value) {
802
- if (/\s/.test(value))
803
- return false;
804
- const at = value.indexOf('@');
805
- if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
806
- return false;
807
- const domain = value.slice(at + 1);
808
- return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
809
- }
@@ -53,6 +53,7 @@
53
53
  * composed engines via DI tokens; it never news-up an engine or an adapter.
54
54
  */
55
55
  import type { Authenticator, PasswordHasher, SessionAuthority } from '@astram/core';
56
+ import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
56
57
  import type { SessionRestrictionStore } from '../session/session-restriction-store.js';
57
58
  import type { SessionRecordStore } from '@astram/core';
58
59
  import type { Clock, SessionLifetimeConfig } from '@astram/core';
@@ -113,7 +114,8 @@ export declare class AuthController {
113
114
  private readonly rootIds?;
114
115
  private readonly sessionRecords?;
115
116
  private readonly sessionRestrictions?;
116
- constructor(authenticator: Authenticator, sessionAuthority: SessionAuthority, pendingMfa: PendingMfaStore, tokenBinder?: SessionTokenBinder | undefined, profileResolver?: ProfileResolver | undefined, credentialResolver?: (CredentialResolver | null) | undefined, resetTokenStore?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, credentialWriter?: (CredentialWriter | null) | undefined, sessionSweeper?: (SessionSweeper | null) | undefined, passwordHasher?: PasswordHasher | undefined, resetLinkBaseUrl?: string | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, loginIpLimiter?: (AttemptLimiter | null) | undefined, trustProxyCheck?: (TrustProxyBootCheck | null) | undefined, recoveryLimiter?: (AttemptLimiter | null) | undefined, forgotLimiter?: (AttemptLimiter | null) | undefined, forgotHintLimiter?: (AttemptLimiter | null) | undefined, resetLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined, sessionFactsProjector?: (SessionFactsProjector | null) | undefined, scopeSubtreeResolver?: (ScopeSubtreeResolver | null) | undefined, membershipChecker?: (MembershipChecker | null) | undefined, permissionFacts?: (MariaDbPermissionFactsAdapter | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, mfaStepUp?: (MfaStepUp | null) | undefined, clock?: (Clock | null) | undefined, lifetimes?: (SessionLifetimeConfig | null) | undefined, rootIds?: (readonly string[] | null) | undefined, sessionRecords?: (Pick<SessionRecordStore, "findSessionById"> | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined);
117
+ private readonly emailChangeTokens?;
118
+ constructor(authenticator: Authenticator, sessionAuthority: SessionAuthority, pendingMfa: PendingMfaStore, tokenBinder?: SessionTokenBinder | undefined, profileResolver?: ProfileResolver | undefined, credentialResolver?: (CredentialResolver | null) | undefined, resetTokenStore?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, credentialWriter?: (CredentialWriter | null) | undefined, sessionSweeper?: (SessionSweeper | null) | undefined, passwordHasher?: PasswordHasher | undefined, resetLinkBaseUrl?: string | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, loginIpLimiter?: (AttemptLimiter | null) | undefined, trustProxyCheck?: (TrustProxyBootCheck | null) | undefined, recoveryLimiter?: (AttemptLimiter | null) | undefined, forgotLimiter?: (AttemptLimiter | null) | undefined, forgotHintLimiter?: (AttemptLimiter | null) | undefined, resetLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined, sessionFactsProjector?: (SessionFactsProjector | null) | undefined, scopeSubtreeResolver?: (ScopeSubtreeResolver | null) | undefined, membershipChecker?: (MembershipChecker | null) | undefined, permissionFacts?: (MariaDbPermissionFactsAdapter | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, mfaStepUp?: (MfaStepUp | null) | undefined, clock?: (Clock | null) | undefined, lifetimes?: (SessionLifetimeConfig | null) | undefined, rootIds?: (readonly string[] | null) | undefined, sessionRecords?: (Pick<SessionRecordStore, "findSessionById"> | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, emailChangeTokens?: (EmailChangeTokenStore | null) | undefined);
117
119
  /**
118
120
  * The access-token binding TTL for an establishment (security hardening #8):
119
121
  * `clamp(session.expiresAtMs − now, 1, accessTtlMs)` — on login AND refresh. Never derived