@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/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 +307 -27
- package/dist/index.mjs +483 -71
- 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";
|
|
@@ -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).
|
|
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 (
|
|
825
|
-
* the MFA challenge) terminates the
|
|
826
|
-
*
|
|
827
|
-
* sub-schema
|
|
828
|
-
* `
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
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, [
|
|
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) →
|
|
2887
|
-
* (
|
|
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
|
-
|
|
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
|
|
3058
|
-
*
|
|
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
|
-
|
|
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.
|
|
3485
|
-
*
|
|
3486
|
-
*
|
|
3487
|
-
*
|
|
3488
|
-
*
|
|
3489
|
-
*
|
|
3490
|
-
*
|
|
3491
|
-
*
|
|
3492
|
-
*
|
|
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
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4151
|
-
* and stashes the login response envelope on
|
|
4152
|
-
* preserves any `message` set by an earlier terminal
|
|
4153
|
-
* the SPA paints the confirmation text alongside the
|
|
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'`).
|
|
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",
|
|
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",
|
|
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",
|
|
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),
|