@aooth/auth-moost 0.1.22 → 0.1.24

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,
@@ -1229,6 +1240,47 @@ let AuthWorkflow = class AuthWorkflow {
1229
1240
  });
1230
1241
  }
1231
1242
  /**
1243
+ * A user just authenticated and a session is being established — interactive
1244
+ * login, federated (SSO) login, OR invite/signup/recovery auto-login. Fired
1245
+ * from the single `record-login` funnel, AFTER `account.lastLogin` is stamped
1246
+ * and BEFORE the session/code is delivered, so a throw aborts the login
1247
+ * atomically (no half-issued session). `ctx.subject` is set; read
1248
+ * `ctx.isFirstLogin` to distinguish the very first sign-in, `ctx.oauth` for
1249
+ * federated context. Does NOT fire for the no-session "fresh-login" finalize
1250
+ * (where the user is redirected to sign in separately).
1251
+ */
1252
+ afterLogin(_ctx) {}
1253
+ /**
1254
+ * An invitee finished accepting their invite — account activated — whether or
1255
+ * not the flow auto-logged-them-in. Fires once, before the finalize terminal.
1256
+ * (An auto-login invite ALSO fires {@link afterLogin}.)
1257
+ */
1258
+ afterInvitationAccepted(_ctx) {}
1259
+ /**
1260
+ * A self-signup account was created + activated (post password-set, post
1261
+ * activate). Fires once, before the auto-login finalize. (Signup always
1262
+ * auto-logins, so {@link afterLogin} fires too.)
1263
+ */
1264
+ afterSignup(_ctx) {}
1265
+ /**
1266
+ * A recovery password reset completed. Fires once even when an `admin-only`
1267
+ * lock survived the reset (the password DID change) — so it runs ahead of the
1268
+ * still-locked guard. An auto-login recovery ALSO fires {@link afterLogin}.
1269
+ */
1270
+ afterPasswordReset(_ctx) {}
1271
+ /**
1272
+ * An already-authenticated user changed their own password (change-password
1273
+ * flow). NOT a login — the session is rotated, not established — so
1274
+ * {@link afterLogin} does NOT fire.
1275
+ */
1276
+ afterPasswordChanged(_ctx) {}
1277
+ /**
1278
+ * A user added, changed, or removed an MFA factor (add-mfa flow). NOT fired
1279
+ * on a cancel / nothing-to-do finish. The user KEEPS their session (no
1280
+ * re-issue), so {@link afterLogin} does NOT fire.
1281
+ */
1282
+ afterMfaChanged(_ctx) {}
1283
+ /**
1232
1284
  * Return the list of selectable role identifiers for the admin invite form.
1233
1285
  * Mirrors the prior `InviteWorkflow.getAvailableRoles()` consumer hook —
1234
1286
  * `undefined` (default) means no whitelist is enforced. Read by
@@ -1445,6 +1497,41 @@ let AuthWorkflow = class AuthWorkflow {
1445
1497
  return false;
1446
1498
  }
1447
1499
  /**
1500
+ * Pin the enrolment address for an sms/email transport — the policy seam
1501
+ * for deployments whose factor must be BOUND to an account record (e.g.
1502
+ * staff MFA locked to the work mailbox so portal access dies with it at
1503
+ * offboarding; a free-text form would let a self-service swap to a personal
1504
+ * inbox defeat that control entirely). Asked by `enroll-address` BEFORE its
1505
+ * form renders, with the staged transport (ctx-first, extra positional arg —
1506
+ * same convention as `resolveEnrollPreConfirmed`).
1507
+ *
1508
+ * Returning a string stages it as the enrolment address (normalized via
1509
+ * `normalizeMfaAddress`; the free-text form is SKIPPED — the same staging
1510
+ * seam consumer pre-seeding uses, so the user is never shown a form whose
1511
+ * only valid input is one known string). `'collect'` (the default) keeps
1512
+ * the free-text form. A pinned address composes with the rest of the trio
1513
+ * machinery untouched: `enroll-send` dispatches the pincode to it, and
1514
+ * `resolveEnrollPreConfirmed` may vouch it (a deployment pinning to a
1515
+ * verified-by-construction address gets the no-code path for free).
1516
+ *
1517
+ * ```ts
1518
+ * protected async resolveEnrollAddress(ctx: AuthWfCtx, method: MfaTransport) {
1519
+ * if (method !== "email") return "collect";
1520
+ * const user = await this.users.getUser(ctx.subject!);
1521
+ * return (user as { email?: string }).email ?? "collect";
1522
+ * }
1523
+ * ```
1524
+ *
1525
+ * The returned address is trusted as-is (no `validateMfaAddress` pass) —
1526
+ * the deployment is authoritative for its own records. An empty/blank
1527
+ * return falls back to `'collect'`. For nuanced RULES on a user-typed
1528
+ * address (domain allowlists, record comparisons) override the ctx-first
1529
+ * {@link validateMfaAddress} instead.
1530
+ */
1531
+ resolveEnrollAddress(_ctx, _method) {
1532
+ return "collect";
1533
+ }
1534
+ /**
1448
1535
  * Resolve the finalize policy. Reached from login.flow. `auditLogin` is
1449
1536
  * dropped from the shape per §2 — audit moved out of the workflow layer.
1450
1537
  */
@@ -1558,6 +1645,40 @@ let AuthWorkflow = class AuthWorkflow {
1558
1645
  return [];
1559
1646
  }
1560
1647
  /**
1648
+ * Whether the manage-MFA step-up must collect explicit consent BEFORE
1649
+ * dispatching its sms/email pincode (the `manage-stepup-confirm` pause:
1650
+ * "To continue, we will send a verification code to ma•••@x"). Default
1651
+ * `true` — nothing should email/text the user as a side effect of opening
1652
+ * a manage dialog: a user who opened it by mistake (or just to look)
1653
+ * closes it with zero codes consumed, no resend cooldown burnt. Override
1654
+ * to `false` to restore the zero-click dispatch (the code is already in
1655
+ * flight when the first form renders). Never asked for TOTP step-up
1656
+ * (nothing is dispatched) and not consulted by the login flow (its
1657
+ * challenge is mid-authentication, where zero-click is the norm).
1658
+ */
1659
+ resolveStepUpConfirmBeforeSend(_ctx) {
1660
+ return true;
1661
+ }
1662
+ /**
1663
+ * What the user's authenticator app shows as the ACCOUNT half of
1664
+ * "issuer: account" for a TOTP enrolment (the issuer half is
1665
+ * `resolveMfaPolicy().issuer` / `opts.totpIssuer`). Cosmetic only — never
1666
+ * used for lookup — but it is how a user with several entries tells
1667
+ * accounts apart, and it is encoded into the `otpauth://` URI at
1668
+ * secret-provisioning time, so it lives in the authenticator FOREVER
1669
+ * (re-labeling requires re-enrolment). Default prefers a human-readable
1670
+ * identifier the flow already carries (`ctx.email` — invite/recovery/
1671
+ * signup) and otherwise loads the user's `username`; the stable-uuid
1672
+ * `ctx.subject` is the last resort. Override for a richer label (display
1673
+ * name, tenant-qualified email, …).
1674
+ */
1675
+ resolveTotpAccountLabel(ctx) {
1676
+ if (ctx.email) return ctx.email;
1677
+ if (!ctx.subject) return "";
1678
+ const subject = ctx.subject;
1679
+ return this.users.getUser(subject).then((u) => u.username || subject);
1680
+ }
1681
+ /**
1561
1682
  * Resolve the channel-OTP disclosure copy rendered beneath the email/phone
1562
1683
  * input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
1563
1684
  * Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
@@ -1868,7 +1989,11 @@ let AuthWorkflow = class AuthWorkflow {
1868
1989
  if (sub) pub.mfaEnroll = sub;
1869
1990
  }
1870
1991
  if (ctx.addMfa) {
1871
- const sub = pickDefined(ctx.addMfa, ["candidates", "locked"]);
1992
+ const sub = pickDefined(ctx.addMfa, [
1993
+ "candidates",
1994
+ "locked",
1995
+ "removeBlocked"
1996
+ ]);
1872
1997
  if (sub) pub.manage = sub;
1873
1998
  }
1874
1999
  if (ctx.defaults) {
@@ -2029,6 +2154,30 @@ let AuthWorkflow = class AuthWorkflow {
2029
2154
  await this.sendEnrollPincode(ctx, address, code);
2030
2155
  }
2031
2156
  /**
2157
+ * Idempotent TOTP secret provisioning into wf-state ONLY (the QR renders
2158
+ * from `public.mfaEnroll.secret/uri`; the user record is written on confirm
2159
+ * — write-on-confirm). The single implementation behind BOTH provisioning
2160
+ * sites — `enroll-pick-method`'s auto-pick/picker tail and `enroll-totp-qr`
2161
+ * (covers the manage add/change path where the picker is skipped) — so the
2162
+ * account label baked into the `otpauth://` URI cannot drift between them.
2163
+ * The label comes from {@link resolveTotpAccountLabel} (human-readable
2164
+ * default); blank falls back to the subject uuid so the URI always carries
2165
+ * SOME account discriminator.
2166
+ */
2167
+ provisionTotpSecret(ctx) {
2168
+ const m = ctx.mfaEnroll ??= {};
2169
+ if (m.method !== "totp" || m.secret) return void 0;
2170
+ const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
2171
+ const secret = generateTotpSecret();
2172
+ const apply = (label) => {
2173
+ m.secret = secret;
2174
+ m.uri = generateTotpUri(secret, issuer, label.trim() || (ctx.subject ?? ""));
2175
+ };
2176
+ const label = this.resolveTotpAccountLabel(ctx);
2177
+ if (label instanceof Promise) return label.then(apply);
2178
+ return apply(label);
2179
+ }
2180
+ /**
2032
2181
  * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
2033
2182
  * the pincode timers/`sentTo`) off ctx — the shared teardown used by the
2034
2183
  * opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
@@ -2058,8 +2207,24 @@ let AuthWorkflow = class AuthWorkflow {
2058
2207
  * or `undefined` when valid. Email must look like an email; SMS is permissive
2059
2208
  * E.164-ish (normalized by {@link normalizeMfaAddress}). Override for stricter
2060
2209
  * (e.g. libphonenumber) validation.
2210
+ *
2211
+ * Ctx-first and async-capable, so record-based rules need no ctx-stash
2212
+ * workaround — an override can load the account and compare directly
2213
+ * (e.g. domain-allowlist the typed inbox, or require it to match a
2214
+ * record field). To PIN the address outright — never show the free-text
2215
+ * form at all — use {@link resolveEnrollAddress} instead; this hook is for
2216
+ * nuanced rules on what the user typed.
2217
+ *
2218
+ * ```ts
2219
+ * protected async validateMfaAddress(ctx: AuthWfCtx, method: MfaTransport, value: string) {
2220
+ * if (method === "email" && !value.trim().toLowerCase().endsWith("@corp.example")) {
2221
+ * return "Use your corporate email address";
2222
+ * }
2223
+ * return super.validateMfaAddress(ctx, method, value);
2224
+ * }
2225
+ * ```
2061
2226
  */
2062
- validateMfaAddress(method, value) {
2227
+ validateMfaAddress(_ctx, method, value) {
2063
2228
  const v = (value ?? "").trim();
2064
2229
  if (!v) return "This field is required";
2065
2230
  if (method === "email") return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? void 0 : "Enter a valid email address";
@@ -2240,6 +2405,7 @@ let AuthWorkflow = class AuthWorkflow {
2240
2405
  try {
2241
2406
  const result = await this.users.login(input.username, input.password, this.lockoutOverride(ctx));
2242
2407
  ctx.subject = result.user.id;
2408
+ ctx.loginRecorded = true;
2243
2409
  swapStrategy("store");
2244
2410
  if (ctx.guards?.passwordInitial && result.user.password.isInitial) ctx.isPasswordInitial = true;
2245
2411
  if (ctx.guards?.passwordExpiry && this.users.isPasswordExpired(result.user)) ctx.isPasswordExpired = true;
@@ -2879,14 +3045,30 @@ let AuthWorkflow = class AuthWorkflow {
2879
3045
  value: envelope,
2880
3046
  cookies: auth.buildFinishedCookies(issue)
2881
3047
  });
3048
+ await this.afterPasswordChanged(ctx);
2882
3049
  }
2883
3050
  /**
2884
3051
  * Terminal for the manage-MFA flow. The user KEEPS their current session (no
2885
3052
  * 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.
3053
+ * removed → changed (`replace` + done) → added (done) → blocked
3054
+ * (un-removable operation aborted by `confirm-remove-mfa`) →
3055
+ * nothing-available (zero candidates, never had to step-up) → cancelled.
2888
3056
  */
2889
3057
  finishAddMfa(ctx) {
3058
+ useWfFinished().set({
3059
+ type: "data",
3060
+ value: this.buildAddMfaFinishEnvelope(ctx)
3061
+ });
3062
+ if (!ctx.addMfa?.removed && !(ctx.mfaEnroll?.done && ctx.mfaEnroll?.method)) return void 0;
3063
+ const hook = this.afterMfaChanged(ctx);
3064
+ return hook instanceof Promise ? hook.then(() => void 0) : void 0;
3065
+ }
3066
+ /**
3067
+ * `finish-add-mfa`'s envelope construction, extracted pure so the outcome
3068
+ * priority (removed → changed → added → blocked → nothing-available →
3069
+ * cancelled) is unit-testable without a wf event context.
3070
+ */
3071
+ buildAddMfaFinishEnvelope(ctx) {
2890
3072
  const labels = {
2891
3073
  totp: "Authenticator app",
2892
3074
  email: "Email code",
@@ -2930,6 +3112,17 @@ let AuthWorkflow = class AuthWorkflow {
2930
3112
  text: `${labels[method]} added.`
2931
3113
  }
2932
3114
  };
3115
+ else if (addMfa?.blocked) envelope = {
3116
+ finished: true,
3117
+ data: {
3118
+ added: false,
3119
+ reason: addMfa.blocked
3120
+ },
3121
+ message: {
3122
+ level: "info",
3123
+ 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."
3124
+ }
3125
+ };
2933
3126
  else if (candidates.length === 0 && !addMfa?.stepUpRequired) envelope = {
2934
3127
  finished: true,
2935
3128
  data: {
@@ -2952,10 +3145,7 @@ let AuthWorkflow = class AuthWorkflow {
2952
3145
  text: "No changes were made to your two-factor methods."
2953
3146
  }
2954
3147
  };
2955
- useWfFinished().set({
2956
- type: "data",
2957
- value: envelope
2958
- });
3148
+ return envelope;
2959
3149
  }
2960
3150
  /**
2961
3151
  * Resolve which transports the user may NOT change/remove via the manage flow
@@ -2982,6 +3172,46 @@ let AuthWorkflow = class AuthWorkflow {
2982
3172
  (ctx.addMfa ??= {}).stepUpDone = true;
2983
3173
  }
2984
3174
  /**
3175
+ * Manage-MFA step-up consent — pauses on `StepUpConfirmForm` ("To continue,
3176
+ * we will send a verification code to ma•••@x") BEFORE `pincode-send`
3177
+ * dispatches the step-up code, so opening the manage dialog never consumes
3178
+ * a code send as a side effect. Fires only on the auto-picked paths (single
3179
+ * factor, or default factor) — an explicit `select-2fa` pick already
3180
+ * counts as consent (`select2fa` sets `stepUpConfirmed`). `Continue`
3181
+ * consents and the SAME engine pass mints + sends; `useDifferentMethod`
3182
+ * re-opens the picker; `cancel` aborts with nothing dispatched (the
3183
+ * schema's `{ break: aborted }` right after this step keeps the pair from
3184
+ * sending the declined code). Gated by {@link resolveStepUpConfirmBeforeSend}
3185
+ * (default on) — an opt-out marks consent and falls straight through.
3186
+ */
3187
+ manageStepUpConfirm(ctx) {
3188
+ const result = this.resolveStepUpConfirmBeforeSend(ctx);
3189
+ if (result instanceof Promise) return result.then((r) => this.applyStepUpConfirm(ctx, r));
3190
+ return this.applyStepUpConfirm(ctx, result);
3191
+ }
3192
+ /** `manage-stepup-confirm` tail — opt-out fall-through or the consent pause. */
3193
+ applyStepUpConfirm(ctx, confirmBeforeSend) {
3194
+ const addMfa = ctx.addMfa ??= {};
3195
+ if (!confirmBeforeSend) {
3196
+ addMfa.stepUpConfirmed = true;
3197
+ return;
3198
+ }
3199
+ const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.stepUpConfirm);
3200
+ const action = wf.resolveAction();
3201
+ if (action === "cancel") {
3202
+ ctx.aborted = true;
3203
+ return;
3204
+ }
3205
+ if (action === "useDifferentMethod") {
3206
+ const mfa = ctx.mfa ??= {};
3207
+ mfa.ignoreDefault = true;
3208
+ delete mfa.method;
3209
+ return;
3210
+ }
3211
+ wf.resolveInput();
3212
+ addMfa.stepUpConfirmed = true;
3213
+ }
3214
+ /**
2985
3215
  * Manage-MFA password re-auth — the step-up FALLBACK when the user's only
2986
3216
  * confirmed factor(s) are of kinds the policy no longer allows, so nothing is
2987
3217
  * MFA-challengeable (`addMfa.stepUpMode === "password"`; see `init-add-mfa`).
@@ -3006,6 +3236,17 @@ let AuthWorkflow = class AuthWorkflow {
3006
3236
  (ctx.otp ??= {}).verified = true;
3007
3237
  }
3008
3238
  /**
3239
+ * The keep-at-least-one rule: removing the user's LAST confirmed factor under
3240
+ * a `required` policy can never succeed. The single source for the predicate
3241
+ * `manage-menu` mirrors into `addMfa.removeBlocked` (to drop the Remove option)
3242
+ * AND `confirm-remove-mfa` re-checks before its pause (defence in depth) — so
3243
+ * a policy change (e.g. "keep at least two", or a backup-codes exception)
3244
+ * lands in one place, not two copies that can drift.
3245
+ */
3246
+ isLastRequiredFactor(ctx) {
3247
+ return (ctx.mfa?.enrolledMethods?.length ?? 0) <= 1 && ctx.mfaPolicy?.mode === "required";
3248
+ }
3249
+ /**
3009
3250
  * Manage-MFA menu — pauses on `ManageMfaForm` and routes the chosen
3010
3251
  * `operation` (`add:<t>` / `replace:<t>` / `remove:<t>`). Only reached when
3011
3252
  * the user has ≥1 confirmed factor (a zero-MFA user goes straight to the enrol
@@ -3024,6 +3265,8 @@ let AuthWorkflow = class AuthWorkflow {
3024
3265
  ctx.aborted = true;
3025
3266
  return;
3026
3267
  }
3268
+ if (this.isLastRequiredFactor(ctx)) addMfa.removeBlocked = true;
3269
+ else delete addMfa.removeBlocked;
3027
3270
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.manageMfa);
3028
3271
  if (wf.resolveAction() === "cancel") {
3029
3272
  ctx.aborted = true;
@@ -3038,6 +3281,7 @@ let AuthWorkflow = class AuthWorkflow {
3038
3281
  } else if (action === "replace" || action === "remove") {
3039
3282
  if (locked.has(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be changed here." });
3040
3283
  if (!enrolled.some((e) => e.kind === target)) throw this.throwPublic(ctx, wf, { errors: { operation: "Unknown method" } });
3284
+ if (action === "remove" && addMfa.removeBlocked) throw this.throwPublic(ctx, wf, { formMessage: "You must keep at least one two-factor method." });
3041
3285
  } else throw this.throwPublic(ctx, wf, { errors: { operation: "Choose an option" } });
3042
3286
  addMfa.action = action;
3043
3287
  addMfa.target = target;
@@ -3054,22 +3298,35 @@ let AuthWorkflow = class AuthWorkflow {
3054
3298
  /**
3055
3299
  * Manage-MFA remove confirmation. Pauses on `RemoveMfaConfirmForm`; the
3056
3300
  * '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).
3301
+ * set and the keep-at-least-one rule (LAST confirmed factor under a
3302
+ * `required` policy) BEFORE the pause and an un-removable state aborts to
3303
+ * the `finish-add-mfa` terminal (reason on `addMfa.blocked`) instead of
3304
+ * pausing: `manage-menu` filters these operations out, so arriving here
3305
+ * blocked means a stale/crafted route, and a retryable form whose only
3306
+ * submit re-throws the same guard would be a dead-end loop (the manage
3307
+ * forms hide their built-in cancel — the host owns it).
3059
3308
  */
3060
3309
  async confirmRemoveMfa(ctx) {
3061
3310
  this.requireSubject(ctx);
3062
3311
  const username = ctx.subject;
3063
3312
  const addMfa = ctx.addMfa ??= {};
3064
3313
  const target = addMfa.target;
3314
+ const enrolled = ctx.mfa?.enrolledMethods ?? [];
3315
+ if ((addMfa.locked ?? []).includes(target)) {
3316
+ addMfa.blocked = "method-locked";
3317
+ ctx.aborted = true;
3318
+ return;
3319
+ }
3320
+ if (this.isLastRequiredFactor(ctx)) {
3321
+ addMfa.blocked = "last-required-factor";
3322
+ ctx.aborted = true;
3323
+ return;
3324
+ }
3065
3325
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.removeMfaConfirm);
3066
3326
  if (wf.resolveAction() === "cancel") {
3067
3327
  ctx.aborted = true;
3068
3328
  return;
3069
3329
  }
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
3330
  wf.resolveInput();
3074
3331
  const methodName = enrolled.find((e) => e.kind === target)?.methodName ?? target;
3075
3332
  await this.withStoreErrorTranslation(() => this.users.removeMfaMethod(username, methodName));
@@ -3293,6 +3550,7 @@ let AuthWorkflow = class AuthWorkflow {
3293
3550
  }
3294
3551
  }
3295
3552
  mfa.method = picked.kind;
3553
+ if (ctx.addMfa) ctx.addMfa.stepUpConfirmed = true;
3296
3554
  mfa.saveAsDefault = Boolean(input.saveAsDefault);
3297
3555
  if (mfa.saveAsDefault && ctx.subject) await this.users.setDefaultMfaMethod(ctx.subject, picked.methodName);
3298
3556
  }
@@ -3441,7 +3699,6 @@ let AuthWorkflow = class AuthWorkflow {
3441
3699
  */
3442
3700
  enrollPickMethod(ctx) {
3443
3701
  this.requireSubject(ctx);
3444
- const username = ctx.subject;
3445
3702
  const transports = ctx.mfaPolicy?.availableTransports ?? [];
3446
3703
  const m = ctx.mfaEnroll ??= {};
3447
3704
  const mode = m.mode === "manage" ? "manage" : ctx.mfaPolicy?.mode === "required" ? "required" : "optional";
@@ -3472,28 +3729,45 @@ let AuthWorkflow = class AuthWorkflow {
3472
3729
  if (!m.availableTransports.includes(picked)) throw this.throwPublic(ctx, wf, { errors: { method: "Unknown method" } });
3473
3730
  m.method = picked;
3474
3731
  }
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
- }
3732
+ return this.provisionTotpSecret(ctx);
3481
3733
  }
3482
3734
  /**
3483
3735
  * 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.
3736
+ * totp. Asks {@link resolveEnrollAddress} FIRST a deployment that pins
3737
+ * the address (factor bound to an account record) stages it here and the
3738
+ * free-text form never renders; this single call site covers every trio
3739
+ * path (picker, auto-pick, manage add/replace pre-seed), and `enroll-send`
3740
+ * dispatches to the pinned address in the same engine pass. Otherwise
3741
+ * (`'collect'`) handles `skip` (opt-in) / `cancel` (manage) /
3742
+ * `useDifferentMethod`, validates the typed address server-side via the
3743
+ * ctx-first {@link validateMfaAddress} (the client `@ui.form.validate`
3744
+ * hint is advisory), then STAGES the candidate value in wf-state
3745
+ * (`m.address`) — the user record is written only on confirm
3746
+ * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps
3747
+ * the old confirmed value live until the new code verifies in
3748
+ * `enroll-confirm`. Collection ONLY: the pincode dispatch lives in
3749
+ * `enroll-send` (same engine pass, no extra round-trip), so a consumer
3750
+ * pre-seeding `mfaEnroll.address` — which skips this whole step via its
3751
+ * schema condition — still gets exactly one code.
3493
3752
  */
3494
3753
  enrollAddress(ctx) {
3495
3754
  this.requireSubject(ctx);
3755
+ const methodName = (ctx.mfaEnroll ??= {}).method;
3756
+ const pinned = this.resolveEnrollAddress(ctx, methodName);
3757
+ if (pinned instanceof Promise) return pinned.then((p) => this.collectEnrollAddress(ctx, methodName, p));
3758
+ return this.collectEnrollAddress(ctx, methodName, pinned);
3759
+ }
3760
+ /**
3761
+ * `enroll-address` tail — stage a pinned address, or run the free-text
3762
+ * collect pause (skip/cancel/useDifferentMethod triage + ctx-first
3763
+ * validation + write-on-confirm staging).
3764
+ */
3765
+ collectEnrollAddress(ctx, methodName, pinned) {
3496
3766
  const m = ctx.mfaEnroll ??= {};
3767
+ if (pinned !== "collect" && pinned.trim()) {
3768
+ m.address = this.normalizeMfaAddress(methodName, pinned.trim());
3769
+ return;
3770
+ }
3497
3771
  const mode = m.mode ?? "optional";
3498
3772
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollAddress);
3499
3773
  const action = wf.resolveAction();
@@ -3511,10 +3785,13 @@ let AuthWorkflow = class AuthWorkflow {
3511
3785
  return;
3512
3786
  }
3513
3787
  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);
3788
+ const stage = (addrErr) => {
3789
+ if (addrErr) throw this.throwPublic(ctx, wf, { errors: { address: addrErr } });
3790
+ m.address = this.normalizeMfaAddress(methodName, input.address);
3791
+ };
3792
+ const addrErr = this.validateMfaAddress(ctx, methodName, input.address);
3793
+ if (addrErr instanceof Promise) return addrErr.then(stage);
3794
+ return stage(addrErr);
3518
3795
  }
3519
3796
  /**
3520
3797
  * Unified MFA-enrol dispatch (sms/email only) — the trio's ONLY pincode
@@ -3551,14 +3828,8 @@ let AuthWorkflow = class AuthWorkflow {
3551
3828
  */
3552
3829
  async enrollTotpQr(ctx) {
3553
3830
  this.requireSubject(ctx);
3554
- const username = ctx.subject;
3555
3831
  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
- }
3832
+ await this.provisionTotpSecret(ctx);
3562
3833
  const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollTotpQr);
3563
3834
  if (this.handleEnrollExit(ctx, wf.resolveAction())) return void 0;
3564
3835
  wf.resolveInput();
@@ -3852,6 +4123,71 @@ let AuthWorkflow = class AuthWorkflow {
3852
4123
  await this.users.unlockAccount(ctx.subject);
3853
4124
  }
3854
4125
  /**
4126
+ * The SINGLE login-completion funnel. Every flow that establishes an
4127
+ * authenticated session routes through here — placed in each login schema
4128
+ * right after the guards and BEFORE the delivery terminal (`issue` /
4129
+ * `mint-authz-code` / `finalize-auto-login`) — so a login can never be
4130
+ * delivered without passing this point. Two uniform jobs:
4131
+ * 1. Stamp `account.lastLogin` exactly once (the sole workflow writer of it).
4132
+ * 2. Fire the `afterLogin` customer hook.
4133
+ *
4134
+ * Idempotent per run via `ctx.loginRecorded`: the password `credentials` path
4135
+ * already stamped eagerly through `users.login()` and latched the flag, so the
4136
+ * stamp NO-OPS there (no double write) — but `afterLogin` still fires, so the
4137
+ * hook runs exactly once for password logins too. Federated (`sso-callback`)
4138
+ * and auto-login (invite/signup/recovery) paths never call `login()`, so this
4139
+ * is their sole stamp. Self-gates on `ctx.subject`. Runs AFTER
4140
+ * `prepare-semantic-flags` derived `isFirstLogin`, so a genuine first
4141
+ * federated login still observes `isFirstLogin === true`.
4142
+ *
4143
+ * A throw (from the stamp or the hook) aborts the flow BEFORE delivery, so the
4144
+ * login fails atomically — no half-issued session.
4145
+ */
4146
+ recordLogin(ctx) {
4147
+ if (!ctx.subject) return void 0;
4148
+ if (!ctx.loginRecorded) {
4149
+ ctx.loginRecorded = true;
4150
+ return this.users.recordLogin(ctx.subject).then(() => this.afterLogin(ctx)).then(() => void 0);
4151
+ }
4152
+ const hook = this.afterLogin(ctx);
4153
+ return hook instanceof Promise ? hook.then(() => void 0) : void 0;
4154
+ }
4155
+ /**
4156
+ * Recovery-only guard, extracted from the finalize terminals so they stay pure
4157
+ * delivery. An `admin-only` lockout never self-unlocks, so a reset that left
4158
+ * the account frozen must NOT be confirmed-as-success OR auto-logged-in
4159
+ * (minting tokens would defeat the very freeze). When still locked it emits the
4160
+ * warn terminal and sets `ctx.aborted`, so the schema's following `{ break }`
4161
+ * skips BOTH the `record-login` funnel and the finalize terminals — a frozen
4162
+ * account is never stamped or logged in. The schema gates this on
4163
+ * `ctx.lockout?.mode === "admin-only"` (the only mode that can reach finalize
4164
+ * still locked: self-service ran `unlock-account`; temporary auto-expires).
4165
+ */
4166
+ async recoveryLockCheck(ctx) {
4167
+ if (await this.recoveryLeftAccountLocked(ctx)) {
4168
+ this.finishRecoveryReset(ctx, true);
4169
+ ctx.aborted = true;
4170
+ }
4171
+ }
4172
+ /**
4173
+ * Thin dispatcher steps for the flow-specific lifecycle hooks. Each is its own
4174
+ * schema step (rather than an in-terminal call) because its flow's finalize
4175
+ * terminals are SHARED across flows (`finalize-auto-login` serves invite +
4176
+ * recovery + signup; `finalize-fresh-login` serves invite + recovery), so a
4177
+ * dedicated step in the flow-specific schema fires the right hook without
4178
+ * ctx-discrimination inside a shared terminal. Named `fire*` so the overridable
4179
+ * `after*` hooks keep the clean public name.
4180
+ */
4181
+ fireInvitationAccepted(ctx) {
4182
+ return this.afterInvitationAccepted(ctx);
4183
+ }
4184
+ fireSignup(ctx) {
4185
+ return this.afterSignup(ctx);
4186
+ }
4187
+ firePasswordReset(ctx) {
4188
+ return this.afterPasswordReset(ctx);
4189
+ }
4190
+ /**
3855
4191
  * Issue access + refresh tokens via `auth.issue`. Stashes the login
3856
4192
  * response envelope on `useWfFinished` so downstream `redirect` can
3857
4193
  * override with a redirect envelope while preserving the cookies.
@@ -3871,7 +4207,7 @@ let AuthWorkflow = class AuthWorkflow {
3871
4207
  ...cookies,
3872
4208
  [name]: {
3873
4209
  value,
3874
- options: auth.cookieAttrs({ maxAge: ttlMs / 1e3 })
4210
+ options: auth.cookieAttrs({ maxAge: ttlMs })
3875
4211
  }
3876
4212
  };
3877
4213
  };
@@ -4083,9 +4419,6 @@ let AuthWorkflow = class AuthWorkflow {
4083
4419
  (ctx.completion ??= {}).redirectUrl = target;
4084
4420
  return;
4085
4421
  }
4086
- if (ctx.lockout?.mode === "admin-only") return this.recoveryLeftAccountLocked(ctx).then((locked) => {
4087
- this.finishRecoveryReset(ctx, locked);
4088
- });
4089
4422
  this.finishRecoveryReset(ctx, false);
4090
4423
  }
4091
4424
  /**
@@ -4147,17 +4480,20 @@ let AuthWorkflow = class AuthWorkflow {
4147
4480
  (ctx.completion ??= {}).redirectUrl = target;
4148
4481
  }
4149
4482
  /**
4150
- * Auto-login finalize — invite + recovery. Issues access + refresh tokens
4151
- * and stashes the login response envelope on `useWfFinished`. Invite
4152
- * preserves any `message` set by an earlier terminal (`confirmation`) so
4153
- * the SPA paints the confirmation text alongside the tokens (WF-INVITE-020).
4483
+ * Auto-login finalize — invite + recovery + signup. PURE delivery: issues
4484
+ * access + refresh tokens and stashes the login response envelope on
4485
+ * `useWfFinished`. Invite preserves any `message` set by an earlier terminal
4486
+ * (`confirmation`) so the SPA paints the confirmation text alongside the
4487
+ * tokens (WF-INVITE-020).
4488
+ *
4489
+ * It records NOTHING and guards NOTHING: `account.lastLogin` + the
4490
+ * `afterLogin` hook are owned by the upstream `record-login` funnel step, and
4491
+ * the admin-only-survived-lock guard by the upstream `recovery-lock-check`
4492
+ * step — both of which `{ break }`/gate this step out when they apply, so a
4493
+ * still-frozen account never reaches here.
4154
4494
  */
4155
4495
  async finalizeAutoLogin(ctx) {
4156
4496
  this.requireSubject(ctx);
4157
- if (ctx.lockout?.mode === "admin-only" && await this.recoveryLeftAccountLocked(ctx)) {
4158
- this.finishRecoveryReset(ctx, true);
4159
- return;
4160
- }
4161
4497
  const issue = await this.issueForContext(ctx);
4162
4498
  const auth = useAuth();
4163
4499
  const previousMessage = (useWfFinished().get()?.value)?.message;
@@ -4657,12 +4993,19 @@ let AuthWorkflow = class AuthWorkflow {
4657
4993
  * 3. STEP-UP (only when `stepUpRequired`): re-verify identity before any
4658
4994
  * change — `mfaStepUpLoop` challenges an EXISTING factor when one is still
4659
4995
  * challengeable (`stepUpMode==='mfa'`), else `manage-password-reauth` falls
4660
- * back to the account password (`stepUpMode==='password'`). On success
4996
+ * back to the account password (`stepUpMode==='password'`). The sms/email
4997
+ * challenge collects explicit consent (`manage-stepup-confirm`) BEFORE
4998
+ * dispatching its pincode — opening the dialog never sends a code as a
4999
+ * side effect (see `resolveStepUpConfirmBeforeSend`). On success
4661
5000
  * `manage-stepup-done` swaps off the encapsulated start onto the durable
4662
5001
  * `store` strategy (server-anchored, replay-resistant; mirrors login's
4663
5002
  * swap-after-credentials).
4664
5003
  * 4. `manage-menu` (only when `stepUpRequired`) — pick add / change / remove +
4665
- * target; pre-seeds `mfaEnroll.method` for add/change.
5004
+ * target; pre-seeds `mfaEnroll.method` for add/change. Un-offerable
5005
+ * operations never render: locked transports drop their Change/Remove
5006
+ * options, and the LAST factor under a `required` policy drops Remove
5007
+ * (`removeBlocked`) — `confirm-remove-mfa` aborts to the finish terminal
5008
+ * if a blocked remove arrives anyway (no retryable dead-end form).
4666
5009
  * 5. Route: `confirm-remove-mfa` for remove; otherwise the REUSED enrol trio
4667
5010
  * (`enroll-pick-method` → `enroll-address` / `enroll-totp-qr` →
4668
5011
  * `enroll-confirm`). A zero-MFA user skips step-up + menu and lands on the
@@ -5021,7 +5364,7 @@ __decorate([
5021
5364
  __decorateParam(0, WorkflowParam("context")),
5022
5365
  __decorateMetadata("design:type", Function),
5023
5366
  __decorateMetadata("design:paramtypes", [Object]),
5024
- __decorateMetadata("design:returntype", void 0)
5367
+ __decorateMetadata("design:returntype", Object)
5025
5368
  ], AuthWorkflow.prototype, "finishAddMfa", null);
5026
5369
  __decorate([
5027
5370
  Step("prepare-locked-mfa-transports"),
@@ -5041,6 +5384,15 @@ __decorate([
5041
5384
  __decorateMetadata("design:paramtypes", [Object]),
5042
5385
  __decorateMetadata("design:returntype", void 0)
5043
5386
  ], AuthWorkflow.prototype, "manageStepUpDone", null);
5387
+ __decorate([
5388
+ Step("manage-stepup-confirm"),
5389
+ ArbacResource("auth.add-mfa"),
5390
+ ArbacAction("self"),
5391
+ __decorateParam(0, WorkflowParam("context")),
5392
+ __decorateMetadata("design:type", Function),
5393
+ __decorateMetadata("design:paramtypes", [Object]),
5394
+ __decorateMetadata("design:returntype", Object)
5395
+ ], AuthWorkflow.prototype, "manageStepUpConfirm", null);
5044
5396
  __decorate([
5045
5397
  Step("manage-password-reauth"),
5046
5398
  ArbacResource("auth.add-mfa"),
@@ -5156,7 +5508,7 @@ __decorate([
5156
5508
  __decorateParam(0, WorkflowParam("context")),
5157
5509
  __decorateMetadata("design:type", Function),
5158
5510
  __decorateMetadata("design:paramtypes", [Object]),
5159
- __decorateMetadata("design:returntype", void 0)
5511
+ __decorateMetadata("design:returntype", Object)
5160
5512
  ], AuthWorkflow.prototype, "enrollAddress", null);
5161
5513
  __decorate([
5162
5514
  Step("enroll-send"),
@@ -5270,6 +5622,46 @@ __decorate([
5270
5622
  __decorateMetadata("design:paramtypes", [Object]),
5271
5623
  __decorateMetadata("design:returntype", Promise)
5272
5624
  ], AuthWorkflow.prototype, "unlockAccount", null);
5625
+ __decorate([
5626
+ Step("record-login"),
5627
+ Public(),
5628
+ __decorateParam(0, WorkflowParam("context")),
5629
+ __decorateMetadata("design:type", Function),
5630
+ __decorateMetadata("design:paramtypes", [Object]),
5631
+ __decorateMetadata("design:returntype", Object)
5632
+ ], AuthWorkflow.prototype, "recordLogin", null);
5633
+ __decorate([
5634
+ Step("recovery-lock-check"),
5635
+ Public(),
5636
+ __decorateParam(0, WorkflowParam("context")),
5637
+ __decorateMetadata("design:type", Function),
5638
+ __decorateMetadata("design:paramtypes", [Object]),
5639
+ __decorateMetadata("design:returntype", Promise)
5640
+ ], AuthWorkflow.prototype, "recoveryLockCheck", null);
5641
+ __decorate([
5642
+ Step("after-invitation-accepted"),
5643
+ Public(),
5644
+ __decorateParam(0, WorkflowParam("context")),
5645
+ __decorateMetadata("design:type", Function),
5646
+ __decorateMetadata("design:paramtypes", [Object]),
5647
+ __decorateMetadata("design:returntype", Object)
5648
+ ], AuthWorkflow.prototype, "fireInvitationAccepted", null);
5649
+ __decorate([
5650
+ Step("after-signup"),
5651
+ Public(),
5652
+ __decorateParam(0, WorkflowParam("context")),
5653
+ __decorateMetadata("design:type", Function),
5654
+ __decorateMetadata("design:paramtypes", [Object]),
5655
+ __decorateMetadata("design:returntype", Object)
5656
+ ], AuthWorkflow.prototype, "fireSignup", null);
5657
+ __decorate([
5658
+ Step("after-password-reset"),
5659
+ Public(),
5660
+ __decorateParam(0, WorkflowParam("context")),
5661
+ __decorateMetadata("design:type", Function),
5662
+ __decorateMetadata("design:paramtypes", [Object]),
5663
+ __decorateMetadata("design:returntype", Object)
5664
+ ], AuthWorkflow.prototype, "firePasswordReset", null);
5273
5665
  __decorate([
5274
5666
  Step("issue"),
5275
5667
  Public(),
@@ -5316,7 +5708,7 @@ __decorate([
5316
5708
  __decorateParam(0, WorkflowParam("context")),
5317
5709
  __decorateMetadata("design:type", Function),
5318
5710
  __decorateMetadata("design:paramtypes", [Object]),
5319
- __decorateMetadata("design:returntype", Object)
5711
+ __decorateMetadata("design:returntype", void 0)
5320
5712
  ], AuthWorkflow.prototype, "finalizeFreshLogin", null);
5321
5713
  __decorate([
5322
5714
  Step("finalize-auto-login"),
@@ -5482,6 +5874,10 @@ __decorate([
5482
5874
  }]
5483
5875
  },
5484
5876
  { break: (ctx) => !!ctx.aborted },
5877
+ {
5878
+ id: "record-login",
5879
+ condition: (ctx) => !!ctx.subject
5880
+ },
5485
5881
  {
5486
5882
  condition: (ctx) => !ctx.authz,
5487
5883
  steps: [
@@ -5555,6 +5951,7 @@ __decorate([
5555
5951
  ...consentsPersistTailSchema,
5556
5952
  { id: "unset-pending-invitation" },
5557
5953
  { id: "activate-user" },
5954
+ { id: "after-invitation-accepted" },
5558
5955
  {
5559
5956
  id: "confirmation",
5560
5957
  condition: (ctx) => !!ctx.accept?.showConfirmation
@@ -5563,6 +5960,10 @@ __decorate([
5563
5960
  id: "finalize-fresh-login",
5564
5961
  condition: (ctx) => !ctx.autoLogin
5565
5962
  },
5963
+ {
5964
+ id: "record-login",
5965
+ condition: (ctx) => !!ctx.autoLogin
5966
+ },
5566
5967
  {
5567
5968
  id: "finalize-auto-login",
5568
5969
  condition: (ctx) => !!ctx.autoLogin
@@ -5605,10 +6006,20 @@ __decorate([
5605
6006
  condition: (ctx) => ctx.lockout?.mode === "self-service"
5606
6007
  },
5607
6008
  ...consentsPersistTailSchema,
6009
+ { id: "after-password-reset" },
6010
+ {
6011
+ id: "recovery-lock-check",
6012
+ condition: (ctx) => ctx.lockout?.mode === "admin-only"
6013
+ },
6014
+ { break: (ctx) => !!ctx.aborted },
5608
6015
  {
5609
6016
  id: "finalize-fresh-login",
5610
6017
  condition: (ctx) => !ctx.autoLogin
5611
6018
  },
6019
+ {
6020
+ id: "record-login",
6021
+ condition: (ctx) => !!ctx.autoLogin
6022
+ },
5612
6023
  {
5613
6024
  id: "finalize-auto-login",
5614
6025
  condition: (ctx) => !!ctx.autoLogin
@@ -5659,14 +6070,13 @@ __decorate([
5659
6070
  id: "manage-password-reauth",
5660
6071
  condition: (ctx) => !!ctx.addMfa?.stepUpRequired && ctx.addMfa?.stepUpMode === "password" && !ctx.otp?.verified
5661
6072
  },
5662
- { break: (ctx) => !!ctx.aborted },
5663
6073
  {
5664
6074
  id: "manage-stepup-done",
5665
- condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !!ctx.otp?.verified && !ctx.addMfa?.stepUpDone
6075
+ condition: (ctx) => !ctx.aborted && !!ctx.addMfa?.stepUpRequired && !!ctx.otp?.verified && !ctx.addMfa?.stepUpDone
5666
6076
  },
5667
6077
  {
5668
6078
  id: "manage-menu",
5669
- condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
6079
+ condition: (ctx) => !ctx.aborted && !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
5670
6080
  },
5671
6081
  {
5672
6082
  id: "confirm-remove-mfa",
@@ -5706,6 +6116,8 @@ __decorate([
5706
6116
  { id: "activate-user" },
5707
6117
  ...consentsPersistTailSchema,
5708
6118
  { id: "signup-extra-step" },
6119
+ { id: "after-signup" },
6120
+ { id: "record-login" },
5709
6121
  { id: "finalize-auto-login" }
5710
6122
  ]),
5711
6123
  __decorateMetadata("design:type", Function),