@aooth/auth-moost 0.1.22 → 0.1.23

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,4 +1,4 @@
1
- import { C as Select2faForm, E as TermsBumpForm, S as RemoveMfaConfirmForm, T as SignupForm, _ as PincodeForm, a as ConcurrencyLimitForm, c as EnrollConfirmForm, d as InviteForm, f as LoginCredentialsForm, g as PasswordReauthForm, h as MfaCodeForm, i as ChangePasswordForm, l as EnrollPickMethodForm, m as ManageMfaForm, n as AskPhoneForm, o as EmailIdentifierForm, r as AuthorizeConsentForm, s as EnrollAddressForm, t as AskEmailForm, u as EnrollTotpQrForm, v as ProveControlForm, w as SetPasswordForm, y as ProveControlOtpForm } from "./forms-uqegc32h.mjs";
1
+ import { C as Select2faForm, D as TermsBumpForm, E as StepUpConfirmForm, S as RemoveMfaConfirmForm, T as SignupForm, _ as PincodeForm, a as ConcurrencyLimitForm, c as EnrollConfirmForm, d as InviteForm, f as LoginCredentialsForm, g as PasswordReauthForm, h as MfaCodeForm, i as ChangePasswordForm, l as EnrollPickMethodForm, m as ManageMfaForm, n as AskPhoneForm, o as EmailIdentifierForm, r as AuthorizeConsentForm, s as EnrollAddressForm, t as AskEmailForm, u as EnrollTotpQrForm, v as ProveControlForm, w as SetPasswordForm, y as ProveControlOtpForm } from "./forms-xaBNc5Ng.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";
@@ -815,21 +815,26 @@ const enrollTrioSteps = [
815
815
  * Reuses the login challenge steps verbatim, but DELIBERATELY omits
816
816
  * `check-trusted-device` and `risk-step-up`: in a management context a trusted
817
817
  * device must NOT be allowed to bypass the step-up (that is the whole point of
818
- * re-verifying before letting the user change/remove a factor). Loop exits when
818
+ * re-verifying before letting the user change/remove a factor). It ADDS one
819
+ * manage-only step the login loop doesn't have: `manage-stepup-confirm`, the
820
+ * explicit-consent notice before the sms/email pincode dispatch (login is
821
+ * mid-authentication, so its zero-click dispatch stays). Loop exits when
819
822
  * a challenge step flips `ctx.otp.verified`. Used by the standalone add/manage-
820
823
  * MFA flow, guarded by `ctx.addMfa.stepUpRequired` (set only when the user has
821
824
  * ≥1 confirmed method).
822
825
  *
823
826
  * The `while` also breaks on `ctx.aborted` so a cancel/exit on the challenge
824
- * form (e.g. `pincode-check`'s `exit` alt-action, or a customer-added Back on
825
- * the MFA challenge) terminates the loop instead of spinning the engine's
826
- * guardless inner loop forever. The paired `{ break: !!aborted }` after this
827
- * sub-schema in `addMfaFlow` then routes an aborted step-up straight to
828
- * `finish-add-mfa` (the cancelled terminal) fail CLOSED: the user reaches no
829
- * management write without a fresh challenge. (Note: login's `mfaLoopSchema`
830
- * intentionally does NOT carry this guard exiting login's challenge loop
831
- * without a paired failure terminal would risk issuing a session, so that one
832
- * stays fail-closed via the engine's no-progress stall instead.)
827
+ * form (the `manage-stepup-confirm` consent cancel, `pincode-check`'s `exit`
828
+ * alt-action, or a customer-added Back on the MFA challenge) terminates the
829
+ * loop instead of spinning the engine's guardless inner loop forever. Every
830
+ * `addMfaFlow` step after this sub-schema is gated off `ctx.aborted` (or on
831
+ * `otp.verified`, which an aborted step-up never set), so the run falls
832
+ * through to `finish-add-mfa` (the cancelled terminal) — fail CLOSED: the
833
+ * user reaches no management write without a fresh challenge. (Note: login's
834
+ * `mfaLoopSchema` intentionally does NOT carry this guard exiting login's
835
+ * challenge loop without a paired failure terminal would risk issuing a
836
+ * session, so that one stays fail-closed via the engine's no-progress stall
837
+ * instead.)
833
838
  */
834
839
  const mfaStepUpLoop = [{
835
840
  while: (ctx) => !ctx.otp?.verified && !ctx.aborted,
@@ -846,6 +851,11 @@ const mfaStepUpLoop = [{
846
851
  id: "select-2fa",
847
852
  condition: (ctx) => !ctx.otp?.verified && !ctx.mfa?.method && (ctx.mfa?.enrolledMethods?.length ?? 0) > 1
848
853
  },
854
+ {
855
+ id: "manage-stepup-confirm",
856
+ condition: (ctx) => !ctx.otp?.verified && (ctx.mfa?.method === "sms" || ctx.mfa?.method === "email") && !ctx.addMfa?.stepUpConfirmed && !ctx.pin
857
+ },
858
+ { break: (ctx) => !!ctx.aborted },
849
859
  {
850
860
  condition: (ctx) => !ctx.otp?.verified && (ctx.mfa?.method === "sms" || ctx.mfa?.method === "email"),
851
861
  steps: pincodeSendCheckPair
@@ -954,6 +964,7 @@ const DEFAULT_FORMS = {
954
964
  manageMfa: ManageMfaForm,
955
965
  removeMfaConfirm: RemoveMfaConfirmForm,
956
966
  passwordReauth: PasswordReauthForm,
967
+ stepUpConfirm: StepUpConfirmForm,
957
968
  select2fa: Select2faForm,
958
969
  mfaCode: MfaCodeForm,
959
970
  pincode: PincodeForm,
@@ -1445,6 +1456,41 @@ let AuthWorkflow = class AuthWorkflow {
1445
1456
  return false;
1446
1457
  }
1447
1458
  /**
1459
+ * Pin the enrolment address for an sms/email transport — the policy seam
1460
+ * for deployments whose factor must be BOUND to an account record (e.g.
1461
+ * staff MFA locked to the work mailbox so portal access dies with it at
1462
+ * offboarding; a free-text form would let a self-service swap to a personal
1463
+ * inbox defeat that control entirely). Asked by `enroll-address` BEFORE its
1464
+ * form renders, with the staged transport (ctx-first, extra positional arg —
1465
+ * same convention as `resolveEnrollPreConfirmed`).
1466
+ *
1467
+ * Returning a string stages it as the enrolment address (normalized via
1468
+ * `normalizeMfaAddress`; the free-text form is SKIPPED — the same staging
1469
+ * seam consumer pre-seeding uses, so the user is never shown a form whose
1470
+ * only valid input is one known string). `'collect'` (the default) keeps
1471
+ * the free-text form. A pinned address composes with the rest of the trio
1472
+ * machinery untouched: `enroll-send` dispatches the pincode to it, and
1473
+ * `resolveEnrollPreConfirmed` may vouch it (a deployment pinning to a
1474
+ * verified-by-construction address gets the no-code path for free).
1475
+ *
1476
+ * ```ts
1477
+ * protected async resolveEnrollAddress(ctx: AuthWfCtx, method: MfaTransport) {
1478
+ * if (method !== "email") return "collect";
1479
+ * const user = await this.users.getUser(ctx.subject!);
1480
+ * return (user as { email?: string }).email ?? "collect";
1481
+ * }
1482
+ * ```
1483
+ *
1484
+ * The returned address is trusted as-is (no `validateMfaAddress` pass) —
1485
+ * the deployment is authoritative for its own records. An empty/blank
1486
+ * return falls back to `'collect'`. For nuanced RULES on a user-typed
1487
+ * address (domain allowlists, record comparisons) override the ctx-first
1488
+ * {@link validateMfaAddress} instead.
1489
+ */
1490
+ resolveEnrollAddress(_ctx, _method) {
1491
+ return "collect";
1492
+ }
1493
+ /**
1448
1494
  * Resolve the finalize policy. Reached from login.flow. `auditLogin` is
1449
1495
  * dropped from the shape per §2 — audit moved out of the workflow layer.
1450
1496
  */
@@ -1558,6 +1604,40 @@ let AuthWorkflow = class AuthWorkflow {
1558
1604
  return [];
1559
1605
  }
1560
1606
  /**
1607
+ * Whether the manage-MFA step-up must collect explicit consent BEFORE
1608
+ * dispatching its sms/email pincode (the `manage-stepup-confirm` pause:
1609
+ * "To continue, we will send a verification code to ma•••@x"). Default
1610
+ * `true` — nothing should email/text the user as a side effect of opening
1611
+ * a manage dialog: a user who opened it by mistake (or just to look)
1612
+ * closes it with zero codes consumed, no resend cooldown burnt. Override
1613
+ * to `false` to restore the zero-click dispatch (the code is already in
1614
+ * flight when the first form renders). Never asked for TOTP step-up
1615
+ * (nothing is dispatched) and not consulted by the login flow (its
1616
+ * challenge is mid-authentication, where zero-click is the norm).
1617
+ */
1618
+ resolveStepUpConfirmBeforeSend(_ctx) {
1619
+ return true;
1620
+ }
1621
+ /**
1622
+ * What the user's authenticator app shows as the ACCOUNT half of
1623
+ * "issuer: account" for a TOTP enrolment (the issuer half is
1624
+ * `resolveMfaPolicy().issuer` / `opts.totpIssuer`). Cosmetic only — never
1625
+ * used for lookup — but it is how a user with several entries tells
1626
+ * accounts apart, and it is encoded into the `otpauth://` URI at
1627
+ * secret-provisioning time, so it lives in the authenticator FOREVER
1628
+ * (re-labeling requires re-enrolment). Default prefers a human-readable
1629
+ * identifier the flow already carries (`ctx.email` — invite/recovery/
1630
+ * signup) and otherwise loads the user's `username`; the stable-uuid
1631
+ * `ctx.subject` is the last resort. Override for a richer label (display
1632
+ * name, tenant-qualified email, …).
1633
+ */
1634
+ resolveTotpAccountLabel(ctx) {
1635
+ if (ctx.email) return ctx.email;
1636
+ if (!ctx.subject) return "";
1637
+ const subject = ctx.subject;
1638
+ return this.users.getUser(subject).then((u) => u.username || subject);
1639
+ }
1640
+ /**
1561
1641
  * Resolve the channel-OTP disclosure copy rendered beneath the email/phone
1562
1642
  * input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
1563
1643
  * Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
@@ -1868,7 +1948,11 @@ let AuthWorkflow = class AuthWorkflow {
1868
1948
  if (sub) pub.mfaEnroll = sub;
1869
1949
  }
1870
1950
  if (ctx.addMfa) {
1871
- const sub = pickDefined(ctx.addMfa, ["candidates", "locked"]);
1951
+ const sub = pickDefined(ctx.addMfa, [
1952
+ "candidates",
1953
+ "locked",
1954
+ "removeBlocked"
1955
+ ]);
1872
1956
  if (sub) pub.manage = sub;
1873
1957
  }
1874
1958
  if (ctx.defaults) {
@@ -2029,6 +2113,30 @@ let AuthWorkflow = class AuthWorkflow {
2029
2113
  await this.sendEnrollPincode(ctx, address, code);
2030
2114
  }
2031
2115
  /**
2116
+ * Idempotent TOTP secret provisioning into wf-state ONLY (the QR renders
2117
+ * from `public.mfaEnroll.secret/uri`; the user record is written on confirm
2118
+ * — write-on-confirm). The single implementation behind BOTH provisioning
2119
+ * sites — `enroll-pick-method`'s auto-pick/picker tail and `enroll-totp-qr`
2120
+ * (covers the manage add/change path where the picker is skipped) — so the
2121
+ * account label baked into the `otpauth://` URI cannot drift between them.
2122
+ * The label comes from {@link resolveTotpAccountLabel} (human-readable
2123
+ * default); blank falls back to the subject uuid so the URI always carries
2124
+ * SOME account discriminator.
2125
+ */
2126
+ provisionTotpSecret(ctx) {
2127
+ const m = ctx.mfaEnroll ??= {};
2128
+ if (m.method !== "totp" || m.secret) return void 0;
2129
+ const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
2130
+ const secret = generateTotpSecret();
2131
+ const apply = (label) => {
2132
+ m.secret = secret;
2133
+ m.uri = generateTotpUri(secret, issuer, label.trim() || (ctx.subject ?? ""));
2134
+ };
2135
+ const label = this.resolveTotpAccountLabel(ctx);
2136
+ if (label instanceof Promise) return label.then(apply);
2137
+ return apply(label);
2138
+ }
2139
+ /**
2032
2140
  * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
2033
2141
  * the pincode timers/`sentTo`) off ctx — the shared teardown used by the
2034
2142
  * opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
@@ -2058,8 +2166,24 @@ let AuthWorkflow = class AuthWorkflow {
2058
2166
  * or `undefined` when valid. Email must look like an email; SMS is permissive
2059
2167
  * E.164-ish (normalized by {@link normalizeMfaAddress}). Override for stricter
2060
2168
  * (e.g. libphonenumber) validation.
2169
+ *
2170
+ * Ctx-first and async-capable, so record-based rules need no ctx-stash
2171
+ * workaround — an override can load the account and compare directly
2172
+ * (e.g. domain-allowlist the typed inbox, or require it to match a
2173
+ * record field). To PIN the address outright — never show the free-text
2174
+ * form at all — use {@link resolveEnrollAddress} instead; this hook is for
2175
+ * nuanced rules on what the user typed.
2176
+ *
2177
+ * ```ts
2178
+ * protected async validateMfaAddress(ctx: AuthWfCtx, method: MfaTransport, value: string) {
2179
+ * if (method === "email" && !value.trim().toLowerCase().endsWith("@corp.example")) {
2180
+ * return "Use your corporate email address";
2181
+ * }
2182
+ * return super.validateMfaAddress(ctx, method, value);
2183
+ * }
2184
+ * ```
2061
2185
  */
2062
- validateMfaAddress(method, value) {
2186
+ validateMfaAddress(_ctx, method, value) {
2063
2187
  const v = (value ?? "").trim();
2064
2188
  if (!v) return "This field is required";
2065
2189
  if (method === "email") return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? void 0 : "Enter a valid email address";
@@ -2883,10 +3007,22 @@ let AuthWorkflow = class AuthWorkflow {
2883
3007
  /**
2884
3008
  * Terminal for the manage-MFA flow. The user KEEPS their current session (no
2885
3009
  * re-issue, no cookies) — a plain data finish. Outcomes, in priority order:
2886
- * removed → changed (`replace` + done) → added (done) → nothing-available
2887
- * (zero candidates, never had to step-up) → cancelled.
3010
+ * removed → changed (`replace` + done) → added (done) → blocked
3011
+ * (un-removable operation aborted by `confirm-remove-mfa`) →
3012
+ * nothing-available (zero candidates, never had to step-up) → cancelled.
2888
3013
  */
2889
3014
  finishAddMfa(ctx) {
3015
+ useWfFinished().set({
3016
+ type: "data",
3017
+ value: this.buildAddMfaFinishEnvelope(ctx)
3018
+ });
3019
+ }
3020
+ /**
3021
+ * `finish-add-mfa`'s envelope construction, extracted pure so the outcome
3022
+ * priority (removed → changed → added → blocked → nothing-available →
3023
+ * cancelled) is unit-testable without a wf event context.
3024
+ */
3025
+ buildAddMfaFinishEnvelope(ctx) {
2890
3026
  const labels = {
2891
3027
  totp: "Authenticator app",
2892
3028
  email: "Email code",
@@ -2930,6 +3066,17 @@ let AuthWorkflow = class AuthWorkflow {
2930
3066
  text: `${labels[method]} added.`
2931
3067
  }
2932
3068
  };
3069
+ else if (addMfa?.blocked) envelope = {
3070
+ finished: true,
3071
+ data: {
3072
+ added: false,
3073
+ reason: addMfa.blocked
3074
+ },
3075
+ message: {
3076
+ level: "info",
3077
+ text: addMfa.blocked === "last-required-factor" ? "You must keep at least one two-factor method, so this one can't be removed." : "That method can't be changed here."
3078
+ }
3079
+ };
2933
3080
  else if (candidates.length === 0 && !addMfa?.stepUpRequired) envelope = {
2934
3081
  finished: true,
2935
3082
  data: {
@@ -2952,10 +3099,7 @@ let AuthWorkflow = class AuthWorkflow {
2952
3099
  text: "No changes were made to your two-factor methods."
2953
3100
  }
2954
3101
  };
2955
- useWfFinished().set({
2956
- type: "data",
2957
- value: envelope
2958
- });
3102
+ return envelope;
2959
3103
  }
2960
3104
  /**
2961
3105
  * Resolve which transports the user may NOT change/remove via the manage flow
@@ -2982,6 +3126,46 @@ let AuthWorkflow = class AuthWorkflow {
2982
3126
  (ctx.addMfa ??= {}).stepUpDone = true;
2983
3127
  }
2984
3128
  /**
3129
+ * Manage-MFA step-up consent — pauses on `StepUpConfirmForm` ("To continue,
3130
+ * we will send a verification code to ma•••@x") BEFORE `pincode-send`
3131
+ * dispatches the step-up code, so opening the manage dialog never consumes
3132
+ * a code send as a side effect. Fires only on the auto-picked paths (single
3133
+ * factor, or default factor) — an explicit `select-2fa` pick already
3134
+ * counts as consent (`select2fa` sets `stepUpConfirmed`). `Continue`
3135
+ * consents and the SAME engine pass mints + sends; `useDifferentMethod`
3136
+ * re-opens the picker; `cancel` aborts with nothing dispatched (the
3137
+ * schema's `{ break: aborted }` right after this step keeps the pair from
3138
+ * sending the declined code). Gated by {@link resolveStepUpConfirmBeforeSend}
3139
+ * (default on) — an opt-out marks consent and falls straight through.
3140
+ */
3141
+ manageStepUpConfirm(ctx) {
3142
+ const result = this.resolveStepUpConfirmBeforeSend(ctx);
3143
+ if (result instanceof Promise) return result.then((r) => this.applyStepUpConfirm(ctx, r));
3144
+ return this.applyStepUpConfirm(ctx, result);
3145
+ }
3146
+ /** `manage-stepup-confirm` tail — opt-out fall-through or the consent pause. */
3147
+ applyStepUpConfirm(ctx, confirmBeforeSend) {
3148
+ const addMfa = ctx.addMfa ??= {};
3149
+ if (!confirmBeforeSend) {
3150
+ addMfa.stepUpConfirmed = true;
3151
+ return;
3152
+ }
3153
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.stepUpConfirm);
3154
+ const action = wf.resolveAction();
3155
+ if (action === "cancel") {
3156
+ ctx.aborted = true;
3157
+ return;
3158
+ }
3159
+ if (action === "useDifferentMethod") {
3160
+ const mfa = ctx.mfa ??= {};
3161
+ mfa.ignoreDefault = true;
3162
+ delete mfa.method;
3163
+ return;
3164
+ }
3165
+ wf.resolveInput();
3166
+ addMfa.stepUpConfirmed = true;
3167
+ }
3168
+ /**
2985
3169
  * Manage-MFA password re-auth — the step-up FALLBACK when the user's only
2986
3170
  * confirmed factor(s) are of kinds the policy no longer allows, so nothing is
2987
3171
  * MFA-challengeable (`addMfa.stepUpMode === "password"`; see `init-add-mfa`).
@@ -3006,6 +3190,17 @@ let AuthWorkflow = class AuthWorkflow {
3006
3190
  (ctx.otp ??= {}).verified = true;
3007
3191
  }
3008
3192
  /**
3193
+ * The keep-at-least-one rule: removing the user's LAST confirmed factor under
3194
+ * a `required` policy can never succeed. The single source for the predicate
3195
+ * `manage-menu` mirrors into `addMfa.removeBlocked` (to drop the Remove option)
3196
+ * AND `confirm-remove-mfa` re-checks before its pause (defence in depth) — so
3197
+ * a policy change (e.g. "keep at least two", or a backup-codes exception)
3198
+ * lands in one place, not two copies that can drift.
3199
+ */
3200
+ isLastRequiredFactor(ctx) {
3201
+ return (ctx.mfa?.enrolledMethods?.length ?? 0) <= 1 && ctx.mfaPolicy?.mode === "required";
3202
+ }
3203
+ /**
3009
3204
  * Manage-MFA menu — pauses on `ManageMfaForm` and routes the chosen
3010
3205
  * `operation` (`add:<t>` / `replace:<t>` / `remove:<t>`). Only reached when
3011
3206
  * the user has ≥1 confirmed factor (a zero-MFA user goes straight to the enrol
@@ -3024,6 +3219,8 @@ let AuthWorkflow = class AuthWorkflow {
3024
3219
  ctx.aborted = true;
3025
3220
  return;
3026
3221
  }
3222
+ if (this.isLastRequiredFactor(ctx)) addMfa.removeBlocked = true;
3223
+ else delete addMfa.removeBlocked;
3027
3224
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.manageMfa);
3028
3225
  if (wf.resolveAction() === "cancel") {
3029
3226
  ctx.aborted = true;
@@ -3038,6 +3235,7 @@ let AuthWorkflow = class AuthWorkflow {
3038
3235
  } else if (action === "replace" || action === "remove") {
3039
3236
  if (locked.has(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be changed here." });
3040
3237
  if (!enrolled.some((e) => e.kind === target)) throw this.throwPublic(ctx, wf, { errors: { operation: "Unknown method" } });
3238
+ if (action === "remove" && addMfa.removeBlocked) throw this.throwPublic(ctx, wf, { formMessage: "You must keep at least one two-factor method." });
3041
3239
  } else throw this.throwPublic(ctx, wf, { errors: { operation: "Choose an option" } });
3042
3240
  addMfa.action = action;
3043
3241
  addMfa.target = target;
@@ -3054,22 +3252,35 @@ let AuthWorkflow = class AuthWorkflow {
3054
3252
  /**
3055
3253
  * Manage-MFA remove confirmation. Pauses on `RemoveMfaConfirmForm`; the
3056
3254
  * 'Remove' submit performs the removal, 'Cancel' aborts. Re-checks the locked
3057
- * set (defence in depth) and blocks removing the LAST confirmed factor when
3058
- * the policy mode is `required` (you must keep at least one).
3255
+ * set and the keep-at-least-one rule (LAST confirmed factor under a
3256
+ * `required` policy) BEFORE the pause and an un-removable state aborts to
3257
+ * the `finish-add-mfa` terminal (reason on `addMfa.blocked`) instead of
3258
+ * pausing: `manage-menu` filters these operations out, so arriving here
3259
+ * blocked means a stale/crafted route, and a retryable form whose only
3260
+ * submit re-throws the same guard would be a dead-end loop (the manage
3261
+ * forms hide their built-in cancel — the host owns it).
3059
3262
  */
3060
3263
  async confirmRemoveMfa(ctx) {
3061
3264
  this.requireSubject(ctx);
3062
3265
  const username = ctx.subject;
3063
3266
  const addMfa = ctx.addMfa ??= {};
3064
3267
  const target = addMfa.target;
3268
+ const enrolled = ctx.mfa?.enrolledMethods ?? [];
3269
+ if ((addMfa.locked ?? []).includes(target)) {
3270
+ addMfa.blocked = "method-locked";
3271
+ ctx.aborted = true;
3272
+ return;
3273
+ }
3274
+ if (this.isLastRequiredFactor(ctx)) {
3275
+ addMfa.blocked = "last-required-factor";
3276
+ ctx.aborted = true;
3277
+ return;
3278
+ }
3065
3279
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.removeMfaConfirm);
3066
3280
  if (wf.resolveAction() === "cancel") {
3067
3281
  ctx.aborted = true;
3068
3282
  return;
3069
3283
  }
3070
- if ((addMfa.locked ?? []).includes(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be removed here." });
3071
- const enrolled = ctx.mfa?.enrolledMethods ?? [];
3072
- if (enrolled.length <= 1 && ctx.mfaPolicy?.mode === "required") throw this.throwPublic(ctx, wf, { formMessage: "You must keep at least one two-factor method." });
3073
3284
  wf.resolveInput();
3074
3285
  const methodName = enrolled.find((e) => e.kind === target)?.methodName ?? target;
3075
3286
  await this.withStoreErrorTranslation(() => this.users.removeMfaMethod(username, methodName));
@@ -3293,6 +3504,7 @@ let AuthWorkflow = class AuthWorkflow {
3293
3504
  }
3294
3505
  }
3295
3506
  mfa.method = picked.kind;
3507
+ if (ctx.addMfa) ctx.addMfa.stepUpConfirmed = true;
3296
3508
  mfa.saveAsDefault = Boolean(input.saveAsDefault);
3297
3509
  if (mfa.saveAsDefault && ctx.subject) await this.users.setDefaultMfaMethod(ctx.subject, picked.methodName);
3298
3510
  }
@@ -3441,7 +3653,6 @@ let AuthWorkflow = class AuthWorkflow {
3441
3653
  */
3442
3654
  enrollPickMethod(ctx) {
3443
3655
  this.requireSubject(ctx);
3444
- const username = ctx.subject;
3445
3656
  const transports = ctx.mfaPolicy?.availableTransports ?? [];
3446
3657
  const m = ctx.mfaEnroll ??= {};
3447
3658
  const mode = m.mode === "manage" ? "manage" : ctx.mfaPolicy?.mode === "required" ? "required" : "optional";
@@ -3472,28 +3683,45 @@ let AuthWorkflow = class AuthWorkflow {
3472
3683
  if (!m.availableTransports.includes(picked)) throw this.throwPublic(ctx, wf, { errors: { method: "Unknown method" } });
3473
3684
  m.method = picked;
3474
3685
  }
3475
- if (m.method === "totp" && !m.secret) {
3476
- const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
3477
- const secret = generateTotpSecret();
3478
- m.secret = secret;
3479
- m.uri = generateTotpUri(secret, issuer, username);
3480
- }
3686
+ return this.provisionTotpSecret(ctx);
3481
3687
  }
3482
3688
  /**
3483
3689
  * Unified MFA-enrol phase 2 (collect sms/email address). Not invoked for
3484
- * totp. Handles `skip` (opt-in) / `cancel` (manage) / `useDifferentMethod`.
3485
- * Validates the address server-side (the client `@ui.form.validate` hint is
3486
- * advisory), then STAGES the candidate value in wf-state (`m.address`) the
3487
- * user record is written only on confirm (write-on-confirm), so an ADD
3488
- * leaves no partial row and a REPLACE keeps the old confirmed value live
3489
- * until the new code verifies in `enroll-confirm`. Collection ONLY: the
3490
- * pincode dispatch lives in `enroll-send` (same engine pass, no extra
3491
- * round-trip), so a consumer pre-seeding `mfaEnroll.address` — which skips
3492
- * this whole step via its schema condition still gets exactly one code.
3690
+ * totp. Asks {@link resolveEnrollAddress} FIRST a deployment that pins
3691
+ * the address (factor bound to an account record) stages it here and the
3692
+ * free-text form never renders; this single call site covers every trio
3693
+ * path (picker, auto-pick, manage add/replace pre-seed), and `enroll-send`
3694
+ * dispatches to the pinned address in the same engine pass. Otherwise
3695
+ * (`'collect'`) handles `skip` (opt-in) / `cancel` (manage) /
3696
+ * `useDifferentMethod`, validates the typed address server-side via the
3697
+ * ctx-first {@link validateMfaAddress} (the client `@ui.form.validate`
3698
+ * hint is advisory), then STAGES the candidate value in wf-state
3699
+ * (`m.address`) — the user record is written only on confirm
3700
+ * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps
3701
+ * the old confirmed value live until the new code verifies in
3702
+ * `enroll-confirm`. Collection ONLY: the pincode dispatch lives in
3703
+ * `enroll-send` (same engine pass, no extra round-trip), so a consumer
3704
+ * pre-seeding `mfaEnroll.address` — which skips this whole step via its
3705
+ * schema condition — still gets exactly one code.
3493
3706
  */
3494
3707
  enrollAddress(ctx) {
3495
3708
  this.requireSubject(ctx);
3709
+ const methodName = (ctx.mfaEnroll ??= {}).method;
3710
+ const pinned = this.resolveEnrollAddress(ctx, methodName);
3711
+ if (pinned instanceof Promise) return pinned.then((p) => this.collectEnrollAddress(ctx, methodName, p));
3712
+ return this.collectEnrollAddress(ctx, methodName, pinned);
3713
+ }
3714
+ /**
3715
+ * `enroll-address` tail — stage a pinned address, or run the free-text
3716
+ * collect pause (skip/cancel/useDifferentMethod triage + ctx-first
3717
+ * validation + write-on-confirm staging).
3718
+ */
3719
+ collectEnrollAddress(ctx, methodName, pinned) {
3496
3720
  const m = ctx.mfaEnroll ??= {};
3721
+ if (pinned !== "collect" && pinned.trim()) {
3722
+ m.address = this.normalizeMfaAddress(methodName, pinned.trim());
3723
+ return;
3724
+ }
3497
3725
  const mode = m.mode ?? "optional";
3498
3726
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollAddress);
3499
3727
  const action = wf.resolveAction();
@@ -3511,10 +3739,13 @@ let AuthWorkflow = class AuthWorkflow {
3511
3739
  return;
3512
3740
  }
3513
3741
  const input = wf.resolveInput();
3514
- const methodName = m.method;
3515
- const addrErr = this.validateMfaAddress(methodName, input.address);
3516
- if (addrErr) throw this.throwPublic(ctx, wf, { errors: { address: addrErr } });
3517
- m.address = this.normalizeMfaAddress(methodName, input.address);
3742
+ const stage = (addrErr) => {
3743
+ if (addrErr) throw this.throwPublic(ctx, wf, { errors: { address: addrErr } });
3744
+ m.address = this.normalizeMfaAddress(methodName, input.address);
3745
+ };
3746
+ const addrErr = this.validateMfaAddress(ctx, methodName, input.address);
3747
+ if (addrErr instanceof Promise) return addrErr.then(stage);
3748
+ return stage(addrErr);
3518
3749
  }
3519
3750
  /**
3520
3751
  * Unified MFA-enrol dispatch (sms/email only) — the trio's ONLY pincode
@@ -3551,14 +3782,8 @@ let AuthWorkflow = class AuthWorkflow {
3551
3782
  */
3552
3783
  async enrollTotpQr(ctx) {
3553
3784
  this.requireSubject(ctx);
3554
- const username = ctx.subject;
3555
3785
  const m = ctx.mfaEnroll ??= {};
3556
- if (m.method === "totp" && !m.secret) {
3557
- const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
3558
- const secret = generateTotpSecret();
3559
- m.secret = secret;
3560
- m.uri = generateTotpUri(secret, issuer, username);
3561
- }
3786
+ await this.provisionTotpSecret(ctx);
3562
3787
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollTotpQr);
3563
3788
  if (this.handleEnrollExit(ctx, wf.resolveAction())) return void 0;
3564
3789
  wf.resolveInput();
@@ -4657,12 +4882,19 @@ let AuthWorkflow = class AuthWorkflow {
4657
4882
  * 3. STEP-UP (only when `stepUpRequired`): re-verify identity before any
4658
4883
  * change — `mfaStepUpLoop` challenges an EXISTING factor when one is still
4659
4884
  * challengeable (`stepUpMode==='mfa'`), else `manage-password-reauth` falls
4660
- * back to the account password (`stepUpMode==='password'`). On success
4885
+ * back to the account password (`stepUpMode==='password'`). The sms/email
4886
+ * challenge collects explicit consent (`manage-stepup-confirm`) BEFORE
4887
+ * dispatching its pincode — opening the dialog never sends a code as a
4888
+ * side effect (see `resolveStepUpConfirmBeforeSend`). On success
4661
4889
  * `manage-stepup-done` swaps off the encapsulated start onto the durable
4662
4890
  * `store` strategy (server-anchored, replay-resistant; mirrors login's
4663
4891
  * swap-after-credentials).
4664
4892
  * 4. `manage-menu` (only when `stepUpRequired`) — pick add / change / remove +
4665
- * target; pre-seeds `mfaEnroll.method` for add/change.
4893
+ * target; pre-seeds `mfaEnroll.method` for add/change. Un-offerable
4894
+ * operations never render: locked transports drop their Change/Remove
4895
+ * options, and the LAST factor under a `required` policy drops Remove
4896
+ * (`removeBlocked`) — `confirm-remove-mfa` aborts to the finish terminal
4897
+ * if a blocked remove arrives anyway (no retryable dead-end form).
4666
4898
  * 5. Route: `confirm-remove-mfa` for remove; otherwise the REUSED enrol trio
4667
4899
  * (`enroll-pick-method` → `enroll-address` / `enroll-totp-qr` →
4668
4900
  * `enroll-confirm`). A zero-MFA user skips step-up + menu and lands on the
@@ -5041,6 +5273,15 @@ __decorate([
5041
5273
  __decorateMetadata("design:paramtypes", [Object]),
5042
5274
  __decorateMetadata("design:returntype", void 0)
5043
5275
  ], AuthWorkflow.prototype, "manageStepUpDone", null);
5276
+ __decorate([
5277
+ Step("manage-stepup-confirm"),
5278
+ ArbacResource("auth.add-mfa"),
5279
+ ArbacAction("self"),
5280
+ __decorateParam(0, WorkflowParam("context")),
5281
+ __decorateMetadata("design:type", Function),
5282
+ __decorateMetadata("design:paramtypes", [Object]),
5283
+ __decorateMetadata("design:returntype", Object)
5284
+ ], AuthWorkflow.prototype, "manageStepUpConfirm", null);
5044
5285
  __decorate([
5045
5286
  Step("manage-password-reauth"),
5046
5287
  ArbacResource("auth.add-mfa"),
@@ -5156,7 +5397,7 @@ __decorate([
5156
5397
  __decorateParam(0, WorkflowParam("context")),
5157
5398
  __decorateMetadata("design:type", Function),
5158
5399
  __decorateMetadata("design:paramtypes", [Object]),
5159
- __decorateMetadata("design:returntype", void 0)
5400
+ __decorateMetadata("design:returntype", Object)
5160
5401
  ], AuthWorkflow.prototype, "enrollAddress", null);
5161
5402
  __decorate([
5162
5403
  Step("enroll-send"),
@@ -5659,14 +5900,13 @@ __decorate([
5659
5900
  id: "manage-password-reauth",
5660
5901
  condition: (ctx) => !!ctx.addMfa?.stepUpRequired && ctx.addMfa?.stepUpMode === "password" && !ctx.otp?.verified
5661
5902
  },
5662
- { break: (ctx) => !!ctx.aborted },
5663
5903
  {
5664
5904
  id: "manage-stepup-done",
5665
- condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !!ctx.otp?.verified && !ctx.addMfa?.stepUpDone
5905
+ condition: (ctx) => !ctx.aborted && !!ctx.addMfa?.stepUpRequired && !!ctx.otp?.verified && !ctx.addMfa?.stepUpDone
5666
5906
  },
5667
5907
  {
5668
5908
  id: "manage-menu",
5669
- condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
5909
+ condition: (ctx) => !ctx.aborted && !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
5670
5910
  },
5671
5911
  {
5672
5912
  id: "confirm-remove-mfa",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/auth-moost",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Moost auth integration for aoothjs — AuthGuard interceptor, useAuth composable, REST endpoints, workflows",
5
5
  "keywords": [
6
6
  "aoothjs",
@@ -57,18 +57,18 @@
57
57
  "access": "public"
58
58
  },
59
59
  "dependencies": {
60
- "@atscript/moost-wf": "^0.1.100",
60
+ "@atscript/moost-wf": "^0.1.101",
61
61
  "@wooksjs/http-body": "^0.7.19",
62
- "@aooth/auth": "0.1.22",
63
- "@aooth/arbac-moost": "^0.1.22",
64
- "@aooth/user": "0.1.22",
65
- "@aooth/idp": "0.1.22"
62
+ "@aooth/arbac-moost": "^0.1.23",
63
+ "@aooth/idp": "0.1.23",
64
+ "@aooth/auth": "0.1.23",
65
+ "@aooth/user": "0.1.23"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@atscript/core": "^0.1.76",
69
69
  "@atscript/typescript": "^0.1.76",
70
- "@atscript/ui": "^0.1.100",
71
- "@atscript/ui-fns": "^0.1.100",
70
+ "@atscript/ui": "^0.1.101",
71
+ "@atscript/ui-fns": "^0.1.101",
72
72
  "@moostjs/event-http": "^0.6.27",
73
73
  "@moostjs/event-wf": "^0.6.27",
74
74
  "moost": "^0.6.27",
@@ -76,7 +76,7 @@
76
76
  "wooks": "^0.7.19"
77
77
  },
78
78
  "peerDependencies": {
79
- "@atscript/moost-wf": "^0.1.100",
79
+ "@atscript/moost-wf": "^0.1.101",
80
80
  "@atscript/typescript": "^0.1.76",
81
81
  "@moostjs/event-http": "^0.6.27",
82
82
  "@moostjs/event-wf": "^0.6.27",