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