@aooth/auth-moost 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { Mate, TConsoleBase, TInterceptorDef, TMateParamMeta, TMoostMetadata } from "moost";
2
2
  import { AuthContext, AuthContext as AuthContext$1, AuthCredential, AuthEmailEvent, AuthEmailKind, AuthEmailKind as AuthEmailKind$1, AuthSmsEvent, AuthSmsKind, BuildMagicLinkUrl, BuildMagicLinkUrl as BuildMagicLinkUrl$1, CredentialMetadata, EmailSender, EmailSender as EmailSender$1, EnrichedSession, EnrichedSession as EnrichedSession$1, IssueResult, IssueResult as IssueResult$1, SessionEnricher, SessionEnricher as SessionEnricher$1, SessionInfo, SessionInfo as SessionInfo$1, SmsSender, generateMagicLinkToken } from "@aooth/auth";
3
3
  import { TCookieAttributesInput } from "@wooksjs/event-http";
4
- import { FederatedIdentityStore, FederatedProfileSnapshot, TransferablePolicy, TrustedDeviceRecord, UserCredentials, UserService } from "@aooth/user";
4
+ import { FederatedIdentityStore, FederatedProfileSnapshot, MfaMethod, TransferablePolicy, TrustedDeviceRecord, UserCredentials, UserService } from "@aooth/user";
5
5
  import { FinishWfOpts, WfFinished, useAtscriptWf } from "@atscript/moost-wf";
6
6
  import { MoostWf, WfOutlet, WfOutletTokenConfig, WfStateStrategy } from "@moostjs/event-wf";
7
7
  import { FederatedLoginService, NormalizedProfile, OAuthProviderRegistry } from "@aooth/idp";
8
8
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
- import { AuthCodeStore, ClientRedirectPolicy, PendingAuthorizationStore } from "@aooth/auth/authz";
9
+ import { AuthCodeStore, ClientRedirectPolicy, IdTokenSigner, OidcClaimsResolver, PendingAuthorizationStore } from "@aooth/auth/authz";
10
10
 
11
11
  //#region src/auth.config.d.ts
12
12
  /** Resolved cookie attributes. Same shape is used for both access + refresh. */
@@ -837,25 +837,50 @@ declare const FEDERATED_IDENTITY_STORE_TOKEN = "aooth:FederatedIdentityStore";
837
837
  interface TokenError {
838
838
  error: string;
839
839
  }
840
+ /** OIDC token-endpoint success (RFC 6749 + OIDC Core). `id_token` for OIDC clients; `access_token` per registration. */
841
+ interface TokenSuccess {
842
+ token_type: "Bearer";
843
+ access_token?: string;
844
+ id_token?: string;
845
+ expires_in?: number;
846
+ /** The authenticated user id (`sub`) — convenience for a CLI; not part of the OIDC token response. */
847
+ userId: string;
848
+ }
849
+ /** A minimal OIDC discovery document (`/.well-known/openid-configuration`). */
850
+ interface OidcDiscoveryDocument {
851
+ issuer: string;
852
+ authorization_endpoint: string;
853
+ token_endpoint: string;
854
+ jwks_uri: string;
855
+ response_types_supported: string[];
856
+ grant_types_supported: string[];
857
+ subject_types_supported: string[];
858
+ id_token_signing_alg_values_supported: string[];
859
+ scopes_supported: string[];
860
+ code_challenge_methods_supported: string[];
861
+ token_endpoint_auth_methods_supported: string[];
862
+ }
840
863
  /**
841
- * The authorization-server endpoints (AUTH-SERVER.md Tier 1). Turns the existing
842
- * login workflow into an OAuth authorization server for the app's OWN clients —
843
- * a local CLI on a loopback redirect today, a registered first-party service
844
- * (Tier 2) later. One authorization-code + PKCE flow; the only thing that varies
845
- * is the injected {@link ClientRedirectPolicy}.
864
+ * The authorization-server endpoints (AUTH-SERVER.md). Turns the existing login
865
+ * workflow into an OAuth/OIDC authorization server for the app's OWN clients — a
866
+ * local CLI on a loopback redirect (Tier 1) and registered first-party services
867
+ * (Tier 2, `id_token` / "Sign in with <main app>"). One authorization-code + PKCE
868
+ * flow; the only things that vary are the injected {@link ClientRedirectPolicy}
869
+ * and whether an {@link IdTokenSigner} is wired.
846
870
  *
847
871
  * - `GET /auth/authorize` — validate the client + `redirect_uri` (the policy),
848
- * record a {@link PendingAuthorizationStore} entry, and 302 the browser to the
849
- * login page carrying the opaque `handle`. The login workflow authenticates
850
- * the human and its `mint-authz-code` terminal delivers the code to the client
851
- * — this controller never runs the login itself.
872
+ * record a {@link PendingAuthorizationStore} entry (authority fixed HERE), and
873
+ * 302 the browser to the login page carrying the opaque `handle`. The login
874
+ * workflow's `mint-authz-code` terminal delivers the code to the client.
852
875
  * - `POST /auth/token` — the back-channel: consume the single-use code, verify
853
- * PKCE, and `AuthCredential.issue(userId, tokenPolicy)`. The token is minted
854
- * HERE, off the browser, so nothing long-lived ever rides a redirect URL.
876
+ * PKCE, authenticate the client (Tier 2), and mint the access token and/or the
877
+ * `id_token`. Minted HERE, off the browser, so nothing long-lived rides a URL.
878
+ * - `GET /auth/.well-known/openid-configuration` + `GET /auth/jwks` — OIDC
879
+ * discovery + the signer's public JWKS (Tier 2 only; 404 without a signer).
855
880
  *
856
- * Both routes are `@Public()` (anonymous). The grant's authority is fixed at
857
- * `/authorize` time (the policy's {@link TokenPolicy} is recorded on the pending
858
- * authorization and copied onto the issued code), never inferred at `/token`.
881
+ * All routes are `@Public()`. The grant's authority (token policy, `id_token`
882
+ * intent, audience, scope) is fixed at `/authorize` time and recorded on the
883
+ * pending authorization + the issued code never inferred at `/token`.
859
884
  */
860
885
  declare class AuthorizeController {
861
886
  protected readonly auth: AuthCredential;
@@ -863,6 +888,20 @@ declare class AuthorizeController {
863
888
  protected readonly pending: PendingAuthorizationStore;
864
889
  protected readonly codes: AuthCodeStore;
865
890
  constructor(auth: AuthCredential, policy: ClientRedirectPolicy, pending: PendingAuthorizationStore, codes: AuthCodeStore);
891
+ /**
892
+ * The Tier-2 OIDC `id_token` signer, or `undefined` for a Tier-1-only (CLI)
893
+ * deployment — then discovery / `/auth/jwks` 404 and no `id_token` is minted.
894
+ * **Override** in a subclass to enable OIDC (return one `IdTokenSigner` whose
895
+ * issuer is `{origin}/auth`). A plain getter rather than a DI token because an
896
+ * OPTIONAL `@Inject`/`@Optional` dependency panics in moost's `resolveMoost`
897
+ * route-table pass (`useHandlerPaths`); a method has nothing for it to resolve.
898
+ */
899
+ protected getIdTokenSigner(): IdTokenSigner | undefined;
900
+ /**
901
+ * The Tier-2 OIDC profile-claims resolver. Defaults to a no-op (`sub`-only
902
+ * tokens); **override** to emit `email` / `name` / … from your user record.
903
+ */
904
+ protected getOidcClaimsResolver(): OidcClaimsResolver;
866
905
  /**
867
906
  * The SPA login route the authorize request bounces to. The opaque pending-auth
868
907
  * `handle` is appended as `?authz=`; the SPA forwards it into the login
@@ -870,18 +909,23 @@ declare class AuthorizeController {
870
909
  * custom login path.
871
910
  */
872
911
  protected loginPath(): string;
873
- authorize(responseType: string | undefined, redirectUri: string | undefined, clientId: string | undefined, state: string | undefined, codeChallenge: string | undefined, codeChallengeMethod: string | undefined, scope: string | undefined): Promise<string>;
912
+ authorize(responseType: string | undefined, redirectUri: string | undefined, clientId: string | undefined, state: string | undefined, codeChallenge: string | undefined, codeChallengeMethod: string | undefined, scope: string | undefined, nonce: string | undefined): Promise<string>;
874
913
  token(body: {
875
914
  grant_type?: string;
876
915
  code?: string;
877
916
  code_verifier?: string;
878
917
  client_id?: string;
879
- } | undefined): Promise<{
880
- access_token: string;
881
- token_type: "Bearer";
882
- expires_in: number;
883
- userId: string;
884
- } | TokenError>;
918
+ client_secret?: string;
919
+ } | undefined): Promise<TokenSuccess | TokenError>;
920
+ /**
921
+ * OIDC discovery (Tier 2). Derives every endpoint from the signer's `issuer`
922
+ * (configured as `{origin}/auth`), so a relying `OidcProvider` configured with
923
+ * the same `issuer` resolves `/authorize`, `/token`, and `/jwks` automatically.
924
+ * 404 when no signer is wired (Tier-1-only deployment).
925
+ */
926
+ discovery(): OidcDiscoveryDocument | TokenError;
927
+ /** The signer's public JWKS (Tier 2). 404 when no signer is wired. */
928
+ jwks(): Promise<Awaited<ReturnType<IdTokenSigner["jwks"]>>> | TokenError;
885
929
  /** Fail soft: 302 the validated client redirect with an `?error=` (+ echoed `state`). */
886
930
  protected redirectError(redirectUri: string, error: string, state: string | undefined): string;
887
931
  }
@@ -932,7 +976,8 @@ declare const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
932
976
  /**
933
977
  * DI token for the {@link import("@aooth/auth/authz").ClientRedirectPolicy} — an
934
978
  * interface, so it has no class reference to inject by. Provide the concrete
935
- * policy (e.g. `new LoopbackClientPolicy()`) under this string.
979
+ * policy (e.g. `new LoopbackClientPolicy()`, a `RegisteredClientPolicy`, or a
980
+ * `CompositeClientPolicy` of both) under this string.
936
981
  */
937
982
  declare const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
938
983
  //#endregion
@@ -1008,8 +1053,21 @@ interface AuthWfMfaEnrollState {
1008
1053
  secret?: string;
1009
1054
  uri?: string;
1010
1055
  availableTransports?: MfaTransport[];
1011
- mode?: "required" | "optional";
1056
+ /**
1057
+ * Drives skip / cancel visibility on the enrolment forms:
1058
+ * - `'optional'` — login/invite first-time opt-in: "Skip for now" shows.
1059
+ * - `'required'` — forced enrolment: neither skip nor cancel.
1060
+ * - `'manage'` — the standalone manage-MFA flow (user opened it on purpose):
1061
+ * "Skip for now" is hidden, a "Cancel" action shows instead.
1062
+ */
1063
+ mode?: "required" | "optional" | "manage";
1012
1064
  done?: boolean;
1065
+ /**
1066
+ * Gates the standalone `enroll-totp-qr` step (TOTP only). Set once the user
1067
+ * has been shown the QR/secret and clicked Continue, so the QR pause fires
1068
+ * before — not alongside — the code-entry step. Shared by both surfaces.
1069
+ */
1070
+ qrSeen?: boolean;
1013
1071
  /**
1014
1072
  * When set, `enroll-confirm` does NOT make the freshly-confirmed method the
1015
1073
  * user's default. Set by `init-add-mfa` (the standalone add-MFA flow) when the
@@ -1311,12 +1369,52 @@ interface AuthWfPendingLinkState {
1311
1369
  interface AuthWfAddMfaState {
1312
1370
  /**
1313
1371
  * Transports the user has NOT yet confirmed = resolved
1314
- * `availableTransports` minus already-enrolled. The enrol picker offers
1315
- * exactly these; an empty list means there is nothing to add and the flow
1316
- * finishes with a benign "no methods available" terminal. `finish-add-mfa`
1317
- * reads it to distinguish "nothing to add" from "user cancelled".
1372
+ * `availableTransports` minus already-enrolled. The manage menu offers these
1373
+ * as "Add" options (a zero-MFA user goes straight to the enrol picker over
1374
+ * the same list). `finish-add-mfa` reads it to distinguish "nothing to add"
1375
+ * from "user cancelled".
1318
1376
  */
1319
1377
  candidates?: MfaTransport[];
1378
+ /**
1379
+ * Transports the user may NOT change or remove via this flow — resolved by
1380
+ * `resolveLockedMfaTransports` (default: none). A customer locks a factor
1381
+ * whose value IS a login handle (e.g. the MFA email equals the
1382
+ * `@aooth.user.email` handle) so the user can't silently desync it here.
1383
+ * Locked transports are omitted from the menu's Change/Remove options and
1384
+ * re-checked server-side in `manage-menu` / `confirm-remove-mfa`.
1385
+ */
1386
+ locked?: MfaTransport[];
1387
+ /**
1388
+ * `true` when the user already has ≥1 confirmed method, so the manage flow
1389
+ * must step-up (re-verify identity) BEFORE offering add/change/remove, and
1390
+ * shows the management menu. The METHOD of step-up is `stepUpMode`. `false`
1391
+ * (zero confirmed methods) skips both — the flow degrades to the first-time
1392
+ * enrol picker (the opt-in path).
1393
+ */
1394
+ stepUpRequired?: boolean;
1395
+ /**
1396
+ * How the step-up is performed (set by `init-add-mfa` when `stepUpRequired`):
1397
+ * - `'mfa'` — the user has ≥1 confirmed factor whose kind is still in the
1398
+ * policy's `availableTransports`, so `mfaStepUpLoop` challenges it.
1399
+ * - `'password'` — every confirmed factor is of a kind the policy no longer
1400
+ * allows (none is challengeable), so `manage-password-reauth` re-verifies
1401
+ * via the account password instead. Fail-closed fallback that keeps "prove
1402
+ * identity before managing factors" intact even after a policy tightening
1403
+ * orphaned the only enrolled factor.
1404
+ */
1405
+ stepUpMode?: "mfa" | "password";
1406
+ /**
1407
+ * Set once the step-up factor verifies AND the flow has swapped off the
1408
+ * encapsulated start onto the durable `store` strategy (server-anchored,
1409
+ * replay-resistant). Gates the one-time swap + the management menu.
1410
+ */
1411
+ stepUpDone?: boolean;
1412
+ /** The management action the user picked on the menu. */
1413
+ action?: "add" | "replace" | "remove";
1414
+ /** The transport the chosen `action` applies to. */
1415
+ target?: MfaTransport;
1416
+ /** Set by `confirm-remove-mfa` so `finish-add-mfa` can report which factor was removed. */
1417
+ removed?: MfaTransport;
1320
1418
  }
1321
1419
  /**
1322
1420
  * Self-signup flow state. Populated by `init-signup` (policy from
@@ -1412,12 +1510,15 @@ interface AuthWfPublicState {
1412
1510
  * Mirrors `ctx.mfaEnroll` — only what the enrolment forms display.
1413
1511
  * `address` stays internal (user-typed, no need to bounce it back).
1414
1512
  */
1415
- mfaEnroll?: {
1416
- method?: MfaTransport;
1417
- mode?: "required" | "optional";
1418
- availableTransports?: MfaTransport[];
1419
- secret?: string;
1420
- uri?: string;
1513
+ mfaEnroll?: Pick<AuthWfMfaEnrollState, "method" | "mode" | "availableTransports" | "secret" | "uri">;
1514
+ /**
1515
+ * Mirrors the manage-MFA menu inputs — the un-enrolled transports the user
1516
+ * can Add and the locked transports to omit from Change/Remove. The enrolled
1517
+ * method list the menu cross-references is `public.mfa.enrolledMethods`.
1518
+ */
1519
+ manage?: {
1520
+ candidates?: MfaTransport[];
1521
+ locked?: MfaTransport[];
1421
1522
  };
1422
1523
  /** Mirrors `ctx.defaults` — prefill source for the recovery email field. */
1423
1524
  defaults?: {
@@ -1460,6 +1561,13 @@ interface AuthWfCtx {
1460
1561
  mfaEnroll?: AuthWfMfaEnrollState;
1461
1562
  password?: AuthWfPasswordUiState;
1462
1563
  completion?: AuthWfCompletionState;
1564
+ /**
1565
+ * Marks that the `promote-to-handle` @Step has already run for this flow, so
1566
+ * it fires once after a channel is confirmed and is skipped on every later
1567
+ * resume (the store write is idempotent, but re-running it each resume would
1568
+ * be wasteful). Server-only — never `@wf.context.pass`'d.
1569
+ */
1570
+ promoteToHandleDone?: boolean;
1463
1571
  alternateCredentials?: AuthWfAltCredsPolicy;
1464
1572
  deviceTrust?: AuthWfDeviceTrustPolicy;
1465
1573
  enrollment?: AuthWfEnrollmentPolicy;
@@ -1548,8 +1656,12 @@ interface AuthWorkflowOpts {
1548
1656
  askEmail?: TAtscriptAnnotatedType;
1549
1657
  askPhone?: TAtscriptAnnotatedType;
1550
1658
  enrollPickMethod?: TAtscriptAnnotatedType;
1551
- enrollAddress?: TAtscriptAnnotatedType;
1552
- enrollConfirm?: TAtscriptAnnotatedType;
1659
+ enrollAddress?: TAtscriptAnnotatedType; /** TOTP QR step — shown before the code-entry pause (manage + opt-in). */
1660
+ enrollTotpQr?: TAtscriptAnnotatedType;
1661
+ enrollConfirm?: TAtscriptAnnotatedType; /** Manage-MFA menu (Add / Change / Remove) shown after step-up. */
1662
+ manageMfa?: TAtscriptAnnotatedType; /** Confirm-removal pause for the manage-MFA "Remove" action. */
1663
+ removeMfaConfirm?: TAtscriptAnnotatedType; /** Password re-auth — step-up fallback when no factor is MFA-challengeable. */
1664
+ passwordReauth?: TAtscriptAnnotatedType;
1553
1665
  select2fa?: TAtscriptAnnotatedType;
1554
1666
  mfaCode?: TAtscriptAnnotatedType;
1555
1667
  pincode?: TAtscriptAnnotatedType;
@@ -1594,7 +1706,11 @@ interface ResolvedAuthWorkflowOpts {
1594
1706
  askPhone: TAtscriptAnnotatedType;
1595
1707
  enrollPickMethod: TAtscriptAnnotatedType;
1596
1708
  enrollAddress: TAtscriptAnnotatedType;
1709
+ enrollTotpQr: TAtscriptAnnotatedType;
1597
1710
  enrollConfirm: TAtscriptAnnotatedType;
1711
+ manageMfa: TAtscriptAnnotatedType;
1712
+ removeMfaConfirm: TAtscriptAnnotatedType;
1713
+ passwordReauth: TAtscriptAnnotatedType;
1598
1714
  select2fa: TAtscriptAnnotatedType;
1599
1715
  mfaCode: TAtscriptAnnotatedType;
1600
1716
  pincode: TAtscriptAnnotatedType;
@@ -1624,7 +1740,7 @@ type AuthDeliveryPayload = {
1624
1740
  expiresInMs: number;
1625
1741
  } | {
1626
1742
  kind: "recovery-pincode";
1627
- channel: "email";
1743
+ channel: "sms" | "email";
1628
1744
  recipient: string;
1629
1745
  code: string;
1630
1746
  expiresInMs: number;
@@ -1869,6 +1985,28 @@ declare class AuthWorkflow {
1869
1985
  * Reached from login.flow + invite.start.
1870
1986
  */
1871
1987
  protected resolveMfaPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["mfaPolicy"]> | Promise<NonNullable<AuthWfCtx["mfaPolicy"]>>;
1988
+ /**
1989
+ * Transports the user may NOT change or remove via the manage-MFA flow.
1990
+ * Default: none — every factor is freely manageable. Reached from
1991
+ * `auth/add-mfa/flow` (`prepare-locked-mfa-transports`).
1992
+ *
1993
+ * Override to forbid changing a factor whose value IS a login handle — e.g.
1994
+ * the MFA `email` equals the `@aooth.user.email` handle, so letting the user
1995
+ * swap it here would desync identity. A typical override loads the user and
1996
+ * compares each enrolled channel value against the boot-resolved handle
1997
+ * fields (`getAoothUserHandleSpec(...).emailField` / `.phoneField`):
1998
+ *
1999
+ * ```ts
2000
+ * protected async resolveLockedMfaTransports(ctx: AuthWfCtx): Promise<MfaTransport[]> {
2001
+ * const user = await this.users.getUser(ctx.subject!);
2002
+ * const locked: MfaTransport[] = [];
2003
+ * const email = user.mfa?.methods?.find((m) => m.name === "email" && m.confirmed);
2004
+ * if (email && email.value === (user as { email?: string }).email) locked.push("email");
2005
+ * return locked;
2006
+ * }
2007
+ * ```
2008
+ */
2009
+ protected resolveLockedMfaTransports(_ctx: AuthWfCtx): MfaTransport[] | Promise<MfaTransport[]>;
1872
2010
  /**
1873
2011
  * Resolve the channel-OTP disclosure copy rendered beneath the email/phone
1874
2012
  * input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
@@ -1939,6 +2077,79 @@ declare class AuthWorkflow {
1939
2077
  address: string;
1940
2078
  channel: "sms" | "email";
1941
2079
  }>;
2080
+ /**
2081
+ * Resolve the recovery OTP `{ address, channel }` for the chosen delivery
2082
+ * `source`.
2083
+ *
2084
+ * - `"typed"` (M1): the address is the typed recovery identifier (`ctx.email`)
2085
+ * — identifier == destination, so no cross-account redirect — and the
2086
+ * channel comes from `resolveRecoveryChannel` (identifier-shape inference).
2087
+ * - `"registered"` (M2): the address is read off a confirmed MFA method on the
2088
+ * row (`selectRecoveryRegisteredMethod`) and the channel is that method's own
2089
+ * kind. The user only typed an account identifier; the destination is a
2090
+ * pre-verified channel they already control, so this also can't redirect
2091
+ * cross-account. `request`'s M2 guard normally generic-finishes any row with
2092
+ * no deliverable method up front; if the method is deleted in the narrow
2093
+ * window between that guard and this send (e.g. on a resend), this throws
2094
+ * `RecoveryMethodUnavailableError`, which `pincode-send` degrades to the
2095
+ * same generic finish — never a distinguishable 500.
2096
+ */
2097
+ private recoveryPincodeTarget;
2098
+ /**
2099
+ * Recovery OTP delivery channel. The address is ALWAYS the typed recovery
2100
+ * identifier (`ctx.email`) — symmetric with how email recovery already works:
2101
+ * the OTP goes to the value the user typed, which is the handle that resolved
2102
+ * the account (`findByHandle`), so identifier == destination and there is no
2103
+ * cross-account redirect. Default `"email"`. A deployment whose recovery form
2104
+ * accepts a phone overrides this to route SMS (e.g. infer from the identifier
2105
+ * shape) — see the demo's `DemoAuthWorkflow`. Recovery picks ONE channel per
2106
+ * run, so the single `resendAllowedAt` cooldown gate still suffices.
2107
+ */
2108
+ protected resolveRecoveryChannel(_ctx: AuthWfCtx): "email" | "sms" | Promise<"email" | "sms">;
2109
+ /**
2110
+ * Recovery OTP delivery model. Two options:
2111
+ *
2112
+ * - `"typed"` (default — M1): the OTP goes to the recovery identifier the user
2113
+ * types. Identifier == destination, so there is no cross-account redirect;
2114
+ * `resolveRecoveryChannel` picks email vs SMS from the identifier shape.
2115
+ * - `"registered"` (M2): the user enters only an account identifier (e.g. a
2116
+ * username) and the OTP is delivered to a channel **already verified on the
2117
+ * row** — `selectRecoveryRegisteredMethod` picks the confirmed MFA method;
2118
+ * the destination is never taken from user input, so it cannot be redirected
2119
+ * to an attacker-controlled address. A row with no deliverable confirmed
2120
+ * method finishes with the generic anti-enumeration envelope (see `request`).
2121
+ *
2122
+ * Consulted inline by `request` (no-method guard) and `recoveryPincodeTarget`
2123
+ * — no `prepare-*` step, mirroring `resolveRecoveryChannel`. Override to arm
2124
+ * M2 (per-tenant / per-variant); see the demo's `DemoAuthWorkflow`.
2125
+ */
2126
+ protected resolveRecoveryDeliverySource(_ctx: AuthWfCtx): "typed" | "registered" | Promise<"typed" | "registered">;
2127
+ /**
2128
+ * Pick the confirmed MFA method a registered-channel recovery (M2) delivers
2129
+ * its OTP to. Prefers a confirmed SMS method, then a confirmed email method —
2130
+ * phone-recovery-first. TOTP carries no deliverable address and is skipped.
2131
+ * Returns `null` when the row has no deliverable confirmed method; the caller
2132
+ * turns that into the anti-enumeration generic finish. Stays sync (operates on
2133
+ * an already-loaded row); override to change the selection policy (e.g. honour
2134
+ * the user's `mfa.defaultMethod`).
2135
+ */
2136
+ protected selectRecoveryRegisteredMethod(user: UserCredentials): MfaMethod | null;
2137
+ /**
2138
+ * Decide which login-handle column a freshly-confirmed channel should be
2139
+ * promoted into — so a verified email/phone becomes a login + recovery
2140
+ * handle (`findByHandle`) automatically. Returns the target field name, or
2141
+ * `undefined` to NOT promote (the default).
2142
+ *
2143
+ * Default is OFF: the handle columns are declared via `@aooth.user.*`
2144
+ * annotations on the consumer's concrete model and resolved ONCE at boot
2145
+ * (`@aooth/arbac-moost`'s `getAoothUserHandleSpec`) — `AuthWorkflow` holds no
2146
+ * handle to that model and stays off the per-request reflection path. A
2147
+ * deployment turns promotion ON by overriding this to return the
2148
+ * boot-resolved `emailField` / `phoneField` for the channel — see the demo's
2149
+ * `DemoAuthWorkflow`. `channel` is the wire protocol (`'email'` | `'sms'`),
2150
+ * matching `resolveOtpDisclosure` / the MFA transport.
2151
+ */
2152
+ protected resolvePromoteHandleField(_ctx: AuthWfCtx, _channel: "email" | "sms"): string | undefined | Promise<string | undefined>;
1942
2153
  /**
1943
2154
  * Route a form alt-action click to a canonical outcome. Defaults match the
1944
2155
  * action ids the bundled `PincodeForm` declares; customers override per
@@ -2046,13 +2257,60 @@ declare class AuthWorkflow {
2046
2257
  */
2047
2258
  protected sendEnrollPincode(ctx: AuthWfCtx, address: string, code: string): Promise<void>;
2048
2259
  /**
2049
- * Cleanup any partially-persisted enrolment state (unconfirmed method row +
2050
- * ctx scratch). Called when the user picks `skip` or `useDifferentMethod`
2051
- * mid-flow on `enrollConfirm`, where the unconfirmed method has already
2052
- * been written via `addMfaMethod` (in `enrollPickMethod` for totp /
2053
- * `enrollAddress` for sms+email).
2054
- */
2055
- protected cleanupEnrollment(ctx: AuthWfCtx, username: string): Promise<void>;
2260
+ * Drop the per-enrolment scratch fields (the `mfaEnroll` provisioning fields +
2261
+ * the pincode timers/`sentTo`) off ctx the shared teardown used by the
2262
+ * opt-in `skip` / `useDifferentMethod` arms and {@link cancelManageEnrollment}.
2263
+ * Does NOT touch the user record: the enrol trio stages every candidate value
2264
+ * (sms/email address, totp secret) in wf-state and writes it to the store ONLY
2265
+ * on confirm (write-on-confirm), so a bailed enrolment never persisted a
2266
+ * partial row to undo.
2267
+ */
2268
+ protected clearEnrollScratch(ctx: AuthWfCtx): void;
2269
+ /**
2270
+ * Validate a user-typed MFA address for its transport. Server-side counterpart
2271
+ * to `EnrollAddressForm`'s client `@ui.form.validate` hint — the authoritative
2272
+ * check (a client can bypass the hint). Returns an error string for the form,
2273
+ * or `undefined` when valid. Email must look like an email; SMS is permissive
2274
+ * E.164-ish (normalized by {@link normalizeMfaAddress}). Override for stricter
2275
+ * (e.g. libphonenumber) validation.
2276
+ */
2277
+ protected validateMfaAddress(method: MfaTransport, value: string): string | undefined;
2278
+ /**
2279
+ * Light normalization of a validated MFA address before it is stored. Default:
2280
+ * trims, and strips spacing/punctuation from SMS numbers while KEEPING the
2281
+ * leading `+` (E.164 canonical form). Email is just trimmed. Override for full
2282
+ * E.164 canonicalization.
2283
+ */
2284
+ protected normalizeMfaAddress(method: MfaTransport, value: string): string;
2285
+ /**
2286
+ * Abort an in-progress manage-MFA enrolment cleanly: drop the wf-state scratch
2287
+ * and set `ctx.aborted` so the schema breaks to `finish-add-mfa` (cancelled
2288
+ * terminal). Nothing to undo in the user record — the manage flow stages every
2289
+ * candidate value (sms/email address, totp secret) in wf-state and writes it
2290
+ * to the store ONLY on confirm (write-on-confirm), so an in-progress
2291
+ * add/change/replace never touched the existing factors. This is exactly why
2292
+ * a cancel — or a crafted `useDifferentMethod` routed here — can never strand
2293
+ * or clobber a live factor.
2294
+ */
2295
+ protected cancelManageEnrollment(ctx: AuthWfCtx): void;
2296
+ /**
2297
+ * Shared skip / cancel / useDifferentMethod triage for the enrol-trio steps
2298
+ * that pause after the candidate value is staged (`enroll-totp-qr` +
2299
+ * `enroll-confirm`): `cancel` / `useDifferentMethod` (manage) abort the flow,
2300
+ * `skip` (opt-in) and `useDifferentMethod` (opt-in) drop the wf-state scratch.
2301
+ * Returns `true` when the action terminated the step so the caller can
2302
+ * `return undefined`. (`enroll-pick-method` / `enroll-address` keep their own
2303
+ * preludes — their skip/useDifferentMethod arms diverge from this one.)
2304
+ *
2305
+ * SECURITY: in `'manage'` mode BOTH `cancel` and `useDifferentMethod` (the
2306
+ * manage forms HIDE the latter but it stays in their declared action
2307
+ * whitelist, so a crafted resume can still send it) route through the abort,
2308
+ * which only clears scratch. Because the enrol trio writes the user record
2309
+ * ONLY on confirm (write-on-confirm), an in-progress add/change has touched
2310
+ * nothing in the store — so a cancel/useDifferentMethod can never strand or
2311
+ * clobber the user's live factor, by construction.
2312
+ */
2313
+ protected handleEnrollExit(ctx: AuthWfCtx, action: string | undefined): boolean;
2056
2314
  initLogin(ctx: AuthWfCtx): void;
2057
2315
  initInviteAdmin(ctx: AuthWfCtx): void;
2058
2316
  initInviteAccept(ctx: AuthWfCtx): void;
@@ -2070,23 +2328,25 @@ declare class AuthWorkflow {
2070
2328
  */
2071
2329
  initChangePassword(ctx: AuthWfCtx): void;
2072
2330
  /**
2073
- * Bind the standalone "add an MFA method" flow to the CURRENT authenticated
2074
- * user and narrow enrolment to the transports they have NOT enrolled yet.
2075
- * Identity comes from the session (`useAuth().getUserId()`) — never form input
2076
- * so it is structurally "add a factor to MY account". Mirrors
2077
- * `init-change-password`'s arbac gate (`auth.add-mfa` / `self`): a customer
2078
- * enables the feature with a single `allow("auth.add-mfa", "*")` grant and
2079
- * forbids it by omitting it. `getUserId()` throws 401 if unauthenticated —
2080
- * defence in depth on top of the guarded trigger route.
2331
+ * Bind the standalone "Manage two-factor authentication" flow (add / change /
2332
+ * remove) to the CURRENT authenticated user. Identity comes from the session
2333
+ * (`useAuth().getUserId()`) — never form input — so it is structurally "manage
2334
+ * MY factors". Mirrors `init-change-password`'s arbac gate (`auth.add-mfa` /
2335
+ * `self`): a customer enables the feature with a single
2336
+ * `allow("auth.add-mfa", "*")` grant and forbids it by omitting it.
2337
+ * `getUserId()` throws 401 if unauthenticated — defence in depth on top of the
2338
+ * guarded trigger route.
2081
2339
  *
2082
- * Drives the REUSED enrol trio (`enroll-pick-method` / `enroll-address` /
2083
- * `enroll-confirm`) by setting `ctx.mfaPolicy.availableTransports` to the
2084
- * un-enrolled remainder so the picker offers only those and auto-picks when
2085
- * exactly one remains. Forces `mode: "optional"` (the user opted in; an empty
2086
- * remainder must finish gracefully, never 500 as `required` would). The
2087
- * remainder is stashed on `ctx.addMfa.candidates` (flow discriminator + finish
2088
- * summary); when the user already has a default, `enroll-confirm` is asked to
2089
- * keep it (`mfaEnroll.keepExistingDefault`).
2340
+ * Sets `ctx.mfaPolicy.availableTransports` to the FULL policy set (so the
2341
+ * step-up's `load-enrolled-mfa-methods` can see the confirmed factors to
2342
+ * challenge) and tracks the un-enrolled `candidates` separately on
2343
+ * `ctx.addMfa` for the menu's Add options. `stepUpRequired` is set when the
2344
+ * user has ANY confirmed factor gating both the step-up and the management
2345
+ * menu; a zero-MFA user skips both and falls through to the first-time enrol
2346
+ * picker (the opt-in path). `stepUpMode` picks the step-up method: `'mfa'`
2347
+ * when a confirmed factor is still challengeable, else `'password'` (a
2348
+ * password re-auth fallback for an orphaned factor). Puts the enrol forms in
2349
+ * `'manage'` mode (Cancel, not "Skip for now") and keeps the existing default.
2090
2350
  */
2091
2351
  initAddMfa(ctx: AuthWfCtx): Promise<undefined>;
2092
2352
  credentials(ctx: AuthWfCtx): Promise<unknown>;
@@ -2267,13 +2527,55 @@ declare class AuthWorkflow {
2267
2527
  */
2268
2528
  finishChangePassword(ctx: AuthWfCtx): Promise<undefined>;
2269
2529
  /**
2270
- * Terminal for the add-MFA flow. The user KEEPS their current session (no
2271
- * re-issue, no cookies) — this is a plain data finish. `mfaEnroll.done &&
2272
- * mfaEnroll.method` is the success signal: a real confirm keeps `.method`,
2273
- * whereas a cancel runs `cleanupEnrollment` (which deletes it). An empty
2274
- * `addMfa.candidates` distinguishes "nothing left to add" from a user cancel.
2530
+ * Terminal for the manage-MFA flow. The user KEEPS their current session (no
2531
+ * re-issue, no cookies) — a plain data finish. Outcomes, in priority order:
2532
+ * removed changed (`replace` + done) added (done) → nothing-available
2533
+ * (zero candidates, never had to step-up) cancelled.
2275
2534
  */
2276
2535
  finishAddMfa(ctx: AuthWfCtx): undefined;
2536
+ /**
2537
+ * Resolve which transports the user may NOT change/remove via the manage flow
2538
+ * (calls {@link resolveLockedMfaTransports}) and write them to
2539
+ * `ctx.addMfa.locked`. Mirrors the `prepare-<group>` convention.
2540
+ */
2541
+ prepareLockedMfaTransports(ctx: AuthWfCtx): undefined | Promise<undefined>;
2542
+ /**
2543
+ * Fires once the step-up factor verifies — anchor the rest of the flow in the
2544
+ * durable `store` strategy (mirrors login's swap-after-credentials): the
2545
+ * pincode becomes single-use server state and the staged new factor lives
2546
+ * server-side instead of in the SPA-held encapsulated token. Degrades to
2547
+ * encapsulated when no durable store is wired (the registry default).
2548
+ */
2549
+ manageStepUpDone(ctx: AuthWfCtx): undefined;
2550
+ /**
2551
+ * Manage-MFA password re-auth — the step-up FALLBACK when the user's only
2552
+ * confirmed factor(s) are of kinds the policy no longer allows, so nothing is
2553
+ * MFA-challengeable (`addMfa.stepUpMode === "password"`; see `init-add-mfa`).
2554
+ * Pauses on `PasswordReauthForm`, verifies the account password via
2555
+ * `UserService.verifyPassword`, and on success flips `ctx.otp.verified` — the
2556
+ * SAME step-up success signal `mfaStepUpLoop` sets — so `manage-stepup-done`
2557
+ * (swap-to-store) and `manage-menu` proceed identically. `cancel` aborts to
2558
+ * the cancelled terminal (fail closed: no management write without a fresh
2559
+ * proof of identity). Only ARBAC-gated callers reach it (session-bound
2560
+ * subject), and `verifyPassword` is the same check `changePassword` enforces.
2561
+ */
2562
+ managePasswordReauth(ctx: AuthWfCtx): Promise<undefined>;
2563
+ /**
2564
+ * Manage-MFA menu — pauses on `ManageMfaForm` and routes the chosen
2565
+ * `operation` (`add:<t>` / `replace:<t>` / `remove:<t>`). Only reached when
2566
+ * the user has ≥1 confirmed factor (a zero-MFA user goes straight to the enrol
2567
+ * picker). Re-checks the locked set + candidate membership server-side, then
2568
+ * sets `ctx.addMfa.action`/`target` (and pre-seeds `mfaEnroll.method` for
2569
+ * add/change). `cancel`, or nothing actionable, aborts to the finish terminal.
2570
+ */
2571
+ manageMenu(ctx: AuthWfCtx): Promise<undefined>;
2572
+ /**
2573
+ * Manage-MFA remove confirmation. Pauses on `RemoveMfaConfirmForm`; the
2574
+ * 'Remove' submit performs the removal, 'Cancel' aborts. Re-checks the locked
2575
+ * set (defence in depth) and blocks removing the LAST confirmed factor when
2576
+ * the policy mode is `required` (you must keep at least one).
2577
+ */
2578
+ confirmRemoveMfa(ctx: AuthWfCtx): Promise<undefined>;
2277
2579
  askChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
2278
2580
  verifyChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
2279
2581
  /**
@@ -2346,22 +2648,72 @@ declare class AuthWorkflow {
2346
2648
  /**
2347
2649
  * Unified MFA-enrol phase 1 (pick method). Auto-picks a single transport,
2348
2650
  * otherwise pauses for `EnrollPickMethodForm`. When TOTP is picked, the
2349
- * secret is idempotently provisioned in the same step body. Handles
2350
- * `skip` in `'optional'` mode.
2651
+ * secret is idempotently provisioned in the same step body. Handles `skip`
2652
+ * (optional opt-in) / `cancel` (manage). In the manage flow this only runs
2653
+ * for a zero-MFA user — once the user has factors, the menu pre-seeds
2654
+ * `mfaEnroll.method` (add/change) so the picker is skipped.
2351
2655
  */
2352
2656
  enrollPickMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
2353
2657
  /**
2354
2658
  * Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
2355
- * Not invoked for totp. Handles `skip` / `useDifferentMethod`.
2659
+ * Not invoked for totp. Handles `skip` (opt-in) / `cancel` (manage) /
2660
+ * `useDifferentMethod`. Validates the address server-side (the client
2661
+ * `@ui.form.validate` hint is advisory), then STAGES the candidate value in
2662
+ * wf-state (`m.address`) — the user record is written only on confirm
2663
+ * (write-on-confirm), so an ADD leaves no partial row and a REPLACE keeps the
2664
+ * old confirmed value live until the new code verifies in `enroll-confirm`.
2356
2665
  */
2357
2666
  enrollAddress(ctx: AuthWfCtx): Promise<undefined>;
2358
2667
  /**
2359
- * Unified MFA-enrol phase 3 (verify pincode/TOTP, mark confirmed). On
2668
+ * MFA-enrol TOTP QR step shown on its OWN pause between method-pick and
2669
+ * code-entry (so the user scans first, types the code next). Idempotently
2670
+ * provisions the TOTP secret in wf-state ONLY (covers the auto-pick /
2671
+ * menu-pre-seeded paths where `enroll-pick-method` was skipped), then pauses
2672
+ * on `EnrollTotpQrForm`. The user record is written only on confirm
2673
+ * (write-on-confirm), so a manage **replace** never clobbers the live totp
2674
+ * secret and a cancel/crash leaves the existing factor intact — no stash or
2675
+ * restore needed. Handles `skip` (opt-in) / `cancel` (manage) /
2676
+ * `useDifferentMethod`.
2677
+ */
2678
+ enrollTotpQr(ctx: AuthWfCtx): Promise<undefined>;
2679
+ /**
2680
+ * Unified MFA-enrol phase 3 (verify pincode/TOTP, then write the factor). On
2360
2681
  * success sets `ctx.mfaEnroll.done = true` AND `ctx.otp.verified = true`
2361
2682
  * (the loop-exit signal — enrol-confirm verifies an OTP, so the unified
2362
2683
  * `otp.verified` flag fires alongside the MFA-specific `mfaEnroll.done`).
2684
+ * This is the ONLY place the enrol trio touches the user record
2685
+ * (write-on-confirm): the proven value (sms/email address or totp secret,
2686
+ * staged in wf-state) is upserted as confirmed via `addMfaMethod`, which
2687
+ * atomically swaps in a REPLACE with no pre-confirm clobber window and creates
2688
+ * a fresh row for an ADD.
2363
2689
  */
2364
2690
  enrollConfirm(ctx: AuthWfCtx): Promise<undefined>;
2691
+ /**
2692
+ * Promote a freshly-confirmed channel into its login-handle column so future
2693
+ * login + recovery resolve the account by it (`findByHandle`). Runs once,
2694
+ * right after `enroll-confirm` in the shared enrolment trio (so it covers
2695
+ * add-mfa AND login/invite forced first-time enrolment). Default is a no-op
2696
+ * unless `resolvePromoteHandleField` is overridden to name a handle column.
2697
+ *
2698
+ * Overridable extension point: a deployment can replace this with richer
2699
+ * logic — e.g. pause on a carrier form asking whether to use the new number
2700
+ * as a login handle before writing it.
2701
+ *
2702
+ * Fires only for a freshly-confirmed `email` / `sms` factor carrying an
2703
+ * address. TOTP has no address; a skipped / `useDifferentMethod` enrolment
2704
+ * cleared `method` + `address` via `clearEnrollScratch`, so the guard below
2705
+ * excludes both — only an actually-confirmed channel is promoted.
2706
+ */
2707
+ promoteToHandle(ctx: AuthWfCtx): Promise<undefined>;
2708
+ /**
2709
+ * Best-effort write of a confirmed channel value into its handle column.
2710
+ * Swallows `ALREADY_EXISTS` — the value is already a handle on ANOTHER
2711
+ * account (e.g. two accounts legitimately sharing one phone for MFA): the
2712
+ * second account keeps the factor as MFA-only and is simply not promoted.
2713
+ * Any other store error propagates. (`UserService.update` translates a
2714
+ * unique-index `CONFLICT` to `ALREADY_EXISTS` for both store adapters.)
2715
+ */
2716
+ protected applyHandlePromotion(subject: string, field: string, value: string): Promise<void>;
2365
2717
  /**
2366
2718
  * Risk step-up: re-evaluate whether to require another MFA round. Default
2367
2719
  * `resolveRiskStepUp` returns `{require: false}`. When `require: true`,
@@ -2664,20 +3016,35 @@ declare class AuthWorkflow {
2664
3016
  */
2665
3017
  changePasswordFlow(): void;
2666
3018
  /**
2667
- * add-mfa.flow — authenticated self-service "add a second factor". Same
2668
- * gating model as change-password: NOT `@Public()` `init-add-mfa` is arbac-
2669
- * gated (`auth.add-mfa` / `self`) and binds `ctx.subject` from the session, so
2670
- * an unauthenticated / unauthorized caller is rejected at the first step. NOT
2671
- * in `DEFAULT_AUTH_WORKFLOWS` reached only via the GUARDED trigger route
3019
+ * add-mfa.flow — authenticated self-service "Manage two-factor
3020
+ * authentication" (add / change / remove). Same gating model as
3021
+ * change-password: NOT `@Public()` `init-add-mfa` is arbac-gated
3022
+ * (`auth.add-mfa` / `self`) and binds `ctx.subject` from the session, so an
3023
+ * unauthenticated / unauthorized caller is rejected at the first step. NOT in
3024
+ * `DEFAULT_AUTH_WORKFLOWS` — reached only via the GUARDED trigger route
2672
3025
  * (`AuthController.addMfa`), never the public `/auth/trigger`.
2673
3026
  *
2674
- * The body REUSES the login/invite forced-enrolment trio verbatim
2675
- * (`enroll-pick-method` `enroll-address` `enroll-confirm`); the only
2676
- * difference is the driver: `init-add-mfa` narrows `ctx.mfaPolicy`
2677
- * `availableTransports` to the transports the user has NOT enrolled, so the
2678
- * picker offers exactly those and auto-picks when one remains. Available only
2679
- * when something is un-enrolled — with everything enrolled the trio is skipped
2680
- * and `finish-add-mfa` returns a benign "nothing to add" terminal.
3027
+ * Shape:
3028
+ * 1. `init-add-mfa` bind subject, resolve the FULL transport set + the
3029
+ * un-enrolled `candidates`, mark `stepUpRequired` when the user already has
3030
+ * ≥1 confirmed factor, and put the enrol forms in `'manage'` mode.
3031
+ * 2. `prepare-locked-mfa-transports` resolve which factors the consumer
3032
+ * forbids changing (handle-bound email/phone).
3033
+ * 3. STEP-UP (only when `stepUpRequired`): re-verify identity before any
3034
+ * change — `mfaStepUpLoop` challenges an EXISTING factor when one is still
3035
+ * challengeable (`stepUpMode==='mfa'`), else `manage-password-reauth` falls
3036
+ * back to the account password (`stepUpMode==='password'`). On success
3037
+ * `manage-stepup-done` swaps off the encapsulated start onto the durable
3038
+ * `store` strategy (server-anchored, replay-resistant; mirrors login's
3039
+ * swap-after-credentials).
3040
+ * 4. `manage-menu` (only when `stepUpRequired`) — pick add / change / remove +
3041
+ * target; pre-seeds `mfaEnroll.method` for add/change.
3042
+ * 5. Route: `confirm-remove-mfa` for remove; otherwise the REUSED enrol trio
3043
+ * (`enroll-pick-method` → `enroll-address` / `enroll-totp-qr` →
3044
+ * `enroll-confirm`). A zero-MFA user skips step-up + menu and lands on the
3045
+ * enrol picker directly (the first-time opt-in path).
3046
+ * 6. `finish-add-mfa` — added / changed / removed / cancelled / nothing terminal.
3047
+ * The user KEEPS their session (no token re-issue).
2681
3048
  */
2682
3049
  addMfaFlow(): void;
2683
3050
  /**