@aooth/auth-moost 0.1.8 → 0.1.9

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/index.mjs CHANGED
@@ -1,16 +1,16 @@
1
- import { a as EmailIdentifierForm, b as SignupForm, c as EnrollPickMethodForm, f as MfaCodeForm, h as ProveControlOtpForm, i as ConcurrencyLimitForm, l as InviteForm, m as ProveControlForm, n as AskPhoneForm, o as EnrollAddressForm, p as PincodeForm, r as ChangePasswordForm, s as EnrollConfirmForm, t as AskEmailForm, u as LoginCredentialsForm, v as Select2faForm, x as TermsBumpForm, y as SetPasswordForm } from "./forms-Bkr7ECKu.mjs";
1
+ import { C as SetPasswordForm, S as Select2faForm, T as TermsBumpForm, _ as ProveControlForm, a as EmailIdentifierForm, c as EnrollPickMethodForm, d as LoginCredentialsForm, g as PincodeForm, h as PasswordReauthForm, i as ConcurrencyLimitForm, l as EnrollTotpQrForm, m as MfaCodeForm, n as AskPhoneForm, o as EnrollAddressForm, p as ManageMfaForm, r as ChangePasswordForm, s as EnrollConfirmForm, t as AskEmailForm, u as InviteForm, v as ProveControlOtpForm, w as SignupForm, x as RemoveMfaConfirmForm } from "./forms-DV4UcC29.mjs";
2
2
  import { Controller, HandlerPaths, Inherit, Inject, InjectMoostLogger, Injectable, Intercept, MoostInit, Optional, Param, Resolve, TInterceptorPriority, defineAfterInterceptor, defineBeforeInterceptor, getMoostMate, useControllerContext } from "moost";
3
3
  import { AuthCredential, AuthError, generateMagicLinkToken } from "@aooth/auth";
4
4
  import { current, defineWook, eventTypeKey, key } from "@wooksjs/event-core";
5
5
  import { HttpError, useAuthorization, useCookies, useHeaders, useRequest, useResponse, useUrlParams } from "@wooksjs/event-http";
6
6
  import { ArbacAction, ArbacResource, getArbacMate } from "@aooth/arbac-moost";
7
- import { FederatedIdentityStore, UserAuthError, UserService, generateTotpSecret, generateTotpUri, maskEmail, maskPhone, pickDefinedProfile } from "@aooth/user";
7
+ import { FederatedIdentityStore, UserAuthError, UserService, generateTotpSecret, generateTotpUri, maskEmail, maskPhone, pickDefinedProfile, verifyTotpCode } from "@aooth/user";
8
8
  import { Body, Delete, Get, HttpError as HttpError$1, Post, Query } from "@moostjs/event-http";
9
9
  import { createHash, timingSafeEqual } from "node:crypto";
10
10
  import { createAsHttpOutlet, finishWf, handleAsOutletRequest, useAtscriptWf } from "@atscript/moost-wf";
11
11
  import { EncapsulatedStateStrategy, MoostWf, Step, StepRetriableError, StepTTL, Workflow, WorkflowParam, WorkflowSchema, createEmailOutlet, outletEmail, swapStrategy, useWfFinished, useWfState } from "@moostjs/event-wf";
12
12
  import { FederatedLoginService, OAuthError, OAuthProviderRegistry, generateRandomState, pkceChallengeFor } from "@aooth/idp";
13
- import { AuthorizeError } from "@aooth/auth/authz";
13
+ import { AuthorizeError, NoopOidcClaimsResolver } from "@aooth/auth/authz";
14
14
  //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorate.js
15
15
  function __decorate(decorators, target, key, desc) {
16
16
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -651,7 +651,8 @@ const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
651
651
  /**
652
652
  * DI token for the {@link import("@aooth/auth/authz").ClientRedirectPolicy} — an
653
653
  * interface, so it has no class reference to inject by. Provide the concrete
654
- * policy (e.g. `new LoopbackClientPolicy()`) under this string.
654
+ * policy (e.g. `new LoopbackClientPolicy()`, a `RegisteredClientPolicy`, or a
655
+ * `CompositeClientPolicy` of both) under this string.
655
656
  */
656
657
  const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
657
658
  //#endregion
@@ -702,7 +703,10 @@ OAuthRuntime = __decorate([Injectable(), __decorateMetadata("design:paramtypes",
702
703
  const pincodeSendCheckPair = [{
703
704
  id: "pincode-send",
704
705
  condition: (ctx) => !ctx.pin
705
- }, { id: "pincode-check" }];
706
+ }, {
707
+ id: "pincode-check",
708
+ condition: (ctx) => !ctx.aborted
709
+ }];
706
710
  /**
707
711
  * Shared MFA loop — challenge OR enrol. Used by login.flow + invite.start.
708
712
  * Loop exits when `ctx.otp.verified` flips true — set by ANY of: pincode-check (SMS/email challenge),
@@ -728,11 +732,65 @@ const enrollTrioSteps = [
728
732
  id: "enroll-address",
729
733
  condition: (ctx) => !!ctx.mfaEnroll?.method && (ctx.mfaEnroll.method === "sms" || ctx.mfaEnroll.method === "email") && !ctx.mfaEnroll.address
730
734
  },
735
+ {
736
+ id: "enroll-totp-qr",
737
+ condition: (ctx) => ctx.mfaEnroll?.method === "totp" && !ctx.mfaEnroll.qrSeen
738
+ },
731
739
  {
732
740
  id: "enroll-confirm",
733
- condition: (ctx) => !!ctx.mfaEnroll?.method && (ctx.mfaEnroll.method === "totp" || !!ctx.mfaEnroll.address) && !ctx.mfaEnroll.done
741
+ condition: (ctx) => !!ctx.mfaEnroll?.method && (ctx.mfaEnroll.method === "totp" ? !!ctx.mfaEnroll.qrSeen : !!ctx.mfaEnroll.address) && !ctx.mfaEnroll.done
742
+ },
743
+ {
744
+ id: "promote-to-handle",
745
+ condition: (ctx) => !!ctx.mfaEnroll?.done && !ctx.promoteToHandleDone
734
746
  }
735
747
  ];
748
+ /**
749
+ * MFA step-up loop — challenge an EXISTING confirmed factor (no enrolment).
750
+ * Reuses the login challenge steps verbatim, but DELIBERATELY omits
751
+ * `check-trusted-device` and `risk-step-up`: in a management context a trusted
752
+ * device must NOT be allowed to bypass the step-up (that is the whole point of
753
+ * re-verifying before letting the user change/remove a factor). Loop exits when
754
+ * a challenge step flips `ctx.otp.verified`. Used by the standalone add/manage-
755
+ * MFA flow, guarded by `ctx.addMfa.stepUpRequired` (set only when the user has
756
+ * ≥1 confirmed method).
757
+ *
758
+ * The `while` also breaks on `ctx.aborted` so a cancel/exit on the challenge
759
+ * form (e.g. `pincode-check`'s `exit` alt-action, or a customer-added Back on
760
+ * the MFA challenge) terminates the loop instead of spinning the engine's
761
+ * guardless inner loop forever. The paired `{ break: !!aborted }` after this
762
+ * sub-schema in `addMfaFlow` then routes an aborted step-up straight to
763
+ * `finish-add-mfa` (the cancelled terminal) — fail CLOSED: the user reaches no
764
+ * management write without a fresh challenge. (Note: login's `mfaLoopSchema`
765
+ * intentionally does NOT carry this guard — exiting login's challenge loop
766
+ * without a paired failure terminal would risk issuing a session, so that one
767
+ * stays fail-closed via the engine's no-progress stall instead.)
768
+ */
769
+ const mfaStepUpLoop = [{
770
+ while: (ctx) => !ctx.otp?.verified && !ctx.aborted,
771
+ steps: [
772
+ {
773
+ id: "load-enrolled-mfa-methods",
774
+ condition: (ctx) => !ctx.otp?.verified
775
+ },
776
+ {
777
+ id: "select-mfa-method",
778
+ condition: (ctx) => !ctx.otp?.verified
779
+ },
780
+ {
781
+ id: "select-2fa",
782
+ condition: (ctx) => !ctx.otp?.verified && !ctx.mfa?.method && (ctx.mfa?.enrolledMethods?.length ?? 0) > 1
783
+ },
784
+ {
785
+ condition: (ctx) => !ctx.otp?.verified && (ctx.mfa?.method === "sms" || ctx.mfa?.method === "email"),
786
+ steps: pincodeSendCheckPair
787
+ },
788
+ {
789
+ id: "totp-check",
790
+ condition: (ctx) => !ctx.otp?.verified && ctx.mfa?.method === "totp"
791
+ }
792
+ ]
793
+ }];
736
794
  const mfaLoopSchema = [{ id: "prepare-mfa" }, {
737
795
  while: (ctx) => ctx.mfaPolicy?.mode !== "disabled" && !ctx.otp?.verified,
738
796
  steps: [
@@ -826,7 +884,11 @@ const DEFAULT_FORMS = {
826
884
  askPhone: AskPhoneForm,
827
885
  enrollPickMethod: EnrollPickMethodForm,
828
886
  enrollAddress: EnrollAddressForm,
887
+ enrollTotpQr: EnrollTotpQrForm,
829
888
  enrollConfirm: EnrollConfirmForm,
889
+ manageMfa: ManageMfaForm,
890
+ removeMfaConfirm: RemoveMfaConfirmForm,
891
+ passwordReauth: PasswordReauthForm,
830
892
  select2fa: Select2faForm,
831
893
  mfaCode: MfaCodeForm,
832
894
  pincode: PincodeForm,
@@ -996,6 +1058,17 @@ function pickDefined(src, keys) {
996
1058
  }
997
1059
  return any ? out : void 0;
998
1060
  }
1061
+ /**
1062
+ * Thrown by `recoveryPincodeTarget`'s registered (M2) branch when the confirmed
1063
+ * recovery method that `request`'s guard saw has VANISHED before send time
1064
+ * (deleted between the two row loads — the request→send TOCTOU). `pincode-send`
1065
+ * catches it and degrades to the generic anti-enumeration finish instead of
1066
+ * surfacing a distinguishable 500, so a known account can never become
1067
+ * distinguishable from an unknown one on a resend. Recovery-only; the MFA
1068
+ * challenge branch keeps its own `HttpError(500)` (that path is post-password,
1069
+ * so enumeration is moot there).
1070
+ */
1071
+ var RecoveryMethodUnavailableError = class extends Error {};
999
1072
  let AuthWorkflow = class AuthWorkflow {
1000
1073
  opts;
1001
1074
  users;
@@ -1288,6 +1361,30 @@ let AuthWorkflow = class AuthWorkflow {
1288
1361
  };
1289
1362
  }
1290
1363
  /**
1364
+ * Transports the user may NOT change or remove via the manage-MFA flow.
1365
+ * Default: none — every factor is freely manageable. Reached from
1366
+ * `auth/add-mfa/flow` (`prepare-locked-mfa-transports`).
1367
+ *
1368
+ * Override to forbid changing a factor whose value IS a login handle — e.g.
1369
+ * the MFA `email` equals the `@aooth.user.email` handle, so letting the user
1370
+ * swap it here would desync identity. A typical override loads the user and
1371
+ * compares each enrolled channel value against the boot-resolved handle
1372
+ * fields (`getAoothUserHandleSpec(...).emailField` / `.phoneField`):
1373
+ *
1374
+ * ```ts
1375
+ * protected async resolveLockedMfaTransports(ctx: AuthWfCtx): Promise<MfaTransport[]> {
1376
+ * const user = await this.users.getUser(ctx.subject!);
1377
+ * const locked: MfaTransport[] = [];
1378
+ * const email = user.mfa?.methods?.find((m) => m.name === "email" && m.confirmed);
1379
+ * if (email && email.value === (user as { email?: string }).email) locked.push("email");
1380
+ * return locked;
1381
+ * }
1382
+ * ```
1383
+ */
1384
+ resolveLockedMfaTransports(_ctx) {
1385
+ return [];
1386
+ }
1387
+ /**
1291
1388
  * Resolve the channel-OTP disclosure copy rendered beneath the email/phone
1292
1389
  * input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
1293
1390
  * Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
@@ -1386,12 +1483,114 @@ let AuthWorkflow = class AuthWorkflow {
1386
1483
  };
1387
1484
  });
1388
1485
  }
1389
- return {
1390
- address: ctx.email ?? "",
1391
- channel: "email"
1486
+ const sourceResult = this.resolveRecoveryDeliverySource(ctx);
1487
+ if (sourceResult instanceof Promise) return sourceResult.then((source) => this.recoveryPincodeTarget(ctx, source));
1488
+ return this.recoveryPincodeTarget(ctx, sourceResult);
1489
+ }
1490
+ /**
1491
+ * Resolve the recovery OTP `{ address, channel }` for the chosen delivery
1492
+ * `source`.
1493
+ *
1494
+ * - `"typed"` (M1): the address is the typed recovery identifier (`ctx.email`)
1495
+ * — identifier == destination, so no cross-account redirect — and the
1496
+ * channel comes from `resolveRecoveryChannel` (identifier-shape inference).
1497
+ * - `"registered"` (M2): the address is read off a confirmed MFA method on the
1498
+ * row (`selectRecoveryRegisteredMethod`) and the channel is that method's own
1499
+ * kind. The user only typed an account identifier; the destination is a
1500
+ * pre-verified channel they already control, so this also can't redirect
1501
+ * cross-account. `request`'s M2 guard normally generic-finishes any row with
1502
+ * no deliverable method up front; if the method is deleted in the narrow
1503
+ * window between that guard and this send (e.g. on a resend), this throws
1504
+ * `RecoveryMethodUnavailableError`, which `pincode-send` degrades to the
1505
+ * same generic finish — never a distinguishable 500.
1506
+ */
1507
+ recoveryPincodeTarget(ctx, source) {
1508
+ if (source === "registered") {
1509
+ this.requireSubject(ctx);
1510
+ return this.users.getUser(ctx.subject).then((user) => {
1511
+ const method = this.selectRecoveryRegisteredMethod(user);
1512
+ const channel = method && this.mfaKindOf(method.name);
1513
+ if (!method || channel !== "sms" && channel !== "email") throw new RecoveryMethodUnavailableError();
1514
+ return {
1515
+ address: method.value,
1516
+ channel
1517
+ };
1518
+ });
1519
+ }
1520
+ const address = ctx.email ?? "";
1521
+ const channel = this.resolveRecoveryChannel(ctx);
1522
+ return channel instanceof Promise ? channel.then((c) => ({
1523
+ address,
1524
+ channel: c
1525
+ })) : {
1526
+ address,
1527
+ channel
1392
1528
  };
1393
1529
  }
1394
1530
  /**
1531
+ * Recovery OTP delivery channel. The address is ALWAYS the typed recovery
1532
+ * identifier (`ctx.email`) — symmetric with how email recovery already works:
1533
+ * the OTP goes to the value the user typed, which is the handle that resolved
1534
+ * the account (`findByHandle`), so identifier == destination and there is no
1535
+ * cross-account redirect. Default `"email"`. A deployment whose recovery form
1536
+ * accepts a phone overrides this to route SMS (e.g. infer from the identifier
1537
+ * shape) — see the demo's `DemoAuthWorkflow`. Recovery picks ONE channel per
1538
+ * run, so the single `resendAllowedAt` cooldown gate still suffices.
1539
+ */
1540
+ resolveRecoveryChannel(_ctx) {
1541
+ return "email";
1542
+ }
1543
+ /**
1544
+ * Recovery OTP delivery model. Two options:
1545
+ *
1546
+ * - `"typed"` (default — M1): the OTP goes to the recovery identifier the user
1547
+ * types. Identifier == destination, so there is no cross-account redirect;
1548
+ * `resolveRecoveryChannel` picks email vs SMS from the identifier shape.
1549
+ * - `"registered"` (M2): the user enters only an account identifier (e.g. a
1550
+ * username) and the OTP is delivered to a channel **already verified on the
1551
+ * row** — `selectRecoveryRegisteredMethod` picks the confirmed MFA method;
1552
+ * the destination is never taken from user input, so it cannot be redirected
1553
+ * to an attacker-controlled address. A row with no deliverable confirmed
1554
+ * method finishes with the generic anti-enumeration envelope (see `request`).
1555
+ *
1556
+ * Consulted inline by `request` (no-method guard) and `recoveryPincodeTarget`
1557
+ * — no `prepare-*` step, mirroring `resolveRecoveryChannel`. Override to arm
1558
+ * M2 (per-tenant / per-variant); see the demo's `DemoAuthWorkflow`.
1559
+ */
1560
+ resolveRecoveryDeliverySource(_ctx) {
1561
+ return "typed";
1562
+ }
1563
+ /**
1564
+ * Pick the confirmed MFA method a registered-channel recovery (M2) delivers
1565
+ * its OTP to. Prefers a confirmed SMS method, then a confirmed email method —
1566
+ * phone-recovery-first. TOTP carries no deliverable address and is skipped.
1567
+ * Returns `null` when the row has no deliverable confirmed method; the caller
1568
+ * turns that into the anti-enumeration generic finish. Stays sync (operates on
1569
+ * an already-loaded row); override to change the selection policy (e.g. honour
1570
+ * the user's `mfa.defaultMethod`).
1571
+ */
1572
+ selectRecoveryRegisteredMethod(user) {
1573
+ const methods = user.mfa?.methods ?? [];
1574
+ const pick = (kind) => methods.find((m) => m.confirmed && !!m.value && this.mfaKindOf(m.name) === kind);
1575
+ return pick("sms") ?? pick("email") ?? null;
1576
+ }
1577
+ /**
1578
+ * Decide which login-handle column a freshly-confirmed channel should be
1579
+ * promoted into — so a verified email/phone becomes a login + recovery
1580
+ * handle (`findByHandle`) automatically. Returns the target field name, or
1581
+ * `undefined` to NOT promote (the default).
1582
+ *
1583
+ * Default is OFF: the handle columns are declared via `@aooth.user.*`
1584
+ * annotations on the consumer's concrete model and resolved ONCE at boot
1585
+ * (`@aooth/arbac-moost`'s `getAoothUserHandleSpec`) — `AuthWorkflow` holds no
1586
+ * handle to that model and stays off the per-request reflection path. A
1587
+ * deployment turns promotion ON by overriding this to return the
1588
+ * boot-resolved `emailField` / `phoneField` for the channel — see the demo's
1589
+ * `DemoAuthWorkflow`. `channel` is the wire protocol (`'email'` | `'sms'`),
1590
+ * matching `resolveOtpDisclosure` / the MFA transport.
1591
+ */
1592
+ resolvePromoteHandleField(_ctx, _channel) {}
1593
+ /**
1395
1594
  * Route a form alt-action click to a canonical outcome. Defaults match the
1396
1595
  * action ids the bundled `PincodeForm` declares; customers override per
1397
1596
  * form when adding new actions or remapping the canonical ones.
@@ -1482,6 +1681,10 @@ let AuthWorkflow = class AuthWorkflow {
1482
1681
  ]);
1483
1682
  if (sub) pub.mfaEnroll = sub;
1484
1683
  }
1684
+ if (ctx.addMfa) {
1685
+ const sub = pickDefined(ctx.addMfa, ["candidates", "locked"]);
1686
+ if (sub) pub.manage = sub;
1687
+ }
1485
1688
  if (ctx.defaults) {
1486
1689
  const sub = pickDefined(ctx.defaults, ["email"]);
1487
1690
  if (sub) pub.defaults = sub;
@@ -1619,25 +1822,104 @@ let AuthWorkflow = class AuthWorkflow {
1619
1822
  });
1620
1823
  }
1621
1824
  /**
1622
- * Cleanup any partially-persisted enrolment state (unconfirmed method row +
1623
- * ctx scratch). Called when the user picks `skip` or `useDifferentMethod`
1624
- * mid-flow on `enrollConfirm`, where the unconfirmed method has already
1625
- * been written via `addMfaMethod` (in `enrollPickMethod` for totp /
1626
- * `enrollAddress` for sms+email).
1825
+ * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
1826
+ * the pincode timers/`sentTo`) off ctx the shared teardown used by the
1827
+ * opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
1828
+ * Does NOT touch the user record: the enrol trio stages every candidate value
1829
+ * (sms/email address, totp secret) in wf-state and writes it to the store ONLY
1830
+ * on confirm (write-on-confirm), so a bailed enrolment never persisted a
1831
+ * partial row to undo.
1627
1832
  */
1628
- async cleanupEnrollment(ctx, username) {
1833
+ clearEnrollScratch(ctx) {
1629
1834
  const m = ctx.mfaEnroll;
1630
1835
  if (m) {
1631
- if (m.method) await this.withStoreErrorTranslation(() => this.users.removeMfaMethod(username, m.method));
1632
1836
  delete m.method;
1633
1837
  delete m.address;
1634
1838
  delete m.secret;
1635
1839
  delete m.uri;
1840
+ delete m.qrSeen;
1636
1841
  }
1637
1842
  delete ctx.pin;
1638
1843
  delete ctx.pinExpire;
1639
1844
  if (ctx.pincode) delete ctx.pincode.sentTo;
1640
1845
  }
1846
+ /**
1847
+ * Validate a user-typed MFA address for its transport. Server-side counterpart
1848
+ * to `EnrollAddressForm`'s client `@ui.form.validate` hint — the authoritative
1849
+ * check (a client can bypass the hint). Returns an error string for the form,
1850
+ * or `undefined` when valid. Email must look like an email; SMS is permissive
1851
+ * E.164-ish (normalized by {@link normalizeMfaAddress}). Override for stricter
1852
+ * (e.g. libphonenumber) validation.
1853
+ */
1854
+ validateMfaAddress(method, value) {
1855
+ const v = (value ?? "").trim();
1856
+ if (!v) return "This field is required";
1857
+ if (method === "email") return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? void 0 : "Enter a valid email address";
1858
+ if (method === "sms") {
1859
+ const digits = v.replace(/[\s()+.-]/g, "");
1860
+ return /^[1-9]\d{6,14}$/.test(digits) ? void 0 : "Enter a valid phone number";
1861
+ }
1862
+ }
1863
+ /**
1864
+ * Light normalization of a validated MFA address before it is stored. Default:
1865
+ * trims, and strips spacing/punctuation from SMS numbers while KEEPING the
1866
+ * leading `+` (E.164 canonical form). Email is just trimmed. Override for full
1867
+ * E.164 canonicalization.
1868
+ */
1869
+ normalizeMfaAddress(method, value) {
1870
+ const v = value.trim();
1871
+ return method === "sms" ? v.replace(/[\s().-]/g, "") : v;
1872
+ }
1873
+ /**
1874
+ * Abort an in-progress manage-MFA enrolment cleanly: drop the wf-state scratch
1875
+ * and set `ctx.aborted` so the schema breaks to `finish-add-mfa` (cancelled
1876
+ * terminal). Nothing to undo in the user record — the manage flow stages every
1877
+ * candidate value (sms/email address, totp secret) in wf-state and writes it
1878
+ * to the store ONLY on confirm (write-on-confirm), so an in-progress
1879
+ * add/change/replace never touched the existing factors. This is exactly why
1880
+ * a cancel — or a crafted `useDifferentMethod` routed here — can never strand
1881
+ * or clobber a live factor.
1882
+ */
1883
+ cancelManageEnrollment(ctx) {
1884
+ this.clearEnrollScratch(ctx);
1885
+ ctx.aborted = true;
1886
+ }
1887
+ /**
1888
+ * Shared skip / cancel / useDifferentMethod triage for the enrol-trio steps
1889
+ * that pause after the candidate value is staged (`enroll-totp-qr` +
1890
+ * `enroll-confirm`): `cancel` / `useDifferentMethod` (manage) abort the flow,
1891
+ * `skip` (opt-in) and `useDifferentMethod` (opt-in) drop the wf-state scratch.
1892
+ * Returns `true` when the action terminated the step so the caller can
1893
+ * `return undefined`. (`enroll-pick-method` / `enroll-address` keep their own
1894
+ * preludes — their skip/useDifferentMethod arms diverge from this one.)
1895
+ *
1896
+ * SECURITY: in `'manage'` mode BOTH `cancel` and `useDifferentMethod` (the
1897
+ * manage forms HIDE the latter but it stays in their declared action
1898
+ * whitelist, so a crafted resume can still send it) route through the abort,
1899
+ * which only clears scratch. Because the enrol trio writes the user record
1900
+ * ONLY on confirm (write-on-confirm), an in-progress add/change has touched
1901
+ * nothing in the store — so a cancel/useDifferentMethod can never strand or
1902
+ * clobber the user's live factor, by construction.
1903
+ */
1904
+ handleEnrollExit(ctx, action) {
1905
+ const m = ctx.mfaEnroll ??= {};
1906
+ const mode = m.mode ?? "optional";
1907
+ if (mode === "manage" && (action === "cancel" || action === "useDifferentMethod")) {
1908
+ this.cancelManageEnrollment(ctx);
1909
+ return true;
1910
+ }
1911
+ if (mode === "optional" && action === "skip") {
1912
+ this.clearEnrollScratch(ctx);
1913
+ m.done = true;
1914
+ (ctx.otp ??= {}).verified = true;
1915
+ return true;
1916
+ }
1917
+ if (action === "useDifferentMethod") {
1918
+ this.clearEnrollScratch(ctx);
1919
+ return true;
1920
+ }
1921
+ return false;
1922
+ }
1641
1923
  initLogin(ctx) {
1642
1924
  const state = getInputField("state");
1643
1925
  if (state) {
@@ -1682,23 +1964,25 @@ let AuthWorkflow = class AuthWorkflow {
1682
1964
  password.intro = "Enter your current password, then choose a new one.";
1683
1965
  }
1684
1966
  /**
1685
- * Bind the standalone "add an MFA method" flow to the CURRENT authenticated
1686
- * user and narrow enrolment to the transports they have NOT enrolled yet.
1687
- * Identity comes from the session (`useAuth().getUserId()`) — never form input
1688
- * so it is structurally "add a factor to MY account". Mirrors
1689
- * `init-change-password`'s arbac gate (`auth.add-mfa` / `self`): a customer
1690
- * enables the feature with a single `allow("auth.add-mfa", "*")` grant and
1691
- * forbids it by omitting it. `getUserId()` throws 401 if unauthenticated —
1692
- * defence in depth on top of the guarded trigger route.
1967
+ * Bind the standalone "Manage two-factor authentication" flow (add / change /
1968
+ * remove) to the CURRENT authenticated user. Identity comes from the session
1969
+ * (`useAuth().getUserId()`) — never form input — so it is structurally "manage
1970
+ * MY factors". Mirrors `init-change-password`'s arbac gate (`auth.add-mfa` /
1971
+ * `self`): a customer enables the feature with a single
1972
+ * `allow("auth.add-mfa", "*")` grant and forbids it by omitting it.
1973
+ * `getUserId()` throws 401 if unauthenticated — defence in depth on top of the
1974
+ * guarded trigger route.
1693
1975
  *
1694
- * Drives the REUSED enrol trio (`enroll-pick-method` / `enroll-address` /
1695
- * `enroll-confirm`) by setting `ctx.mfaPolicy.availableTransports` to the
1696
- * un-enrolled remainder so the picker offers only those and auto-picks when
1697
- * exactly one remains. Forces `mode: "optional"` (the user opted in; an empty
1698
- * remainder must finish gracefully, never 500 as `required` would). The
1699
- * remainder is stashed on `ctx.addMfa.candidates` (flow discriminator + finish
1700
- * summary); when the user already has a default, `enroll-confirm` is asked to
1701
- * keep it (`mfaEnroll.keepExistingDefault`).
1976
+ * Sets `ctx.mfaPolicy.availableTransports` to the FULL policy set (so the
1977
+ * step-up's `load-enrolled-mfa-methods` can see the confirmed factors to
1978
+ * challenge) and tracks the un-enrolled `candidates` separately on
1979
+ * `ctx.addMfa` for the menu's Add options. `stepUpRequired` is set when the
1980
+ * user has ANY confirmed factor gating both the step-up and the management
1981
+ * menu; a zero-MFA user skips both and falls through to the first-time enrol
1982
+ * picker (the opt-in path). `stepUpMode` picks the step-up method: `'mfa'`
1983
+ * when a confirmed factor is still challengeable, else `'password'` (a
1984
+ * password re-auth fallback for an orphaned factor). Puts the enrol forms in
1985
+ * `'manage'` mode (Cancel, not "Skip for now") and keeps the existing default.
1702
1986
  */
1703
1987
  async initAddMfa(ctx) {
1704
1988
  const username = useAuth().getUserId();
@@ -1707,15 +1991,23 @@ let AuthWorkflow = class AuthWorkflow {
1707
1991
  const policy = policyResult instanceof Promise ? await policyResult : policyResult;
1708
1992
  const all = policy.availableTransports;
1709
1993
  const user = await this.users.getUser(username);
1710
- const enrolled = new Set((user.mfa?.methods ?? []).filter((m) => m.confirmed).map((m) => m.name));
1711
- const remaining = all.filter((t) => !enrolled.has(t));
1994
+ const enrolledKinds = new Set(this.users.getAvailableMfaMethods(user.mfa).map((m) => this.mfaKindOf(m.name)).filter((k) => k !== null));
1995
+ const candidates = all.filter((t) => !enrolledKinds.has(t));
1996
+ const challengeable = [...enrolledKinds].filter((k) => all.includes(k));
1997
+ const hasFactors = enrolledKinds.size > 0;
1712
1998
  ctx.mfaPolicy = {
1713
- mode: "optional",
1714
- availableTransports: remaining,
1999
+ mode: policy.mode,
2000
+ availableTransports: all,
1715
2001
  issuer: policy.issuer
1716
2002
  };
1717
- ctx.addMfa = { candidates: remaining };
1718
- if (user.mfa?.defaultMethod) (ctx.mfaEnroll ??= {}).keepExistingDefault = true;
2003
+ ctx.addMfa = {
2004
+ candidates,
2005
+ stepUpRequired: hasFactors,
2006
+ stepUpMode: challengeable.length > 0 ? "mfa" : "password"
2007
+ };
2008
+ const m = ctx.mfaEnroll ??= {};
2009
+ m.mode = "manage";
2010
+ if (user.mfa?.defaultMethod) m.keepExistingDefault = true;
1719
2011
  }
1720
2012
  async credentials(ctx) {
1721
2013
  const altResult = this.resolveAlternateCredentials(ctx);
@@ -1829,6 +2121,14 @@ let AuthWorkflow = class AuthWorkflow {
1829
2121
  this.finishGenericRecovery();
1830
2122
  return;
1831
2123
  }
2124
+ const sourceResult = this.resolveRecoveryDeliverySource(ctx);
2125
+ if ((sourceResult instanceof Promise ? await sourceResult : sourceResult) === "registered") {
2126
+ const user = await this.users.getUser(subject);
2127
+ if (!this.selectRecoveryRegisteredMethod(user)) {
2128
+ this.finishGenericRecovery();
2129
+ return;
2130
+ }
2131
+ }
1832
2132
  ctx.subject = subject;
1833
2133
  swapStrategy("store");
1834
2134
  }
@@ -2345,11 +2645,10 @@ let AuthWorkflow = class AuthWorkflow {
2345
2645
  });
2346
2646
  }
2347
2647
  /**
2348
- * Terminal for the add-MFA flow. The user KEEPS their current session (no
2349
- * re-issue, no cookies) — this is a plain data finish. `mfaEnroll.done &&
2350
- * mfaEnroll.method` is the success signal: a real confirm keeps `.method`,
2351
- * whereas a cancel runs `cleanupEnrollment` (which deletes it). An empty
2352
- * `addMfa.candidates` distinguishes "nothing left to add" from a user cancel.
2648
+ * Terminal for the manage-MFA flow. The user KEEPS their current session (no
2649
+ * re-issue, no cookies) — a plain data finish. Outcomes, in priority order:
2650
+ * removed changed (`replace` + done) added (done) → nothing-available
2651
+ * (zero candidates, never had to step-up) cancelled.
2353
2652
  */
2354
2653
  finishAddMfa(ctx) {
2355
2654
  const labels = {
@@ -2357,10 +2656,34 @@ let AuthWorkflow = class AuthWorkflow {
2357
2656
  email: "Email code",
2358
2657
  sms: "Text-message code"
2359
2658
  };
2360
- const method = ctx.mfaEnroll?.method;
2361
- const candidates = ctx.addMfa?.candidates ?? [];
2659
+ const m = ctx.mfaEnroll;
2660
+ const addMfa = ctx.addMfa;
2661
+ const method = m?.method;
2662
+ const candidates = addMfa?.candidates ?? [];
2362
2663
  let envelope;
2363
- if (ctx.mfaEnroll?.done && method) envelope = {
2664
+ if (addMfa?.removed) envelope = {
2665
+ finished: true,
2666
+ data: {
2667
+ removed: true,
2668
+ method: addMfa.removed
2669
+ },
2670
+ message: {
2671
+ level: "success",
2672
+ text: `${labels[addMfa.removed]} removed.`
2673
+ }
2674
+ };
2675
+ else if (m?.done && method && addMfa?.action === "replace") envelope = {
2676
+ finished: true,
2677
+ data: {
2678
+ changed: true,
2679
+ method
2680
+ },
2681
+ message: {
2682
+ level: "success",
2683
+ text: `${labels[method]} updated.`
2684
+ }
2685
+ };
2686
+ else if (m?.done && method) envelope = {
2364
2687
  finished: true,
2365
2688
  data: {
2366
2689
  added: true,
@@ -2371,7 +2694,7 @@ let AuthWorkflow = class AuthWorkflow {
2371
2694
  text: `${labels[method]} added.`
2372
2695
  }
2373
2696
  };
2374
- else if (candidates.length === 0) envelope = {
2697
+ else if (candidates.length === 0 && !addMfa?.stepUpRequired) envelope = {
2375
2698
  finished: true,
2376
2699
  data: {
2377
2700
  added: false,
@@ -2390,7 +2713,7 @@ let AuthWorkflow = class AuthWorkflow {
2390
2713
  },
2391
2714
  message: {
2392
2715
  level: "info",
2393
- text: "No authentication method was added."
2716
+ text: "No changes were made to your two-factor methods."
2394
2717
  }
2395
2718
  };
2396
2719
  useWfFinished().set({
@@ -2398,6 +2721,124 @@ let AuthWorkflow = class AuthWorkflow {
2398
2721
  value: envelope
2399
2722
  });
2400
2723
  }
2724
+ /**
2725
+ * Resolve which transports the user may NOT change/remove via the manage flow
2726
+ * (calls {@link resolveLockedMfaTransports}) and write them to
2727
+ * `ctx.addMfa.locked`. Mirrors the `prepare-<group>` convention.
2728
+ */
2729
+ prepareLockedMfaTransports(ctx) {
2730
+ const result = this.resolveLockedMfaTransports(ctx);
2731
+ const apply = (locked) => {
2732
+ (ctx.addMfa ??= {}).locked = locked;
2733
+ };
2734
+ if (result instanceof Promise) return result.then(apply);
2735
+ return apply(result);
2736
+ }
2737
+ /**
2738
+ * Fires once the step-up factor verifies — anchor the rest of the flow in the
2739
+ * durable `store` strategy (mirrors login's swap-after-credentials): the
2740
+ * pincode becomes single-use server state and the staged new factor lives
2741
+ * server-side instead of in the SPA-held encapsulated token. Degrades to
2742
+ * encapsulated when no durable store is wired (the registry default).
2743
+ */
2744
+ manageStepUpDone(ctx) {
2745
+ swapStrategy("store");
2746
+ (ctx.addMfa ??= {}).stepUpDone = true;
2747
+ }
2748
+ /**
2749
+ * Manage-MFA password re-auth — the step-up FALLBACK when the user's only
2750
+ * confirmed factor(s) are of kinds the policy no longer allows, so nothing is
2751
+ * MFA-challengeable (`addMfa.stepUpMode === "password"`; see `init-add-mfa`).
2752
+ * Pauses on `PasswordReauthForm`, verifies the account password via
2753
+ * `UserService.verifyPassword`, and on success flips `ctx.otp.verified` — the
2754
+ * SAME step-up success signal `mfaStepUpLoop` sets — so `manage-stepup-done`
2755
+ * (swap-to-store) and `manage-menu` proceed identically. `cancel` aborts to
2756
+ * the cancelled terminal (fail closed: no management write without a fresh
2757
+ * proof of identity). Only ARBAC-gated callers reach it (session-bound
2758
+ * subject), and `verifyPassword` is the same check `changePassword` enforces.
2759
+ */
2760
+ async managePasswordReauth(ctx) {
2761
+ this.requireSubject(ctx);
2762
+ const username = ctx.subject;
2763
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.passwordReauth);
2764
+ if (wf.resolveAction() === "cancel") {
2765
+ ctx.aborted = true;
2766
+ return;
2767
+ }
2768
+ const input = wf.resolveInput();
2769
+ if (!await this.users.verifyPassword(username, input.password)) throw this.throwPublic(ctx, wf, { errors: { password: "Incorrect password" } });
2770
+ (ctx.otp ??= {}).verified = true;
2771
+ }
2772
+ /**
2773
+ * Manage-MFA menu — pauses on `ManageMfaForm` and routes the chosen
2774
+ * `operation` (`add:<t>` / `replace:<t>` / `remove:<t>`). Only reached when
2775
+ * the user has ≥1 confirmed factor (a zero-MFA user goes straight to the enrol
2776
+ * picker). Re-checks the locked set + candidate membership server-side, then
2777
+ * sets `ctx.addMfa.action`/`target` (and pre-seeds `mfaEnroll.method` for
2778
+ * add/change). `cancel`, or nothing actionable, aborts to the finish terminal.
2779
+ */
2780
+ async manageMenu(ctx) {
2781
+ this.requireSubject(ctx);
2782
+ const addMfa = ctx.addMfa ??= {};
2783
+ const enrolled = ctx.mfa?.enrolledMethods ?? [];
2784
+ const locked = new Set(addMfa.locked ?? []);
2785
+ const candidates = addMfa.candidates ?? [];
2786
+ const changeable = enrolled.filter((e) => !locked.has(e.kind));
2787
+ if (candidates.length === 0 && changeable.length === 0) {
2788
+ ctx.aborted = true;
2789
+ return;
2790
+ }
2791
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.manageMfa);
2792
+ if (wf.resolveAction() === "cancel") {
2793
+ ctx.aborted = true;
2794
+ return;
2795
+ }
2796
+ const input = wf.resolveInput();
2797
+ const sep = input.operation.indexOf(":");
2798
+ const action = sep >= 0 ? input.operation.slice(0, sep) : "";
2799
+ const target = sep >= 0 ? input.operation.slice(sep + 1) : "";
2800
+ if (action === "add") {
2801
+ if (!candidates.includes(target)) throw this.throwPublic(ctx, wf, { errors: { operation: "That method isn't available" } });
2802
+ } else if (action === "replace" || action === "remove") {
2803
+ if (locked.has(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be changed here." });
2804
+ if (!enrolled.some((e) => e.kind === target)) throw this.throwPublic(ctx, wf, { errors: { operation: "Unknown method" } });
2805
+ } else throw this.throwPublic(ctx, wf, { errors: { operation: "Choose an option" } });
2806
+ addMfa.action = action;
2807
+ addMfa.target = target;
2808
+ const m = ctx.mfaEnroll ??= {};
2809
+ if (action === "add" || action === "replace") {
2810
+ m.method = target;
2811
+ delete m.address;
2812
+ delete m.qrSeen;
2813
+ delete m.done;
2814
+ delete m.secret;
2815
+ delete m.uri;
2816
+ } else m.method = target;
2817
+ }
2818
+ /**
2819
+ * Manage-MFA remove confirmation. Pauses on `RemoveMfaConfirmForm`; the
2820
+ * 'Remove' submit performs the removal, 'Cancel' aborts. Re-checks the locked
2821
+ * set (defence in depth) and blocks removing the LAST confirmed factor when
2822
+ * the policy mode is `required` (you must keep at least one).
2823
+ */
2824
+ async confirmRemoveMfa(ctx) {
2825
+ this.requireSubject(ctx);
2826
+ const username = ctx.subject;
2827
+ const addMfa = ctx.addMfa ??= {};
2828
+ const target = addMfa.target;
2829
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.removeMfaConfirm);
2830
+ if (wf.resolveAction() === "cancel") {
2831
+ ctx.aborted = true;
2832
+ return;
2833
+ }
2834
+ if ((addMfa.locked ?? []).includes(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be removed here." });
2835
+ const enrolled = ctx.mfa?.enrolledMethods ?? [];
2836
+ if (enrolled.length <= 1 && ctx.mfaPolicy?.mode === "required") throw this.throwPublic(ctx, wf, { formMessage: "You must keep at least one two-factor method." });
2837
+ wf.resolveInput();
2838
+ const methodName = enrolled.find((e) => e.kind === target)?.methodName ?? target;
2839
+ await this.withStoreErrorTranslation(() => this.users.removeMfaMethod(username, methodName));
2840
+ addMfa.removed = target;
2841
+ }
2401
2842
  async askChannel(ctx, channel) {
2402
2843
  this.requireSubject(ctx);
2403
2844
  const isEmail = channel === "email";
@@ -2620,8 +3061,18 @@ let AuthWorkflow = class AuthWorkflow {
2620
3061
  * `resolvePincodeTarget` (which discriminate on `ctx.mfa?.method` presence).
2621
3062
  */
2622
3063
  async pincodeSend(ctx) {
2623
- const targetResult = this.resolvePincodeTarget(ctx);
2624
- const target = targetResult instanceof Promise ? await targetResult : targetResult;
3064
+ let target;
3065
+ try {
3066
+ const targetResult = this.resolvePincodeTarget(ctx);
3067
+ target = targetResult instanceof Promise ? await targetResult : targetResult;
3068
+ } catch (err) {
3069
+ if (err instanceof RecoveryMethodUnavailableError) {
3070
+ ctx.aborted = true;
3071
+ this.finishGenericRecovery();
3072
+ return;
3073
+ }
3074
+ throw err;
3075
+ }
2625
3076
  const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
2626
3077
  if (ctx.mfa?.method) await this.deliver({
2627
3078
  kind: "mfa-pincode",
@@ -2639,7 +3090,7 @@ let AuthWorkflow = class AuthWorkflow {
2639
3090
  });
2640
3091
  else await this.deliver({
2641
3092
  kind: "recovery-pincode",
2642
- channel: "email",
3093
+ channel: target.channel,
2643
3094
  recipient: target.address,
2644
3095
  code,
2645
3096
  expiresInMs: this.opts.mfa.pincodeTtlMs
@@ -2737,19 +3188,21 @@ let AuthWorkflow = class AuthWorkflow {
2737
3188
  /**
2738
3189
  * Unified MFA-enrol phase 1 (pick method). Auto-picks a single transport,
2739
3190
  * otherwise pauses for `EnrollPickMethodForm`. When TOTP is picked, the
2740
- * secret is idempotently provisioned in the same step body. Handles
2741
- * `skip` in `'optional'` mode.
3191
+ * secret is idempotently provisioned in the same step body. Handles `skip`
3192
+ * (optional opt-in) / `cancel` (manage). In the manage flow this only runs
3193
+ * for a zero-MFA user — once the user has factors, the menu pre-seeds
3194
+ * `mfaEnroll.method` (add/change) so the picker is skipped.
2742
3195
  */
2743
3196
  enrollPickMethod(ctx) {
2744
3197
  this.requireSubject(ctx);
2745
3198
  const username = ctx.subject;
2746
3199
  const transports = ctx.mfaPolicy?.availableTransports ?? [];
2747
- const mode = ctx.mfaPolicy?.mode === "required" ? "required" : "optional";
2748
3200
  const m = ctx.mfaEnroll ??= {};
3201
+ const mode = m.mode === "manage" ? "manage" : ctx.mfaPolicy?.mode === "required" ? "required" : "optional";
2749
3202
  m.mode = mode;
2750
3203
  if (!m.availableTransports) m.availableTransports = [...transports];
2751
3204
  if (transports.length === 0) {
2752
- if (mode === "optional") {
3205
+ if (mode !== "required") {
2753
3206
  m.done = true;
2754
3207
  (ctx.otp ??= {}).verified = true;
2755
3208
  return;
@@ -2759,7 +3212,12 @@ let AuthWorkflow = class AuthWorkflow {
2759
3212
  if (transports.length === 1) m.method = transports[0];
2760
3213
  else {
2761
3214
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollPickMethod);
2762
- if (mode === "optional" && wf.resolveAction() === "skip") {
3215
+ const action = wf.resolveAction();
3216
+ if (mode === "manage" && action === "cancel") {
3217
+ ctx.aborted = true;
3218
+ return;
3219
+ }
3220
+ if (mode === "optional" && action === "skip") {
2763
3221
  m.done = true;
2764
3222
  (ctx.otp ??= {}).verified = true;
2765
3223
  return;
@@ -2771,28 +3229,29 @@ let AuthWorkflow = class AuthWorkflow {
2771
3229
  if (m.method === "totp" && !m.secret) {
2772
3230
  const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
2773
3231
  const secret = generateTotpSecret();
2774
- const uri = generateTotpUri(secret, issuer, username);
2775
- return this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
2776
- name: "totp",
2777
- value: secret,
2778
- confirmed: false
2779
- })).then(() => {
2780
- m.secret = secret;
2781
- m.uri = uri;
2782
- });
3232
+ m.secret = secret;
3233
+ m.uri = generateTotpUri(secret, issuer, username);
2783
3234
  }
2784
3235
  }
2785
3236
  /**
2786
3237
  * Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
2787
- * Not invoked for totp. Handles `skip` / `useDifferentMethod`.
3238
+ * Not invoked for totp. Handles `skip` (opt-in) / `cancel` (manage) /
3239
+ * `useDifferentMethod`. Validates the address server-side (the client
3240
+ * `@ui.form.validate` hint is advisory), then STAGES the candidate value in
3241
+ * wf-state (`m.address`) — the user record is written only on confirm
3242
+ * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps the
3243
+ * old confirmed value live until the new code verifies in `enroll-confirm`.
2788
3244
  */
2789
3245
  async enrollAddress(ctx) {
2790
3246
  this.requireSubject(ctx);
2791
- const username = ctx.subject;
2792
3247
  const m = ctx.mfaEnroll ??= {};
2793
3248
  const mode = m.mode ?? "optional";
2794
3249
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollAddress);
2795
3250
  const action = wf.resolveAction();
3251
+ if (mode === "manage" && action === "cancel") {
3252
+ this.cancelManageEnrollment(ctx);
3253
+ return;
3254
+ }
2796
3255
  if (mode === "optional" && action === "skip") {
2797
3256
  m.done = true;
2798
3257
  (ctx.otp ??= {}).verified = true;
@@ -2804,53 +3263,60 @@ let AuthWorkflow = class AuthWorkflow {
2804
3263
  }
2805
3264
  const input = wf.resolveInput();
2806
3265
  const methodName = m.method;
2807
- await this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
2808
- name: methodName,
2809
- value: input.address,
2810
- confirmed: false
2811
- }));
2812
- m.address = input.address;
3266
+ const addrErr = this.validateMfaAddress(methodName, input.address);
3267
+ if (addrErr) throw this.throwPublic(ctx, wf, { errors: { address: addrErr } });
3268
+ const address = this.normalizeMfaAddress(methodName, input.address);
3269
+ m.address = address;
2813
3270
  const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
2814
3271
  const pincode = ctx.pincode ??= {};
2815
3272
  pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
2816
3273
  pincode.codeLength = this.opts.mfa.pincodeLength;
2817
- await this.sendEnrollPincode(ctx, input.address, code);
3274
+ await this.sendEnrollPincode(ctx, address, code);
2818
3275
  }
2819
3276
  /**
2820
- * Unified MFA-enrol phase 3 (verify pincode/TOTP, mark confirmed). On
2821
- * success sets `ctx.mfaEnroll.done = true` AND `ctx.otp.verified = true`
2822
- * (the loop-exit signal enrol-confirm verifies an OTP, so the unified
2823
- * `otp.verified` flag fires alongside the MFA-specific `mfaEnroll.done`).
3277
+ * MFA-enrol TOTP QR step shown on its OWN pause between method-pick and
3278
+ * code-entry (so the user scans first, types the code next). Idempotently
3279
+ * provisions the TOTP secret in wf-state ONLY (covers the auto-pick /
3280
+ * menu-pre-seeded paths where `enroll-pick-method` was skipped), then pauses
3281
+ * on `EnrollTotpQrForm`. The user record is written only on confirm
3282
+ * (write-on-confirm), so a manage **replace** never clobbers the live totp
3283
+ * secret and a cancel/crash leaves the existing factor intact — no stash or
3284
+ * restore needed. Handles `skip` (opt-in) / `cancel` (manage) /
3285
+ * `useDifferentMethod`.
2824
3286
  */
2825
- async enrollConfirm(ctx) {
3287
+ async enrollTotpQr(ctx) {
2826
3288
  this.requireSubject(ctx);
2827
3289
  const username = ctx.subject;
2828
3290
  const m = ctx.mfaEnroll ??= {};
2829
3291
  if (m.method === "totp" && !m.secret) {
2830
3292
  const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
2831
3293
  const secret = generateTotpSecret();
2832
- const uri = generateTotpUri(secret, issuer, username);
2833
- await this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
2834
- name: "totp",
2835
- value: secret,
2836
- confirmed: false
2837
- }));
2838
3294
  m.secret = secret;
2839
- m.uri = uri;
3295
+ m.uri = generateTotpUri(secret, issuer, username);
2840
3296
  }
3297
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollTotpQr);
3298
+ if (this.handleEnrollExit(ctx, wf.resolveAction())) return void 0;
3299
+ wf.resolveInput();
3300
+ m.qrSeen = true;
3301
+ }
3302
+ /**
3303
+ * Unified MFA-enrol phase 3 (verify pincode/TOTP, then write the factor). On
3304
+ * success sets `ctx.mfaEnroll.done = true` AND `ctx.otp.verified = true`
3305
+ * (the loop-exit signal — enrol-confirm verifies an OTP, so the unified
3306
+ * `otp.verified` flag fires alongside the MFA-specific `mfaEnroll.done`).
3307
+ * This is the ONLY place the enrol trio touches the user record
3308
+ * (write-on-confirm): the proven value (sms/email address or totp secret,
3309
+ * staged in wf-state) is upserted as confirmed via `addMfaMethod`, which
3310
+ * atomically swaps in a REPLACE with no pre-confirm clobber window and creates
3311
+ * a fresh row for an ADD.
3312
+ */
3313
+ async enrollConfirm(ctx) {
3314
+ this.requireSubject(ctx);
3315
+ const username = ctx.subject;
3316
+ const m = ctx.mfaEnroll ??= {};
2841
3317
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollConfirm);
2842
- const mode = m.mode ?? "optional";
2843
3318
  const action = wf.resolveAction();
2844
- if (mode === "optional" && action === "skip") {
2845
- await this.cleanupEnrollment(ctx, username);
2846
- m.done = true;
2847
- (ctx.otp ??= {}).verified = true;
2848
- return;
2849
- }
2850
- if (action === "useDifferentMethod") {
2851
- await this.cleanupEnrollment(ctx, username);
2852
- return;
2853
- }
3319
+ if (this.handleEnrollExit(ctx, action)) return void 0;
2854
3320
  if (action === "resend") {
2855
3321
  if (m.method === "totp") throw this.throwPublic(ctx, wf, { formMessage: "Resend is not applicable for TOTP" });
2856
3322
  const cooldown = ctx.pincode?.resendAllowedAt;
@@ -2866,18 +3332,19 @@ let AuthWorkflow = class AuthWorkflow {
2866
3332
  return;
2867
3333
  }
2868
3334
  const input = wf.resolveInput();
2869
- if (m.method === "totp") try {
2870
- await this.users.verifyTotpSetupCode(username, input.code);
2871
- } catch (err) {
2872
- if (err instanceof UserAuthError && err.type === "MFA_INVALID") throw this.throwPublic(ctx, wf, { errors: { code: "Invalid code" } });
2873
- throw err;
2874
- }
2875
- else {
3335
+ if (m.method === "totp") {
3336
+ if (verifyTotpCode(m.secret, input.code) === null) throw this.throwPublic(ctx, wf, { errors: { code: "Invalid code" } });
3337
+ } else {
2876
3338
  const pinErr = this.verifyPin(ctx, input.code);
2877
3339
  if (pinErr) throw this.throwPublic(ctx, wf, { errors: pinErr });
2878
3340
  }
2879
3341
  const methodName = m.method;
2880
- await this.withStoreErrorTranslation(() => this.users.confirmMfaMethod(username, methodName));
3342
+ const value = m.method === "totp" ? m.secret : m.address;
3343
+ await this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
3344
+ name: methodName,
3345
+ value,
3346
+ confirmed: true
3347
+ }));
2881
3348
  if (!m.keepExistingDefault) await this.users.setDefaultMfaMethod(username, methodName);
2882
3349
  m.done = true;
2883
3350
  (ctx.otp ??= {}).verified = true;
@@ -2885,6 +3352,46 @@ let AuthWorkflow = class AuthWorkflow {
2885
3352
  delete ctx.pinExpire;
2886
3353
  }
2887
3354
  /**
3355
+ * Promote a freshly-confirmed channel into its login-handle column so future
3356
+ * login + recovery resolve the account by it (`findByHandle`). Runs once,
3357
+ * right after `enroll-confirm` in the shared enrolment trio (so it covers
3358
+ * add-mfa AND login/invite forced first-time enrolment). Default is a no-op
3359
+ * unless `resolvePromoteHandleField` is overridden to name a handle column.
3360
+ *
3361
+ * Overridable extension point: a deployment can replace this with richer
3362
+ * logic — e.g. pause on a carrier form asking whether to use the new number
3363
+ * as a login handle before writing it.
3364
+ *
3365
+ * Fires only for a freshly-confirmed `email` / `sms` factor carrying an
3366
+ * address. TOTP has no address; a skipped / `useDifferentMethod` enrolment
3367
+ * cleared `method` + `address` via `clearEnrollScratch`, so the guard below
3368
+ * excludes both — only an actually-confirmed channel is promoted.
3369
+ */
3370
+ async promoteToHandle(ctx) {
3371
+ const m = ctx.mfaEnroll;
3372
+ if (ctx.subject && m?.address && (m.method === "email" || m.method === "sms")) {
3373
+ const field = await this.resolvePromoteHandleField(ctx, m.method);
3374
+ if (field) await this.applyHandlePromotion(ctx.subject, field, m.address);
3375
+ }
3376
+ ctx.promoteToHandleDone = true;
3377
+ }
3378
+ /**
3379
+ * Best-effort write of a confirmed channel value into its handle column.
3380
+ * Swallows `ALREADY_EXISTS` — the value is already a handle on ANOTHER
3381
+ * account (e.g. two accounts legitimately sharing one phone for MFA): the
3382
+ * second account keeps the factor as MFA-only and is simply not promoted.
3383
+ * Any other store error propagates. (`UserService.update` translates a
3384
+ * unique-index `CONFLICT` to `ALREADY_EXISTS` for both store adapters.)
3385
+ */
3386
+ async applyHandlePromotion(subject, field, value) {
3387
+ try {
3388
+ await this.users.update(subject, { [field]: value });
3389
+ } catch (err) {
3390
+ if (err instanceof UserAuthError && err.type === "ALREADY_EXISTS") return;
3391
+ throw err;
3392
+ }
3393
+ }
3394
+ /**
2888
3395
  * Risk step-up: re-evaluate whether to require another MFA round. Default
2889
3396
  * `resolveRiskStepUp` returns `{require: false}`. When `require: true`,
2890
3397
  * clear `ctx.otp.verified` to re-arm the loop.
@@ -3123,6 +3630,11 @@ let AuthWorkflow = class AuthWorkflow {
3123
3630
  codeChallenge: req.codeChallenge,
3124
3631
  redirectUri: req.redirectUri,
3125
3632
  ...req.clientId !== void 0 && { clientId: req.clientId },
3633
+ ...req.scope !== void 0 && { scope: req.scope },
3634
+ ...req.nonce !== void 0 && { nonce: req.nonce },
3635
+ ...req.idToken !== void 0 && { idToken: req.idToken },
3636
+ ...req.accessToken !== void 0 && { accessToken: req.accessToken },
3637
+ ...req.audience !== void 0 && { audience: req.audience },
3126
3638
  tokenPolicy: req.tokenPolicy
3127
3639
  });
3128
3640
  await pending.delete(handle);
@@ -3691,20 +4203,35 @@ let AuthWorkflow = class AuthWorkflow {
3691
4203
  */
3692
4204
  changePasswordFlow() {}
3693
4205
  /**
3694
- * add-mfa.flow — authenticated self-service "add a second factor". Same
3695
- * gating model as change-password: NOT `@Public()` `init-add-mfa` is arbac-
3696
- * gated (`auth.add-mfa` / `self`) and binds `ctx.subject` from the session, so
3697
- * an unauthenticated / unauthorized caller is rejected at the first step. NOT
3698
- * in `DEFAULT_AUTH_WORKFLOWS` reached only via the GUARDED trigger route
4206
+ * add-mfa.flow — authenticated self-service "Manage two-factor
4207
+ * authentication" (add / change / remove). Same gating model as
4208
+ * change-password: NOT `@Public()` `init-add-mfa` is arbac-gated
4209
+ * (`auth.add-mfa` / `self`) and binds `ctx.subject` from the session, so an
4210
+ * unauthenticated / unauthorized caller is rejected at the first step. NOT in
4211
+ * `DEFAULT_AUTH_WORKFLOWS` — reached only via the GUARDED trigger route
3699
4212
  * (`AuthController.addMfa`), never the public `/auth/trigger`.
3700
4213
  *
3701
- * The body REUSES the login/invite forced-enrolment trio verbatim
3702
- * (`enroll-pick-method` `enroll-address` `enroll-confirm`); the only
3703
- * difference is the driver: `init-add-mfa` narrows `ctx.mfaPolicy`
3704
- * `availableTransports` to the transports the user has NOT enrolled, so the
3705
- * picker offers exactly those and auto-picks when one remains. Available only
3706
- * when something is un-enrolled — with everything enrolled the trio is skipped
3707
- * and `finish-add-mfa` returns a benign "nothing to add" terminal.
4214
+ * Shape:
4215
+ * 1. `init-add-mfa` bind subject, resolve the FULL transport set + the
4216
+ * un-enrolled `candidates`, mark `stepUpRequired` when the user already has
4217
+ * ≥1 confirmed factor, and put the enrol forms in `'manage'` mode.
4218
+ * 2. `prepare-locked-mfa-transports` resolve which factors the consumer
4219
+ * forbids changing (handle-bound email/phone).
4220
+ * 3. STEP-UP (only when `stepUpRequired`): re-verify identity before any
4221
+ * change — `mfaStepUpLoop` challenges an EXISTING factor when one is still
4222
+ * challengeable (`stepUpMode==='mfa'`), else `manage-password-reauth` falls
4223
+ * back to the account password (`stepUpMode==='password'`). On success
4224
+ * `manage-stepup-done` swaps off the encapsulated start onto the durable
4225
+ * `store` strategy (server-anchored, replay-resistant; mirrors login's
4226
+ * swap-after-credentials).
4227
+ * 4. `manage-menu` (only when `stepUpRequired`) — pick add / change / remove +
4228
+ * target; pre-seeds `mfaEnroll.method` for add/change.
4229
+ * 5. Route: `confirm-remove-mfa` for remove; otherwise the REUSED enrol trio
4230
+ * (`enroll-pick-method` → `enroll-address` / `enroll-totp-qr` →
4231
+ * `enroll-confirm`). A zero-MFA user skips step-up + menu and lands on the
4232
+ * enrol picker directly (the first-time opt-in path).
4233
+ * 6. `finish-add-mfa` — added / changed / removed / cancelled / nothing terminal.
4234
+ * The user KEEPS their session (no token re-issue).
3708
4235
  */
3709
4236
  addMfaFlow() {}
3710
4237
  /**
@@ -4059,6 +4586,51 @@ __decorate([
4059
4586
  __decorateMetadata("design:paramtypes", [Object]),
4060
4587
  __decorateMetadata("design:returntype", void 0)
4061
4588
  ], AuthWorkflow.prototype, "finishAddMfa", null);
4589
+ __decorate([
4590
+ Step("prepare-locked-mfa-transports"),
4591
+ ArbacResource("auth.add-mfa"),
4592
+ ArbacAction("self"),
4593
+ __decorateParam(0, WorkflowParam("context")),
4594
+ __decorateMetadata("design:type", Function),
4595
+ __decorateMetadata("design:paramtypes", [Object]),
4596
+ __decorateMetadata("design:returntype", Object)
4597
+ ], AuthWorkflow.prototype, "prepareLockedMfaTransports", null);
4598
+ __decorate([
4599
+ Step("manage-stepup-done"),
4600
+ ArbacResource("auth.add-mfa"),
4601
+ ArbacAction("self"),
4602
+ __decorateParam(0, WorkflowParam("context")),
4603
+ __decorateMetadata("design:type", Function),
4604
+ __decorateMetadata("design:paramtypes", [Object]),
4605
+ __decorateMetadata("design:returntype", void 0)
4606
+ ], AuthWorkflow.prototype, "manageStepUpDone", null);
4607
+ __decorate([
4608
+ Step("manage-password-reauth"),
4609
+ ArbacResource("auth.add-mfa"),
4610
+ ArbacAction("self"),
4611
+ __decorateParam(0, WorkflowParam("context")),
4612
+ __decorateMetadata("design:type", Function),
4613
+ __decorateMetadata("design:paramtypes", [Object]),
4614
+ __decorateMetadata("design:returntype", Promise)
4615
+ ], AuthWorkflow.prototype, "managePasswordReauth", null);
4616
+ __decorate([
4617
+ Step("manage-menu"),
4618
+ ArbacResource("auth.add-mfa"),
4619
+ ArbacAction("self"),
4620
+ __decorateParam(0, WorkflowParam("context")),
4621
+ __decorateMetadata("design:type", Function),
4622
+ __decorateMetadata("design:paramtypes", [Object]),
4623
+ __decorateMetadata("design:returntype", Promise)
4624
+ ], AuthWorkflow.prototype, "manageMenu", null);
4625
+ __decorate([
4626
+ Step("confirm-remove-mfa"),
4627
+ ArbacResource("auth.add-mfa"),
4628
+ ArbacAction("self"),
4629
+ __decorateParam(0, WorkflowParam("context")),
4630
+ __decorateMetadata("design:type", Function),
4631
+ __decorateMetadata("design:paramtypes", [Object]),
4632
+ __decorateMetadata("design:returntype", Promise)
4633
+ ], AuthWorkflow.prototype, "confirmRemoveMfa", null);
4062
4634
  __decorate([
4063
4635
  Step("ask/:channel(email|phone)"),
4064
4636
  Public(),
@@ -4149,6 +4721,14 @@ __decorate([
4149
4721
  __decorateMetadata("design:paramtypes", [Object]),
4150
4722
  __decorateMetadata("design:returntype", Promise)
4151
4723
  ], AuthWorkflow.prototype, "enrollAddress", null);
4724
+ __decorate([
4725
+ Step("enroll-totp-qr"),
4726
+ Public(),
4727
+ __decorateParam(0, WorkflowParam("context")),
4728
+ __decorateMetadata("design:type", Function),
4729
+ __decorateMetadata("design:paramtypes", [Object]),
4730
+ __decorateMetadata("design:returntype", Promise)
4731
+ ], AuthWorkflow.prototype, "enrollTotpQr", null);
4152
4732
  __decorate([
4153
4733
  Step("enroll-confirm"),
4154
4734
  Public(),
@@ -4157,6 +4737,14 @@ __decorate([
4157
4737
  __decorateMetadata("design:paramtypes", [Object]),
4158
4738
  __decorateMetadata("design:returntype", Promise)
4159
4739
  ], AuthWorkflow.prototype, "enrollConfirm", null);
4740
+ __decorate([
4741
+ Step("promote-to-handle"),
4742
+ Public(),
4743
+ __decorateParam(0, WorkflowParam("context")),
4744
+ __decorateMetadata("design:type", Function),
4745
+ __decorateMetadata("design:paramtypes", [Object]),
4746
+ __decorateMetadata("design:returntype", Promise)
4747
+ ], AuthWorkflow.prototype, "promoteToHandle", null);
4160
4748
  __decorate([
4161
4749
  Step("risk-step-up"),
4162
4750
  Public(),
@@ -4596,8 +5184,30 @@ __decorate([
4596
5184
  WorkflowSchema([
4597
5185
  { id: "init-add-mfa" },
4598
5186
  { break: (ctx) => !ctx.subject },
5187
+ { id: "prepare-locked-mfa-transports" },
5188
+ {
5189
+ condition: (ctx) => !!ctx.addMfa?.stepUpRequired && ctx.addMfa?.stepUpMode === "mfa",
5190
+ steps: mfaStepUpLoop
5191
+ },
5192
+ {
5193
+ id: "manage-password-reauth",
5194
+ condition: (ctx) => !!ctx.addMfa?.stepUpRequired && ctx.addMfa?.stepUpMode === "password" && !ctx.otp?.verified
5195
+ },
5196
+ { break: (ctx) => !!ctx.aborted },
4599
5197
  {
4600
- condition: (ctx) => (ctx.addMfa?.candidates?.length ?? 0) > 0,
5198
+ id: "manage-stepup-done",
5199
+ condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !!ctx.otp?.verified && !ctx.addMfa?.stepUpDone
5200
+ },
5201
+ {
5202
+ id: "manage-menu",
5203
+ condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
5204
+ },
5205
+ {
5206
+ id: "confirm-remove-mfa",
5207
+ condition: (ctx) => !ctx.aborted && ctx.addMfa?.action === "remove"
5208
+ },
5209
+ {
5210
+ condition: (ctx) => !ctx.aborted && ctx.addMfa?.action !== "remove" && (ctx.addMfa?.action === "replace" || (ctx.addMfa?.candidates?.length ?? 0) > 0),
4601
5211
  steps: enrollTrioSteps
4602
5212
  },
4603
5213
  { id: "finish-add-mfa" }
@@ -5178,6 +5788,8 @@ function toConnectedAccount(i) {
5178
5788
  }
5179
5789
  //#endregion
5180
5790
  //#region src/authz/authorize.controller.ts
5791
+ /** Shared default claims resolver — stateless, so one instance is reused across requests. */
5792
+ const NOOP_OIDC_CLAIMS_RESOLVER = new NoopOidcClaimsResolver();
5181
5793
  let AuthorizeController = class AuthorizeController {
5182
5794
  auth;
5183
5795
  policy;
@@ -5190,6 +5802,22 @@ let AuthorizeController = class AuthorizeController {
5190
5802
  this.codes = codes;
5191
5803
  }
5192
5804
  /**
5805
+ * The Tier-2 OIDC `id_token` signer, or `undefined` for a Tier-1-only (CLI)
5806
+ * deployment — then discovery / `/auth/jwks` 404 and no `id_token` is minted.
5807
+ * **Override** in a subclass to enable OIDC (return one `IdTokenSigner` whose
5808
+ * issuer is `{origin}/auth`). A plain getter rather than a DI token because an
5809
+ * OPTIONAL `@Inject`/`@Optional` dependency panics in moost's `resolveMoost`
5810
+ * route-table pass (`useHandlerPaths`); a method has nothing for it to resolve.
5811
+ */
5812
+ getIdTokenSigner() {}
5813
+ /**
5814
+ * The Tier-2 OIDC profile-claims resolver. Defaults to a no-op (`sub`-only
5815
+ * tokens); **override** to emit `email` / `name` / … from your user record.
5816
+ */
5817
+ getOidcClaimsResolver() {
5818
+ return NOOP_OIDC_CLAIMS_RESOLVER;
5819
+ }
5820
+ /**
5193
5821
  * The SPA login route the authorize request bounces to. The opaque pending-auth
5194
5822
  * `handle` is appended as `?authz=`; the SPA forwards it into the login
5195
5823
  * workflow's START input so `init-login` raises `ctx.authz`. Override for a
@@ -5198,7 +5826,7 @@ let AuthorizeController = class AuthorizeController {
5198
5826
  loginPath() {
5199
5827
  return "/login";
5200
5828
  }
5201
- async authorize(responseType, redirectUri, clientId, state, codeChallenge, codeChallengeMethod, scope) {
5829
+ async authorize(responseType, redirectUri, clientId, state, codeChallenge, codeChallengeMethod, scope, nonce) {
5202
5830
  const res = useResponse(current());
5203
5831
  if (!redirectUri) {
5204
5832
  res.status = 400;
@@ -5208,7 +5836,8 @@ let AuthorizeController = class AuthorizeController {
5208
5836
  try {
5209
5837
  resolved = await this.policy.resolveClient({
5210
5838
  ...clientId !== void 0 && { clientId },
5211
- redirectUri
5839
+ redirectUri,
5840
+ ...scope !== void 0 && { scope }
5212
5841
  });
5213
5842
  } catch (e) {
5214
5843
  res.status = 400;
@@ -5220,7 +5849,11 @@ let AuthorizeController = class AuthorizeController {
5220
5849
  redirectUri: resolved.redirectUri,
5221
5850
  codeChallenge,
5222
5851
  ...state !== void 0 && { clientState: state },
5223
- ...scope !== void 0 && { scope },
5852
+ ...resolved.scope !== void 0 && { scope: resolved.scope },
5853
+ ...nonce !== void 0 && { nonce },
5854
+ ...resolved.idToken !== void 0 && { idToken: resolved.idToken },
5855
+ ...resolved.accessToken !== void 0 && { accessToken: resolved.accessToken },
5856
+ ...resolved.audience !== void 0 && { audience: resolved.audience },
5224
5857
  tokenPolicy: resolved.tokenPolicy
5225
5858
  });
5226
5859
  const loginPath = this.loginPath();
@@ -5231,36 +5864,130 @@ let AuthorizeController = class AuthorizeController {
5231
5864
  }
5232
5865
  async token(body) {
5233
5866
  const res = useResponse(current());
5234
- const grantType = body?.grant_type;
5235
- const code = body?.code;
5236
- const codeVerifier = body?.code_verifier;
5237
- if (grantType !== "authorization_code") {
5867
+ if (body?.grant_type !== "authorization_code") {
5238
5868
  res.status = 400;
5239
5869
  return { error: "unsupported_grant_type" };
5240
5870
  }
5241
- if (!code || !codeVerifier) {
5871
+ if (!body.code || !body.code_verifier) {
5242
5872
  res.status = 400;
5243
5873
  return { error: "invalid_request" };
5244
5874
  }
5245
- const row = await this.codes.consume(code);
5875
+ const row = await this.codes.consume(body.code);
5246
5876
  if (!row) {
5247
5877
  res.status = 400;
5248
5878
  return { error: "invalid_grant" };
5249
5879
  }
5250
- if (pkceChallengeFor(codeVerifier) !== row.codeChallenge) {
5880
+ if (pkceChallengeFor(body.code_verifier) !== row.codeChallenge) {
5251
5881
  res.status = 400;
5252
5882
  return { error: "invalid_grant" };
5253
5883
  }
5254
- const issued = await this.auth.issue(row.userId, tokenPolicyToIssueOptions(row.tokenPolicy));
5255
- const expiresIn = Math.max(0, Math.floor((issued.accessExpiresAt - Date.now()) / 1e3));
5884
+ if (row.clientId !== void 0) {
5885
+ if (body.client_id !== row.clientId) {
5886
+ res.status = 401;
5887
+ return { error: "invalid_client" };
5888
+ }
5889
+ try {
5890
+ await this.policy.authenticateClient?.({
5891
+ clientId: row.clientId,
5892
+ ...body.client_secret !== void 0 && { clientSecret: body.client_secret }
5893
+ });
5894
+ } catch {
5895
+ res.status = 401;
5896
+ return { error: "invalid_client" };
5897
+ }
5898
+ } else if (body.client_id !== void 0) {
5899
+ res.status = 401;
5900
+ return { error: "invalid_client" };
5901
+ }
5902
+ const wantIdToken = row.idToken === true;
5903
+ const wantAccessToken = row.accessToken !== false;
5904
+ if (!wantIdToken && !wantAccessToken) {
5905
+ res.status = 400;
5906
+ return { error: "invalid_request" };
5907
+ }
5908
+ let signing;
5909
+ if (wantIdToken) {
5910
+ const signer = this.getIdTokenSigner();
5911
+ const audience = row.audience ?? row.clientId;
5912
+ if (!signer || audience === void 0) {
5913
+ res.status = 500;
5914
+ return { error: "server_error" };
5915
+ }
5916
+ signing = {
5917
+ signer,
5918
+ audience
5919
+ };
5920
+ }
5921
+ let accessToken;
5922
+ let expiresIn;
5923
+ if (wantAccessToken) {
5924
+ const issued = await this.auth.issue(row.userId, tokenPolicyToIssueOptions(row.tokenPolicy));
5925
+ accessToken = issued.accessToken;
5926
+ expiresIn = Math.max(0, Math.floor((issued.accessExpiresAt - Date.now()) / 1e3));
5927
+ }
5928
+ let idToken;
5929
+ if (signing) {
5930
+ const extra = await this.getOidcClaimsResolver().resolveClaims(row.userId, row.scope);
5931
+ idToken = await signing.signer.sign({
5932
+ sub: row.userId,
5933
+ aud: signing.audience,
5934
+ ...row.nonce !== void 0 && { nonce: row.nonce },
5935
+ extra
5936
+ });
5937
+ }
5256
5938
  res.status = 200;
5257
5939
  return {
5258
- access_token: issued.accessToken,
5259
5940
  token_type: "Bearer",
5260
- expires_in: expiresIn,
5941
+ ...accessToken !== void 0 && {
5942
+ access_token: accessToken,
5943
+ expires_in: expiresIn
5944
+ },
5945
+ ...idToken !== void 0 && { id_token: idToken },
5261
5946
  userId: row.userId
5262
5947
  };
5263
5948
  }
5949
+ /**
5950
+ * OIDC discovery (Tier 2). Derives every endpoint from the signer's `issuer`
5951
+ * (configured as `{origin}/auth`), so a relying `OidcProvider` configured with
5952
+ * the same `issuer` resolves `/authorize`, `/token`, and `/jwks` automatically.
5953
+ * 404 when no signer is wired (Tier-1-only deployment).
5954
+ */
5955
+ discovery() {
5956
+ const res = useResponse(current());
5957
+ const signer = this.getIdTokenSigner();
5958
+ if (!signer) {
5959
+ res.status = 404;
5960
+ return { error: "not_found" };
5961
+ }
5962
+ const iss = signer.issuer;
5963
+ return {
5964
+ issuer: iss,
5965
+ authorization_endpoint: `${iss}/authorize`,
5966
+ token_endpoint: `${iss}/token`,
5967
+ jwks_uri: `${iss}/jwks`,
5968
+ response_types_supported: ["code"],
5969
+ grant_types_supported: ["authorization_code"],
5970
+ subject_types_supported: ["public"],
5971
+ id_token_signing_alg_values_supported: [signer.alg],
5972
+ scopes_supported: [
5973
+ "openid",
5974
+ "email",
5975
+ "profile"
5976
+ ],
5977
+ code_challenge_methods_supported: ["S256"],
5978
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"]
5979
+ };
5980
+ }
5981
+ /** The signer's public JWKS (Tier 2). 404 when no signer is wired. */
5982
+ jwks() {
5983
+ const res = useResponse(current());
5984
+ const signer = this.getIdTokenSigner();
5985
+ if (!signer) {
5986
+ res.status = 404;
5987
+ return { error: "not_found" };
5988
+ }
5989
+ return signer.jwks();
5990
+ }
5264
5991
  /** Fail soft: 302 the validated client redirect with an `?error=` (+ echoed `state`). */
5265
5992
  redirectError(redirectUri, error, state) {
5266
5993
  const url = new URL(redirectUri);
@@ -5282,6 +6009,7 @@ __decorate([
5282
6009
  __decorateParam(4, Query("code_challenge")),
5283
6010
  __decorateParam(5, Query("code_challenge_method")),
5284
6011
  __decorateParam(6, Query("scope")),
6012
+ __decorateParam(7, Query("nonce")),
5285
6013
  __decorateMetadata("design:type", Function),
5286
6014
  __decorateMetadata("design:paramtypes", [
5287
6015
  Object,
@@ -5290,6 +6018,7 @@ __decorate([
5290
6018
  Object,
5291
6019
  Object,
5292
6020
  Object,
6021
+ Object,
5293
6022
  Object
5294
6023
  ]),
5295
6024
  __decorateMetadata("design:returntype", Promise)
@@ -5302,6 +6031,20 @@ __decorate([
5302
6031
  __decorateMetadata("design:paramtypes", [Object]),
5303
6032
  __decorateMetadata("design:returntype", Promise)
5304
6033
  ], AuthorizeController.prototype, "token", null);
6034
+ __decorate([
6035
+ Get(".well-known/openid-configuration"),
6036
+ Public(),
6037
+ __decorateMetadata("design:type", Function),
6038
+ __decorateMetadata("design:paramtypes", []),
6039
+ __decorateMetadata("design:returntype", Object)
6040
+ ], AuthorizeController.prototype, "discovery", null);
6041
+ __decorate([
6042
+ Get("jwks"),
6043
+ Public(),
6044
+ __decorateMetadata("design:type", Function),
6045
+ __decorateMetadata("design:paramtypes", []),
6046
+ __decorateMetadata("design:returntype", Object)
6047
+ ], AuthorizeController.prototype, "jwks", null);
5305
6048
  AuthorizeController = __decorate([
5306
6049
  Controller("auth"),
5307
6050
  __decorateParam(1, Inject(CLIENT_REDIRECT_POLICY_TOKEN)),