@aooth/auth-moost 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atscript/index.d.mts +2 -2
- package/dist/atscript/index.mjs +2 -2
- package/dist/forms-Bkr7ECKu.mjs +375 -0
- package/dist/index.d.mts +2173 -1656
- package/dist/index.mjs +4350 -3659
- package/package.json +32 -25
- package/src/atscript/models/forms.as +474 -273
- package/src/atscript/models/forms.as.d.ts +87 -120
- package/dist/forms-sF41Fzzn.mjs +0 -380
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,
|
|
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, 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, 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/
|
|
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
|
|
439
|
-
* next prompt boundary
|
|
440
|
-
*
|
|
441
|
-
*
|
|
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
|
|
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.
|
|
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. `
|
|
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<
|
|
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:
|
|
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/
|
|
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
|
|
647
|
-
* `
|
|
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 `
|
|
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
|
|
677
|
-
* `
|
|
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
|
-
* `
|
|
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
|
|
717
|
-
* (email, SMS, ...), or override `handle()` for per-request dispatch
|
|
538
|
+
* and token wire. Consumers subclass to swap the durable state strategy, add
|
|
539
|
+
* outlets (email, SMS, ...), or override `handle()` for per-request dispatch.
|
|
718
540
|
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
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.
|
|
547
|
+
*
|
|
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,1475 +564,2138 @@ 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
|
|
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/
|
|
626
|
+
//#region src/oauth/oauth.controller.d.ts
|
|
745
627
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
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
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
+
/**
|
|
841
|
+
* The authorization-server endpoints (AUTH-SERVER.md Tier 1). Turns the existing
|
|
842
|
+
* login workflow into an OAuth authorization server for the app's OWN clients —
|
|
843
|
+
* a local CLI on a loopback redirect today, a registered first-party service
|
|
844
|
+
* (Tier 2) later. One authorization-code + PKCE flow; the only thing that varies
|
|
845
|
+
* is the injected {@link ClientRedirectPolicy}.
|
|
846
|
+
*
|
|
847
|
+
* - `GET /auth/authorize` — validate the client + `redirect_uri` (the policy),
|
|
848
|
+
* record a {@link PendingAuthorizationStore} entry, and 302 the browser to the
|
|
849
|
+
* login page carrying the opaque `handle`. The login workflow authenticates
|
|
850
|
+
* the human and its `mint-authz-code` terminal delivers the code to the client
|
|
851
|
+
* — this controller never runs the login itself.
|
|
852
|
+
* - `POST /auth/token` — the back-channel: consume the single-use code, verify
|
|
853
|
+
* PKCE, and `AuthCredential.issue(userId, tokenPolicy)`. The token is minted
|
|
854
|
+
* HERE, off the browser, so nothing long-lived ever rides a redirect URL.
|
|
855
|
+
*
|
|
856
|
+
* Both routes are `@Public()` (anonymous). The grant's authority is fixed at
|
|
857
|
+
* `/authorize` time (the policy's {@link TokenPolicy} is recorded on the pending
|
|
858
|
+
* authorization and copied onto the issued code), never inferred at `/token`.
|
|
859
|
+
*/
|
|
860
|
+
declare class AuthorizeController {
|
|
861
|
+
protected readonly auth: AuthCredential;
|
|
862
|
+
protected readonly policy: ClientRedirectPolicy;
|
|
863
|
+
protected readonly pending: PendingAuthorizationStore;
|
|
864
|
+
protected readonly codes: AuthCodeStore;
|
|
865
|
+
constructor(auth: AuthCredential, policy: ClientRedirectPolicy, pending: PendingAuthorizationStore, codes: AuthCodeStore);
|
|
866
|
+
/**
|
|
867
|
+
* The SPA login route the authorize request bounces to. The opaque pending-auth
|
|
868
|
+
* `handle` is appended as `?authz=`; the SPA forwards it into the login
|
|
869
|
+
* workflow's START input so `init-login` raises `ctx.authz`. Override for a
|
|
870
|
+
* custom login path.
|
|
871
|
+
*/
|
|
872
|
+
protected loginPath(): string;
|
|
873
|
+
authorize(responseType: string | undefined, redirectUri: string | undefined, clientId: string | undefined, state: string | undefined, codeChallenge: string | undefined, codeChallengeMethod: string | undefined, scope: string | undefined): Promise<string>;
|
|
874
|
+
token(body: {
|
|
875
|
+
grant_type?: string;
|
|
876
|
+
code?: string;
|
|
877
|
+
code_verifier?: string;
|
|
878
|
+
client_id?: string;
|
|
879
|
+
} | undefined): Promise<{
|
|
880
|
+
access_token: string;
|
|
881
|
+
token_type: "Bearer";
|
|
882
|
+
expires_in: number;
|
|
883
|
+
userId: string;
|
|
884
|
+
} | TokenError>;
|
|
885
|
+
/** Fail soft: 302 the validated client redirect with an `?error=` (+ echoed `state`). */
|
|
886
|
+
protected redirectError(redirectUri: string, error: string, state: string | undefined): string;
|
|
887
|
+
}
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/authz/authorize-runtime.d.ts
|
|
890
|
+
/**
|
|
891
|
+
* DI holder bundling the two abstract authorization-server stores the login-wf
|
|
892
|
+
* terminal (`mint-authz-code`) needs. A `@Step` body cannot `@Inject` a string
|
|
893
|
+
* token, so it resolves THIS `@Injectable` via
|
|
894
|
+
* `useControllerContext().instantiate(AuthorizeRuntime)` — instantiating it
|
|
895
|
+
* resolves its constructor deps THROUGH the provide-registry (the same path that
|
|
896
|
+
* injects `AuthCredential` & friends), keeping `AuthWorkflow`'s documented ctor
|
|
897
|
+
* untouched. Mirrors `OAuthRuntime`.
|
|
898
|
+
*/
|
|
899
|
+
declare class AuthorizeRuntime {
|
|
900
|
+
readonly pending: PendingAuthorizationStore;
|
|
901
|
+
readonly codes: AuthCodeStore;
|
|
902
|
+
constructor(pending: PendingAuthorizationStore, codes: AuthCodeStore);
|
|
769
903
|
}
|
|
770
904
|
//#endregion
|
|
771
|
-
//#region src/
|
|
905
|
+
//#region src/authz/authz-tokens.d.ts
|
|
906
|
+
/**
|
|
907
|
+
* Explicit string DI tokens for the ABSTRACT authorization-server stores
|
|
908
|
+
* ({@link import("@aooth/auth/authz").PendingAuthorizationStore},
|
|
909
|
+
* {@link import("@aooth/auth/authz").AuthCodeStore}) — the framework-agnostic
|
|
910
|
+
* abstracts live in `@aooth/auth/authz`; these moost-DI binding strings stay in
|
|
911
|
+
* the integration layer (the same split as `FEDERATED_IDENTITY_STORE_TOKEN`).
|
|
912
|
+
*
|
|
913
|
+
* Both are abstract classes. moost's constructor injection keys the
|
|
914
|
+
* provide-registry by the design:paramtype class reference — fine for a CONCRETE
|
|
915
|
+
* provided class, but for an abstract paramtype infact falls back to auto-
|
|
916
|
+
* instantiating the body-less abstract class, yielding an object whose methods
|
|
917
|
+
* are missing. Binding under an explicit string token + `@Inject(<token>)`
|
|
918
|
+
* sidesteps the class-reference path (the same pattern as
|
|
919
|
+
* `FEDERATED_IDENTITY_STORE_TOKEN`).
|
|
920
|
+
*
|
|
921
|
+
* Consumers provide the concrete instance under the exact string:
|
|
922
|
+
*
|
|
923
|
+
* ```ts
|
|
924
|
+
* createProvideRegistry(
|
|
925
|
+
* [PENDING_AUTHORIZATION_STORE_TOKEN, () => new PendingAuthorizationStoreMemory()],
|
|
926
|
+
* [AUTH_CODE_STORE_TOKEN, () => new AuthCodeStoreMemory()],
|
|
927
|
+
* )
|
|
928
|
+
* ```
|
|
929
|
+
*/
|
|
930
|
+
declare const PENDING_AUTHORIZATION_STORE_TOKEN = "aooth:PendingAuthorizationStore";
|
|
931
|
+
declare const AUTH_CODE_STORE_TOKEN = "aooth:AuthCodeStore";
|
|
932
|
+
/**
|
|
933
|
+
* DI token for the {@link import("@aooth/auth/authz").ClientRedirectPolicy} — an
|
|
934
|
+
* interface, so it has no class reference to inject by. Provide the concrete
|
|
935
|
+
* policy (e.g. `new LoopbackClientPolicy()`) under this string.
|
|
936
|
+
*/
|
|
937
|
+
declare const CLIENT_REDIRECT_POLICY_TOKEN = "aooth:ClientRedirectPolicy";
|
|
938
|
+
//#endregion
|
|
939
|
+
//#region src/workflow/auth-workflow.ctx.d.ts
|
|
940
|
+
type MfaTransport = "sms" | "email" | "totp";
|
|
941
|
+
/** Per-user MFA method summary surfaced to forms via `@wf.context.pass 'mfa'`. */
|
|
942
|
+
interface MfaSummary {
|
|
943
|
+
kind: "sms" | "email" | "totp";
|
|
944
|
+
methodName: string;
|
|
945
|
+
masked: string;
|
|
946
|
+
isDefault: boolean;
|
|
947
|
+
}
|
|
772
948
|
type LoginRedirect = "referer" | "home" | false | null;
|
|
773
949
|
interface SsoProvider {
|
|
950
|
+
/** Provider id — matches an `OAuthProviderRegistry` entry; sent as `ssoProvider` data on the `sso` action. */
|
|
774
951
|
id: string;
|
|
952
|
+
/** Provider display name — the bundled `AsSsoProviders` renders "Continue with {label}". */
|
|
775
953
|
label: string;
|
|
776
|
-
|
|
954
|
+
/** Optional icon hint for the button (e.g. an icon-class key the renderer maps). */
|
|
955
|
+
icon?: string;
|
|
777
956
|
}
|
|
778
957
|
interface ConcurrencyLimitOptions {
|
|
779
958
|
max: number;
|
|
780
959
|
onLimit: "reject" | "kickPrompt";
|
|
781
960
|
}
|
|
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
961
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* `this.opts.<group>.<flag>` directly without optional chaining.
|
|
962
|
+
* Consent descriptor wire shape — mirrors `ConsentDescriptor` from the
|
|
963
|
+
* consent store without importing the runtime module (avoids cycles).
|
|
816
964
|
*/
|
|
817
|
-
interface
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
965
|
+
interface ConsentDescriptorLike {
|
|
966
|
+
id: string;
|
|
967
|
+
text: string;
|
|
968
|
+
required?: string;
|
|
969
|
+
version?: string;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Consents — both server state (`accepted` / `decidedAt`) and the
|
|
973
|
+
* UI-visible descriptor list (`pending`). Shipped via
|
|
974
|
+
* `@wf.context.pass 'consents'`.
|
|
975
|
+
*/
|
|
976
|
+
interface AuthWfConsentsState {
|
|
977
|
+
pending?: ConsentDescriptorLike[];
|
|
978
|
+
accepted?: string[];
|
|
979
|
+
decidedAt?: number;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* UI hints for pincode entry — FORM-FACING via `@wf.context.pass 'pincode'`.
|
|
983
|
+
* All three flows (login MFA SMS/email, recovery OTP, invite MFA
|
|
984
|
+
* enrol-confirm) write here.
|
|
985
|
+
*
|
|
986
|
+
* `channelCooldowns` is the anti-ping-pong gate for the MFA-challenge loop:
|
|
987
|
+
* a single `resendAllowedAt` is cleared on every `useDifferentMethod` so the
|
|
988
|
+
* user's first attempt at the new channel isn't gated for the WRONG
|
|
989
|
+
* channel's reason, BUT the per-channel timestamps in `channelCooldowns`
|
|
990
|
+
* survive method-switching so ping-ponging (SMS → Email → SMS → …) cannot
|
|
991
|
+
* be used to bypass the per-channel rate limit. `pincode-send` enforces the
|
|
992
|
+
* per-channel gate before delivering; `select-2fa` enforces it BEFORE the
|
|
993
|
+
* send to surface a per-channel error string on the form.
|
|
994
|
+
*/
|
|
995
|
+
interface AuthWfPincodeUiState {
|
|
996
|
+
sentTo?: string;
|
|
997
|
+
codeLength?: number;
|
|
998
|
+
resendAllowedAt?: number;
|
|
999
|
+
channelCooldowns?: Partial<Record<MfaTransport, number>>;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* MFA enrolment running state. `pincodeCooldown` removed — enrol-confirm
|
|
1003
|
+
* uses the same `ctx.pincode.resendAllowedAt` slot as the challenge path.
|
|
1004
|
+
*/
|
|
1005
|
+
interface AuthWfMfaEnrollState {
|
|
1006
|
+
method?: MfaTransport;
|
|
1007
|
+
address?: string;
|
|
1008
|
+
secret?: string;
|
|
1009
|
+
uri?: string;
|
|
1010
|
+
availableTransports?: MfaTransport[];
|
|
1011
|
+
mode?: "required" | "optional";
|
|
1012
|
+
done?: boolean;
|
|
1013
|
+
/**
|
|
1014
|
+
* When set, `enroll-confirm` does NOT make the freshly-confirmed method the
|
|
1015
|
+
* user's default. Set by `init-add-mfa` (the standalone add-MFA flow) when the
|
|
1016
|
+
* user already has a default — adding a secondary factor must not silently
|
|
1017
|
+
* change which method is challenged first. Unset on the login/invite forced-
|
|
1018
|
+
* enrolment path (the user has no default yet), so the first method still
|
|
1019
|
+
* becomes the default there — behaviour-preserving.
|
|
1020
|
+
*/
|
|
1021
|
+
keepExistingDefault?: boolean;
|
|
1022
|
+
}
|
|
1023
|
+
/** FORM-FACING via `@wf.context.pass 'password'`. Read by `SetPasswordForm`. */
|
|
1024
|
+
interface AuthWfPasswordUiState {
|
|
1025
|
+
policies?: TransferablePolicy[];
|
|
1026
|
+
changeReason?: "initial" | "expired" | "reset";
|
|
1027
|
+
heading?: string;
|
|
1028
|
+
intro?: string;
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Completion outcome — carries data set by terminal steps. Step-completion
|
|
1032
|
+
* is encoded by the wf engine's cursor, NOT by ctx flags; only fields that
|
|
1033
|
+
* carry actual data (read downstream) live here.
|
|
1034
|
+
*/
|
|
1035
|
+
interface AuthWfCompletionState {
|
|
1036
|
+
redirectUrl?: string;
|
|
1037
|
+
}
|
|
1038
|
+
/** Unified MFA policy — replaces login's hardcoded defaults + invite's `{issuer}` resolver. */
|
|
1039
|
+
interface AuthWfMfaPolicy {
|
|
1040
|
+
mode: "required" | "optional" | "disabled";
|
|
1041
|
+
availableTransports: MfaTransport[];
|
|
1042
|
+
issuer: string;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* MFA verification state. Verification result migrated to `AuthWfOtpState`;
|
|
1046
|
+
* cooldown migrated to `AuthWfPincodeUiState`.
|
|
1047
|
+
*/
|
|
1048
|
+
interface AuthWfMfaState {
|
|
1049
|
+
enrolledMethods?: MfaSummary[];
|
|
1050
|
+
current?: MfaTransport;
|
|
1051
|
+
method?: "sms" | "email" | "totp";
|
|
1052
|
+
saveAsDefault?: boolean;
|
|
1053
|
+
ignoreDefault?: boolean;
|
|
1054
|
+
runsRemaining?: number;
|
|
1055
|
+
methodCount?: number;
|
|
1056
|
+
}
|
|
1057
|
+
/** Channel-onboarding state (login Phase 3). */
|
|
1058
|
+
interface AuthWfChannelState {
|
|
1059
|
+
emailConfirmed?: boolean;
|
|
1060
|
+
phone?: string;
|
|
1061
|
+
phoneConfirmed?: boolean;
|
|
1062
|
+
otpDisclosure?: string;
|
|
1063
|
+
}
|
|
1064
|
+
/** Device-trust state (login). */
|
|
1065
|
+
interface AuthWfTrustState {
|
|
1066
|
+
deviceTrustToken?: string;
|
|
1067
|
+
newDevice?: boolean;
|
|
1068
|
+
rememberDevice?: boolean;
|
|
1069
|
+
optIn?: boolean;
|
|
1070
|
+
}
|
|
1071
|
+
/** Session-policy state (login). */
|
|
1072
|
+
interface AuthWfSessionState {
|
|
1073
|
+
riskStepUpReason?: string;
|
|
1074
|
+
activeSessions?: number;
|
|
1075
|
+
riskStepUpEvaluated?: boolean;
|
|
1076
|
+
}
|
|
1077
|
+
/** Alt-credential mirror flags (login). */
|
|
1078
|
+
interface AuthWfAltActionsState {
|
|
1079
|
+
forgotPassword?: boolean;
|
|
1080
|
+
signup?: boolean;
|
|
1081
|
+
magicLink?: boolean;
|
|
1082
|
+
usedMagicLink?: boolean;
|
|
1083
|
+
/** SSO providers offered on the login form — each renders a `sso-<id>` button. */
|
|
1084
|
+
ssoProviders?: SsoProvider[];
|
|
1085
|
+
}
|
|
1086
|
+
/** Alternate-credential policy (login). */
|
|
1087
|
+
interface AuthWfAltCredsPolicy {
|
|
1088
|
+
forgotPassword: boolean;
|
|
1089
|
+
signup: boolean;
|
|
1090
|
+
magicLink: boolean;
|
|
1091
|
+
magicLinkSkipsMfa: boolean;
|
|
1092
|
+
ssoProviders: SsoProvider[];
|
|
1093
|
+
recoveryUrl: string;
|
|
1094
|
+
signupUrl: string;
|
|
1095
|
+
embedRecovery: boolean;
|
|
1096
|
+
}
|
|
1097
|
+
/** Device-trust policy (login). */
|
|
1098
|
+
interface AuthWfDeviceTrustPolicy {
|
|
1099
|
+
enabled: boolean;
|
|
1100
|
+
optIn: boolean;
|
|
1101
|
+
skipsMfa: boolean;
|
|
1102
|
+
}
|
|
1103
|
+
/** Channel-enrolment policy (login). */
|
|
1104
|
+
interface AuthWfEnrollmentPolicy {
|
|
1105
|
+
ensureEmail: boolean;
|
|
1106
|
+
ensurePhone: boolean;
|
|
1107
|
+
}
|
|
1108
|
+
/** Finalize policy (login). `auditLogin` REMOVED — audit moved to interceptors. */
|
|
1109
|
+
interface AuthWfFinalizePolicy {
|
|
1110
|
+
notifyNewDevice: boolean;
|
|
1111
|
+
redirect: LoginRedirect;
|
|
1112
|
+
}
|
|
1113
|
+
/** Login-time guards policy. */
|
|
1114
|
+
interface AuthWfGuardsPolicy {
|
|
1115
|
+
passwordInitial: boolean;
|
|
1116
|
+
passwordExpiry: boolean;
|
|
1117
|
+
emailVerifiedRequired: boolean;
|
|
1118
|
+
}
|
|
1119
|
+
/** Session-policy (login). */
|
|
1120
|
+
interface AuthWfSessionPolicy {
|
|
1121
|
+
concurrencyLimit?: ConcurrencyLimitOptions;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Authenticated change-password policy (change-password.flow). Resolved by
|
|
1125
|
+
* `resolveChangePasswordPolicy`, written to `ctx.changePassword` by
|
|
1126
|
+
* `prepare-change-password`. The flow's whole purpose is gated on this slot's
|
|
1127
|
+
* presence (it's also the per-flow discriminator — see the package CLAUDE.md
|
|
1128
|
+
* flow-discrimination table).
|
|
1129
|
+
*
|
|
1130
|
+
* Primary protection is current-password re-entry (enforced by
|
|
1131
|
+
* `UserService.changePassword`), NOT rate limiting — so `rateLimit` is
|
|
1132
|
+
* optional and off by default.
|
|
1133
|
+
*
|
|
1134
|
+
* - `revokeOtherSessions` — on success, revoke every session for the user then
|
|
1135
|
+
* re-issue the acting session a fresh token (OWASP Session Management: kill
|
|
1136
|
+
* ghost sessions after a credential change). Default `true`.
|
|
1137
|
+
* - `rateLimit.minIntervalMs` — minimum gap between successive password changes
|
|
1138
|
+
* (Okta "minimum password age"). Enforced against `password.lastChanged` with
|
|
1139
|
+
* ZERO extra storage. Omit to disable.
|
|
1140
|
+
*/
|
|
1141
|
+
interface AuthWfChangePasswordPolicy {
|
|
1142
|
+
revokeOtherSessions: boolean;
|
|
1143
|
+
rateLimit?: {
|
|
1144
|
+
minIntervalMs: number;
|
|
840
1145
|
};
|
|
841
1146
|
}
|
|
842
1147
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
1148
|
+
* Failed-login lockout posture. Picks HOW a tripped account lockout is lifted:
|
|
1149
|
+
* - `admin-only` — permanent lock; only an admin (UserService.unlockAccount)
|
|
1150
|
+
* lifts it. The recovery flow may still reset the password
|
|
1151
|
+
* but does NOT unlock.
|
|
1152
|
+
* - `self-service` — permanent lock; completing the recovery (password-reset)
|
|
1153
|
+
* flow lifts it (`unlock-account` step).
|
|
1154
|
+
* - `temporary` — timed lock; auto-expires after the configured duration
|
|
1155
|
+
* (UserService `lockout.duration`). Recovery does NOT
|
|
1156
|
+
* unlock early. This is the default (preserves prior behavior).
|
|
1157
|
+
*
|
|
1158
|
+
* The mode governs the lock DURATION the workflow asks UserService to apply on
|
|
1159
|
+
* the threshold trip (`temporary` → configured duration; the two permanent
|
|
1160
|
+
* modes → `0`) and whether recovery runs `unlock-account`. The attempt
|
|
1161
|
+
* THRESHOLD and the temporary DURATION themselves remain UserService config.
|
|
846
1162
|
*/
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1163
|
+
type AuthWfLockoutMode = "admin-only" | "self-service" | "temporary";
|
|
1164
|
+
/** Lockout policy (login + recovery). */
|
|
1165
|
+
interface AuthWfLockoutPolicy {
|
|
1166
|
+
mode: AuthWfLockoutMode;
|
|
1167
|
+
}
|
|
1168
|
+
/** Admin-form policy (invite admin phase). */
|
|
1169
|
+
interface AuthWfAdminFormPolicy {
|
|
1170
|
+
collectRoles: boolean;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Invite-accept state (merged policy + state). No `freshLoginRequired` —
|
|
1174
|
+
* the auto-login choice is the static `AuthWorkflowOpts.autoLoginOnInvite`.
|
|
1175
|
+
*/
|
|
1176
|
+
interface AuthWfAcceptState {
|
|
1177
|
+
alreadyAcceptedRedirectUrl?: string;
|
|
1178
|
+
loginUrl?: string;
|
|
1179
|
+
showConfirmation?: boolean;
|
|
1180
|
+
confirmationMessage?: string;
|
|
1181
|
+
alreadyAccepted?: boolean;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Recovery post-reset state. `freshLoginRequired` removed — the choice is
|
|
1185
|
+
* the static `AuthWorkflowOpts.autoLoginOnRecover`.
|
|
1186
|
+
*/
|
|
1187
|
+
interface AuthWfPostResetState {
|
|
1188
|
+
revokeAllSessions?: boolean;
|
|
1189
|
+
loginUrl?: string;
|
|
1190
|
+
}
|
|
1191
|
+
/** Recovery alt-actions policy. */
|
|
1192
|
+
interface AuthWfRecoveryAltActions {
|
|
1193
|
+
backToLogin: boolean;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* OTP verification flag — SERVER-ONLY (NOT form-facing). Set true by any
|
|
1197
|
+
* of: pincode-check, totp-check, enroll-confirm. Loop-exit signal.
|
|
1198
|
+
*/
|
|
1199
|
+
interface AuthWfOtpState {
|
|
1200
|
+
verified?: boolean;
|
|
1201
|
+
}
|
|
1202
|
+
/** Invite admin-side (Phase A) state. */
|
|
1203
|
+
interface AuthWfAdminState {
|
|
1204
|
+
availableRoles?: string[];
|
|
1205
|
+
roles?: string[];
|
|
1206
|
+
userExtras?: Record<string, unknown>;
|
|
1207
|
+
/**
|
|
1208
|
+
* Outlet-pause idempotency marker for `send-email`. Flipped to `true`
|
|
1209
|
+
* after the first dispatch so the invitee's magic-link resume — which
|
|
1210
|
+
* re-executes the step body — short-circuits instead of dispatching a
|
|
1211
|
+
* second email and re-pausing. See `sendInviteEmail` for the why.
|
|
1212
|
+
*/
|
|
1213
|
+
emailDispatched?: boolean;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Pre-fill payload surfaced to forms via `@wf.context.pass 'defaults'`.
|
|
1217
|
+
* Used by recovery's `request` step to seed the email input from a
|
|
1218
|
+
* `?username=` query param carried from login's `forgotPassword` alt-action.
|
|
1219
|
+
*/
|
|
1220
|
+
interface AuthWfDefaults {
|
|
1221
|
+
email?: string;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Federated-login (OAuth2 / OIDC) flow state. Populated by the `sso-callback`
|
|
1225
|
+
* @Step after a verified provider profile resolves to a user. The login flow
|
|
1226
|
+
* runs `sso-callback` (instead of `credentials`) when the start input carries
|
|
1227
|
+
* an OAuth callback (`ctx.idpInbound`); `ctx.oauth` set ⇒ this login run came
|
|
1228
|
+
* in through the federated leg (a discriminator usable by `resolveXxx` hooks,
|
|
1229
|
+
* alongside `ctx.accept` / `ctx.postReset` / `ctx.signup`).
|
|
1230
|
+
*
|
|
1231
|
+
* Carries NO secret material — the PKCE verifier / nonce / authorization `code`
|
|
1232
|
+
* are consumed inside `sso-callback`. The verifier + nonce are RE-DERIVED there
|
|
1233
|
+
* from the signed-state seed (HMAC, stateless — no server-side flow store),
|
|
1234
|
+
* never stored and never on ctx. Only the post-resolve audit/UX fields land here.
|
|
1235
|
+
*/
|
|
1236
|
+
interface AuthWfOAuthState {
|
|
1237
|
+
/** The provider id (`google`, `oidc:<issuer>`, …) the subject authenticated with. */
|
|
1238
|
+
provider: string;
|
|
1239
|
+
/**
|
|
1240
|
+
* The `FederatedLoginService.resolveUser` outcome that set `ctx.subject`.
|
|
1241
|
+
* `interactively-linked` is the `prove-control` completion of a `needs-link`
|
|
1242
|
+
* (the user proved control of an existing account, after which the verified
|
|
1243
|
+
* identity was attached) — recorded distinctly from a returning `linked` for
|
|
1244
|
+
* audit fidelity.
|
|
1245
|
+
*/
|
|
1246
|
+
outcome?: "linked" | "created" | "auto-linked" | "interactively-linked";
|
|
1247
|
+
/** `true` only for the `created` outcome (a brand-new federated account). */
|
|
1248
|
+
isNew?: boolean;
|
|
1249
|
+
/**
|
|
1250
|
+
* The validated post-login app redirect target carried across the OAuth
|
|
1251
|
+
* bounce (signed into `state`, re-validated against the allow-list in
|
|
1252
|
+
* `oauth-exchange`). Read by `resolveRedirect` so the `redirect` tail step
|
|
1253
|
+
* sends the SPA to the originating page. Same-origin relative path only.
|
|
1254
|
+
*/
|
|
1255
|
+
redirect?: string;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Pending interactive identity-link state — set by `sso-callback` when
|
|
1259
|
+
* `FederatedLoginService.resolveUser` returns `needs-link` (a verified
|
|
1260
|
+
* federated profile whose email matches an EXISTING local account under the
|
|
1261
|
+
* default `require-interactive-link` policy). The `prove-control` @Step reads
|
|
1262
|
+
* this to challenge the user for control of `candidateUserId`; on success it
|
|
1263
|
+
* calls `linkIdentity` and only THEN sets `ctx.subject`. Cleared
|
|
1264
|
+
* (`delete ctx.pendingLink`) once the link completes, the user cancels, or a
|
|
1265
|
+
* terminal failure fires.
|
|
1266
|
+
*
|
|
1267
|
+
* SECURITY: `candidateUserId` is UNTRUSTED until the proof passes — it must
|
|
1268
|
+
* NEVER be copied to `ctx.subject` before then, because `{ break: !ctx.subject }`
|
|
1269
|
+
* (the schema gate right after `sso-callback`) is what keeps an unproven user
|
|
1270
|
+
* out of the token-issuing tail. The `snapshot` has `profile.raw` stripped (raw
|
|
1271
|
+
* claims are never persisted — RFC §7). The OTP-proof code lives HERE, not on the
|
|
1272
|
+
* shared top-level `ctx.pin`, so it cannot collide with the post-link MFA loop's
|
|
1273
|
+
* own pincode in the same run.
|
|
1274
|
+
*/
|
|
1275
|
+
interface AuthWfPendingLinkState {
|
|
1276
|
+
/** The existing local account the verified identity attaches to once control is proven. UNTRUSTED until then. */
|
|
1277
|
+
candidateUserId: string;
|
|
1278
|
+
/** Provider id (`google`, `oidc:<issuer>`, …) of the verified identity awaiting link. */
|
|
1279
|
+
provider: string;
|
|
1280
|
+
/** The IdP subject (`sub`) of the verified identity awaiting link. */
|
|
1281
|
+
subject: string;
|
|
1282
|
+
/** Display snapshot stamped onto the federated row on link (`profile.raw` stripped). */
|
|
1283
|
+
snapshot?: FederatedProfileSnapshot;
|
|
1284
|
+
/** Validated post-link app redirect, carried from the signed `state`. Same-origin relative path. */
|
|
1285
|
+
redirect?: string;
|
|
1286
|
+
/** Proof channel: `password` (account has a real password) or `otp` (passwordless → code to the account's OWN confirmed channel). */
|
|
1287
|
+
mode: "password" | "otp";
|
|
1288
|
+
/** OTP mode only — the candidate's confirmed channel the proof code is delivered to. */
|
|
1289
|
+
otpChannel?: "email" | "sms";
|
|
1290
|
+
/** OTP mode only — flipped `true` after the first code dispatch so a re-pause doesn't re-send. */
|
|
1291
|
+
sent?: boolean;
|
|
1292
|
+
/** OTP mode only — epoch ms before which a `resend` is refused (same per-pincode cooldown the MFA loop uses). Armed on every dispatch. */
|
|
1293
|
+
resendAllowedAt?: number;
|
|
1294
|
+
/** Masked candidate identifier shown on the prove-control form ("an account for a***@x.com exists"). Safe to expose. */
|
|
1295
|
+
hint?: string;
|
|
1296
|
+
/** OTP mode only — masked delivery target for the "code sent to …" copy. Safe to expose. */
|
|
1297
|
+
sentTo?: string;
|
|
1298
|
+
pin?: string;
|
|
1299
|
+
pinExpire?: number;
|
|
1300
|
+
pinAttempts?: number;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Standalone add-MFA flow state (`auth/add-mfa/flow`). Populated by
|
|
1304
|
+
* `init-add-mfa`; its presence on ctx is the flow discriminator (§ per-flow
|
|
1305
|
+
* discrimination — mirrors `ctx.changePassword` / `ctx.signup`). The flow
|
|
1306
|
+
* REUSES the login/invite enrolment trio (`enroll-pick-method` /
|
|
1307
|
+
* `enroll-address` / `enroll-confirm`), driving it via `ctx.mfaPolicy`
|
|
1308
|
+
* narrowed to the un-enrolled transports — so a logged-in user adds exactly the
|
|
1309
|
+
* methods they don't already have (single remaining transport auto-picks).
|
|
1310
|
+
*/
|
|
1311
|
+
interface AuthWfAddMfaState {
|
|
1312
|
+
/**
|
|
1313
|
+
* Transports the user has NOT yet confirmed = resolved
|
|
1314
|
+
* `availableTransports` minus already-enrolled. The enrol picker offers
|
|
1315
|
+
* exactly these; an empty list means there is nothing to add and the flow
|
|
1316
|
+
* finishes with a benign "no methods available" terminal. `finish-add-mfa`
|
|
1317
|
+
* reads it to distinguish "nothing to add" from "user cancelled".
|
|
1318
|
+
*/
|
|
1319
|
+
candidates?: MfaTransport[];
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Self-signup flow state. Populated by `init-signup` (policy from
|
|
1323
|
+
* `resolveSignupPolicy`) and `signup-form` (the `submitted` marker). Its
|
|
1324
|
+
* presence on ctx is the signup-flow discriminator (§ per-flow discrimination):
|
|
1325
|
+
* `ctx.signup` set ⇒ `auth/signup/flow` is running (mirrors `ctx.accept` /
|
|
1326
|
+
* `ctx.postReset` / `ctx.changePassword` for the other flows).
|
|
1327
|
+
*/
|
|
1328
|
+
interface AuthWfSignupState {
|
|
1329
|
+
/** Resolved gate — `false` (the default) disables self-signup; `init-signup` emits a terminal "signups are disabled" finish. */
|
|
1330
|
+
allowSignup?: boolean;
|
|
1331
|
+
/** When `true`, the signup form collects a `username` distinct from the email; otherwise `username := email`. */
|
|
1332
|
+
collectUsername?: boolean;
|
|
1333
|
+
/** Set by `signup-form` once a valid email (+ optional username) is submitted — gates the OTP loop. */
|
|
1334
|
+
submitted?: boolean;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Public-facing context surface — the **only** group form schemas may read
|
|
1338
|
+
* from via `@wf.context.pass 'public'`. Every other top-level key
|
|
1339
|
+
* (`ctx.mfa`, `ctx.pincode`, `ctx.trust`, etc.) is server-only and must
|
|
1340
|
+
* never be whitelisted on a form.
|
|
1341
|
+
*
|
|
1342
|
+
* Population is centralized in `AuthWorkflow.populatePublic(ctx)` which is
|
|
1343
|
+
* invoked by the `throwPublic` helper immediately before any
|
|
1344
|
+
* `requireInput`-style pause. Adding a new FE-consumed field has three
|
|
1345
|
+
* touch points: add it to a subgroup here, copy it in `populatePublic`,
|
|
1346
|
+
* and reference it in the form schema as `ctx.public.<group>.<field>`.
|
|
1347
|
+
*
|
|
1348
|
+
* Subgroups mirror the internal `ctx.<group>` shape one-for-one but only
|
|
1349
|
+
* carry the subset of fields that forms actually read. Internal-only
|
|
1350
|
+
* fields (e.g., `pincode.channelCooldowns`, `mfa.saveAsDefault`,
|
|
1351
|
+
* `mfa.current`, `mfa.ignoreDefault`, `trust.deviceTrustToken`,
|
|
1352
|
+
* `channel.phone`, `mfaEnroll.address`, …) are deliberately omitted so
|
|
1353
|
+
* they cannot leak to the wire.
|
|
1354
|
+
*/
|
|
1355
|
+
interface AuthWfPublicState {
|
|
1356
|
+
/** Mirrors `ctx.consents` — pending descriptor list + decision marker. */
|
|
1357
|
+
consents?: {
|
|
1358
|
+
pending?: ConsentDescriptorLike[];
|
|
1359
|
+
decidedAt?: number;
|
|
867
1360
|
};
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1361
|
+
/** Mirrors `ctx.altActions` — which alt-action buttons render on login (incl. SSO providers). */
|
|
1362
|
+
altActions?: {
|
|
1363
|
+
forgotPassword?: boolean;
|
|
1364
|
+
signup?: boolean;
|
|
1365
|
+
magicLink?: boolean;
|
|
1366
|
+
ssoProviders?: SsoProvider[];
|
|
872
1367
|
};
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1368
|
+
/**
|
|
1369
|
+
* Mirrors `ctx.mfa` — only the fields forms read (method picker /
|
|
1370
|
+
* useDifferentMethod gating / transport-hint copy). Internal fields like
|
|
1371
|
+
* `saveAsDefault`, `ignoreDefault`, `current` stay on `ctx.mfa`.
|
|
1372
|
+
*/
|
|
1373
|
+
mfa?: {
|
|
1374
|
+
method?: MfaTransport;
|
|
1375
|
+
methodCount?: number;
|
|
1376
|
+
enrolledMethods?: MfaSummary[];
|
|
876
1377
|
};
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1378
|
+
/**
|
|
1379
|
+
* Mirrors `ctx.pincode` — masked recipient + code length + resend
|
|
1380
|
+
* timestamp. `channelCooldowns` (the per-channel anti-ping-pong ledger)
|
|
1381
|
+
* is deliberately omitted so the FE cannot see which other channels are
|
|
1382
|
+
* currently rate-limited.
|
|
1383
|
+
*/
|
|
1384
|
+
pincode?: {
|
|
1385
|
+
sentTo?: string;
|
|
1386
|
+
codeLength?: number;
|
|
1387
|
+
resendAllowedAt?: number;
|
|
881
1388
|
};
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
emailVerifiedRequired: boolean;
|
|
1389
|
+
/** Mirrors `ctx.trust.optIn` — gates the "Remember this device" checkbox. */
|
|
1390
|
+
trust?: {
|
|
1391
|
+
optIn?: boolean;
|
|
886
1392
|
};
|
|
887
|
-
|
|
888
|
-
|
|
1393
|
+
/**
|
|
1394
|
+
* Mirrors `ctx.password` — policy ruleset for the live-rules renderer
|
|
1395
|
+
* plus the per-flow title / intro copy. `changeReason` stays internal —
|
|
1396
|
+
* the user-facing copy is already pre-rendered into `heading`/`intro`.
|
|
1397
|
+
*/
|
|
1398
|
+
password?: {
|
|
1399
|
+
heading?: string;
|
|
1400
|
+
intro?: string;
|
|
1401
|
+
policies?: TransferablePolicy[];
|
|
889
1402
|
};
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1403
|
+
/** Mirrors `ctx.admin.availableRoles` — the role picker for invites. */
|
|
1404
|
+
admin?: {
|
|
1405
|
+
availableRoles?: string[];
|
|
893
1406
|
};
|
|
894
|
-
|
|
895
|
-
|
|
1407
|
+
/** Mirrors `ctx.channel.otpDisclosure` — TCPA/PECR disclosure paragraph. */
|
|
1408
|
+
channel?: {
|
|
1409
|
+
otpDisclosure?: string;
|
|
896
1410
|
};
|
|
897
|
-
username?: string;
|
|
898
|
-
/** Legacy alias for `pwReset`; kept until tests migrate. */
|
|
899
|
-
mfaRequired?: boolean;
|
|
900
|
-
isPasswordInitial?: boolean;
|
|
901
|
-
usedMagicLink?: boolean;
|
|
902
1411
|
/**
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
* - `'optional'` — MFA prompted; users with 0 methods see an enrollment
|
|
906
|
-
* form that offers a `skip` action (in-flight opt-out).
|
|
907
|
-
* - `'disabled'` — MFA loops never fire; Phase 4 is skipped entirely.
|
|
1412
|
+
* Mirrors `ctx.mfaEnroll` — only what the enrolment forms display.
|
|
1413
|
+
* `address` stays internal (user-typed, no need to bounce it back).
|
|
908
1414
|
*/
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1415
|
+
mfaEnroll?: {
|
|
1416
|
+
method?: MfaTransport;
|
|
1417
|
+
mode?: "required" | "optional";
|
|
1418
|
+
availableTransports?: MfaTransport[];
|
|
1419
|
+
secret?: string;
|
|
1420
|
+
uri?: string;
|
|
1421
|
+
};
|
|
1422
|
+
/** Mirrors `ctx.defaults` — prefill source for the recovery email field. */
|
|
1423
|
+
defaults?: {
|
|
1424
|
+
email?: string;
|
|
1425
|
+
};
|
|
917
1426
|
/**
|
|
918
|
-
*
|
|
919
|
-
* `
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1427
|
+
* Mirrors `ctx.pendingLink` display fields — the proof `mode`, the masked
|
|
1428
|
+
* account `hint` ("an account for a***@x.com exists"), the masked delivery
|
|
1429
|
+
* `sentTo` (OTP mode), and the `resendAllowedAt` cooldown the resend button
|
|
1430
|
+
* reads to disable/count-down. Only masked/UX fields are projected — the
|
|
1431
|
+
* `candidateUserId` / provider `subject` / proof `pin` stay server-only.
|
|
1432
|
+
*/
|
|
1433
|
+
proveControl?: {
|
|
1434
|
+
mode?: "password" | "otp";
|
|
1435
|
+
hint?: string;
|
|
1436
|
+
sentTo?: string;
|
|
1437
|
+
resendAllowedAt?: number;
|
|
1438
|
+
};
|
|
1439
|
+
/**
|
|
1440
|
+
* Mirrors `ctx.newPasswordRequired` — hides "Remember this device" on
|
|
1441
|
+
* verify forms when a forced password change will follow.
|
|
926
1442
|
*/
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
* Set true the first time the user picks `useBackupCode` on the MFA step,
|
|
935
|
-
* so the workflow remembers to route the subsequent `BackupCodeForm`
|
|
936
|
-
* submission (which carries no `action`) through `handleBackupCode` instead
|
|
937
|
-
* of falling through to `verifyMfa` / pincode-verify.
|
|
938
|
-
*/
|
|
939
|
-
usingBackupCode?: boolean;
|
|
940
|
-
/** Counter incremented by the `risk-step-up` step so MFA reruns for the extra factor. */
|
|
941
|
-
mfaRunsRemaining?: number;
|
|
942
|
-
/** Mirror of `mfaEnrolledMethods.length`. Passed to client forms via `@wf.context.pass` so action buttons (`useDifferentMethod`) can hide when only one method exists. */
|
|
943
|
-
mfaMethodCount?: number;
|
|
944
|
-
/** Mirror of `opts.mfa.backupCodes`. Passed to client forms so `useBackupCode` can hide when backup codes are disabled. */
|
|
945
|
-
mfaBackupCodes?: boolean;
|
|
946
|
-
altForgotPassword?: boolean;
|
|
947
|
-
altSignup?: boolean;
|
|
948
|
-
altMagicLink?: boolean;
|
|
949
|
-
enrollMethod?: MfaTransport;
|
|
950
|
-
enrollAddress?: string;
|
|
951
|
-
/** TOTP secret in flight (passed to UI via `@wf.context.pass` for QR rendering). */
|
|
952
|
-
enrollSecret?: string;
|
|
953
|
-
/** Provisioning URI for TOTP QR rendering. */
|
|
954
|
-
enrollUri?: string;
|
|
955
|
-
/** Mirror of `ctx.availableMfaTransports`, surfaced to `EnrollPickMethodForm` via `@wf.context.pass`. */
|
|
956
|
-
enrollAvailableTransports?: MfaTransport[];
|
|
957
|
-
/**
|
|
958
|
-
* Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
|
|
959
|
-
* `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
|
|
960
|
-
* hide unless mode is `'optional'`.
|
|
961
|
-
*/
|
|
962
|
-
enrollMode?: "required" | "optional";
|
|
963
|
-
/** Set true by `enrollConfirmPhase` (or `enrollPickPhase`/`enrollAddressPhase` on `skip` in `'optional'` mode); mirrored to `mfaChecked` via `buildLoginEnrollDeps` `onComplete`. */
|
|
964
|
-
enrollDone?: boolean;
|
|
965
|
-
/** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
|
|
966
|
-
enrollPincodeCooldown?: number;
|
|
1443
|
+
newPasswordRequired?: boolean;
|
|
1444
|
+
}
|
|
1445
|
+
/** Unified workflow context shape — one type for all three flows. */
|
|
1446
|
+
interface AuthWfCtx {
|
|
1447
|
+
subject?: string;
|
|
1448
|
+
email?: string;
|
|
1449
|
+
defaults?: AuthWfDefaults;
|
|
967
1450
|
pin?: string;
|
|
968
1451
|
pinExpire?: number;
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1452
|
+
/** 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). */
|
|
1453
|
+
pinAttempts?: number;
|
|
1454
|
+
aborted?: boolean;
|
|
1455
|
+
isFirstLogin?: boolean;
|
|
1456
|
+
newPasswordRequired?: boolean;
|
|
1457
|
+
autoLogin?: boolean;
|
|
1458
|
+
consents?: AuthWfConsentsState;
|
|
1459
|
+
pincode?: AuthWfPincodeUiState;
|
|
1460
|
+
mfaEnroll?: AuthWfMfaEnrollState;
|
|
1461
|
+
password?: AuthWfPasswordUiState;
|
|
1462
|
+
completion?: AuthWfCompletionState;
|
|
1463
|
+
alternateCredentials?: AuthWfAltCredsPolicy;
|
|
1464
|
+
deviceTrust?: AuthWfDeviceTrustPolicy;
|
|
1465
|
+
enrollment?: AuthWfEnrollmentPolicy;
|
|
1466
|
+
finalize?: AuthWfFinalizePolicy;
|
|
1467
|
+
guards?: AuthWfGuardsPolicy;
|
|
1468
|
+
lockout?: AuthWfLockoutPolicy;
|
|
1469
|
+
sessionPolicy?: AuthWfSessionPolicy;
|
|
1470
|
+
changePassword?: AuthWfChangePasswordPolicy;
|
|
1471
|
+
signup?: AuthWfSignupState;
|
|
1472
|
+
addMfa?: AuthWfAddMfaState;
|
|
1473
|
+
oauth?: AuthWfOAuthState;
|
|
1474
|
+
pendingLink?: AuthWfPendingLinkState;
|
|
1475
|
+
mfaPolicy?: AuthWfMfaPolicy;
|
|
1476
|
+
adminForm?: AuthWfAdminFormPolicy;
|
|
1477
|
+
accept?: AuthWfAcceptState;
|
|
1478
|
+
postReset?: AuthWfPostResetState;
|
|
1479
|
+
recoveryAltActions?: AuthWfRecoveryAltActions;
|
|
1480
|
+
mfa?: AuthWfMfaState;
|
|
1481
|
+
channel?: AuthWfChannelState;
|
|
1482
|
+
trust?: AuthWfTrustState;
|
|
1483
|
+
session?: AuthWfSessionState;
|
|
1484
|
+
altActions?: AuthWfAltActionsState;
|
|
1485
|
+
admin?: AuthWfAdminState;
|
|
1486
|
+
otp?: AuthWfOtpState;
|
|
1487
|
+
isPasswordInitial?: boolean;
|
|
1488
|
+
isPasswordExpired?: boolean;
|
|
1489
|
+
/**
|
|
1490
|
+
* Captured by `init-login` when the START input is an OAuth callback (signed
|
|
1491
|
+
* `state` present). Presence routes the login schema to `sso-callback` and
|
|
1492
|
+
* skips `credentials`; `sso-callback` reads the callback inputs from HERE (not
|
|
1493
|
+
* the step input) because the wf engine clears the step input after
|
|
1494
|
+
* `init-login` runs — before `sso-callback` (the next step) executes.
|
|
1495
|
+
*/
|
|
1496
|
+
idpInbound?: {
|
|
1497
|
+
code?: string;
|
|
1498
|
+
state: string;
|
|
1499
|
+
error?: string;
|
|
983
1500
|
};
|
|
984
|
-
deviceTrustToken?: string;
|
|
985
|
-
/** Set true at MFA gate when no trust cookie matched → trigger `notify-new-device`. */
|
|
986
|
-
newDevice?: boolean;
|
|
987
|
-
/** Captured from the OTP/pincode form when `opts.deviceTrust.optIn`. */
|
|
988
|
-
rememberDevice?: boolean;
|
|
989
|
-
/** 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). */
|
|
990
|
-
deviceTrustOptIn?: boolean;
|
|
991
|
-
/**
|
|
992
|
-
* Descriptors for the customer-defined general consents (terms, marketing,
|
|
993
|
-
* jurisdiction, ...) the user still needs to accept. Populated once by
|
|
994
|
-
* `prepare-consents` after username-bind. Phase 5 will migrate carrier
|
|
995
|
-
* forms to consume this array; Phase 4 populates transport only.
|
|
996
|
-
*/
|
|
997
|
-
pendingConsents?: ConsentDescriptor[];
|
|
998
|
-
/**
|
|
999
|
-
* Subset of `pendingConsents[].id` the user ticked on the carrier form —
|
|
1000
|
-
* set by `processInlineConsent` after silent-dropping unknown ids.
|
|
1001
|
-
* Consumed by `persist-consents` to compute `accepted: boolean` per
|
|
1002
|
-
* pending descriptor.
|
|
1003
|
-
*/
|
|
1004
|
-
acceptedConsentIds?: string[];
|
|
1005
|
-
/**
|
|
1006
|
-
* Wall-clock ms at the moment `processInlineConsent` resolved the
|
|
1007
|
-
* `consents` carrier-form submission (NOT at write-time — captured BEFORE
|
|
1008
|
-
* the batched `persist-consents` step so a paused-workflow resume gap
|
|
1009
|
-
* doesn't drift the timestamp). Also the schema-gate for the
|
|
1010
|
-
* `persist-consents` step — set ⇒ a carrier form has collected consents.
|
|
1011
|
-
*/
|
|
1012
|
-
consentsDecidedAt?: number;
|
|
1013
|
-
profileMissingFields?: string[];
|
|
1014
|
-
availableTenants?: Array<{
|
|
1015
|
-
id: string;
|
|
1016
|
-
name: string;
|
|
1017
|
-
}>;
|
|
1018
|
-
selectedTenantId?: string;
|
|
1019
|
-
availablePersonas?: Array<{
|
|
1020
|
-
id: string;
|
|
1021
|
-
label: string;
|
|
1022
|
-
}>;
|
|
1023
|
-
selectedPersonaId?: string;
|
|
1024
|
-
riskStepUpReason?: string;
|
|
1025
|
-
activeSessions?: number;
|
|
1026
|
-
passwordChanged?: boolean;
|
|
1027
|
-
profileApplied?: boolean;
|
|
1028
1501
|
/**
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1502
|
+
* Authorization-server marker (AUTH-SERVER.md §4.4). Set when this login was
|
|
1503
|
+
* started from `GET /auth/authorize` — `init-login` raises it from the START
|
|
1504
|
+
* input `authz` (the opaque pending-authorization handle), and `sso-callback`
|
|
1505
|
+
* re-raises it from the federated `state.handle` when the user took a
|
|
1506
|
+
* "Continue with <provider>" detour mid-authorize. Presence routes the login
|
|
1507
|
+
* tail to the `mint-authz-code` terminal (deliver an auth code to the client)
|
|
1508
|
+
* INSTEAD of `issue`/`redirect` — no browser session is minted.
|
|
1034
1509
|
*/
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1510
|
+
authz?: {
|
|
1511
|
+
handle: string;
|
|
1512
|
+
};
|
|
1038
1513
|
/**
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1514
|
+
* FE-facing surface — the ONLY top-level ctx key whitelisted on form
|
|
1515
|
+
* schemas. Populated by `AuthWorkflow.populatePublic(ctx)` at every pause
|
|
1516
|
+
* boundary; see `AuthWfPublicState` for the exact mirror shape. Never
|
|
1517
|
+
* write to other `ctx.<group>` slots from form schemas — they're internal.
|
|
1042
1518
|
*/
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1519
|
+
public?: AuthWfPublicState;
|
|
1520
|
+
}
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/workflow/auth-workflow.opts.d.ts
|
|
1523
|
+
interface AuthWorkflowOpts {
|
|
1524
|
+
autoLoginOnInvite?: boolean;
|
|
1525
|
+
autoLoginOnRecover?: boolean;
|
|
1526
|
+
/** Pincode infrastructure shared by login MFA, invite MFA, and recovery OTP. */
|
|
1527
|
+
mfa?: {
|
|
1528
|
+
pincodeLength?: number;
|
|
1529
|
+
pincodeTtlMs?: number;
|
|
1530
|
+
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. */
|
|
1531
|
+
pincodeMaxAttempts?: number;
|
|
1532
|
+
};
|
|
1533
|
+
/** 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`. */
|
|
1534
|
+
recoveryStateTtlMs?: number;
|
|
1535
|
+
/** Canonical login URL — used by invite (post-accept redirect) and recovery (abort-to-login + post-reset redirect) as the resolver-default loginUrl. */
|
|
1536
|
+
loginUrl?: string;
|
|
1537
|
+
/** TOTP provisioning issuer — used by login MFA and invite MFA enrollment. */
|
|
1538
|
+
totpIssuer?: string;
|
|
1539
|
+
deviceTrust?: {
|
|
1540
|
+
cookieName?: string;
|
|
1541
|
+
ttlMs?: number;
|
|
1542
|
+
bindsTo?: "cookie" | "cookie+ip";
|
|
1543
|
+
};
|
|
1544
|
+
forms?: {
|
|
1545
|
+
loginCredentials?: TAtscriptAnnotatedType;
|
|
1546
|
+
invite?: TAtscriptAnnotatedType;
|
|
1547
|
+
recoveryEmailIdentifier?: TAtscriptAnnotatedType;
|
|
1548
|
+
askEmail?: TAtscriptAnnotatedType;
|
|
1549
|
+
askPhone?: TAtscriptAnnotatedType;
|
|
1550
|
+
enrollPickMethod?: TAtscriptAnnotatedType;
|
|
1551
|
+
enrollAddress?: TAtscriptAnnotatedType;
|
|
1552
|
+
enrollConfirm?: TAtscriptAnnotatedType;
|
|
1553
|
+
select2fa?: TAtscriptAnnotatedType;
|
|
1554
|
+
mfaCode?: TAtscriptAnnotatedType;
|
|
1555
|
+
pincode?: TAtscriptAnnotatedType;
|
|
1556
|
+
setPassword?: TAtscriptAnnotatedType; /** Authenticated self-service "change my password" form (current + new + confirm). */
|
|
1557
|
+
changePassword?: TAtscriptAnnotatedType; /** Password-proof form rendered by `prove-control` when the matched account has a real password. */
|
|
1558
|
+
proveControl?: TAtscriptAnnotatedType; /** OTP-proof form rendered by `prove-control` when the matched account is passwordless. */
|
|
1559
|
+
proveControlOtp?: TAtscriptAnnotatedType;
|
|
1560
|
+
termsBump?: TAtscriptAnnotatedType;
|
|
1561
|
+
concurrencyLimit?: TAtscriptAnnotatedType;
|
|
1562
|
+
recoveryPincode?: TAtscriptAnnotatedType; /** Self-signup identifier form (`auth/signup/flow` entry pause). */
|
|
1563
|
+
signup?: TAtscriptAnnotatedType;
|
|
1564
|
+
};
|
|
1046
1565
|
}
|
|
1047
1566
|
/**
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* test harness and have each `resolveXxx` return its matching key (falling
|
|
1053
|
-
* back to `super.resolveXxx(ctx)` for unset groups).
|
|
1567
|
+
* Fully-resolved view used by the workflow at runtime — every nested group
|
|
1568
|
+
* is populated by the (future) `mergeAuthOpts` so step bodies can read
|
|
1569
|
+
* `this.opts.<group>.<field>` without optional chaining. The two
|
|
1570
|
+
* auto-login booleans become required after defaults are applied.
|
|
1054
1571
|
*/
|
|
1055
|
-
interface
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1572
|
+
interface ResolvedAuthWorkflowOpts {
|
|
1573
|
+
autoLoginOnInvite: boolean;
|
|
1574
|
+
autoLoginOnRecover: boolean;
|
|
1575
|
+
mfa: {
|
|
1576
|
+
pincodeLength: number;
|
|
1577
|
+
pincodeTtlMs: number;
|
|
1578
|
+
pincodeResendTimeoutMs: number;
|
|
1579
|
+
pincodeMaxAttempts: number;
|
|
1580
|
+
};
|
|
1581
|
+
recoveryStateTtlMs: number;
|
|
1582
|
+
loginUrl: string;
|
|
1583
|
+
totpIssuer: string;
|
|
1584
|
+
deviceTrust: {
|
|
1585
|
+
cookieName: string;
|
|
1586
|
+
ttlMs: number;
|
|
1587
|
+
bindsTo: "cookie" | "cookie+ip";
|
|
1588
|
+
};
|
|
1589
|
+
forms: {
|
|
1590
|
+
loginCredentials: TAtscriptAnnotatedType;
|
|
1591
|
+
invite: TAtscriptAnnotatedType;
|
|
1592
|
+
recoveryEmailIdentifier: TAtscriptAnnotatedType;
|
|
1593
|
+
askEmail: TAtscriptAnnotatedType;
|
|
1594
|
+
askPhone: TAtscriptAnnotatedType;
|
|
1595
|
+
enrollPickMethod: TAtscriptAnnotatedType;
|
|
1596
|
+
enrollAddress: TAtscriptAnnotatedType;
|
|
1597
|
+
enrollConfirm: TAtscriptAnnotatedType;
|
|
1598
|
+
select2fa: TAtscriptAnnotatedType;
|
|
1599
|
+
mfaCode: TAtscriptAnnotatedType;
|
|
1600
|
+
pincode: TAtscriptAnnotatedType;
|
|
1601
|
+
setPassword: TAtscriptAnnotatedType;
|
|
1602
|
+
changePassword: TAtscriptAnnotatedType;
|
|
1603
|
+
proveControl: TAtscriptAnnotatedType;
|
|
1604
|
+
proveControlOtp: TAtscriptAnnotatedType;
|
|
1605
|
+
termsBump: TAtscriptAnnotatedType;
|
|
1606
|
+
concurrencyLimit: TAtscriptAnnotatedType;
|
|
1607
|
+
recoveryPincode: TAtscriptAnnotatedType;
|
|
1608
|
+
signup: TAtscriptAnnotatedType;
|
|
1063
1609
|
};
|
|
1064
|
-
alternateCredentials?: NonNullable<LoginWfCtx["alternateCredentials"]>;
|
|
1065
|
-
deviceTrust?: NonNullable<LoginWfCtx["deviceTrust"]>;
|
|
1066
|
-
enrollment?: NonNullable<LoginWfCtx["enrollment"]>;
|
|
1067
|
-
finalize?: NonNullable<LoginWfCtx["finalize"]>;
|
|
1068
|
-
guards?: NonNullable<LoginWfCtx["guards"]>;
|
|
1069
|
-
mfaConfig?: NonNullable<LoginWfCtx["mfaConfig"]>;
|
|
1070
|
-
multiContext?: NonNullable<LoginWfCtx["multiContext"]>;
|
|
1071
|
-
sessionPolicy?: NonNullable<LoginWfCtx["sessionPolicy"]>;
|
|
1072
|
-
}
|
|
1073
|
-
interface MfaSummary {
|
|
1074
|
-
kind: "sms" | "email" | "totp";
|
|
1075
|
-
/** Underlying `MfaMethod.name` so the workflow can call into UserService. */
|
|
1076
|
-
methodName: string;
|
|
1077
|
-
masked: string;
|
|
1078
|
-
isDefault: boolean;
|
|
1079
1610
|
}
|
|
1611
|
+
//#endregion
|
|
1612
|
+
//#region src/workflow/auth-workflow.d.ts
|
|
1080
1613
|
/**
|
|
1081
|
-
* Unified payload
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1614
|
+
* Unified outbound-dispatch payload. Customers override `deliver(payload)` on
|
|
1615
|
+
* the `AuthWorkflow` subclass to route by `kind` (per-purpose templates) and
|
|
1616
|
+
* `channel` (email vs SMS). Replaces the prior workflow-specific deliver
|
|
1617
|
+
* payloads which carried slightly different field sets per call site.
|
|
1085
1618
|
*/
|
|
1086
|
-
|
|
1619
|
+
type AuthDeliveryPayload = {
|
|
1620
|
+
kind: "mfa-pincode";
|
|
1621
|
+
channel: "sms" | "email";
|
|
1622
|
+
recipient: string;
|
|
1623
|
+
code: string;
|
|
1624
|
+
expiresInMs: number;
|
|
1625
|
+
} | {
|
|
1626
|
+
kind: "recovery-pincode";
|
|
1087
1627
|
channel: "email";
|
|
1088
|
-
/** Template kind — discriminator the consumer uses to pick which email template to render. */
|
|
1089
|
-
kind: AuthEmailKind$1;
|
|
1090
1628
|
recipient: string;
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
expiresAt?: number;
|
|
1097
|
-
/** Associated user id, when known. */
|
|
1098
|
-
userId?: string;
|
|
1099
|
-
/** Extra context (e.g. `roles` for invite emails, IP/UA for notifyNewDevice). */
|
|
1100
|
-
metadata?: Record<string, unknown>;
|
|
1101
|
-
}
|
|
1102
|
-
interface DeliverSms {
|
|
1103
|
-
channel: "sms";
|
|
1104
|
-
kind: AuthSmsKind$1;
|
|
1629
|
+
code: string;
|
|
1630
|
+
expiresInMs: number;
|
|
1631
|
+
} | {
|
|
1632
|
+
kind: "signup-pincode";
|
|
1633
|
+
channel: "email";
|
|
1105
1634
|
recipient: string;
|
|
1106
|
-
/** SMS always carries a pincode — that's the only thing SMS gets used for in this lib. */
|
|
1107
1635
|
code: string;
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1636
|
+
expiresInMs: number;
|
|
1637
|
+
} | {
|
|
1638
|
+
kind: "enroll-pincode";
|
|
1639
|
+
channel: "sms" | "email";
|
|
1640
|
+
recipient: string;
|
|
1641
|
+
code: string;
|
|
1642
|
+
expiresInMs: number;
|
|
1643
|
+
} | {
|
|
1644
|
+
kind: "invite-link";
|
|
1645
|
+
channel: "email";
|
|
1646
|
+
recipient: string;
|
|
1647
|
+
url: string;
|
|
1648
|
+
expiresInMs: number;
|
|
1649
|
+
} | {
|
|
1650
|
+
kind: "new-device-notice";
|
|
1651
|
+
channel: "email";
|
|
1652
|
+
recipient: string;
|
|
1653
|
+
deviceLabel?: string;
|
|
1654
|
+
loginAt: number;
|
|
1655
|
+
};
|
|
1656
|
+
/**
|
|
1657
|
+
* Top-level `UserCredentials` keys that workflow-collected profile payloads
|
|
1658
|
+
* MUST NEVER carry through to persistence. The server sets these out-of-band
|
|
1659
|
+
* (admin-supplied `ctx.admin?.roles`, password-set step, account activation,
|
|
1660
|
+
* MFA enrolment elsewhere). If the consumer's `.as` profile form mistakenly
|
|
1661
|
+
* declares one — or an attacker submits one as an extra field — the strip
|
|
1662
|
+
* applied at the workflow step blocks shadowing.
|
|
1663
|
+
*/
|
|
1664
|
+
declare const RESERVED_USER_KEYS: ReadonlySet<string>;
|
|
1665
|
+
/**
|
|
1666
|
+
* Return a shallow copy of `profile` with `RESERVED_USER_KEYS` removed.
|
|
1667
|
+
* Does not mutate the input.
|
|
1668
|
+
*/
|
|
1669
|
+
declare function stripReservedUserKeys(profile: Record<string, unknown>): Record<string, unknown>;
|
|
1670
|
+
/** Trim + de-duplicate role identifiers submitted via the admin invite form. */
|
|
1671
|
+
declare function parseInviteRoles(input?: string[]): string[];
|
|
1672
|
+
/**
|
|
1673
|
+
* Single source of truth for the "this invite was already accepted" finish
|
|
1674
|
+
* envelope. Used by both `idempotent-redirect` (in-workflow) and by
|
|
1675
|
+
* `AuthController.invitePostRedemption` (side route reached when the wf
|
|
1676
|
+
* state store has evicted the finished row and re-resume hits 410).
|
|
1677
|
+
*
|
|
1678
|
+
* Secondary "Request a new invite" option is gated on
|
|
1679
|
+
* `alreadyAcceptedRedirectUrl` being non-empty — mirrors how the resolver
|
|
1680
|
+
* defaults it, but lets consumers blank it to suppress the secondary button.
|
|
1681
|
+
*/
|
|
1682
|
+
declare function buildInviteAlreadyAcceptedEnvelope(opts: {
|
|
1683
|
+
loginUrl: string;
|
|
1684
|
+
alreadyAcceptedRedirectUrl: string;
|
|
1685
|
+
}): FinishWfOpts;
|
|
1686
|
+
declare class AuthWorkflow {
|
|
1687
|
+
protected readonly opts: ResolvedAuthWorkflowOpts;
|
|
1114
1688
|
protected readonly users: UserService;
|
|
1115
1689
|
protected readonly auth: AuthCredential;
|
|
1116
|
-
protected readonly authOpts: AuthOpts;
|
|
1117
1690
|
protected readonly consentStore: ConsentStore;
|
|
1118
|
-
constructor(opts:
|
|
1691
|
+
constructor(opts: Partial<AuthWorkflowOpts>, users: UserService, auth: AuthCredential, consentStore: ConsentStore);
|
|
1119
1692
|
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1693
|
+
* Unified outbound dispatch hook for direct synchronous deliveries
|
|
1694
|
+
* (MFA / recovery / enrollment pincodes, new-device notices). NOT used for
|
|
1695
|
+
* resume-token flows — the invite magic link is emitted via the wf engine's
|
|
1696
|
+
* `outletEmail()` primitive (pause-and-resume), since the resume URL is
|
|
1697
|
+
* minted by the engine AFTER the step yields and is not knowable here.
|
|
1698
|
+
* Default is a no-op — customer overrides wire concrete senders. Stays
|
|
1699
|
+
* sync-friendly: the default `void` preserves the engine's sync fast path.
|
|
1124
1700
|
*/
|
|
1125
|
-
protected deliver(_payload:
|
|
1701
|
+
protected deliver(_payload: AuthDeliveryPayload): void | Promise<void>;
|
|
1126
1702
|
/**
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1703
|
+
* Return the list of selectable role identifiers for the admin invite form.
|
|
1704
|
+
* Mirrors the prior `InviteWorkflow.getAvailableRoles()` consumer hook —
|
|
1705
|
+
* `undefined` (default) means no whitelist is enforced. Read by
|
|
1706
|
+
* `prepareAvailableRoles`.
|
|
1129
1707
|
*/
|
|
1130
|
-
protected
|
|
1708
|
+
protected getAvailableRoles(): Promise<string[] | undefined> | string[] | undefined;
|
|
1131
1709
|
/**
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1710
|
+
* Build the extras dict merged into the freshly-created user row. Runs for
|
|
1711
|
+
* EVERY new-account path: password-signup and invite-accept merge it at
|
|
1712
|
+
* `createUser` time (the `create-user` step), and a first-time federated
|
|
1713
|
+
* login applies it from `sso-callback` (post-create `users.update`). Default:
|
|
1714
|
+
* `{}`. Override to populate e.g. a required `tenantId` from request context.
|
|
1715
|
+
*
|
|
1716
|
+
* `email` is optional: a federated profile can carry no email, so overrides
|
|
1717
|
+
* must tolerate `email === undefined`.
|
|
1136
1718
|
*/
|
|
1137
|
-
protected
|
|
1719
|
+
protected prepareUser(_input: {
|
|
1720
|
+
email?: string;
|
|
1721
|
+
roles: string[];
|
|
1722
|
+
invitedBy?: string;
|
|
1723
|
+
}): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
1138
1724
|
/**
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
* record belongs to (passed alongside since `TrustedDeviceRecord` itself
|
|
1143
|
-
* carries no user identifier).
|
|
1725
|
+
* Derive roles server-side from the admin-form payload (e.g. AD lookup).
|
|
1726
|
+
* Result is set-unioned with admin-supplied roles by `infer-roles`.
|
|
1727
|
+
* Default: `[]`.
|
|
1144
1728
|
*/
|
|
1145
|
-
protected
|
|
1729
|
+
protected inferAdminRoles(_input: {
|
|
1730
|
+
email: string;
|
|
1731
|
+
}): Promise<string[]> | string[];
|
|
1146
1732
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
* everywhere" flows for symmetry with `storeTrustedDevice`.
|
|
1733
|
+
* Override the structural duplicate rule for `admin-form`. Default: any
|
|
1734
|
+
* existing row → `'reject'`; nothing → `'allow'`. Multi-tenant apps that
|
|
1735
|
+
* allow re-inviting the same email into a different tenant override.
|
|
1151
1736
|
*/
|
|
1152
|
-
protected
|
|
1737
|
+
protected duplicateInviteCheck(input: {
|
|
1738
|
+
email: string;
|
|
1739
|
+
existingUser: UserCredentials | null;
|
|
1740
|
+
}): Promise<"allow" | "reject"> | "allow" | "reject";
|
|
1153
1741
|
/**
|
|
1154
|
-
*
|
|
1155
|
-
* `
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*/
|
|
1160
|
-
protected
|
|
1161
|
-
/**
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
* `
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
* the
|
|
1742
|
+
* Implements the "log out other sessions" branch of `sessionPolicy.concurrencyLimit`.
|
|
1743
|
+
* Default revokes every existing session via `auth.revokeAllForUser` — which is
|
|
1744
|
+
* mandatory on every store (stateless ones use a per-user epoch sentinel), so the
|
|
1745
|
+
* kick works without an override. Runs BEFORE `issue`, so the session about to be
|
|
1746
|
+
* minted survives. Override to scope the revoke (e.g. keep the current device).
|
|
1747
|
+
*/
|
|
1748
|
+
protected logoutOtherSessions(username: string): Promise<void>;
|
|
1749
|
+
/**
|
|
1750
|
+
* Return the number of active (non-revoked, non-expired) sessions for the user,
|
|
1751
|
+
* used by the concurrency-limit gate. Default delegates to `auth.listForUser`,
|
|
1752
|
+
* which counts access-kind credentials and returns `[]` for stateless stores
|
|
1753
|
+
* (no round-trip) — so the count is real when the store can enumerate and `0`
|
|
1754
|
+
* (gate disabled) when it can't. Only consulted when `resolveSessionPolicy`
|
|
1755
|
+
* declared a `concurrencyLimit`. Override for a custom session source.
|
|
1756
|
+
*/
|
|
1757
|
+
protected loadActiveSessionsCount(username: string): Promise<number>;
|
|
1758
|
+
/**
|
|
1759
|
+
* Resolves the post-login redirect URL. Default reads `finalize.redirect`:
|
|
1760
|
+
* `false` / `null` → no redirect; `'home'` → `/`; `'referer'` → request
|
|
1761
|
+
* `Referer` header (undefined when absent).
|
|
1762
|
+
*/
|
|
1763
|
+
protected resolveRedirect(ctx: AuthWfCtx): string | undefined;
|
|
1764
|
+
protected oauthRuntime(): Promise<OAuthRuntime>;
|
|
1765
|
+
/**
|
|
1766
|
+
* Resolve the {@link AuthorizeRuntime} (the pending-authorization + auth-code
|
|
1767
|
+
* stores) for the `mint-authz-code` terminal — same `instantiate` path as
|
|
1768
|
+
* {@link oauthRuntime}, reached ONLY when a login was started from
|
|
1769
|
+
* `/auth/authorize` (`ctx.authz` set). Override in a unit test to inject fakes.
|
|
1770
|
+
*/
|
|
1771
|
+
protected authorizeRuntime(): Promise<AuthorizeRuntime>;
|
|
1772
|
+
/**
|
|
1773
|
+
* Redirect target for a federated-login FAILURE terminal — provider denial,
|
|
1774
|
+
* invalid/expired state, CSRF mismatch, missing transaction, exchange
|
|
1775
|
+
* failure, `denied` / `needs-link` resolution, or a locked/inactive account.
|
|
1776
|
+
* Benign + generic: it MUST NOT reveal WHICH check tripped (no
|
|
1777
|
+
* tamper-vs-expiry oracle — see invariant #5). Default: the login URL with a
|
|
1778
|
+
* single generic `?error=oauth`. Override to route to a dedicated SPA error
|
|
1779
|
+
* page (still without leaking the reason).
|
|
1780
|
+
*/
|
|
1781
|
+
protected resolveOAuthErrorRedirect(_ctx: AuthWfCtx, _reason: string): string;
|
|
1782
|
+
/**
|
|
1783
|
+
* Leg 1 of federated login: turn an `sso-<id>` click on the login form into a
|
|
1784
|
+
* redirect to the provider, then END the login wf. STATELESS — no flow store:
|
|
1785
|
+
* a fresh non-secret `seed` is minted, the PKCE verifier + OIDC nonce are
|
|
1786
|
+
* DERIVED from it (`registry.deriveSeededPkce`), the `challenge`/`nonce` build
|
|
1787
|
+
* the authorize URL, and the seed rides in BOTH the signed `state` and a Lax
|
|
1788
|
+
* double-submit CSRF cookie. The callback re-derives the identical verifier
|
|
1789
|
+
* from `state.random` to redeem the `code` (see `sso-callback`) — so nothing
|
|
1790
|
+
* secret is ever in the URL and no server-side transaction is persisted.
|
|
1172
1791
|
*
|
|
1173
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1792
|
+
* The CSRF cookie is attached to the FINISH ENVELOPE's `cookies` (which the
|
|
1793
|
+
* wf-trigger outlet writes onto the real HTTP response), NOT via
|
|
1794
|
+
* `useResponse().setCookie` — the outlet builds its response from the
|
|
1795
|
+
* `WfFinished` envelope and ignores response-context cookies. Same mechanism
|
|
1796
|
+
* `issue` uses for the session cookie. The resume is a same-origin XHR, so the
|
|
1797
|
+
* `Set-Cookie` is stored before `AsWfForm` follows the redirect.
|
|
1176
1798
|
*/
|
|
1177
|
-
protected
|
|
1178
|
-
required: boolean;
|
|
1179
|
-
} | Promise<{
|
|
1180
|
-
required: boolean;
|
|
1181
|
-
}>;
|
|
1799
|
+
protected beginSso(providerId: string, authzHandle?: string): Promise<void>;
|
|
1182
1800
|
/**
|
|
1183
1801
|
* Resolve the alternate-credentials policy (forgot-password / signup /
|
|
1184
|
-
* magic-link / SSO providers
|
|
1185
|
-
* tenant. Sync default; async overrides supported.
|
|
1186
|
-
*/
|
|
1187
|
-
protected resolveAlternateCredentials(_ctx: LoginWfCtx): NonNullable<LoginWfCtx["alternateCredentials"]> | Promise<NonNullable<LoginWfCtx["alternateCredentials"]>>;
|
|
1188
|
-
/**
|
|
1189
|
-
* Resolve the device-trust policy (enabled / opt-in / skipsMfa). Infrastructure
|
|
1190
|
-
* (cookieName / ttlMs / bindsTo) still lives on `this.opts.deviceTrust` since
|
|
1191
|
-
* those are app-wide constants, not per-request policy. Sync/async friendly.
|
|
1802
|
+
* magic-link / SSO providers). Reached from login.flow.
|
|
1192
1803
|
*/
|
|
1193
|
-
protected
|
|
1804
|
+
protected resolveAlternateCredentials(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["alternateCredentials"]> | Promise<NonNullable<AuthWfCtx["alternateCredentials"]>>;
|
|
1194
1805
|
/**
|
|
1195
|
-
* Resolve the
|
|
1196
|
-
*
|
|
1806
|
+
* Resolve the device-trust policy. Infrastructure (cookieName / ttlMs /
|
|
1807
|
+
* bindsTo) lives on `this.opts.deviceTrust`. Reached from login.flow.
|
|
1197
1808
|
*/
|
|
1198
|
-
protected
|
|
1809
|
+
protected resolveDeviceTrust(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["deviceTrust"]> | Promise<NonNullable<AuthWfCtx["deviceTrust"]>>;
|
|
1199
1810
|
/**
|
|
1200
|
-
* Resolve the
|
|
1201
|
-
*
|
|
1202
|
-
* per-app redirect targets. Sync/async friendly.
|
|
1811
|
+
* Resolve the channel-enrolment policy (ensureEmail / ensurePhone).
|
|
1812
|
+
* Reached from login.flow.
|
|
1203
1813
|
*/
|
|
1204
|
-
protected
|
|
1814
|
+
protected resolveEnrollment(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["enrollment"]> | Promise<NonNullable<AuthWfCtx["enrollment"]>>;
|
|
1205
1815
|
/**
|
|
1206
|
-
* Resolve the
|
|
1207
|
-
*
|
|
1208
|
-
* post-credentials gates. Sync/async friendly.
|
|
1816
|
+
* Resolve the finalize policy. Reached from login.flow. `auditLogin` is
|
|
1817
|
+
* dropped from the shape per §2 — audit moved out of the workflow layer.
|
|
1209
1818
|
*/
|
|
1210
|
-
protected
|
|
1819
|
+
protected resolveFinalize(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["finalize"]> | Promise<NonNullable<AuthWfCtx["finalize"]>>;
|
|
1211
1820
|
/**
|
|
1212
|
-
* Resolve the
|
|
1213
|
-
*
|
|
1214
|
-
* disable backup-code redemption per tenant. Sync/async friendly.
|
|
1821
|
+
* Resolve the login-time guards policy (passwordInitial / passwordExpiry /
|
|
1822
|
+
* emailVerifiedRequired). Reached from login.flow.
|
|
1215
1823
|
*/
|
|
1216
|
-
protected
|
|
1824
|
+
protected resolveGuards(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["guards"]> | Promise<NonNullable<AuthWfCtx["guards"]>>;
|
|
1217
1825
|
/**
|
|
1218
|
-
* Resolve the
|
|
1219
|
-
* Override to require a tenant/persona pick when the user has more than one.
|
|
1220
|
-
* Sync/async friendly.
|
|
1826
|
+
* Resolve the session-policy (concurrency limit). Reached from login.flow.
|
|
1221
1827
|
*/
|
|
1222
|
-
protected
|
|
1828
|
+
protected resolveSessionPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["sessionPolicy"]> | Promise<NonNullable<AuthWfCtx["sessionPolicy"]>>;
|
|
1223
1829
|
/**
|
|
1224
|
-
* Resolve the
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
*
|
|
1230
|
-
* mirrored onto `ctx.otpDisclosure`, transported to the SPA via
|
|
1231
|
-
* `@wf.context.pass`, and forwarded to
|
|
1232
|
-
* `consentStore.recordOtpChannelConsent` at `verify/:channel` AFTER the
|
|
1233
|
-
* pincode validates AND the channel is confirmed as an MFA method — the
|
|
1234
|
-
* persisted audit record pins BOTH the literal disclosure copy the user
|
|
1235
|
-
* saw AND the verified target as a separate field.
|
|
1830
|
+
* Resolve the authenticated change-password policy. Reached from
|
|
1831
|
+
* change-password.flow only. Default revokes the user's other sessions on a
|
|
1832
|
+
* successful change (OWASP Session Management) and applies NO rate limit —
|
|
1833
|
+
* current-password re-entry (enforced by `UserService.changePassword`) is the
|
|
1834
|
+
* primary protection, not throttling. Customers override to add a min-interval
|
|
1835
|
+
* (`rateLimit.minIntervalMs`) or to keep other sessions alive.
|
|
1236
1836
|
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*/
|
|
1241
|
-
protected
|
|
1242
|
-
/**
|
|
1243
|
-
* Resolve the
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1277
|
-
*
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1837
|
+
* Whether the flow may be STARTED at all is governed by arbac on the trigger
|
|
1838
|
+
* route (deny the `change-password` action to forbid it for SSO-only orgs) —
|
|
1839
|
+
* there is deliberately no on/off flag here.
|
|
1840
|
+
*/
|
|
1841
|
+
protected resolveChangePasswordPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["changePassword"]> | Promise<NonNullable<AuthWfCtx["changePassword"]>>;
|
|
1842
|
+
/**
|
|
1843
|
+
* Resolve the self-signup policy. Reached from signup.flow's `init-signup`.
|
|
1844
|
+
* Default `allowSignup: false` — invite-only is the safe default (mirrors
|
|
1845
|
+
* `resolveAlternateCredentials().signup`); a deployment that wants open
|
|
1846
|
+
* self-serve overrides this to `true` (and flips the login form's `signup`
|
|
1847
|
+
* alt-action on via `resolveAlternateCredentials`). `collectUsername: false`
|
|
1848
|
+
* means `username := email`; override + replace `opts.forms.signup` to
|
|
1849
|
+
* collect a distinct username. There is intentionally no rate-limit field
|
|
1850
|
+
* here yet — override the `signup-form` step (or front it with a captcha /
|
|
1851
|
+
* IP gate) for abuse control; the OTP resend cooldown already bounds repeat
|
|
1852
|
+
* sends per run.
|
|
1853
|
+
*/
|
|
1854
|
+
protected resolveSignupPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["signup"]> | Promise<NonNullable<AuthWfCtx["signup"]>>;
|
|
1855
|
+
/**
|
|
1856
|
+
* Resolve the failed-login lockout posture (admin-only / self-service /
|
|
1857
|
+
* temporary — see `AuthWfLockoutMode`). Reached from login.flow (decides the
|
|
1858
|
+
* lock duration passed to `users.login` / `users.verifyMfa` on a threshold
|
|
1859
|
+
* trip) and recovery.flow (decides whether `unlock-account` runs after a
|
|
1860
|
+
* reset). Default `temporary` preserves the prior auto-expiry behavior.
|
|
1861
|
+
* Customers override per-tenant / per-user (e.g. force `admin-only` for
|
|
1862
|
+
* privileged accounts).
|
|
1863
|
+
*/
|
|
1864
|
+
protected resolveLockout(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["lockout"]> | Promise<NonNullable<AuthWfCtx["lockout"]>>;
|
|
1865
|
+
/**
|
|
1866
|
+
* Resolve the unified MFA policy. Replaces login's hardcoded defaults +
|
|
1867
|
+
* invite's `{ issuer }` resolver. Issuer is sourced from
|
|
1868
|
+
* `this.opts.totpIssuer` so per-app TOTP labels remain a single knob.
|
|
1869
|
+
* Reached from login.flow + invite.start.
|
|
1870
|
+
*/
|
|
1871
|
+
protected resolveMfaPolicy(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["mfaPolicy"]> | Promise<NonNullable<AuthWfCtx["mfaPolicy"]>>;
|
|
1872
|
+
/**
|
|
1873
|
+
* Resolve the channel-OTP disclosure copy rendered beneath the email/phone
|
|
1874
|
+
* input on `AskEmailForm` / `AskPhoneForm`. Reached from login.flow Phase 3.
|
|
1875
|
+
* Default returns a TCPA / PECR / CASL / GDPR-safe English paragraph that is
|
|
1876
|
+
* GENERIC per channel (no target templated in — the user hasn't submitted
|
|
1877
|
+
* yet at ask-time).
|
|
1878
|
+
*/
|
|
1879
|
+
protected resolveOtpDisclosure(_ctx: AuthWfCtx, channel: "email" | "phone"): string | Promise<string>;
|
|
1880
|
+
/**
|
|
1881
|
+
* Resolve whether to require an additional MFA round (risk step-up).
|
|
1882
|
+
* Default never requires an extra factor.
|
|
1883
|
+
*/
|
|
1884
|
+
protected resolveRiskStepUp(_ctx: AuthWfCtx): Promise<{
|
|
1885
|
+
require: boolean;
|
|
1886
|
+
reason?: string;
|
|
1887
|
+
}>;
|
|
1888
|
+
/**
|
|
1889
|
+
* Resolve the recovery URL targeted by the `forgotPassword` alt-action on
|
|
1890
|
+
* login's credentials form. Receives whatever the user typed into the
|
|
1891
|
+
* username field so the recovery page can pre-fill it.
|
|
1287
1892
|
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
init(_ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1293
|
-
/**
|
|
1294
|
-
* Prepare MFA setup: writes `ctx.mfaMode`, `ctx.availableMfaTransports`, and
|
|
1295
|
-
* (when the user is resolvable) pre-selects `ctx.currentMfa` from the
|
|
1296
|
-
* existing-user `defaultMethod` or the single-available-transport auto-pick.
|
|
1297
|
-
* Override to compute any of the three from tenant policy / user attrs /
|
|
1298
|
-
* request context. Return type allows a sync override (skip the promise
|
|
1299
|
-
* round-trip) when no async work is needed — the default body is async only
|
|
1300
|
-
* because of the `users.getUser` lookup for `currentMfa`.
|
|
1301
|
-
*/
|
|
1302
|
-
prepareMfaSetup(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1303
|
-
credentials(ctx: LoginWfCtx): Promise<unknown>;
|
|
1304
|
-
private handleCredentialsAlt;
|
|
1893
|
+
* Sync return type only — the caller (`credentials` @Step alt-action
|
|
1894
|
+
* handler) uses the URL inline.
|
|
1895
|
+
*/
|
|
1896
|
+
protected resolveRecoveryUrl(username: string | undefined, alt: AuthWfAltCredsPolicy): string;
|
|
1305
1897
|
/**
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
* Sync return type only — the caller (`credentials` step's alt-action
|
|
1311
|
-
* handler) uses the URL inline; consumers needing async URL construction
|
|
1312
|
-
* should override the `credentials` @Step instead. The resolved
|
|
1313
|
-
* `alternateCredentials` policy is supplied by the caller so the base impl
|
|
1314
|
-
* doesn't have to re-call `resolveAlternateCredentials`.
|
|
1315
|
-
*/
|
|
1316
|
-
protected resolveRecoveryUrl(username: string | undefined, alt: NonNullable<LoginWfCtx["alternateCredentials"]>): string;
|
|
1317
|
-
magicLinkRequest(): void | Promise<void>;
|
|
1318
|
-
magicLinkSend(): void | Promise<void>;
|
|
1319
|
-
magicLinkVerified(): void | Promise<void>;
|
|
1320
|
-
passkey(): void | Promise<void>;
|
|
1321
|
-
ssoCallback(): void | Promise<void>;
|
|
1322
|
-
ask(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
|
|
1323
|
-
verify(ctx: LoginWfCtx, channel: "email" | "phone"): Promise<unknown>;
|
|
1324
|
-
checkTrustedDevice(ctx: LoginWfCtx): Promise<undefined>;
|
|
1898
|
+
* Resolve the admin-form policy (whether to collect roles on the invite
|
|
1899
|
+
* admin form). Reached from invite.start admin phase.
|
|
1900
|
+
*/
|
|
1901
|
+
protected resolveAdminForm(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["adminForm"]> | Promise<NonNullable<AuthWfCtx["adminForm"]>>;
|
|
1325
1902
|
/**
|
|
1326
|
-
*
|
|
1327
|
-
*
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
* heuristics in `selectMfaMethod`.
|
|
1333
|
-
*/
|
|
1334
|
-
loadEnrolledMfaMethods(ctx: LoginWfCtx): Promise<undefined>;
|
|
1335
|
-
/**
|
|
1336
|
-
* Pick which MFA method to use from the already-loaded
|
|
1337
|
-
* `ctx.mfaEnrolledMethods` summary. Decision-only — no IO. Honors
|
|
1338
|
-
* `ctx.currentMfa` (pre-selected by `prepareMfaSetup` from the user's
|
|
1339
|
-
* `defaultMethod` or single-transport auto-pick), auto-picks when only one
|
|
1340
|
-
* method is enrolled, falls back to the `isDefault` method. All paths are
|
|
1341
|
-
* gated on `!ctx.ignoreMfaDefault` so the `useDifferentMethod` re-pick flow
|
|
1342
|
-
* (which sets the flag) skips straight to the `select2fa` picker. Split out
|
|
1343
|
-
* of the old `prepare-mfa-options` step so consumers can override selection
|
|
1344
|
-
* heuristics (e.g. risk-based per-tenant defaults) without re-implementing
|
|
1345
|
-
* the load/summary.
|
|
1346
|
-
*/
|
|
1347
|
-
selectMfaMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1348
|
-
select2fa(ctx: LoginWfCtx): Promise<unknown>;
|
|
1349
|
-
pincodeSendLogin(ctx: LoginWfCtx): Promise<undefined>;
|
|
1350
|
-
pincodeCheckLogin(ctx: LoginWfCtx): Promise<unknown>;
|
|
1351
|
-
mfaTotp(ctx: LoginWfCtx): Promise<unknown>;
|
|
1352
|
-
/**
|
|
1353
|
-
* Backup-code alt-action handler shared by `select2fa`, `pincode-check-login`,
|
|
1354
|
-
* and `mfa-totp`. Validates against `BackupCodeForm` (alphanumeric +
|
|
1355
|
-
* hyphen-grouped — `MfaCodeForm` is digits-only and rejects backup codes
|
|
1356
|
-
* produced by `UserService.generateBackupCodes`).
|
|
1357
|
-
*/
|
|
1358
|
-
private handleBackupCode;
|
|
1359
|
-
/**
|
|
1360
|
-
* Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
|
|
1361
|
-
* transport, otherwise pauses for the picker form. When TOTP is picked, the
|
|
1362
|
-
* secret is provisioned in the same step body (see `enrollPickPhase`).
|
|
1363
|
-
* Sync-friendly return: the auto-pick branch and the picker-form branch are
|
|
1364
|
-
* both synchronous; only the TOTP-provisioning tail is async.
|
|
1365
|
-
*/
|
|
1366
|
-
loginEnrollPickMethod(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1367
|
-
/**
|
|
1368
|
-
* Forced MFA enrollment — Phase 2 (collect sms/email address + send
|
|
1369
|
-
* pincode). Gated out for totp by the schema condition.
|
|
1370
|
-
*/
|
|
1371
|
-
loginEnrollAddress(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1372
|
-
/**
|
|
1373
|
-
* Forced MFA enrollment — Phase 3 (verify code + activate method). Fires
|
|
1374
|
-
* `onComplete` to bridge `enrollDone` → `mfaChecked` so login's outer MFA
|
|
1375
|
-
* while-loop (gated on `!mfaChecked`) exits.
|
|
1376
|
-
*/
|
|
1377
|
-
loginEnrollConfirm(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1378
|
-
/**
|
|
1379
|
-
* Build the `MfaEnrollDeps` payload shared by all three login enrollment
|
|
1380
|
-
* step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
|
|
1381
|
-
* `EnrollPickMethodForm` can hide the `skip` action unless mode is
|
|
1382
|
-
* `'optional'`) and supplies `onComplete` to mirror `enrollDone` →
|
|
1383
|
-
* `mfaChecked` for login's loop-exit signal.
|
|
1384
|
-
*/
|
|
1385
|
-
private buildLoginEnrollDeps;
|
|
1386
|
-
deviceTrust(ctx: LoginWfCtx): Promise<undefined>;
|
|
1387
|
-
preparePasswordRules(ctx: LoginWfCtx): undefined | Promise<undefined>;
|
|
1388
|
-
createPasswordForm(ctx: LoginWfCtx): Promise<unknown>;
|
|
1389
|
-
profileComplete(ctx: LoginWfCtx): Promise<unknown>;
|
|
1390
|
-
/**
|
|
1391
|
-
* Persists the profile-complete payload onto the user record. Default:
|
|
1392
|
-
* no-op (the workflow records the form was submitted but writes nothing).
|
|
1393
|
-
* Consumers override to write into their user store.
|
|
1394
|
-
*/
|
|
1395
|
-
protected applyProfile(_username: string, _payload: Record<string, unknown>): Promise<void>;
|
|
1396
|
-
/**
|
|
1397
|
-
* Standalone terms re-acceptance prompt for returning users whose accepted
|
|
1398
|
-
* terms version is stale — the consumer's `ConsentStore.getPendingConsents`
|
|
1399
|
-
* returned a non-empty descriptor list (typically a bumped terms version)
|
|
1400
|
-
* and no onboarding carrier form (ask-email/ask-phone/set-password/
|
|
1401
|
-
* profile-complete) ran to capture them via the dynamic `consents: string[]`
|
|
1402
|
-
* carrier field. The body delegates to `processInlineConsent`, which
|
|
1403
|
-
* handles validation + ctx writes identically to the inline path.
|
|
1404
|
-
*/
|
|
1405
|
-
termsBumpPrompt(ctx: LoginWfCtx): undefined;
|
|
1406
|
-
/**
|
|
1407
|
-
* Batched consent persistence — delegates to
|
|
1408
|
-
* `AuthWorkflowBase.runPersistConsents`. See that helper for the full
|
|
1409
|
-
* audit-friendly-default / idempotency / silent-drop contract.
|
|
1410
|
-
*/
|
|
1411
|
-
persistConsentsStep(ctx: LoginWfCtx): Promise<undefined>;
|
|
1412
|
-
tenantSelect(ctx: LoginWfCtx): Promise<unknown>;
|
|
1413
|
-
/**
|
|
1414
|
-
* Resolves the user's available tenants. Default: empty array. Consumers
|
|
1415
|
-
* who enable `multiContext.tenantSelect` must override this to return the
|
|
1416
|
-
* tenants the user belongs to.
|
|
1417
|
-
*/
|
|
1418
|
-
protected loadTenants(_username: string): Promise<Array<{
|
|
1419
|
-
id: string;
|
|
1420
|
-
name: string;
|
|
1421
|
-
}>>;
|
|
1422
|
-
personaSelect(ctx: LoginWfCtx): Promise<unknown>;
|
|
1423
|
-
/**
|
|
1424
|
-
* Resolves the user's available personas. Default: empty array. Consumers
|
|
1425
|
-
* who enable `multiContext.personaSelect` must override this.
|
|
1426
|
-
*/
|
|
1427
|
-
protected loadPersonas(_username: string): Promise<Array<{
|
|
1428
|
-
id: string;
|
|
1429
|
-
label: string;
|
|
1430
|
-
}>>;
|
|
1431
|
-
loadActiveSessionsStep(ctx: LoginWfCtx): Promise<undefined>;
|
|
1432
|
-
/**
|
|
1433
|
-
* Return the number of active (non-revoked, non-expired) sessions for the
|
|
1434
|
-
* user — consulted by `concurrency-limit` to decide whether the kickPrompt
|
|
1435
|
-
* branch fires. Default returns `0` (no enforcement). Override with a real
|
|
1436
|
-
* count from your credential store or session table.
|
|
1437
|
-
*/
|
|
1438
|
-
protected loadActiveSessions(_username: string): Promise<number>;
|
|
1439
|
-
concurrencyLimit(ctx: LoginWfCtx): Promise<unknown>;
|
|
1903
|
+
* Resolve the invite accept-tail policy. Reached from invite.start accept
|
|
1904
|
+
* phase. `loginUrl` defaults to `this.opts.loginUrl`. Note: today's
|
|
1905
|
+
* `freshLoginRequired` field is GONE — the auto-login choice is the static
|
|
1906
|
+
* `AuthWorkflowOpts.autoLoginOnInvite` boolean (per §2 decision).
|
|
1907
|
+
*/
|
|
1908
|
+
protected resolveAccept(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["accept"]> | Promise<NonNullable<AuthWfCtx["accept"]>>;
|
|
1440
1909
|
/**
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1910
|
+
* Resolve the recovery post-reset policy. Reached from recovery.flow.
|
|
1911
|
+
* `freshLoginRequired` REMOVED — the auto-login choice is the static
|
|
1912
|
+
* `AuthWorkflowOpts.autoLoginOnRecover` boolean (per §2 decision).
|
|
1443
1913
|
*/
|
|
1444
|
-
protected
|
|
1445
|
-
riskStepUp(ctx: LoginWfCtx): Promise<undefined>;
|
|
1914
|
+
protected resolvePostReset(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["postReset"]> | Promise<NonNullable<AuthWfCtx["postReset"]>>;
|
|
1446
1915
|
/**
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1916
|
+
* Resolve the recovery alt-actions policy (whether `backToLogin` is offered
|
|
1917
|
+
* on the recovery forms). Renamed from the prior `resolveAltActions` to
|
|
1918
|
+
* disambiguate from login's `resolveAlternateCredentials` (different
|
|
1919
|
+
* concept). Reached from recovery.flow.
|
|
1451
1920
|
*/
|
|
1452
|
-
protected
|
|
1453
|
-
|
|
1454
|
-
|
|
1921
|
+
protected resolveRecoveryAltActions(_ctx: AuthWfCtx): NonNullable<AuthWfCtx["recoveryAltActions"]> | Promise<NonNullable<AuthWfCtx["recoveryAltActions"]>>;
|
|
1922
|
+
/**
|
|
1923
|
+
* Pick the form to render for the unified pincode pair. Default routes to
|
|
1924
|
+
* `opts.forms.pincode` (MFA alt-actions) when `ctx.mfa?.method` is set;
|
|
1925
|
+
* otherwise `opts.forms.recoveryPincode` (recovery alt-actions).
|
|
1926
|
+
*/
|
|
1927
|
+
protected resolvePincodeForm(ctx: AuthWfCtx): TAtscriptAnnotatedType;
|
|
1928
|
+
/**
|
|
1929
|
+
* Pick the raw recipient + channel for pincode delivery. Default sources
|
|
1930
|
+
* the address from the user's enrolled MFA method (when `ctx.mfa.method` is
|
|
1931
|
+
* set) or from `ctx.email` (recovery path). Loads the user to read the raw
|
|
1932
|
+
* method `value` — the `ctx.mfa.enrolledMethods` summary carries only the
|
|
1933
|
+
* MASKED form, which is for display, never for delivery.
|
|
1934
|
+
*/
|
|
1935
|
+
protected resolvePincodeTarget(ctx: AuthWfCtx): {
|
|
1936
|
+
address: string;
|
|
1937
|
+
channel: "sms" | "email";
|
|
1938
|
+
} | Promise<{
|
|
1939
|
+
address: string;
|
|
1940
|
+
channel: "sms" | "email";
|
|
1455
1941
|
}>;
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* Resolves the post-login redirect URL. Default reads
|
|
1462
|
-
* `finalize.redirect`: `false` / `null` (the default) → no redirect, the
|
|
1463
|
-
* `issue` step's data response stands (typical for SPAs/API clients);
|
|
1464
|
-
* `'home'` → `/`; `'referer'` → request `Referer` header (undefined when
|
|
1465
|
-
* absent, falling back to the data response).
|
|
1466
|
-
*
|
|
1467
|
-
* Sync return type only — the caller (`redirect` @Step's default body)
|
|
1468
|
-
* uses the URL inline; consumers needing async redirect resolution should
|
|
1469
|
-
* override the `redirect` @Step instead.
|
|
1942
|
+
/**
|
|
1943
|
+
* Route a form alt-action click to a canonical outcome. Defaults match the
|
|
1944
|
+
* action ids the bundled `PincodeForm` declares; customers override per
|
|
1945
|
+
* form when adding new actions or remapping the canonical ones.
|
|
1470
1946
|
*/
|
|
1471
|
-
protected
|
|
1472
|
-
}
|
|
1473
|
-
//#endregion
|
|
1474
|
-
//#region src/workflows/recovery.workflow.options.d.ts
|
|
1475
|
-
type RecoveryDeliveryMode = "magicLink" | "otp" | "choice";
|
|
1476
|
-
type RecoveryOtpTransport = "sms" | "email";
|
|
1477
|
-
interface RecoveryWorkflowOpts {
|
|
1947
|
+
protected resolvePincodeAltAction(_ctx: AuthWfCtx, action: string): "resend" | "exit" | "useDifferentMethod" | undefined;
|
|
1478
1948
|
/**
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1949
|
+
* Asserts `ctx.subject` is populated. Throws `HttpError(500)` on miss;
|
|
1950
|
+
* narrows `subject` to `string` for the caller. Ported from
|
|
1951
|
+
* `AuthWorkflowBase` since the unified class no longer extends it.
|
|
1481
1952
|
*/
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
recoveryModeSelect?: TAtscriptAnnotatedType;
|
|
1487
|
-
setPassword?: TAtscriptAnnotatedType;
|
|
1488
|
-
};
|
|
1489
|
-
}
|
|
1490
|
-
/**
|
|
1491
|
-
* Fully-resolved view used by the workflow at runtime — every nested group is
|
|
1492
|
-
* always populated by `mergeRecoveryOpts`, so step bodies can read
|
|
1493
|
-
* `this.opts.<group>.<flag>` directly without optional chaining.
|
|
1494
|
-
*/
|
|
1495
|
-
interface ResolvedRecoveryWorkflowOpts {
|
|
1496
|
-
forms: {
|
|
1497
|
-
emailIdentifier: TAtscriptAnnotatedType;
|
|
1498
|
-
pincode: TAtscriptAnnotatedType;
|
|
1499
|
-
recoveryFactor: TAtscriptAnnotatedType;
|
|
1500
|
-
recoveryModeSelect: TAtscriptAnnotatedType;
|
|
1501
|
-
setPassword: TAtscriptAnnotatedType;
|
|
1502
|
-
};
|
|
1503
|
-
}
|
|
1504
|
-
/**
|
|
1505
|
-
* Deep-merge defaults with the user-supplied nested pojo. Each group has its
|
|
1506
|
-
* own `{ ...defaults, ...input }` line — small enough that pulling in lodash
|
|
1507
|
-
* would be silly.
|
|
1508
|
-
*/
|
|
1509
|
-
declare function mergeRecoveryOpts(opts?: RecoveryWorkflowOpts): ResolvedRecoveryWorkflowOpts;
|
|
1510
|
-
//#endregion
|
|
1511
|
-
//#region src/workflows/recovery.workflow.d.ts
|
|
1512
|
-
interface RecoveryWfCtx {
|
|
1513
|
-
delivery?: {
|
|
1514
|
-
mode: RecoveryDeliveryMode;
|
|
1515
|
-
otpTransports: RecoveryOtpTransport[];
|
|
1516
|
-
};
|
|
1517
|
-
preReset?: {
|
|
1518
|
-
requireKnownFactor: boolean;
|
|
1519
|
-
allowedFactors?: Array<"phone" | "totp">;
|
|
1520
|
-
};
|
|
1521
|
-
postReset?: {
|
|
1522
|
-
revokeAllSessions: boolean;
|
|
1523
|
-
freshLoginRequired: boolean;
|
|
1524
|
-
loginUrl: string;
|
|
1525
|
-
};
|
|
1526
|
-
altActions?: {
|
|
1527
|
-
backToLogin: boolean;
|
|
1528
|
-
};
|
|
1529
|
-
audit?: {
|
|
1530
|
-
enabled: boolean;
|
|
1953
|
+
protected requireSubject<T extends {
|
|
1954
|
+
subject?: string;
|
|
1955
|
+
}>(ctx: T): asserts ctx is T & {
|
|
1956
|
+
subject: string;
|
|
1531
1957
|
};
|
|
1532
|
-
email?: string;
|
|
1533
|
-
username?: string;
|
|
1534
|
-
selectedMode?: "magicLink" | "otp";
|
|
1535
|
-
/** Resolved delivery mode the workflow committed to (set by `prepare-delivery` for fixed modes, by `select-mode` for `'choice'`). */
|
|
1536
|
-
resolvedMode?: "magicLink" | "otp";
|
|
1537
|
-
otpTransport?: "sms" | "email";
|
|
1538
|
-
otpCodeLength?: number;
|
|
1539
|
-
pin?: string;
|
|
1540
|
-
pinExpire?: number;
|
|
1541
|
-
pinResendAllowedAt?: number;
|
|
1542
|
-
pinVerified?: boolean;
|
|
1543
|
-
/** Mirror of `delivery.otpTransports.length`. Passed to `PincodeForm` so the `useDifferentTransport` action hides when only one transport is configured. */
|
|
1544
|
-
recoveryTransportCount?: number;
|
|
1545
|
-
linkSent?: boolean;
|
|
1546
|
-
factorVerified?: boolean;
|
|
1547
|
-
/**
|
|
1548
|
-
* Recovery factors the user is actually able to verify on this attempt —
|
|
1549
|
-
* intersection of `preReset.allowedFactors` (workflow whitelist) and
|
|
1550
|
-
* what the user has enrolled (e.g. phone only if a confirmed SMS method
|
|
1551
|
-
* exists). Populated by `verify-factor` before its form pauses and
|
|
1552
|
-
* consumed by `RecoveryFactorForm` via `@wf.context.pass` to render only
|
|
1553
|
-
* the available radio options.
|
|
1554
|
-
*/
|
|
1555
|
-
availableRecoveryFactors?: Array<{
|
|
1556
|
-
key: string;
|
|
1557
|
-
label: string;
|
|
1558
|
-
}>;
|
|
1559
|
-
passwordChanged?: boolean;
|
|
1560
|
-
sessionsRevoked?: boolean;
|
|
1561
|
-
tokensIssued?: boolean;
|
|
1562
1958
|
/**
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1959
|
+
* Project the internal ctx state onto `ctx.public` — the ONLY top-level
|
|
1960
|
+
* key whitelisted on form schemas (via `@wf.context.pass 'public'`).
|
|
1961
|
+
* Mirrors `AuthWfPublicState` field-for-field; intentionally drops
|
|
1962
|
+
* internal-only fields (`pincode.channelCooldowns`, `mfa.saveAsDefault` /
|
|
1963
|
+
* `mfa.current` / `mfa.ignoreDefault`, `trust.deviceTrustToken`,
|
|
1964
|
+
* `channel.phone` / `channel.emailConfirmed`, `mfaEnroll.address`, …)
|
|
1965
|
+
* so they cannot leak to the wire.
|
|
1966
|
+
*
|
|
1967
|
+
* Called via `throwPublic` immediately before every `requireInput`-style
|
|
1968
|
+
* pause so the FE always reads a fresh projection of the post-step ctx.
|
|
1969
|
+
*/
|
|
1970
|
+
protected populatePublic(ctx: AuthWfCtx): void;
|
|
1971
|
+
/**
|
|
1972
|
+
* Wrap `wf.requireInput(opts)` so `ctx.public` is freshly projected
|
|
1973
|
+
* before the pause throws. Every `throw wf.requireInput(...)` in the
|
|
1974
|
+
* codebase routes through this so no pause can ship a stale public
|
|
1975
|
+
* surface (and no contributor can accidentally skip the projection).
|
|
1976
|
+
*/
|
|
1977
|
+
protected throwPublic<T>(ctx: AuthWfCtx, wf: {
|
|
1978
|
+
requireInput(opts?: T): unknown;
|
|
1979
|
+
}, opts?: T): unknown;
|
|
1980
|
+
/**
|
|
1981
|
+
* Drop-in wrapper around `useAtscriptWf(type)` that projects `ctx.public`
|
|
1982
|
+
* BEFORE returning the form handle. Steps that pause implicitly — via
|
|
1983
|
+
* `wf.resolveInput()` throwing on missing input or `wf.resolveAction()`
|
|
1984
|
+
* throwing on an unknown action — bypass `throwPublic`, so without this
|
|
1985
|
+
* wrapper the implicit-pause path would ship a stale (or missing)
|
|
1986
|
+
* `ctx.public`. Every `useAtscriptWf(...)` call in the workflow routes
|
|
1987
|
+
* through this so both pause flavors snapshot the same fresh projection.
|
|
1988
|
+
*/
|
|
1989
|
+
protected useAtscriptWfPublic(ctx: AuthWfCtx, type: Parameters<typeof useAtscriptWf>[0]): ReturnType<typeof useAtscriptWf>;
|
|
1990
|
+
/** Translate `CAS_EXHAUSTED` UserAuthError to 409 Conflict (OCC contract). */
|
|
1991
|
+
protected withStoreErrorTranslation<T>(op: () => Promise<T>): Promise<T>;
|
|
1992
|
+
/** Mint a numeric pincode + stash it onto ctx. Returns the plain code. */
|
|
1993
|
+
protected mintPin(ctx: {
|
|
1994
|
+
pin?: string;
|
|
1995
|
+
pinExpire?: number;
|
|
1996
|
+
pinAttempts?: number;
|
|
1997
|
+
}, length: number, ttlMs: number): string;
|
|
1998
|
+
/**
|
|
1999
|
+
* Verify a submitted pincode against `ctx.pin`. Returns an error map (with
|
|
2000
|
+
* the message keyed under `code` so it renders inline on the code input) or
|
|
2001
|
+
* `null` on success. Brute-force protection: wrong-code attempts increment
|
|
2002
|
+
* `ctx.pinAttempts`; on the `pincodeMaxAttempts`-th miss the code is
|
|
2003
|
+
* invalidated (clears `pin` + `pinExpire` + `pinAttempts`) and the returned
|
|
2004
|
+
* error tells the user to request a fresh code. Without this gate the user
|
|
2005
|
+
* could probe the full 10^pincodeLength space inside one `pincodeTtlMs`
|
|
2006
|
+
* window.
|
|
2007
|
+
*/
|
|
2008
|
+
protected verifyPin(ctx: {
|
|
2009
|
+
pin?: string;
|
|
2010
|
+
pinExpire?: number;
|
|
2011
|
+
pinAttempts?: number;
|
|
2012
|
+
}, submitted: string | undefined): {
|
|
2013
|
+
code: string;
|
|
2014
|
+
} | null;
|
|
2015
|
+
/**
|
|
2016
|
+
* Validate + stash inline-consent fields submitted on a carrier form.
|
|
2017
|
+
* SECURITY: silently drops unknown ids (audit-grade defense — see base
|
|
2018
|
+
* class docstring).
|
|
2019
|
+
*
|
|
2020
|
+
* Does NOT persist — persistence is deferred to `persist-consents` at the
|
|
2021
|
+
* workflow tail, AFTER channel/identity verification. Staging the decision
|
|
2022
|
+
* here (without persisting) lets downstream carrier forms hide the consent
|
|
2023
|
+
* block via `decidedAt` while the wf engine still owns the rollback
|
|
2024
|
+
* boundary: if the user abandons before the verification step succeeds,
|
|
2025
|
+
* the consent record never lands in the durable store.
|
|
2026
|
+
*/
|
|
2027
|
+
protected processInlineConsent(ctx: AuthWfCtx, input: {
|
|
2028
|
+
consents?: string[];
|
|
2029
|
+
}, wf: {
|
|
2030
|
+
requireInput(opts?: {
|
|
2031
|
+
errors?: Record<string, string>;
|
|
2032
|
+
formMessage?: string;
|
|
2033
|
+
}): unknown;
|
|
2034
|
+
}): void;
|
|
2035
|
+
/**
|
|
2036
|
+
* Mask a raw address for UI display. The masked string is for
|
|
2037
|
+
* `ctx.pincode.sentTo`; the raw value is what gets passed to `deliver`.
|
|
2038
|
+
*/
|
|
2039
|
+
protected maskAddress(address: string, channel: "sms" | "email"): string;
|
|
2040
|
+
/** Narrow `MfaMethod.name` to the canonical MfaTransport union. */
|
|
2041
|
+
protected mfaKindOf(methodName: string): MfaTransport | null;
|
|
2042
|
+
/**
|
|
2043
|
+
* Send an enrolment pincode and stamp `ctx.pincode.sentTo` with the masked
|
|
2044
|
+
* recipient. Shared by `enrollAddress` (initial dispatch) and the resend
|
|
2045
|
+
* path inside `enrollConfirm`.
|
|
2046
|
+
*/
|
|
2047
|
+
protected sendEnrollPincode(ctx: AuthWfCtx, address: string, code: string): Promise<void>;
|
|
2048
|
+
/**
|
|
2049
|
+
* Cleanup any partially-persisted enrolment state (unconfirmed method row +
|
|
2050
|
+
* ctx scratch). Called when the user picks `skip` or `useDifferentMethod`
|
|
2051
|
+
* mid-flow on `enrollConfirm`, where the unconfirmed method has already
|
|
2052
|
+
* been written via `addMfaMethod` (in `enrollPickMethod` for totp /
|
|
2053
|
+
* `enrollAddress` for sms+email).
|
|
2054
|
+
*/
|
|
2055
|
+
protected cleanupEnrollment(ctx: AuthWfCtx, username: string): Promise<void>;
|
|
2056
|
+
initLogin(ctx: AuthWfCtx): void;
|
|
2057
|
+
initInviteAdmin(ctx: AuthWfCtx): void;
|
|
2058
|
+
initInviteAccept(ctx: AuthWfCtx): void;
|
|
2059
|
+
initRecovery(ctx: AuthWfCtx): void;
|
|
2060
|
+
/**
|
|
2061
|
+
* Bind the change-password flow to the CURRENT authenticated user. Identity
|
|
2062
|
+
* comes from the session (`useAuth().getUserId()`) — NEVER from form input —
|
|
2063
|
+
* so the flow is structurally "change MY password" with no target-user
|
|
2064
|
+
* parameter. NOT `@Public()`: the trigger, the `@Workflow` body, and every
|
|
2065
|
+
* step in this flow are gated by `@ArbacResource("auth.change-password")` +
|
|
2066
|
+
* `@ArbacAction("self")`, so a customer enables the whole feature with a
|
|
2067
|
+
* single `allow("auth.change-password", "*")` grant and forbids it (SSO-only
|
|
2068
|
+
* orgs) by omitting it. `getUserId()` throws 401 if unauthenticated — defense
|
|
2069
|
+
* in depth on top of the guarded trigger route.
|
|
2070
|
+
*/
|
|
2071
|
+
initChangePassword(ctx: AuthWfCtx): void;
|
|
2072
|
+
/**
|
|
2073
|
+
* Bind the standalone "add an MFA method" flow to the CURRENT authenticated
|
|
2074
|
+
* user and narrow enrolment to the transports they have NOT enrolled yet.
|
|
2075
|
+
* Identity comes from the session (`useAuth().getUserId()`) — never form input
|
|
2076
|
+
* — so it is structurally "add a factor to MY account". Mirrors
|
|
2077
|
+
* `init-change-password`'s arbac gate (`auth.add-mfa` / `self`): a customer
|
|
2078
|
+
* enables the feature with a single `allow("auth.add-mfa", "*")` grant and
|
|
2079
|
+
* forbids it by omitting it. `getUserId()` throws 401 if unauthenticated —
|
|
2080
|
+
* defence in depth on top of the guarded trigger route.
|
|
2081
|
+
*
|
|
2082
|
+
* Drives the REUSED enrol trio (`enroll-pick-method` / `enroll-address` /
|
|
2083
|
+
* `enroll-confirm`) by setting `ctx.mfaPolicy.availableTransports` to the
|
|
2084
|
+
* un-enrolled remainder — so the picker offers only those and auto-picks when
|
|
2085
|
+
* exactly one remains. Forces `mode: "optional"` (the user opted in; an empty
|
|
2086
|
+
* remainder must finish gracefully, never 500 as `required` would). The
|
|
2087
|
+
* remainder is stashed on `ctx.addMfa.candidates` (flow discriminator + finish
|
|
2088
|
+
* summary); when the user already has a default, `enroll-confirm` is asked to
|
|
2089
|
+
* keep it (`mfaEnroll.keepExistingDefault`).
|
|
2090
|
+
*/
|
|
2091
|
+
initAddMfa(ctx: AuthWfCtx): Promise<undefined>;
|
|
2092
|
+
credentials(ctx: AuthWfCtx): Promise<unknown>;
|
|
2093
|
+
/**
|
|
2094
|
+
* Route a credentials alt-action click (forgotPassword / signup / magicLink
|
|
2095
|
+
* / sso-<id>) to a `finishWf` redirect envelope. Returns `ALT_HANDLED` when
|
|
2096
|
+
* the caller should short-circuit without running form validation.
|
|
1565
2097
|
*/
|
|
1566
|
-
|
|
2098
|
+
private handleCredentialsAlt;
|
|
2099
|
+
request(ctx: AuthWfCtx): Promise<unknown>;
|
|
1567
2100
|
/**
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
2101
|
+
* Resolves the recovery-step `email` input to the user's stable `id` (the
|
|
2102
|
+
* token subject). Default: resolves via `findByHandle` (username exact, then
|
|
2103
|
+
* email exact). Override for custom handle→id mapping; return `null` when no
|
|
2104
|
+
* user matches.
|
|
1570
2105
|
*/
|
|
1571
|
-
|
|
1572
|
-
/**
|
|
1573
|
-
|
|
2106
|
+
protected emailToUserId(email: string): Promise<string | null>;
|
|
2107
|
+
/** Anti-enumeration generic finish envelope used when recovery's `request` step receives an unknown email. */
|
|
2108
|
+
private finishGenericRecovery;
|
|
2109
|
+
/**
|
|
2110
|
+
* Stamp `recoveryStateTtlMs` on a paused-state error so the wf engine's
|
|
2111
|
+
* persisted-state strategy expires the state at that timestamp. Use at
|
|
2112
|
+
* recovery-side `requireInput` throws (recovery-only steps) or wrap a
|
|
2113
|
+
* shared-step `resolveInput()` throw inside a `ctx.postReset` guard.
|
|
2114
|
+
*/
|
|
2115
|
+
protected stampRecoveryExpiry<E extends {
|
|
2116
|
+
expires?: number;
|
|
2117
|
+
}>(err: E): E;
|
|
2118
|
+
/** Emit the recovery "backToLogin" abort envelope and stamp `ctx.aborted`. */
|
|
2119
|
+
private abortRecoveryToLogin;
|
|
2120
|
+
/**
|
|
2121
|
+
* Canonical writer of `ctx.password.changeReason` + `isFirstLogin` /
|
|
2122
|
+
* `newPasswordRequired`. Discriminates by ctx-slot presence (§10):
|
|
2123
|
+
* `ctx.accept` → invite-accept; `ctx.postReset` → recovery; otherwise login.
|
|
2124
|
+
* Idempotent on re-entry.
|
|
2125
|
+
*/
|
|
2126
|
+
prepareSemanticFlags(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2127
|
+
prepareConsents(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2128
|
+
prepareAlternateCredentials(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2129
|
+
prepareDeviceTrust(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2130
|
+
prepareEnrollment(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2131
|
+
prepareFinalize(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2132
|
+
prepareGuards(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2133
|
+
prepareLockout(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2134
|
+
/**
|
|
2135
|
+
* Per-call lockout override for `users.login` / `users.verifyMfa`, derived
|
|
2136
|
+
* from the resolved mode. A permanent mode (`admin-only` / `self-service`)
|
|
2137
|
+
* forces `duration: 0` so the threshold trip locks permanently;
|
|
2138
|
+
* `temporary` (or an unresolved policy) returns `undefined` so UserService's
|
|
2139
|
+
* own configured duration applies. Threshold stays UserService config.
|
|
2140
|
+
*/
|
|
2141
|
+
protected lockoutOverride(ctx: AuthWfCtx): {
|
|
2142
|
+
duration: number;
|
|
2143
|
+
} | undefined;
|
|
2144
|
+
prepareSessionPolicy(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2145
|
+
prepareChangePassword(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2146
|
+
/**
|
|
2147
|
+
* Merges login's `prepare-mfa-setup` + invite's `prepare-mfa` + `setup-mfa`.
|
|
2148
|
+
* Writes `ctx.mfaPolicy`; with `ctx.subject` bound, pre-picks
|
|
2149
|
+
* `ctx.mfa.current` from the user's `defaultMethod` (challenge branch);
|
|
2150
|
+
* with zero confirmed methods and a single available transport, pre-picks
|
|
2151
|
+
* `ctx.mfaEnroll.method` (enrol branch). `enrolledMethods` is NOT written
|
|
2152
|
+
* here — `load-enrolled-mfa-methods` owns that masking. Idempotent.
|
|
2153
|
+
*/
|
|
2154
|
+
prepareMfa(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2155
|
+
prepareAdminForm(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2156
|
+
prepareAvailableRoles(ctx: AuthWfCtx): Promise<undefined>;
|
|
2157
|
+
/**
|
|
2158
|
+
* Merges policy from `resolveAccept` into `ctx.accept` (rather than
|
|
2159
|
+
* overwriting) so any state stamped by later steps (`alreadyAccepted`)
|
|
2160
|
+
* survives.
|
|
2161
|
+
*/
|
|
2162
|
+
prepareAccept(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2163
|
+
preparePasswordRules(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2164
|
+
preparePostReset(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2165
|
+
prepareRecoveryAltActions(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2166
|
+
/**
|
|
2167
|
+
* Admin-side invite form. Pauses for `InviteForm`; binds `ctx.email` +
|
|
2168
|
+
* `ctx.admin.roles`. Server-side enforces the `availableRoles` whitelist
|
|
2169
|
+
* (populated by `prepare-available-roles`). Calls `duplicateInviteCheck`
|
|
2170
|
+
* to decide whether to reject duplicates.
|
|
2171
|
+
*/
|
|
2172
|
+
adminForm(ctx: AuthWfCtx): Promise<unknown>;
|
|
2173
|
+
/**
|
|
2174
|
+
* Map admin-provided role labels to canonical IDs via `inferAdminRoles`
|
|
2175
|
+
* hook, set-unioning with admin-supplied roles.
|
|
2176
|
+
*/
|
|
2177
|
+
inferRoles(ctx: AuthWfCtx): Promise<undefined>;
|
|
2178
|
+
/**
|
|
2179
|
+
* Build the extras dict that `create-user` merges into the new user row.
|
|
2180
|
+
* Calls `prepareUser({email, roles, invitedBy})` and writes the result onto
|
|
2181
|
+
* `ctx.admin.userExtras`.
|
|
2182
|
+
*/
|
|
2183
|
+
buildUserExtras(ctx: AuthWfCtx): Promise<undefined>;
|
|
2184
|
+
/**
|
|
2185
|
+
* Create the user row from `ctx.admin.userExtras` (plus the admin-supplied
|
|
2186
|
+
* `ctx.admin.roles`), then stamp `pendingInvitation = true` via a follow-up
|
|
2187
|
+
* deep-merge update so `createUser`-applied account defaults survive.
|
|
2188
|
+
*/
|
|
2189
|
+
createUser(ctx: AuthWfCtx): Promise<undefined>;
|
|
2190
|
+
/**
|
|
2191
|
+
* Issue magic-link token and dispatch the invite email via `outletEmail` —
|
|
2192
|
+
* the wf engine pauses, the email-outlet trigger mints the resume URL, and
|
|
2193
|
+
* the click-through re-enters at this step's level. NOT routed through the
|
|
2194
|
+
* `deliver()` hook because that hook is for direct dispatches where the
|
|
2195
|
+
* payload is fully known at call-time; the magic-link URL exists only after
|
|
2196
|
+
* the pause. Public so the engine can re-enter on the anonymous resume.
|
|
2197
|
+
*
|
|
2198
|
+
* Outlet-pause idempotency via `admin.emailDispatched`: on the invitee's
|
|
2199
|
+
* magic-link resume, the engine re-executes this step body (a paused step's
|
|
2200
|
+
* cursor stays AT the step until the body returns a non-`inputRequired`
|
|
2201
|
+
* value). Returning the outletEmail envelope again would dispatch another
|
|
2202
|
+
* email + re-pause; the flag short-circuits the resume so the cursor
|
|
2203
|
+
* advances into the Phase B accept-tail. This is the engine-documented
|
|
2204
|
+
* step-layer idempotency pattern for outlet pauses with side effects
|
|
2205
|
+
* (`@wooksjs/event-wf` resume semantics — the cursor advances on a
|
|
2206
|
+
* successful step but a re-invocation of the same step body must guard
|
|
2207
|
+
* its own non-idempotent work).
|
|
2208
|
+
*/
|
|
2209
|
+
sendInviteEmail(ctx: AuthWfCtx): unknown;
|
|
1574
2210
|
/**
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
* dynamic `AsConsentArray` field on the carrier form (Phase 5).
|
|
2211
|
+
* Check whether the invite was already accepted. Sets
|
|
2212
|
+
* `ctx.accept.alreadyAccepted` when the user's pending-invitation marker is
|
|
2213
|
+
* cleared.
|
|
1579
2214
|
*/
|
|
1580
|
-
|
|
1581
|
-
/** Set by abort alt-actions (`backToLogin`). Gates all terminal steps. */
|
|
1582
|
-
aborted?: boolean;
|
|
1583
|
-
}
|
|
1584
|
-
/**
|
|
1585
|
-
* Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
|
|
1586
|
-
* overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
|
|
1587
|
-
* @Step methods populate — one entry per resolver. Library users typically
|
|
1588
|
-
* accept a payload of this shape on their `RecoveryWorkflow` subclass ctor /
|
|
1589
|
-
* test harness and have each `resolveXxx` return its matching key (falling
|
|
1590
|
-
* back to `super.resolveXxx(ctx)` for unset groups).
|
|
1591
|
-
*/
|
|
1592
|
-
interface RecoveryPolicyOverrides {
|
|
1593
|
-
delivery?: NonNullable<RecoveryWfCtx["delivery"]>;
|
|
1594
|
-
preReset?: NonNullable<RecoveryWfCtx["preReset"]>;
|
|
1595
|
-
postReset?: NonNullable<RecoveryWfCtx["postReset"]>;
|
|
1596
|
-
altActions?: NonNullable<RecoveryWfCtx["altActions"]>;
|
|
1597
|
-
audit?: NonNullable<RecoveryWfCtx["audit"]>;
|
|
1598
|
-
}
|
|
1599
|
-
declare class RecoveryWorkflow extends AuthWorkflowBase {
|
|
1600
|
-
protected readonly opts: ResolvedRecoveryWorkflowOpts;
|
|
1601
|
-
protected readonly users: UserService;
|
|
1602
|
-
protected readonly auth: AuthCredential;
|
|
1603
|
-
protected readonly authOpts: AuthOpts;
|
|
1604
|
-
protected readonly consentStore: ConsentStore;
|
|
1605
|
-
constructor(opts: RecoveryWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
|
|
2215
|
+
checkPendingInvitation(ctx: AuthWfCtx): Promise<undefined>;
|
|
1606
2216
|
/**
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
* mode AND for `magicLink` mode the `outletEmail` outlet still runs the
|
|
1610
|
-
* email through `createAuthEmailOutlet`'s `EmailSender` — see the trigger
|
|
1611
|
-
* controller wiring; this method covers OTP code dispatch).
|
|
2217
|
+
* Emit the "this invite was already accepted" finish envelope and short-
|
|
2218
|
+
* circuit the rest of the accept tail.
|
|
1612
2219
|
*/
|
|
1613
|
-
|
|
2220
|
+
idempotentRedirect(ctx: AuthWfCtx): undefined;
|
|
1614
2221
|
/**
|
|
1615
|
-
*
|
|
1616
|
-
* their audit sink.
|
|
2222
|
+
* Clear the user's `pendingInvitation` marker after successful password set.
|
|
1617
2223
|
*/
|
|
1618
|
-
|
|
2224
|
+
unsetPendingInvitation(ctx: AuthWfCtx): Promise<undefined>;
|
|
2225
|
+
/** Activate the invited user account (flips the account status flag). */
|
|
2226
|
+
activateUser(ctx: AuthWfCtx): Promise<undefined>;
|
|
1619
2227
|
/**
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
2228
|
+
* Emit the success-confirmation envelope. The downstream `finalize-auto-
|
|
2229
|
+
* login` step preserves this `message` so the SPA paints the configured
|
|
2230
|
+
* confirmation text alongside the tokens (WF-INVITE-020).
|
|
1622
2231
|
*/
|
|
1623
|
-
|
|
2232
|
+
confirmation(ctx: AuthWfCtx): undefined;
|
|
1624
2233
|
/**
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
2234
|
+
* Unified password-set step body — merges login Phase 5 + invite accept-tail
|
|
2235
|
+
* + recovery set-password. Stages copy via `ctx.password.changeReason`
|
|
2236
|
+
* (`initial` / `expired` / `reset`), pauses for `SetPasswordForm`, validates
|
|
2237
|
+
* match, calls `users.setPassword`, processes inline consents, and clears
|
|
2238
|
+
* the per-user `isPasswordInitial` / `isPasswordExpired` flags.
|
|
1629
2239
|
*/
|
|
1630
|
-
|
|
2240
|
+
createPasswordForm(ctx: AuthWfCtx): Promise<unknown>;
|
|
1631
2241
|
/**
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
2242
|
+
* Optional min-interval rate limit (Okta "minimum password age"). Gated
|
|
2243
|
+
* upstream by `!!ctx.changePassword?.rateLimit`. Reuses `password.lastChanged`
|
|
2244
|
+
* (no extra storage) — if the last change is more recent than
|
|
2245
|
+
* `minIntervalMs`, emit a warn terminal and set `ctx.aborted` so the schema's
|
|
2246
|
+
* `{ break }` short-circuits BEFORE the form pause (the user can't fix this by
|
|
2247
|
+
* retrying the form — they must wait — so this is a terminal, not a
|
|
2248
|
+
* `requireInput`). NOT the primary protection: current-password re-entry is.
|
|
1637
2249
|
*/
|
|
1638
|
-
|
|
2250
|
+
enforceChangePasswordRateLimit(ctx: AuthWfCtx): Promise<undefined>;
|
|
1639
2251
|
/**
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
2252
|
+
* Authenticated self-service password change. `ctx.subject` is the SIGNED-IN
|
|
2253
|
+
* user (set by `init-change-password` from the session — never form input).
|
|
2254
|
+
* Pauses for `ChangePasswordForm`, then calls `users.changePassword`, which
|
|
2255
|
+
* re-verifies the CURRENT password (primary protection) before applying the
|
|
2256
|
+
* policy + history checks. `UserAuthError`s map to per-field form errors so
|
|
2257
|
+
* the user can fix and retry in place (per the requireInput-not-HttpError
|
|
2258
|
+
* convention).
|
|
1643
2259
|
*/
|
|
1644
|
-
|
|
2260
|
+
changePasswordForm(ctx: AuthWfCtx): Promise<unknown>;
|
|
1645
2261
|
/**
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
2262
|
+
* Change-password terminal — rotate the acting session's token (so the
|
|
2263
|
+
* current device stays signed in on a FRESH credential) and emit a success
|
|
2264
|
+
* message. Runs AFTER the optional `revoke-sessions` step, so the net effect
|
|
2265
|
+
* is "kill every other session, keep this one on a new token" (OWASP Session
|
|
2266
|
+
* Management: no ghost sessions survive a credential change).
|
|
1648
2267
|
*/
|
|
1649
|
-
|
|
1650
|
-
prepareDelivery(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
2268
|
+
finishChangePassword(ctx: AuthWfCtx): Promise<undefined>;
|
|
1651
2269
|
/**
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
* the old construction-time `validateOpts` check; the value is now
|
|
1658
|
-
* ctx-driven so the check has to fire at step time).
|
|
1659
|
-
*/
|
|
1660
|
-
private applyResolvedDelivery;
|
|
1661
|
-
preparePreReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
1662
|
-
preparePostReset(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
1663
|
-
prepareAltActions(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
1664
|
-
prepareAudit(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
1665
|
-
/**
|
|
1666
|
-
* Populate `ctx.pendingConsents` with the customer-defined general-consent
|
|
1667
|
-
* descriptors (terms, marketing, jurisdiction, ...) the user still needs to
|
|
1668
|
-
* accept. Phase 4 transport only — nothing reads `ctx.pendingConsents` yet;
|
|
1669
|
-
* Phase 5 will migrate the carrier `SetPasswordForm` from the
|
|
1670
|
-
* `WithInlineConsentForm` static-checkbox mixin onto this dynamic array.
|
|
1671
|
-
*
|
|
1672
|
-
* Username MUST be bound before we fetch consents — the schema places this
|
|
1673
|
-
* step AFTER the `!ctx.username` break gate, so the `if (!ctx.username)`
|
|
1674
|
-
* guard is belt-and-brace for future refactors that might re-order the
|
|
1675
|
-
* schema.
|
|
2270
|
+
* Terminal for the add-MFA flow. The user KEEPS their current session (no
|
|
2271
|
+
* re-issue, no cookies) — this is a plain data finish. `mfaEnroll.done &&
|
|
2272
|
+
* mfaEnroll.method` is the success signal: a real confirm keeps `.method`,
|
|
2273
|
+
* whereas a cancel runs `cleanupEnrollment` (which deletes it). An empty
|
|
2274
|
+
* `addMfa.candidates` distinguishes "nothing left to add" from a user cancel.
|
|
1676
2275
|
*/
|
|
1677
|
-
|
|
1678
|
-
|
|
2276
|
+
finishAddMfa(ctx: AuthWfCtx): undefined;
|
|
2277
|
+
askChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
|
|
2278
|
+
verifyChannel(ctx: AuthWfCtx, channel: "email" | "phone"): Promise<unknown>;
|
|
1679
2279
|
/**
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
* Return type is `undefined | Promise<undefined>` so consumers can override
|
|
1684
|
-
* with `async init(...)` without the default fast-path paying a Promise
|
|
1685
|
-
* allocation (the wf engine awaits only when the return value is a Promise).
|
|
2280
|
+
* Read the device-trust cookie; if it matches a valid record, set
|
|
2281
|
+
* `ctx.otp.verified = true` to skip the MFA loop. Otherwise stamp
|
|
2282
|
+
* `ctx.trust.newDevice = true` to drive the post-MFA notify gate.
|
|
1686
2283
|
*/
|
|
1687
|
-
|
|
1688
|
-
request(ctx: RecoveryWfCtx): Promise<unknown>;
|
|
2284
|
+
checkTrustedDevice(ctx: AuthWfCtx): Promise<undefined>;
|
|
1689
2285
|
/**
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
* `email` MUST override this; return `null` when no user matches.
|
|
2286
|
+
* Resolve the client IP from the active HTTP request, swallowing the case
|
|
2287
|
+
* where there is no HTTP context (unit tests that hand-roll the wf runtime).
|
|
2288
|
+
* Ported from `AuthWorkflowBase`.
|
|
1694
2289
|
*/
|
|
1695
|
-
protected
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
verifyFactor(ctx: RecoveryWfCtx): Promise<unknown>;
|
|
1701
|
-
/**
|
|
1702
|
-
* Returns the factor options to show on `RecoveryFactorForm`. Default:
|
|
1703
|
-
* intersection of `preReset.allowedFactors` (workflow whitelist —
|
|
1704
|
-
* `undefined` means both `phone` and `totp` are eligible) and the kinds
|
|
1705
|
-
* the user has actually enrolled (`phone` if a confirmed SMS method
|
|
1706
|
-
* exists, `totp` if a confirmed TOTP method exists). Override to add
|
|
1707
|
-
* custom factors (e.g. security questions) — call `super` to keep the
|
|
1708
|
-
* built-in pair.
|
|
1709
|
-
*/
|
|
1710
|
-
protected loadAvailableRecoveryFactors(ctx: RecoveryWfCtx): Promise<Array<{
|
|
1711
|
-
key: string;
|
|
1712
|
-
label: string;
|
|
1713
|
-
}>>;
|
|
1714
|
-
/**
|
|
1715
|
-
* Verifies a recovery factor against the user's enrolled MFA methods.
|
|
1716
|
-
* Default: supports `'phone'` (phone last-4 match) and `'totp'` (current
|
|
1717
|
-
* TOTP code). Returns `true` when the factor matches.
|
|
1718
|
-
*
|
|
1719
|
-
* Consumers extend by overriding to support additional factors (e.g.
|
|
1720
|
-
* security questions); call `super.verifyRecoveryFactor(...)` to keep
|
|
1721
|
-
* the built-in checks.
|
|
2290
|
+
protected resolveClientIp(): string | undefined;
|
|
2291
|
+
/**
|
|
2292
|
+
* Resolve the client `User-Agent` from the active HTTP request. Sibling to
|
|
2293
|
+
* {@link resolveClientIp}; swallows the no-HTTP case (unit tests that
|
|
2294
|
+
* hand-roll the wf runtime) by returning `undefined`.
|
|
1722
2295
|
*/
|
|
1723
|
-
protected
|
|
1724
|
-
factor: string;
|
|
1725
|
-
value: string;
|
|
1726
|
-
ctx: RecoveryWfCtx;
|
|
1727
|
-
}): Promise<boolean>;
|
|
1728
|
-
setPassword(ctx: RecoveryWfCtx): Promise<unknown>;
|
|
1729
|
-
revokeSessions(ctx: RecoveryWfCtx): Promise<undefined>;
|
|
1730
|
-
auditStep(ctx: RecoveryWfCtx): Promise<undefined>;
|
|
2296
|
+
protected resolveUserAgent(): string | undefined;
|
|
1731
2297
|
/**
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
2298
|
+
* Build the {@link CredentialMetadata} captured onto every credential at
|
|
2299
|
+
* issue time. Default records the request IP + User-Agent (the raw facts the
|
|
2300
|
+
* session UI derives device/location from at read time). Returns `undefined`
|
|
2301
|
+
* outside an HTTP context — so a hand-rolled (no-HTTP) wf run issues with
|
|
2302
|
+
* `metadata: undefined`. Override to add a `label`, trim PII, etc.
|
|
1735
2303
|
*/
|
|
1736
|
-
|
|
1737
|
-
freshLoginFinish(ctx: RecoveryWfCtx): undefined | Promise<undefined>;
|
|
1738
|
-
autoLoginFinish(ctx: RecoveryWfCtx): Promise<undefined>;
|
|
2304
|
+
protected resolveIssueMetadata(_ctx: AuthWfCtx): CredentialMetadata | undefined;
|
|
1739
2305
|
/**
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
2306
|
+
* Mint a fresh credential for the workflow user, stamping the default
|
|
2307
|
+
* issue-time metadata (IP + User-Agent via {@link resolveIssueMetadata}).
|
|
2308
|
+
* Shared by every finish step that issues a session (login, change-password,
|
|
2309
|
+
* recovery auto-login). Call after {@link requireSubject} has narrowed
|
|
2310
|
+
* `subject` (the typed param enforces it at the call site).
|
|
1743
2311
|
*/
|
|
1744
|
-
private
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
//#endregion
|
|
1750
|
-
//#region src/workflows/invite.workflow.options.d.ts
|
|
1751
|
-
/**
|
|
1752
|
-
* Input passed to {@link InviteWorkflow.prepareUser}. The workflow resolves the
|
|
1753
|
-
* admin form to these fields before calling the hook, so the override sees a
|
|
1754
|
-
* fully-typed payload regardless of which optional fields the admin filled in.
|
|
1755
|
-
*/
|
|
1756
|
-
interface PreparedUserInput {
|
|
1757
|
-
email: string;
|
|
1758
|
-
firstName?: string;
|
|
1759
|
-
lastName?: string;
|
|
1760
|
-
roles: string[];
|
|
1761
|
-
/** Admin's `username` (`useAuth().getAuthContext()?.userId` at invite time). */
|
|
1762
|
-
invitedBy?: string;
|
|
1763
|
-
}
|
|
1764
|
-
/** Return value of {@link InviteWorkflow.duplicateCheck}. */
|
|
1765
|
-
type DuplicateAction = "allow" | "reject" | "reuseAsReInvite";
|
|
1766
|
-
type InviteSendMode = "email" | "shareableLink" | "choice";
|
|
1767
|
-
interface InviteWorkflowOpts {
|
|
1768
|
-
/**
|
|
1769
|
-
* Replaceable form schemas. Each field defaults to the corresponding
|
|
1770
|
-
* `.as` form shipped under `@aooth/auth-moost/atscript/models`.
|
|
2312
|
+
private issueForContext;
|
|
2313
|
+
/**
|
|
2314
|
+
* Load + summarise the user's enrolled MFA methods (filtered against
|
|
2315
|
+
* `ctx.mfaPolicy.availableTransports`) and mirror form-gating flags
|
|
2316
|
+
* (`mfa.methodCount`, `trust.optIn`) onto ctx. Pure data-load.
|
|
1771
2317
|
*/
|
|
1772
|
-
|
|
1773
|
-
enrollAddress?: TAtscriptAnnotatedType;
|
|
1774
|
-
enrollConfirm?: TAtscriptAnnotatedType;
|
|
1775
|
-
enrollPickMethod?: TAtscriptAnnotatedType;
|
|
1776
|
-
invite?: TAtscriptAnnotatedType;
|
|
1777
|
-
inviteEmail?: TAtscriptAnnotatedType;
|
|
1778
|
-
inviteSendMode?: TAtscriptAnnotatedType;
|
|
1779
|
-
setPassword?: TAtscriptAnnotatedType;
|
|
1780
|
-
};
|
|
1781
|
-
}
|
|
1782
|
-
/**
|
|
1783
|
-
* Fully-resolved view used by the workflow at runtime — every nested group is
|
|
1784
|
-
* always populated by `mergeInviteOpts`, so step bodies can read
|
|
1785
|
-
* `this.opts.<group>.<flag>` directly without optional chaining.
|
|
1786
|
-
*/
|
|
1787
|
-
interface ResolvedInviteWorkflowOpts {
|
|
1788
|
-
forms: {
|
|
1789
|
-
enrollAddress: TAtscriptAnnotatedType;
|
|
1790
|
-
enrollConfirm: TAtscriptAnnotatedType;
|
|
1791
|
-
enrollPickMethod: TAtscriptAnnotatedType;
|
|
1792
|
-
invite: TAtscriptAnnotatedType;
|
|
1793
|
-
inviteEmail: TAtscriptAnnotatedType;
|
|
1794
|
-
inviteSendMode: TAtscriptAnnotatedType;
|
|
1795
|
-
setPassword: TAtscriptAnnotatedType;
|
|
1796
|
-
};
|
|
1797
|
-
}
|
|
1798
|
-
/**
|
|
1799
|
-
* Deep-merge defaults with the user-supplied nested pojo. Each group has its
|
|
1800
|
-
* own `{ ...defaults, ...input }` line — small enough that pulling in lodash
|
|
1801
|
-
* would be silly.
|
|
1802
|
-
*/
|
|
1803
|
-
declare function mergeInviteOpts(opts?: InviteWorkflowOpts): ResolvedInviteWorkflowOpts;
|
|
1804
|
-
/**
|
|
1805
|
-
* Backwards-compat alias for the prior input-shape name. Consumers who type
|
|
1806
|
-
* their `prepareUser()` override against this still compile.
|
|
1807
|
-
*/
|
|
1808
|
-
type InvitePrepareUserInput = PreparedUserInput;
|
|
1809
|
-
//#endregion
|
|
1810
|
-
//#region src/workflows/invite.workflow.d.ts
|
|
1811
|
-
interface InviteWfCtx {
|
|
1812
|
-
adminForm?: {
|
|
1813
|
-
collectRoles: boolean;
|
|
1814
|
-
};
|
|
1815
|
-
send?: {
|
|
1816
|
-
mode: InviteSendMode;
|
|
1817
|
-
};
|
|
1818
|
-
accept?: {
|
|
1819
|
-
alreadyAcceptedRedirectUrl: string;
|
|
1820
|
-
freshLoginRequired: boolean;
|
|
1821
|
-
loginUrl: string;
|
|
1822
|
-
showConfirmation: boolean;
|
|
1823
|
-
confirmationMessage: string;
|
|
1824
|
-
};
|
|
1825
|
-
cancellation?: {
|
|
1826
|
-
allowed: boolean;
|
|
1827
|
-
};
|
|
1828
|
-
audit?: {
|
|
1829
|
-
enabled: boolean;
|
|
1830
|
-
};
|
|
1831
|
-
mfa?: {
|
|
1832
|
-
issuer: string;
|
|
1833
|
-
};
|
|
1834
|
-
/** Boolean projection of `this.getProfileForm() !== undefined` — schema gates on it. */
|
|
1835
|
-
acceptProfileFormPresent?: boolean;
|
|
2318
|
+
loadEnrolledMfaMethods(ctx: AuthWfCtx): Promise<undefined>;
|
|
1836
2319
|
/**
|
|
1837
|
-
*
|
|
1838
|
-
*
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
2320
|
+
* Pick which MFA method to use from `ctx.mfa.enrolledMethods`. Decision-only,
|
|
2321
|
+
* no IO. Honors `ctx.mfa.current` (pre-selected by `prepare-mfa` from the
|
|
2322
|
+
* user's `defaultMethod`), auto-picks when only one method is enrolled,
|
|
2323
|
+
* falls back to the `isDefault` method. Gated on `!ctx.mfa.ignoreDefault`.
|
|
1841
2324
|
*/
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
username?: string;
|
|
1846
|
-
firstName?: string;
|
|
1847
|
-
lastName?: string;
|
|
1848
|
-
roles?: string[];
|
|
2325
|
+
selectMfaMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2326
|
+
/** Pauses for `Select2faForm`; binds `ctx.mfa.method` from input. */
|
|
2327
|
+
select2fa(ctx: AuthWfCtx): Promise<unknown>;
|
|
1849
2328
|
/**
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1853
|
-
* tenant-validation step between extras-build and create-user without
|
|
1854
|
-
* copying either body.
|
|
2329
|
+
* Unified MFA pincode send. Used by login MFA SMS/email challenge and
|
|
2330
|
+
* recovery OTP. Form/target picked via `resolvePincodeForm` /
|
|
2331
|
+
* `resolvePincodeTarget` (which discriminate on `ctx.mfa?.method` presence).
|
|
1855
2332
|
*/
|
|
1856
|
-
|
|
1857
|
-
/** Populated by `select-send-mode` (when `send.mode === 'choice'`). */
|
|
1858
|
-
selectedSendMode?: "email" | "shareableLink";
|
|
1859
|
-
/** Resolved send mode the workflow committed to (set in `prepare-send` or `select-send-mode`). */
|
|
1860
|
-
resolvedSendMode?: "email" | "shareableLink";
|
|
1861
|
-
/** Populated by `return-shareable-link` so the admin's UI can display it. */
|
|
1862
|
-
shareableLinkUrl?: string;
|
|
1863
|
-
/** Marks that `send-email` already emitted the outlet — resume → advance. */
|
|
1864
|
-
linkSent?: boolean;
|
|
1865
|
-
/** Detected at `check-pending-invitation`; triggers `idempotent-redirect`. */
|
|
1866
|
-
alreadyAccepted?: boolean;
|
|
1867
|
-
passwordSet?: boolean;
|
|
1868
|
-
enrollMethod?: "sms" | "email" | "totp";
|
|
1869
|
-
enrollAddress?: string;
|
|
1870
|
-
enrollSecret?: string;
|
|
1871
|
-
enrollUri?: string;
|
|
1872
|
-
enrollAvailableTransports?: Array<"sms" | "email" | "totp">;
|
|
1873
|
-
/**
|
|
1874
|
-
* MFA policy (set by `inviteSetupMfa` setter — overridable per consumer).
|
|
1875
|
-
* - `'required'` — invitee MUST enroll a second factor BEFORE activation.
|
|
1876
|
-
* - `'optional'` — invitee is prompted but may `skip` the enrollment form.
|
|
1877
|
-
* - `'disabled'` — enrollment loop is skipped entirely.
|
|
1878
|
-
*/
|
|
1879
|
-
mfaMode?: "required" | "optional" | "disabled";
|
|
1880
|
-
/** Available MFA transports (set by `inviteSetupMfa` setter — overridable per consumer). */
|
|
1881
|
-
availableMfaTransports?: Array<"sms" | "email" | "totp">;
|
|
1882
|
-
/**
|
|
1883
|
-
* Mirror of `ctx.mfaMode` (only set when not `'disabled'`). Surfaced to
|
|
1884
|
-
* `EnrollPickMethodForm` via `@wf.context.pass` so the `skip` action can
|
|
1885
|
-
* hide unless mode is `'optional'`.
|
|
1886
|
-
*/
|
|
1887
|
-
enrollMode?: "required" | "optional";
|
|
1888
|
-
enrollDone?: boolean;
|
|
1889
|
-
/** Phase 3 confirm-pincode resend cooldown (sms/email). See `MfaEnrollCtx.enrollPincodeCooldown`. */
|
|
1890
|
-
enrollPincodeCooldown?: number;
|
|
1891
|
-
/** Pincode scratch shared with the enrollment helper. */
|
|
1892
|
-
pin?: string;
|
|
1893
|
-
pinExpire?: number;
|
|
1894
|
-
pinSentTo?: string;
|
|
1895
|
-
/** Raw input from `collect-profile`. */
|
|
1896
|
-
profile?: Record<string, unknown>;
|
|
1897
|
-
profileApplied?: boolean;
|
|
1898
|
-
pendingInvitationCleared?: boolean;
|
|
1899
|
-
activated?: boolean;
|
|
1900
|
-
confirmationShown?: boolean;
|
|
1901
|
-
tokensIssued?: boolean;
|
|
1902
|
-
/**
|
|
1903
|
-
* Subset of `pendingConsents[].id` the user ticked — set by
|
|
1904
|
-
* `processInlineConsent` after silent-dropping unknown ids.
|
|
1905
|
-
*/
|
|
1906
|
-
acceptedConsentIds?: string[];
|
|
1907
|
-
/**
|
|
1908
|
-
* Wall-clock ms when `processInlineConsent` resolved the carrier-form
|
|
1909
|
-
* submission. Also the schema-gate for `persist-consents`.
|
|
1910
|
-
*/
|
|
1911
|
-
consentsDecidedAt?: number;
|
|
1912
|
-
/** Set true by `persist-consents` after the batched `consentStore.save` call fires. */
|
|
1913
|
-
consentsPersisted?: boolean;
|
|
1914
|
-
/**
|
|
1915
|
-
* Descriptors for the customer-defined consents (terms, marketing,
|
|
1916
|
-
* jurisdiction, ...) the user still needs to accept. Populated once by
|
|
1917
|
-
* `prepare-consents` after username-bind; consumed by `WithInlineConsentForm`'s
|
|
1918
|
-
* dynamic `AsConsentArray` field on the carrier form (Phase 5).
|
|
1919
|
-
*/
|
|
1920
|
-
pendingConsents?: ConsentDescriptor[];
|
|
1921
|
-
/** Set true by abort alt-actions (`cancel`). Gates all terminal steps. */
|
|
1922
|
-
aborted?: boolean;
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Per-group policy override shape consumed by `resolveXxx(ctx)` subclass
|
|
1926
|
-
* overrides. Mirrors the `ctx.<group>` fields that the `prepare-<group>`
|
|
1927
|
-
* @Step methods populate — one entry per resolver. Library users typically
|
|
1928
|
-
* accept a payload of this shape on their `InviteWorkflow` subclass ctor /
|
|
1929
|
-
* test harness and have each `resolveXxx` return its matching key (falling
|
|
1930
|
-
* back to `super.resolveXxx(ctx)` for unset groups).
|
|
1931
|
-
*/
|
|
1932
|
-
interface InvitePolicyOverrides {
|
|
1933
|
-
adminForm?: NonNullable<InviteWfCtx["adminForm"]>;
|
|
1934
|
-
send?: NonNullable<InviteWfCtx["send"]>;
|
|
1935
|
-
accept?: NonNullable<InviteWfCtx["accept"]>;
|
|
1936
|
-
cancellation?: NonNullable<InviteWfCtx["cancellation"]>;
|
|
1937
|
-
audit?: NonNullable<InviteWfCtx["audit"]>;
|
|
1938
|
-
mfa?: NonNullable<InviteWfCtx["mfa"]>;
|
|
1939
|
-
}
|
|
1940
|
-
/** Trim + de-duplicate role identifiers submitted via the admin invite form. */
|
|
1941
|
-
declare function parseInviteRoles(input?: string[]): string[];
|
|
1942
|
-
/**
|
|
1943
|
-
* **Per-step ARBAC model.** Phase-A steps (admin-side, pre magic-link send)
|
|
1944
|
-
* inherit the class-level `@ArbacResource('auth.invite') @ArbacAction('start')`
|
|
1945
|
-
* so every admin-side step event is gated. Apps that wire
|
|
1946
|
-
* `arbacAuthorizeInterceptor` globally grant admin a single rule:
|
|
1947
|
-
* `allow('auth.invite', 'start')`. The ARBAC resource name is intentionally
|
|
1948
|
-
* distinct from the wfid path (`auth/invite/start`) — RBAC policy ids and
|
|
1949
|
-
* wfid namespacing are separate naming schemes.
|
|
1950
|
-
*
|
|
1951
|
-
* The three `@Workflow` body methods (`inviteFlow` / `reInviteFlow` /
|
|
1952
|
-
* `cancelInviteFlow`) are `@Public()` because the wf adapter dispatches the
|
|
1953
|
-
* flow body on EVERY `start()` / `resume()` call — gating it would 401 the
|
|
1954
|
-
* anonymous magic-link resume before any step runs. The real gate is the
|
|
1955
|
-
* step methods themselves, which the wf runtime invokes through the same
|
|
1956
|
-
* interceptor chain.
|
|
1957
|
-
*
|
|
1958
|
-
* Phase-B steps (post `ctx.linkSent`, accept tail) are method-level
|
|
1959
|
-
* `@Public()` because they fire on the anonymous magic-link resume.
|
|
1960
|
-
* `send-email` / `return-shareable-link` are the boundary: also `@Public()`
|
|
1961
|
-
* because the @prostojs/wf runtime re-enters the saved step on resume (the
|
|
1962
|
-
* loop restarts at `indexes[level]`, not after it). Their bodies are
|
|
1963
|
-
* idempotent via `if (ctx.linkSent) return`.
|
|
1964
|
-
*
|
|
1965
|
-
* `auth/invite/resend` / `auth/invite/cancel` are admin-only end-to-end
|
|
1966
|
-
* (admin confirms in their own UI; no anonymous boundary), so their phase-A
|
|
1967
|
-
* steps stay class-gated under the same `auth.invite` / `start` grant.
|
|
1968
|
-
*/
|
|
1969
|
-
declare class InviteWorkflow extends AuthWorkflowBase {
|
|
1970
|
-
protected readonly opts: ResolvedInviteWorkflowOpts;
|
|
1971
|
-
protected readonly users: UserService;
|
|
1972
|
-
protected readonly auth: AuthCredential;
|
|
1973
|
-
protected readonly authOpts: AuthOpts;
|
|
1974
|
-
protected readonly consentStore: ConsentStore;
|
|
1975
|
-
constructor(opts: InviteWorkflowOpts, users: UserService, auth: AuthCredential, authOpts: AuthOpts, consentStore: ConsentStore);
|
|
2333
|
+
pincodeSend(ctx: AuthWfCtx): Promise<unknown>;
|
|
1976
2334
|
/**
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1980
|
-
* Override to wire your senders.
|
|
2335
|
+
* Unified MFA pincode check. Used by login MFA SMS/email challenge and
|
|
2336
|
+
* recovery OTP. Alt-actions routed via `resolvePincodeAltAction` — the
|
|
2337
|
+
* default returns `undefined` (customers override per form).
|
|
1981
2338
|
*/
|
|
1982
|
-
|
|
2339
|
+
pincodeCheck(ctx: AuthWfCtx): Promise<unknown>;
|
|
1983
2340
|
/**
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
2341
|
+
* TOTP MFA challenge step body. Verifies a TOTP code via `users.verifyMfa`
|
|
2342
|
+
* (lockout-aware); sets `ctx.otp.verified = true` on success. Replaces
|
|
2343
|
+
* login's prior `mfa-totp` step.
|
|
1986
2344
|
*/
|
|
1987
|
-
|
|
2345
|
+
totpCheck(ctx: AuthWfCtx): Promise<unknown>;
|
|
1988
2346
|
/**
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
* `
|
|
1993
|
-
* own columns (e.g. `displayName`) and return them here.
|
|
2347
|
+
* Unified MFA-enrol phase 1 (pick method). Auto-picks a single transport,
|
|
2348
|
+
* otherwise pauses for `EnrollPickMethodForm`. When TOTP is picked, the
|
|
2349
|
+
* secret is idempotently provisioned in the same step body. Handles
|
|
2350
|
+
* `skip` in `'optional'` mode.
|
|
1994
2351
|
*/
|
|
1995
|
-
|
|
2352
|
+
enrollPickMethod(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
1996
2353
|
/**
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
* `ctx.availableRoles` so the UI renders a multi-select AND the
|
|
2000
|
-
* `admin-form` step rejects admin-submitted roles outside the
|
|
2001
|
-
* list. When `undefined` (default) → no whitelist is enforced and any role
|
|
2002
|
-
* value the admin form supplies is accepted.
|
|
2354
|
+
* Unified MFA-enrol phase 2 (collect sms/email address + send pincode).
|
|
2355
|
+
* Not invoked for totp. Handles `skip` / `useDifferentMethod`.
|
|
2003
2356
|
*/
|
|
2004
|
-
|
|
2357
|
+
enrollAddress(ctx: AuthWfCtx): Promise<undefined>;
|
|
2005
2358
|
/**
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2359
|
+
* Unified MFA-enrol phase 3 (verify pincode/TOTP, mark confirmed). On
|
|
2360
|
+
* success sets `ctx.mfaEnroll.done = true` AND `ctx.otp.verified = true`
|
|
2361
|
+
* (the loop-exit signal — enrol-confirm verifies an OTP, so the unified
|
|
2362
|
+
* `otp.verified` flag fires alongside the MFA-specific `mfaEnroll.done`).
|
|
2009
2363
|
*/
|
|
2010
|
-
|
|
2011
|
-
email: string;
|
|
2012
|
-
firstName?: string;
|
|
2013
|
-
lastName?: string;
|
|
2014
|
-
}): Promise<string[]>;
|
|
2364
|
+
enrollConfirm(ctx: AuthWfCtx): Promise<undefined>;
|
|
2015
2365
|
/**
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
*
|
|
2366
|
+
* Risk step-up: re-evaluate whether to require another MFA round. Default
|
|
2367
|
+
* `resolveRiskStepUp` returns `{require: false}`. When `require: true`,
|
|
2368
|
+
* clear `ctx.otp.verified` to re-arm the loop.
|
|
2019
2369
|
*/
|
|
2020
|
-
|
|
2021
|
-
username: string;
|
|
2022
|
-
profile: Record<string, unknown>;
|
|
2023
|
-
}): Promise<void>;
|
|
2370
|
+
riskStepUp(ctx: AuthWfCtx): Promise<undefined>;
|
|
2024
2371
|
/**
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2027
|
-
*
|
|
2028
|
-
* override to return `'allow'` for those cases.
|
|
2372
|
+
* Mint a device-trust record + cookie value. Default: delegates to
|
|
2373
|
+
* `UserService.issueTrustedDevice` — produces an HMAC-signed token bound to
|
|
2374
|
+
* `username` (+ `ip` when `bindsTo === 'cookie+ip'`).
|
|
2029
2375
|
*/
|
|
2030
|
-
protected
|
|
2031
|
-
email: string;
|
|
2032
|
-
existingUser: UserCredentials | null;
|
|
2033
|
-
}): Promise<DuplicateAction>;
|
|
2376
|
+
protected issueTrustedDevice(username: string, ip: string | undefined, ttlMs: number): Promise<TrustedDeviceRecord>;
|
|
2034
2377
|
/**
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
* entirely (just password collection).
|
|
2378
|
+
* Persist the trusted-device record onto the user store. Default: appends
|
|
2379
|
+
* onto the `trustedDevices` array.
|
|
2038
2380
|
*/
|
|
2039
|
-
protected
|
|
2381
|
+
protected storeTrustedDevice(username: string, record: TrustedDeviceRecord): Promise<void>;
|
|
2040
2382
|
/**
|
|
2041
|
-
*
|
|
2042
|
-
*
|
|
2383
|
+
* Post-MFA device-trust issuance. SECURITY: must bail when
|
|
2384
|
+
* `ctx.newPasswordRequired` is true — issuing a trusted-device token before
|
|
2385
|
+
* the user has set their own password would let an admin-set temporary
|
|
2386
|
+
* credential establish persistent device trust (defence-in-depth on top of
|
|
2387
|
+
* the MFA-form `hidden` expression on `rememberDevice`).
|
|
2043
2388
|
*/
|
|
2044
|
-
|
|
2389
|
+
deviceTrust(ctx: AuthWfCtx): Promise<undefined>;
|
|
2045
2390
|
/**
|
|
2046
|
-
*
|
|
2047
|
-
*
|
|
2048
|
-
*
|
|
2391
|
+
* Standalone terms-bump prompt for returning users whose accepted terms
|
|
2392
|
+
* version is stale and no carrier form ran. Delegates to
|
|
2393
|
+
* `processInlineConsent` for validation + ctx writes.
|
|
2049
2394
|
*/
|
|
2050
|
-
|
|
2395
|
+
termsBumpPrompt(ctx: AuthWfCtx): undefined;
|
|
2051
2396
|
/**
|
|
2052
|
-
*
|
|
2053
|
-
*
|
|
2054
|
-
* `loginUrl` defaults to `this.authOpts.loginUrl` (the cross-workflow shared
|
|
2055
|
-
* login URL); customers can still override per-tenant by overriding this
|
|
2056
|
-
* resolver — the field stays on the policy surface.
|
|
2397
|
+
* Load the user's active session count for the concurrency-limit gate.
|
|
2398
|
+
* Pure data-load — calls the overridable `loadActiveSessionsCount` hook.
|
|
2057
2399
|
*/
|
|
2058
|
-
|
|
2400
|
+
loadActiveSessions(ctx: AuthWfCtx): Promise<undefined>;
|
|
2059
2401
|
/**
|
|
2060
|
-
*
|
|
2061
|
-
*
|
|
2402
|
+
* Concurrency-limit gate — pauses for `ConcurrencyLimitForm`. `reject` mode
|
|
2403
|
+
* blocks the login outright with a form-level error. `kickPrompt` mode pauses
|
|
2404
|
+
* on the fieldless prompt; submitting it (the 'Login' button) logs out the
|
|
2405
|
+
* user's other sessions and continues. `resolveInput()` throws to pause on
|
|
2406
|
+
* first arrival and returns once the submit resumes the step.
|
|
2062
2407
|
*/
|
|
2063
|
-
|
|
2408
|
+
concurrencyLimit(ctx: AuthWfCtx): Promise<unknown>;
|
|
2064
2409
|
/**
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2410
|
+
* Consumer extension point — override in your subclass to inject extra
|
|
2411
|
+
* accept-tail logic (input pauses, alt actions, persistence). Default:
|
|
2412
|
+
* no-op.
|
|
2067
2413
|
*/
|
|
2068
|
-
|
|
2414
|
+
extraStep(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2069
2415
|
/**
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
* customers override the resolver for per-tenant issuers. Pincode timers/
|
|
2073
|
-
* length live on `AuthOpts.mfa`. Sync/async friendly.
|
|
2416
|
+
* Batched consent persistence — fans one `ConsentEvent` per pending
|
|
2417
|
+
* descriptor out to the `ConsentStore.save` DI provider.
|
|
2074
2418
|
*/
|
|
2075
|
-
|
|
2076
|
-
prepareAdminForm(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2077
|
-
prepareSend(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2078
|
-
prepareAccept(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2079
|
-
prepareCancellation(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2080
|
-
prepareAudit(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2081
|
-
prepareMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2419
|
+
persistConsents(ctx: AuthWfCtx): Promise<undefined>;
|
|
2082
2420
|
/**
|
|
2083
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2421
|
+
* Revoke the user's existing sessions. Shared by recovery (gated upstream by
|
|
2422
|
+
* `ctx.postReset.revokeAllSessions`) and authenticated change-password (gated
|
|
2423
|
+
* by `ctx.changePassword.revokeOtherSessions`).
|
|
2424
|
+
*
|
|
2425
|
+
* - Change-password runs in an authenticated context, so we KEEP the caller's
|
|
2426
|
+
* current device via `revokeOtherSessions(username, currentSessionId)` —
|
|
2427
|
+
* OWASP "invalidate other sessions" without logging the user out of the tab
|
|
2428
|
+
* they just changed their password in. If the current session can't be
|
|
2429
|
+
* resolved (no session id), fall back to revoking everything (fail-secure).
|
|
2430
|
+
* - Recovery is anonymous (no current session to keep) → `revokeAllForUser`.
|
|
2431
|
+
*/
|
|
2432
|
+
revokeSessions(ctx: AuthWfCtx): Promise<undefined>;
|
|
2433
|
+
/**
|
|
2434
|
+
* Lift a failed-login lockout after a successful password reset. Recovery
|
|
2435
|
+
* only — gated upstream by `ctx.lockout?.mode === "self-service"` so the
|
|
2436
|
+
* `admin-only` mode keeps the account frozen (reset succeeds, lock stays)
|
|
2437
|
+
* and `temporary` continues to rely on its own timeout. `unlockAccount`
|
|
2438
|
+
* also zeroes `failedLoginAttempts`, so the next login starts clean.
|
|
2439
|
+
*/
|
|
2440
|
+
unlockAccount(ctx: AuthWfCtx): Promise<undefined>;
|
|
2441
|
+
/**
|
|
2442
|
+
* Issue access + refresh tokens via `auth.issue`. Stashes the login
|
|
2443
|
+
* response envelope on `useWfFinished` so downstream `redirect` can
|
|
2444
|
+
* override with a redirect envelope while preserving the cookies.
|
|
2445
|
+
*/
|
|
2446
|
+
issue(ctx: AuthWfCtx): Promise<void>;
|
|
2447
|
+
/**
|
|
2448
|
+
* Notify the user of a login from a new device via the unified `deliver`
|
|
2449
|
+
* hook. Gated upstream by
|
|
2450
|
+
* `!ctx.isFirstLogin && !!ctx.finalize.notifyNewDevice && !!ctx.trust.newDevice`.
|
|
2451
|
+
*/
|
|
2452
|
+
notifyNewDevice(ctx: AuthWfCtx): Promise<undefined>;
|
|
2453
|
+
/**
|
|
2454
|
+
* Set `ctx.completion.redirectUrl` from `resolveRedirect`. When set,
|
|
2455
|
+
* overrides `issue`'s data envelope with an immediate-redirect envelope
|
|
2456
|
+
* (cookies from `issue` are preserved).
|
|
2457
|
+
*/
|
|
2458
|
+
redirect(ctx: AuthWfCtx): undefined;
|
|
2459
|
+
/**
|
|
2460
|
+
* Authorization-server terminal (AUTH-SERVER.md §4.4). Reached INSTEAD of
|
|
2461
|
+
* `issue`/`redirect` when this login was started from `GET /auth/authorize`
|
|
2462
|
+
* (`ctx.authz` set by `init-login` or re-raised by `sso-callback`). Mints a
|
|
2463
|
+
* single-use authorization code bound to the authenticated user + the pending
|
|
2464
|
+
* request's PKCE challenge / redirect / token policy, then 302s the browser to
|
|
2465
|
+
* `redirect_uri?code&state`. It does NOT issue a session and attaches NO
|
|
2466
|
+
* cookies — the token is minted later, off the browser, at `POST /auth/token`.
|
|
2467
|
+
*/
|
|
2468
|
+
mintAuthzCode(ctx: AuthWfCtx): Promise<undefined>;
|
|
2469
|
+
/**
|
|
2470
|
+
* Fresh-login finalize — invite + recovery. Emits a finish envelope that
|
|
2471
|
+
* redirects the user to `loginUrl`. Invite uses an immediate redirect;
|
|
2472
|
+
* recovery uses an auto countdown so the user reads the "Password updated"
|
|
2473
|
+
* confirmation first. Discriminated by ctx-slot presence
|
|
2474
|
+
* (`ctx.postReset` → recovery; otherwise invite).
|
|
2475
|
+
*/
|
|
2476
|
+
finalizeFreshLogin(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2477
|
+
/**
|
|
2478
|
+
* Post-reset store read: did an `admin-only` lockout survive the password
|
|
2479
|
+
* reset (account still frozen)? Callers MUST first confirm
|
|
2480
|
+
* `ctx.lockout?.mode === "admin-only"` so this read stays off the sync fast
|
|
2481
|
+
* path for every other recovery + invite finalize — `self-service` ran
|
|
2482
|
+
* `unlock-account` and `temporary` auto-expires, so only `admin-only` can
|
|
2483
|
+
* reach finalize still locked. Shared by BOTH finalize terminals: fresh-login
|
|
2484
|
+
* warns instead of confirming success, auto-login warns instead of minting
|
|
2485
|
+
* tokens (a still-frozen account must never be logged straight in).
|
|
2486
|
+
*/
|
|
2487
|
+
private recoveryLeftAccountLocked;
|
|
2488
|
+
/**
|
|
2489
|
+
* Emit the recovery password-reset terminal. `stillLocked` is only ever true
|
|
2490
|
+
* for an `admin-only` account whose lock survived the reset — that terminal
|
|
2491
|
+
* warns the user the account remains frozen and offers a manual back-to-
|
|
2492
|
+
* sign-in (no misleading auto-redirect to a login they can't pass yet).
|
|
2493
|
+
* Every other reset confirms success and auto-redirects to sign-in.
|
|
2494
|
+
*/
|
|
2495
|
+
private finishRecoveryReset;
|
|
2496
|
+
/**
|
|
2497
|
+
* Auto-login finalize — invite + recovery. Issues access + refresh tokens
|
|
2498
|
+
* and stashes the login response envelope on `useWfFinished`. Invite
|
|
2499
|
+
* preserves any `message` set by an earlier terminal (`confirmation`) so
|
|
2500
|
+
* the SPA paints the confirmation text alongside the tokens (WF-INVITE-020).
|
|
2501
|
+
*/
|
|
2502
|
+
finalizeAutoLogin(ctx: AuthWfCtx): Promise<undefined>;
|
|
2503
|
+
/**
|
|
2504
|
+
* Entry step of `auth/signup/flow`. Inline-resolves the signup policy (runs
|
|
2505
|
+
* BEFORE the `!allowSignup` gate, mirroring how `credentials` / `request`
|
|
2506
|
+
* inline the front policies they need) and stamps `ctx.signup` — whose
|
|
2507
|
+
* presence is the flow discriminator. Sets `ctx.autoLogin = true`: v1 always
|
|
2508
|
+
* issues a session on success (the shared `finalize-fresh-login` assumes
|
|
2509
|
+
* invite/recovery ctx slots, so signup uses `finalize-auto-login` only).
|
|
2510
|
+
* When self-signup is disabled (the default), emits a terminal finish so the
|
|
2511
|
+
* SPA shows a closed-signups message instead of a form; the schema's
|
|
2512
|
+
* `{ break: !allowSignup }` short-circuits the rest.
|
|
2513
|
+
*/
|
|
2514
|
+
initSignup(ctx: AuthWfCtx): undefined | Promise<undefined>;
|
|
2515
|
+
/**
|
|
2516
|
+
* Collect the signup email (verify-first — no account exists yet). First
|
|
2517
|
+
* entry pauses on `SignupForm`; on submit, stashes `ctx.email` and flips
|
|
2518
|
+
* `ctx.signup.submitted` to open the OTP loop. `backToLogin` aborts to the
|
|
2519
|
+
* login page. The bundled form is email-only (`username := email`); a custom
|
|
2520
|
+
* `opts.forms.signup` + an override here can collect more.
|
|
2521
|
+
*/
|
|
2522
|
+
signupForm(ctx: AuthWfCtx): Promise<undefined>;
|
|
2523
|
+
/**
|
|
2524
|
+
* Create the account — runs AFTER the OTP loop, so the email is proven. The
|
|
2525
|
+
* existence check lives HERE (not before the OTP) so the wire path is
|
|
2526
|
+
* identical for new and already-registered emails: both received an OTP
|
|
2527
|
+
* pause, so an attacker on the wire cannot enumerate accounts. A taken email
|
|
2528
|
+
* is only revealed to someone who actually controls the inbox, at which point
|
|
2529
|
+
* we route them to sign-in. A new email creates the (still-inactive) user and
|
|
2530
|
+
* arms the shared password-set phase (`newPasswordRequired`); the reused
|
|
2531
|
+
* `activate-user` step flips it active AFTER the password is set.
|
|
2532
|
+
*/
|
|
2533
|
+
signupCreateUser(ctx: AuthWfCtx): Promise<undefined>;
|
|
2534
|
+
/** Generic "you already have an account" finish for the signup existence collision (safe — only reached post-OTP). */
|
|
2535
|
+
private finishSignupAlreadyRegistered;
|
|
2536
|
+
/**
|
|
2537
|
+
* Customer extension point for signup — runs after the account is created,
|
|
2538
|
+
* activated, and consents persisted, just before `finalize-auto-login`. The
|
|
2539
|
+
* default is a no-op; a subclass overrides it to seed app-specific rows
|
|
2540
|
+
* (tenant, profile, welcome email, audit record, ConsentStore.save, …) for
|
|
2541
|
+
* the freshly-created `ctx.subject`. Mirrors login's `extra-step` seam.
|
|
2542
|
+
*/
|
|
2543
|
+
signupExtraStep(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2544
|
+
magicLinkRequest(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2545
|
+
magicLinkSend(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2546
|
+
magicLinkVerified(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2547
|
+
passkey(_ctx: AuthWfCtx): unknown | Promise<unknown>;
|
|
2548
|
+
/**
|
|
2549
|
+
* Verify the OAuth callback and resolve a user. Reaches here (instead of
|
|
2550
|
+
* `credentials`) when `init-login` saw an inbound `state` (`ctx.idpInbound`);
|
|
2551
|
+
* reads `{ provider, code, state, error }` from the START input (the SPA
|
|
2552
|
+
* bridges the provider callback into `/auth/trigger` STARTING `auth/login/flow`).
|
|
2553
|
+
* Order is security-critical:
|
|
2088
2554
|
*
|
|
2089
|
-
*
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2095
|
-
|
|
2096
|
-
|
|
2555
|
+
* verify state (HS256) → CSRF double-submit → re-derive PKCE verifier/nonce
|
|
2556
|
+
* from the seed → provider.exchange (verified ID token) → link OR resolveUser
|
|
2557
|
+
* → ACCOUNT-STATE GATE → seed ctx.subject → fall through to the shared tail.
|
|
2558
|
+
*
|
|
2559
|
+
* Replay defense is the provider's ONE-TIME `code` (a replayed callback fails
|
|
2560
|
+
* at `exchange` when the provider rejects the already-redeemed code), plus the
|
|
2561
|
+
* short-TTL signed state + CSRF cookie — the stateless design carries no
|
|
2562
|
+
* single-use server marker, by design.
|
|
2563
|
+
*
|
|
2564
|
+
* Every pre-subject failure collapses to one benign redirect terminal
|
|
2565
|
+
* (`finishOAuth`) so the wire is not an oracle for which check tripped. The
|
|
2566
|
+
* account-state gate MUST live here — `issue` does not re-gate, so without it
|
|
2567
|
+
* a locked/inactive account could log straight in via OAuth.
|
|
2568
|
+
*/
|
|
2569
|
+
ssoCallback(ctx: AuthWfCtx): Promise<undefined>;
|
|
2570
|
+
/**
|
|
2571
|
+
* Emit a benign, generic federated-login failure terminal (immediate redirect
|
|
2572
|
+
* to {@link resolveOAuthErrorRedirect}). Collapses EVERY pre-subject failure
|
|
2573
|
+
* mode so the wire response is never an oracle for which check tripped
|
|
2574
|
+
* (invariant #5). Returns `undefined` so the step can `return this.finishOAuth(...)`;
|
|
2575
|
+
* `{ break: !ctx.subject }` then halts the flow (subject is never set on failure).
|
|
2576
|
+
*
|
|
2577
|
+
* The precise `reason` is handed to `resolveOAuthErrorRedirect` (the override
|
|
2578
|
+
* seam — a consumer MAY branch the target on it, server-side) but the
|
|
2579
|
+
* CLIENT-facing `action.reason` is deliberately the constant `"oauth-failed"`:
|
|
2580
|
+
* exposing `oauth-${reason}` to the SPA would re-introduce the very
|
|
2581
|
+
* which-check-tripped oracle invariant #5 forbids.
|
|
2582
|
+
*/
|
|
2583
|
+
private finishOAuth;
|
|
2584
|
+
/**
|
|
2585
|
+
* Seed `ctx.email` / `ctx.channel` from a resolved user's confirmed channels —
|
|
2586
|
+
* shared by `ssoCallback` (linked / created / auto-linked) and `proveControl`
|
|
2587
|
+
* (interactively-linked) so the post-success channel shape can't drift between
|
|
2588
|
+
* the two federated entry points. Mirrors `credentials`' post-login seeding.
|
|
2589
|
+
* `fallbackEmail` (the provider / snapshot email) is a DISPLAY fallback only —
|
|
2590
|
+
* never promoted to the unique login handle (a gated, later-phase concern).
|
|
2591
|
+
*/
|
|
2592
|
+
private seedChannelState;
|
|
2593
|
+
/**
|
|
2594
|
+
* `needs-link` setup (decision A — password, OTP fallback). Decide how the
|
|
2595
|
+
* user will prove control of the matched account, stash the pending-link
|
|
2596
|
+
* state, and return so the `prove-control` @Step (gated on `ctx.pendingLink`)
|
|
2597
|
+
* pauses for the challenge. Deliberately does NOT set `ctx.subject` — proving
|
|
2598
|
+
* control is exactly what authorizes that.
|
|
2599
|
+
*
|
|
2600
|
+
* Proof channel:
|
|
2601
|
+
* - account has a real password (`!password.isInitial`) → `password`;
|
|
2602
|
+
* - else a confirmed email/SMS factor exists → `otp` to THAT channel (NEVER
|
|
2603
|
+
* the provider-supplied email — the attacker controls the provider account,
|
|
2604
|
+
* so a code sent there would be circular);
|
|
2605
|
+
* - else no provable channel → generic terminal (cannot safely link).
|
|
2606
|
+
*/
|
|
2607
|
+
protected stashPendingLink(ctx: AuthWfCtx, profile: NormalizedProfile, candidateUserId: string, redirect: string): Promise<undefined>;
|
|
2608
|
+
/**
|
|
2609
|
+
* Mint + deliver an OTP proof code to the pending-link candidate's OWN
|
|
2610
|
+
* confirmed channel (NEVER the provider-supplied email — that would be
|
|
2611
|
+
* circular) and arm the resend cooldown. Shared by the first auto-dispatch
|
|
2612
|
+
* and the `resend` action. Returns the masked delivery target, or `null` if
|
|
2613
|
+
* the confirmed channel vanished between resolve and dispatch (the caller
|
|
2614
|
+
* routes that to the safe generic terminal).
|
|
2615
|
+
*/
|
|
2616
|
+
private deliverPendingLinkPin;
|
|
2617
|
+
/**
|
|
2618
|
+
* Interactive `needs-link` completion — prove control of the matched local
|
|
2619
|
+
* account, then attach the verified federated identity to it. Gated by the
|
|
2620
|
+
* login schema on `ctx.pendingLink && !ctx.subject`, so it runs ONLY on the
|
|
2621
|
+
* federated email-collision path and ONLY while the account is unproven.
|
|
2622
|
+
*
|
|
2623
|
+
* PASSWORD mode re-verifies the account's password via `UserService.login`
|
|
2624
|
+
* with the username bound server-side from `candidateUserId` (the user never
|
|
2625
|
+
* types it, so this can't be repurposed to sign into a different account).
|
|
2626
|
+
* OTP mode verifies a code delivered to the account's OWN confirmed channel.
|
|
2627
|
+
* A wrong proof re-pauses with a generic inline error; `cancel` abandons the
|
|
2628
|
+
* link. On success: `linkIdentity` (cross-user `ALREADY_EXISTS` guarded) →
|
|
2629
|
+
* account-state gate → set `ctx.subject` + `ctx.oauth` → seed channel state →
|
|
2630
|
+
* fall through to the shared login tail exactly like any other login.
|
|
2631
|
+
*/
|
|
2632
|
+
proveControl(ctx: AuthWfCtx): Promise<undefined>;
|
|
2633
|
+
/**
|
|
2634
|
+
* login.flow — `wfid = '<controller-prefix>/auth/login/flow'` once wired.
|
|
2635
|
+
* `@Public()` on the body because the wf adapter dispatches the flow body
|
|
2636
|
+
* on every `start()` / `resume()` call (anonymous login).
|
|
2637
|
+
*/
|
|
2638
|
+
loginFlow(): void;
|
|
2639
|
+
/**
|
|
2640
|
+
* invite.start — admin-phase + anonymous magic-link accept-tail. Admin
|
|
2641
|
+
* steps are arbac-evaluated (no `@Public()` on them); accept-tail steps are
|
|
2642
|
+
* all `@Public()` (anonymous resume). The body itself is `@Public()` so the
|
|
2643
|
+
* wf adapter can dispatch start/resume on anonymous magic-link clicks.
|
|
2644
|
+
*/
|
|
2097
2645
|
inviteFlow(): void;
|
|
2098
|
-
reInviteFlow(): void;
|
|
2099
|
-
cancelInviteFlow(): void;
|
|
2100
|
-
init(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2101
|
-
prepareAvailableRoles(ctx: InviteWfCtx): Promise<undefined>;
|
|
2102
|
-
selectSendMode(ctx: InviteWfCtx): unknown;
|
|
2103
|
-
adminInviteForm(ctx: InviteWfCtx): Promise<unknown>;
|
|
2104
|
-
inferRolesStep(ctx: InviteWfCtx): Promise<undefined>;
|
|
2105
|
-
/**
|
|
2106
|
-
* Build the extras dict that `create-user` merges into the new user
|
|
2107
|
-
* row. Calls `prepareUser({email, firstName, lastName, roles, invitedBy})`
|
|
2108
|
-
* and writes the result onto `ctx.userExtras`. Split out of the old
|
|
2109
|
-
* `invitePreCreateUser` step so consumers can inject e.g. a
|
|
2110
|
-
* tenant-validation step between extras-build and create-user without
|
|
2111
|
-
* copying the createUser body.
|
|
2112
|
-
*/
|
|
2113
|
-
buildUserExtras(ctx: InviteWfCtx): Promise<undefined>;
|
|
2114
|
-
/**
|
|
2115
|
-
* Create the user row from `ctx.userExtras` (plus the admin-supplied
|
|
2116
|
-
* `ctx.roles`), translate store-level CONFLICT into HTTP 409, then stamp
|
|
2117
|
-
* `pendingInvitation = true` via a deep-merge update so the
|
|
2118
|
-
* `createUser`-applied account defaults (`active: false`, `locked: false`)
|
|
2119
|
-
* survive. Split out of the old `invitePreCreateUser` step so consumers can
|
|
2120
|
-
* override extras-build (`build-user-extras`) without touching the
|
|
2121
|
-
* store-write transaction.
|
|
2122
|
-
*/
|
|
2123
|
-
createUserStep(ctx: InviteWfCtx): Promise<undefined>;
|
|
2124
|
-
sendInviteEmail(ctx: InviteWfCtx): unknown;
|
|
2125
|
-
returnShareableLink(ctx: InviteWfCtx): unknown;
|
|
2126
|
-
loadPendingUser(ctx: InviteWfCtx): Promise<unknown>;
|
|
2127
|
-
checkPendingInvitation(ctx: InviteWfCtx): Promise<undefined>;
|
|
2128
|
-
idempotentRedirect(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2129
|
-
preparePasswordRules(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2130
|
-
createPasswordForm(ctx: InviteWfCtx): Promise<unknown>;
|
|
2131
|
-
/**
|
|
2132
|
-
* Build the `MfaEnrollDeps` payload shared by all three invite enrollment
|
|
2133
|
-
* step bodies. Sets `ctx.enrollMode` (mirrored onto ctx so
|
|
2134
|
-
* `EnrollPickMethodForm` can hide the `skip` action unless mode is
|
|
2135
|
-
* `'optional'`). Omits `onComplete` because invite's enrollment while-loop
|
|
2136
|
-
* is gated on `!enrollDone` directly — no mirror needed.
|
|
2137
|
-
*/
|
|
2138
|
-
private buildInviteEnrollDeps;
|
|
2139
|
-
/**
|
|
2140
|
-
* Prepare MFA enrolment setup: writes `ctx.mfaMode`,
|
|
2141
|
-
* `ctx.availableMfaTransports`, and pre-picks `ctx.enrollMethod` when only
|
|
2142
|
-
* one transport is available. Override to compute any of the three from
|
|
2143
|
-
* tenant policy / invitee role / request context in a single hook. Return
|
|
2144
|
-
* type allows a sync override (skip the promise round-trip) when no async
|
|
2145
|
-
* work is needed.
|
|
2146
|
-
*/
|
|
2147
|
-
inviteSetupMfa(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2148
|
-
/**
|
|
2149
|
-
* Forced MFA enrollment — Phase 1 (pick method). Auto-picks a single
|
|
2150
|
-
* transport, otherwise pauses for the picker form. When TOTP is picked, the
|
|
2151
|
-
* secret is provisioned in the same step body (see `enrollPickPhase`).
|
|
2152
|
-
*/
|
|
2153
|
-
inviteEnrollPickMethod(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2154
|
-
/**
|
|
2155
|
-
* Forced MFA enrollment — Phase 2 (collect sms/email address + send
|
|
2156
|
-
* pincode). Gated out for totp by the schema condition.
|
|
2157
|
-
*/
|
|
2158
|
-
inviteEnrollAddress(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2159
|
-
/**
|
|
2160
|
-
* Forced MFA enrollment — Phase 3 (verify code + activate method). Sets
|
|
2161
|
-
* `enrollDone` on success, which the schema's enrollment while-loop reads
|
|
2162
|
-
* as the exit signal directly.
|
|
2163
|
-
*/
|
|
2164
|
-
inviteEnrollConfirm(ctx: InviteWfCtx): undefined | Promise<undefined>;
|
|
2165
|
-
collectProfile(ctx: InviteWfCtx): Promise<unknown>;
|
|
2166
|
-
applyProfileStep(ctx: InviteWfCtx): Promise<undefined>;
|
|
2167
2646
|
/**
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
*
|
|
2177
|
-
* `
|
|
2178
|
-
*
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2647
|
+
* recovery.flow — OTP-via-email reset. `@Public()` on the body because
|
|
2648
|
+
* anonymous users start recovery.
|
|
2649
|
+
*/
|
|
2650
|
+
recoveryFlow(): void;
|
|
2651
|
+
/**
|
|
2652
|
+
* change-password.flow — authenticated self-service "change my password".
|
|
2653
|
+
*
|
|
2654
|
+
* `@Public()` on the body lets the wf adapter dispatch start/resume (mirrors
|
|
2655
|
+
* the other flows); the FLOW itself is NOT public — `init-change-password` is
|
|
2656
|
+
* arbac-gated (`auth:change-password`) and binds `ctx.subject` from the
|
|
2657
|
+
* session, so an unauthenticated / unauthorized caller is rejected at the
|
|
2658
|
+
* first step. Customers forbid the feature (e.g. SSO-only orgs) by denying
|
|
2659
|
+
* the `change-password` action — there is no on/off opts flag.
|
|
2660
|
+
*
|
|
2661
|
+
* NOT in `DEFAULT_AUTH_WORKFLOWS` — it must be reached via a GUARDED trigger
|
|
2662
|
+
* route (see `AuthController.changePassword`), never the public
|
|
2663
|
+
* `/auth/trigger`.
|
|
2664
|
+
*/
|
|
2665
|
+
changePasswordFlow(): void;
|
|
2666
|
+
/**
|
|
2667
|
+
* add-mfa.flow — authenticated self-service "add a second factor". Same
|
|
2668
|
+
* gating model as change-password: NOT `@Public()` — `init-add-mfa` is arbac-
|
|
2669
|
+
* gated (`auth.add-mfa` / `self`) and binds `ctx.subject` from the session, so
|
|
2670
|
+
* an unauthenticated / unauthorized caller is rejected at the first step. NOT
|
|
2671
|
+
* in `DEFAULT_AUTH_WORKFLOWS` — reached only via the GUARDED trigger route
|
|
2672
|
+
* (`AuthController.addMfa`), never the public `/auth/trigger`.
|
|
2673
|
+
*
|
|
2674
|
+
* The body REUSES the login/invite forced-enrolment trio verbatim
|
|
2675
|
+
* (`enroll-pick-method` → `enroll-address` → `enroll-confirm`); the only
|
|
2676
|
+
* difference is the driver: `init-add-mfa` narrows `ctx.mfaPolicy`
|
|
2677
|
+
* `availableTransports` to the transports the user has NOT enrolled, so the
|
|
2678
|
+
* picker offers exactly those and auto-picks when one remains. Available only
|
|
2679
|
+
* when something is un-enrolled — with everything enrolled the trio is skipped
|
|
2680
|
+
* and `finish-add-mfa` returns a benign "nothing to add" terminal.
|
|
2681
|
+
*/
|
|
2682
|
+
addMfaFlow(): void;
|
|
2683
|
+
/**
|
|
2684
|
+
* signup.flow — verify-first self-signup. `@Public()` on the body (anonymous).
|
|
2685
|
+
* NOT arbac-gated at the flow level: the `resolveSignupPolicy().allowSignup`
|
|
2686
|
+
* gate is the on/off switch (default OFF — invite-only is the safe default).
|
|
2687
|
+
* Reachable via the public `/auth/trigger` (add `auth/signup/flow` to the
|
|
2688
|
+
* controller's `DEFAULT_AUTH_WORKFLOWS`).
|
|
2689
|
+
*
|
|
2690
|
+
* Shape = recovery's email→OTP front + invite's create→set-password→activate→
|
|
2691
|
+
* auto-login tail, so it reuses `pincodeSendCheckPair`, `passwordPhaseSchema`,
|
|
2692
|
+
* `prepare-consents` + `consentsPersistTailSchema`, `activate-user`, and
|
|
2693
|
+
* `finalize-auto-login` verbatim. The account-existence check is deferred to
|
|
2694
|
+
* `signup-create-user` (POST-OTP) so account existence never leaks on the
|
|
2695
|
+
* wire — every email gets an identical OTP pause regardless of whether it is
|
|
2696
|
+
* already registered.
|
|
2697
|
+
*/
|
|
2698
|
+
signupFlow(): void;
|
|
2201
2699
|
}
|
|
2202
2700
|
//#endregion
|
|
2203
2701
|
//#region src/workflows/auth-email-outlet.d.ts
|
|
@@ -2214,12 +2712,31 @@ interface AuthEmailOutletDeps {
|
|
|
2214
2712
|
*/
|
|
2215
2713
|
declare function createAuthEmailOutlet(deps: AuthEmailOutletDeps): WfOutlet;
|
|
2216
2714
|
//#endregion
|
|
2217
|
-
//#region src/
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2715
|
+
//#region src/audit/index.d.ts
|
|
2716
|
+
/**
|
|
2717
|
+
* Audit event emitter — used by `AuthWorkflow`'s audit-login step (and
|
|
2718
|
+
* future recovery / invite audit steps) to fan out login.success and similar
|
|
2719
|
+
* events to consumer-supplied sinks (DB table, log file, Kafka topic).
|
|
2720
|
+
*
|
|
2721
|
+
* Aoothjs ships no concrete sink. Workflow subclasses override the
|
|
2722
|
+
* `audit(event)` protected method to wire their preferred sink; when not
|
|
2723
|
+
* overridden the workflow's default implementation is a no-op.
|
|
2724
|
+
*/
|
|
2725
|
+
interface AuditEvent {
|
|
2726
|
+
kind: string;
|
|
2727
|
+
/** Auth-scoped user identity (the `username` resolved by the workflow). */
|
|
2728
|
+
userId?: string;
|
|
2729
|
+
/** Workflow id that emitted the event (e.g. `auth/login/flow`). */
|
|
2730
|
+
workflow?: string;
|
|
2731
|
+
/** Source IP (when the workflow could resolve one). */
|
|
2732
|
+
ip?: string;
|
|
2733
|
+
/** User-agent header. */
|
|
2734
|
+
userAgent?: string;
|
|
2735
|
+
/** Free-form payload — `method`, `tenantId`, etc. */
|
|
2736
|
+
[key: string]: unknown;
|
|
2737
|
+
}
|
|
2738
|
+
interface AuditEmitter {
|
|
2739
|
+
emit(event: AuditEvent): Promise<void> | void;
|
|
2222
2740
|
}
|
|
2223
|
-
declare function createAuthShareableLinkOutlet(deps: AuthShareableLinkOutletDeps): WfOutlet;
|
|
2224
2741
|
//#endregion
|
|
2225
|
-
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,
|
|
2742
|
+
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 };
|