@apifuse/provider-sdk 2.0.0-beta.1 → 2.1.0-beta.1
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 +93 -0
- package/CHANGELOG.md +21 -0
- package/README.md +133 -28
- package/bin/apifuse-check.ts +78 -71
- package/bin/apifuse-create.ts +12 -0
- package/bin/apifuse-dev.ts +24 -61
- package/bin/apifuse-pack-check.ts +87 -0
- package/bin/apifuse-pack-smoke.ts +122 -0
- package/bin/apifuse-perf.ts +33 -32
- package/bin/apifuse-record.ts +17 -7
- package/bin/apifuse-test.ts +6 -4
- package/bin/apifuse.ts +36 -35
- package/package.json +29 -9
- package/src/ceremonies/index.ts +768 -0
- package/src/cli/commands.ts +87 -0
- package/src/cli/create.ts +845 -0
- package/src/cli/templates/provider/Dockerfile.tpl +7 -0
- package/src/cli/templates/provider/README.md.tpl +41 -0
- package/src/cli/templates/provider/dev.ts.tpl +5 -0
- package/src/cli/templates/provider/index.test.ts.tpl +13 -0
- package/src/cli/templates/provider/index.ts.tpl +58 -0
- package/src/cli/templates/provider/start.ts.tpl +5 -0
- package/src/config/loader.ts +61 -1
- package/src/define.ts +565 -41
- package/src/dev.ts +2 -6
- package/src/errors.ts +42 -0
- package/src/index.ts +44 -38
- package/src/lint.ts +574 -0
- package/src/provider.ts +13 -0
- package/src/runtime/auth-flow.ts +67 -0
- package/src/runtime/credential.ts +95 -0
- package/src/runtime/env.ts +13 -0
- package/src/runtime/executor.ts +13 -14
- package/src/runtime/http.ts +36 -12
- package/src/runtime/insights.ts +3 -3
- package/src/runtime/key-derivation.ts +122 -0
- package/src/runtime/keyring.ts +148 -0
- package/src/runtime/namespace.ts +33 -0
- package/src/runtime/prevalidate.ts +252 -0
- package/src/runtime/tls.ts +41 -17
- package/src/runtime/waterfall.ts +0 -1
- package/src/schema.ts +77 -0
- package/src/serve.ts +1 -664
- package/src/server/index.ts +22 -0
- package/src/server/serve.ts +624 -0
- package/src/server/types.ts +78 -0
- package/src/stealth/profiles.ts +10 -93
- package/src/testing/run.ts +391 -32
- package/src/types.ts +390 -41
- package/bin/apifuse-init.ts +0 -387
- package/src/__tests__/auth.test.ts +0 -396
- package/src/__tests__/browser-auth.test.ts +0 -180
- package/src/__tests__/browser.test.ts +0 -632
- package/src/__tests__/define.test.ts +0 -225
- package/src/__tests__/errors.test.ts +0 -69
- package/src/__tests__/executor.test.ts +0 -214
- package/src/__tests__/http.test.ts +0 -238
- package/src/__tests__/insights.test.ts +0 -210
- package/src/__tests__/instrumentation.test.ts +0 -290
- package/src/__tests__/otlp.test.ts +0 -141
- package/src/__tests__/perf.test.ts +0 -60
- package/src/__tests__/providers-yaml.test.ts +0 -135
- package/src/__tests__/proxy.test.ts +0 -359
- package/src/__tests__/recipes.test.ts +0 -36
- package/src/__tests__/serve.test.ts +0 -233
- package/src/__tests__/session.test.ts +0 -231
- package/src/__tests__/state.test.ts +0 -100
- package/src/__tests__/stealth.test.ts +0 -57
- package/src/__tests__/testing.test.ts +0 -97
- package/src/__tests__/tls.test.ts +0 -345
- package/src/__tests__/types.test.ts +0 -142
- package/src/__tests__/utils.test.ts +0 -62
- package/src/__tests__/waterfall.test.ts +0 -270
- package/src/config/providers-yaml.ts +0 -370
- package/src/index.test.ts +0 -1
- package/src/protocol.ts +0 -183
- package/src/runtime/auth.ts +0 -245
- package/src/runtime/session.ts +0 -573
- package/src/runtime/state.ts +0 -124
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import Ajv from "ajv";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FlowExpiredError,
|
|
7
|
+
ProviderSecretError,
|
|
8
|
+
TurnValidationError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
} from "../errors";
|
|
11
|
+
import type { AuthFlowDefinition, AuthTurn, FlowContext } from "../types";
|
|
12
|
+
|
|
13
|
+
type TurnKind =
|
|
14
|
+
| "abort"
|
|
15
|
+
| "challenge"
|
|
16
|
+
| "complete"
|
|
17
|
+
| "form"
|
|
18
|
+
| "message"
|
|
19
|
+
| "multi_choice"
|
|
20
|
+
| "poll"
|
|
21
|
+
| "redirect"
|
|
22
|
+
| "retry";
|
|
23
|
+
|
|
24
|
+
type CeremonyHandler = AuthFlowDefinition["start"];
|
|
25
|
+
|
|
26
|
+
type JsonObject = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
const ajv = new Ajv({ allErrors: true, strict: true, strictSchema: true });
|
|
29
|
+
|
|
30
|
+
const authTurnSchema = {
|
|
31
|
+
type: "object",
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
required: ["kind", "turnId"],
|
|
34
|
+
properties: {
|
|
35
|
+
kind: { type: "string", minLength: 1 },
|
|
36
|
+
turnId: { type: "string", minLength: 1 },
|
|
37
|
+
expiresAt: { type: "string", minLength: 1 },
|
|
38
|
+
data: {
|
|
39
|
+
type: "object",
|
|
40
|
+
additionalProperties: true,
|
|
41
|
+
},
|
|
42
|
+
expectedInput: {
|
|
43
|
+
type: "object",
|
|
44
|
+
additionalProperties: true,
|
|
45
|
+
},
|
|
46
|
+
hint: { type: "string" },
|
|
47
|
+
timing: {
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
suggestedPollIntervalMs: { type: "number", minimum: 1 },
|
|
52
|
+
maxWaitMs: { type: "number", minimum: 1 },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
const validateAuthTurn = ajv.compile(authTurnSchema);
|
|
59
|
+
|
|
60
|
+
const OAUTH2_STATE_KEY = "__oauth2_state";
|
|
61
|
+
const OAUTH2_PKCE_VERIFIER_KEY = "__oauth2_pkce_verifier";
|
|
62
|
+
const DEVICE_FLOW_KEY = "__device_flow";
|
|
63
|
+
const MAGIC_LINK_KEY = "__magic_link";
|
|
64
|
+
const COMBINED_STAGE_KEY = "__combined_stage";
|
|
65
|
+
const SWITCH_SELECTION_KEY = "__switch_selection";
|
|
66
|
+
const FORM_FIELD_ORDER_EXTENSION = "x-apifuse-field-order";
|
|
67
|
+
|
|
68
|
+
function isRecord(value: unknown): value is JsonObject {
|
|
69
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ensureRecord(value: unknown): JsonObject {
|
|
73
|
+
return isRecord(value) ? value : {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createTurn(
|
|
77
|
+
kind: TurnKind,
|
|
78
|
+
options: Omit<AuthTurn, "kind" | "turnId"> = {},
|
|
79
|
+
): AuthTurn {
|
|
80
|
+
return {
|
|
81
|
+
kind,
|
|
82
|
+
turnId: randomUUID(),
|
|
83
|
+
...options,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createExpiresAt(ttlMs: number): string {
|
|
88
|
+
return new Date(Date.now() + ttlMs).toISOString();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getRequiredEnv(ctx: FlowContext, key: string): string {
|
|
92
|
+
const value = ctx.env.get(key);
|
|
93
|
+
if (!value) {
|
|
94
|
+
throw new ProviderSecretError(`Missing required secret: ${key}`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toBase64Url(input: Buffer): string {
|
|
100
|
+
return input
|
|
101
|
+
.toString("base64")
|
|
102
|
+
.replace(/\+/g, "-")
|
|
103
|
+
.replace(/\//g, "_")
|
|
104
|
+
.replace(/=+$/g, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createCodeVerifier(): string {
|
|
108
|
+
return toBase64Url(randomBytes(32));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createCodeChallenge(verifier: string): string {
|
|
112
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeError(error: unknown): Error {
|
|
116
|
+
if (error instanceof Error) {
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return new Error("Unexpected ceremony error");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toRetryTurn(error: unknown, hint: string): AuthTurn {
|
|
124
|
+
const normalizedError = normalizeError(error);
|
|
125
|
+
|
|
126
|
+
if (normalizedError instanceof FlowExpiredError) {
|
|
127
|
+
return validateCeremonyOutput(
|
|
128
|
+
createTurn("abort", {
|
|
129
|
+
hint: normalizedError.message,
|
|
130
|
+
data: { code: normalizedError.code ?? "flow_expired" },
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const retryData =
|
|
136
|
+
normalizedError instanceof ValidationError
|
|
137
|
+
? { errors: normalizedError.zodError }
|
|
138
|
+
: { error: normalizedError.message };
|
|
139
|
+
|
|
140
|
+
return validateCeremonyOutput(
|
|
141
|
+
createTurn("retry", {
|
|
142
|
+
hint: `${hint}: ${normalizedError.message}`,
|
|
143
|
+
data: retryData,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function runCeremonyHandler(
|
|
149
|
+
handler: CeremonyHandler,
|
|
150
|
+
hint: string,
|
|
151
|
+
ctx: FlowContext,
|
|
152
|
+
input?: Record<string, unknown>,
|
|
153
|
+
): Promise<AuthTurn> {
|
|
154
|
+
try {
|
|
155
|
+
return validateCeremonyOutput(await handler(ctx, input));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return toRetryTurn(error, hint);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getString(
|
|
162
|
+
input: Record<string, unknown>,
|
|
163
|
+
key: string,
|
|
164
|
+
): string | undefined {
|
|
165
|
+
const value = input[key];
|
|
166
|
+
return typeof value === "string" ? value : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getNestedRecord(ctx: FlowContext, key: string): JsonObject {
|
|
170
|
+
return ensureRecord(ctx.context.get(key));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildJsonSchemaForm(
|
|
174
|
+
expectedInput: JsonObject,
|
|
175
|
+
hint: string,
|
|
176
|
+
): AuthTurn {
|
|
177
|
+
return createTurn("form", {
|
|
178
|
+
hint,
|
|
179
|
+
expectedInput: withDeclaredFormFieldOrder(expectedInput),
|
|
180
|
+
data: {},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function withDeclaredFormFieldOrder(expectedInput: JsonObject): JsonObject {
|
|
185
|
+
const properties = expectedInput.properties;
|
|
186
|
+
if (!isRecord(properties)) {
|
|
187
|
+
return expectedInput;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const existingOrder = expectedInput[FORM_FIELD_ORDER_EXTENSION];
|
|
191
|
+
if (
|
|
192
|
+
Array.isArray(existingOrder) &&
|
|
193
|
+
existingOrder.every((value) => typeof value === "string")
|
|
194
|
+
) {
|
|
195
|
+
return expectedInput;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
...expectedInput,
|
|
200
|
+
[FORM_FIELD_ORDER_EXTENSION]: Object.keys(properties),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function validateCeremonyOutput(turn: unknown): AuthTurn {
|
|
205
|
+
if (!validateAuthTurn(turn)) {
|
|
206
|
+
const detail = validateAuthTurn.errors
|
|
207
|
+
?.map(
|
|
208
|
+
(error) => `${error.instancePath || "$"} ${error.message ?? "invalid"}`,
|
|
209
|
+
)
|
|
210
|
+
.join("; ");
|
|
211
|
+
throw new TurnValidationError(detail || "Invalid AuthTurn output");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return turn;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createOAuth2Ceremony(options: {
|
|
218
|
+
authorizeUrl: string;
|
|
219
|
+
tokenUrl: string;
|
|
220
|
+
clientIdEnvKey: string;
|
|
221
|
+
clientSecretEnvKey: string;
|
|
222
|
+
scopes: string[];
|
|
223
|
+
usePKCE?: boolean;
|
|
224
|
+
}): AuthFlowDefinition {
|
|
225
|
+
return {
|
|
226
|
+
start: (ctx) =>
|
|
227
|
+
runCeremonyHandler(
|
|
228
|
+
async () => {
|
|
229
|
+
const clientId = getRequiredEnv(ctx, options.clientIdEnvKey);
|
|
230
|
+
getRequiredEnv(ctx, options.clientSecretEnvKey);
|
|
231
|
+
const state = toBase64Url(randomBytes(24));
|
|
232
|
+
ctx.context.set(OAUTH2_STATE_KEY, state);
|
|
233
|
+
|
|
234
|
+
const authorizeUrl = new URL(options.authorizeUrl);
|
|
235
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
236
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
237
|
+
authorizeUrl.searchParams.set("state", state);
|
|
238
|
+
if (options.scopes.length > 0) {
|
|
239
|
+
authorizeUrl.searchParams.set("scope", options.scopes.join(" "));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (options.usePKCE) {
|
|
243
|
+
const verifier = createCodeVerifier();
|
|
244
|
+
ctx.context.set(OAUTH2_PKCE_VERIFIER_KEY, verifier);
|
|
245
|
+
authorizeUrl.searchParams.set(
|
|
246
|
+
"code_challenge",
|
|
247
|
+
createCodeChallenge(verifier),
|
|
248
|
+
);
|
|
249
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return createTurn("redirect", {
|
|
253
|
+
data: { url: authorizeUrl.toString() },
|
|
254
|
+
hint: "Open the provider authorization page to continue.",
|
|
255
|
+
expectedInput: {
|
|
256
|
+
type: "object",
|
|
257
|
+
required: ["code", "state"],
|
|
258
|
+
properties: {
|
|
259
|
+
code: { type: "string" },
|
|
260
|
+
state: { type: "string" },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
"OAuth start failed",
|
|
266
|
+
ctx,
|
|
267
|
+
),
|
|
268
|
+
continue: (ctx, input = {}) =>
|
|
269
|
+
runCeremonyHandler(
|
|
270
|
+
async () => {
|
|
271
|
+
const code = getString(input, "code");
|
|
272
|
+
const receivedState = getString(input, "state");
|
|
273
|
+
const storedState = ctx.context.get(OAUTH2_STATE_KEY);
|
|
274
|
+
const codeVerifier = ctx.context.get(OAUTH2_PKCE_VERIFIER_KEY);
|
|
275
|
+
|
|
276
|
+
if (!code || !receivedState || receivedState !== storedState) {
|
|
277
|
+
throw new ValidationError("OAuth callback payload is invalid.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const tokenResponse = await ctx.http.post(options.tokenUrl, {
|
|
281
|
+
grant_type: "authorization_code",
|
|
282
|
+
code,
|
|
283
|
+
client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
|
|
284
|
+
client_secret: getRequiredEnv(ctx, options.clientSecretEnvKey),
|
|
285
|
+
...(options.usePKCE
|
|
286
|
+
? typeof codeVerifier === "string"
|
|
287
|
+
? { code_verifier: codeVerifier }
|
|
288
|
+
: {}
|
|
289
|
+
: {}),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return createTurn("complete", {
|
|
293
|
+
data: { credential: ensureRecord(tokenResponse.data) },
|
|
294
|
+
hint: "OAuth flow completed.",
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
"OAuth token exchange failed",
|
|
298
|
+
ctx,
|
|
299
|
+
input,
|
|
300
|
+
),
|
|
301
|
+
abort: async () =>
|
|
302
|
+
validateCeremonyOutput(
|
|
303
|
+
createTurn("abort", { hint: "OAuth flow aborted." }),
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function createDeviceFlowCeremony(options: {
|
|
309
|
+
deviceCodeUrl: string;
|
|
310
|
+
tokenUrl: string;
|
|
311
|
+
clientIdEnvKey: string;
|
|
312
|
+
clientSecretEnvKey?: string;
|
|
313
|
+
scopes: string[];
|
|
314
|
+
}): AuthFlowDefinition {
|
|
315
|
+
return {
|
|
316
|
+
start: (ctx) =>
|
|
317
|
+
runCeremonyHandler(
|
|
318
|
+
async () => {
|
|
319
|
+
const response = await ctx.http.post(options.deviceCodeUrl, {
|
|
320
|
+
client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
|
|
321
|
+
scope: options.scopes.join(" "),
|
|
322
|
+
});
|
|
323
|
+
const data = ensureRecord(response.data);
|
|
324
|
+
ctx.context.set(DEVICE_FLOW_KEY, data);
|
|
325
|
+
|
|
326
|
+
return createTurn("message", {
|
|
327
|
+
data: {
|
|
328
|
+
user_code: getString(data, "user_code") ?? "",
|
|
329
|
+
verification_uri: getString(data, "verification_uri") ?? "",
|
|
330
|
+
},
|
|
331
|
+
hint: "Enter the code on the verification page, then poll for completion.",
|
|
332
|
+
timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 120_000 },
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
"Device flow start failed",
|
|
336
|
+
ctx,
|
|
337
|
+
),
|
|
338
|
+
continue: async () =>
|
|
339
|
+
validateCeremonyOutput(
|
|
340
|
+
createTurn("poll", {
|
|
341
|
+
hint: "Continue polling until the device flow completes.",
|
|
342
|
+
timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 120_000 },
|
|
343
|
+
}),
|
|
344
|
+
),
|
|
345
|
+
poll: (ctx) =>
|
|
346
|
+
runCeremonyHandler(
|
|
347
|
+
async () => {
|
|
348
|
+
const deviceData = getNestedRecord(ctx, DEVICE_FLOW_KEY);
|
|
349
|
+
const deviceCode = getString(deviceData, "device_code");
|
|
350
|
+
if (!deviceCode) {
|
|
351
|
+
throw new FlowExpiredError("Device flow state is missing.");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const response = await ctx.http.post(options.tokenUrl, {
|
|
355
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
356
|
+
device_code: deviceCode,
|
|
357
|
+
client_id: getRequiredEnv(ctx, options.clientIdEnvKey),
|
|
358
|
+
...(options.clientSecretEnvKey
|
|
359
|
+
? {
|
|
360
|
+
client_secret: getRequiredEnv(
|
|
361
|
+
ctx,
|
|
362
|
+
options.clientSecretEnvKey,
|
|
363
|
+
),
|
|
364
|
+
}
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
367
|
+
const data = ensureRecord(response.data);
|
|
368
|
+
const errorCode = getString(data, "error");
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
errorCode === "authorization_pending" ||
|
|
372
|
+
errorCode === "slow_down"
|
|
373
|
+
) {
|
|
374
|
+
return createTurn("poll", {
|
|
375
|
+
data,
|
|
376
|
+
hint: "Authorization pending.",
|
|
377
|
+
timing: {
|
|
378
|
+
suggestedPollIntervalMs:
|
|
379
|
+
errorCode === "slow_down" ? 10_000 : 5_000,
|
|
380
|
+
maxWaitMs: 120_000,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (errorCode === "expired_token") {
|
|
386
|
+
throw new FlowExpiredError("Device code expired.");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return createTurn("complete", {
|
|
390
|
+
data: { credential: data },
|
|
391
|
+
hint: "Device flow completed.",
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
"Device flow polling failed",
|
|
395
|
+
ctx,
|
|
396
|
+
),
|
|
397
|
+
abort: async () =>
|
|
398
|
+
validateCeremonyOutput(
|
|
399
|
+
createTurn("abort", { hint: "Device flow aborted." }),
|
|
400
|
+
),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function createWebAuthnCeremony(options: {
|
|
405
|
+
rpId: string;
|
|
406
|
+
challengeUrl?: string;
|
|
407
|
+
verifyUrl?: string;
|
|
408
|
+
timeoutMs?: number;
|
|
409
|
+
}): AuthFlowDefinition {
|
|
410
|
+
return {
|
|
411
|
+
start: (ctx) =>
|
|
412
|
+
runCeremonyHandler(
|
|
413
|
+
async () => {
|
|
414
|
+
const challenge = toBase64Url(randomBytes(32));
|
|
415
|
+
ctx.context.set("__webauthn_challenge", challenge);
|
|
416
|
+
|
|
417
|
+
if (options.challengeUrl) {
|
|
418
|
+
await ctx.http.post(options.challengeUrl, {
|
|
419
|
+
challenge,
|
|
420
|
+
rpId: options.rpId,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return createTurn("challenge", {
|
|
425
|
+
data: { challenge, rpId: options.rpId },
|
|
426
|
+
hint: "Complete the WebAuthn prompt in your browser.",
|
|
427
|
+
expiresAt: createExpiresAt(options.timeoutMs ?? 60_000),
|
|
428
|
+
expectedInput: {
|
|
429
|
+
type: "object",
|
|
430
|
+
required: ["attestation"],
|
|
431
|
+
properties: { attestation: { type: "object" } },
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
"WebAuthn start failed",
|
|
436
|
+
ctx,
|
|
437
|
+
),
|
|
438
|
+
continue: (ctx, input = {}) =>
|
|
439
|
+
runCeremonyHandler(
|
|
440
|
+
async () => {
|
|
441
|
+
if (!isRecord(input.attestation)) {
|
|
442
|
+
throw new ValidationError("WebAuthn attestation is required.");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const challenge = ctx.context.get("__webauthn_challenge");
|
|
446
|
+
if (typeof challenge !== "string" || challenge.length === 0) {
|
|
447
|
+
throw new FlowExpiredError("WebAuthn challenge has expired.");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (options.verifyUrl) {
|
|
451
|
+
await ctx.http.post(options.verifyUrl, {
|
|
452
|
+
challenge,
|
|
453
|
+
attestation: input.attestation,
|
|
454
|
+
rpId: options.rpId,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return createTurn("complete", {
|
|
459
|
+
data: { credential: { attestation: input.attestation } },
|
|
460
|
+
hint: "WebAuthn ceremony completed.",
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
"WebAuthn verification failed",
|
|
464
|
+
ctx,
|
|
465
|
+
input,
|
|
466
|
+
),
|
|
467
|
+
abort: async () =>
|
|
468
|
+
validateCeremonyOutput(
|
|
469
|
+
createTurn("abort", { hint: "WebAuthn ceremony aborted." }),
|
|
470
|
+
),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function createMagicLinkCeremony(options: {
|
|
475
|
+
sendUrl: string;
|
|
476
|
+
verifyUrl: string;
|
|
477
|
+
emailField?: string;
|
|
478
|
+
expiresInMs?: number;
|
|
479
|
+
}): AuthFlowDefinition {
|
|
480
|
+
const emailField = options.emailField ?? "email";
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
start: (ctx, input = {}) =>
|
|
484
|
+
runCeremonyHandler(
|
|
485
|
+
async () => {
|
|
486
|
+
const email = getString(input, emailField);
|
|
487
|
+
if (!email) {
|
|
488
|
+
return buildJsonSchemaForm(
|
|
489
|
+
{
|
|
490
|
+
type: "object",
|
|
491
|
+
required: [emailField],
|
|
492
|
+
properties: {
|
|
493
|
+
[emailField]: { type: "string", format: "email" },
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
"Provide the email address to receive a magic link.",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await ctx.http.post(options.sendUrl, { email });
|
|
501
|
+
ctx.context.set(MAGIC_LINK_KEY, {
|
|
502
|
+
email,
|
|
503
|
+
expiresAt: createExpiresAt(options.expiresInMs ?? 300_000),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
return createTurn("message", {
|
|
507
|
+
data: { email },
|
|
508
|
+
hint: "Check your email for the magic link, then poll for completion.",
|
|
509
|
+
timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
"Magic link start failed",
|
|
513
|
+
ctx,
|
|
514
|
+
input,
|
|
515
|
+
),
|
|
516
|
+
continue: async () =>
|
|
517
|
+
validateCeremonyOutput(
|
|
518
|
+
createTurn("poll", {
|
|
519
|
+
hint: "Continue polling for magic link completion.",
|
|
520
|
+
timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
|
|
521
|
+
}),
|
|
522
|
+
),
|
|
523
|
+
poll: (ctx) =>
|
|
524
|
+
runCeremonyHandler(
|
|
525
|
+
async () => {
|
|
526
|
+
const state = getNestedRecord(ctx, MAGIC_LINK_KEY);
|
|
527
|
+
const email = getString(state, "email");
|
|
528
|
+
const expiresAt = getString(state, "expiresAt");
|
|
529
|
+
|
|
530
|
+
if (!email || !expiresAt) {
|
|
531
|
+
throw new FlowExpiredError("Magic link state is missing.");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (new Date(expiresAt).getTime() < Date.now()) {
|
|
535
|
+
throw new FlowExpiredError("Magic link expired.");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const response = await ctx.http.post(options.verifyUrl, { email });
|
|
539
|
+
const data = ensureRecord(response.data);
|
|
540
|
+
if (data.completed !== true) {
|
|
541
|
+
return createTurn("poll", {
|
|
542
|
+
data,
|
|
543
|
+
hint: "Waiting for the magic link click.",
|
|
544
|
+
timing: { suggestedPollIntervalMs: 5_000, maxWaitMs: 300_000 },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return createTurn("complete", {
|
|
549
|
+
data: { credential: ensureRecord(data.credential) },
|
|
550
|
+
hint: "Magic link completed.",
|
|
551
|
+
});
|
|
552
|
+
},
|
|
553
|
+
"Magic link polling failed",
|
|
554
|
+
ctx,
|
|
555
|
+
),
|
|
556
|
+
abort: async () =>
|
|
557
|
+
validateCeremonyOutput(
|
|
558
|
+
createTurn("abort", { hint: "Magic link flow aborted." }),
|
|
559
|
+
),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function createFormCeremony(options: {
|
|
564
|
+
schema: JsonObject;
|
|
565
|
+
hint?: string;
|
|
566
|
+
mapCredential?: (input: Record<string, unknown>) => JsonObject;
|
|
567
|
+
}): AuthFlowDefinition {
|
|
568
|
+
return {
|
|
569
|
+
start: async () =>
|
|
570
|
+
validateCeremonyOutput(
|
|
571
|
+
buildJsonSchemaForm(
|
|
572
|
+
options.schema,
|
|
573
|
+
options.hint ?? "Provide the required input to continue.",
|
|
574
|
+
),
|
|
575
|
+
),
|
|
576
|
+
continue: (ctx, input = {}) =>
|
|
577
|
+
runCeremonyHandler(
|
|
578
|
+
async () => {
|
|
579
|
+
const { prevalidate } = await import("../runtime/prevalidate");
|
|
580
|
+
const result = prevalidate(options.schema, input);
|
|
581
|
+
if (!result.valid) {
|
|
582
|
+
throw new ValidationError("Form input failed validation.", {
|
|
583
|
+
zodError: result.errors,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return createTurn("complete", {
|
|
588
|
+
data: {
|
|
589
|
+
credential: options.mapCredential
|
|
590
|
+
? options.mapCredential(input)
|
|
591
|
+
: input,
|
|
592
|
+
},
|
|
593
|
+
hint: "Form completed.",
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
"Form submission failed",
|
|
597
|
+
ctx,
|
|
598
|
+
input,
|
|
599
|
+
),
|
|
600
|
+
abort: async () =>
|
|
601
|
+
validateCeremonyOutput(
|
|
602
|
+
createTurn("abort", { hint: "Form ceremony aborted." }),
|
|
603
|
+
),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function combineCeremonies(
|
|
608
|
+
...ceremonies: AuthFlowDefinition[]
|
|
609
|
+
): AuthFlowDefinition {
|
|
610
|
+
function getStage(ctx: FlowContext): number {
|
|
611
|
+
const rawStage = ctx.context.get(COMBINED_STAGE_KEY);
|
|
612
|
+
return typeof rawStage === "number" ? rawStage : 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
start: (ctx) =>
|
|
617
|
+
runCeremonyHandler(
|
|
618
|
+
async () => {
|
|
619
|
+
ctx.context.set(COMBINED_STAGE_KEY, 0);
|
|
620
|
+
const first = ceremonies[0];
|
|
621
|
+
if (!first) {
|
|
622
|
+
throw new ValidationError("At least one ceremony is required.");
|
|
623
|
+
}
|
|
624
|
+
return await first.start(ctx);
|
|
625
|
+
},
|
|
626
|
+
"Combined ceremony start failed",
|
|
627
|
+
ctx,
|
|
628
|
+
),
|
|
629
|
+
continue: (ctx, input = {}) =>
|
|
630
|
+
runCeremonyHandler(
|
|
631
|
+
async () => {
|
|
632
|
+
const stage = getStage(ctx);
|
|
633
|
+
const current = ceremonies[stage];
|
|
634
|
+
if (!current) {
|
|
635
|
+
throw new FlowExpiredError("Combined ceremony stage is invalid.");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const result = await current.continue(ctx, input);
|
|
639
|
+
if (result.kind !== "complete") {
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const nextStage = stage + 1;
|
|
644
|
+
const nextCeremony = ceremonies[nextStage];
|
|
645
|
+
if (!nextCeremony) {
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
ctx.context.set(COMBINED_STAGE_KEY, nextStage);
|
|
650
|
+
return await nextCeremony.start(ctx);
|
|
651
|
+
},
|
|
652
|
+
"Combined ceremony continue failed",
|
|
653
|
+
ctx,
|
|
654
|
+
input,
|
|
655
|
+
),
|
|
656
|
+
poll: (ctx) =>
|
|
657
|
+
runCeremonyHandler(
|
|
658
|
+
async () => {
|
|
659
|
+
const current = ceremonies[getStage(ctx)];
|
|
660
|
+
if (!current?.poll) {
|
|
661
|
+
throw new ValidationError(
|
|
662
|
+
"Current ceremony does not support polling.",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return await current.poll(ctx);
|
|
666
|
+
},
|
|
667
|
+
"Combined ceremony poll failed",
|
|
668
|
+
ctx,
|
|
669
|
+
),
|
|
670
|
+
abort: (ctx) =>
|
|
671
|
+
runCeremonyHandler(
|
|
672
|
+
async () => {
|
|
673
|
+
const current = ceremonies[getStage(ctx)];
|
|
674
|
+
if (current?.abort) {
|
|
675
|
+
return await current.abort(ctx);
|
|
676
|
+
}
|
|
677
|
+
return createTurn("abort", { hint: "Combined ceremony aborted." });
|
|
678
|
+
},
|
|
679
|
+
"Combined ceremony abort failed",
|
|
680
|
+
ctx,
|
|
681
|
+
),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function createSwitchCeremony(options: {
|
|
686
|
+
choices: Record<string, AuthFlowDefinition>;
|
|
687
|
+
prompt?: string;
|
|
688
|
+
}): AuthFlowDefinition {
|
|
689
|
+
const choiceKeys = Object.keys(options.choices);
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
start: async () =>
|
|
693
|
+
validateCeremonyOutput(
|
|
694
|
+
createTurn("multi_choice", {
|
|
695
|
+
data: { choices: choiceKeys },
|
|
696
|
+
hint: options.prompt ?? "Choose an authentication method.",
|
|
697
|
+
expectedInput: {
|
|
698
|
+
type: "object",
|
|
699
|
+
required: ["choice"],
|
|
700
|
+
properties: {
|
|
701
|
+
choice: { type: "string", enum: choiceKeys },
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
}),
|
|
705
|
+
),
|
|
706
|
+
continue: (ctx, input = {}) =>
|
|
707
|
+
runCeremonyHandler(
|
|
708
|
+
async () => {
|
|
709
|
+
const storedChoice = ctx.context.get(SWITCH_SELECTION_KEY);
|
|
710
|
+
const choice =
|
|
711
|
+
typeof storedChoice === "string"
|
|
712
|
+
? storedChoice
|
|
713
|
+
: getString(input, "choice");
|
|
714
|
+
|
|
715
|
+
if (!choice || !options.choices[choice]) {
|
|
716
|
+
throw new ValidationError("A valid choice is required.");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
ctx.context.set(SWITCH_SELECTION_KEY, choice);
|
|
720
|
+
const ceremony = options.choices[choice];
|
|
721
|
+
if (storedChoice === undefined) {
|
|
722
|
+
return await ceremony.start(ctx);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return await ceremony.continue(ctx, input);
|
|
726
|
+
},
|
|
727
|
+
"Switch ceremony failed",
|
|
728
|
+
ctx,
|
|
729
|
+
input,
|
|
730
|
+
),
|
|
731
|
+
poll: (ctx) =>
|
|
732
|
+
runCeremonyHandler(
|
|
733
|
+
async () => {
|
|
734
|
+
const choice = ctx.context.get(SWITCH_SELECTION_KEY);
|
|
735
|
+
if (typeof choice !== "string") {
|
|
736
|
+
throw new FlowExpiredError("No selected ceremony is active.");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const ceremony = options.choices[choice];
|
|
740
|
+
if (!ceremony?.poll) {
|
|
741
|
+
throw new ValidationError(
|
|
742
|
+
"Selected ceremony does not support polling.",
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return await ceremony.poll(ctx);
|
|
747
|
+
},
|
|
748
|
+
"Switch ceremony poll failed",
|
|
749
|
+
ctx,
|
|
750
|
+
),
|
|
751
|
+
abort: (ctx) =>
|
|
752
|
+
runCeremonyHandler(
|
|
753
|
+
async () => {
|
|
754
|
+
const choice = ctx.context.get(SWITCH_SELECTION_KEY);
|
|
755
|
+
if (typeof choice === "string") {
|
|
756
|
+
const ceremony = options.choices[choice];
|
|
757
|
+
if (ceremony?.abort) {
|
|
758
|
+
return await ceremony.abort(ctx);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return createTurn("abort", { hint: "Switch ceremony aborted." });
|
|
763
|
+
},
|
|
764
|
+
"Switch ceremony abort failed",
|
|
765
|
+
ctx,
|
|
766
|
+
),
|
|
767
|
+
};
|
|
768
|
+
}
|