@aooth/auth-moost 0.1.7 → 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/index.d.mts CHANGED
@@ -1,11 +1,12 @@
1
- import { Mate, TInterceptorDef, TMateParamMeta, TMoostMetadata } from "moost";
2
- import { AuthContext, AuthContext as AuthContext$1, AuthCredential, AuthEmailEvent, AuthEmailKind, AuthEmailKind as AuthEmailKind$1, AuthSmsEvent, AuthSmsKind, AuthSmsKind as AuthSmsKind$1, BuildMagicLinkUrl, BuildMagicLinkUrl as BuildMagicLinkUrl$1, EmailSender, EmailSender as EmailSender$1, IssueResult, IssueResult as IssueResult$1, SmsSender, generateMagicLinkToken } from "@aooth/auth";
3
- import * as _$_wooksjs_event_core0 from "@wooksjs/event-core";
1
+ import { Mate, TConsoleBase, TInterceptorDef, TMateParamMeta, TMoostMetadata } from "moost";
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";
4
3
  import { TCookieAttributesInput } from "@wooksjs/event-http";
5
- import { TrustedDeviceRecord, UserCredentials, UserService } from "@aooth/user";
6
- import { WfFinished } from "@atscript/moost-wf";
4
+ import { FederatedIdentityStore, FederatedProfileSnapshot, MfaMethod, TransferablePolicy, TrustedDeviceRecord, UserCredentials, UserService } from "@aooth/user";
5
+ import { FinishWfOpts, WfFinished, useAtscriptWf } from "@atscript/moost-wf";
7
6
  import { MoostWf, WfOutlet, WfOutletTokenConfig, WfStateStrategy } from "@moostjs/event-wf";
7
+ import { FederatedLoginService, NormalizedProfile, OAuthProviderRegistry } from "@aooth/idp";
8
8
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
+ import { AuthCodeStore, ClientRedirectPolicy, IdTokenSigner, OidcClaimsResolver, PendingAuthorizationStore } from "@aooth/auth/authz";
9
10
 
10
11
  //#region src/auth.config.d.ts
11
12
  /** Resolved cookie attributes. Same shape is used for both access + refresh. */
@@ -47,170 +48,7 @@ interface ResolvedAuthOptions {
47
48
  enableBearer: boolean;
48
49
  }
49
50
  //#endregion
50
- //#region src/auth.opts.d.ts
51
- declare class AuthOpts {
52
- /** Pincode infrastructure shared by login MFA, invite MFA, and recovery OTP. */
53
- mfa: {
54
- pincodeLength: number;
55
- pincodeTtlMs: number;
56
- pincodeResendTimeoutMs: number;
57
- };
58
- /** Magic-link TTL shared by login (alt-credentials), invite, recovery. */
59
- magicLinkTtlMs: number;
60
- /** Canonical login URL — used by invite (post-accept redirect) and recovery (abort-to-login + post-reset redirect) as the resolver-default loginUrl. */
61
- loginUrl: string;
62
- /** TOTP provisioning issuer — used by login MFA and invite MFA enrollment. */
63
- totpIssuer: string;
64
- }
65
- //#endregion
66
- //#region src/workflows/auth-workflow.base.d.ts
67
- /**
68
- * Method names for the MFA enrollment helper. Re-exported from
69
- * `login.workflow.options` as `MfaTransport` (kept as the public alias) so
70
- * existing consumers don't need to switch import paths.
71
- */
72
- type MfaTransport = "sms" | "email" | "totp";
73
- /**
74
- * Context shape consumed by the `enrollPickPhase` / `enrollAddressPhase` /
75
- * `enrollConfirmPhase` helpers. Both `LoginWfCtx` and
76
- * `InviteWfCtx` extend this implicitly (they declare the same field set).
77
- * Kept structural so neither workflow's full ctx union has to be imported
78
- * here — base stays workflow-agnostic.
79
- */
80
- interface MfaEnrollCtx {
81
- enrollMethod?: MfaTransport;
82
- enrollAddress?: string;
83
- enrollSecret?: string;
84
- enrollUri?: string;
85
- enrollAvailableTransports?: MfaTransport[];
86
- enrollDone?: boolean;
87
- pin?: string;
88
- pinExpire?: number;
89
- pinSentTo?: string;
90
- /**
91
- * Next-allowed-resend timestamp for the Phase 3 confirm pincode (sms/email
92
- * only). Written by the Phase 2 initial send + the Phase 3 `resend`
93
- * alt-action; consulted by `resend` to throttle re-emits. Mirrors the
94
- * `pinTimeout` pattern used by the login `pincode-check-login` step.
95
- */
96
- enrollPincodeCooldown?: number;
97
- }
98
- /**
99
- * Looser structural mirror of `DeliverPayload` from `login.workflow.ts`.
100
- * The base file mustn't import from a sibling workflow file; the concrete
101
- * workflow's strict discriminated union is structurally assignable to this.
102
- */
103
- interface DeliverPayloadLike {
104
- channel: "email" | "sms";
105
- kind: string;
106
- recipient: string;
107
- code?: string;
108
- expiresAt?: number;
109
- ttlMs?: number;
110
- userId?: string;
111
- }
112
- interface MfaEnrollDeps {
113
- ctx: MfaEnrollCtx;
114
- username: string;
115
- users: UserService;
116
- /** Concrete workflow's `deliver` hook, narrowed to the payloads this flow emits. */
117
- deliver: (payload: DeliverPayloadLike) => Promise<void>;
118
- forms: {
119
- pickMethod: TAtscriptAnnotatedType;
120
- address: TAtscriptAnnotatedType;
121
- confirm: TAtscriptAnnotatedType;
122
- };
123
- transports: MfaTransport[];
124
- pincodeLength: number;
125
- pincodeTtlMs: number;
126
- /**
127
- * Per-method resend cooldown for the Phase 3 confirm pincode (sms/email).
128
- * Mirrors `LoginWorkflowOpts.mfa.pincodeResendTimeoutMs`.
129
- */
130
- pincodeResendTimeoutMs: number;
131
- /** TOTP provisioning issuer (rendered in the authenticator app). */
132
- issuer: string;
133
- /**
134
- * Enrollment policy. `'required'` runs through all 3 phases. `'optional'`
135
- * additionally watches for a `skip` action on the Phase 1 pickMethod form —
136
- * a skip click short-circuits by setting `ctx.enrollDone = true`. The
137
- * caller is expected to gate this step out entirely when `mode === 'disabled'`.
138
- */
139
- mode: "required" | "optional";
140
- /**
141
- * Optional bridge fired at the end of `enrollConfirmPhase` (or on a skip in
142
- * `'optional'` mode at any phase) — right after `enrollDone` flips true. Login
143
- * uses this to mirror `enrollDone` → `mfaChecked` so its outer MFA while-loop
144
- * (gated on `!mfaChecked`) exits. Invite omits it because its enrollment
145
- * while-loop is gated on `!enrollDone` directly.
146
- */
147
- onComplete?: (ctx: MfaEnrollCtx) => void;
148
- }
149
- /** Workflow context shape expected by `mintPin` + `verifyPin`. */
150
- interface PinCtx {
151
- pin?: string;
152
- pinExpire?: number;
153
- }
154
- /**
155
- * Structural ctx shape consumed by `processInlineConsent`. Mirrors the
156
- * relevant subset of the workflow ctx types so the helper stays
157
- * workflow-agnostic. The helper consumes only the dynamic `pendingConsents`
158
- * descriptor array (populated by `prepare-consents` from
159
- * `ConsentStore.getPendingConsents()`) and the per-run booking fields it
160
- * writes itself — the prior static `acceptance` / `termsVersion` branches
161
- * were retired in Phase 6 along with the matching `ctx.acceptance` field
162
- * on each workflow ctx type.
163
- */
164
- interface InlineConsentCtx {
165
- /**
166
- * Descriptors the user still needs to be prompted for — set once by
167
- * `prepare-consents` after username-bind. Empty / unset ⇒ no consents to
168
- * collect (helper short-circuits).
169
- */
170
- pendingConsents?: ConsentDescriptorLike[];
171
- /**
172
- * Subset of descriptor ids the user ticked on the carrier form. Set by
173
- * `processInlineConsent` after silent-dropping unknown ids — `pendingConsents`
174
- * is the server's whitelist, NOT what the client posts.
175
- */
176
- acceptedConsentIds?: string[];
177
- /**
178
- * Wall-clock ms at the moment `processInlineConsent` resolved the
179
- * `consents` array. Captured here so the persisted `ConsentEvent.at`
180
- * reflects user-action time, not write-time — surviving a paused
181
- * workflow's resume gap.
182
- */
183
- consentsDecidedAt?: number;
184
- /**
185
- * Set true by `persist-consents` after the batched `consentStore.save`
186
- * call fires (or after the step short-circuits with no pending consents).
187
- * Gates the helper from re-staging on a subsequent carrier-form submission.
188
- */
189
- consentsPersisted?: boolean;
190
- }
191
- /**
192
- * Structural alias of `ConsentDescriptor` — kept inline so this module
193
- * doesn't import from `../consent.store.ts` (which would create a cycle:
194
- * consent.store.ts already imports `ConsentEvent` from here).
195
- */
196
- interface ConsentDescriptorLike {
197
- id: string;
198
- text: string;
199
- required?: string;
200
- version?: string;
201
- }
202
- /**
203
- * Subset of the carrier-form payload that `processInlineConsent` reads.
204
- * Phase 5 replaces the pre-existing static `{ acceptedTerms?, marketingOptIn? }`
205
- * pair with a single dynamic `consents: string[]` — the SUBSET of descriptor
206
- * ids the user ticked in the `AsConsentArray` (`@atscript/vue-aooth`)
207
- * component. The server reads `pendingConsents` from its own ctx (NOT from
208
- * this input) to decide which ids are valid; unknown ids are silently
209
- * dropped (audit-grade defense — see helper rationale).
210
- */
211
- interface InlineConsentInput {
212
- consents?: string[];
213
- }
51
+ //#region src/consent.store.d.ts
214
52
  /**
215
53
  * Consent event emitted to the `ConsentStore.save(username, events)` DI
216
54
  * provider. Storage shape is intentionally the consumer's call — Mongo users
@@ -238,164 +76,6 @@ interface ConsentEvent {
238
76
  */
239
77
  at: number;
240
78
  }
241
- /**
242
- * Structural alias for `ReturnType<typeof useAtscriptWf>` — only the
243
- * `requireInput` method is consumed by `processInlineConsent`, kept narrow
244
- * so callers can pass any form's wf handle without TS choking on the form-
245
- * specific `resolveInput` return type.
246
- */
247
- type WfRequireInputOnly = {
248
- requireInput(opts?: {
249
- errors?: Record<string, string>;
250
- formMessage?: string;
251
- }): unknown;
252
- };
253
- declare class AuthWorkflowBase {
254
- /**
255
- * Asserts `ctx.username` is populated. Workflow steps reach for `ctx.username`
256
- * after `credentials`/`init` has set it; losing it indicates a workflow-state
257
- * bug, not a client error. Throws `HttpError(500)` on miss; otherwise narrows
258
- * the field to `string` for the caller via `asserts`.
259
- */
260
- protected requireUsername<T extends {
261
- username?: string;
262
- }>(ctx: T): asserts ctx is T & {
263
- username: string;
264
- };
265
- /**
266
- * Wrap an `UserStore` mutation that can race (`withCas`-backed paths:
267
- * `consumeBackupCode`, `addMfaMethod`, `confirmMfaMethod`, `addTrustedDevice`)
268
- * so a CAS retry-budget exhaustion surfaces as 409 Conflict — the canonical
269
- * OCC status — rather than bubbling to the moost default 500. Client SHOULD
270
- * retry; a 500 falsely implies the server is broken. Other `UserAuthError`
271
- * shapes pass through unchanged so step-local catch blocks (e.g. ALREADY_EXISTS
272
- * → 409 with a different reason) still see them.
273
- */
274
- protected withStoreErrorTranslation<T>(op: () => Promise<T>): Promise<T>;
275
- /**
276
- * Resolve the client IP from the active HTTP request, swallowing the case
277
- * where there is no HTTP context (unit tests that hand-roll the wf runtime).
278
- */
279
- protected resolveClientIp(): string | undefined;
280
- /**
281
- * Mint a numeric pincode and stash it + its expiry onto `ctx`. Returns the
282
- * code so the caller can hand it to the delivery transport.
283
- */
284
- protected mintPin(ctx: PinCtx, length: number, ttlMs: number): string;
285
- /**
286
- * Verify a submitted pincode against `ctx.pin`. Returns a `{ code: '…' }`
287
- * error map on expired/invalid, or `null` on success. Callers wrap the result
288
- * with `useAtscriptWf(PincodeForm).requireInput({ errors })`.
289
- */
290
- protected verifyPin(ctx: PinCtx, submitted: string | undefined): {
291
- code: string;
292
- } | null;
293
- /**
294
- * Validate + stash inline-consent fields submitted on a carrier form.
295
- *
296
- * SECURITY (silent-drop): the server reads its OWN `ctx.pendingConsents`
297
- * (set once by `prepare-consents` from `ConsentStore.getPendingConsents()`)
298
- * as the authoritative whitelist of valid descriptor ids. Any id in the
299
- * user-submitted `input.consents` array that does NOT match a current
300
- * pending descriptor is SILENTLY DROPPED — no error, no log, no signal
301
- * back to the client. This preserves the audit-grade
302
- * "what user saw is what server records" invariant: an attacker
303
- * submitting `consents: ['terms', 'gdpr-forged-id']` against a descriptor
304
- * list of only `['terms']` cannot forge an audit record for the
305
- * never-displayed `'gdpr-forged-id'` consent. Surfacing the drop would
306
- * leak the consent universe (probing surface), so the defense is silent.
307
- *
308
- * SECURITY (mandatory-by-message): each descriptor's `required` field is
309
- * the load-bearing mandatory flag. A non-empty string means the consent
310
- * is MANDATORY and that string IS the per-row error message — the
311
- * `AsConsentArray` component surfaces it inline per descriptor; the
312
- * server throws the SAME copy as a form-level error on the bound
313
- * `consents` field when the first required descriptor is missing from
314
- * the submitted set. Absent / empty `required` ⇒ optional consent — the
315
- * un-ticked descriptor is still persisted as `{accepted: false}` (audit
316
- * default — proves the user was asked).
317
- *
318
- * Idempotency: once `ctx.consentsPersisted` is true, the helper is a
319
- * no-op. Same for `ctx.pendingConsents` being empty / unset — no
320
- * pending = nothing to validate (the carrier-form's `AsConsentArray`
321
- * also self-hides on empty `pendingConsents`).
322
- */
323
- protected processInlineConsent(ctx: InlineConsentCtx, input: InlineConsentInput, wf: WfRequireInputOnly): void;
324
- /**
325
- * Batched consent persistence — shared `persist-consents` step body for
326
- * `LoginWorkflow` / `InviteWorkflow` / `RecoveryWorkflow`. Fans one
327
- * `ConsentEvent` per pending descriptor out to the `ConsentStore.save`
328
- * DI provider in a single call. Audit-friendly default: declined-optional
329
- * consents are persisted with `accepted: false` (customers who want only
330
- * accepted events filter in their `save()` override). `accepted` is
331
- * derived per descriptor by `acceptedConsentIds.has(id)`. Idempotent via
332
- * `ctx.consentsPersisted`; short-circuits with no events when
333
- * `pendingConsents` is empty (defensive — the schema condition gates on
334
- * `consentsDecidedAt` which is only set when pending was non-empty).
335
- *
336
- * Each workflow's `@Step("persist-consents")` method is a one-liner
337
- * delegate to this helper — the @Step decorator must stay on the
338
- * subclass so the wf engine registers the step id under the correct
339
- * controller, but the body lives here once.
340
- */
341
- protected runPersistConsents(ctx: InlineConsentCtx & {
342
- username?: string;
343
- }, consentStore: ConsentStore): Promise<undefined>;
344
- /**
345
- * Phase 1 of MFA enrollment. Picks the method (auto-pick if only one
346
- * transport, otherwise pause for the picker form), handles the `skip`
347
- * alt-action in `'optional'` mode, and — when TOTP is picked — provisions
348
- * the secret idempotently in the same step body so the next iteration can
349
- * proceed straight to confirm. Sync-friendly return type because the
350
- * auto-pick branch and the picker-form branch both stay synchronous; the
351
- * TOTP-provisioning tail is the only async path.
352
- *
353
- * Atomic boundary: after this helper runs, `ctx.enrollMethod` is set AND
354
- * (for totp) `ctx.enrollSecret` is provisioned. Confirm doesn't need to
355
- * worry about provisioning.
356
- */
357
- protected enrollPickPhase(deps: MfaEnrollDeps): undefined | Promise<undefined>;
358
- /**
359
- * Phase 2 of MFA enrollment. Collects the sms/email address, persists it as
360
- * an unconfirmed method, mints + dispatches the pincode. Handles `skip` /
361
- * `useDifferentMethod` alt-actions. Not invoked for totp (no address to
362
- * collect — the schema condition gates it out).
363
- */
364
- protected enrollAddressPhase(deps: MfaEnrollDeps): Promise<undefined>;
365
- /**
366
- * Phase 3 of MFA enrollment. Verifies the user-submitted code (TOTP or
367
- * pincode), marks the method confirmed, sets it as the default, and flags
368
- * `ctx.enrollDone = true`. Handles `skip` / `useDifferentMethod` / `resend`
369
- * alt-actions (cleanup on the first two; resend re-mints + redispatches).
370
- *
371
- * Idempotently provisions the TOTP secret at the top when missing — covers
372
- * the path where a consumer setter override (e.g. `inviteSetupMfa`)
373
- * pre-picks totp, leaving pickPhase's schema gate closed; the secret IS
374
- * what confirm needs, so confirm guarantees it exists. For sms/email the
375
- * unconfirmed method row + pincode are written by addressPhase, so there's
376
- * nothing to provision here.
377
- */
378
- protected enrollConfirmPhase(deps: MfaEnrollDeps): Promise<undefined>;
379
- /**
380
- * Send a pincode for the active sms/email enrollment method and stamp
381
- * `ctx.pinSentTo` with the masked recipient. Shared by Phase 2 initial
382
- * dispatch and Phase 3 `resend`. Caller is responsible for `mintPin` +
383
- * stamping `enrollPincodeCooldown` BEFORE calling — this just dispatches.
384
- * Not called for TOTP (no pincode to send).
385
- */
386
- protected sendEnrollPincode(ctx: MfaEnrollCtx, deps: MfaEnrollDeps, address: string, code: string): Promise<void>;
387
- /**
388
- * Cleanup any partially-persisted enrollment state (unconfirmed method row +
389
- * ctx scratch). Called when the user picks `skip` or `useDifferentMethod`
390
- * mid-flow on Phase 3, where the unconfirmed method has already been written
391
- * via `addMfaMethod` (Phase 1 for totp, Phase 2 for sms/email). On
392
- * `useDifferentMethod` the caller relies on `enrollMethod` being cleared so
393
- * the loop re-enters Phase 1.
394
- */
395
- protected cleanupEnrollment(ctx: MfaEnrollCtx, users: UserService, username: string): Promise<void>;
396
- }
397
- //#endregion
398
- //#region src/consent.store.d.ts
399
79
  /**
400
80
  * Descriptor for a single consent prompt. Customers' ConsentStore.getPendingConsents
401
81
  * returns an array of these — the workflow transports them via `@wf.context.pass`
@@ -435,15 +115,14 @@ interface ConsentDescriptor {
435
115
  */
436
116
  declare class ConsentStore {
437
117
  /**
438
- * Returns descriptors for consents this user still needs to accept on the
439
- * next prompt boundary. Empty array no consent step renders. The workflow
440
- * passes its identity + (optionally) the channel it's about to use so the
441
- * customer's impl can prompt different consent sets per workflow/channel.
118
+ * Returns descriptors for general-purpose consents this user still needs to
119
+ * accept on the next prompt boundary terms / privacy / marketing / age /
120
+ * jurisdiction-specific notices. Empty array no consent step renders.
121
+ * Scope is the user; the returned descriptor set MUST NOT vary by workflow
122
+ * or transport channel. OTP channel-ownership disclosures are captured
123
+ * separately via `recordOtpChannelConsent`.
442
124
  */
443
- getPendingConsents(_username: string | undefined, _ctx: {
444
- workflow: string;
445
- channel?: "email" | "sms";
446
- }): Promise<ConsentDescriptor[]>;
125
+ getPendingConsents(_username: string | undefined): Promise<ConsentDescriptor[]>;
447
126
  /**
448
127
  * Persist a batch of captured consent events. Default: no-op. Override to
449
128
  * write to your audit table / event store / whatever your legal team
@@ -502,7 +181,7 @@ declare function authGuardInterceptor(opts?: AuthOptions): TInterceptorDef;
502
181
  */
503
182
  declare function AuthGuarded(opts?: AuthOptions): ClassDecorator & MethodDecorator;
504
183
  //#endregion
505
- //#region ../../node_modules/.pnpm/@wooksjs+event-wf@0.7.15_@prostojs+logger@0.4.3_@wooksjs+event-core@0.7.15_@wooksjs+eve_11afd4acf41dda7cd1e566c02621a2f8/node_modules/@wooksjs/event-wf/dist/index.d.ts
184
+ //#region ../../node_modules/.pnpm/@wooksjs+event-wf@0.7.17_@prostojs+logger@0.4.3_@wooksjs+event-core@0.7.17_@wooksjs+eve_308375c6ad6313773ebd6e419ace01e4/node_modules/@wooksjs/event-wf/dist/index.d.ts
506
185
  interface WfFinishedResponse {
507
186
  type: 'redirect' | 'data';
508
187
  /** Redirect URL or response body */
@@ -544,7 +223,7 @@ interface AuthLogoutBody {
544
223
  }
545
224
  /**
546
225
  * POST /auth/refresh response body. Also returned by workflow finalize steps
547
- * (e.g. `LoginWorkflow`) when issuing tokens after a successful flow.
226
+ * (e.g. `AuthWorkflow`) when issuing tokens after a successful flow.
548
227
  *
549
228
  * Tokens are populated only when `enableBearer` is true. With `enableBearer=false`
550
229
  * the body still echoes `userId` + `accessExpiresAt` so the caller can schedule
@@ -564,10 +243,32 @@ interface AuthOkResponse {
564
243
  //#endregion
565
244
  //#region src/auth.composables.d.ts
566
245
  interface AuthBindings {
567
- getAuthContext<TClaims extends object = Record<string, unknown>>(): AuthContext$1<TClaims> | null;
246
+ getAuthContext<TPayload extends object = Record<string, unknown>>(): AuthContext$1<TPayload> | null;
568
247
  /** @throws `HttpError(401)` if no `AuthContext` is present in the event. */
569
248
  getUserId(): string;
570
249
  isAuthenticated(): boolean;
250
+ /**
251
+ * `sessionId` of the token family that authenticated THIS request — for
252
+ * "this device" matching and as the `keepSessionId` for `revokeOtherSessions`.
253
+ * `undefined` when unauthenticated.
254
+ */
255
+ getSessionId(): string | undefined;
256
+ /**
257
+ * List the current user's active sessions (one row per device). Defaults to
258
+ * the browser-safe set (ordinary interactive sessions); pass `kind` to segment
259
+ * a non-browser bucket (`kind: "cli-session"`) or `kind: "*"` for every kind.
260
+ */
261
+ listSessions(opts?: {
262
+ enrich?: SessionEnricher$1;
263
+ kind?: string | string[];
264
+ }): Promise<SessionInfo$1[] | EnrichedSession$1[]>;
265
+ /** Revoke one of the current user's sessions by id (whole token family). */
266
+ revokeSession(sessionId: string): Promise<void>;
267
+ /**
268
+ * Log out the current user's OTHER sessions, keeping this one. Returns the
269
+ * count revoked. @throws `HttpError(401)` if the current session is unknown.
270
+ */
271
+ revokeOtherSessions(): Promise<number>;
571
272
  /** @throws `HttpError(500)` when no `authGuardInterceptor(opts)` is on the chain. */
572
273
  readonly options: ResolvedAuthOptions;
573
274
  /** Same precedence as `authGuardInterceptor`: Bearer wins when both enabled. */
@@ -590,7 +291,7 @@ interface AuthBindings {
590
291
  * `HttpError(500)` loudly — that's a configuration error, not a runtime
591
292
  * fallback case.
592
293
  */
593
- declare const useAuth: _$_wooksjs_event_core0.WookComposable<AuthBindings>;
294
+ declare const useAuth: import("@wooksjs/event-core").WookComposable<AuthBindings>;
594
295
  //#endregion
595
296
  //#region src/auth.decorator.d.ts
596
297
  /**
@@ -636,19 +337,32 @@ declare function getAuthMate(): AuthMate;
636
337
  //#endregion
637
338
  //#region src/auth.controller.d.ts
638
339
  /** Workflows allowed by the bundled `/auth/trigger` endpoint. Subclasses override `triggerWf()` to extend. */
639
- declare const DEFAULT_AUTH_WORKFLOWS: readonly ["auth/login/flow", "auth/recovery/flow", "auth/invite/start"];
340
+ declare const DEFAULT_AUTH_WORKFLOWS: readonly ["auth/login/flow", "auth/invite/start", "auth/recovery/flow", "auth/signup/flow"];
341
+ /**
342
+ * Workflow id allowed by the GUARDED `/auth/change-password` trigger.
343
+ * Deliberately NOT in `DEFAULT_AUTH_WORKFLOWS` — the authenticated
344
+ * change-password flow must never be reachable from the public `/auth/trigger`.
345
+ */
346
+ declare const CHANGE_PASSWORD_WORKFLOW = "auth/change-password/flow";
347
+ /**
348
+ * Workflow id allowed by the GUARDED `/auth/add-mfa` trigger — the authenticated
349
+ * "add a second factor" flow. Like {@link CHANGE_PASSWORD_WORKFLOW}, deliberately
350
+ * NOT in `DEFAULT_AUTH_WORKFLOWS`: it must never be reachable from the public
351
+ * `/auth/trigger`.
352
+ */
353
+ declare const ADD_MFA_WORKFLOW = "auth/add-mfa/flow";
640
354
  /**
641
355
  * Public REST endpoints for credential management. Four endpoints total:
642
356
  *
643
357
  * - `POST /auth/logout` — best-effort token revocation + cookie clear.
644
358
  * - `POST /auth/refresh` — rotate access/refresh tokens.
645
359
  * - `GET /auth/status` — return the current `AuthContext`.
646
- * - `POST /auth/trigger` — single workflow trigger covering `auth/login/flow`,
647
- * `auth/recovery/flow`, and `auth/invite/start`.
360
+ * - `POST /auth/trigger` — single workflow trigger covering the unified
361
+ * `AuthWorkflow`'s three `@Workflow` schemas (`/login`, `/invite`, `/recover`).
648
362
  *
649
363
  * The historical `/auth/login` and `/auth/password` endpoints were dropped —
650
364
  * both flows go through the workflow trigger now (full MFA / SSO / etc.
651
- * surface lives in `LoginWorkflow` and `RecoveryWorkflow`).
365
+ * surface lives in `AuthWorkflow`).
652
366
  *
653
367
  * Exported so consumers can subclass to add app-specific workflow ids to the
654
368
  * allow-list (override `triggerWf()` with a different `@WfTrigger({ allow })`)
@@ -658,10 +372,47 @@ declare class AuthController {
658
372
  protected readonly auth: AuthCredential;
659
373
  protected readonly users?: UserService | undefined;
660
374
  constructor(auth: AuthCredential, users?: UserService | undefined);
375
+ /**
376
+ * Scope the refresh cookie to this controller's REAL mounted route, resolved
377
+ * once at application boot from Moost's post-bind route table. `@HandlerPaths`
378
+ * defaults to the running controller, so a prefixed or subclassed mount (e.g.
379
+ * `api/auth` → `/api/auth/refresh`) is handled with no config; the result
380
+ * feeds {@link RefreshCookiePathHolder}, which `authGuardInterceptor` reads.
381
+ * Leaves the holder unset — so the guard keeps its configured default — on 0
382
+ * matches (no refresh route registered) or >1 (ambiguous; never guess),
383
+ * warning at boot in the ambiguous case rather than on the first request.
384
+ */
385
+ initRefreshCookiePath(paths: string[], logger: TConsoleBase): Promise<void>;
661
386
  logout(body: AuthLogoutBody | undefined): Promise<AuthOkResponse>;
662
387
  refresh(body: AuthRefreshBody | undefined): Promise<AuthLoginResponse>;
663
388
  status(): AuthContext$1;
664
389
  triggerWf(): void;
390
+ /**
391
+ * GUARDED trigger for the authenticated "change my password" flow. Unlike
392
+ * `triggerWf`, this is NOT `@Public()` — the auth guard rejects an
393
+ * unauthenticated caller with 401 before the flow starts. The method-level
394
+ * `@ArbacResource("auth.change-password")` overrides the class `"auth"`
395
+ * resource so the trigger, the `@Workflow` body, and every flow step all
396
+ * resolve to the same `auth.change-password` resource / `self` action — a
397
+ * customer enables the whole feature with a single
398
+ * `allow("auth.change-password", "*")` grant and forbids it (SSO-only orgs)
399
+ * by omitting that grant. The flow binds `ctx.subject` from the session in
400
+ * `init-change-password`, so it is structurally "change MY password" — there
401
+ * is no target-user parameter.
402
+ */
403
+ changePassword(): void;
404
+ /**
405
+ * GUARDED trigger for the authenticated "add an MFA method" flow — the
406
+ * profile-maintenance twin of {@link changePassword}. NOT `@Public()`: the
407
+ * auth guard rejects an unauthenticated caller with 401 before the flow
408
+ * starts, and `@ArbacResource("auth.add-mfa")` / `@ArbacAction("self")` gate
409
+ * the trigger, the `@Workflow` body, and the arbac-gated flow steps to one
410
+ * resource/action — a customer enables it with a single
411
+ * `allow("auth.add-mfa", "*")` grant and forbids it by omitting it. The flow
412
+ * binds `ctx.subject` from the session in `init-add-mfa`, so it is
413
+ * structurally "add a factor to MY account" with no target-user parameter.
414
+ */
415
+ addMfa(): void;
665
416
  /**
666
417
  * Side route mapping a redeemed-invite `uid` to the same idempotent
667
418
  * envelope the `inviteIdempotentRedirect` workflow step renders. The SPA
@@ -673,15 +424,15 @@ declare class AuthController {
673
424
  *
674
425
  * `@Public()` — invitees aren't signed in at this point.
675
426
  *
676
- * Defaults for `loginUrl` / `alreadyAcceptedRedirectUrl` mirror the bundled
677
- * `InviteWorkflowOpts` defaults (`/login` / `/login`). Subclasses override
427
+ * Defaults for `loginUrl` / `alreadyAcceptedRedirectUrl` mirror the unified
428
+ * `AuthWorkflow` defaults (`/login` / `/login`). Subclasses override
678
429
  * `resolveInvitePostRedemption()` to read live workflow opts.
679
430
  */
680
431
  invitePostRedemption(uid: string | undefined): Promise<WfFinished>;
681
432
  /**
682
- * URLs used by `invitePostRedemption`. Defaults mirror
683
- * `mergeInviteOpts({})` so subclasses that customize either of those
684
- * options can override here to keep the side route in sync.
433
+ * URLs used by `invitePostRedemption`. Defaults mirror the unified
434
+ * `AuthWorkflow` resolved opts so subclasses that customize either of
435
+ * those options can override here to keep the side route in sync.
685
436
  */
686
437
  protected resolveInvitePostRedemption(): {
687
438
  loginUrl: string;
@@ -689,6 +440,77 @@ declare class AuthController {
689
440
  };
690
441
  }
691
442
  //#endregion
443
+ //#region src/sessions.controller.d.ts
444
+ /**
445
+ * Injectable read-time session enricher. Default is identity — aooth ships NO
446
+ * UA-parser or GeoIP dependency. Consumers who want `device` / `browser` / `os`
447
+ * / `location` columns subclass this, override `enrich`, and register the
448
+ * replacement via moost's `createReplaceRegistry([SessionEnricherProvider, MyEnricher])`:
449
+ *
450
+ * ```ts
451
+ * @Injectable() // SINGLETON
452
+ * class MyEnricher extends SessionEnricherProvider {
453
+ * override enrich(s: SessionInfo): EnrichedSession {
454
+ * const ua = parseUserAgent(s.metadata?.userAgent);
455
+ * return { ...s, device: ua.device, browser: ua.browser, os: ua.os,
456
+ * location: geoLookup(s.metadata?.ip) };
457
+ * }
458
+ * }
459
+ * ```
460
+ *
461
+ * Singleton scope is required — `@Injectable()` (no scope arg) → SINGLETON.
462
+ */
463
+ declare class SessionEnricherProvider {
464
+ enrich(session: SessionInfo$1): EnrichedSession$1 | Promise<EnrichedSession$1>;
465
+ }
466
+ /**
467
+ * Optional, mountable controller exposing a user's active sessions for the
468
+ * "Active sessions" UI. Register it (or a subclass) alongside `AuthController`
469
+ * to enable the endpoints — registration IS the opt-in; aooth never mounts it
470
+ * implicitly.
471
+ *
472
+ * Routes (all under the `auth.sessions` ARBAC resource):
473
+ *
474
+ * | Method + path | Action | Effect |
475
+ * | ---------------------------------- | ---------- | ----------------------------------- |
476
+ * | `GET /auth/sessions` | `read` | the caller's own sessions |
477
+ * | `GET /auth/sessions/of/:userId` | `readAny` | another user's sessions (admin) |
478
+ * | `DELETE /auth/sessions/:sessionId` | `revoke` | revoke one of the caller's sessions |
479
+ * | `DELETE /auth/sessions?others=true`| `revoke` | revoke all but the caller's current |
480
+ *
481
+ * Each `SessionInfo` is mapped through the injectable {@link SessionEnricherProvider}
482
+ * before returning, and the caller's own session is flagged `current: true`.
483
+ * Both reads default to the browser-safe set (ordinary interactive sessions);
484
+ * `?kind=<kind>` segments a non-browser bucket (e.g. `?kind=cli-session`) and
485
+ * `?kind=*` returns every kind.
486
+ * NOT `@Public()` — the auth guard rejects unauthenticated callers with 401 and
487
+ * ARBAC gates each action; a customer enables it with `allow("auth.sessions", "*")`.
488
+ */
489
+ declare class SessionsController {
490
+ protected readonly auth: AuthCredential;
491
+ protected readonly enricher: SessionEnricherProvider;
492
+ constructor(auth: AuthCredential, enricher: SessionEnricherProvider);
493
+ listSessions(kind?: string): Promise<EnrichedSession$1[]>;
494
+ /**
495
+ * Admin read of another user's sessions. Gated by the separate `readAny`
496
+ * action so a customer can grant ordinary users `read` (own sessions) without
497
+ * granting cross-user visibility.
498
+ */
499
+ listSessionsOf(userId: string, kind?: string): Promise<EnrichedSession$1[]>;
500
+ revokeSession(sessionId: string): Promise<{
501
+ ok: true;
502
+ }>;
503
+ /**
504
+ * `DELETE /auth/sessions?others=true` — log out everywhere else, keeping the
505
+ * caller's current session. Returns the number of sessions revoked. A bare
506
+ * `DELETE /auth/sessions` (no `others`) is a 400 — revoking ALL of one's own
507
+ * sessions (including the current one) is what `POST /auth/logout` is for.
508
+ */
509
+ revokeOthers(others: string | undefined): Promise<{
510
+ revoked: number;
511
+ }>;
512
+ }
513
+ //#endregion
692
514
  //#region src/wf-trigger/decorator.d.ts
693
515
  interface WfTriggerOpts {
694
516
  /** Whitelist of workflow ids the trigger may start/resume. Defaults to the provider's setting. */
@@ -713,11 +535,24 @@ declare const WfTrigger: (opts?: WfTriggerOpts) => ClassDecorator & MethodDecora
713
535
  //#region src/wf-trigger/provider.d.ts
714
536
  /**
715
537
  * DI singleton owning the workflow-trigger wiring: state persistence, outlets,
716
- * and token wire. Consumers subclass to swap the state store, add outlets
717
- * (email, SMS, ...), or override `handle()` for per-request dispatch logic.
538
+ * and token wire. Consumers subclass to swap the durable state strategy, add
539
+ * outlets (email, SMS, ...), or override `handle()` for per-request dispatch.
540
+ *
541
+ * State is a NAMED STRATEGY REGISTRY rather than a single strategy. Every
542
+ * workflow STARTS on the `encapsulated` strategy (the registry default): state
543
+ * rides inside the SPA-held token, so opening a login form persists ZERO
544
+ * server-side rows before the first validated input — a restart/eviction can no
545
+ * longer 410 GONE an idle form. A later step swaps to the durable `store`
546
+ * strategy (`swapStrategy('store')`) once there is real state worth persisting.
718
547
  *
719
- * Defaults are intentionally minimal: in-memory state + HTTP outlet only.
720
- * Production deployments swap in a persistent `WfStateStore` and any outlets
548
+ * Per product decision BOTH registry entries default to `EncapsulatedStateStrategy`
549
+ * customers override only `storeStrategy()` to supply a real Redis/DB-backed
550
+ * `HandleStateStrategy`, so `swapStrategy('store')` never crashes on the bundled
551
+ * default. The encapsulated secret reuses the auth secret via
552
+ * `AuthCredential.deriveStateKey("wf-state")` (HKDF-derived, stable across
553
+ * restarts) unless `wfStateSecret()` is overridden with a dedicated secret.
554
+ *
555
+ * Outlets default to the HTTP outlet only; production deployments add the ones
721
556
  * they need by extending this class and re-binding via `setReplaceRegistry`.
722
557
  *
723
558
  * Uses `handleAsOutletRequest` (not `MoostWf.handleOutlet`) because the atscript
@@ -729,1501 +564,2505 @@ declare const WfTrigger: (opts?: WfTriggerOpts) => ClassDecorator & MethodDecora
729
564
  * and the wf engine reads action + form data directly from `body.input`. No
730
565
  * app-level bridging of `body.action` is needed.
731
566
  */
567
+ /** Named state-strategy registry: the shape `handleAsOutletRequest` reads off `state`. */
568
+ type StateRegistry = {
569
+ strategies: Record<string, WfStateStrategy>;
570
+ default: string;
571
+ };
572
+ /**
573
+ * Derive the exact 32-byte key the encapsulated wf-state strategy requires from
574
+ * an arbitrary-length app secret.
575
+ *
576
+ * The default {@link WfTriggerProvider.wfStateSecret} reuses the auth secret via
577
+ * `AuthCredential.deriveStateKey()`, which only works for stores backed by a
578
+ * symmetric secret (JWT / encapsulated). A stateful store (e.g. atscript-db —
579
+ * the recommended default once you adopt the session-listing APIs) has no key
580
+ * material to derive from, so its `deriveStateKey()` throws. Such consumers must
581
+ * override `wfStateSecret()` and supply their own secret — but the strategy
582
+ * needs *exactly* 32 bytes (a raw string is otherwise parsed as hex), so the
583
+ * obvious `return env.MY_SECRET` fails. This helper does the SHA-256-to-32-bytes
584
+ * derivation for you, no `node:crypto` import required:
585
+ *
586
+ * ```ts
587
+ * protected override wfStateSecret(): Buffer {
588
+ * return deriveWfStateSecret(env.MY_SECRET);
589
+ * }
590
+ * ```
591
+ *
592
+ * Deterministic — the same input always yields the same key, so it is stable
593
+ * across restarts (matching what `deriveStateKey` provided).
594
+ */
595
+ declare function deriveWfStateSecret(secret: string): Buffer;
732
596
  declare class WfTriggerProvider {
733
597
  protected readonly wf: MoostWf;
734
- protected state: WfStateStrategy;
598
+ protected readonly auth: AuthCredential;
735
599
  protected outlets: WfOutlet[];
736
600
  protected token: WfOutletTokenConfig;
737
- constructor(wf: MoostWf);
601
+ constructor(wf: MoostWf, auth: AuthCredential);
602
+ /**
603
+ * Secret for the encapsulated wf-state token. Default reuses the auth secret
604
+ * (HKDF-derived, stable across restarts) — which works only for stores backed
605
+ * by a symmetric secret (JWT / encapsulated). A stateful store (atscript-db)
606
+ * has nothing to derive from, so `deriveStateKey()` throws; those consumers
607
+ * MUST override this and supply a dedicated secret. The strategy needs exactly
608
+ * 32 bytes, so wrap your app secret in {@link deriveWfStateSecret}:
609
+ * `return deriveWfStateSecret(env.MY_SECRET)`.
610
+ */
611
+ protected wfStateSecret(): string | Buffer;
612
+ /** TTL (ms) for encapsulated pre-validation tokens. Default: undefined = no TTL (token valid until used), so an idle login form never expires server-side. */
613
+ protected wfStateEncapsulatedTtlMs(): number | undefined;
614
+ /** Durable strategy a workflow swaps to after the first validated input. Default = encapsulated (no real store); customers override to return e.g. new HandleStateStrategy({ store: <redis/db> }). */
615
+ protected storeStrategy(): WfStateStrategy;
616
+ private makeEncapsulated;
617
+ private cachedState?;
618
+ /** Named strategy registry: every wf starts on `encapsulated` (default); a step calls swapStrategy('store') to move durable. Built lazily so subclass overrides (storeStrategy/wfStateSecret) are in effect. */
619
+ protected stateRegistry(): StateRegistry;
738
620
  handle(opts?: {
739
621
  allow?: string[];
740
622
  token?: WfOutletTokenConfig;
741
623
  }): Promise<unknown>;
742
624
  }
743
625
  //#endregion
744
- //#region src/audit/index.d.ts
626
+ //#region src/oauth/oauth.controller.d.ts
745
627
  /**
746
- * Audit event emitter used by `LoginWorkflow.audit-login` (and future
747
- * recovery / invite audit steps) to fan out login.success and similar events
748
- * to consumer-supplied sinks (DB table, log file, Kafka topic).
628
+ * REST surface for federated-login ACCOUNT MANAGEMENT (OAuth2 / OIDC), RFC
629
+ * IDP.md §3.7. Two routes anonymous LOGIN is NOT here: it lives in the login
630
+ * workflow (the login form offers a "Continue with <provider>" button that ends
631
+ * the wf with a redirect to the provider; see `AuthWorkflow.beginSso`). The
632
+ * provider's `redirect_uri` lands on the SPA, which bridges `{ provider, code,
633
+ * state }` into the public `/auth/trigger` STARTING `auth/login/flow` — so MFA /
634
+ * consent / cookie issuance reuse the existing workflow machinery.
749
635
  *
750
- * Aoothjs ships no concrete sink. Workflow subclasses override the
751
- * `audit(event)` protected method to wire their preferred sink; when not
752
- * overridden the workflow's default implementation is a no-op.
636
+ * The round-trip is STATELESS — no flow store. The PKCE verifier + OIDC nonce
637
+ * are DERIVED from a non-secret seed carried in the signed `state` (the same
638
+ * seed double-submitted in the CSRF cookie) and re-derived at the callback (see
639
+ * {@link OAuthProviderRegistry.deriveSeededPkce}). Nothing secret rides in the URL.
640
+ *
641
+ * - `GET /auth/oauth/identities` — list the current user's CONNECTED ACCOUNTS
642
+ * (linked provider identities). Self-scoped read projection over
643
+ * `FederatedIdentityStore.listForUser(userId)`; `(provider, subject)` is the
644
+ * key the client passes back to `unlink`.
645
+ * - `GET /auth/oauth/:provider/link` — begin an account-LINK for the
646
+ * authenticated user. 302s to the provider after deriving PKCE/nonce from a
647
+ * fresh seed and signing `{ random, provider, redirect, userId }` into `state`
648
+ * (the `userId` is HS256-signed → tamper-proof, server-minted). Self-scoped:
649
+ * `getUserId()` 401s an anonymous caller. `sso-callback` links the verified
650
+ * identity to that `userId`.
651
+ * - `DELETE /auth/oauth/:provider/:subject` — disconnect a linked identity.
652
+ * Self-scoped; guards against removing the user's only sign-in method, then
653
+ * revokes the user's sessions.
654
+ *
655
+ * `identities` / `link` / `unlink` are `@Public()` self-scoped primitives
656
+ * (mirroring `AuthController.logout`/`status`): they derive identity from the
657
+ * session, never from a parameter. Subclass + add `@ArbacAction(...)` to gate
658
+ * them further (e.g. an admin cross-user view).
753
659
  */
754
- interface AuditEvent {
755
- kind: string;
756
- /** Auth-scoped user identity (the `username` resolved by the workflow). */
757
- userId?: string;
758
- /** Workflow id that emitted the event (e.g. `auth/login/flow`). */
759
- workflow?: string;
760
- /** Source IP (when the workflow could resolve one). */
761
- ip?: string;
762
- /** User-agent header. */
763
- userAgent?: string;
764
- /** Free-form payload `method`, `tenantId`, etc. */
765
- [key: string]: unknown;
660
+ declare class OAuthController {
661
+ protected readonly registry: OAuthProviderRegistry;
662
+ protected readonly auth: AuthCredential;
663
+ protected readonly users: UserService;
664
+ protected readonly federated: FederatedIdentityStore;
665
+ constructor(registry: OAuthProviderRegistry, auth: AuthCredential, users: UserService, federated: FederatedIdentityStore);
666
+ /** Default post-login redirect when the caller supplies none / an unsafe one. */
667
+ protected defaultRedirect(): string;
668
+ /**
669
+ * List the CURRENT user's connected accounts — every provider identity linked
670
+ * to them, the "connected accounts" view. Self-scoped (`getUserId()` 401s an
671
+ * anonymous caller), mirroring `link`/`unlink`. Returns a display projection
672
+ * via {@link toConnectedAccount}: the surrogate `id` and the (own) `userId`
673
+ * are dropped, and `(provider, subject)` is exactly the key the client passes
674
+ * back to `DELETE :provider/:subject` to disconnect a row. Ordered by
675
+ * `linkedAt` (oldest first) by the store.
676
+ */
677
+ identities(): Promise<ConnectedAccount[]>;
678
+ link(providerId: string, redirect: string | undefined): Promise<string>;
679
+ /**
680
+ * Start machinery for an account-LINK: STATELESS — mint a fresh non-secret
681
+ * `seed`, DERIVE the PKCE verifier + OIDC nonce from it
682
+ * (`registry.deriveSeededPkce`), sign `{ random: seed, provider, redirect,
683
+ * userId }` into `state` (the `userId` makes the callback link to THIS user;
684
+ * HS256-signed so it's tamper-proof), drop the Lax double-submit cookie
685
+ * holding the seed, and 302 to the provider. The verifier is NOT persisted —
686
+ * `sso-callback` re-derives it from `state.random`. Returns an empty body
687
+ * (the redirect is in the headers).
688
+ */
689
+ protected begin(providerId: string, redirect: string | undefined, userId: string | undefined): Promise<string>;
690
+ /**
691
+ * `response_mode=form_post` callback bounce (Apple). Apple POSTs the callback
692
+ * — `application/x-www-form-urlencoded { code, state, id_token, user? }` — to
693
+ * the FIXED `redirect_uri`, because it requires `form_post` whenever `email`/
694
+ * `name` scope is requested. A static SPA page can't read a POST body, so this
695
+ * thin server route 303-redirects (POST → GET) to the SAME SPA callback URL
696
+ * with `code`/`state`/`error` in the query. From there it is BYTE-IDENTICAL to
697
+ * the Google/GitHub GET-callback path: the SPA forwards `{ code, state }` to
698
+ * `/auth/trigger`, and `sso-callback` does ALL verification (signed state,
699
+ * CSRF double-submit, PKCE re-derivation, ID-token exchange).
700
+ *
701
+ * This route is a DUMB transport adapter — it intentionally verifies nothing.
702
+ * The Lax CSRF cookie is (correctly) NOT sent on Apple's cross-site POST and
703
+ * is NOT read here; it rides the subsequent SAME-ORIGIN `/auth/trigger` XHR,
704
+ * where `sso-callback` checks it. The GET method on this same path is served
705
+ * by the SPA (no server handler), so there is no collision.
706
+ *
707
+ * Same-origin only: the 303 target is the registry's relative callback path
708
+ * with `:provider` path-encoded and only `code`/`state`/`error` echoed — never
709
+ * an attacker-influenced absolute URL.
710
+ */
711
+ formPostCallback(providerId: string, body: {
712
+ code?: unknown;
713
+ state?: unknown;
714
+ error?: unknown;
715
+ } | undefined): string;
716
+ /**
717
+ * Disconnect a linked provider identity from the current user. Self-scoped
718
+ * (`getUserId()` 401s an anonymous caller). Refuses to remove the user's ONLY
719
+ * remaining sign-in method (no other federated identity AND no real password)
720
+ * — that would strand them. On success revokes the user's sessions so a
721
+ * session established through the now-removed identity can't outlive it.
722
+ */
723
+ unlink(providerId: string, subject: string): Promise<{
724
+ ok: true;
725
+ }>;
726
+ /** Resolve a provider id, mapping the idp `UNKNOWN_PROVIDER` error to HTTP 404. */
727
+ protected requireProvider(providerId: string): ReturnType<OAuthProviderRegistry["require"]>;
766
728
  }
767
- interface AuditEmitter {
768
- emit(event: AuditEvent): Promise<void> | void;
729
+ /**
730
+ * Wire shape of one connected account returned by `GET /auth/oauth/identities`.
731
+ * A display projection of a {@link FederatedIdentity} row: the surrogate `id`
732
+ * and the (caller's own) `userId` are intentionally omitted. `(provider,
733
+ * subject)` is the disconnect key the client passes back to
734
+ * `DELETE /auth/oauth/:provider/:subject`; the remaining fields are the profile
735
+ * snapshot the row carries for display.
736
+ */
737
+ interface ConnectedAccount {
738
+ provider: string;
739
+ subject: string;
740
+ linkedAt: number;
741
+ lastLoginAt?: number;
742
+ email?: string;
743
+ emailVerified?: boolean;
744
+ displayName?: string;
745
+ avatarUrl?: string;
746
+ }
747
+ //#endregion
748
+ //#region src/oauth/oauth-csrf.d.ts
749
+ /**
750
+ * Name of the double-submit anti-CSRF cookie set at `/:provider/start` and
751
+ * verified in `oauth-exchange`. Holds the signed-state `random`; the callback
752
+ * proves it speaks for the same browser that started the flow by matching the
753
+ * cookie value against the verified `state.random` (RFC IDP.md §7).
754
+ */
755
+ declare const OAUTH_CSRF_COOKIE = "aooth_oauth";
756
+ /**
757
+ * Cookie attributes for the CSRF cookie. `SameSite=Lax` (NOT `Strict`) so the
758
+ * top-level GET navigation BACK from the provider still carries it; `httpOnly`
759
+ * so script can't read it; short `maxAge` matched to the state TTL. `secure` is
760
+ * caller-controlled (off for the http test harness, on in production).
761
+ */
762
+ declare function oauthCsrfCookieAttrs(opts: {
763
+ secure: boolean; /** Cookie lifetime in seconds — match the signed-state TTL (default 600). */
764
+ maxAgeSec?: number;
765
+ path?: string;
766
+ }): TCookieAttributesInput;
767
+ //#endregion
768
+ //#region src/oauth/oauth-redirect.d.ts
769
+ /**
770
+ * Open-redirect defense for the post-login `redirect` carried across the OAuth
771
+ * bounce (RFC IDP.md §7). The provider round-trips whatever target the SPA
772
+ * asked for, so it MUST be validated before it is signed into `state` AND again
773
+ * before it is honored — an attacker who can seed the `redirect` could
774
+ * otherwise turn a trusted callback into an open redirector (phishing /
775
+ * token-leak via `//evil.test`, `/\evil.test`, `https://evil.test`,
776
+ * `javascript:…`).
777
+ *
778
+ * Policy: accept ONLY a same-origin, absolute-path relative URL — it must start
779
+ * with a single `/`, contain no backslashes (some browsers fold `\` → `/`), and
780
+ * carry no control/whitespace characters (which browsers may strip, changing
781
+ * the parsed target). Everything else falls back to the caller's default.
782
+ */
783
+ declare function isSafeRelativeRedirect(target: string | undefined): target is string;
784
+ /**
785
+ * Return `requested` when it is a safe same-origin relative redirect, else
786
+ * `fallback`. Used at `/start` (before signing) and re-checked in
787
+ * `oauth-exchange` (before honoring) — defense in depth around the signed
788
+ * round-trip.
789
+ */
790
+ declare function resolveOAuthRedirect(requested: string | undefined, fallback: string): string;
791
+ //#endregion
792
+ //#region src/oauth/oauth-runtime.d.ts
793
+ /**
794
+ * DI holder bundling the two app-provided federated-login singletons the
795
+ * `sso-callback` workflow step needs. It exists so the step can resolve them
796
+ * via `useControllerContext().instantiate(OAuthRuntime)`: instantiating THIS
797
+ * `@Injectable` class resolves its constructor deps THROUGH the provide-registry
798
+ * — the same path that injects `AuthCredential` & friends elsewhere.
799
+ *
800
+ * The app provides `OAuthProviderRegistry` + `FederatedLoginService` via
801
+ * `createProvideRegistry`; this class wires them together for the step without
802
+ * touching `AuthWorkflow`'s constructor (so the documented subclass ctor stays
803
+ * unchanged). NO flow store: the PKCE verifier + OIDC nonce are derived
804
+ * statelessly from the signed-state seed (see `OAuthProviderRegistry.deriveSeededPkce`).
805
+ */
806
+ declare class OAuthRuntime {
807
+ readonly registry: OAuthProviderRegistry;
808
+ readonly federated: FederatedLoginService;
809
+ constructor(registry: OAuthProviderRegistry, federated: FederatedLoginService);
810
+ }
811
+ //#endregion
812
+ //#region src/oauth/oauth-tokens.d.ts
813
+ /**
814
+ * Explicit string DI token for the ABSTRACT `FederatedIdentityStore`.
815
+ *
816
+ * `FederatedIdentityStore` is an abstract class. moost's constructor injection
817
+ * keys the provide-registry by the design:paramtype class reference, which
818
+ * resolves a CONCRETE provided class fine (e.g. `AuthCredential`) but not an
819
+ * abstract one — for an abstract paramtype infact falls back to auto-
820
+ * instantiating the (body-less) abstract class, yielding an object whose methods
821
+ * are missing. Binding the store under an explicit string token and injecting it
822
+ * with `@Inject(<token>)` sidesteps the class-reference path entirely (the same
823
+ * pattern the demo uses for its `"EmailSender"` provider).
824
+ *
825
+ * Consumers provide the concrete instance under this exact string:
826
+ *
827
+ * ```ts
828
+ * createProvideRegistry(
829
+ * [FEDERATED_IDENTITY_STORE_TOKEN, () => new FederatedIdentityStoreAtscriptDb({ table })],
830
+ * )
831
+ * ```
832
+ */
833
+ declare const FEDERATED_IDENTITY_STORE_TOKEN = "aooth:FederatedIdentityStore";
834
+ //#endregion
835
+ //#region src/authz/authorize.controller.d.ts
836
+ /** RFC-6749-shaped token-endpoint error response. */
837
+ interface TokenError {
838
+ error: string;
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[];
769
862
  }
863
+ /**
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.
870
+ *
871
+ * - `GET /auth/authorize` — validate the client + `redirect_uri` (the policy),
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.
875
+ * - `POST /auth/token` — the back-channel: consume the single-use code, verify
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).
880
+ *
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`.
884
+ */
885
+ declare class AuthorizeController {
886
+ protected readonly auth: AuthCredential;
887
+ protected readonly policy: ClientRedirectPolicy;
888
+ protected readonly pending: PendingAuthorizationStore;
889
+ protected readonly codes: AuthCodeStore;
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;
905
+ /**
906
+ * The SPA login route the authorize request bounces to. The opaque pending-auth
907
+ * `handle` is appended as `?authz=`; the SPA forwards it into the login
908
+ * workflow's START input so `init-login` raises `ctx.authz`. Override for a
909
+ * custom login path.
910
+ */
911
+ protected loginPath(): 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>;
913
+ token(body: {
914
+ grant_type?: string;
915
+ code?: string;
916
+ code_verifier?: string;
917
+ client_id?: string;
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;
929
+ /** Fail soft: 302 the validated client redirect with an `?error=` (+ echoed `state`). */
930
+ protected redirectError(redirectUri: string, error: string, state: string | undefined): string;
931
+ }
932
+ //#endregion
933
+ //#region src/authz/authorize-runtime.d.ts
934
+ /**
935
+ * DI holder bundling the two abstract authorization-server stores the login-wf
936
+ * terminal (`mint-authz-code`) needs. A `@Step` body cannot `@Inject` a string
937
+ * token, so it resolves THIS `@Injectable` via
938
+ * `useControllerContext().instantiate(AuthorizeRuntime)` — instantiating it
939
+ * resolves its constructor deps THROUGH the provide-registry (the same path that
940
+ * injects `AuthCredential` & friends), keeping `AuthWorkflow`'s documented ctor
941
+ * untouched. Mirrors `OAuthRuntime`.
942
+ */
943
+ declare class AuthorizeRuntime {
944
+ readonly pending: PendingAuthorizationStore;
945
+ readonly codes: AuthCodeStore;
946
+ constructor(pending: PendingAuthorizationStore, codes: AuthCodeStore);
947
+ }
948
+ //#endregion
949
+ //#region src/authz/authz-tokens.d.ts
950
+ /**
951
+ * Explicit string DI tokens for the ABSTRACT authorization-server stores
952
+ * ({@link import("@aooth/auth/authz").PendingAuthorizationStore},
953
+ * {@link import("@aooth/auth/authz").AuthCodeStore}) — the framework-agnostic
954
+ * abstracts live in `@aooth/auth/authz`; these moost-DI binding strings stay in
955
+ * the integration layer (the same split as `FEDERATED_IDENTITY_STORE_TOKEN`).
956
+ *
957
+ * Both are abstract classes. moost's constructor injection keys the
958
+ * provide-registry by the design:paramtype class reference — fine for a CONCRETE
959
+ * provided class, but for an abstract paramtype infact falls back to auto-
960
+ * instantiating the body-less abstract class, yielding an object whose methods
961
+ * are missing. Binding under an explicit string token + `@Inject(<token>)`
962
+ * sidesteps the class-reference path (the same pattern as
963
+ * `FEDERATED_IDENTITY_STORE_TOKEN`).
964
+ *
965
+ * Consumers provide the concrete instance under the exact string:
966
+ *
967
+ * ```ts
968
+ * createProvideRegistry(
969
+ * [PENDING_AUTHORIZATION_STORE_TOKEN, () => new PendingAuthorizationStoreMemory()],
970
+ * [AUTH_CODE_STORE_TOKEN, () => new AuthCodeStoreMemory()],
971
+ * )
972
+ * ```
973
+ */
974
+ declare const PENDING_AUTHORIZATION_STORE_TOKEN = "aooth:PendingAuthorizationStore";
975
+ declare const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
976
+ /**
977
+ * DI token for the {@link import("@aooth/auth/authz").ClientRedirectPolicy} — an
978
+ * interface, so it has no class reference to inject by. Provide the concrete
979
+ * policy (e.g. `new LoopbackClientPolicy()`, a `RegisteredClientPolicy`, or a
980
+ * `CompositeClientPolicy` of both) under this string.
981
+ */
982
+ declare const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
770
983
  //#endregion
771
- //#region src/workflows/login.workflow.options.d.ts
984
+ //#region src/workflow/auth-workflow.ctx.d.ts
985
+ type MfaTransport = "sms" | "email" | "totp";
986
+ /** Per-user MFA method summary surfaced to forms via `@wf.context.pass 'mfa'`. */
987
+ interface MfaSummary {
988
+ kind: "sms" | "email" | "totp";
989
+ methodName: string;
990
+ masked: string;
991
+ isDefault: boolean;
992
+ }
772
993
  type LoginRedirect = "referer" | "home" | false | null;
773
994
  interface SsoProvider {
995
+ /** Provider id — matches an `OAuthProviderRegistry` entry; sent as `ssoProvider` data on the `sso` action. */
774
996
  id: string;
997
+ /** Provider display name — the bundled `AsSsoProviders` renders "Continue with {label}". */
775
998
  label: string;
776
- url: string;
999
+ /** Optional icon hint for the button (e.g. an icon-class key the renderer maps). */
1000
+ icon?: string;
777
1001
  }
778
1002
  interface ConcurrencyLimitOptions {
779
1003
  max: number;
780
1004
  onLimit: "reject" | "kickPrompt";
781
1005
  }
782
- interface LoginWorkflowOpts {
783
- deviceTrust?: {
784
- cookieName?: string;
785
- ttlMs?: number;
786
- bindsTo?: "cookie" | "cookie+ip";
787
- };
788
- /**
789
- * Replaceable form schemas. Each field defaults to the corresponding
790
- * `.as` form shipped under `@aooth/auth-moost/atscript/models`; supply a
791
- * subset to override only the forms you want to swap.
792
- */
793
- forms?: {
794
- askEmail?: TAtscriptAnnotatedType;
795
- askPhone?: TAtscriptAnnotatedType;
796
- backupCode?: TAtscriptAnnotatedType;
797
- concurrencyLimit?: TAtscriptAnnotatedType;
798
- enrollAddress?: TAtscriptAnnotatedType;
799
- enrollConfirm?: TAtscriptAnnotatedType;
800
- enrollPickMethod?: TAtscriptAnnotatedType;
801
- loginCredentials?: TAtscriptAnnotatedType;
802
- mfaCode?: TAtscriptAnnotatedType;
803
- personaSelect?: TAtscriptAnnotatedType;
804
- pincode?: TAtscriptAnnotatedType;
805
- profileComplete?: TAtscriptAnnotatedType;
806
- select2fa?: TAtscriptAnnotatedType;
807
- setPassword?: TAtscriptAnnotatedType;
808
- tenantSelect?: TAtscriptAnnotatedType;
809
- termsBump?: TAtscriptAnnotatedType;
810
- };
811
- }
812
1006
  /**
813
- * Fully-resolved view used by the workflow at runtime every nested group is
814
- * always populated by `mergeLoginOpts`, so step bodies can read
815
- * `this.opts.<group>.<flag>` directly without optional chaining.
1007
+ * Consent descriptor wire shapemirrors `ConsentDescriptor` from the
1008
+ * consent store without importing the runtime module (avoids cycles).
816
1009
  */
817
- interface ResolvedLoginWorkflowOpts {
818
- deviceTrust: {
819
- cookieName: string;
820
- ttlMs: number;
821
- bindsTo: "cookie" | "cookie+ip";
822
- };
823
- forms: {
824
- askEmail: TAtscriptAnnotatedType;
825
- askPhone: TAtscriptAnnotatedType;
826
- backupCode: TAtscriptAnnotatedType;
827
- concurrencyLimit: TAtscriptAnnotatedType;
828
- enrollAddress: TAtscriptAnnotatedType;
829
- enrollConfirm: TAtscriptAnnotatedType;
830
- enrollPickMethod: TAtscriptAnnotatedType;
831
- loginCredentials: TAtscriptAnnotatedType;
832
- mfaCode: TAtscriptAnnotatedType;
833
- personaSelect: TAtscriptAnnotatedType;
834
- pincode: TAtscriptAnnotatedType;
835
- profileComplete: TAtscriptAnnotatedType;
836
- select2fa: TAtscriptAnnotatedType;
837
- setPassword: TAtscriptAnnotatedType;
838
- tenantSelect: TAtscriptAnnotatedType;
839
- termsBump: TAtscriptAnnotatedType;
1010
+ interface ConsentDescriptorLike {
1011
+ id: string;
1012
+ text: string;
1013
+ required?: string;
1014
+ version?: string;
1015
+ }
1016
+ /**
1017
+ * Consents — both server state (`accepted` / `decidedAt`) and the
1018
+ * UI-visible descriptor list (`pending`). Shipped via
1019
+ * `@wf.context.pass 'consents'`.
1020
+ */
1021
+ interface AuthWfConsentsState {
1022
+ pending?: ConsentDescriptorLike[];
1023
+ accepted?: string[];
1024
+ decidedAt?: number;
1025
+ }
1026
+ /**
1027
+ * UI hints for pincode entry — FORM-FACING via `@wf.context.pass 'pincode'`.
1028
+ * All three flows (login MFA SMS/email, recovery OTP, invite MFA
1029
+ * enrol-confirm) write here.
1030
+ *
1031
+ * `channelCooldowns` is the anti-ping-pong gate for the MFA-challenge loop:
1032
+ * a single `resendAllowedAt` is cleared on every `useDifferentMethod` so the
1033
+ * user's first attempt at the new channel isn't gated for the WRONG
1034
+ * channel's reason, BUT the per-channel timestamps in `channelCooldowns`
1035
+ * survive method-switching so ping-ponging (SMS → Email → SMS → …) cannot
1036
+ * be used to bypass the per-channel rate limit. `pincode-send` enforces the
1037
+ * per-channel gate before delivering; `select-2fa` enforces it BEFORE the
1038
+ * send to surface a per-channel error string on the form.
1039
+ */
1040
+ interface AuthWfPincodeUiState {
1041
+ sentTo?: string;
1042
+ codeLength?: number;
1043
+ resendAllowedAt?: number;
1044
+ channelCooldowns?: Partial<Record<MfaTransport, number>>;
1045
+ }
1046
+ /**
1047
+ * MFA enrolment running state. `pincodeCooldown` removed — enrol-confirm
1048
+ * uses the same `ctx.pincode.resendAllowedAt` slot as the challenge path.
1049
+ */
1050
+ interface AuthWfMfaEnrollState {
1051
+ method?: MfaTransport;
1052
+ address?: string;
1053
+ secret?: string;
1054
+ uri?: string;
1055
+ availableTransports?: MfaTransport[];
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";
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;
1071
+ /**
1072
+ * When set, `enroll-confirm` does NOT make the freshly-confirmed method the
1073
+ * user's default. Set by `init-add-mfa` (the standalone add-MFA flow) when the
1074
+ * user already has a default — adding a secondary factor must not silently
1075
+ * change which method is challenged first. Unset on the login/invite forced-
1076
+ * enrolment path (the user has no default yet), so the first method still
1077
+ * becomes the default there — behaviour-preserving.
1078
+ */
1079
+ keepExistingDefault?: boolean;
1080
+ }
1081
+ /** FORM-FACING via `@wf.context.pass 'password'`. Read by `SetPasswordForm`. */
1082
+ interface AuthWfPasswordUiState {
1083
+ policies?: TransferablePolicy[];
1084
+ changeReason?: "initial" | "expired" | "reset";
1085
+ heading?: string;
1086
+ intro?: string;
1087
+ }
1088
+ /**
1089
+ * Completion outcome — carries data set by terminal steps. Step-completion
1090
+ * is encoded by the wf engine's cursor, NOT by ctx flags; only fields that
1091
+ * carry actual data (read downstream) live here.
1092
+ */
1093
+ interface AuthWfCompletionState {
1094
+ redirectUrl?: string;
1095
+ }
1096
+ /** Unified MFA policy — replaces login's hardcoded defaults + invite's `{issuer}` resolver. */
1097
+ interface AuthWfMfaPolicy {
1098
+ mode: "required" | "optional" | "disabled";
1099
+ availableTransports: MfaTransport[];
1100
+ issuer: string;
1101
+ }
1102
+ /**
1103
+ * MFA verification state. Verification result migrated to `AuthWfOtpState`;
1104
+ * cooldown migrated to `AuthWfPincodeUiState`.
1105
+ */
1106
+ interface AuthWfMfaState {
1107
+ enrolledMethods?: MfaSummary[];
1108
+ current?: MfaTransport;
1109
+ method?: "sms" | "email" | "totp";
1110
+ saveAsDefault?: boolean;
1111
+ ignoreDefault?: boolean;
1112
+ runsRemaining?: number;
1113
+ methodCount?: number;
1114
+ }
1115
+ /** Channel-onboarding state (login Phase 3). */
1116
+ interface AuthWfChannelState {
1117
+ emailConfirmed?: boolean;
1118
+ phone?: string;
1119
+ phoneConfirmed?: boolean;
1120
+ otpDisclosure?: string;
1121
+ }
1122
+ /** Device-trust state (login). */
1123
+ interface AuthWfTrustState {
1124
+ deviceTrustToken?: string;
1125
+ newDevice?: boolean;
1126
+ rememberDevice?: boolean;
1127
+ optIn?: boolean;
1128
+ }
1129
+ /** Session-policy state (login). */
1130
+ interface AuthWfSessionState {
1131
+ riskStepUpReason?: string;
1132
+ activeSessions?: number;
1133
+ riskStepUpEvaluated?: boolean;
1134
+ }
1135
+ /** Alt-credential mirror flags (login). */
1136
+ interface AuthWfAltActionsState {
1137
+ forgotPassword?: boolean;
1138
+ signup?: boolean;
1139
+ magicLink?: boolean;
1140
+ usedMagicLink?: boolean;
1141
+ /** SSO providers offered on the login form — each renders a `sso-<id>` button. */
1142
+ ssoProviders?: SsoProvider[];
1143
+ }
1144
+ /** Alternate-credential policy (login). */
1145
+ interface AuthWfAltCredsPolicy {
1146
+ forgotPassword: boolean;
1147
+ signup: boolean;
1148
+ magicLink: boolean;
1149
+ magicLinkSkipsMfa: boolean;
1150
+ ssoProviders: SsoProvider[];
1151
+ recoveryUrl: string;
1152
+ signupUrl: string;
1153
+ embedRecovery: boolean;
1154
+ }
1155
+ /** Device-trust policy (login). */
1156
+ interface AuthWfDeviceTrustPolicy {
1157
+ enabled: boolean;
1158
+ optIn: boolean;
1159
+ skipsMfa: boolean;
1160
+ }
1161
+ /** Channel-enrolment policy (login). */
1162
+ interface AuthWfEnrollmentPolicy {
1163
+ ensureEmail: boolean;
1164
+ ensurePhone: boolean;
1165
+ }
1166
+ /** Finalize policy (login). `auditLogin` REMOVED — audit moved to interceptors. */
1167
+ interface AuthWfFinalizePolicy {
1168
+ notifyNewDevice: boolean;
1169
+ redirect: LoginRedirect;
1170
+ }
1171
+ /** Login-time guards policy. */
1172
+ interface AuthWfGuardsPolicy {
1173
+ passwordInitial: boolean;
1174
+ passwordExpiry: boolean;
1175
+ emailVerifiedRequired: boolean;
1176
+ }
1177
+ /** Session-policy (login). */
1178
+ interface AuthWfSessionPolicy {
1179
+ concurrencyLimit?: ConcurrencyLimitOptions;
1180
+ }
1181
+ /**
1182
+ * Authenticated change-password policy (change-password.flow). Resolved by
1183
+ * `resolveChangePasswordPolicy`, written to `ctx.changePassword` by
1184
+ * `prepare-change-password`. The flow's whole purpose is gated on this slot's
1185
+ * presence (it's also the per-flow discriminator — see the package CLAUDE.md
1186
+ * flow-discrimination table).
1187
+ *
1188
+ * Primary protection is current-password re-entry (enforced by
1189
+ * `UserService.changePassword`), NOT rate limiting — so `rateLimit` is
1190
+ * optional and off by default.
1191
+ *
1192
+ * - `revokeOtherSessions` — on success, revoke every session for the user then
1193
+ * re-issue the acting session a fresh token (OWASP Session Management: kill
1194
+ * ghost sessions after a credential change). Default `true`.
1195
+ * - `rateLimit.minIntervalMs` — minimum gap between successive password changes
1196
+ * (Okta "minimum password age"). Enforced against `password.lastChanged` with
1197
+ * ZERO extra storage. Omit to disable.
1198
+ */
1199
+ interface AuthWfChangePasswordPolicy {
1200
+ revokeOtherSessions: boolean;
1201
+ rateLimit?: {
1202
+ minIntervalMs: number;
840
1203
  };
841
1204
  }
842
1205
  /**
843
- * Deep-merge defaults with the user-supplied nested pojo. Each group has its
844
- * own `{ ...defaults, ...input }` line small enough that pulling in lodash
845
- * would be silly.
1206
+ * Failed-login lockout posture. Picks HOW a tripped account lockout is lifted:
1207
+ * - `admin-only` permanent lock; only an admin (UserService.unlockAccount)
1208
+ * lifts it. The recovery flow may still reset the password
1209
+ * but does NOT unlock.
1210
+ * - `self-service` — permanent lock; completing the recovery (password-reset)
1211
+ * flow lifts it (`unlock-account` step).
1212
+ * - `temporary` — timed lock; auto-expires after the configured duration
1213
+ * (UserService `lockout.duration`). Recovery does NOT
1214
+ * unlock early. This is the default (preserves prior behavior).
1215
+ *
1216
+ * The mode governs the lock DURATION the workflow asks UserService to apply on
1217
+ * the threshold trip (`temporary` → configured duration; the two permanent
1218
+ * modes → `0`) and whether recovery runs `unlock-account`. The attempt
1219
+ * THRESHOLD and the temporary DURATION themselves remain UserService config.
1220
+ */
1221
+ type AuthWfLockoutMode = "admin-only" | "self-service" | "temporary";
1222
+ /** Lockout policy (login + recovery). */
1223
+ interface AuthWfLockoutPolicy {
1224
+ mode: AuthWfLockoutMode;
1225
+ }
1226
+ /** Admin-form policy (invite admin phase). */
1227
+ interface AuthWfAdminFormPolicy {
1228
+ collectRoles: boolean;
1229
+ }
1230
+ /**
1231
+ * Invite-accept state (merged policy + state). No `freshLoginRequired` —
1232
+ * the auto-login choice is the static `AuthWorkflowOpts.autoLoginOnInvite`.
846
1233
  */
847
- declare function mergeLoginOpts(opts?: LoginWorkflowOpts): ResolvedLoginWorkflowOpts;
848
- //#endregion
849
- //#region src/workflows/login.workflow.d.ts
850
- interface LoginWfCtx {
851
- /**
852
- * Whether the user must complete profile fields BEFORE token issuance.
853
- * Populated by `prepare-profile` from `resolveProfile(ctx).required`.
854
- * Default-false matches the prior behavior (most consumers don't gate logins
855
- * on profile completion). Read by the `profile-complete` schema condition.
856
- */
857
- profileCompleteRequired?: boolean;
858
- alternateCredentials?: {
859
- forgotPassword: boolean;
860
- signup: boolean;
861
- magicLink: boolean;
862
- magicLinkSkipsMfa: boolean;
863
- ssoProviders: SsoProvider[];
864
- recoveryUrl: string;
865
- signupUrl: string;
866
- embedRecovery: boolean;
1234
+ interface AuthWfAcceptState {
1235
+ alreadyAcceptedRedirectUrl?: string;
1236
+ loginUrl?: string;
1237
+ showConfirmation?: boolean;
1238
+ confirmationMessage?: string;
1239
+ alreadyAccepted?: boolean;
1240
+ }
1241
+ /**
1242
+ * Recovery post-reset state. `freshLoginRequired` removed the choice is
1243
+ * the static `AuthWorkflowOpts.autoLoginOnRecover`.
1244
+ */
1245
+ interface AuthWfPostResetState {
1246
+ revokeAllSessions?: boolean;
1247
+ loginUrl?: string;
1248
+ }
1249
+ /** Recovery alt-actions policy. */
1250
+ interface AuthWfRecoveryAltActions {
1251
+ backToLogin: boolean;
1252
+ }
1253
+ /**
1254
+ * OTP verification flag — SERVER-ONLY (NOT form-facing). Set true by any
1255
+ * of: pincode-check, totp-check, enroll-confirm. Loop-exit signal.
1256
+ */
1257
+ interface AuthWfOtpState {
1258
+ verified?: boolean;
1259
+ }
1260
+ /** Invite admin-side (Phase A) state. */
1261
+ interface AuthWfAdminState {
1262
+ availableRoles?: string[];
1263
+ roles?: string[];
1264
+ userExtras?: Record<string, unknown>;
1265
+ /**
1266
+ * Outlet-pause idempotency marker for `send-email`. Flipped to `true`
1267
+ * after the first dispatch so the invitee's magic-link resume — which
1268
+ * re-executes the step body — short-circuits instead of dispatching a
1269
+ * second email and re-pausing. See `sendInviteEmail` for the why.
1270
+ */
1271
+ emailDispatched?: boolean;
1272
+ }
1273
+ /**
1274
+ * Pre-fill payload surfaced to forms via `@wf.context.pass 'defaults'`.
1275
+ * Used by recovery's `request` step to seed the email input from a
1276
+ * `?username=` query param carried from login's `forgotPassword` alt-action.
1277
+ */
1278
+ interface AuthWfDefaults {
1279
+ email?: string;
1280
+ }
1281
+ /**
1282
+ * Federated-login (OAuth2 / OIDC) flow state. Populated by the `sso-callback`
1283
+ * @Step after a verified provider profile resolves to a user. The login flow
1284
+ * runs `sso-callback` (instead of `credentials`) when the start input carries
1285
+ * an OAuth callback (`ctx.idpInbound`); `ctx.oauth` set ⇒ this login run came
1286
+ * in through the federated leg (a discriminator usable by `resolveXxx` hooks,
1287
+ * alongside `ctx.accept` / `ctx.postReset` / `ctx.signup`).
1288
+ *
1289
+ * Carries NO secret material — the PKCE verifier / nonce / authorization `code`
1290
+ * are consumed inside `sso-callback`. The verifier + nonce are RE-DERIVED there
1291
+ * from the signed-state seed (HMAC, stateless — no server-side flow store),
1292
+ * never stored and never on ctx. Only the post-resolve audit/UX fields land here.
1293
+ */
1294
+ interface AuthWfOAuthState {
1295
+ /** The provider id (`google`, `oidc:<issuer>`, …) the subject authenticated with. */
1296
+ provider: string;
1297
+ /**
1298
+ * The `FederatedLoginService.resolveUser` outcome that set `ctx.subject`.
1299
+ * `interactively-linked` is the `prove-control` completion of a `needs-link`
1300
+ * (the user proved control of an existing account, after which the verified
1301
+ * identity was attached) — recorded distinctly from a returning `linked` for
1302
+ * audit fidelity.
1303
+ */
1304
+ outcome?: "linked" | "created" | "auto-linked" | "interactively-linked";
1305
+ /** `true` only for the `created` outcome (a brand-new federated account). */
1306
+ isNew?: boolean;
1307
+ /**
1308
+ * The validated post-login app redirect target carried across the OAuth
1309
+ * bounce (signed into `state`, re-validated against the allow-list in
1310
+ * `oauth-exchange`). Read by `resolveRedirect` so the `redirect` tail step
1311
+ * sends the SPA to the originating page. Same-origin relative path only.
1312
+ */
1313
+ redirect?: string;
1314
+ }
1315
+ /**
1316
+ * Pending interactive identity-link state — set by `sso-callback` when
1317
+ * `FederatedLoginService.resolveUser` returns `needs-link` (a verified
1318
+ * federated profile whose email matches an EXISTING local account under the
1319
+ * default `require-interactive-link` policy). The `prove-control` @Step reads
1320
+ * this to challenge the user for control of `candidateUserId`; on success it
1321
+ * calls `linkIdentity` and only THEN sets `ctx.subject`. Cleared
1322
+ * (`delete ctx.pendingLink`) once the link completes, the user cancels, or a
1323
+ * terminal failure fires.
1324
+ *
1325
+ * SECURITY: `candidateUserId` is UNTRUSTED until the proof passes — it must
1326
+ * NEVER be copied to `ctx.subject` before then, because `{ break: !ctx.subject }`
1327
+ * (the schema gate right after `sso-callback`) is what keeps an unproven user
1328
+ * out of the token-issuing tail. The `snapshot` has `profile.raw` stripped (raw
1329
+ * claims are never persisted — RFC §7). The OTP-proof code lives HERE, not on the
1330
+ * shared top-level `ctx.pin`, so it cannot collide with the post-link MFA loop's
1331
+ * own pincode in the same run.
1332
+ */
1333
+ interface AuthWfPendingLinkState {
1334
+ /** The existing local account the verified identity attaches to once control is proven. UNTRUSTED until then. */
1335
+ candidateUserId: string;
1336
+ /** Provider id (`google`, `oidc:<issuer>`, …) of the verified identity awaiting link. */
1337
+ provider: string;
1338
+ /** The IdP subject (`sub`) of the verified identity awaiting link. */
1339
+ subject: string;
1340
+ /** Display snapshot stamped onto the federated row on link (`profile.raw` stripped). */
1341
+ snapshot?: FederatedProfileSnapshot;
1342
+ /** Validated post-link app redirect, carried from the signed `state`. Same-origin relative path. */
1343
+ redirect?: string;
1344
+ /** Proof channel: `password` (account has a real password) or `otp` (passwordless → code to the account's OWN confirmed channel). */
1345
+ mode: "password" | "otp";
1346
+ /** OTP mode only — the candidate's confirmed channel the proof code is delivered to. */
1347
+ otpChannel?: "email" | "sms";
1348
+ /** OTP mode only — flipped `true` after the first code dispatch so a re-pause doesn't re-send. */
1349
+ sent?: boolean;
1350
+ /** OTP mode only — epoch ms before which a `resend` is refused (same per-pincode cooldown the MFA loop uses). Armed on every dispatch. */
1351
+ resendAllowedAt?: number;
1352
+ /** Masked candidate identifier shown on the prove-control form ("an account for a***@x.com exists"). Safe to expose. */
1353
+ hint?: string;
1354
+ /** OTP mode only — masked delivery target for the "code sent to …" copy. Safe to expose. */
1355
+ sentTo?: string;
1356
+ pin?: string;
1357
+ pinExpire?: number;
1358
+ pinAttempts?: number;
1359
+ }
1360
+ /**
1361
+ * Standalone add-MFA flow state (`auth/add-mfa/flow`). Populated by
1362
+ * `init-add-mfa`; its presence on ctx is the flow discriminator (§ per-flow
1363
+ * discrimination — mirrors `ctx.changePassword` / `ctx.signup`). The flow
1364
+ * REUSES the login/invite enrolment trio (`enroll-pick-method` /
1365
+ * `enroll-address` / `enroll-confirm`), driving it via `ctx.mfaPolicy`
1366
+ * narrowed to the un-enrolled transports — so a logged-in user adds exactly the
1367
+ * methods they don't already have (single remaining transport auto-picks).
1368
+ */
1369
+ interface AuthWfAddMfaState {
1370
+ /**
1371
+ * Transports the user has NOT yet confirmed = resolved
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".
1376
+ */
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;
1418
+ }
1419
+ /**
1420
+ * Self-signup flow state. Populated by `init-signup` (policy from
1421
+ * `resolveSignupPolicy`) and `signup-form` (the `submitted` marker). Its
1422
+ * presence on ctx is the signup-flow discriminator (§ per-flow discrimination):
1423
+ * `ctx.signup` set ⇒ `auth/signup/flow` is running (mirrors `ctx.accept` /
1424
+ * `ctx.postReset` / `ctx.changePassword` for the other flows).
1425
+ */
1426
+ interface AuthWfSignupState {
1427
+ /** Resolved gate — `false` (the default) disables self-signup; `init-signup` emits a terminal "signups are disabled" finish. */
1428
+ allowSignup?: boolean;
1429
+ /** When `true`, the signup form collects a `username` distinct from the email; otherwise `username := email`. */
1430
+ collectUsername?: boolean;
1431
+ /** Set by `signup-form` once a valid email (+ optional username) is submitted — gates the OTP loop. */
1432
+ submitted?: boolean;
1433
+ }
1434
+ /**
1435
+ * Public-facing context surface — the **only** group form schemas may read
1436
+ * from via `@wf.context.pass 'public'`. Every other top-level key
1437
+ * (`ctx.mfa`, `ctx.pincode`, `ctx.trust`, etc.) is server-only and must
1438
+ * never be whitelisted on a form.
1439
+ *
1440
+ * Population is centralized in `AuthWorkflow.populatePublic(ctx)` which is
1441
+ * invoked by the `throwPublic` helper immediately before any
1442
+ * `requireInput`-style pause. Adding a new FE-consumed field has three
1443
+ * touch points: add it to a subgroup here, copy it in `populatePublic`,
1444
+ * and reference it in the form schema as `ctx.public.<group>.<field>`.
1445
+ *
1446
+ * Subgroups mirror the internal `ctx.<group>` shape one-for-one but only
1447
+ * carry the subset of fields that forms actually read. Internal-only
1448
+ * fields (e.g., `pincode.channelCooldowns`, `mfa.saveAsDefault`,
1449
+ * `mfa.current`, `mfa.ignoreDefault`, `trust.deviceTrustToken`,
1450
+ * `channel.phone`, `mfaEnroll.address`, …) are deliberately omitted so
1451
+ * they cannot leak to the wire.
1452
+ */
1453
+ interface AuthWfPublicState {
1454
+ /** Mirrors `ctx.consents` — pending descriptor list + decision marker. */
1455
+ consents?: {
1456
+ pending?: ConsentDescriptorLike[];
1457
+ decidedAt?: number;
867
1458
  };
868
- deviceTrust?: {
869
- enabled: boolean;
870
- optIn: boolean;
871
- skipsMfa: boolean;
1459
+ /** Mirrors `ctx.altActions` — which alt-action buttons render on login (incl. SSO providers). */
1460
+ altActions?: {
1461
+ forgotPassword?: boolean;
1462
+ signup?: boolean;
1463
+ magicLink?: boolean;
1464
+ ssoProviders?: SsoProvider[];
872
1465
  };
873
- enrollment?: {
874
- ensureEmail: boolean;
875
- ensurePhone: boolean;
1466
+ /**
1467
+ * Mirrors `ctx.mfa` — only the fields forms read (method picker /
1468
+ * useDifferentMethod gating / transport-hint copy). Internal fields like
1469
+ * `saveAsDefault`, `ignoreDefault`, `current` stay on `ctx.mfa`.
1470
+ */
1471
+ mfa?: {
1472
+ method?: MfaTransport;
1473
+ methodCount?: number;
1474
+ enrolledMethods?: MfaSummary[];
876
1475
  };
877
- finalize?: {
878
- auditLogin: boolean;
879
- notifyNewDevice: boolean;
880
- redirect: LoginRedirect;
1476
+ /**
1477
+ * Mirrors `ctx.pincode` — masked recipient + code length + resend
1478
+ * timestamp. `channelCooldowns` (the per-channel anti-ping-pong ledger)
1479
+ * is deliberately omitted so the FE cannot see which other channels are
1480
+ * currently rate-limited.
1481
+ */
1482
+ pincode?: {
1483
+ sentTo?: string;
1484
+ codeLength?: number;
1485
+ resendAllowedAt?: number;
881
1486
  };
882
- guards?: {
883
- passwordInitial: boolean;
884
- passwordExpiry: boolean;
885
- emailVerifiedRequired: boolean;
1487
+ /** Mirrors `ctx.trust.optIn` — gates the "Remember this device" checkbox. */
1488
+ trust?: {
1489
+ optIn?: boolean;
886
1490
  };
887
- mfaConfig?: {
888
- backupCodes: boolean;
1491
+ /**
1492
+ * Mirrors `ctx.password` — policy ruleset for the live-rules renderer
1493
+ * plus the per-flow title / intro copy. `changeReason` stays internal —
1494
+ * the user-facing copy is already pre-rendered into `heading`/`intro`.
1495
+ */
1496
+ password?: {
1497
+ heading?: string;
1498
+ intro?: string;
1499
+ policies?: TransferablePolicy[];
889
1500
  };
890
- multiContext?: {
891
- tenantSelect: boolean;
892
- personaSelect: boolean;
1501
+ /** Mirrors `ctx.admin.availableRoles` — the role picker for invites. */
1502
+ admin?: {
1503
+ availableRoles?: string[];
893
1504
  };
894
- sessionPolicy?: {
895
- concurrencyLimit?: ConcurrencyLimitOptions;
1505
+ /** Mirrors `ctx.channel.otpDisclosure` — TCPA/PECR disclosure paragraph. */
1506
+ channel?: {
1507
+ otpDisclosure?: string;
896
1508
  };
897
- username?: string;
898
- /** Legacy alias for `pwReset`; kept until tests migrate. */
899
- mfaRequired?: boolean;
900
- isPasswordInitial?: boolean;
901
1509
  /**
902
- * Set in `credentials` when `guards.passwordExpiry` is true AND
903
- * `UserService.isPasswordExpired(user)` returns true. Combined with
904
- * `isPasswordInitial` in the forced-change schema condition — either
905
- * being truthy routes the user through `prepare-password-rules` +
906
- * `create-password-form`. Reset after `create-password-form` commits.
1510
+ * Mirrors `ctx.mfaEnroll` only what the enrolment forms display.
1511
+ * `address` stays internal (user-typed, no need to bounce it back).
907
1512
  */
908
- isPasswordExpired?: boolean;
1513
+ mfaEnroll?: Pick<AuthWfMfaEnrollState, "method" | "mode" | "availableTransports" | "secret" | "uri">;
909
1514
  /**
910
- * Discriminator for `SetPasswordForm` UX. `'initial'` when the
911
- * password has never been used (first-set flow); `'expired'` when the
912
- * password aged past `config.password.maxAgeMs` (rotation flow). When
913
- * both conditions are true, `'initial'` wins — a never-used password
914
- * being "expired" is semantically still its initial set. Shipped to
915
- * the client via `@wf.context.pass` on `SetPasswordForm` so the SPA
916
- * can swap banner copy; default form labels stay reason-agnostic.
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`.
917
1518
  */
918
- passwordChangeReason?: "initial" | "expired";
919
- usedMagicLink?: boolean;
1519
+ manage?: {
1520
+ candidates?: MfaTransport[];
1521
+ locked?: MfaTransport[];
1522
+ };
1523
+ /** Mirrors `ctx.defaults` — prefill source for the recovery email field. */
1524
+ defaults?: {
1525
+ email?: string;
1526
+ };
920
1527
  /**
921
- * 3-state MFA policy:
922
- * - `'required'` MFA enforced; users with 0 methods MUST enroll (no skip).
923
- * - `'optional'` MFA prompted; users with 0 methods see an enrollment
924
- * form that offers a `skip` action (in-flight opt-out).
925
- * - `'disabled'` MFA loops never fire; Phase 4 is skipped entirely.
926
- */
927
- mfaMode?: "required" | "optional" | "disabled";
928
- availableMfaTransports?: MfaTransport[];
929
- /** Pre-selected MFA transport (e.g. existing-user default, single-transport auto-pick). */
930
- currentMfa?: MfaTransport;
931
- email?: string;
932
- emailConfirmed?: boolean;
933
- phone?: string;
934
- phoneConfirmed?: boolean;
1528
+ * Mirrors `ctx.pendingLink` display fields — the proof `mode`, the masked
1529
+ * account `hint` ("an account for a***@x.com exists"), the masked delivery
1530
+ * `sentTo` (OTP mode), and the `resendAllowedAt` cooldown the resend button
1531
+ * reads to disable/count-down. Only masked/UX fields are projected — the
1532
+ * `candidateUserId` / provider `subject` / proof `pin` stay server-only.
1533
+ */
1534
+ proveControl?: {
1535
+ mode?: "password" | "otp";
1536
+ hint?: string;
1537
+ sentTo?: string;
1538
+ resendAllowedAt?: number;
1539
+ };
935
1540
  /**
936
- * Disclosure text rendered beneath the channel input field on
937
- * `AskEmailForm` / `AskPhoneForm` at ask-time (BEFORE the user submits
938
- * their email/phone — typing + submitting constitutes implied consent).
939
- * Forwarded to `consentStore.recordOtpChannelConsent` at `verify/:channel`
940
- * AFTER the channel is confirmed as an MFA method, so the persisted record
941
- * pins the literal copy the user actually saw. The disclosure text itself
942
- * is GENERIC per-channel (no target templated in) — the verified target
943
- * is captured as a separate audit-record field.
1541
+ * Mirrors `ctx.newPasswordRequired` hides "Remember this device" on
1542
+ * verify forms when a forced password change will follow.
944
1543
  */
945
- otpDisclosure?: string;
946
- mfaEnrolledMethods?: MfaSummary[];
947
- mfaMethod?: "sms" | "email" | "totp";
948
- mfaSaveAsDefault?: boolean;
949
- ignoreMfaDefault?: boolean;
950
- mfaChecked?: boolean;
951
- /**
952
- * Set true the first time the user picks `useBackupCode` on the MFA step,
953
- * so the workflow remembers to route the subsequent `BackupCodeForm`
954
- * submission (which carries no `action`) through `handleBackupCode` instead
955
- * of falling through to `verifyMfa` / pincode-verify.
956
- */
957
- usingBackupCode?: boolean;
958
- /** Counter incremented by the `risk-step-up` step so MFA reruns for the extra factor. */
959
- mfaRunsRemaining?: number;
960
- /** Mirror of `mfaEnrolledMethods.length`. Passed to client forms via `@wf.context.pass` so action buttons (`useDifferentMethod`) can hide when only one method exists. */
961
- mfaMethodCount?: number;
962
- /** Mirror of `opts.mfa.backupCodes`. Passed to client forms so `useBackupCode` can hide when backup codes are disabled. */
963
- mfaBackupCodes?: boolean;
964
- altForgotPassword?: boolean;
965
- altSignup?: boolean;
966
- altMagicLink?: boolean;
967
- enrollMethod?: MfaTransport;
968
- enrollAddress?: string;
969
- /** TOTP secret in flight (passed to UI via `@wf.context.pass` for QR rendering). */
970
- enrollSecret?: string;
971
- /** Provisioning URI for TOTP QR rendering. */
972
- enrollUri?: string;
973
- /** Mirror of `ctx.availableMfaTransports`, surfaced to `EnrollPickMethodForm` via `@wf.context.pass`. */
974
- enrollAvailableTransports?: MfaTransport[];
975
- /**
976
- * Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
977
- * `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
978
- * hide unless mode is `'optional'`.
979
- */
980
- enrollMode?: "required" | "optional";
981
- /** Set true by `enrollConfirmPhase` (or `enrollPickPhase`/`enrollAddressPhase` on `skip` in `'optional'` mode); mirrored to `mfaChecked` via `buildLoginEnrollDeps` `onComplete`. */
982
- enrollDone?: boolean;
983
- /** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
984
- enrollPincodeCooldown?: number;
1544
+ newPasswordRequired?: boolean;
1545
+ }
1546
+ /** Unified workflow context shape — one type for all three flows. */
1547
+ interface AuthWfCtx {
1548
+ subject?: string;
1549
+ email?: string;
1550
+ defaults?: AuthWfDefaults;
985
1551
  pin?: string;
986
1552
  pinExpire?: number;
987
- pinTimeout?: number;
988
- pinSentTo?: string;
989
- /**
990
- * Per-method "next-allowed-send-at" timestamp. Written by
991
- * `pincode-send-login` after each send and consulted by `select2fa` to
992
- * reject re-picking a method while it's still in cooldown. Closes the
993
- * `useDifferentMethod → same method → fresh SMS` abuse loop: without this
994
- * an attacker (or an impatient user) can spam SMS/email by alternating
995
- * methods. Persists across `delete ctx.pin` (resend/useDifferentMethod)
996
- * so the throttle survives a method switch.
997
- */
998
- pincodeCooldowns?: {
999
- sms?: number;
1000
- email?: number;
1553
+ /** Wrong-code attempt counter for the currently minted pincode. Reset by `mintPin`; incremented by `verifyPin`; cleared when the cap is hit (which also clears `pin`/`pinExpire` so the user must request a fresh code). */
1554
+ pinAttempts?: number;
1555
+ aborted?: boolean;
1556
+ isFirstLogin?: boolean;
1557
+ newPasswordRequired?: boolean;
1558
+ autoLogin?: boolean;
1559
+ consents?: AuthWfConsentsState;
1560
+ pincode?: AuthWfPincodeUiState;
1561
+ mfaEnroll?: AuthWfMfaEnrollState;
1562
+ password?: AuthWfPasswordUiState;
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;
1571
+ alternateCredentials?: AuthWfAltCredsPolicy;
1572
+ deviceTrust?: AuthWfDeviceTrustPolicy;
1573
+ enrollment?: AuthWfEnrollmentPolicy;
1574
+ finalize?: AuthWfFinalizePolicy;
1575
+ guards?: AuthWfGuardsPolicy;
1576
+ lockout?: AuthWfLockoutPolicy;
1577
+ sessionPolicy?: AuthWfSessionPolicy;
1578
+ changePassword?: AuthWfChangePasswordPolicy;
1579
+ signup?: AuthWfSignupState;
1580
+ addMfa?: AuthWfAddMfaState;
1581
+ oauth?: AuthWfOAuthState;
1582
+ pendingLink?: AuthWfPendingLinkState;
1583
+ mfaPolicy?: AuthWfMfaPolicy;
1584
+ adminForm?: AuthWfAdminFormPolicy;
1585
+ accept?: AuthWfAcceptState;
1586
+ postReset?: AuthWfPostResetState;
1587
+ recoveryAltActions?: AuthWfRecoveryAltActions;
1588
+ mfa?: AuthWfMfaState;
1589
+ channel?: AuthWfChannelState;
1590
+ trust?: AuthWfTrustState;
1591
+ session?: AuthWfSessionState;
1592
+ altActions?: AuthWfAltActionsState;
1593
+ admin?: AuthWfAdminState;
1594
+ otp?: AuthWfOtpState;
1595
+ isPasswordInitial?: boolean;
1596
+ isPasswordExpired?: boolean;
1597
+ /**
1598
+ * Captured by `init-login` when the START input is an OAuth callback (signed
1599
+ * `state` present). Presence routes the login schema to `sso-callback` and
1600
+ * skips `credentials`; `sso-callback` reads the callback inputs from HERE (not
1601
+ * the step input) because the wf engine clears the step input after
1602
+ * `init-login` runs — before `sso-callback` (the next step) executes.
1603
+ */
1604
+ idpInbound?: {
1605
+ code?: string;
1606
+ state: string;
1607
+ error?: string;
1001
1608
  };
1002
- deviceTrustToken?: string;
1003
- /** Set true at MFA gate when no trust cookie matched → trigger `notify-new-device`. */
1004
- newDevice?: boolean;
1005
- /** Captured from the OTP/pincode form when `opts.deviceTrust.optIn`. */
1006
- rememberDevice?: boolean;
1007
- /** Mirror of `opts.deviceTrust.optIn`. Passed to `PincodeForm` so the `rememberDevice` checkbox can hide when the consumer's device-trust is off-by-default (no user choice to make). */
1008
- deviceTrustOptIn?: boolean;
1009
- /**
1010
- * Descriptors for the customer-defined general consents (terms, marketing,
1011
- * jurisdiction, ...) the user still needs to accept. Populated once by
1012
- * `prepare-consents` after username-bind. Phase 5 will migrate carrier
1013
- * forms to consume this array; Phase 4 populates transport only.
1014
- */
1015
- pendingConsents?: ConsentDescriptor[];
1016
- /**
1017
- * Subset of `pendingConsents[].id` the user ticked on the carrier form —
1018
- * set by `processInlineConsent` after silent-dropping unknown ids.
1019
- * Consumed by `persist-consents` to compute `accepted: boolean` per
1020
- * pending descriptor.
1021
- */
1022
- acceptedConsentIds?: string[];
1023
- /**
1024
- * Wall-clock ms at the moment `processInlineConsent` resolved the
1025
- * `consents` carrier-form submission (NOT at write-time — captured BEFORE
1026
- * the batched `persist-consents` step so a paused-workflow resume gap
1027
- * doesn't drift the timestamp). Also the schema-gate for the
1028
- * `persist-consents` step — set ⇒ a carrier form has collected consents.
1029
- */
1030
- consentsDecidedAt?: number;
1031
- profileMissingFields?: string[];
1032
- availableTenants?: Array<{
1033
- id: string;
1034
- name: string;
1035
- }>;
1036
- selectedTenantId?: string;
1037
- availablePersonas?: Array<{
1038
- id: string;
1039
- label: string;
1040
- }>;
1041
- selectedPersonaId?: string;
1042
- riskStepUpReason?: string;
1043
- activeSessions?: number;
1044
- passwordChanged?: boolean;
1045
- profileApplied?: boolean;
1046
1609
  /**
1047
- * Set true by `persist-consents` after the batched `consentStore.save`
1048
- * call fires (or after the step short-circuits with no pending consents).
1049
- * Gates `processInlineConsent` from re-staging on subsequent carrier
1050
- * forms in the same workflow run, and the `persist-consents` schema
1051
- * condition from re-firing.
1610
+ * Authorization-server marker (AUTH-SERVER.md §4.4). Set when this login was
1611
+ * started from `GET /auth/authorize` `init-login` raises it from the START
1612
+ * input `authz` (the opaque pending-authorization handle), and `sso-callback`
1613
+ * re-raises it from the federated `state.handle` when the user took a
1614
+ * "Continue with <provider>" detour mid-authorize. Presence routes the login
1615
+ * tail to the `mint-authz-code` terminal (deliver an auth code to the client)
1616
+ * INSTEAD of `issue`/`redirect` — no browser session is minted.
1052
1617
  */
1053
- consentsPersisted?: boolean;
1054
- tokensIssued?: boolean;
1055
- redirectUrl?: string;
1618
+ authz?: {
1619
+ handle: string;
1620
+ };
1056
1621
  /**
1057
- * Set true by abort alt-actions (`logout`, `decline`, `cancel`). All terminal
1058
- * steps (`issue`, `audit-login`, `notify-new-device`, `redirect`) gate on
1059
- * `!ctx.aborted` so the abort response set via `useWfFinished()` stays.
1622
+ * FE-facing surface the ONLY top-level ctx key whitelisted on form
1623
+ * schemas. Populated by `AuthWorkflow.populatePublic(ctx)` at every pause
1624
+ * boundary; see `AuthWfPublicState` for the exact mirror shape. Never
1625
+ * write to other `ctx.<group>` slots from form schemas — they're internal.
1060
1626
  */
1061
- aborted?: boolean;
1062
- /** Tracks whether `risk-step-up` has already been evaluated this iteration. */
1063
- riskStepUpEvaluated?: boolean;
1627
+ public?: AuthWfPublicState;
1628
+ }
1629
+ //#endregion
1630
+ //#region src/workflow/auth-workflow.opts.d.ts
1631
+ interface AuthWorkflowOpts {
1632
+ autoLoginOnInvite?: boolean;
1633
+ autoLoginOnRecover?: boolean;
1634
+ /** Pincode infrastructure shared by login MFA, invite MFA, and recovery OTP. */
1635
+ mfa?: {
1636
+ pincodeLength?: number;
1637
+ pincodeTtlMs?: number;
1638
+ pincodeResendTimeoutMs?: number; /** Max wrong-code submissions per minted pincode before the code is invalidated and the user must request a fresh one. Defaults to 5. */
1639
+ pincodeMaxAttempts?: number;
1640
+ };
1641
+ /** Persisted-state TTL for the recovery flow — caps the window between OTP request and password reset. Applied at every recovery-side `requireInput` pause via the wf engine's `output.expires`. */
1642
+ recoveryStateTtlMs?: number;
1643
+ /** Canonical login URL — used by invite (post-accept redirect) and recovery (abort-to-login + post-reset redirect) as the resolver-default loginUrl. */
1644
+ loginUrl?: string;
1645
+ /** TOTP provisioning issuer — used by login MFA and invite MFA enrollment. */
1646
+ totpIssuer?: string;
1647
+ deviceTrust?: {
1648
+ cookieName?: string;
1649
+ ttlMs?: number;
1650
+ bindsTo?: "cookie" | "cookie+ip";
1651
+ };
1652
+ forms?: {
1653
+ loginCredentials?: TAtscriptAnnotatedType;
1654
+ invite?: TAtscriptAnnotatedType;
1655
+ recoveryEmailIdentifier?: TAtscriptAnnotatedType;
1656
+ askEmail?: TAtscriptAnnotatedType;
1657
+ askPhone?: TAtscriptAnnotatedType;
1658
+ enrollPickMethod?: 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;
1665
+ select2fa?: TAtscriptAnnotatedType;
1666
+ mfaCode?: TAtscriptAnnotatedType;
1667
+ pincode?: TAtscriptAnnotatedType;
1668
+ setPassword?: TAtscriptAnnotatedType; /** Authenticated self-service "change my password" form (current + new + confirm). */
1669
+ changePassword?: TAtscriptAnnotatedType; /** Password-proof form rendered by `prove-control` when the matched account has a real password. */
1670
+ proveControl?: TAtscriptAnnotatedType; /** OTP-proof form rendered by `prove-control` when the matched account is passwordless. */
1671
+ proveControlOtp?: TAtscriptAnnotatedType;
1672
+ termsBump?: TAtscriptAnnotatedType;
1673
+ concurrencyLimit?: TAtscriptAnnotatedType;
1674
+ recoveryPincode?: TAtscriptAnnotatedType; /** Self-signup identifier form (`auth/signup/flow` entry pause). */
1675
+ signup?: TAtscriptAnnotatedType;
1676
+ };
1064
1677
  }
1065
1678
  /**
1066
- * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1067
- * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1068
- * @Step methods populate — one entry per resolver. Library users typically
1069
- * accept a payload of this shape on their `LoginWorkflow` subclass ctor /
1070
- * test harness and have each `resolveXxx` return its matching key (falling
1071
- * back to `super.resolveXxx(ctx)` for unset groups).
1679
+ * Fully-resolved view used by the workflow at runtime — every nested group
1680
+ * is populated by the (future) `mergeAuthOpts` so step bodies can read
1681
+ * `this.opts.<group>.<field>` without optional chaining. The two
1682
+ * auto-login booleans become required after defaults are applied.
1072
1683
  */
1073
- interface LoginPolicyOverrides {
1074
- /**
1075
- * Override the profile-completion policy (`{ required: boolean }`) — the
1076
- * boolean is mirrored onto `ctx.profileCompleteRequired` by `prepare-profile`
1077
- * and read by the `profile-complete` schema condition.
1078
- */
1079
- profile?: {
1080
- required: boolean;
1684
+ interface ResolvedAuthWorkflowOpts {
1685
+ autoLoginOnInvite: boolean;
1686
+ autoLoginOnRecover: boolean;
1687
+ mfa: {
1688
+ pincodeLength: number;
1689
+ pincodeTtlMs: number;
1690
+ pincodeResendTimeoutMs: number;
1691
+ pincodeMaxAttempts: number;
1692
+ };
1693
+ recoveryStateTtlMs: number;
1694
+ loginUrl: string;
1695
+ totpIssuer: string;
1696
+ deviceTrust: {
1697
+ cookieName: string;
1698
+ ttlMs: number;
1699
+ bindsTo: "cookie" | "cookie+ip";
1700
+ };
1701
+ forms: {
1702
+ loginCredentials: TAtscriptAnnotatedType;
1703
+ invite: TAtscriptAnnotatedType;
1704
+ recoveryEmailIdentifier: TAtscriptAnnotatedType;
1705
+ askEmail: TAtscriptAnnotatedType;
1706
+ askPhone: TAtscriptAnnotatedType;
1707
+ enrollPickMethod: TAtscriptAnnotatedType;
1708
+ enrollAddress: TAtscriptAnnotatedType;
1709
+ enrollTotpQr: TAtscriptAnnotatedType;
1710
+ enrollConfirm: TAtscriptAnnotatedType;
1711
+ manageMfa: TAtscriptAnnotatedType;
1712
+ removeMfaConfirm: TAtscriptAnnotatedType;
1713
+ passwordReauth: TAtscriptAnnotatedType;
1714
+ select2fa: TAtscriptAnnotatedType;
1715
+ mfaCode: TAtscriptAnnotatedType;
1716
+ pincode: TAtscriptAnnotatedType;
1717
+ setPassword: TAtscriptAnnotatedType;
1718
+ changePassword: TAtscriptAnnotatedType;
1719
+ proveControl: TAtscriptAnnotatedType;
1720
+ proveControlOtp: TAtscriptAnnotatedType;
1721
+ termsBump: TAtscriptAnnotatedType;
1722
+ concurrencyLimit: TAtscriptAnnotatedType;
1723
+ recoveryPincode: TAtscriptAnnotatedType;
1724
+ signup: TAtscriptAnnotatedType;
1081
1725
  };
1082
- alternateCredentials?: NonNullable<LoginWfCtx["alternateCredentials"]>;
1083
- deviceTrust?: NonNullable<LoginWfCtx["deviceTrust"]>;
1084
- enrollment?: NonNullable<LoginWfCtx["enrollment"]>;
1085
- finalize?: NonNullable<LoginWfCtx["finalize"]>;
1086
- guards?: NonNullable<LoginWfCtx["guards"]>;
1087
- mfaConfig?: NonNullable<LoginWfCtx["mfaConfig"]>;
1088
- multiContext?: NonNullable<LoginWfCtx["multiContext"]>;
1089
- sessionPolicy?: NonNullable<LoginWfCtx["sessionPolicy"]>;
1090
- }
1091
- interface MfaSummary {
1092
- kind: "sms" | "email" | "totp";
1093
- /** Underlying `MfaMethod.name` so the workflow can call into UserService. */
1094
- methodName: string;
1095
- masked: string;
1096
- isDefault: boolean;
1097
1726
  }
1727
+ //#endregion
1728
+ //#region src/workflow/auth-workflow.d.ts
1098
1729
  /**
1099
- * Unified payload for `deliver()` — discriminated by `channel`. `kind`
1100
- * narrows further to the template the consumer should render. The two
1101
- * channels do not share a fields set (email carries `url` for magic links
1102
- * and `expiresAt`; SMS always carries a pincode + `ttlMs`).
1730
+ * Unified outbound-dispatch payload. Customers override `deliver(payload)` on
1731
+ * the `AuthWorkflow` subclass to route by `kind` (per-purpose templates) and
1732
+ * `channel` (email vs SMS). Replaces the prior workflow-specific deliver
1733
+ * payloads which carried slightly different field sets per call site.
1103
1734
  */
1104
- interface DeliverEmail {
1735
+ type AuthDeliveryPayload = {
1736
+ kind: "mfa-pincode";
1737
+ channel: "sms" | "email";
1738
+ recipient: string;
1739
+ code: string;
1740
+ expiresInMs: number;
1741
+ } | {
1742
+ kind: "recovery-pincode";
1743
+ channel: "sms" | "email";
1744
+ recipient: string;
1745
+ code: string;
1746
+ expiresInMs: number;
1747
+ } | {
1748
+ kind: "signup-pincode";
1105
1749
  channel: "email";
1106
- /** Template kind — discriminator the consumer uses to pick which email template to render. */
1107
- kind: AuthEmailKind$1;
1108
1750
  recipient: string;
1109
- /** Numeric pincode (set for `*.pincode` kinds). */
1110
- code?: string;
1111
- /** Magic-link URL (set for `*.magicLink` kinds). */
1112
- url?: string;
1113
- /** Absolute expiry timestamp for the link/code (ms epoch). */
1114
- expiresAt?: number;
1115
- /** Associated user id, when known. */
1116
- userId?: string;
1117
- /** Extra context (e.g. `roles` for invite emails, IP/UA for notifyNewDevice). */
1118
- metadata?: Record<string, unknown>;
1119
- }
1120
- interface DeliverSms {
1121
- channel: "sms";
1122
- kind: AuthSmsKind$1;
1751
+ code: string;
1752
+ expiresInMs: number;
1753
+ } | {
1754
+ kind: "enroll-pincode";
1755
+ channel: "sms" | "email";
1123
1756
  recipient: string;
1124
- /** SMS always carries a pincode — that's the only thing SMS gets used for in this lib. */
1125
1757
  code: string;
1126
- ttlMs?: number;
1127
- userId?: string;
1128
- }
1129
- type DeliverPayload = DeliverEmail | DeliverSms;
1130
- declare class LoginWorkflow extends AuthWorkflowBase {
1131
- protected readonly opts: ResolvedLoginWorkflowOpts;
1758
+ expiresInMs: number;
1759
+ } | {
1760
+ kind: "invite-link";
1761
+ channel: "email";
1762
+ recipient: string;
1763
+ url: string;
1764
+ expiresInMs: number;
1765
+ } | {
1766
+ kind: "new-device-notice";
1767
+ channel: "email";
1768
+ recipient: string;
1769
+ deviceLabel?: string;
1770
+ loginAt: number;
1771
+ };
1772
+ /**
1773
+ * Top-level `UserCredentials` keys that workflow-collected profile payloads
1774
+ * MUST NEVER carry through to persistence. The server sets these out-of-band
1775
+ * (admin-supplied `ctx.admin?.roles`, password-set step, account activation,
1776
+ * MFA enrolment elsewhere). If the consumer's `.as` profile form mistakenly
1777
+ * declares one — or an attacker submits one as an extra field — the strip
1778
+ * applied at the workflow step blocks shadowing.
1779
+ */
1780
+ declare const RESERVED_USER_KEYS: ReadonlySet<string>;
1781
+ /**
1782
+ * Return a shallow copy of `profile` with `RESERVED_USER_KEYS` removed.
1783
+ * Does not mutate the input.
1784
+ */
1785
+ declare function stripReservedUserKeys(profile: Record<string, unknown>): Record<string, unknown>;
1786
+ /** Trim + de-duplicate role identifiers submitted via the admin invite form. */
1787
+ declare function parseInviteRoles(input?: string[]): string[];
1788
+ /**
1789
+ * Single source of truth for the "this invite was already accepted" finish
1790
+ * envelope. Used by both `idempotent-redirect` (in-workflow) and by
1791
+ * `AuthController.invitePostRedemption` (side route reached when the wf
1792
+ * state store has evicted the finished row and re-resume hits 410).
1793
+ *
1794
+ * Secondary "Request a new invite" option is gated on
1795
+ * `alreadyAcceptedRedirectUrl` being non-empty — mirrors how the resolver
1796
+ * defaults it, but lets consumers blank it to suppress the secondary button.
1797
+ */
1798
+ declare function buildInviteAlreadyAcceptedEnvelope(opts: {
1799
+ loginUrl: string;
1800
+ alreadyAcceptedRedirectUrl: string;
1801
+ }): FinishWfOpts;
1802
+ declare class AuthWorkflow {
1803
+ protected readonly opts: ResolvedAuthWorkflowOpts;
1132
1804
  protected readonly users: UserService;
1133
1805
  protected readonly auth: AuthCredential;
1134
- protected readonly authOpts: AuthOpts;
1135
1806
  protected readonly consentStore: ConsentStore;
1136
- constructor(opts: LoginWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
1807
+ constructor(opts: Partial<AuthWorkflowOpts>, users: UserService, auth: AuthCredential, consentStore: ConsentStore);
1137
1808
  /**
1138
- * Dispatch an email or SMS event. Default throws — consumers MUST override
1139
- * if any feature that emits is enabled (MFA pincode, ask/verify channel OTP,
1140
- * notifyNewDevice). The throw surfaces at the HTTP layer as 500 on the
1141
- * first event that triggers a send, which is the fail-loud signal.
1809
+ * Unified outbound dispatch hook for direct synchronous deliveries
1810
+ * (MFA / recovery / enrollment pincodes, new-device notices). NOT used for
1811
+ * resume-token flows the invite magic link is emitted via the wf engine's
1812
+ * `outletEmail()` primitive (pause-and-resume), since the resume URL is
1813
+ * minted by the engine AFTER the step yields and is not knowable here.
1814
+ * Default is a no-op — customer overrides wire concrete senders. Stays
1815
+ * sync-friendly: the default `void` preserves the engine's sync fast path.
1142
1816
  */
1143
- protected deliver(_payload: DeliverPayload): Promise<void>;
1817
+ protected deliver(_payload: AuthDeliveryPayload): void | Promise<void>;
1144
1818
  /**
1145
- * Emit an audit event. Default: no-op. Consumers override to fan out to
1146
- * their audit sink (DB table, log file, Kafka topic, …).
1819
+ * Return the list of selectable role identifiers for the admin invite form.
1820
+ * Mirrors the prior `InviteWorkflow.getAvailableRoles()` consumer hook
1821
+ * `undefined` (default) means no whitelist is enforced. Read by
1822
+ * `prepareAvailableRoles`.
1147
1823
  */
1148
- protected audit(_event: AuditEvent): Promise<void>;
1824
+ protected getAvailableRoles(): Promise<string[] | undefined> | string[] | undefined;
1149
1825
  /**
1150
- * Verify whether a presented trust-cookie token belongs to `userId` and is
1151
- * still valid. Default: delegates to `UserService.verifyTrustedDevice`
1152
- * (HMAC + persisted record + expiry + IP-binding). Override to use a
1153
- * different trust backend.
1826
+ * Build the extras dict merged into the freshly-created user row. Runs for
1827
+ * EVERY new-account path: password-signup and invite-accept merge it at
1828
+ * `createUser` time (the `create-user` step), and a first-time federated
1829
+ * login applies it from `sso-callback` (post-create `users.update`). Default:
1830
+ * `{}`. Override to populate e.g. a required `tenantId` from request context.
1831
+ *
1832
+ * `email` is optional: a federated profile can carry no email, so overrides
1833
+ * must tolerate `email === undefined`.
1154
1834
  */
1155
- protected loadTrustedDevice(userId: string, token: string, ip?: string): Promise<boolean>;
1835
+ protected prepareUser(_input: {
1836
+ email?: string;
1837
+ roles: string[];
1838
+ invitedBy?: string;
1839
+ }): Promise<Record<string, unknown>> | Record<string, unknown>;
1156
1840
  /**
1157
- * Persist a freshly-issued trust record. Default: delegates to
1158
- * `UserService.addTrustedDevice` the record is appended to the user's
1159
- * `trustedDevices` array on the user store. `userId` is the username the
1160
- * record belongs to (passed alongside since `TrustedDeviceRecord` itself
1161
- * carries no user identifier).
1841
+ * Derive roles server-side from the admin-form payload (e.g. AD lookup).
1842
+ * Result is set-unioned with admin-supplied roles by `infer-roles`.
1843
+ * Default: `[]`.
1162
1844
  */
1163
- protected storeTrustedDevice(userId: string, record: TrustedDeviceRecord): Promise<void>;
1845
+ protected inferAdminRoles(_input: {
1846
+ email: string;
1847
+ }): Promise<string[]> | string[];
1164
1848
  /**
1165
- * Revoke a trust record. Default: delegates to
1166
- * `UserService.revokeTrustedDevice`. Currently unused by the workflow's own
1167
- * happy path but exposed so consumers can call it from their own "sign out
1168
- * everywhere" flows for symmetry with `storeTrustedDevice`.
1849
+ * Override the structural duplicate rule for `admin-form`. Default: any
1850
+ * existing row `'reject'`; nothing → `'allow'`. Multi-tenant apps that
1851
+ * allow re-inviting the same email into a different tenant override.
1169
1852
  */
1170
- protected revokeTrustedDevice(userId: string, token: string): Promise<void>;
1853
+ protected duplicateInviteCheck(input: {
1854
+ email: string;
1855
+ existingUser: UserCredentials | null;
1856
+ }): Promise<"allow" | "reject"> | "allow" | "reject";
1171
1857
  /**
1172
- * Mint a new device-trust record + cookie value. Default: delegates to
1173
- * `UserService.issueTrustedDevice` — produces an HMAC-signed token bound to
1174
- * `userId` (+ `ip` when `bindsTo === 'cookie+ip'`). Consumers running
1175
- * multiple instances typically override `loadTrustedDevice`/
1176
- * `storeTrustedDevice` against Redis but keep this default.
1177
- */
1178
- protected issueTrustedDevice(userId: string, ip: string | undefined, ttlMs: number): Promise<TrustedDeviceRecord>;
1179
- /**
1180
- * Resolve the profile-completion policy. Returns `{ required: boolean }`
1181
- * whether the user must complete profile fields (e.g. `firstName` /
1182
- * `lastName`) BEFORE token issuance. Override per-tenant or per-user to
1183
- * gate logins on missing profile fields; the boolean is mirrored onto
1184
- * `ctx.profileCompleteRequired` by `prepare-profile` and read by the
1185
- * `profile-complete` schema condition (which AND-s the gate with
1186
- * `ctx.profileMissingFields.length > 0` so the step only fires when the
1187
- * consumer has surfaced fields to collect — typically populated by a
1188
- * `credentials` override that hydrates `ctx.profileMissingFields` from
1189
- * the user row).
1858
+ * Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
1859
+ * Default revokes every existing session via `auth.revokeAllForUser` — which is
1860
+ * mandatory on every store (stateless ones use a per-user epoch sentinel), so the
1861
+ * kick works without an override. Runs BEFORE `issue`, so the session about to be
1862
+ * minted survives. Override to scope the revoke (e.g. keep the current device).
1863
+ */
1864
+ protected logoutOtherSessions(username: string): Promise<void>;
1865
+ /**
1866
+ * Return the number of active (non-revoked, non-expired) sessions for the user,
1867
+ * used by the concurrency-limit gate. Default delegates to `auth.listForUser`,
1868
+ * which counts access-kind credentials and returns `[]` for stateless stores
1869
+ * (no round-trip) so the count is real when the store can enumerate and `0`
1870
+ * (gate disabled) when it can't. Only consulted when `resolveSessionPolicy`
1871
+ * declared a `concurrencyLimit`. Override for a custom session source.
1872
+ */
1873
+ protected loadActiveSessionsCount(username: string): Promise<number>;
1874
+ /**
1875
+ * Resolves the post-login redirect URL. Default reads `finalize.redirect`:
1876
+ * `false` / `null` → no redirect; `'home'` → `/`; `'referer'` → request
1877
+ * `Referer` header (undefined when absent).
1878
+ */
1879
+ protected resolveRedirect(ctx: AuthWfCtx): string | undefined;
1880
+ protected oauthRuntime(): Promise<OAuthRuntime>;
1881
+ /**
1882
+ * Resolve the {@link AuthorizeRuntime} (the pending-authorization + auth-code
1883
+ * stores) for the `mint-authz-code` terminal — same `instantiate` path as
1884
+ * {@link oauthRuntime}, reached ONLY when a login was started from
1885
+ * `/auth/authorize` (`ctx.authz` set). Override in a unit test to inject fakes.
1886
+ */
1887
+ protected authorizeRuntime(): Promise<AuthorizeRuntime>;
1888
+ /**
1889
+ * Redirect target for a federated-login FAILURE terminal — provider denial,
1890
+ * invalid/expired state, CSRF mismatch, missing transaction, exchange
1891
+ * failure, `denied` / `needs-link` resolution, or a locked/inactive account.
1892
+ * Benign + generic: it MUST NOT reveal WHICH check tripped (no
1893
+ * tamper-vs-expiry oracle — see invariant #5). Default: the login URL with a
1894
+ * single generic `?error=oauth`. Override to route to a dedicated SPA error
1895
+ * page (still without leaking the reason).
1896
+ */
1897
+ protected resolveOAuthErrorRedirect(_ctx: AuthWfCtx, _reason: string): string;
1898
+ /**
1899
+ * Leg 1 of federated login: turn an `sso-<id>` click on the login form into a
1900
+ * redirect to the provider, then END the login wf. STATELESS — no flow store:
1901
+ * a fresh non-secret `seed` is minted, the PKCE verifier + OIDC nonce are
1902
+ * DERIVED from it (`registry.deriveSeededPkce`), the `challenge`/`nonce` build
1903
+ * the authorize URL, and the seed rides in BOTH the signed `state` and a Lax
1904
+ * double-submit CSRF cookie. The callback re-derives the identical verifier
1905
+ * from `state.random` to redeem the `code` (see `sso-callback`) — so nothing
1906
+ * secret is ever in the URL and no server-side transaction is persisted.
1190
1907
  *
1191
- * Default-false matches the prior behavior most consumers don't gate
1192
- * logins on profile completion. Async-friendly via the union return type —
1193
- * sync defaults stay sync (engine fast path).
1908
+ * The CSRF cookie is attached to the FINISH ENVELOPE's `cookies` (which the
1909
+ * wf-trigger outlet writes onto the real HTTP response), NOT via
1910
+ * `useResponse().setCookie` the outlet builds its response from the
1911
+ * `WfFinished` envelope and ignores response-context cookies. Same mechanism
1912
+ * `issue` uses for the session cookie. The resume is a same-origin XHR, so the
1913
+ * `Set-Cookie` is stored before `AsWfForm` follows the redirect.
1194
1914
  */
1195
- protected resolveProfile(_ctx: LoginWfCtx): {
1196
- required: boolean;
1197
- } | Promise<{
1198
- required: boolean;
1199
- }>;
1915
+ protected beginSso(providerId: string, authzHandle?: string): Promise<void>;
1200
1916
  /**
1201
1917
  * Resolve the alternate-credentials policy (forgot-password / signup /
1202
- * magic-link / SSO providers + their URLs). Override to enable/disable per
1203
- * tenant. Sync default; async overrides supported.
1918
+ * magic-link / SSO providers). Reached from login.flow.
1919
+ */
1920
+ protected resolveAlternateCredentials(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["alternateCredentials"]> | Promise<NonNullable<AuthWfCtx["alternateCredentials"]>>;
1921
+ /**
1922
+ * Resolve the device-trust policy. Infrastructure (cookieName / ttlMs /
1923
+ * bindsTo) lives on `this.opts.deviceTrust`. Reached from login.flow.
1204
1924
  */
1205
- protected resolveAlternateCredentials(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["alternateCredentials"]> | Promise<NonNullable<LoginWfCtx["alternateCredentials"]>>;
1925
+ protected resolveDeviceTrust(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["deviceTrust"]> | Promise<NonNullable<AuthWfCtx["deviceTrust"]>>;
1206
1926
  /**
1207
- * Resolve the device-trust policy (enabled / opt-in / skipsMfa). Infrastructure
1208
- * (cookieName / ttlMs / bindsTo) still lives on `this.opts.deviceTrust` since
1209
- * those are app-wide constants, not per-request policy. Sync/async friendly.
1927
+ * Resolve the channel-enrolment policy (ensureEmail / ensurePhone).
1928
+ * Reached from login.flow.
1210
1929
  */
1211
- protected resolveDeviceTrust(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["deviceTrust"]> | Promise<NonNullable<LoginWfCtx["deviceTrust"]>>;
1930
+ protected resolveEnrollment(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["enrollment"]> | Promise<NonNullable<AuthWfCtx["enrollment"]>>;
1212
1931
  /**
1213
- * Resolve the channel-enrollment policy (ensureEmail / ensurePhone gates).
1214
- * Override to force email/phone capture per user segment. Sync/async friendly.
1932
+ * Resolve the finalize policy. Reached from login.flow. `auditLogin` is
1933
+ * dropped from the shape per §2 audit moved out of the workflow layer.
1215
1934
  */
1216
- protected resolveEnrollment(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["enrollment"]> | Promise<NonNullable<LoginWfCtx["enrollment"]>>;
1935
+ protected resolveFinalize(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["finalize"]> | Promise<NonNullable<AuthWfCtx["finalize"]>>;
1217
1936
  /**
1218
- * Resolve the finalize policy (audit emission / new-device notification /
1219
- * redirect target). Override to drive per-tenant audit-log routing or
1220
- * per-app redirect targets. Sync/async friendly.
1937
+ * Resolve the login-time guards policy (passwordInitial / passwordExpiry /
1938
+ * emailVerifiedRequired). Reached from login.flow.
1221
1939
  */
1222
- protected resolveFinalize(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["finalize"]> | Promise<NonNullable<LoginWfCtx["finalize"]>>;
1940
+ protected resolveGuards(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["guards"]> | Promise<NonNullable<AuthWfCtx["guards"]>>;
1223
1941
  /**
1224
- * Resolve the guards policy (passwordInitial / passwordExpiry /
1225
- * emailVerifiedRequired). Override per-tenant to tighten or loosen the
1226
- * post-credentials gates. Sync/async friendly.
1942
+ * Resolve the session-policy (concurrency limit). Reached from login.flow.
1943
+ */
1944
+ protected resolveSessionPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["sessionPolicy"]> | Promise<NonNullable<AuthWfCtx["sessionPolicy"]>>;
1945
+ /**
1946
+ * Resolve the authenticated change-password policy. Reached from
1947
+ * change-password.flow only. Default revokes the user's other sessions on a
1948
+ * successful change (OWASP Session Management) and applies NO rate limit —
1949
+ * current-password re-entry (enforced by `UserService.changePassword`) is the
1950
+ * primary protection, not throttling. Customers override to add a min-interval
1951
+ * (`rateLimit.minIntervalMs`) or to keep other sessions alive.
1227
1952
  *
1228
- * The `passwordExpiry` flag (default `true`) is the per-tenant escape
1229
- * hatch for rotation policy: flip to `false` for SSO-only tenants
1230
- * where the IdP owns rotation, or for service accounts where forced
1231
- * change would break automation. When `true`, the `credentials` step
1232
- * consults `UserService.isPasswordExpired(user)` which is itself
1233
- * gated on `config.password.maxAgeMs`, so an unset `maxAgeMs` already
1234
- * disables expiry independent of this flag.
1235
- */
1236
- protected resolveGuards(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["guards"]> | Promise<NonNullable<LoginWfCtx["guards"]>>;
1237
- /**
1238
- * Resolve the MFA config (currently only backup-codes availability — pincode
1239
- * timings stay on `this.opts.mfa` as infrastructure). Override to enable or
1240
- * disable backup-code redemption per tenant. Sync/async friendly.
1241
- */
1242
- protected resolveMfaConfig(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["mfaConfig"]> | Promise<NonNullable<LoginWfCtx["mfaConfig"]>>;
1243
- /**
1244
- * Resolve the multi-context policy (tenantSelect / personaSelect prompts).
1245
- * Override to require a tenant/persona pick when the user has more than one.
1246
- * Sync/async friendly.
1247
- */
1248
- protected resolveMultiContext(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["multiContext"]> | Promise<NonNullable<LoginWfCtx["multiContext"]>>;
1249
- /**
1250
- * Resolve the disclosure text rendered beneath the channel input field on
1251
- * `AskEmailForm` / `AskPhoneForm` at ask-time BEFORE the user submits
1252
- * their email/phone. Default returns a TCPA / PECR / CASL / GDPR-safe
1253
- * English paragraph that is GENERIC per channel (no target templated in,
1254
- * since the user hasn't submitted it yet). Override per-tenant or per-
1255
- * locale to swap copy (e.g. i18n catalog lookup). The resolved string is
1256
- * mirrored onto `ctx.otpDisclosure`, transported to the SPA via
1257
- * `@wf.context.pass`, and forwarded to
1258
- * `consentStore.recordOtpChannelConsent` at `verify/:channel` AFTER the
1259
- * pincode validates AND the channel is confirmed as an MFA method — the
1260
- * persisted audit record pins BOTH the literal disclosure copy the user
1261
- * saw AND the verified target as a separate field.
1953
+ * Whether the flow may be STARTED at all is governed by arbac on the trigger
1954
+ * route (deny the `change-password` action to forbid it for SSO-only orgs) —
1955
+ * there is deliberately no on/off flag here.
1956
+ */
1957
+ protected resolveChangePasswordPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["changePassword"]> | Promise<NonNullable<AuthWfCtx["changePassword"]>>;
1958
+ /**
1959
+ * Resolve the self-signup policy. Reached from signup.flow's `init-signup`.
1960
+ * Default `allowSignup: false` — invite-only is the safe default (mirrors
1961
+ * `resolveAlternateCredentials().signup`); a deployment that wants open
1962
+ * self-serve overrides this to `true` (and flips the login form's `signup`
1963
+ * alt-action on via `resolveAlternateCredentials`). `collectUsername: false`
1964
+ * means `username := email`; override + replace `opts.forms.signup` to
1965
+ * collect a distinct username. There is intentionally no rate-limit field
1966
+ * here yet — override the `signup-form` step (or front it with a captcha /
1967
+ * IP gate) for abuse control; the OTP resend cooldown already bounds repeat
1968
+ * sends per run.
1969
+ */
1970
+ protected resolveSignupPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["signup"]> | Promise<NonNullable<AuthWfCtx["signup"]>>;
1971
+ /**
1972
+ * Resolve the failed-login lockout posture (admin-only / self-service /
1973
+ * temporary — see `AuthWfLockoutMode`). Reached from login.flow (decides the
1974
+ * lock duration passed to `users.login` / `users.verifyMfa` on a threshold
1975
+ * trip) and recovery.flow (decides whether `unlock-account` runs after a
1976
+ * reset). Default `temporary` preserves the prior auto-expiry behavior.
1977
+ * Customers override per-tenant / per-user (e.g. force `admin-only` for
1978
+ * privileged accounts).
1979
+ */
1980
+ protected resolveLockout(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["lockout"]> | Promise<NonNullable<AuthWfCtx["lockout"]>>;
1981
+ /**
1982
+ * Resolve the unified MFA policy. Replaces login's hardcoded defaults +
1983
+ * invite's `{ issuer }` resolver. Issuer is sourced from
1984
+ * `this.opts.totpIssuer` so per-app TOTP labels remain a single knob.
1985
+ * Reached from login.flow + invite.start.
1986
+ */
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`).
1262
1992
  *
1263
- * Disclosure-only is sufficient for transactional security codes by default;
1264
- * customers wanting affirmative consent capture override
1265
- * `ConsentStore.recordOtpChannelConsent` instead. Sync/async friendly.
1266
- */
1267
- protected resolveOtpDisclosure(_ctx: LoginWfCtx, channel: "email" | "phone"): string | Promise<string>;
1268
- /**
1269
- * Resolve the session policy (concurrency limit). Override to enforce a
1270
- * per-tenant or per-user max-concurrent-sessions cap with reject / kickPrompt
1271
- * behaviour. Sync/async friendly.
1272
- */
1273
- protected resolveSessionPolicy(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["sessionPolicy"]> | Promise<NonNullable<LoginWfCtx["sessionPolicy"]>>;
1274
- /**
1275
- * Call `resolveProfile(ctx)` and mirror `result.required` onto
1276
- * `ctx.profileCompleteRequired`. Promise-branched body preserves the engine's
1277
- * sync fast path: a sync `resolveProfile` override skips the microtask
1278
- * allocation, while an `async` override is awaited via `.then` before the
1279
- * `profile-complete` schema condition reads the boolean. The resolved POJO
1280
- * is intentionally NOT stashed on ctx as a group — `profileCompleteRequired`
1281
- * is the only field, so a top-level boolean keeps the ctx shape flat.
1282
- */
1283
- prepareProfile(ctx: LoginWfCtx): undefined | Promise<undefined>;
1284
- prepareAlternateCredentials(ctx: LoginWfCtx): undefined | Promise<undefined>;
1285
- prepareDeviceTrust(ctx: LoginWfCtx): undefined | Promise<undefined>;
1286
- prepareEnrollment(ctx: LoginWfCtx): undefined | Promise<undefined>;
1287
- prepareFinalize(ctx: LoginWfCtx): undefined | Promise<undefined>;
1288
- prepareGuards(ctx: LoginWfCtx): undefined | Promise<undefined>;
1289
- prepareMfaConfig(ctx: LoginWfCtx): undefined | Promise<undefined>;
1290
- prepareMultiContext(ctx: LoginWfCtx): undefined | Promise<undefined>;
1291
- prepareSessionPolicy(ctx: LoginWfCtx): undefined | Promise<undefined>;
1292
- /**
1293
- * Populate `ctx.pendingConsents` with the customer-defined general-consent
1294
- * descriptors (terms, marketing, jurisdiction, ...) the user still needs to
1295
- * accept. Phase 4 transport only — nothing reads `ctx.pendingConsents` yet;
1296
- * Phase 5 will migrate carrier forms (`SetPasswordForm`, `ProfileCompleteForm`,
1297
- * ...) from the `WithInlineConsentForm` static-checkbox mixin onto this
1298
- * dynamic array.
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`):
1299
1998
  *
1300
- * Username MUST be bound before we fetch consents — customer impls key
1301
- * history by user, and pre-bind there's no identity to dedup against. The
1302
- * schema places this step AFTER the `!ctx.username` break gate, so the
1303
- * `if (!ctx.username)` guard is belt-and-brace for future refactors that
1304
- * might re-order the schema.
1305
- */
1306
- prepareConsents(ctx: LoginWfCtx): undefined | Promise<undefined>;
1307
- flow(): void;
1308
- /**
1309
- * First step of the workflow; remains as a no-op override hook for
1310
- * consumers (e.g. seeding pre-flight ctx fields, capturing request metadata).
1311
- * The pre-PR policy-pojo-on-ctx stash was dropped — policy now lives on
1312
- * `ctx.<group>` populated by the dedicated `prepare-<group>` steps.
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[]>;
2010
+ /**
2011
+ * Resolve the channel-OTP disclosure copy rendered beneath the email/phone
2012
+ * input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
2013
+ * Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
2014
+ * GENERIC per channel (no target templated in — the user hasn't submitted
2015
+ * yet at ask-time).
2016
+ */
2017
+ protected resolveOtpDisclosure(_ctx: AuthWfCtx, channel: "email" | "phone"): string | Promise<string>;
2018
+ /**
2019
+ * Resolve whether to require an additional MFA round (risk step-up).
2020
+ * Default never requires an extra factor.
2021
+ */
2022
+ protected resolveRiskStepUp(_ctx: AuthWfCtx): Promise<{
2023
+ require: boolean;
2024
+ reason?: string;
2025
+ }>;
2026
+ /**
2027
+ * Resolve the recovery URL targeted by the `forgotPassword` alt-action on
2028
+ * login's credentials form. Receives whatever the user typed into the
2029
+ * username field so the recovery page can pre-fill it.
1313
2030
  *
1314
- * Return type is `undefined | Promise<undefined>` so consumers can override
1315
- * with `async init(...)` without the default fast-path paying a Promise
1316
- * allocation (the wf engine awaits only when the return value is a Promise).
1317
- */
1318
- init(_ctx: LoginWfCtx): undefined | Promise<undefined>;
1319
- /**
1320
- * Prepare MFA setup: writes `ctx.mfaMode`, `ctx.availableMfaTransports`, and
1321
- * (when the user is resolvable) pre-selects `ctx.currentMfa` from the
1322
- * existing-user `defaultMethod` or the single-available-transport auto-pick.
1323
- * Override to compute any of the three from tenant policy / user attrs /
1324
- * request context. Return type allows a sync override (skip the promise
1325
- * round-trip) when no async work is needed — the default body is async only
1326
- * because of the `users.getUser` lookup for `currentMfa`.
1327
- */
1328
- prepareMfaSetup(ctx: LoginWfCtx): undefined | Promise<undefined>;
1329
- credentials(ctx: LoginWfCtx): Promise<unknown>;
1330
- private handleCredentialsAlt;
2031
+ * Sync return type only the caller (`credentials` @Step alt-action
2032
+ * handler) uses the URL inline.
2033
+ */
2034
+ protected resolveRecoveryUrl(username: string | undefined, alt: AuthWfAltCredsPolicy): string;
1331
2035
  /**
1332
- * Resolves the redirect URL the `forgotPassword` alt-action navigates to.
1333
- * Receives whatever the user typed into the username field so the recovery
1334
- * page can pre-fill it. Default:
1335
- * `${alternateCredentials.recoveryUrl}?username=${encodeURIComponent(username ?? '')}`.
1336
- * Sync return type only — the caller (`credentials` step's alt-action
1337
- * handler) uses the URL inline; consumers needing async URL construction
1338
- * should override the `credentials` @Step instead. The resolved
1339
- * `alternateCredentials` policy is supplied by the caller so the base impl
1340
- * doesn't have to re-call `resolveAlternateCredentials`.
1341
- */
1342
- protected resolveRecoveryUrl(username: string | undefined, alt: NonNullable<LoginWfCtx["alternateCredentials"]>): string;
1343
- magicLinkRequest(): void | Promise<void>;
1344
- magicLinkSend(): void | Promise<void>;
1345
- magicLinkVerified(): void | Promise<void>;
1346
- passkey(): void | Promise<void>;
1347
- ssoCallback(): void | Promise<void>;
1348
- ask(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
1349
- verify(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
1350
- checkTrustedDevice(ctx: LoginWfCtx): Promise<undefined>;
2036
+ * Resolve the admin-form policy (whether to collect roles on the invite
2037
+ * admin form). Reached from invite.start admin phase.
2038
+ */
2039
+ protected resolveAdminForm(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["adminForm"]> | Promise<NonNullable<AuthWfCtx["adminForm"]>>;
1351
2040
  /**
1352
- * Load + summarise the user's enrolled MFA methods (filtered against
1353
- * `ctx.availableMfaTransports`) and mirror the form-gating flags
1354
- * (`mfaMethodCount`, `mfaBackupCodes`, `deviceTrustOptIn`) onto ctx. Pure
1355
- * data-load no selection decision. Split out of the old
1356
- * `prepare-mfa-options` step so consumers can override the load/summary
1357
- * shape (custom MFA inventory source) without copying the selection
1358
- * heuristics in `selectMfaMethod`.
1359
- */
1360
- loadEnrolledMfaMethods(ctx: LoginWfCtx): Promise<undefined>;
1361
- /**
1362
- * Pick which MFA method to use from the already-loaded
1363
- * `ctx.mfaEnrolledMethods` summary. Decision-only — no IO. Honors
1364
- * `ctx.currentMfa` (pre-selected by `prepareMfaSetup` from the user's
1365
- * `defaultMethod` or single-transport auto-pick), auto-picks when only one
1366
- * method is enrolled, falls back to the `isDefault` method. All paths are
1367
- * gated on `!ctx.ignoreMfaDefault` so the `useDifferentMethod` re-pick flow
1368
- * (which sets the flag) skips straight to the `select2fa` picker. Split out
1369
- * of the old `prepare-mfa-options` step so consumers can override selection
1370
- * heuristics (e.g. risk-based per-tenant defaults) without re-implementing
1371
- * the load/summary.
1372
- */
1373
- selectMfaMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
1374
- select2fa(ctx: LoginWfCtx): Promise<unknown>;
1375
- pincodeSendLogin(ctx: LoginWfCtx): Promise<undefined>;
1376
- pincodeCheckLogin(ctx: LoginWfCtx): Promise<unknown>;
1377
- mfaTotp(ctx: LoginWfCtx): Promise<unknown>;
1378
- /**
1379
- * Backup-code alt-action handler shared by `select2fa`, `pincode-check-login`,
1380
- * and `mfa-totp`. Validates against `BackupCodeForm` (alphanumeric +
1381
- * hyphen-grouped — `MfaCodeForm` is digits-only and rejects backup codes
1382
- * produced by `UserService.generateBackupCodes`).
1383
- */
1384
- private handleBackupCode;
1385
- /**
1386
- * Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
1387
- * transport, otherwise pauses for the picker form. When TOTP is picked, the
1388
- * secret is provisioned in the same step body (see `enrollPickPhase`).
1389
- * Sync-friendly return: the auto-pick branch and the picker-form branch are
1390
- * both synchronous; only the TOTP-provisioning tail is async.
1391
- */
1392
- loginEnrollPickMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
1393
- /**
1394
- * Forced MFA enrollment — Phase 2 (collect sms/email address + send
1395
- * pincode). Gated out for totp by the schema condition.
1396
- */
1397
- loginEnrollAddress(ctx: LoginWfCtx): undefined | Promise<undefined>;
1398
- /**
1399
- * Forced MFA enrollment — Phase 3 (verify code + activate method). Fires
1400
- * `onComplete` to bridge `enrollDone` → `mfaChecked` so login's outer MFA
1401
- * while-loop (gated on `!mfaChecked`) exits.
1402
- */
1403
- loginEnrollConfirm(ctx: LoginWfCtx): undefined | Promise<undefined>;
1404
- /**
1405
- * Build the `MfaEnrollDeps` payload shared by all three login enrollment
1406
- * step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
1407
- * `EnrollPickMethodForm` can hide the `skip` action unless mode is
1408
- * `'optional'`) and supplies `onComplete` to mirror `enrollDone` →
1409
- * `mfaChecked` for login's loop-exit signal.
1410
- */
1411
- private buildLoginEnrollDeps;
1412
- deviceTrust(ctx: LoginWfCtx): Promise<undefined>;
1413
- preparePasswordRules(ctx: LoginWfCtx): undefined | Promise<undefined>;
1414
- createPasswordForm(ctx: LoginWfCtx): Promise<unknown>;
1415
- profileComplete(ctx: LoginWfCtx): Promise<unknown>;
1416
- /**
1417
- * Persists the profile-complete payload onto the user record. Default:
1418
- * no-op (the workflow records the form was submitted but writes nothing).
1419
- * Consumers override to write into their user store.
1420
- */
1421
- protected applyProfile(_username: string, _payload: Record<string, unknown>): Promise<void>;
1422
- /**
1423
- * Standalone terms re-acceptance prompt for returning users whose accepted
1424
- * terms version is stale — the consumer's `ConsentStore.getPendingConsents`
1425
- * returned a non-empty descriptor list (typically a bumped terms version)
1426
- * and no onboarding carrier form (ask-email/ask-phone/set-password/
1427
- * profile-complete) ran to capture them via the dynamic `consents: string[]`
1428
- * carrier field. The body delegates to `processInlineConsent`, which
1429
- * handles validation + ctx writes identically to the inline path.
1430
- */
1431
- termsBumpPrompt(ctx: LoginWfCtx): undefined;
1432
- /**
1433
- * Batched consent persistence — delegates to
1434
- * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
1435
- * audit-friendly-default / idempotency / silent-drop contract.
1436
- */
1437
- persistConsentsStep(ctx: LoginWfCtx): Promise<undefined>;
1438
- tenantSelect(ctx: LoginWfCtx): Promise<unknown>;
1439
- /**
1440
- * Resolves the user's available tenants. Default: empty array. Consumers
1441
- * who enable `multiContext.tenantSelect` must override this to return the
1442
- * tenants the user belongs to.
1443
- */
1444
- protected loadTenants(_username: string): Promise<Array<{
1445
- id: string;
1446
- name: string;
1447
- }>>;
1448
- personaSelect(ctx: LoginWfCtx): Promise<unknown>;
1449
- /**
1450
- * Resolves the user's available personas. Default: empty array. Consumers
1451
- * who enable `multiContext.personaSelect` must override this.
1452
- */
1453
- protected loadPersonas(_username: string): Promise<Array<{
1454
- id: string;
1455
- label: string;
1456
- }>>;
1457
- loadActiveSessionsStep(ctx: LoginWfCtx): Promise<undefined>;
1458
- /**
1459
- * Return the number of active (non-revoked, non-expired) sessions for the
1460
- * user — consulted by `concurrency-limit` to decide whether the kickPrompt
1461
- * branch fires. Default returns `0` (no enforcement). Override with a real
1462
- * count from your credential store or session table.
1463
- */
1464
- protected loadActiveSessions(_username: string): Promise<number>;
1465
- concurrencyLimit(ctx: LoginWfCtx): Promise<unknown>;
2041
+ * Resolve the invite accept-tail policy. Reached from invite.start accept
2042
+ * phase. `loginUrl` defaults to `this.opts.loginUrl`. Note: today's
2043
+ * `freshLoginRequired` field is GONE — the auto-login choice is the static
2044
+ * `AuthWorkflowOpts.autoLoginOnInvite` boolean (per §2 decision).
2045
+ */
2046
+ protected resolveAccept(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["accept"]> | Promise<NonNullable<AuthWfCtx["accept"]>>;
1466
2047
  /**
1467
- * Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
1468
- * Default: no-op. Consumers override to revoke sessions in their auth store.
2048
+ * Resolve the recovery post-reset policy. Reached from recovery.flow.
2049
+ * `freshLoginRequired` REMOVED the auto-login choice is the static
2050
+ * `AuthWorkflowOpts.autoLoginOnRecover` boolean (per §2 decision).
1469
2051
  */
1470
- protected logoutOtherSessions(_username: string): Promise<void>;
1471
- riskStepUp(ctx: LoginWfCtx): Promise<undefined>;
2052
+ protected resolvePostReset(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["postReset"]> | Promise<NonNullable<AuthWfCtx["postReset"]>>;
1472
2053
  /**
1473
- * Resolves whether to require an additional MFA round (risk step-up).
1474
- * Default: never requires an extra factor. Consumers override to inspect ctx
1475
- * (IP, geo, time since last login, etc.) and return `{require: true, reason: '…'}`
1476
- * to force an additional MFA round.
2054
+ * Resolve the recovery alt-actions policy (whether `backToLogin` is offered
2055
+ * on the recovery forms). Renamed from the prior `resolveAltActions` to
2056
+ * disambiguate from login's `resolveAlternateCredentials` (different
2057
+ * concept). Reached from recovery.flow.
1477
2058
  */
1478
- protected resolveRiskStepUp(_ctx: LoginWfCtx): Promise<{
1479
- require: boolean;
1480
- reason?: string;
2059
+ protected resolveRecoveryAltActions(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["recoveryAltActions"]> | Promise<NonNullable<AuthWfCtx["recoveryAltActions"]>>;
2060
+ /**
2061
+ * Pick the form to render for the unified pincode pair. Default routes to
2062
+ * `opts.forms.pincode` (MFA alt-actions) when `ctx.mfa?.method` is set;
2063
+ * otherwise `opts.forms.recoveryPincode` (recovery alt-actions).
2064
+ */
2065
+ protected resolvePincodeForm(ctx: AuthWfCtx): TAtscriptAnnotatedType;
2066
+ /**
2067
+ * Pick the raw recipient + channel for pincode delivery. Default sources
2068
+ * the address from the user's enrolled MFA method (when `ctx.mfa.method` is
2069
+ * set) or from `ctx.email` (recovery path). Loads the user to read the raw
2070
+ * method `value` — the `ctx.mfa.enrolledMethods` summary carries only the
2071
+ * MASKED form, which is for display, never for delivery.
2072
+ */
2073
+ protected resolvePincodeTarget(ctx: AuthWfCtx): {
2074
+ address: string;
2075
+ channel: "sms" | "email";
2076
+ } | Promise<{
2077
+ address: string;
2078
+ channel: "sms" | "email";
1481
2079
  }>;
1482
- issue(ctx: LoginWfCtx): Promise<void>;
1483
- auditLogin(ctx: LoginWfCtx): Promise<undefined>;
1484
- notifyNewDevice(ctx: LoginWfCtx): Promise<undefined>;
1485
- redirect(ctx: LoginWfCtx): undefined | Promise<undefined>;
1486
- /**
1487
- * Resolves the post-login redirect URL. Default reads
1488
- * `finalize.redirect`: `false` / `null` (the default) → no redirect, the
1489
- * `issue` step's data response stands (typical for SPAs/API clients);
1490
- * `'home'` `/`; `'referer'` request `Referer` header (undefined when
1491
- * absent, falling back to the data response).
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:
1492
2111
  *
1493
- * Sync return type only — the caller (`redirect` @Step's default body)
1494
- * uses the URL inline; consumers needing async redirect resolution should
1495
- * override the `redirect` @Step instead.
2112
+ * - `"typed"` (defaultM1): 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.
1496
2151
  */
1497
- protected resolveRedirect(ctx: LoginWfCtx): string | undefined;
1498
- }
1499
- //#endregion
1500
- //#region src/workflows/recovery.workflow.options.d.ts
1501
- type RecoveryDeliveryMode = "magicLink" | "otp" | "choice";
1502
- type RecoveryOtpTransport = "sms" | "email";
1503
- interface RecoveryWorkflowOpts {
2152
+ protected resolvePromoteHandleField(_ctx: AuthWfCtx, _channel: "email" | "sms"): string | undefined | Promise<string | undefined>;
1504
2153
  /**
1505
- * Replaceable form schemas. Each field defaults to the corresponding
1506
- * `.as` form shipped under `@aooth/auth-moost/atscript/models`.
2154
+ * Route a form alt-action click to a canonical outcome. Defaults match the
2155
+ * action ids the bundled `PincodeForm` declares; customers override per
2156
+ * form when adding new actions or remapping the canonical ones.
1507
2157
  */
1508
- forms?: {
1509
- emailIdentifier?: TAtscriptAnnotatedType;
1510
- pincode?: TAtscriptAnnotatedType;
1511
- recoveryFactor?: TAtscriptAnnotatedType;
1512
- recoveryModeSelect?: TAtscriptAnnotatedType;
1513
- setPassword?: TAtscriptAnnotatedType;
1514
- };
1515
- }
1516
- /**
1517
- * Fully-resolved view used by the workflow at runtime — every nested group is
1518
- * always populated by `mergeRecoveryOpts`, so step bodies can read
1519
- * `this.opts.<group>.<flag>` directly without optional chaining.
1520
- */
1521
- interface ResolvedRecoveryWorkflowOpts {
1522
- forms: {
1523
- emailIdentifier: TAtscriptAnnotatedType;
1524
- pincode: TAtscriptAnnotatedType;
1525
- recoveryFactor: TAtscriptAnnotatedType;
1526
- recoveryModeSelect: TAtscriptAnnotatedType;
1527
- setPassword: TAtscriptAnnotatedType;
1528
- };
1529
- }
1530
- /**
1531
- * Deep-merge defaults with the user-supplied nested pojo. Each group has its
1532
- * own `{ ...defaults, ...input }` line — small enough that pulling in lodash
1533
- * would be silly.
1534
- */
1535
- declare function mergeRecoveryOpts(opts?: RecoveryWorkflowOpts): ResolvedRecoveryWorkflowOpts;
1536
- //#endregion
1537
- //#region src/workflows/recovery.workflow.d.ts
1538
- interface RecoveryWfCtx {
1539
- delivery?: {
1540
- mode: RecoveryDeliveryMode;
1541
- otpTransports: RecoveryOtpTransport[];
1542
- };
1543
- preReset?: {
1544
- requireKnownFactor: boolean;
1545
- allowedFactors?: Array<"phone" | "totp">;
1546
- };
1547
- postReset?: {
1548
- revokeAllSessions: boolean;
1549
- freshLoginRequired: boolean;
1550
- loginUrl: string;
1551
- };
1552
- altActions?: {
1553
- backToLogin: boolean;
1554
- };
1555
- audit?: {
1556
- enabled: boolean;
2158
+ protected resolvePincodeAltAction(_ctx: AuthWfCtx, action: string): "resend" | "exit" | "useDifferentMethod" | undefined;
2159
+ /**
2160
+ * Asserts `ctx.subject` is populated. Throws `HttpError(500)` on miss;
2161
+ * narrows `subject` to `string` for the caller. Ported from
2162
+ * `AuthWorkflowBase` since the unified class no longer extends it.
2163
+ */
2164
+ protected requireSubject<T extends {
2165
+ subject?: string;
2166
+ }>(ctx: T): asserts ctx is T & {
2167
+ subject: string;
1557
2168
  };
1558
- email?: string;
1559
- username?: string;
1560
- selectedMode?: "magicLink" | "otp";
1561
- /** Resolved delivery mode the workflow committed to (set by `prepare-delivery` for fixed modes, by `select-mode` for `'choice'`). */
1562
- resolvedMode?: "magicLink" | "otp";
1563
- otpTransport?: "sms" | "email";
1564
- otpCodeLength?: number;
1565
- pin?: string;
1566
- pinExpire?: number;
1567
- pinResendAllowedAt?: number;
1568
- pinVerified?: boolean;
1569
- /** Mirror of `delivery.otpTransports.length`. Passed to `PincodeForm` so the `useDifferentTransport` action hides when only one transport is configured. */
1570
- recoveryTransportCount?: number;
1571
- linkSent?: boolean;
1572
- factorVerified?: boolean;
1573
- /**
1574
- * Recovery factors the user is actually able to verify on this attempt —
1575
- * intersection of `preReset.allowedFactors` (workflow whitelist) and
1576
- * what the user has enrolled (e.g. phone only if a confirmed SMS method
1577
- * exists). Populated by `verify-factor` before its form pauses and
1578
- * consumed by `RecoveryFactorForm` via `@wf.context.pass` to render only
1579
- * the available radio options.
1580
- */
1581
- availableRecoveryFactors?: Array<{
1582
- key: string;
1583
- label: string;
1584
- }>;
1585
- passwordChanged?: boolean;
1586
- sessionsRevoked?: boolean;
1587
- tokensIssued?: boolean;
1588
2169
  /**
1589
- * Subset of `pendingConsents[].id` the user ticked — set by
1590
- * `processInlineConsent` after silent-dropping unknown ids.
2170
+ * Project the internal ctx state onto `ctx.public` the ONLY top-level
2171
+ * key whitelisted on form schemas (via `@wf.context.pass 'public'`).
2172
+ * Mirrors `AuthWfPublicState` field-for-field; intentionally drops
2173
+ * internal-only fields (`pincode.channelCooldowns`, `mfa.saveAsDefault` /
2174
+ * `mfa.current` / `mfa.ignoreDefault`, `trust.deviceTrustToken`,
2175
+ * `channel.phone` / `channel.emailConfirmed`, `mfaEnroll.address`, …)
2176
+ * so they cannot leak to the wire.
2177
+ *
2178
+ * Called via `throwPublic` immediately before every `requireInput`-style
2179
+ * pause so the FE always reads a fresh projection of the post-step ctx.
2180
+ */
2181
+ protected populatePublic(ctx: AuthWfCtx): void;
2182
+ /**
2183
+ * Wrap `wf.requireInput(opts)` so `ctx.public` is freshly projected
2184
+ * before the pause throws. Every `throw wf.requireInput(...)` in the
2185
+ * codebase routes through this so no pause can ship a stale public
2186
+ * surface (and no contributor can accidentally skip the projection).
2187
+ */
2188
+ protected throwPublic<T>(ctx: AuthWfCtx, wf: {
2189
+ requireInput(opts?: T): unknown;
2190
+ }, opts?: T): unknown;
2191
+ /**
2192
+ * Drop-in wrapper around `useAtscriptWf(type)` that projects `ctx.public`
2193
+ * BEFORE returning the form handle. Steps that pause implicitly — via
2194
+ * `wf.resolveInput()` throwing on missing input or `wf.resolveAction()`
2195
+ * throwing on an unknown action — bypass `throwPublic`, so without this
2196
+ * wrapper the implicit-pause path would ship a stale (or missing)
2197
+ * `ctx.public`. Every `useAtscriptWf(...)` call in the workflow routes
2198
+ * through this so both pause flavors snapshot the same fresh projection.
2199
+ */
2200
+ protected useAtscriptWfPublic(ctx: AuthWfCtx, type: Parameters<typeof useAtscriptWf>[0]): ReturnType<typeof useAtscriptWf>;
2201
+ /** Translate `CAS_EXHAUSTED` UserAuthError to 409 Conflict (OCC contract). */
2202
+ protected withStoreErrorTranslation<T>(op: () => Promise<T>): Promise<T>;
2203
+ /** Mint a numeric pincode + stash it onto ctx. Returns the plain code. */
2204
+ protected mintPin(ctx: {
2205
+ pin?: string;
2206
+ pinExpire?: number;
2207
+ pinAttempts?: number;
2208
+ }, length: number, ttlMs: number): string;
2209
+ /**
2210
+ * Verify a submitted pincode against `ctx.pin`. Returns an error map (with
2211
+ * the message keyed under `code` so it renders inline on the code input) or
2212
+ * `null` on success. Brute-force protection: wrong-code attempts increment
2213
+ * `ctx.pinAttempts`; on the `pincodeMaxAttempts`-th miss the code is
2214
+ * invalidated (clears `pin` + `pinExpire` + `pinAttempts`) and the returned
2215
+ * error tells the user to request a fresh code. Without this gate the user
2216
+ * could probe the full 10^pincodeLength space inside one `pincodeTtlMs`
2217
+ * window.
2218
+ */
2219
+ protected verifyPin(ctx: {
2220
+ pin?: string;
2221
+ pinExpire?: number;
2222
+ pinAttempts?: number;
2223
+ }, submitted: string | undefined): {
2224
+ code: string;
2225
+ } | null;
2226
+ /**
2227
+ * Validate + stash inline-consent fields submitted on a carrier form.
2228
+ * SECURITY: silently drops unknown ids (audit-grade defense — see base
2229
+ * class docstring).
2230
+ *
2231
+ * Does NOT persist — persistence is deferred to `persist-consents` at the
2232
+ * workflow tail, AFTER channel/identity verification. Staging the decision
2233
+ * here (without persisting) lets downstream carrier forms hide the consent
2234
+ * block via `decidedAt` while the wf engine still owns the rollback
2235
+ * boundary: if the user abandons before the verification step succeeds,
2236
+ * the consent record never lands in the durable store.
2237
+ */
2238
+ protected processInlineConsent(ctx: AuthWfCtx, input: {
2239
+ consents?: string[];
2240
+ }, wf: {
2241
+ requireInput(opts?: {
2242
+ errors?: Record<string, string>;
2243
+ formMessage?: string;
2244
+ }): unknown;
2245
+ }): void;
2246
+ /**
2247
+ * Mask a raw address for UI display. The masked string is for
2248
+ * `ctx.pincode.sentTo`; the raw value is what gets passed to `deliver`.
2249
+ */
2250
+ protected maskAddress(address: string, channel: "sms" | "email"): string;
2251
+ /** Narrow `MfaMethod.name` to the canonical MfaTransport union. */
2252
+ protected mfaKindOf(methodName: string): MfaTransport | null;
2253
+ /**
2254
+ * Send an enrolment pincode and stamp `ctx.pincode.sentTo` with the masked
2255
+ * recipient. Shared by `enrollAddress` (initial dispatch) and the resend
2256
+ * path inside `enrollConfirm`.
2257
+ */
2258
+ protected sendEnrollPincode(ctx: AuthWfCtx, address: string, code: string): Promise<void>;
2259
+ /**
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;
2314
+ initLogin(ctx: AuthWfCtx): void;
2315
+ initInviteAdmin(ctx: AuthWfCtx): void;
2316
+ initInviteAccept(ctx: AuthWfCtx): void;
2317
+ initRecovery(ctx: AuthWfCtx): void;
2318
+ /**
2319
+ * Bind the change-password flow to the CURRENT authenticated user. Identity
2320
+ * comes from the session (`useAuth().getUserId()`) — NEVER from form input —
2321
+ * so the flow is structurally "change MY password" with no target-user
2322
+ * parameter. NOT `@Public()`: the trigger, the `@Workflow` body, and every
2323
+ * step in this flow are gated by `@ArbacResource("auth.change-password")` +
2324
+ * `@ArbacAction("self")`, so a customer enables the whole feature with a
2325
+ * single `allow("auth.change-password", "*")` grant and forbids it (SSO-only
2326
+ * orgs) by omitting it. `getUserId()` throws 401 if unauthenticated — defense
2327
+ * in depth on top of the guarded trigger route.
2328
+ */
2329
+ initChangePassword(ctx: AuthWfCtx): void;
2330
+ /**
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.
2339
+ *
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.
2350
+ */
2351
+ initAddMfa(ctx: AuthWfCtx): Promise<undefined>;
2352
+ credentials(ctx: AuthWfCtx): Promise<unknown>;
2353
+ /**
2354
+ * Route a credentials alt-action click (forgotPassword / signup / magicLink
2355
+ * / sso-<id>) to a `finishWf` redirect envelope. Returns `ALT_HANDLED` when
2356
+ * the caller should short-circuit without running form validation.
1591
2357
  */
1592
- acceptedConsentIds?: string[];
2358
+ private handleCredentialsAlt;
2359
+ request(ctx: AuthWfCtx): Promise<unknown>;
1593
2360
  /**
1594
- * Wall-clock ms when `processInlineConsent` resolved the carrier-form
1595
- * submission. Also the schema-gate for `persist-consents`.
2361
+ * Resolves the recovery-step `email` input to the user's stable `id` (the
2362
+ * token subject). Default: resolves via `findByHandle` (username exact, then
2363
+ * email exact). Override for custom handle→id mapping; return `null` when no
2364
+ * user matches.
1596
2365
  */
1597
- consentsDecidedAt?: number;
1598
- /** Set true by `persist-consents` after the batched `consentStore.save` call fires. */
1599
- consentsPersisted?: boolean;
2366
+ protected emailToUserId(email: string): Promise<string | null>;
2367
+ /** Anti-enumeration generic finish envelope used when recovery's `request` step receives an unknown email. */
2368
+ private finishGenericRecovery;
2369
+ /**
2370
+ * Stamp `recoveryStateTtlMs` on a paused-state error so the wf engine's
2371
+ * persisted-state strategy expires the state at that timestamp. Use at
2372
+ * recovery-side `requireInput` throws (recovery-only steps) or wrap a
2373
+ * shared-step `resolveInput()` throw inside a `ctx.postReset` guard.
2374
+ */
2375
+ protected stampRecoveryExpiry<E extends {
2376
+ expires?: number;
2377
+ }>(err: E): E;
2378
+ /** Emit the recovery "backToLogin" abort envelope and stamp `ctx.aborted`. */
2379
+ private abortRecoveryToLogin;
2380
+ /**
2381
+ * Canonical writer of `ctx.password.changeReason` + `isFirstLogin` /
2382
+ * `newPasswordRequired`. Discriminates by ctx-slot presence (§10):
2383
+ * `ctx.accept` → invite-accept; `ctx.postReset` → recovery; otherwise login.
2384
+ * Idempotent on re-entry.
2385
+ */
2386
+ prepareSemanticFlags(ctx: AuthWfCtx): undefined | Promise<undefined>;
2387
+ prepareConsents(ctx: AuthWfCtx): undefined | Promise<undefined>;
2388
+ prepareAlternateCredentials(ctx: AuthWfCtx): undefined | Promise<undefined>;
2389
+ prepareDeviceTrust(ctx: AuthWfCtx): undefined | Promise<undefined>;
2390
+ prepareEnrollment(ctx: AuthWfCtx): undefined | Promise<undefined>;
2391
+ prepareFinalize(ctx: AuthWfCtx): undefined | Promise<undefined>;
2392
+ prepareGuards(ctx: AuthWfCtx): undefined | Promise<undefined>;
2393
+ prepareLockout(ctx: AuthWfCtx): undefined | Promise<undefined>;
2394
+ /**
2395
+ * Per-call lockout override for `users.login` / `users.verifyMfa`, derived
2396
+ * from the resolved mode. A permanent mode (`admin-only` / `self-service`)
2397
+ * forces `duration: 0` so the threshold trip locks permanently;
2398
+ * `temporary` (or an unresolved policy) returns `undefined` so UserService's
2399
+ * own configured duration applies. Threshold stays UserService config.
2400
+ */
2401
+ protected lockoutOverride(ctx: AuthWfCtx): {
2402
+ duration: number;
2403
+ } | undefined;
2404
+ prepareSessionPolicy(ctx: AuthWfCtx): undefined | Promise<undefined>;
2405
+ prepareChangePassword(ctx: AuthWfCtx): undefined | Promise<undefined>;
2406
+ /**
2407
+ * Merges login's `prepare-mfa-setup` + invite's `prepare-mfa` + `setup-mfa`.
2408
+ * Writes `ctx.mfaPolicy`; with `ctx.subject` bound, pre-picks
2409
+ * `ctx.mfa.current` from the user's `defaultMethod` (challenge branch);
2410
+ * with zero confirmed methods and a single available transport, pre-picks
2411
+ * `ctx.mfaEnroll.method` (enrol branch). `enrolledMethods` is NOT written
2412
+ * here — `load-enrolled-mfa-methods` owns that masking. Idempotent.
2413
+ */
2414
+ prepareMfa(ctx: AuthWfCtx): undefined | Promise<undefined>;
2415
+ prepareAdminForm(ctx: AuthWfCtx): undefined | Promise<undefined>;
2416
+ prepareAvailableRoles(ctx: AuthWfCtx): Promise<undefined>;
2417
+ /**
2418
+ * Merges policy from `resolveAccept` into `ctx.accept` (rather than
2419
+ * overwriting) so any state stamped by later steps (`alreadyAccepted`)
2420
+ * survives.
2421
+ */
2422
+ prepareAccept(ctx: AuthWfCtx): undefined | Promise<undefined>;
2423
+ preparePasswordRules(ctx: AuthWfCtx): undefined | Promise<undefined>;
2424
+ preparePostReset(ctx: AuthWfCtx): undefined | Promise<undefined>;
2425
+ prepareRecoveryAltActions(ctx: AuthWfCtx): undefined | Promise<undefined>;
2426
+ /**
2427
+ * Admin-side invite form. Pauses for `InviteForm`; binds `ctx.email` +
2428
+ * `ctx.admin.roles`. Server-side enforces the `availableRoles` whitelist
2429
+ * (populated by `prepare-available-roles`). Calls `duplicateInviteCheck`
2430
+ * to decide whether to reject duplicates.
2431
+ */
2432
+ adminForm(ctx: AuthWfCtx): Promise<unknown>;
2433
+ /**
2434
+ * Map admin-provided role labels to canonical IDs via `inferAdminRoles`
2435
+ * hook, set-unioning with admin-supplied roles.
2436
+ */
2437
+ inferRoles(ctx: AuthWfCtx): Promise<undefined>;
2438
+ /**
2439
+ * Build the extras dict that `create-user` merges into the new user row.
2440
+ * Calls `prepareUser({email, roles, invitedBy})` and writes the result onto
2441
+ * `ctx.admin.userExtras`.
2442
+ */
2443
+ buildUserExtras(ctx: AuthWfCtx): Promise<undefined>;
2444
+ /**
2445
+ * Create the user row from `ctx.admin.userExtras` (plus the admin-supplied
2446
+ * `ctx.admin.roles`), then stamp `pendingInvitation = true` via a follow-up
2447
+ * deep-merge update so `createUser`-applied account defaults survive.
2448
+ */
2449
+ createUser(ctx: AuthWfCtx): Promise<undefined>;
2450
+ /**
2451
+ * Issue magic-link token and dispatch the invite email via `outletEmail` —
2452
+ * the wf engine pauses, the email-outlet trigger mints the resume URL, and
2453
+ * the click-through re-enters at this step's level. NOT routed through the
2454
+ * `deliver()` hook because that hook is for direct dispatches where the
2455
+ * payload is fully known at call-time; the magic-link URL exists only after
2456
+ * the pause. Public so the engine can re-enter on the anonymous resume.
2457
+ *
2458
+ * Outlet-pause idempotency via `admin.emailDispatched`: on the invitee's
2459
+ * magic-link resume, the engine re-executes this step body (a paused step's
2460
+ * cursor stays AT the step until the body returns a non-`inputRequired`
2461
+ * value). Returning the outletEmail envelope again would dispatch another
2462
+ * email + re-pause; the flag short-circuits the resume so the cursor
2463
+ * advances into the Phase B accept-tail. This is the engine-documented
2464
+ * step-layer idempotency pattern for outlet pauses with side effects
2465
+ * (`@wooksjs/event-wf` resume semantics — the cursor advances on a
2466
+ * successful step but a re-invocation of the same step body must guard
2467
+ * its own non-idempotent work).
2468
+ */
2469
+ sendInviteEmail(ctx: AuthWfCtx): unknown;
2470
+ /**
2471
+ * Check whether the invite was already accepted. Sets
2472
+ * `ctx.accept.alreadyAccepted` when the user's pending-invitation marker is
2473
+ * cleared.
2474
+ */
2475
+ checkPendingInvitation(ctx: AuthWfCtx): Promise<undefined>;
2476
+ /**
2477
+ * Emit the "this invite was already accepted" finish envelope and short-
2478
+ * circuit the rest of the accept tail.
2479
+ */
2480
+ idempotentRedirect(ctx: AuthWfCtx): undefined;
2481
+ /**
2482
+ * Clear the user's `pendingInvitation` marker after successful password set.
2483
+ */
2484
+ unsetPendingInvitation(ctx: AuthWfCtx): Promise<undefined>;
2485
+ /** Activate the invited user account (flips the account status flag). */
2486
+ activateUser(ctx: AuthWfCtx): Promise<undefined>;
1600
2487
  /**
1601
- * Descriptors for the customer-defined consents (terms, marketing,
1602
- * jurisdiction, ...) the user still needs to accept. Populated once by
1603
- * `prepare-consents` after username-bind; consumed by `WithInlineConsentForm`'s
1604
- * dynamic `AsConsentArray` field on the carrier form (Phase 5).
2488
+ * Emit the success-confirmation envelope. The downstream `finalize-auto-
2489
+ * login` step preserves this `message` so the SPA paints the configured
2490
+ * confirmation text alongside the tokens (WF-INVITE-020).
1605
2491
  */
1606
- pendingConsents?: ConsentDescriptor[];
1607
- /** Set by abort alt-actions (`backToLogin`). Gates all terminal steps. */
1608
- aborted?: boolean;
1609
- }
1610
- /**
1611
- * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1612
- * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1613
- * @Step methods populate — one entry per resolver. Library users typically
1614
- * accept a payload of this shape on their `RecoveryWorkflow` subclass ctor /
1615
- * test harness and have each `resolveXxx` return its matching key (falling
1616
- * back to `super.resolveXxx(ctx)` for unset groups).
1617
- */
1618
- interface RecoveryPolicyOverrides {
1619
- delivery?: NonNullable<RecoveryWfCtx["delivery"]>;
1620
- preReset?: NonNullable<RecoveryWfCtx["preReset"]>;
1621
- postReset?: NonNullable<RecoveryWfCtx["postReset"]>;
1622
- altActions?: NonNullable<RecoveryWfCtx["altActions"]>;
1623
- audit?: NonNullable<RecoveryWfCtx["audit"]>;
1624
- }
1625
- declare class RecoveryWorkflow extends AuthWorkflowBase {
1626
- protected readonly opts: ResolvedRecoveryWorkflowOpts;
1627
- protected readonly users: UserService;
1628
- protected readonly auth: AuthCredential;
1629
- protected readonly authOpts: AuthOpts;
1630
- protected readonly consentStore: ConsentStore;
1631
- constructor(opts: RecoveryWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2492
+ confirmation(ctx: AuthWfCtx): undefined;
1632
2493
  /**
1633
- * Dispatch an email or SMS event. Default throws consumers MUST override
1634
- * if `delivery.mode` ever drives email/SMS (i.e. for any non-`magicLink`
1635
- * mode AND for `magicLink` mode the `outletEmail` outlet still runs the
1636
- * email through `createAuthEmailOutlet`'s `EmailSender` see the trigger
1637
- * controller wiring; this method covers OTP code dispatch).
2494
+ * Unified password-set step body merges login Phase 5 + invite accept-tail
2495
+ * + recovery set-password. Stages copy via `ctx.password.changeReason`
2496
+ * (`initial` / `expired` / `reset`), pauses for `SetPasswordForm`, validates
2497
+ * match, calls `users.setPassword`, processes inline consents, and clears
2498
+ * the per-user `isPasswordInitial` / `isPasswordExpired` flags.
1638
2499
  */
1639
- protected deliver(_payload: DeliverPayload): Promise<void>;
2500
+ createPasswordForm(ctx: AuthWfCtx): Promise<unknown>;
1640
2501
  /**
1641
- * Emit an audit event. Default: no-op. Consumers override to fan out to
1642
- * their audit sink.
2502
+ * Optional min-interval rate limit (Okta "minimum password age"). Gated
2503
+ * upstream by `!!ctx.changePassword?.rateLimit`. Reuses `password.lastChanged`
2504
+ * (no extra storage) — if the last change is more recent than
2505
+ * `minIntervalMs`, emit a warn terminal and set `ctx.aborted` so the schema's
2506
+ * `{ break }` short-circuits BEFORE the form pause (the user can't fix this by
2507
+ * retrying the form — they must wait — so this is a terminal, not a
2508
+ * `requireInput`). NOT the primary protection: current-password re-entry is.
1643
2509
  */
1644
- protected audit(_event: AuditEvent): Promise<void>;
2510
+ enforceChangePasswordRateLimit(ctx: AuthWfCtx): Promise<undefined>;
1645
2511
  /**
1646
- * Resolve the delivery policy (mode + OTP transports). Override per-tenant
1647
- * to drive magic-link vs OTP delivery preferences. Sync/async friendly.
2512
+ * Authenticated self-service password change. `ctx.subject` is the SIGNED-IN
2513
+ * user (set by `init-change-password` from the session never form input).
2514
+ * Pauses for `ChangePasswordForm`, then calls `users.changePassword`, which
2515
+ * re-verifies the CURRENT password (primary protection) before applying the
2516
+ * policy + history checks. `UserAuthError`s map to per-field form errors so
2517
+ * the user can fix and retry in place (per the requireInput-not-HttpError
2518
+ * convention).
1648
2519
  */
1649
- protected resolveDelivery(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["delivery"]> | Promise<NonNullable<RecoveryWfCtx["delivery"]>>;
2520
+ changePasswordForm(ctx: AuthWfCtx): Promise<unknown>;
1650
2521
  /**
1651
- * Resolve the pre-reset policy (requireKnownFactor + allowedFactors whitelist).
1652
- * Override to enforce a factor check between the magic-link / OTP step and
1653
- * the new-password form. `allowedFactors` omitted means both phone and TOTP
1654
- * are eligible. Sync/async friendly.
2522
+ * Change-password terminal — rotate the acting session's token (so the
2523
+ * current device stays signed in on a FRESH credential) and emit a success
2524
+ * message. Runs AFTER the optional `revoke-sessions` step, so the net effect
2525
+ * is "kill every other session, keep this one on a new token" (OWASP Session
2526
+ * Management: no ghost sessions survive a credential change).
1655
2527
  */
1656
- protected resolvePreReset(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["preReset"]> | Promise<NonNullable<RecoveryWfCtx["preReset"]>>;
2528
+ finishChangePassword(ctx: AuthWfCtx): Promise<undefined>;
1657
2529
  /**
1658
- * Resolve the post-reset policy (session revocation / fresh-login redirect /
1659
- * loginUrl). Override per-tenant. `loginUrl` defaults to
1660
- * `this.authOpts.loginUrl` (the cross-workflow shared login URL); customers
1661
- * can still override per-tenant by overriding this resolver — the field
1662
- * stays on the policy surface. Sync/async friendly.
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.
1663
2534
  */
1664
- protected resolvePostReset(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["postReset"]> | Promise<NonNullable<RecoveryWfCtx["postReset"]>>;
2535
+ finishAddMfa(ctx: AuthWfCtx): undefined;
1665
2536
  /**
1666
- * Resolve the alt-actions policy (whether `backToLogin` is offered on the
1667
- * recovery forms). Override to hide the escape hatch per-tenant. Sync/async
1668
- * friendly.
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.
1669
2540
  */
1670
- protected resolveAltActions(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["altActions"]> | Promise<NonNullable<RecoveryWfCtx["altActions"]>>;
2541
+ prepareLockedMfaTransports(ctx: AuthWfCtx): undefined | Promise<undefined>;
1671
2542
  /**
1672
- * Resolve the audit policy (whether recovery.* audit events fire). Override
1673
- * to route audit-log emission per-tenant. Sync/async friendly.
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).
1674
2548
  */
1675
- protected resolveAudit(_ctx: RecoveryWfCtx): NonNullable<RecoveryWfCtx["audit"]> | Promise<NonNullable<RecoveryWfCtx["audit"]>>;
1676
- prepareDelivery(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
2549
+ manageStepUpDone(ctx: AuthWfCtx): undefined;
1677
2550
  /**
1678
- * Apply resolved delivery to ctx also auto-resolves derived ctx fields:
1679
- * - `resolvedMode` (when mode !== 'choice' `'choice'` defers to `select-mode`)
1680
- * - `recoveryTransportCount` (mirrored to ctx for the `useDifferentTransport` form gate)
1681
- *
1682
- * Validates the otpTransports-not-empty invariant at step time (replacing
1683
- * the old construction-time `validateOpts` check; the value is now
1684
- * ctx-driven so the check has to fire at step time).
1685
- */
1686
- private applyResolvedDelivery;
1687
- preparePreReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1688
- preparePostReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1689
- prepareAltActions(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1690
- prepareAudit(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1691
- /**
1692
- * Populate `ctx.pendingConsents` with the customer-defined general-consent
1693
- * descriptors (terms, marketing, jurisdiction, ...) the user still needs to
1694
- * accept. Phase 4 transport only — nothing reads `ctx.pendingConsents` yet;
1695
- * Phase 5 will migrate the carrier `SetPasswordForm` from the
1696
- * `WithInlineConsentForm` static-checkbox mixin onto this dynamic array.
1697
- *
1698
- * Username MUST be bound before we fetch consents — the schema places this
1699
- * step AFTER the `!ctx.username` break gate, so the `if (!ctx.username)`
1700
- * guard is belt-and-brace for future refactors that might re-order the
1701
- * schema.
2551
+ * Manage-MFA password re-auththe 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.
1702
2561
  */
1703
- prepareConsents(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1704
- flow(): void;
2562
+ managePasswordReauth(ctx: AuthWfCtx): Promise<undefined>;
1705
2563
  /**
1706
- * First step of the workflow; remains as a no-op override hook for
1707
- * consumers. Policy populated by the dedicated `prepare-<group>` steps.
1708
- *
1709
- * Return type is `undefined | Promise<undefined>` so consumers can override
1710
- * with `async init(...)` without the default fast-path paying a Promise
1711
- * allocation (the wf engine awaits only when the return value is a Promise).
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.
1712
2570
  */
1713
- init(_ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1714
- request(ctx: RecoveryWfCtx): Promise<unknown>;
2571
+ manageMenu(ctx: AuthWfCtx): Promise<undefined>;
1715
2572
  /**
1716
- * Resolves the recovery-step `email` input to the `username` (user-id) that
1717
- * `UserService.getUser` expects. Default: returns the email unchanged (treats
1718
- * email as username). Apps whose user model separates `username` from
1719
- * `email` MUST override this; return `null` when no user matches.
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).
1720
2577
  */
1721
- protected emailToUserId(email: string): Promise<string | null>;
1722
- selectMode(ctx: RecoveryWfCtx): unknown;
1723
- sendMagicLink(ctx: RecoveryWfCtx): unknown;
1724
- sendOtp(ctx: RecoveryWfCtx): Promise<undefined>;
1725
- checkOtp(ctx: RecoveryWfCtx): Promise<unknown>;
1726
- verifyFactor(ctx: RecoveryWfCtx): Promise<unknown>;
1727
- /**
1728
- * Returns the factor options to show on `RecoveryFactorForm`. Default:
1729
- * intersection of `preReset.allowedFactors` (workflow whitelist —
1730
- * `undefined` means both `phone` and `totp` are eligible) and the kinds
1731
- * the user has actually enrolled (`phone` if a confirmed SMS method
1732
- * exists, `totp` if a confirmed TOTP method exists). Override to add
1733
- * custom factors (e.g. security questions) — call `super` to keep the
1734
- * built-in pair.
1735
- */
1736
- protected loadAvailableRecoveryFactors(ctx: RecoveryWfCtx): Promise<Array<{
1737
- key: string;
1738
- label: string;
1739
- }>>;
1740
- /**
1741
- * Verifies a recovery factor against the user's enrolled MFA methods.
1742
- * Default: supports `'phone'` (phone last-4 match) and `'totp'` (current
1743
- * TOTP code). Returns `true` when the factor matches.
1744
- *
1745
- * Consumers extend by overriding to support additional factors (e.g.
1746
- * security questions); call `super.verifyRecoveryFactor(...)` to keep
1747
- * the built-in checks.
2578
+ confirmRemoveMfa(ctx: AuthWfCtx): Promise<undefined>;
2579
+ askChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
2580
+ verifyChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
2581
+ /**
2582
+ * Read the device-trust cookie; if it matches a valid record, set
2583
+ * `ctx.otp.verified = true` to skip the MFA loop. Otherwise stamp
2584
+ * `ctx.trust.newDevice = true` to drive the post-MFA notify gate.
1748
2585
  */
1749
- protected verifyRecoveryFactor(input: {
1750
- factor: string;
1751
- value: string;
1752
- ctx: RecoveryWfCtx;
1753
- }): Promise<boolean>;
1754
- setPassword(ctx: RecoveryWfCtx): Promise<unknown>;
1755
- revokeSessions(ctx: RecoveryWfCtx): Promise<undefined>;
1756
- auditStep(ctx: RecoveryWfCtx): Promise<undefined>;
2586
+ checkTrustedDevice(ctx: AuthWfCtx): Promise<undefined>;
1757
2587
  /**
1758
- * Batched consent persistence delegates to
1759
- * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
1760
- * audit-friendly-default / idempotency / silent-drop contract.
2588
+ * Resolve the client IP from the active HTTP request, swallowing the case
2589
+ * where there is no HTTP context (unit tests that hand-roll the wf runtime).
2590
+ * Ported from `AuthWorkflowBase`.
1761
2591
  */
1762
- persistConsentsStep(ctx: RecoveryWfCtx): Promise<undefined>;
1763
- freshLoginFinish(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
1764
- autoLoginFinish(ctx: RecoveryWfCtx): Promise<undefined>;
2592
+ protected resolveClientIp(): string | undefined;
1765
2593
  /**
1766
- * Send the generic "if an account exists, you'll receive instructions"
1767
- * finished response. Used for unknown emails so a known/unknown lookup is
1768
- * indistinguishable to the client (anti-enumeration).
2594
+ * Resolve the client `User-Agent` from the active HTTP request. Sibling to
2595
+ * {@link resolveClientIp}; swallows the no-HTTP case (unit tests that
2596
+ * hand-roll the wf runtime) by returning `undefined`.
1769
2597
  */
1770
- private finishGeneric;
1771
- private abortToLogin;
1772
- private emitRequested;
1773
- private resolveUserPhone;
1774
- }
1775
- //#endregion
1776
- //#region src/workflows/invite.workflow.options.d.ts
1777
- /**
1778
- * Input passed to {@link InviteWorkflow.prepareUser}. The workflow resolves the
1779
- * admin form to these fields before calling the hook, so the override sees a
1780
- * fully-typed payload regardless of which optional fields the admin filled in.
1781
- */
1782
- interface PreparedUserInput {
1783
- email: string;
1784
- firstName?: string;
1785
- lastName?: string;
1786
- roles: string[];
1787
- /** Admin's `username` (`useAuth().getAuthContext()?.userId` at invite time). */
1788
- invitedBy?: string;
1789
- }
1790
- /** Return value of {@link InviteWorkflow.duplicateCheck}. */
1791
- type DuplicateAction = "allow" | "reject" | "reuseAsReInvite";
1792
- type InviteSendMode = "email" | "shareableLink" | "choice";
1793
- interface InviteWorkflowOpts {
1794
- /**
1795
- * Replaceable form schemas. Each field defaults to the corresponding
1796
- * `.as` form shipped under `@aooth/auth-moost/atscript/models`.
2598
+ protected resolveUserAgent(): string | undefined;
2599
+ /**
2600
+ * Build the {@link CredentialMetadata} captured onto every credential at
2601
+ * issue time. Default records the request IP + User-Agent (the raw facts the
2602
+ * session UI derives device/location from at read time). Returns `undefined`
2603
+ * outside an HTTP context — so a hand-rolled (no-HTTP) wf run issues with
2604
+ * `metadata: undefined`. Override to add a `label`, trim PII, etc.
1797
2605
  */
1798
- forms?: {
1799
- enrollAddress?: TAtscriptAnnotatedType;
1800
- enrollConfirm?: TAtscriptAnnotatedType;
1801
- enrollPickMethod?: TAtscriptAnnotatedType;
1802
- invite?: TAtscriptAnnotatedType;
1803
- inviteEmail?: TAtscriptAnnotatedType;
1804
- inviteSendMode?: TAtscriptAnnotatedType;
1805
- setPassword?: TAtscriptAnnotatedType;
1806
- };
1807
- }
1808
- /**
1809
- * Fully-resolved view used by the workflow at runtime — every nested group is
1810
- * always populated by `mergeInviteOpts`, so step bodies can read
1811
- * `this.opts.<group>.<flag>` directly without optional chaining.
1812
- */
1813
- interface ResolvedInviteWorkflowOpts {
1814
- forms: {
1815
- enrollAddress: TAtscriptAnnotatedType;
1816
- enrollConfirm: TAtscriptAnnotatedType;
1817
- enrollPickMethod: TAtscriptAnnotatedType;
1818
- invite: TAtscriptAnnotatedType;
1819
- inviteEmail: TAtscriptAnnotatedType;
1820
- inviteSendMode: TAtscriptAnnotatedType;
1821
- setPassword: TAtscriptAnnotatedType;
1822
- };
1823
- }
1824
- /**
1825
- * Deep-merge defaults with the user-supplied nested pojo. Each group has its
1826
- * own `{ ...defaults, ...input }` line — small enough that pulling in lodash
1827
- * would be silly.
1828
- */
1829
- declare function mergeInviteOpts(opts?: InviteWorkflowOpts): ResolvedInviteWorkflowOpts;
1830
- /**
1831
- * Backwards-compat alias for the prior input-shape name. Consumers who type
1832
- * their `prepareUser()` override against this still compile.
1833
- */
1834
- type InvitePrepareUserInput = PreparedUserInput;
1835
- //#endregion
1836
- //#region src/workflows/invite.workflow.d.ts
1837
- interface InviteWfCtx {
1838
- adminForm?: {
1839
- collectRoles: boolean;
1840
- };
1841
- send?: {
1842
- mode: InviteSendMode;
1843
- };
1844
- accept?: {
1845
- alreadyAcceptedRedirectUrl: string;
1846
- freshLoginRequired: boolean;
1847
- loginUrl: string;
1848
- showConfirmation: boolean;
1849
- confirmationMessage: string;
1850
- };
1851
- cancellation?: {
1852
- allowed: boolean;
1853
- };
1854
- audit?: {
1855
- enabled: boolean;
1856
- };
1857
- mfa?: {
1858
- issuer: string;
1859
- };
1860
- /** Boolean projection of `this.getProfileForm() !== undefined` — schema gates on it. */
1861
- acceptProfileFormPresent?: boolean;
2606
+ protected resolveIssueMetadata(_ctx: AuthWfCtx): CredentialMetadata | undefined;
1862
2607
  /**
1863
- * Populated by `prepare-available-roles` when the override returns a list.
1864
- * Surfaced into the `InviteForm` via `@wf.context.pass 'availableRoles'` so
1865
- * the role multi-select renders the whitelisted choices; also used by
1866
- * `admin-form` to reject admin-submitted roles outside the list.
2608
+ * Mint a fresh credential for the workflow user, stamping the default
2609
+ * issue-time metadata (IP + User-Agent via {@link resolveIssueMetadata}).
2610
+ * Shared by every finish step that issues a session (login, change-password,
2611
+ * recovery auto-login). Call after {@link requireSubject} has narrowed
2612
+ * `subject` (the typed param enforces it at the call site).
1867
2613
  */
1868
- availableRoles?: string[];
1869
- email?: string;
1870
- /** Typically same as `email`; consumers can override the mapping. */
1871
- username?: string;
1872
- firstName?: string;
1873
- lastName?: string;
1874
- roles?: string[];
2614
+ private issueForContext;
1875
2615
  /**
1876
- * Extras dict prepared by `build-user-extras` (calls `prepareUser`) and
1877
- * consumed by `create-user` to populate the user-row fields beyond the
1878
- * base credential shape. Split apart so consumers can inject e.g. a
1879
- * tenant-validation step between extras-build and create-user without
1880
- * copying either body.
2616
+ * Load + summarise the user's enrolled MFA methods (filtered against
2617
+ * `ctx.mfaPolicy.availableTransports`) and mirror form-gating flags
2618
+ * (`mfa.methodCount`, `trust.optIn`) onto ctx. Pure data-load.
2619
+ */
2620
+ loadEnrolledMfaMethods(ctx: AuthWfCtx): Promise<undefined>;
2621
+ /**
2622
+ * Pick which MFA method to use from `ctx.mfa.enrolledMethods`. Decision-only,
2623
+ * no IO. Honors `ctx.mfa.current` (pre-selected by `prepare-mfa` from the
2624
+ * user's `defaultMethod`), auto-picks when only one method is enrolled,
2625
+ * falls back to the `isDefault` method. Gated on `!ctx.mfa.ignoreDefault`.
2626
+ */
2627
+ selectMfaMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
2628
+ /** Pauses for `Select2faForm`; binds `ctx.mfa.method` from input. */
2629
+ select2fa(ctx: AuthWfCtx): Promise<unknown>;
2630
+ /**
2631
+ * Unified MFA pincode send. Used by login MFA SMS/email challenge and
2632
+ * recovery OTP. Form/target picked via `resolvePincodeForm` /
2633
+ * `resolvePincodeTarget` (which discriminate on `ctx.mfa?.method` presence).
2634
+ */
2635
+ pincodeSend(ctx: AuthWfCtx): Promise<unknown>;
2636
+ /**
2637
+ * Unified MFA pincode check. Used by login MFA SMS/email challenge and
2638
+ * recovery OTP. Alt-actions routed via `resolvePincodeAltAction` — the
2639
+ * default returns `undefined` (customers override per form).
2640
+ */
2641
+ pincodeCheck(ctx: AuthWfCtx): Promise<unknown>;
2642
+ /**
2643
+ * TOTP MFA challenge step body. Verifies a TOTP code via `users.verifyMfa`
2644
+ * (lockout-aware); sets `ctx.otp.verified = true` on success. Replaces
2645
+ * login's prior `mfa-totp` step.
2646
+ */
2647
+ totpCheck(ctx: AuthWfCtx): Promise<unknown>;
2648
+ /**
2649
+ * Unified MFA-enrol phase 1 (pick method). Auto-picks a single transport,
2650
+ * otherwise pauses for `EnrollPickMethodForm`. When TOTP is picked, the
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.
2655
+ */
2656
+ enrollPickMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
2657
+ /**
2658
+ * Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
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`.
2665
+ */
2666
+ enrollAddress(ctx: AuthWfCtx): Promise<undefined>;
2667
+ /**
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
2681
+ * success sets `ctx.mfaEnroll.done = true` AND `ctx.otp.verified = true`
2682
+ * (the loop-exit signal — enrol-confirm verifies an OTP, so the unified
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.
2689
+ */
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.
1881
2706
  */
1882
- userExtras?: Record<string, unknown>;
1883
- /** Populated by `select-send-mode` (when `send.mode === 'choice'`). */
1884
- selectedSendMode?: "email" | "shareableLink";
1885
- /** Resolved send mode the workflow committed to (set in `prepare-send` or `select-send-mode`). */
1886
- resolvedSendMode?: "email" | "shareableLink";
1887
- /** Populated by `return-shareable-link` so the admin's UI can display it. */
1888
- shareableLinkUrl?: string;
1889
- /** Marks that `send-email` already emitted the outlet — resume → advance. */
1890
- linkSent?: boolean;
1891
- /** Detected at `check-pending-invitation`; triggers `idempotent-redirect`. */
1892
- alreadyAccepted?: boolean;
1893
- passwordSet?: boolean;
1894
- enrollMethod?: "sms" | "email" | "totp";
1895
- enrollAddress?: string;
1896
- enrollSecret?: string;
1897
- enrollUri?: string;
1898
- enrollAvailableTransports?: Array<"sms" | "email" | "totp">;
1899
- /**
1900
- * MFA policy (set by `inviteSetupMfa` setter — overridable per consumer).
1901
- * - `'required'` — invitee MUST enroll a second factor BEFORE activation.
1902
- * - `'optional'` — invitee is prompted but may `skip` the enrollment form.
1903
- * - `'disabled'` — enrollment loop is skipped entirely.
1904
- */
1905
- mfaMode?: "required" | "optional" | "disabled";
1906
- /** Available MFA transports (set by `inviteSetupMfa` setter — overridable per consumer). */
1907
- availableMfaTransports?: Array<"sms" | "email" | "totp">;
1908
- /**
1909
- * Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
1910
- * `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
1911
- * hide unless mode is `'optional'`.
1912
- */
1913
- enrollMode?: "required" | "optional";
1914
- enrollDone?: boolean;
1915
- /** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
1916
- enrollPincodeCooldown?: number;
1917
- /** Pincode scratch shared with the enrollment helper. */
1918
- pin?: string;
1919
- pinExpire?: number;
1920
- pinSentTo?: string;
1921
- /** Raw input from `collect-profile`. */
1922
- profile?: Record<string, unknown>;
1923
- profileApplied?: boolean;
1924
- pendingInvitationCleared?: boolean;
1925
- activated?: boolean;
1926
- confirmationShown?: boolean;
1927
- tokensIssued?: boolean;
1928
- /**
1929
- * Subset of `pendingConsents[].id` the user ticked — set by
1930
- * `processInlineConsent` after silent-dropping unknown ids.
1931
- */
1932
- acceptedConsentIds?: string[];
1933
- /**
1934
- * Wall-clock ms when `processInlineConsent` resolved the carrier-form
1935
- * submission. Also the schema-gate for `persist-consents`.
1936
- */
1937
- consentsDecidedAt?: number;
1938
- /** Set true by `persist-consents` after the batched `consentStore.save` call fires. */
1939
- consentsPersisted?: boolean;
1940
- /**
1941
- * Descriptors for the customer-defined consents (terms, marketing,
1942
- * jurisdiction, ...) the user still needs to accept. Populated once by
1943
- * `prepare-consents` after username-bind; consumed by `WithInlineConsentForm`'s
1944
- * dynamic `AsConsentArray` field on the carrier form (Phase 5).
1945
- */
1946
- pendingConsents?: ConsentDescriptor[];
1947
- /** Set true by abort alt-actions (`cancel`). Gates all terminal steps. */
1948
- aborted?: boolean;
1949
- }
1950
- /**
1951
- * Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
1952
- * overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
1953
- * @Step methods populate — one entry per resolver. Library users typically
1954
- * accept a payload of this shape on their `InviteWorkflow` subclass ctor /
1955
- * test harness and have each `resolveXxx` return its matching key (falling
1956
- * back to `super.resolveXxx(ctx)` for unset groups).
1957
- */
1958
- interface InvitePolicyOverrides {
1959
- adminForm?: NonNullable<InviteWfCtx["adminForm"]>;
1960
- send?: NonNullable<InviteWfCtx["send"]>;
1961
- accept?: NonNullable<InviteWfCtx["accept"]>;
1962
- cancellation?: NonNullable<InviteWfCtx["cancellation"]>;
1963
- audit?: NonNullable<InviteWfCtx["audit"]>;
1964
- mfa?: NonNullable<InviteWfCtx["mfa"]>;
1965
- }
1966
- /** Trim + de-duplicate role identifiers submitted via the admin invite form. */
1967
- declare function parseInviteRoles(input?: string[]): string[];
1968
- /**
1969
- * **Per-step ARBAC model.** Phase-A steps (admin-side, pre magic-link send)
1970
- * inherit the class-level `@ArbacResource('auth.invite') @ArbacAction('start')`
1971
- * so every admin-side step event is gated. Apps that wire
1972
- * `arbacAuthorizeInterceptor` globally grant admin a single rule:
1973
- * `allow('auth.invite', 'start')`. The ARBAC resource name is intentionally
1974
- * distinct from the wfid path (`auth/invite/start`) — RBAC policy ids and
1975
- * wfid namespacing are separate naming schemes.
1976
- *
1977
- * The three `@Workflow` body methods (`inviteFlow` / `reInviteFlow` /
1978
- * `cancelInviteFlow`) are `@Public()` because the wf adapter dispatches the
1979
- * flow body on EVERY `start()` / `resume()` call — gating it would 401 the
1980
- * anonymous magic-link resume before any step runs. The real gate is the
1981
- * step methods themselves, which the wf runtime invokes through the same
1982
- * interceptor chain.
1983
- *
1984
- * Phase-B steps (post `ctx.linkSent`, accept tail) are method-level
1985
- * `@Public()` because they fire on the anonymous magic-link resume.
1986
- * `send-email` / `return-shareable-link` are the boundary: also `@Public()`
1987
- * because the @prostojs/wf runtime re-enters the saved step on resume (the
1988
- * loop restarts at `indexes[level]`, not after it). Their bodies are
1989
- * idempotent via `if (ctx.linkSent) return`.
1990
- *
1991
- * `auth/invite/resend` / `auth/invite/cancel` are admin-only end-to-end
1992
- * (admin confirms in their own UI; no anonymous boundary), so their phase-A
1993
- * steps stay class-gated under the same `auth.invite` / `start` grant.
1994
- */
1995
- declare class InviteWorkflow extends AuthWorkflowBase {
1996
- protected readonly opts: ResolvedInviteWorkflowOpts;
1997
- protected readonly users: UserService;
1998
- protected readonly auth: AuthCredential;
1999
- protected readonly authOpts: AuthOpts;
2000
- protected readonly consentStore: ConsentStore;
2001
- constructor(opts: InviteWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2707
+ promoteToHandle(ctx: AuthWfCtx): Promise<undefined>;
2002
2708
  /**
2003
- * Dispatch an email or SMS event. Default throws the default invite send
2004
- * uses `outletEmail` (handled by `createAuthEmailOutlet`) so this method is
2005
- * only invoked when a consumer's accept-tail steps drive a manual send.
2006
- * Override to wire your senders.
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.)
2007
2715
  */
2008
- protected deliver(_payload: DeliverPayload): Promise<void>;
2716
+ protected applyHandlePromotion(subject: string, field: string, value: string): Promise<void>;
2009
2717
  /**
2010
- * Emit an audit event. Default: no-op. Consumers override to fan out to
2011
- * their audit sink.
2718
+ * Risk step-up: re-evaluate whether to require another MFA round. Default
2719
+ * `resolveRiskStepUp` returns `{require: false}`. When `require: true`,
2720
+ * clear `ctx.otp.verified` to re-arm the loop.
2012
2721
  */
2013
- protected audit(_event: AuditEvent): Promise<void>;
2722
+ riskStepUp(ctx: AuthWfCtx): Promise<undefined>;
2014
2723
  /**
2015
- * Build the extras dictionary merged into the freshly-created user row in
2016
- * `invitePreCreateUser`. Default: `{}`. Override to populate e.g. a
2017
- * required `tenantId`. This is the ONLY seam through which the admin form's
2018
- * `firstName` / `lastName` reach persistence — map them into your schema's
2019
- * own columns (e.g. `displayName`) and return them here.
2724
+ * Mint a device-trust record + cookie value. Default: delegates to
2725
+ * `UserService.issueTrustedDevice` produces an HMAC-signed token bound to
2726
+ * `username` (+ `ip` when `bindsTo === 'cookie+ip'`).
2020
2727
  */
2021
- protected prepareUser(_input: PreparedUserInput): Promise<Record<string, unknown>>;
2728
+ protected issueTrustedDevice(username: string, ip: string | undefined, ttlMs: number): Promise<TrustedDeviceRecord>;
2022
2729
  /**
2023
- * Return the list of selectable role identifiers for the admin invite form.
2024
- * When defined AND `adminForm.collectRoles` is true → form ships
2025
- * `ctx.availableRoles` so the UI renders a multi-select AND the
2026
- * `admin-form` step rejects admin-submitted roles outside the
2027
- * list. When `undefined` (default) → no whitelist is enforced and any role
2028
- * value the admin form supplies is accepted.
2730
+ * Persist the trusted-device record onto the user store. Default: appends
2731
+ * onto the `trustedDevices` array.
2029
2732
  */
2030
- protected getAvailableRoles(): Promise<string[] | undefined>;
2733
+ protected storeTrustedDevice(username: string, record: TrustedDeviceRecord): Promise<void>;
2031
2734
  /**
2032
- * Derive roles server-side from the admin-form payload (e.g. email domain
2033
- * tenant role, AD lookup). Result is set-unioned with admin-supplied
2034
- * roles when `adminForm.collectRoles` is true. Default: `[]` (no inference).
2735
+ * Post-MFA device-trust issuance. SECURITY: must bail when
2736
+ * `ctx.newPasswordRequired` is true issuing a trusted-device token before
2737
+ * the user has set their own password would let an admin-set temporary
2738
+ * credential establish persistent device trust (defence-in-depth on top of
2739
+ * the MFA-form `hidden` expression on `rememberDevice`).
2035
2740
  */
2036
- protected inferRoles(_input: {
2037
- email: string;
2038
- firstName?: string;
2039
- lastName?: string;
2040
- }): Promise<string[]>;
2741
+ deviceTrust(ctx: AuthWfCtx): Promise<undefined>;
2041
2742
  /**
2042
- * Persist the accept-time profile payload. Default: deep-merge into the
2043
- * user record via `UserService.update(username, profile)`. Override to
2044
- * route into a separate profile table / external CRM.
2743
+ * Standalone terms-bump prompt for returning users whose accepted terms
2744
+ * version is stale and no carrier form ran. Delegates to
2745
+ * `processInlineConsent` for validation + ctx writes.
2045
2746
  */
2046
- protected applyProfile(input: {
2047
- username: string;
2048
- profile: Record<string, unknown>;
2049
- }): Promise<void>;
2747
+ termsBumpPrompt(ctx: AuthWfCtx): undefined;
2050
2748
  /**
2051
- * Override the structural duplicate rule for `admin-form`.
2052
- * Default: any existing row `'reject'`; nothing → `'allow'`. Multi-tenant
2053
- * apps that allow re-inviting the same email into a different tenant
2054
- * override to return `'allow'` for those cases.
2749
+ * Load the user's active session count for the concurrency-limit gate.
2750
+ * Pure data-load calls the overridable `loadActiveSessionsCount` hook.
2055
2751
  */
2056
- protected duplicateCheck(input: {
2057
- email: string;
2058
- existingUser: UserCredentials | null;
2059
- }): Promise<DuplicateAction>;
2752
+ loadActiveSessions(ctx: AuthWfCtx): Promise<undefined>;
2060
2753
  /**
2061
- * Return the consumer-supplied `.as` form schema rendered in the
2062
- * `collect-profile` step. `undefined` (default) skips the step
2063
- * entirely (just password collection).
2754
+ * Concurrency-limit gate pauses for `ConcurrencyLimitForm`. `reject` mode
2755
+ * blocks the login outright with a form-level error. `kickPrompt` mode pauses
2756
+ * on the fieldless prompt; submitting it (the 'Login' button) logs out the
2757
+ * user's other sessions and continues. `resolveInput()` throws to pause on
2758
+ * first arrival and returns once the submit resumes the step.
2064
2759
  */
2065
- protected getProfileForm(): TAtscriptAnnotatedType | undefined;
2760
+ concurrencyLimit(ctx: AuthWfCtx): Promise<unknown>;
2066
2761
  /**
2067
- * Resolve the admin-form policy (whether to collect roles on the admin
2068
- * invite form). Override per-tenant. Sync/async friendly.
2762
+ * Consumer extension point override in your subclass to inject extra
2763
+ * accept-tail logic (input pauses, alt actions, persistence). Default:
2764
+ * no-op.
2069
2765
  */
2070
- protected resolveAdminForm(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["adminForm"]> | Promise<NonNullable<InviteWfCtx["adminForm"]>>;
2766
+ extraStep(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2071
2767
  /**
2072
- * Resolve the send-mode policy (`'email'` / `'shareableLink'` / `'choice'`).
2073
- * Override to drive per-tenant magic-link delivery preferences. Sync/async
2074
- * friendly.
2768
+ * Batched consent persistence fans one `ConsentEvent` per pending
2769
+ * descriptor out to the `ConsentStore.save` DI provider.
2075
2770
  */
2076
- protected resolveSend(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["send"]> | Promise<NonNullable<InviteWfCtx["send"]>>;
2771
+ persistConsents(ctx: AuthWfCtx): Promise<undefined>;
2077
2772
  /**
2078
- * Resolve the accept-tail policy (idempotent-redirect URL, fresh-login gate,
2079
- * loginUrl, confirmation message). Override per-tenant. Sync/async friendly.
2080
- * `loginUrl` defaults to `this.authOpts.loginUrl` (the cross-workflow shared
2081
- * login URL); customers can still override per-tenant by overriding this
2082
- * resolver the field stays on the policy surface.
2773
+ * Revoke the user's existing sessions. Shared by recovery (gated upstream by
2774
+ * `ctx.postReset.revokeAllSessions`) and authenticated change-password (gated
2775
+ * by `ctx.changePassword.revokeOtherSessions`).
2776
+ *
2777
+ * - Change-password runs in an authenticated context, so we KEEP the caller's
2778
+ * current device via `revokeOtherSessions(username, currentSessionId)` —
2779
+ * OWASP "invalidate other sessions" without logging the user out of the tab
2780
+ * they just changed their password in. If the current session can't be
2781
+ * resolved (no session id), fall back to revoking everything (fail-secure).
2782
+ * - Recovery is anonymous (no current session to keep) → `revokeAllForUser`.
2783
+ */
2784
+ revokeSessions(ctx: AuthWfCtx): Promise<undefined>;
2785
+ /**
2786
+ * Lift a failed-login lockout after a successful password reset. Recovery
2787
+ * only — gated upstream by `ctx.lockout?.mode === "self-service"` so the
2788
+ * `admin-only` mode keeps the account frozen (reset succeeds, lock stays)
2789
+ * and `temporary` continues to rely on its own timeout. `unlockAccount`
2790
+ * also zeroes `failedLoginAttempts`, so the next login starts clean.
2791
+ */
2792
+ unlockAccount(ctx: AuthWfCtx): Promise<undefined>;
2793
+ /**
2794
+ * Issue access + refresh tokens via `auth.issue`. Stashes the login
2795
+ * response envelope on `useWfFinished` so downstream `redirect` can
2796
+ * override with a redirect envelope while preserving the cookies.
2797
+ */
2798
+ issue(ctx: AuthWfCtx): Promise<void>;
2799
+ /**
2800
+ * Notify the user of a login from a new device via the unified `deliver`
2801
+ * hook. Gated upstream by
2802
+ * `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice && !!ctx.trust.newDevice`.
2803
+ */
2804
+ notifyNewDevice(ctx: AuthWfCtx): Promise<undefined>;
2805
+ /**
2806
+ * Set `ctx.completion.redirectUrl` from `resolveRedirect`. When set,
2807
+ * overrides `issue`'s data envelope with an immediate-redirect envelope
2808
+ * (cookies from `issue` are preserved).
2809
+ */
2810
+ redirect(ctx: AuthWfCtx): undefined;
2811
+ /**
2812
+ * Authorization-server terminal (AUTH-SERVER.md §4.4). Reached INSTEAD of
2813
+ * `issue`/`redirect` when this login was started from `GET /auth/authorize`
2814
+ * (`ctx.authz` set by `init-login` or re-raised by `sso-callback`). Mints a
2815
+ * single-use authorization code bound to the authenticated user + the pending
2816
+ * request's PKCE challenge / redirect / token policy, then 302s the browser to
2817
+ * `redirect_uri?code&state`. It does NOT issue a session and attaches NO
2818
+ * cookies — the token is minted later, off the browser, at `POST /auth/token`.
2819
+ */
2820
+ mintAuthzCode(ctx: AuthWfCtx): Promise<undefined>;
2821
+ /**
2822
+ * Fresh-login finalize — invite + recovery. Emits a finish envelope that
2823
+ * redirects the user to `loginUrl`. Invite uses an immediate redirect;
2824
+ * recovery uses an auto countdown so the user reads the "Password updated"
2825
+ * confirmation first. Discriminated by ctx-slot presence
2826
+ * (`ctx.postReset` → recovery; otherwise invite).
2827
+ */
2828
+ finalizeFreshLogin(ctx: AuthWfCtx): undefined | Promise<undefined>;
2829
+ /**
2830
+ * Post-reset store read: did an `admin-only` lockout survive the password
2831
+ * reset (account still frozen)? Callers MUST first confirm
2832
+ * `ctx.lockout?.mode === "admin-only"` so this read stays off the sync fast
2833
+ * path for every other recovery + invite finalize — `self-service` ran
2834
+ * `unlock-account` and `temporary` auto-expires, so only `admin-only` can
2835
+ * reach finalize still locked. Shared by BOTH finalize terminals: fresh-login
2836
+ * warns instead of confirming success, auto-login warns instead of minting
2837
+ * tokens (a still-frozen account must never be logged straight in).
2838
+ */
2839
+ private recoveryLeftAccountLocked;
2840
+ /**
2841
+ * Emit the recovery password-reset terminal. `stillLocked` is only ever true
2842
+ * for an `admin-only` account whose lock survived the reset — that terminal
2843
+ * warns the user the account remains frozen and offers a manual back-to-
2844
+ * sign-in (no misleading auto-redirect to a login they can't pass yet).
2845
+ * Every other reset confirms success and auto-redirects to sign-in.
2846
+ */
2847
+ private finishRecoveryReset;
2848
+ /**
2849
+ * Auto-login finalize — invite + recovery. Issues access + refresh tokens
2850
+ * and stashes the login response envelope on `useWfFinished`. Invite
2851
+ * preserves any `message` set by an earlier terminal (`confirmation`) so
2852
+ * the SPA paints the confirmation text alongside the tokens (WF-INVITE-020).
2853
+ */
2854
+ finalizeAutoLogin(ctx: AuthWfCtx): Promise<undefined>;
2855
+ /**
2856
+ * Entry step of `auth/signup/flow`. Inline-resolves the signup policy (runs
2857
+ * BEFORE the `!allowSignup` gate, mirroring how `credentials` / `request`
2858
+ * inline the front policies they need) and stamps `ctx.signup` — whose
2859
+ * presence is the flow discriminator. Sets `ctx.autoLogin = true`: v1 always
2860
+ * issues a session on success (the shared `finalize-fresh-login` assumes
2861
+ * invite/recovery ctx slots, so signup uses `finalize-auto-login` only).
2862
+ * When self-signup is disabled (the default), emits a terminal finish so the
2863
+ * SPA shows a closed-signups message instead of a form; the schema's
2864
+ * `{ break: !allowSignup }` short-circuits the rest.
2865
+ */
2866
+ initSignup(ctx: AuthWfCtx): undefined | Promise<undefined>;
2867
+ /**
2868
+ * Collect the signup email (verify-first — no account exists yet). First
2869
+ * entry pauses on `SignupForm`; on submit, stashes `ctx.email` and flips
2870
+ * `ctx.signup.submitted` to open the OTP loop. `backToLogin` aborts to the
2871
+ * login page. The bundled form is email-only (`username := email`); a custom
2872
+ * `opts.forms.signup` + an override here can collect more.
2873
+ */
2874
+ signupForm(ctx: AuthWfCtx): Promise<undefined>;
2875
+ /**
2876
+ * Create the account — runs AFTER the OTP loop, so the email is proven. The
2877
+ * existence check lives HERE (not before the OTP) so the wire path is
2878
+ * identical for new and already-registered emails: both received an OTP
2879
+ * pause, so an attacker on the wire cannot enumerate accounts. A taken email
2880
+ * is only revealed to someone who actually controls the inbox, at which point
2881
+ * we route them to sign-in. A new email creates the (still-inactive) user and
2882
+ * arms the shared password-set phase (`newPasswordRequired`); the reused
2883
+ * `activate-user` step flips it active AFTER the password is set.
2884
+ */
2885
+ signupCreateUser(ctx: AuthWfCtx): Promise<undefined>;
2886
+ /** Generic "you already have an account" finish for the signup existence collision (safe — only reached post-OTP). */
2887
+ private finishSignupAlreadyRegistered;
2888
+ /**
2889
+ * Customer extension point for signup — runs after the account is created,
2890
+ * activated, and consents persisted, just before `finalize-auto-login`. The
2891
+ * default is a no-op; a subclass overrides it to seed app-specific rows
2892
+ * (tenant, profile, welcome email, audit record, ConsentStore.save, …) for
2893
+ * the freshly-created `ctx.subject`. Mirrors login's `extra-step` seam.
2894
+ */
2895
+ signupExtraStep(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2896
+ magicLinkRequest(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2897
+ magicLinkSend(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2898
+ magicLinkVerified(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2899
+ passkey(_ctx: AuthWfCtx): unknown | Promise<unknown>;
2900
+ /**
2901
+ * Verify the OAuth callback and resolve a user. Reaches here (instead of
2902
+ * `credentials`) when `init-login` saw an inbound `state` (`ctx.idpInbound`);
2903
+ * reads `{ provider, code, state, error }` from the START input (the SPA
2904
+ * bridges the provider callback into `/auth/trigger` STARTING `auth/login/flow`).
2905
+ * Order is security-critical:
2906
+ *
2907
+ * verify state (HS256) → CSRF double-submit → re-derive PKCE verifier/nonce
2908
+ * from the seed → provider.exchange (verified ID token) → link OR resolveUser
2909
+ * → ACCOUNT-STATE GATE → seed ctx.subject → fall through to the shared tail.
2910
+ *
2911
+ * Replay defense is the provider's ONE-TIME `code` (a replayed callback fails
2912
+ * at `exchange` when the provider rejects the already-redeemed code), plus the
2913
+ * short-TTL signed state + CSRF cookie — the stateless design carries no
2914
+ * single-use server marker, by design.
2915
+ *
2916
+ * Every pre-subject failure collapses to one benign redirect terminal
2917
+ * (`finishOAuth`) so the wire is not an oracle for which check tripped. The
2918
+ * account-state gate MUST live here — `issue` does not re-gate, so without it
2919
+ * a locked/inactive account could log straight in via OAuth.
2920
+ */
2921
+ ssoCallback(ctx: AuthWfCtx): Promise<undefined>;
2922
+ /**
2923
+ * Emit a benign, generic federated-login failure terminal (immediate redirect
2924
+ * to {@link resolveOAuthErrorRedirect}). Collapses EVERY pre-subject failure
2925
+ * mode so the wire response is never an oracle for which check tripped
2926
+ * (invariant #5). Returns `undefined` so the step can `return this.finishOAuth(...)`;
2927
+ * `{ break: !ctx.subject }` then halts the flow (subject is never set on failure).
2928
+ *
2929
+ * The precise `reason` is handed to `resolveOAuthErrorRedirect` (the override
2930
+ * seam — a consumer MAY branch the target on it, server-side) but the
2931
+ * CLIENT-facing `action.reason` is deliberately the constant `"oauth-failed"`:
2932
+ * exposing `oauth-${reason}` to the SPA would re-introduce the very
2933
+ * which-check-tripped oracle invariant #5 forbids.
2934
+ */
2935
+ private finishOAuth;
2936
+ /**
2937
+ * Seed `ctx.email` / `ctx.channel` from a resolved user's confirmed channels —
2938
+ * shared by `ssoCallback` (linked / created / auto-linked) and `proveControl`
2939
+ * (interactively-linked) so the post-success channel shape can't drift between
2940
+ * the two federated entry points. Mirrors `credentials`' post-login seeding.
2941
+ * `fallbackEmail` (the provider / snapshot email) is a DISPLAY fallback only —
2942
+ * never promoted to the unique login handle (a gated, later-phase concern).
2943
+ */
2944
+ private seedChannelState;
2945
+ /**
2946
+ * `needs-link` setup (decision A — password, OTP fallback). Decide how the
2947
+ * user will prove control of the matched account, stash the pending-link
2948
+ * state, and return so the `prove-control` @Step (gated on `ctx.pendingLink`)
2949
+ * pauses for the challenge. Deliberately does NOT set `ctx.subject` — proving
2950
+ * control is exactly what authorizes that.
2951
+ *
2952
+ * Proof channel:
2953
+ * - account has a real password (`!password.isInitial`) → `password`;
2954
+ * - else a confirmed email/SMS factor exists → `otp` to THAT channel (NEVER
2955
+ * the provider-supplied email — the attacker controls the provider account,
2956
+ * so a code sent there would be circular);
2957
+ * - else no provable channel → generic terminal (cannot safely link).
2958
+ */
2959
+ protected stashPendingLink(ctx: AuthWfCtx, profile: NormalizedProfile, candidateUserId: string, redirect: string): Promise<undefined>;
2960
+ /**
2961
+ * Mint + deliver an OTP proof code to the pending-link candidate's OWN
2962
+ * confirmed channel (NEVER the provider-supplied email — that would be
2963
+ * circular) and arm the resend cooldown. Shared by the first auto-dispatch
2964
+ * and the `resend` action. Returns the masked delivery target, or `null` if
2965
+ * the confirmed channel vanished between resolve and dispatch (the caller
2966
+ * routes that to the safe generic terminal).
2967
+ */
2968
+ private deliverPendingLinkPin;
2969
+ /**
2970
+ * Interactive `needs-link` completion — prove control of the matched local
2971
+ * account, then attach the verified federated identity to it. Gated by the
2972
+ * login schema on `ctx.pendingLink && !ctx.subject`, so it runs ONLY on the
2973
+ * federated email-collision path and ONLY while the account is unproven.
2974
+ *
2975
+ * PASSWORD mode re-verifies the account's password via `UserService.login`
2976
+ * with the username bound server-side from `candidateUserId` (the user never
2977
+ * types it, so this can't be repurposed to sign into a different account).
2978
+ * OTP mode verifies a code delivered to the account's OWN confirmed channel.
2979
+ * A wrong proof re-pauses with a generic inline error; `cancel` abandons the
2980
+ * link. On success: `linkIdentity` (cross-user `ALREADY_EXISTS` guarded) →
2981
+ * account-state gate → set `ctx.subject` + `ctx.oauth` → seed channel state →
2982
+ * fall through to the shared login tail exactly like any other login.
2083
2983
  */
2084
- protected resolveAccept(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["accept"]> | Promise<NonNullable<InviteWfCtx["accept"]>>;
2984
+ proveControl(ctx: AuthWfCtx): Promise<undefined>;
2085
2985
  /**
2086
- * Resolve the cancellation policy (whether `auth.cancelInvite` is allowed).
2087
- * Override to disable hard-delete per-tenant. Sync/async friendly.
2986
+ * login.flow `wfid = '<controller-prefix>/auth/login/flow'` once wired.
2987
+ * `@Public()` on the body because the wf adapter dispatches the flow body
2988
+ * on every `start()` / `resume()` call (anonymous login).
2088
2989
  */
2089
- protected resolveCancellation(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["cancellation"]> | Promise<NonNullable<InviteWfCtx["cancellation"]>>;
2990
+ loginFlow(): void;
2090
2991
  /**
2091
- * Resolve the audit policy (whether invite.* audit events fire). Override
2092
- * to route audit-log emission per-tenant. Sync/async friendly.
2992
+ * invite.start admin-phase + anonymous magic-link accept-tail. Admin
2993
+ * steps are arbac-evaluated (no `@Public()` on them); accept-tail steps are
2994
+ * all `@Public()` (anonymous resume). The body itself is `@Public()` so the
2995
+ * wf adapter can dispatch start/resume on anonymous magic-link clicks.
2093
2996
  */
2094
- protected resolveAudit(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["audit"]> | Promise<NonNullable<InviteWfCtx["audit"]>>;
2997
+ inviteFlow(): void;
2095
2998
  /**
2096
- * Resolve the MFA-issuer policy (TOTP provisioning issuer string rendered
2097
- * in the authenticator app). Default tracks `this.authOpts.totpIssuer` —
2098
- * customers override the resolver for per-tenant issuers. Pincode timers/
2099
- * length live on `AuthOpts.mfa`. Sync/async friendly.
2999
+ * recovery.flow OTP-via-email reset. `@Public()` on the body because
3000
+ * anonymous users start recovery.
2100
3001
  */
2101
- protected resolveMfa(_ctx: InviteWfCtx): NonNullable<InviteWfCtx["mfa"]> | Promise<NonNullable<InviteWfCtx["mfa"]>>;
2102
- prepareAdminForm(ctx: InviteWfCtx): undefined | Promise<undefined>;
2103
- prepareSend(ctx: InviteWfCtx): undefined | Promise<undefined>;
2104
- prepareAccept(ctx: InviteWfCtx): undefined | Promise<undefined>;
2105
- prepareCancellation(ctx: InviteWfCtx): undefined | Promise<undefined>;
2106
- prepareAudit(ctx: InviteWfCtx): undefined | Promise<undefined>;
2107
- prepareMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
3002
+ recoveryFlow(): void;
2108
3003
  /**
2109
- * Populate `ctx.pendingConsents` with the customer-defined general-consent
2110
- * descriptors (terms, marketing, jurisdiction, ...) the invitee still needs
2111
- * to accept. Phase 4 transport only — nothing reads `ctx.pendingConsents`
2112
- * yet; Phase 5 will migrate the carrier `SetPasswordForm` from the
2113
- * `WithInlineConsentForm` static-checkbox mixin onto this dynamic array.
3004
+ * change-password.flow authenticated self-service "change my password".
2114
3005
  *
2115
- * Username MUST be bound before we fetch consents schema places this step
2116
- * AFTER `check-pending-invitation` (which sets `ctx.username` from the
2117
- * pending-invite row) inside the `linkSent` accept-tail subflow, so the
2118
- * `if (!ctx.username)` guard is belt-and-brace. `@Public()` is required
2119
- * because this step fires on the anonymous magic-link resume side of the
2120
- * workflow.
2121
- */
2122
- prepareConsents(ctx: InviteWfCtx): undefined | Promise<undefined>;
2123
- inviteFlow(): void;
2124
- reInviteFlow(): void;
2125
- cancelInviteFlow(): void;
2126
- init(ctx: InviteWfCtx): undefined | Promise<undefined>;
2127
- prepareAvailableRoles(ctx: InviteWfCtx): Promise<undefined>;
2128
- selectSendMode(ctx: InviteWfCtx): unknown;
2129
- adminInviteForm(ctx: InviteWfCtx): Promise<unknown>;
2130
- inferRolesStep(ctx: InviteWfCtx): Promise<undefined>;
2131
- /**
2132
- * Build the extras dict that `create-user` merges into the new user
2133
- * row. Calls `prepareUser({email, firstName, lastName, roles, invitedBy})`
2134
- * and writes the result onto `ctx.userExtras`. Split out of the old
2135
- * `invitePreCreateUser` step so consumers can inject e.g. a
2136
- * tenant-validation step between extras-build and create-user without
2137
- * copying the createUser body.
2138
- */
2139
- buildUserExtras(ctx: InviteWfCtx): Promise<undefined>;
2140
- /**
2141
- * Create the user row from `ctx.userExtras` (plus the admin-supplied
2142
- * `ctx.roles`), translate store-level CONFLICT into HTTP 409, then stamp
2143
- * `pendingInvitation = true` via a deep-merge update so the
2144
- * `createUser`-applied account defaults (`active: false`, `locked: false`)
2145
- * survive. Split out of the old `invitePreCreateUser` step so consumers can
2146
- * override extras-build (`build-user-extras`) without touching the
2147
- * store-write transaction.
2148
- */
2149
- createUserStep(ctx: InviteWfCtx): Promise<undefined>;
2150
- sendInviteEmail(ctx: InviteWfCtx): unknown;
2151
- returnShareableLink(ctx: InviteWfCtx): unknown;
2152
- loadPendingUser(ctx: InviteWfCtx): Promise<unknown>;
2153
- checkPendingInvitation(ctx: InviteWfCtx): Promise<undefined>;
2154
- idempotentRedirect(ctx: InviteWfCtx): undefined | Promise<undefined>;
2155
- preparePasswordRules(ctx: InviteWfCtx): undefined | Promise<undefined>;
2156
- createPasswordForm(ctx: InviteWfCtx): Promise<unknown>;
2157
- /**
2158
- * Build the `MfaEnrollDeps` payload shared by all three invite enrollment
2159
- * step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
2160
- * `EnrollPickMethodForm` can hide the `skip` action unless mode is
2161
- * `'optional'`). Omits `onComplete` because invite's enrollment while-loop
2162
- * is gated on `!enrollDone` directlyno mirror needed.
2163
- */
2164
- private buildInviteEnrollDeps;
2165
- /**
2166
- * Prepare MFA enrolment setup: writes `ctx.mfaMode`,
2167
- * `ctx.availableMfaTransports`, and pre-picks `ctx.enrollMethod` when only
2168
- * one transport is available. Override to compute any of the three from
2169
- * tenant policy / invitee role / request context in a single hook. Return
2170
- * type allows a sync override (skip the promise round-trip) when no async
2171
- * work is needed.
2172
- */
2173
- inviteSetupMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
2174
- /**
2175
- * Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
2176
- * transport, otherwise pauses for the picker form. When TOTP is picked, the
2177
- * secret is provisioned in the same step body (see `enrollPickPhase`).
2178
- */
2179
- inviteEnrollPickMethod(ctx: InviteWfCtx): undefined | Promise<undefined>;
2180
- /**
2181
- * Forced MFA enrollment — Phase 2 (collect sms/email address + send
2182
- * pincode). Gated out for totp by the schema condition.
2183
- */
2184
- inviteEnrollAddress(ctx: InviteWfCtx): undefined | Promise<undefined>;
2185
- /**
2186
- * Forced MFA enrollment — Phase 3 (verify code + activate method). Sets
2187
- * `enrollDone` on success, which the schema's enrollment while-loop reads
2188
- * as the exit signal directly.
2189
- */
2190
- inviteEnrollConfirm(ctx: InviteWfCtx): undefined | Promise<undefined>;
2191
- collectProfile(ctx: InviteWfCtx): Promise<unknown>;
2192
- applyProfileStep(ctx: InviteWfCtx): Promise<undefined>;
2193
- /**
2194
- * Consumer extension point — override in your subclass to inject extra
2195
- * accept-tail logic (input pauses, alt actions, persistence). Default:
2196
- * no-op. Runs AFTER profile collection, BEFORE activation. Signature is
2197
- * intentionally arg-less; read ctx + form input via composables
2198
- * (`useWfState`, `useAtscriptWf`) in the override body.
2199
- */
2200
- inviteExtraStep(): unknown;
2201
- /**
2202
- * Batched consent persistence — delegates to
2203
- * `AuthWorkflowBase.runPersistConsents`. See that helper for the full
2204
- * audit-friendly-default / idempotency / silent-drop contract.
2205
- */
2206
- persistConsentsStep(ctx: InviteWfCtx): Promise<undefined>;
2207
- unsetPendingInvitation(ctx: InviteWfCtx): Promise<undefined>;
2208
- activateUser(ctx: InviteWfCtx): Promise<undefined>;
2209
- confirmation(ctx: InviteWfCtx): undefined | Promise<undefined>;
2210
- freshLoginFinish(ctx: InviteWfCtx): undefined | Promise<undefined>;
2211
- autoLoginFinish(ctx: InviteWfCtx): Promise<undefined>;
2212
- cancelInvite(ctx: InviteWfCtx): Promise<unknown>;
2213
- private abort;
2214
- private loadUserOrNull;
2215
- private emitAudit;
2216
- }
2217
- //#endregion
2218
- //#region src/workflows/default-workflows.d.ts
2219
- declare class DefaultLoginWorkflow extends LoginWorkflow {
2220
- constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2221
- }
2222
- declare class DefaultInviteWorkflow extends InviteWorkflow {
2223
- constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
2224
- }
2225
- declare class DefaultRecoveryWorkflow extends RecoveryWorkflow {
2226
- constructor(users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
3006
+ * `@Public()` on the body lets the wf adapter dispatch start/resume (mirrors
3007
+ * the other flows); the FLOW itself is NOT public — `init-change-password` is
3008
+ * arbac-gated (`auth:change-password`) and binds `ctx.subject` from the
3009
+ * session, so an unauthenticated / unauthorized caller is rejected at the
3010
+ * first step. Customers forbid the feature (e.g. SSO-only orgs) by denying
3011
+ * the `change-password` action — there is no on/off opts flag.
3012
+ *
3013
+ * NOT in `DEFAULT_AUTH_WORKFLOWS` — it must be reached via a GUARDED trigger
3014
+ * route (see `AuthController.changePassword`), never the public
3015
+ * `/auth/trigger`.
3016
+ */
3017
+ changePasswordFlow(): void;
3018
+ /**
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
3025
+ * (`AuthController.addMfa`), never the public `/auth/trigger`.
3026
+ *
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).
3048
+ */
3049
+ addMfaFlow(): void;
3050
+ /**
3051
+ * signup.flow verify-first self-signup. `@Public()` on the body (anonymous).
3052
+ * NOT arbac-gated at the flow level: the `resolveSignupPolicy().allowSignup`
3053
+ * gate is the on/off switch (default OFF invite-only is the safe default).
3054
+ * Reachable via the public `/auth/trigger` (add `auth/signup/flow` to the
3055
+ * controller's `DEFAULT_AUTH_WORKFLOWS`).
3056
+ *
3057
+ * Shape = recovery's email→OTP front + invite's create→set-password→activate→
3058
+ * auto-login tail, so it reuses `pincodeSendCheckPair`, `passwordPhaseSchema`,
3059
+ * `prepare-consents` + `consentsPersistTailSchema`, `activate-user`, and
3060
+ * `finalize-auto-login` verbatim. The account-existence check is deferred to
3061
+ * `signup-create-user` (POST-OTP) so account existence never leaks on the
3062
+ * wire — every email gets an identical OTP pause regardless of whether it is
3063
+ * already registered.
3064
+ */
3065
+ signupFlow(): void;
2227
3066
  }
2228
3067
  //#endregion
2229
3068
  //#region src/workflows/auth-email-outlet.d.ts
@@ -2240,12 +3079,31 @@ interface AuthEmailOutletDeps {
2240
3079
  */
2241
3080
  declare function createAuthEmailOutlet(deps: AuthEmailOutletDeps): WfOutlet;
2242
3081
  //#endregion
2243
- //#region src/workflows/auth-shareable-link-outlet.d.ts
2244
- interface AuthShareableLinkOutletDeps {
2245
- buildMagicLinkUrl: BuildMagicLinkUrl$1;
2246
- /** Fallback TTL when the workflow context omits `expiresAtMs`. */
2247
- magicLinkTtlMs: (kind: AuthEmailKind$1) => number;
3082
+ //#region src/audit/index.d.ts
3083
+ /**
3084
+ * Audit event emitter — used by `AuthWorkflow`'s audit-login step (and
3085
+ * future recovery / invite audit steps) to fan out login.success and similar
3086
+ * events to consumer-supplied sinks (DB table, log file, Kafka topic).
3087
+ *
3088
+ * Aoothjs ships no concrete sink. Workflow subclasses override the
3089
+ * `audit(event)` protected method to wire their preferred sink; when not
3090
+ * overridden the workflow's default implementation is a no-op.
3091
+ */
3092
+ interface AuditEvent {
3093
+ kind: string;
3094
+ /** Auth-scoped user identity (the `username` resolved by the workflow). */
3095
+ userId?: string;
3096
+ /** Workflow id that emitted the event (e.g. `auth/login/flow`). */
3097
+ workflow?: string;
3098
+ /** Source IP (when the workflow could resolve one). */
3099
+ ip?: string;
3100
+ /** User-agent header. */
3101
+ userAgent?: string;
3102
+ /** Free-form payload — `method`, `tenantId`, etc. */
3103
+ [key: string]: unknown;
3104
+ }
3105
+ interface AuditEmitter {
3106
+ emit(event: AuditEvent): Promise<void> | void;
2248
3107
  }
2249
- declare function createAuthShareableLinkOutlet(deps: AuthShareableLinkOutletDeps): WfOutlet;
2250
3108
  //#endregion
2251
- export { type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, AuthOpts, type AuthRefreshBody, type AuthShareableLinkOutletDeps, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type ConcurrencyLimitOptions, type ConsentDescriptor, type ConsentEvent, ConsentStore, DEFAULT_AUTH_WORKFLOWS, DefaultInviteWorkflow, DefaultLoginWorkflow, DefaultRecoveryWorkflow, type DeliverEmail, type DeliverPayload, type DeliverSms, type DuplicateAction, type EmailSender, type InvitePolicyOverrides, type InvitePrepareUserInput, type InviteSendMode, type InviteWfCtx, InviteWorkflow, type InviteWorkflowOpts, type IssueResult, type LoginPolicyOverrides, type LoginRedirect, type LoginWfCtx, LoginWorkflow, type LoginWorkflowOpts, type MfaSummary, type MfaTransport, type PreparedUserInput, Public, type RecoveryDeliveryMode, type RecoveryOtpTransport, type RecoveryPolicyOverrides, type RecoveryWfCtx, RecoveryWorkflow, type RecoveryWorkflowOpts, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedInviteWorkflowOpts, type ResolvedLoginWorkflowOpts, type ResolvedRecoveryWorkflowOpts, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, authGuardInterceptor, createAuthEmailOutlet, createAuthShareableLinkOutlet, generateMagicLinkToken, getAuthMate, mergeInviteOpts, mergeLoginOpts, mergeRecoveryOpts, parseInviteRoles, useAuth };
3109
+ export { ADD_MFA_WORKFLOW, AUTH_CODE_STORE_TOKEN, type AuditEmitter, type AuditEvent, type AuthBindings, type AuthContext, AuthController, type AuthDeliveryPayload, type AuthEmailEvent, type AuthEmailKind, type AuthEmailOutletDeps, AuthGuarded, type AuthLoginResponse, type AuthLogoutBody, type AuthOkResponse, type AuthOptions, type AuthRefreshBody, type AuthSmsEvent, type AuthSmsKind, type AuthWfCompletionState, type AuthWfConsentsState, type AuthWfCtx, type AuthWfMfaEnrollState, type AuthWfOAuthState, type AuthWfPasswordUiState, type AuthWfPincodeUiState, AuthWorkflow, type AuthWorkflowOpts, AuthorizeController, AuthorizeRuntime, type BuildMagicLinkUrl, CHANGE_PASSWORD_WORKFLOW, CLIENT_REDIRECT_POLICY_TOKEN, type ConcurrencyLimitOptions, type ConnectedAccount, type ConsentDescriptor, type ConsentDescriptorLike, type ConsentEvent, ConsentStore, DEFAULT_AUTH_WORKFLOWS, type EmailSender, type EnrichedSession, FEDERATED_IDENTITY_STORE_TOKEN, type IssueResult, type LoginRedirect, type MfaSummary, type MfaTransport, OAUTH_CSRF_COOKIE, OAuthController, OAuthRuntime, PENDING_AUTHORIZATION_STORE_TOKEN, Public, RESERVED_USER_KEYS, type ResolvedAuthCookieConfig, type ResolvedAuthOptions, type ResolvedAuthWorkflowOpts, type SessionEnricher, SessionEnricherProvider, type SessionInfo, SessionsController, type SmsSender, type SsoProvider, type TAuthMeta, UserId, WfTrigger, type WfTriggerOpts, WfTriggerProvider, authGuardInterceptor, buildInviteAlreadyAcceptedEnvelope, createAuthEmailOutlet, deriveWfStateSecret, generateMagicLinkToken, getAuthMate, isSafeRelativeRedirect, oauthCsrfCookieAttrs, parseInviteRoles, resolveOAuthRedirect, stripReservedUserKeys, useAuth };