@apifuse/provider-sdk 2.1.0-beta.12 → 2.1.0-beta.14

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/AUTHORING.md CHANGED
@@ -264,6 +264,140 @@ deployment projection checks, and release workflows.
264
264
  - Credential-backed smoke requests pass local-only credential material in
265
265
  `connection.secrets`. Keep real values in shell env or `.env`, never in source
266
266
  or fixtures.
267
+ - Hand-written auth flows should use `ctx.auth` helpers and return exactly one
268
+ terminal/next turn from each handler: `ctx.auth.nextForm(...)` or
269
+ `ctx.auth.nextPoll(...)` to ask Gateway for the next user/system step,
270
+ `ctx.auth.complete(...)` to finish with `data.credential`, or
271
+ `ctx.auth.abort(...)` to stop safely. Keep abort `data` and `actionHint`
272
+ JSON-safe and secret-free; never include raw cookies, credentials, headers,
273
+ HTML, or upstream `Error` objects.
274
+
275
+ ```ts
276
+ export default defineProvider({
277
+ id: "example-provider",
278
+ version: "1.0.0",
279
+ runtime: "standard",
280
+ auth: {
281
+ mode: "credentials",
282
+ flow: {
283
+ async start(ctx) {
284
+ return ctx.auth.nextForm({
285
+ fields: {
286
+ email: { type: "email", labelKey: "auth.email.label" },
287
+ password: { type: "password", labelKey: "auth.password.label" },
288
+ },
289
+ hintKey: "auth.signIn",
290
+ });
291
+ },
292
+ async continue(ctx, input) {
293
+ const result = await loginWithSubmittedFields(ctx, input);
294
+ if (result.blocked) {
295
+ return ctx.auth.abort({
296
+ code: "account_action_required",
297
+ retry: "after_user_action",
298
+ actionHint: { kind: "open_provider_app" },
299
+ message: "Approve the login in the provider app.",
300
+ });
301
+ }
302
+ return ctx.auth.complete({
303
+ credential: { cookie: result.cookie },
304
+ metadata: { accountId: result.accountId },
305
+ });
306
+ },
307
+ },
308
+ },
309
+ credential: { keys: ["cookie"] },
310
+ // ...metadata and operations
311
+ });
312
+ ```
313
+
314
+ - Credentials auth providers should use `defineCredentialsAuth()` instead of
315
+ hand-writing `auth.flow.start/continue`. The helper exposes one happy path:
316
+ declare form `fields`, declare `credentialKeys`, and put upstream login/session
317
+ creation in `login(ctx, input)`. It returns both `auth` and `credential` for
318
+ `defineProvider()` and builds the complete turn as `data.credential`, which is
319
+ the only value Gateway persists onto the connection.
320
+
321
+ ```ts
322
+ import { defineCredentialsAuth, defineProvider } from "@apifuse/provider-sdk";
323
+
324
+ const credentialsAuth = defineCredentialsAuth({
325
+ fields: {
326
+ email: { type: "email", labelKey: "auth.email.label" },
327
+ password: { type: "password", labelKey: "auth.password.label" },
328
+ },
329
+ credentialKeys: ["cookie"] as const,
330
+ storesReusableSecret: true,
331
+ justification: "Session cookie is required for authenticated operations.",
332
+ async login(ctx, input) {
333
+ const cookie = await loginAndBuildSessionCookie(ctx, input);
334
+ return { credential: { cookie } };
335
+ },
336
+ });
337
+
338
+ export default defineProvider({
339
+ id: "example-provider",
340
+ version: "1.0.0",
341
+ runtime: "standard",
342
+ auth: credentialsAuth.auth,
343
+ credential: credentialsAuth.credential,
344
+ context: credentialsAuth.context,
345
+ // ...metadata and operations
346
+ });
347
+ ```
348
+
349
+ For OTP, MFA, CAPTCHA handoff, or user-approved login, return a challenge from
350
+ `login()` instead of hand-writing `contextPatch`, `poll`, and final credential
351
+ turns. SDK stores the pending challenge in auth-flow context, returns the next
352
+ form/pending turn, and still persists only the final `data.credential`.
353
+
354
+ ```ts
355
+ import {
356
+ credentialsAuthChallenge,
357
+ defineCredentialsAuth,
358
+ } from "@apifuse/provider-sdk";
359
+
360
+ const credentialsAuth = defineCredentialsAuth({
361
+ fields: {
362
+ email: { type: "email" },
363
+ password: { type: "password" },
364
+ },
365
+ credentialKeys: ["cookie"] as const,
366
+ async login(ctx, input) {
367
+ const result = await passwordLogin(ctx, input);
368
+ if (result.otpRequired) {
369
+ return credentialsAuthChallenge("otp", {
370
+ state: { transactionId: result.transactionId },
371
+ hintKey: "auth.otp.prompt",
372
+ });
373
+ }
374
+ if (result.manualApprovalRequired) {
375
+ return credentialsAuthChallenge("manualApproval", {
376
+ state: { transactionId: result.transactionId },
377
+ hintKey: "auth.manualApproval.openApp",
378
+ timing: { suggestedPollIntervalMs: 3000, maxWaitMs: 120000 },
379
+ });
380
+ }
381
+ return { credential: { cookie: result.cookie } };
382
+ },
383
+ challenges: {
384
+ otp: {
385
+ fields: { otp: { type: "otp", labelKey: "auth.otp.label" } },
386
+ async verify(ctx, input, state) {
387
+ const result = await verifyOtp(ctx, state.transactionId, input.otp);
388
+ return { credential: { cookie: result.cookie } };
389
+ },
390
+ },
391
+ manualApproval: {
392
+ async poll(ctx, state) {
393
+ const result = await checkApproval(ctx, state.transactionId);
394
+ if (!result.approved) return null;
395
+ return { credential: { cookie: result.cookie } };
396
+ },
397
+ },
398
+ },
399
+ });
400
+ ```
267
401
  - Auth-flow debugging starts with `/auth/start`, continues with
268
402
  `/auth/continue`, and carries returned `contextPatch` values into the next
269
403
  request's `context`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.1.0-beta.14
4
+
5
+ - Release candidate for main commit 6eb132be6abf34ad9a70bfe28c6d26b36348ff4a.
6
+
7
+ ## 2.1.0-beta.13
8
+
9
+ - Release candidate for main commit d7b12716f54781df3e40206144c167844a485f8f.
10
+
3
11
  ## 2.1.0-beta.12
4
12
 
5
13
  - Release candidate for main commit 2bc1061c6a68facaa2efde08bee31bf5cd96945e.
package/README.md CHANGED
@@ -120,6 +120,14 @@ the bad request path; provider/runtime failures include `code`, `message`, and
120
120
  `ctx.credential.get("key")` or `ctx.credential.getAccessToken()`.
121
121
  - **Provider env secrets**: declare `secrets[]`, set values in your shell or
122
122
  `.env`, and read only those names through `ctx.env.get("NAME")`.
123
+ - **Credentials auth flows**: prefer `defineCredentialsAuth()` over hand-written
124
+ `auth.flow`. Declare the form fields and credential keys once, then put the
125
+ upstream login/session creation in `login(ctx, input)`. Return
126
+ `credentialsAuthChallenge("otp" | "manualApproval" | ...)` for MFA, CAPTCHA
127
+ handoff, or user-approved login branches. The helper returns
128
+ `{ auth, credential, context }` for `defineProvider()` and always completes
129
+ with `data.credential`, which is the value Gateway persists onto the
130
+ connection.
123
131
  - **Auth flows**: call `/auth/start`, then `/auth/continue` with the same
124
132
  `flowId`; preserve any returned `contextPatch` in the next local request's
125
133
  `context` object.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { AuthError } from "./errors";
2
+ import type { AuthConfig, AuthFlowTerminalContext, AuthTurn, ContextDeclaration, CredentialDeclaration, FlowContext, ProviderLocaleKeyInput } from "./types";
3
+ export type CredentialsAuthFieldType = "string" | "email" | "password" | "otp";
4
+ export interface CredentialsAuthField {
5
+ type?: CredentialsAuthFieldType;
6
+ labelKey?: ProviderLocaleKeyInput;
7
+ descriptionKey?: ProviderLocaleKeyInput;
8
+ placeholderKey?: ProviderLocaleKeyInput;
9
+ required?: boolean;
10
+ /** Marks field values as secret UI/input material, for example passwords or OTPs. */
11
+ sensitive?: boolean;
12
+ }
13
+ export type CredentialsAuthFields = Record<string, CredentialsAuthField>;
14
+ export type CredentialsAuthInput<TFields extends CredentialsAuthFields> = {
15
+ [K in keyof TFields]: string;
16
+ };
17
+ export type CredentialsAuthCredential<TCredentialKeys extends readonly string[]> = {
18
+ [K in TCredentialKeys[number]]: string;
19
+ };
20
+ export declare function createAuthFlowHelpers(options?: {
21
+ readonly signal?: AbortSignal;
22
+ readonly deadline?: string;
23
+ }): AuthFlowTerminalContext;
24
+ export declare class AuthAbortError extends AuthError {
25
+ readonly turn: AuthTurn;
26
+ constructor(options: Parameters<AuthFlowTerminalContext["abort"]>[0]);
27
+ }
28
+ export interface CredentialsAuthCompleteResult<TCredentialKeys extends readonly string[]> {
29
+ credential: CredentialsAuthCredential<TCredentialKeys>;
30
+ /** Additional non-credential auth-flow data to return alongside credential. */
31
+ data?: Record<string, unknown>;
32
+ turnId?: string;
33
+ expiresAt?: string;
34
+ }
35
+ export interface CredentialsAuthChallengeRequest<TChallengeId extends string = string> {
36
+ kind: "challenge";
37
+ challengeId: TChallengeId;
38
+ state?: Record<string, unknown>;
39
+ turnId?: string;
40
+ hintKey?: ProviderLocaleKeyInput;
41
+ expiresAt?: string;
42
+ data?: Record<string, unknown>;
43
+ timing?: AuthTurn["timing"];
44
+ }
45
+ export type CredentialsAuthLoginResult<TCredentialKeys extends readonly string[], TChallengeId extends string = string> = CredentialsAuthCompleteResult<TCredentialKeys> | CredentialsAuthChallengeRequest<TChallengeId>;
46
+ export interface CredentialsAuthChallengeDefinition<TFields extends CredentialsAuthFields, TCredentialKeys extends readonly string[], TChallengeId extends string> {
47
+ fields?: TFields;
48
+ hintKey?: ProviderLocaleKeyInput;
49
+ turnId?: string;
50
+ retryTurnId?: string;
51
+ pendingTurnId?: string;
52
+ timing?: AuthTurn["timing"];
53
+ verify?: (ctx: FlowContext, input: CredentialsAuthInput<TFields>, state: Record<string, unknown>) => CredentialsAuthLoginResult<TCredentialKeys, TChallengeId> | Promise<CredentialsAuthLoginResult<TCredentialKeys, TChallengeId>>;
54
+ poll?: (ctx: FlowContext, state: Record<string, unknown>) => CredentialsAuthLoginResult<TCredentialKeys, TChallengeId> | null | Promise<CredentialsAuthLoginResult<TCredentialKeys, TChallengeId> | null>;
55
+ }
56
+ export interface DefineCredentialsAuthOptions<TFields extends CredentialsAuthFields, TCredentialKeys extends readonly string[], TChallenges extends Record<string, CredentialsAuthChallengeDefinition<CredentialsAuthFields, TCredentialKeys, keyof TChallenges & string>> = Record<string, CredentialsAuthChallengeDefinition<CredentialsAuthFields, TCredentialKeys, string>>> {
57
+ fields: TFields;
58
+ credentialKeys: TCredentialKeys;
59
+ storesReusableSecret?: boolean;
60
+ justification?: string;
61
+ hintKey?: ProviderLocaleKeyInput;
62
+ startTurnId?: string;
63
+ retryTurnId?: string;
64
+ completeTurnId?: string;
65
+ challenges?: TChallenges;
66
+ /** Extra auth-flow context keys used by custom login/challenge code. */
67
+ contextKeys?: readonly string[];
68
+ login(ctx: FlowContext, input: CredentialsAuthInput<TFields>): CredentialsAuthLoginResult<TCredentialKeys, keyof TChallenges & string> | Promise<CredentialsAuthLoginResult<TCredentialKeys, keyof TChallenges & string>>;
69
+ }
70
+ export interface DefinedCredentialsAuth {
71
+ auth: AuthConfig;
72
+ credential: CredentialDeclaration;
73
+ context: ContextDeclaration;
74
+ }
75
+ export declare function credentialsAuthChallenge<TChallengeId extends string>(challengeId: TChallengeId, options?: Omit<CredentialsAuthChallengeRequest<TChallengeId>, "kind" | "challengeId">): CredentialsAuthChallengeRequest<TChallengeId>;
76
+ export declare function defineCredentialsAuth<TFields extends CredentialsAuthFields, TCredentialKeys extends readonly [string, ...string[]], TChallenges extends Record<string, CredentialsAuthChallengeDefinition<CredentialsAuthFields, TCredentialKeys, keyof TChallenges & string>> = Record<string, CredentialsAuthChallengeDefinition<CredentialsAuthFields, TCredentialKeys, string>>>(options: DefineCredentialsAuthOptions<TFields, TCredentialKeys, TChallenges>): DefinedCredentialsAuth;
package/dist/auth.js ADDED
@@ -0,0 +1,436 @@
1
+ import { AuthError, ProviderError } from "./errors";
2
+ const CREDENTIALS_AUTH_CHALLENGE_CONTEXT_KEY = "__credentialsAuthChallenge";
3
+ const DEFAULT_COMPLETE_TURN_ID = "auth.complete";
4
+ const DEFAULT_ABORT_TURN_ID = "auth.abort";
5
+ const DEFAULT_FORM_TURN_ID = "auth.form";
6
+ const DEFAULT_POLL_TURN_ID = "auth.poll";
7
+ const SENSITIVE_ABORT_DATA_KEY_PATTERN = /(authorization|cookie|credential|header|html|password|secret|session|token|apikey)/i;
8
+ function normalizedAuthDataKey(key) {
9
+ return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
10
+ }
11
+ function isPlainAuthJsonObject(value) {
12
+ const prototype = Object.getPrototypeOf(value);
13
+ return prototype === Object.prototype || prototype === null;
14
+ }
15
+ function isSensitiveAuthDataKey(key) {
16
+ return SENSITIVE_ABORT_DATA_KEY_PATTERN.test(normalizedAuthDataKey(key));
17
+ }
18
+ function assertSafeAuthJson(value, path) {
19
+ if (value === null)
20
+ return;
21
+ if (typeof value === "string" || typeof value === "boolean")
22
+ return;
23
+ if (typeof value === "number") {
24
+ if (Number.isFinite(value))
25
+ return;
26
+ throw new AuthError(`Auth abort ${path} must be a finite number`, {
27
+ code: "auth_abort_unsafe_data",
28
+ });
29
+ }
30
+ if (Array.isArray(value)) {
31
+ for (const [index, item] of value.entries()) {
32
+ assertSafeAuthJson(item, `${path}[${index}]`);
33
+ }
34
+ return;
35
+ }
36
+ if (value instanceof Error) {
37
+ throw new AuthError(`Auth abort ${path} must not include Error objects`, {
38
+ code: "auth_abort_unsafe_data",
39
+ });
40
+ }
41
+ if (typeof value !== "object") {
42
+ throw new AuthError(`Auth abort ${path} must be JSON-safe`, {
43
+ code: "auth_abort_unsafe_data",
44
+ });
45
+ }
46
+ if (!isPlainAuthJsonObject(value)) {
47
+ throw new AuthError(`Auth abort ${path} must be a plain JSON object`, {
48
+ code: "auth_abort_unsafe_data",
49
+ });
50
+ }
51
+ for (const [key, item] of Object.entries(value)) {
52
+ if (isSensitiveAuthDataKey(key)) {
53
+ throw new AuthError(`Auth abort ${path}.${key} must not include secrets`, {
54
+ code: "auth_abort_unsafe_data",
55
+ });
56
+ }
57
+ assertSafeAuthJson(item, `${path}.${key}`);
58
+ }
59
+ }
60
+ function assertSafeAuthData(data, path) {
61
+ assertSafeAuthJson(data, path);
62
+ }
63
+ function authTurnBase(options) {
64
+ return {
65
+ turnId: options.turnId ?? options.defaultTurnId,
66
+ ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
67
+ };
68
+ }
69
+ function abortData(options) {
70
+ if (options.actionHint !== undefined) {
71
+ assertSafeAuthJson(options.actionHint, "actionHint");
72
+ }
73
+ if (options.data !== undefined) {
74
+ assertSafeAuthData(options.data, "data");
75
+ }
76
+ return {
77
+ code: options.code,
78
+ ...(options.message ? { message: options.message } : {}),
79
+ ...(options.retry ? { retry: options.retry } : {}),
80
+ ...(options.actionHint !== undefined ? { actionHint: options.actionHint } : {}),
81
+ ...(options.fieldErrors ? { fieldErrors: options.fieldErrors } : {}),
82
+ ...(options.data ? { details: options.data } : {}),
83
+ };
84
+ }
85
+ export function createAuthFlowHelpers(options = {}) {
86
+ return {
87
+ ...(options.signal ? { signal: options.signal } : {}),
88
+ ...(options.deadline ? { deadline: options.deadline } : {}),
89
+ complete({ credential, metadata, data, turnId, expiresAt }) {
90
+ return {
91
+ kind: "complete",
92
+ ...authTurnBase({ turnId, defaultTurnId: DEFAULT_COMPLETE_TURN_ID, expiresAt }),
93
+ data: {
94
+ ...(data ?? {}),
95
+ credential,
96
+ ...(metadata ? { metadata } : {}),
97
+ },
98
+ };
99
+ },
100
+ abort({ code, message, retry, actionHint, fieldErrors, data, turnId, expiresAt, }) {
101
+ return {
102
+ kind: "abort",
103
+ ...authTurnBase({ turnId, defaultTurnId: DEFAULT_ABORT_TURN_ID, expiresAt }),
104
+ data: abortData({
105
+ code,
106
+ message,
107
+ retry,
108
+ actionHint,
109
+ fieldErrors,
110
+ data,
111
+ }),
112
+ };
113
+ },
114
+ nextForm(options) {
115
+ return {
116
+ kind: "form",
117
+ ...authTurnBase({
118
+ turnId: options.turnId,
119
+ defaultTurnId: DEFAULT_FORM_TURN_ID,
120
+ expiresAt: options.expiresAt,
121
+ }),
122
+ ...(options.hintKey ? { hintKey: options.hintKey } : {}),
123
+ ...(options.timing ? { timing: options.timing } : {}),
124
+ ...(options.data ? { data: options.data } : {}),
125
+ expectedInput: options.expectedInput ?? expectedInputFromFields(options.fields ?? {}),
126
+ };
127
+ },
128
+ nextPoll(options = {}) {
129
+ return {
130
+ kind: "poll",
131
+ ...authTurnBase({
132
+ turnId: options.turnId,
133
+ defaultTurnId: DEFAULT_POLL_TURN_ID,
134
+ expiresAt: options.expiresAt,
135
+ }),
136
+ ...(options.hintKey ? { hintKey: options.hintKey } : {}),
137
+ ...(options.timing ? { timing: options.timing } : {}),
138
+ ...(options.data ? { data: options.data } : {}),
139
+ };
140
+ },
141
+ };
142
+ }
143
+ export class AuthAbortError extends AuthError {
144
+ turn;
145
+ constructor(options) {
146
+ super(options.message ?? options.code, {
147
+ code: options.code,
148
+ retryable: options.retry === "retry",
149
+ });
150
+ this.name = "AuthAbortError";
151
+ this.turn = createAuthFlowHelpers().abort(options);
152
+ }
153
+ }
154
+ export function credentialsAuthChallenge(challengeId, options = {}) {
155
+ return {
156
+ kind: "challenge",
157
+ challengeId,
158
+ ...options,
159
+ };
160
+ }
161
+ function expectedInputFromFields(fields) {
162
+ return {
163
+ type: "object",
164
+ properties: Object.fromEntries(Object.entries(fields).map(([name, field]) => [
165
+ name,
166
+ {
167
+ type: "string",
168
+ ...(field.type === "email" ? { format: "email" } : {}),
169
+ ...(field.type === "password" ? { format: "password" } : {}),
170
+ ...(field.type === "otp" ? { format: "otp" } : {}),
171
+ ...(field.labelKey ? { nameKey: field.labelKey } : {}),
172
+ ...(field.descriptionKey
173
+ ? { descriptionKey: field.descriptionKey }
174
+ : {}),
175
+ ...(field.placeholderKey
176
+ ? { placeholderKey: field.placeholderKey }
177
+ : {}),
178
+ ...(field.sensitive || field.type === "password" || field.type === "otp"
179
+ ? { sensitive: true }
180
+ : {}),
181
+ },
182
+ ])),
183
+ required: Object.entries(fields)
184
+ .filter(([, field]) => field.required !== false)
185
+ .map(([name]) => name),
186
+ };
187
+ }
188
+ function collectMissingFields(fields, input) {
189
+ return Object.entries(fields)
190
+ .filter(([, field]) => field.required !== false)
191
+ .map(([name]) => name)
192
+ .filter((name) => {
193
+ const value = input?.[name];
194
+ return typeof value !== "string" || value.trim().length === 0;
195
+ });
196
+ }
197
+ function normalizeInput(fields, input) {
198
+ const result = {};
199
+ for (const name of Object.keys(fields)) {
200
+ const value = input?.[name];
201
+ result[name] = typeof value === "string" ? value : "";
202
+ }
203
+ return result;
204
+ }
205
+ function assertCredentialKeys(credentialKeys, credential) {
206
+ const missing = credentialKeys.filter((key) => {
207
+ const value = credential[key];
208
+ return typeof value !== "string" || value.length === 0;
209
+ });
210
+ if (missing.length > 0) {
211
+ throw new ProviderError(`Credentials auth login completed without required credential key(s): ${missing.join(", ")}`, {
212
+ code: "credentials_auth_missing_credential_keys",
213
+ fix: "Return every credentialKeys entry from defineCredentialsAuth({ login }) as result.credential. Gateway persists only auth.flow complete data.credential into the connection.",
214
+ });
215
+ }
216
+ }
217
+ function getPendingChallenge(ctx) {
218
+ const value = ctx.context.get(CREDENTIALS_AUTH_CHALLENGE_CONTEXT_KEY);
219
+ if (!value || typeof value !== "object" || Array.isArray(value))
220
+ return null;
221
+ const record = value;
222
+ if (typeof record.challengeId !== "string")
223
+ return null;
224
+ const state = record.state && typeof record.state === "object" && !Array.isArray(record.state)
225
+ ? record.state
226
+ : {};
227
+ return {
228
+ challengeId: record.challengeId,
229
+ state,
230
+ ...(typeof record.turnId === "string" ? { turnId: record.turnId } : {}),
231
+ ...(typeof record.hintKey === "string" ? { hintKey: record.hintKey } : {}),
232
+ ...(typeof record.expiresAt === "string" ? { expiresAt: record.expiresAt } : {}),
233
+ ...(record.data && typeof record.data === "object" && !Array.isArray(record.data)
234
+ ? { data: record.data }
235
+ : {}),
236
+ ...(record.timing && typeof record.timing === "object" && !Array.isArray(record.timing)
237
+ ? { timing: record.timing }
238
+ : {}),
239
+ };
240
+ }
241
+ function setPendingChallenge(ctx, challenge) {
242
+ ctx.context.set(CREDENTIALS_AUTH_CHALLENGE_CONTEXT_KEY, challenge);
243
+ }
244
+ function clearPendingChallenge(ctx) {
245
+ ctx.context.set(CREDENTIALS_AUTH_CHALLENGE_CONTEXT_KEY, null);
246
+ }
247
+ function pendingToChallengeRequest(pending) {
248
+ return {
249
+ kind: "challenge",
250
+ challengeId: pending.challengeId,
251
+ state: pending.state,
252
+ ...(pending.turnId ? { turnId: pending.turnId } : {}),
253
+ ...(pending.hintKey ? { hintKey: pending.hintKey } : {}),
254
+ ...(pending.expiresAt ? { expiresAt: pending.expiresAt } : {}),
255
+ ...(pending.data ? { data: pending.data } : {}),
256
+ ...(pending.timing ? { timing: pending.timing } : {}),
257
+ };
258
+ }
259
+ function retryTurn(expectedInput, missing, retryTurnId) {
260
+ return {
261
+ kind: "retry",
262
+ turnId: retryTurnId,
263
+ expectedInput,
264
+ data: {
265
+ fieldErrors: Object.fromEntries(missing.map((name) => [name, "Required"])),
266
+ fieldErrorKeys: Object.fromEntries(missing.map((name) => [name, "auth.credentials.fieldRequired"])),
267
+ },
268
+ };
269
+ }
270
+ function isChallengeRequest(result) {
271
+ return "kind" in result && result.kind === "challenge";
272
+ }
273
+ function completeTurn(credentialKeys, result, defaultTurnId) {
274
+ if (!result.credential ||
275
+ typeof result.credential !== "object" ||
276
+ Array.isArray(result.credential)) {
277
+ throw new ProviderError("Credentials auth login completed without a credential object", {
278
+ code: "credentials_auth_missing_credential",
279
+ fix: "Return { credential: { ... } } from defineCredentialsAuth handlers. Gateway persists only auth.flow complete data.credential into the connection.",
280
+ });
281
+ }
282
+ assertCredentialKeys(credentialKeys, result.credential);
283
+ return {
284
+ kind: "complete",
285
+ turnId: result.turnId ?? defaultTurnId,
286
+ ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}),
287
+ data: {
288
+ ...(result.data ?? {}),
289
+ credential: result.credential,
290
+ },
291
+ };
292
+ }
293
+ function challengeTurn(definition, request) {
294
+ const expectedInput = definition.fields
295
+ ? expectedInputFromFields(definition.fields)
296
+ : undefined;
297
+ return {
298
+ kind: expectedInput ? "form" : "pending",
299
+ turnId: request.turnId ?? definition.turnId ?? `credentials.${request.challengeId}`,
300
+ ...(request.expiresAt ? { expiresAt: request.expiresAt } : {}),
301
+ ...(request.hintKey ?? definition.hintKey
302
+ ? { hintKey: request.hintKey ?? definition.hintKey }
303
+ : {}),
304
+ ...(request.timing ?? definition.timing
305
+ ? { timing: request.timing ?? definition.timing }
306
+ : {}),
307
+ ...(expectedInput ? { expectedInput } : {}),
308
+ data: {
309
+ ...(request.data ?? {}),
310
+ challengeId: request.challengeId,
311
+ },
312
+ };
313
+ }
314
+ async function resolveAuthResult(ctx, credentialKeys, challenges, result, completeTurnId) {
315
+ if (!result || typeof result !== "object") {
316
+ throw new AuthError("Credentials auth login did not return a result", {
317
+ code: "credentials_auth_invalid_login_result",
318
+ fix: "Return { credential: { ... } } or credentialsAuthChallenge(...) from defineCredentialsAuth handlers.",
319
+ });
320
+ }
321
+ if (!isChallengeRequest(result)) {
322
+ clearPendingChallenge(ctx);
323
+ return completeTurn(credentialKeys, result, completeTurnId);
324
+ }
325
+ const definition = challenges[result.challengeId];
326
+ if (!definition) {
327
+ throw new ProviderError(`Credentials auth requested unknown challenge "${result.challengeId}"`, {
328
+ code: "credentials_auth_unknown_challenge",
329
+ fix: `Add challenges.${result.challengeId} to defineCredentialsAuth({ challenges }).`,
330
+ });
331
+ }
332
+ setPendingChallenge(ctx, {
333
+ challengeId: result.challengeId,
334
+ state: result.state ?? {},
335
+ ...(result.turnId ? { turnId: result.turnId } : {}),
336
+ ...(result.hintKey ? { hintKey: result.hintKey } : {}),
337
+ ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}),
338
+ ...(result.data ? { data: result.data } : {}),
339
+ ...(result.timing ? { timing: result.timing } : {}),
340
+ });
341
+ return challengeTurn(definition, result);
342
+ }
343
+ async function continuePendingChallenge(ctx, credentialKeys, challenges, pending, rawInput, completeTurnId) {
344
+ const definition = challenges[pending.challengeId];
345
+ if (!definition) {
346
+ throw new ProviderError(`Credentials auth has pending unknown challenge "${pending.challengeId}"`, { code: "credentials_auth_unknown_pending_challenge" });
347
+ }
348
+ if (!definition.fields || !definition.verify) {
349
+ return challengeTurn(definition, pendingToChallengeRequest(pending));
350
+ }
351
+ const missing = collectMissingFields(definition.fields, rawInput);
352
+ const expectedInput = expectedInputFromFields(definition.fields);
353
+ if (missing.length > 0) {
354
+ return retryTurn(expectedInput, missing, definition.retryTurnId ?? `credentials.${pending.challengeId}.retry`);
355
+ }
356
+ const result = await definition.verify(ctx, normalizeInput(definition.fields, rawInput), pending.state);
357
+ return await resolveAuthResult(ctx, credentialKeys, challenges, result, completeTurnId);
358
+ }
359
+ async function pollPendingChallenge(ctx, credentialKeys, challenges, pending, completeTurnId) {
360
+ const definition = challenges[pending.challengeId];
361
+ if (!definition) {
362
+ throw new ProviderError(`Credentials auth has pending unknown challenge "${pending.challengeId}"`, { code: "credentials_auth_unknown_pending_challenge" });
363
+ }
364
+ if (!definition.poll) {
365
+ return challengeTurn(definition, pendingToChallengeRequest(pending));
366
+ }
367
+ const result = await definition.poll(ctx, pending.state);
368
+ if (!result) {
369
+ return {
370
+ ...challengeTurn(definition, pendingToChallengeRequest(pending)),
371
+ turnId: definition.pendingTurnId ??
372
+ definition.turnId ??
373
+ `credentials.${pending.challengeId}.pending`,
374
+ };
375
+ }
376
+ return await resolveAuthResult(ctx, credentialKeys, challenges, result, completeTurnId);
377
+ }
378
+ export function defineCredentialsAuth(options) {
379
+ if (Object.keys(options.fields).length === 0) {
380
+ throw new ProviderError("defineCredentialsAuth requires at least one field", {
381
+ fix: "Pass fields such as { email: { type: \"email\" }, password: { type: \"password\" } }.",
382
+ });
383
+ }
384
+ const expectedInput = expectedInputFromFields(options.fields);
385
+ const retryTurnId = options.retryTurnId ?? "credentials.retry";
386
+ const completeTurnId = options.completeTurnId ?? "credentials.complete";
387
+ const challenges = (options.challenges ?? {});
388
+ return {
389
+ auth: {
390
+ mode: "credentials",
391
+ flow: {
392
+ start: async () => ({
393
+ kind: "form",
394
+ turnId: options.startTurnId ?? "credentials.start",
395
+ ...(options.hintKey ? { hintKey: options.hintKey } : {}),
396
+ expectedInput,
397
+ }),
398
+ continue: async (ctx, rawInput) => {
399
+ const pending = getPendingChallenge(ctx);
400
+ if (pending) {
401
+ return await continuePendingChallenge(ctx, options.credentialKeys, challenges, pending, rawInput, completeTurnId);
402
+ }
403
+ const missing = collectMissingFields(options.fields, rawInput);
404
+ if (missing.length > 0) {
405
+ return retryTurn(expectedInput, missing, retryTurnId);
406
+ }
407
+ const result = await options.login(ctx, normalizeInput(options.fields, rawInput));
408
+ return await resolveAuthResult(ctx, options.credentialKeys, challenges, result, completeTurnId);
409
+ },
410
+ poll: async (ctx) => {
411
+ const pending = getPendingChallenge(ctx);
412
+ if (!pending) {
413
+ return {
414
+ kind: "pending",
415
+ turnId: "credentials.noPendingChallenge",
416
+ };
417
+ }
418
+ return await pollPendingChallenge(ctx, options.credentialKeys, challenges, pending, completeTurnId);
419
+ },
420
+ },
421
+ },
422
+ credential: {
423
+ keys: Array.from(options.credentialKeys),
424
+ ...(options.storesReusableSecret === undefined
425
+ ? {}
426
+ : { storesReusableSecret: options.storesReusableSecret }),
427
+ ...(options.justification ? { justification: options.justification } : {}),
428
+ },
429
+ context: {
430
+ keys: Array.from(new Set([
431
+ CREDENTIALS_AUTH_CHALLENGE_CONTEXT_KEY,
432
+ ...(options.contextKeys ?? []),
433
+ ])),
434
+ },
435
+ };
436
+ }