@aooth/auth-moost 0.1.21 → 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/atscript/index.d.mts +38 -20
- package/dist/atscript/index.mjs +2 -2
- package/dist/{forms-uqegc32h.mjs → forms-xaBNc5Ng.mjs} +19 -3
- package/dist/index.d.mts +267 -21
- package/dist/index.mjs +399 -72
- package/package.json +9 -9
- package/src/atscript/models/forms.as +45 -2
- package/src/atscript/models/forms.as.d.ts +38 -19
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as Select2faForm,
|
|
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";
|
|
@@ -797,6 +797,10 @@ const enrollTrioSteps = [
|
|
|
797
797
|
id: "enroll-totp-qr",
|
|
798
798
|
condition: (ctx) => ctx.mfaEnroll?.method === "totp" && !ctx.mfaEnroll.qrSeen
|
|
799
799
|
},
|
|
800
|
+
{
|
|
801
|
+
id: "enroll-send",
|
|
802
|
+
condition: (ctx) => (ctx.mfaEnroll?.method === "sms" || ctx.mfaEnroll?.method === "email") && !!ctx.mfaEnroll.address && !ctx.mfaEnroll.done && !ctx.mfaEnroll.preConfirmed && !ctx.pin
|
|
803
|
+
},
|
|
800
804
|
{
|
|
801
805
|
id: "enroll-confirm",
|
|
802
806
|
condition: (ctx) => !!ctx.mfaEnroll?.method && (ctx.mfaEnroll.method === "totp" ? !!ctx.mfaEnroll.qrSeen : !!ctx.mfaEnroll.address) && !ctx.mfaEnroll.done
|
|
@@ -811,21 +815,26 @@ const enrollTrioSteps = [
|
|
|
811
815
|
* Reuses the login challenge steps verbatim, but DELIBERATELY omits
|
|
812
816
|
* `check-trusted-device` and `risk-step-up`: in a management context a trusted
|
|
813
817
|
* device must NOT be allowed to bypass the step-up (that is the whole point of
|
|
814
|
-
* re-verifying before letting the user change/remove a factor).
|
|
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
|
|
815
822
|
* a challenge step flips `ctx.otp.verified`. Used by the standalone add/manage-
|
|
816
823
|
* MFA flow, guarded by `ctx.addMfa.stepUpRequired` (set only when the user has
|
|
817
824
|
* ≥1 confirmed method).
|
|
818
825
|
*
|
|
819
826
|
* The `while` also breaks on `ctx.aborted` so a cancel/exit on the challenge
|
|
820
|
-
* form (
|
|
821
|
-
* the MFA challenge) terminates the
|
|
822
|
-
*
|
|
823
|
-
* sub-schema
|
|
824
|
-
* `
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
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.)
|
|
829
838
|
*/
|
|
830
839
|
const mfaStepUpLoop = [{
|
|
831
840
|
while: (ctx) => !ctx.otp?.verified && !ctx.aborted,
|
|
@@ -842,6 +851,11 @@ const mfaStepUpLoop = [{
|
|
|
842
851
|
id: "select-2fa",
|
|
843
852
|
condition: (ctx) => !ctx.otp?.verified && !ctx.mfa?.method && (ctx.mfa?.enrolledMethods?.length ?? 0) > 1
|
|
844
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 },
|
|
845
859
|
{
|
|
846
860
|
condition: (ctx) => !ctx.otp?.verified && (ctx.mfa?.method === "sms" || ctx.mfa?.method === "email"),
|
|
847
861
|
steps: pincodeSendCheckPair
|
|
@@ -950,6 +964,7 @@ const DEFAULT_FORMS = {
|
|
|
950
964
|
manageMfa: ManageMfaForm,
|
|
951
965
|
removeMfaConfirm: RemoveMfaConfirmForm,
|
|
952
966
|
passwordReauth: PasswordReauthForm,
|
|
967
|
+
stepUpConfirm: StepUpConfirmForm,
|
|
953
968
|
select2fa: Select2faForm,
|
|
954
969
|
mfaCode: MfaCodeForm,
|
|
955
970
|
pincode: PincodeForm,
|
|
@@ -1414,6 +1429,68 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1414
1429
|
};
|
|
1415
1430
|
}
|
|
1416
1431
|
/**
|
|
1432
|
+
* Vouch that an MFA-enrolment address is verified-by-construction, skipping
|
|
1433
|
+
* the pincode round-trip: `enroll-send` sends nothing and `enroll-confirm`
|
|
1434
|
+
* writes the confirmed factor (+ `verifiedEmail` for email) with no
|
|
1435
|
+
* code-entry pause. Asked once per dispatch with the staged transport +
|
|
1436
|
+
* normalized address (ctx-first, extra positional args — same convention as
|
|
1437
|
+
* `resolveOtpDisclosure`).
|
|
1438
|
+
*
|
|
1439
|
+
* Default `false` — every enrolment proves its address. The canonical
|
|
1440
|
+
* override is the invite-accept case, where the user is inside the flow
|
|
1441
|
+
* only because they redeemed a magic link delivered to that exact address
|
|
1442
|
+
* minutes earlier (the same proof `activate-user` trusts to write
|
|
1443
|
+
* `account.verifiedEmail`):
|
|
1444
|
+
*
|
|
1445
|
+
* ```ts
|
|
1446
|
+
* protected resolveEnrollPreConfirmed(ctx: AuthWfCtx, method: MfaTransport, address: string) {
|
|
1447
|
+
* return !!ctx.accept && method === "email" && address === ctx.email;
|
|
1448
|
+
* }
|
|
1449
|
+
* ```
|
|
1450
|
+
*
|
|
1451
|
+
* Keep the equality check — the proof transfers ONLY to the address the
|
|
1452
|
+
* magic link was delivered to; vouching for a different address the user
|
|
1453
|
+
* typed would confirm an unproven inbox. Never asked for TOTP.
|
|
1454
|
+
*/
|
|
1455
|
+
resolveEnrollPreConfirmed(_ctx, _method, _address) {
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
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
|
+
/**
|
|
1417
1494
|
* Resolve the finalize policy. Reached from login.flow. `auditLogin` is
|
|
1418
1495
|
* dropped from the shape per §2 — audit moved out of the workflow layer.
|
|
1419
1496
|
*/
|
|
@@ -1527,6 +1604,40 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1527
1604
|
return [];
|
|
1528
1605
|
}
|
|
1529
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
|
+
/**
|
|
1530
1641
|
* Resolve the channel-OTP disclosure copy rendered beneath the email/phone
|
|
1531
1642
|
* input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
|
|
1532
1643
|
* Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
|
|
@@ -1837,7 +1948,11 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1837
1948
|
if (sub) pub.mfaEnroll = sub;
|
|
1838
1949
|
}
|
|
1839
1950
|
if (ctx.addMfa) {
|
|
1840
|
-
const sub = pickDefined(ctx.addMfa, [
|
|
1951
|
+
const sub = pickDefined(ctx.addMfa, [
|
|
1952
|
+
"candidates",
|
|
1953
|
+
"locked",
|
|
1954
|
+
"removeBlocked"
|
|
1955
|
+
]);
|
|
1841
1956
|
if (sub) pub.manage = sub;
|
|
1842
1957
|
}
|
|
1843
1958
|
if (ctx.defaults) {
|
|
@@ -1968,8 +2083,8 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1968
2083
|
}
|
|
1969
2084
|
/**
|
|
1970
2085
|
* Send an enrolment pincode and stamp `ctx.pincode.sentTo` with the masked
|
|
1971
|
-
* recipient.
|
|
1972
|
-
*
|
|
2086
|
+
* recipient. Reached only through {@link mintAndSendEnrollPincode}; kept
|
|
2087
|
+
* separate as the delivery-only override seam.
|
|
1973
2088
|
*/
|
|
1974
2089
|
async sendEnrollPincode(ctx, address, code) {
|
|
1975
2090
|
const pincode = ctx.pincode ??= {};
|
|
@@ -1984,6 +2099,44 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
1984
2099
|
});
|
|
1985
2100
|
}
|
|
1986
2101
|
/**
|
|
2102
|
+
* The single enrol-dispatch implementation: mint a fresh pin, arm the
|
|
2103
|
+
* resend cooldown + the code-length form hint, and deliver the code.
|
|
2104
|
+
* Shared by `enrollSend` (initial dispatch) and the resend arm inside
|
|
2105
|
+
* `enrollConfirm`, so the arming policy cannot drift between first send
|
|
2106
|
+
* and resend.
|
|
2107
|
+
*/
|
|
2108
|
+
async mintAndSendEnrollPincode(ctx, address) {
|
|
2109
|
+
const code = this.mintPin(ctx, this.opts.mfa.pincodeLength, this.opts.mfa.pincodeTtlMs);
|
|
2110
|
+
const pincode = ctx.pincode ??= {};
|
|
2111
|
+
pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
|
|
2112
|
+
pincode.codeLength = this.opts.mfa.pincodeLength;
|
|
2113
|
+
await this.sendEnrollPincode(ctx, address, code);
|
|
2114
|
+
}
|
|
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
|
+
/**
|
|
1987
2140
|
* Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
|
|
1988
2141
|
* the pincode timers/`sentTo`) off ctx — the shared teardown used by the
|
|
1989
2142
|
* opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
|
|
@@ -2000,6 +2153,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2000
2153
|
delete m.secret;
|
|
2001
2154
|
delete m.uri;
|
|
2002
2155
|
delete m.qrSeen;
|
|
2156
|
+
delete m.preConfirmed;
|
|
2003
2157
|
}
|
|
2004
2158
|
delete ctx.pin;
|
|
2005
2159
|
delete ctx.pinExpire;
|
|
@@ -2012,8 +2166,24 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2012
2166
|
* or `undefined` when valid. Email must look like an email; SMS is permissive
|
|
2013
2167
|
* E.164-ish (normalized by {@link normalizeMfaAddress}). Override for stricter
|
|
2014
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
|
+
* ```
|
|
2015
2185
|
*/
|
|
2016
|
-
validateMfaAddress(method, value) {
|
|
2186
|
+
validateMfaAddress(_ctx, method, value) {
|
|
2017
2187
|
const v = (value ?? "").trim();
|
|
2018
2188
|
if (!v) return "This field is required";
|
|
2019
2189
|
if (method === "email") return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? void 0 : "Enter a valid email address";
|
|
@@ -2837,10 +3007,22 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2837
3007
|
/**
|
|
2838
3008
|
* Terminal for the manage-MFA flow. The user KEEPS their current session (no
|
|
2839
3009
|
* re-issue, no cookies) — a plain data finish. Outcomes, in priority order:
|
|
2840
|
-
* removed → changed (`replace` + done) → added (done) →
|
|
2841
|
-
* (
|
|
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.
|
|
2842
3013
|
*/
|
|
2843
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) {
|
|
2844
3026
|
const labels = {
|
|
2845
3027
|
totp: "Authenticator app",
|
|
2846
3028
|
email: "Email code",
|
|
@@ -2884,6 +3066,17 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2884
3066
|
text: `${labels[method]} added.`
|
|
2885
3067
|
}
|
|
2886
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
|
+
};
|
|
2887
3080
|
else if (candidates.length === 0 && !addMfa?.stepUpRequired) envelope = {
|
|
2888
3081
|
finished: true,
|
|
2889
3082
|
data: {
|
|
@@ -2906,10 +3099,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2906
3099
|
text: "No changes were made to your two-factor methods."
|
|
2907
3100
|
}
|
|
2908
3101
|
};
|
|
2909
|
-
|
|
2910
|
-
type: "data",
|
|
2911
|
-
value: envelope
|
|
2912
|
-
});
|
|
3102
|
+
return envelope;
|
|
2913
3103
|
}
|
|
2914
3104
|
/**
|
|
2915
3105
|
* Resolve which transports the user may NOT change/remove via the manage flow
|
|
@@ -2936,6 +3126,46 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2936
3126
|
(ctx.addMfa ??= {}).stepUpDone = true;
|
|
2937
3127
|
}
|
|
2938
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
|
+
/**
|
|
2939
3169
|
* Manage-MFA password re-auth — the step-up FALLBACK when the user's only
|
|
2940
3170
|
* confirmed factor(s) are of kinds the policy no longer allows, so nothing is
|
|
2941
3171
|
* MFA-challengeable (`addMfa.stepUpMode === "password"`; see `init-add-mfa`).
|
|
@@ -2960,6 +3190,17 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2960
3190
|
(ctx.otp ??= {}).verified = true;
|
|
2961
3191
|
}
|
|
2962
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
|
+
/**
|
|
2963
3204
|
* Manage-MFA menu — pauses on `ManageMfaForm` and routes the chosen
|
|
2964
3205
|
* `operation` (`add:<t>` / `replace:<t>` / `remove:<t>`). Only reached when
|
|
2965
3206
|
* the user has ≥1 confirmed factor (a zero-MFA user goes straight to the enrol
|
|
@@ -2978,6 +3219,8 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2978
3219
|
ctx.aborted = true;
|
|
2979
3220
|
return;
|
|
2980
3221
|
}
|
|
3222
|
+
if (this.isLastRequiredFactor(ctx)) addMfa.removeBlocked = true;
|
|
3223
|
+
else delete addMfa.removeBlocked;
|
|
2981
3224
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.manageMfa);
|
|
2982
3225
|
if (wf.resolveAction() === "cancel") {
|
|
2983
3226
|
ctx.aborted = true;
|
|
@@ -2992,6 +3235,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
2992
3235
|
} else if (action === "replace" || action === "remove") {
|
|
2993
3236
|
if (locked.has(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be changed here." });
|
|
2994
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." });
|
|
2995
3239
|
} else throw this.throwPublic(ctx, wf, { errors: { operation: "Choose an option" } });
|
|
2996
3240
|
addMfa.action = action;
|
|
2997
3241
|
addMfa.target = target;
|
|
@@ -3008,22 +3252,35 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3008
3252
|
/**
|
|
3009
3253
|
* Manage-MFA remove confirmation. Pauses on `RemoveMfaConfirmForm`; the
|
|
3010
3254
|
* 'Remove' submit performs the removal, 'Cancel' aborts. Re-checks the locked
|
|
3011
|
-
* set
|
|
3012
|
-
*
|
|
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).
|
|
3013
3262
|
*/
|
|
3014
3263
|
async confirmRemoveMfa(ctx) {
|
|
3015
3264
|
this.requireSubject(ctx);
|
|
3016
3265
|
const username = ctx.subject;
|
|
3017
3266
|
const addMfa = ctx.addMfa ??= {};
|
|
3018
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
|
+
}
|
|
3019
3279
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.removeMfaConfirm);
|
|
3020
3280
|
if (wf.resolveAction() === "cancel") {
|
|
3021
3281
|
ctx.aborted = true;
|
|
3022
3282
|
return;
|
|
3023
3283
|
}
|
|
3024
|
-
if ((addMfa.locked ?? []).includes(target)) throw this.throwPublic(ctx, wf, { formMessage: "That method can't be removed here." });
|
|
3025
|
-
const enrolled = ctx.mfa?.enrolledMethods ?? [];
|
|
3026
|
-
if (enrolled.length <= 1 && ctx.mfaPolicy?.mode === "required") throw this.throwPublic(ctx, wf, { formMessage: "You must keep at least one two-factor method." });
|
|
3027
3284
|
wf.resolveInput();
|
|
3028
3285
|
const methodName = enrolled.find((e) => e.kind === target)?.methodName ?? target;
|
|
3029
3286
|
await this.withStoreErrorTranslation(() => this.users.removeMfaMethod(username, methodName));
|
|
@@ -3247,6 +3504,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3247
3504
|
}
|
|
3248
3505
|
}
|
|
3249
3506
|
mfa.method = picked.kind;
|
|
3507
|
+
if (ctx.addMfa) ctx.addMfa.stepUpConfirmed = true;
|
|
3250
3508
|
mfa.saveAsDefault = Boolean(input.saveAsDefault);
|
|
3251
3509
|
if (mfa.saveAsDefault && ctx.subject) await this.users.setDefaultMfaMethod(ctx.subject, picked.methodName);
|
|
3252
3510
|
}
|
|
@@ -3395,7 +3653,6 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3395
3653
|
*/
|
|
3396
3654
|
enrollPickMethod(ctx) {
|
|
3397
3655
|
this.requireSubject(ctx);
|
|
3398
|
-
const username = ctx.subject;
|
|
3399
3656
|
const transports = ctx.mfaPolicy?.availableTransports ?? [];
|
|
3400
3657
|
const m = ctx.mfaEnroll ??= {};
|
|
3401
3658
|
const mode = m.mode === "manage" ? "manage" : ctx.mfaPolicy?.mode === "required" ? "required" : "optional";
|
|
@@ -3426,25 +3683,45 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3426
3683
|
if (!m.availableTransports.includes(picked)) throw this.throwPublic(ctx, wf, { errors: { method: "Unknown method" } });
|
|
3427
3684
|
m.method = picked;
|
|
3428
3685
|
}
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3686
|
+
return this.provisionTotpSecret(ctx);
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* Unified MFA-enrol phase 2 (collect sms/email address). Not invoked for
|
|
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.
|
|
3706
|
+
*/
|
|
3707
|
+
enrollAddress(ctx) {
|
|
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);
|
|
3435
3713
|
}
|
|
3436
3714
|
/**
|
|
3437
|
-
*
|
|
3438
|
-
*
|
|
3439
|
-
*
|
|
3440
|
-
* `@ui.form.validate` hint is advisory), then STAGES the candidate value in
|
|
3441
|
-
* wf-state (`m.address`) — the user record is written only on confirm
|
|
3442
|
-
* (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps the
|
|
3443
|
-
* old confirmed value live until the new code verifies in `enroll-confirm`.
|
|
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).
|
|
3444
3718
|
*/
|
|
3445
|
-
|
|
3446
|
-
this.requireSubject(ctx);
|
|
3719
|
+
collectEnrollAddress(ctx, methodName, pinned) {
|
|
3447
3720
|
const m = ctx.mfaEnroll ??= {};
|
|
3721
|
+
if (pinned !== "collect" && pinned.trim()) {
|
|
3722
|
+
m.address = this.normalizeMfaAddress(methodName, pinned.trim());
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3448
3725
|
const mode = m.mode ?? "optional";
|
|
3449
3726
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollAddress);
|
|
3450
3727
|
const action = wf.resolveAction();
|
|
@@ -3462,16 +3739,35 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3462
3739
|
return;
|
|
3463
3740
|
}
|
|
3464
3741
|
const input = wf.resolveInput();
|
|
3465
|
-
const
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
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);
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* Unified MFA-enrol dispatch (sms/email only) — the trio's ONLY pincode
|
|
3752
|
+
* send. A separate step (the canonical "send if no pin" gate, mirroring
|
|
3753
|
+
* `pincode-send`) so both address paths share one dispatch site: collected
|
|
3754
|
+
* by `enroll-address`, or pre-seeded by a consumer (which skips
|
|
3755
|
+
* `enroll-address` entirely — previously skipping the dispatch with it and
|
|
3756
|
+
* stranding the user on a code form no code was sent for). Asks
|
|
3757
|
+
* `resolveEnrollPreConfirmed` first: a verified-by-construction address
|
|
3758
|
+
* (e.g. the invite email the magic link just proved) skips the round-trip —
|
|
3759
|
+
* `enroll-confirm` then writes the factor without pausing.
|
|
3760
|
+
*/
|
|
3761
|
+
async enrollSend(ctx) {
|
|
3762
|
+
this.requireSubject(ctx);
|
|
3763
|
+
const m = ctx.mfaEnroll ??= {};
|
|
3764
|
+
const method = m.method;
|
|
3765
|
+
const address = m.address;
|
|
3766
|
+
if (await this.resolveEnrollPreConfirmed(ctx, method, address)) {
|
|
3767
|
+
m.preConfirmed = true;
|
|
3768
|
+
return;
|
|
3769
|
+
}
|
|
3770
|
+
await this.mintAndSendEnrollPincode(ctx, address);
|
|
3475
3771
|
}
|
|
3476
3772
|
/**
|
|
3477
3773
|
* MFA-enrol TOTP QR step — shown on its OWN pause between method-pick and
|
|
@@ -3486,14 +3782,8 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3486
3782
|
*/
|
|
3487
3783
|
async enrollTotpQr(ctx) {
|
|
3488
3784
|
this.requireSubject(ctx);
|
|
3489
|
-
const username = ctx.subject;
|
|
3490
3785
|
const m = ctx.mfaEnroll ??= {};
|
|
3491
|
-
|
|
3492
|
-
const issuer = ctx.mfaPolicy?.issuer ?? this.opts.totpIssuer;
|
|
3493
|
-
const secret = generateTotpSecret();
|
|
3494
|
-
m.secret = secret;
|
|
3495
|
-
m.uri = generateTotpUri(secret, issuer, username);
|
|
3496
|
-
}
|
|
3786
|
+
await this.provisionTotpSecret(ctx);
|
|
3497
3787
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollTotpQr);
|
|
3498
3788
|
if (this.handleEnrollExit(ctx, wf.resolveAction())) return void 0;
|
|
3499
3789
|
wf.resolveInput();
|
|
@@ -3512,8 +3802,11 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3512
3802
|
*/
|
|
3513
3803
|
async enrollConfirm(ctx) {
|
|
3514
3804
|
this.requireSubject(ctx);
|
|
3515
|
-
const username = ctx.subject;
|
|
3516
3805
|
const m = ctx.mfaEnroll ??= {};
|
|
3806
|
+
if (m.preConfirmed && m.method !== "totp" && m.address) {
|
|
3807
|
+
await this.confirmEnrolledFactor(ctx);
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3517
3810
|
const wf = this.useAtscriptWfPublic(ctx, this.opts.forms.enrollConfirm);
|
|
3518
3811
|
const action = wf.resolveAction();
|
|
3519
3812
|
if (this.handleEnrollExit(ctx, action)) return void 0;
|
|
@@ -3524,11 +3817,7 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3524
3817
|
const waitSec = Math.ceil((cooldown - Date.now()) / 1e3);
|
|
3525
3818
|
throw this.throwPublic(ctx, wf, { formMessage: `Please wait ${waitSec}s before requesting another code` });
|
|
3526
3819
|
}
|
|
3527
|
-
|
|
3528
|
-
const pincode = ctx.pincode ??= {};
|
|
3529
|
-
pincode.resendAllowedAt = Date.now() + this.opts.mfa.pincodeResendTimeoutMs;
|
|
3530
|
-
pincode.codeLength = this.opts.mfa.pincodeLength;
|
|
3531
|
-
await this.sendEnrollPincode(ctx, m.address, code);
|
|
3820
|
+
await this.mintAndSendEnrollPincode(ctx, m.address);
|
|
3532
3821
|
return;
|
|
3533
3822
|
}
|
|
3534
3823
|
const input = wf.resolveInput();
|
|
@@ -3538,8 +3827,23 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
3538
3827
|
const pinErr = this.verifyPin(ctx, input.code);
|
|
3539
3828
|
if (pinErr) throw this.throwPublic(ctx, wf, { errors: pinErr });
|
|
3540
3829
|
}
|
|
3830
|
+
await this.confirmEnrolledFactor(ctx);
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* `enroll-confirm`'s write-on-confirm tail (uniform for ADD and REPLACE, all
|
|
3834
|
+
* transports): the staged value (sms/email `address`, totp `secret`) is now
|
|
3835
|
+
* proven — by a verified pincode/TOTP code, or vouched by
|
|
3836
|
+
* `resolveEnrollPreConfirmed` — so upsert it as confirmed. `addMfaMethod`
|
|
3837
|
+
* replaces any row of the same name, so a REPLACE atomically swaps in the
|
|
3838
|
+
* new value with no pre-confirm clobber window, and an ADD creates the row
|
|
3839
|
+
* fresh.
|
|
3840
|
+
*/
|
|
3841
|
+
async confirmEnrolledFactor(ctx) {
|
|
3842
|
+
this.requireSubject(ctx);
|
|
3843
|
+
const username = ctx.subject;
|
|
3844
|
+
const m = ctx.mfaEnroll ??= {};
|
|
3541
3845
|
const methodName = m.method;
|
|
3542
|
-
const value =
|
|
3846
|
+
const value = methodName === "totp" ? m.secret : m.address;
|
|
3543
3847
|
await this.withStoreErrorTranslation(() => this.users.addMfaMethod(username, {
|
|
3544
3848
|
name: methodName,
|
|
3545
3849
|
value,
|
|
@@ -4578,12 +4882,19 @@ let AuthWorkflow = class AuthWorkflow {
|
|
|
4578
4882
|
* 3. STEP-UP (only when `stepUpRequired`): re-verify identity before any
|
|
4579
4883
|
* change — `mfaStepUpLoop` challenges an EXISTING factor when one is still
|
|
4580
4884
|
* challengeable (`stepUpMode==='mfa'`), else `manage-password-reauth` falls
|
|
4581
|
-
* back to the account password (`stepUpMode==='password'`).
|
|
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
|
|
4582
4889
|
* `manage-stepup-done` swaps off the encapsulated start onto the durable
|
|
4583
4890
|
* `store` strategy (server-anchored, replay-resistant; mirrors login's
|
|
4584
4891
|
* swap-after-credentials).
|
|
4585
4892
|
* 4. `manage-menu` (only when `stepUpRequired`) — pick add / change / remove +
|
|
4586
|
-
* 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).
|
|
4587
4898
|
* 5. Route: `confirm-remove-mfa` for remove; otherwise the REUSED enrol trio
|
|
4588
4899
|
* (`enroll-pick-method` → `enroll-address` / `enroll-totp-qr` →
|
|
4589
4900
|
* `enroll-confirm`). A zero-MFA user skips step-up + menu and lands on the
|
|
@@ -4962,6 +5273,15 @@ __decorate([
|
|
|
4962
5273
|
__decorateMetadata("design:paramtypes", [Object]),
|
|
4963
5274
|
__decorateMetadata("design:returntype", void 0)
|
|
4964
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);
|
|
4965
5285
|
__decorate([
|
|
4966
5286
|
Step("manage-password-reauth"),
|
|
4967
5287
|
ArbacResource("auth.add-mfa"),
|
|
@@ -5077,8 +5397,16 @@ __decorate([
|
|
|
5077
5397
|
__decorateParam(0, WorkflowParam("context")),
|
|
5078
5398
|
__decorateMetadata("design:type", Function),
|
|
5079
5399
|
__decorateMetadata("design:paramtypes", [Object]),
|
|
5080
|
-
__decorateMetadata("design:returntype",
|
|
5400
|
+
__decorateMetadata("design:returntype", Object)
|
|
5081
5401
|
], AuthWorkflow.prototype, "enrollAddress", null);
|
|
5402
|
+
__decorate([
|
|
5403
|
+
Step("enroll-send"),
|
|
5404
|
+
Public(),
|
|
5405
|
+
__decorateParam(0, WorkflowParam("context")),
|
|
5406
|
+
__decorateMetadata("design:type", Function),
|
|
5407
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
5408
|
+
__decorateMetadata("design:returntype", Promise)
|
|
5409
|
+
], AuthWorkflow.prototype, "enrollSend", null);
|
|
5082
5410
|
__decorate([
|
|
5083
5411
|
Step("enroll-totp-qr"),
|
|
5084
5412
|
Public(),
|
|
@@ -5572,14 +5900,13 @@ __decorate([
|
|
|
5572
5900
|
id: "manage-password-reauth",
|
|
5573
5901
|
condition: (ctx) => !!ctx.addMfa?.stepUpRequired && ctx.addMfa?.stepUpMode === "password" && !ctx.otp?.verified
|
|
5574
5902
|
},
|
|
5575
|
-
{ break: (ctx) => !!ctx.aborted },
|
|
5576
5903
|
{
|
|
5577
5904
|
id: "manage-stepup-done",
|
|
5578
|
-
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
|
|
5579
5906
|
},
|
|
5580
5907
|
{
|
|
5581
5908
|
id: "manage-menu",
|
|
5582
|
-
condition: (ctx) => !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
|
|
5909
|
+
condition: (ctx) => !ctx.aborted && !!ctx.addMfa?.stepUpRequired && !ctx.addMfa?.action
|
|
5583
5910
|
},
|
|
5584
5911
|
{
|
|
5585
5912
|
id: "confirm-remove-mfa",
|