diff --git a/.changeset/openapi-multi-key-auth.md b/.changeset/openapi-multi-key-auth.md new file mode 100644 index 000000000..476118c45 --- /dev/null +++ b/.changeset/openapi-multi-key-auth.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Derive separate credential inputs for OpenAPI auth strategies that require multiple API key headers. diff --git a/e2e/scenarios/openapi-multi-key-auth-ui.test.ts b/e2e/scenarios/openapi-multi-key-auth-ui.test.ts new file mode 100644 index 000000000..f7e824af5 --- /dev/null +++ b/e2e/scenarios/openapi-multi-key-auth-ui.test.ts @@ -0,0 +1,100 @@ +// Cross-target (browser): OpenAPI security strategies that require multiple +// API key headers must collect one credential value per header. Cloudflare's +// legacy API key auth is the concrete regression: one OpenAPI security object +// requires both `api_email` and `api_key`, so Add connection must show two +// credential inputs, not one shared token field. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const cloudflareStyleSpec = (): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Cloudflare Auth Fixture", version: "1.0.0" }, + servers: [{ url: "https://api.cloudflare.test/client/v4" }], + security: [{ api_email: [], api_key: [] }], + components: { + securitySchemes: { + api_email: { type: "apiKey", in: "header", name: "X-Auth-Email" }, + api_key: { type: "apiKey", in: "header", name: "X-Auth-Key" }, + }, + }, + paths: { + "/accounts": { + get: { + operationId: "listAccounts", + summary: "List accounts", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + +scenario( + "OpenAPI · the Cloudflare email and key auth method shows separate credential fields", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const apiClient = yield* makeApiClient(api, identity); + const slug = `openapi_auth_fields_${randomBytes(4).toString("hex")}`; + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* apiClient.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: cloudflareStyleSpec() }, + slug, + }, + }); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Cloudflare-style integration", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByText("Connections").first().waitFor(); + }); + + await step("Open the add-connection modal", async () => { + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("dialog", { name: /Add connection/ }).waitFor(); + }); + + await step("The legacy Cloudflare method asks for email and key separately", async () => { + await page.getByRole("tab", { name: "API key (X-Auth-Email)" }).click(); + const dialog = page.getByRole("dialog", { name: /Add connection/ }); + + const credentialInputs = dialog.locator('input[type="password"]'); + await dialog.getByPlaceholder("paste X-Auth-Email").waitFor(); + await dialog.getByPlaceholder("paste X-Auth-Key").waitFor(); + expect( + await credentialInputs.count(), + "the modal renders one secret input for each required header", + ).toBe(2); + expect( + await dialog.getByPlaceholder("paste X-Auth-Email").isVisible(), + "email has its own input", + ).toBe(true); + expect( + await dialog.getByPlaceholder("paste X-Auth-Key").isVisible(), + "API key has its own input", + ).toBe(true); + }); + }); + }), + apiClient.openapi + .removeSpec({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ); + }), +); diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts index fb96f38cc..ff1730516 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.ts @@ -57,9 +57,12 @@ export const resolvedOAuthScopes = ( // --------------------------------------------------------------------------- // Auth-template builders — turn a preview preset into the integration's stored -// `Authentication` template (v2). The header preset becomes an `apiKey` template -// whose secret header value renders the resolved credential via `variable(token)`; -// the oauth2 preset becomes an `oauth` template carrying the provider endpoints. +// `Authentication` template (v2). A single-header preset becomes an `apiKey` +// template whose secret header value renders from the conventional `token` +// input. A multi-header preset gets one input per header, matching OpenAPI's +// security-strategy semantics where multiple schemes in one object are required +// together. The oauth2 preset becomes an `oauth` template carrying the provider +// endpoints. // --------------------------------------------------------------------------- const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { @@ -71,19 +74,49 @@ const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefi return undefined; }; +const slugifyVariable = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +const variablesForHeaders = (headerNames: readonly string[]): ReadonlyMap => { + const variables = new Map(); + if (headerNames.length <= 1) return variables; + + const taken = new Set(); + for (const headerName of headerNames) { + const base = slugifyVariable(headerName) || "input"; + let variable = base; + for (let suffix = 2; taken.has(variable); suffix += 1) { + variable = `${base}_${suffix}`; + } + taken.add(variable); + variables.set(headerName, variable); + } + return variables; +}; + const apiKeyTemplateFromHeaderPreset = ( preset: HeaderPreset, slug: AuthTemplateSlug, -): APIKeyAuthentication => ({ - slug, - kind: "apikey", - // Every secret header shares the one credential input (the canonical - // `token`, stored as an absent placement variable). - placements: preset.secretHeaders.map((headerName) => { - const prefix = headerPrefix(preset, headerName); - return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; - }), -}); +): APIKeyAuthentication => { + const variables = variablesForHeaders(preset.secretHeaders); + return { + slug, + kind: "apikey", + placements: preset.secretHeaders.map((headerName) => { + const prefix = headerPrefix(preset, headerName); + const variable = variables.get(headerName); + return { + carrier: "header" as const, + name: headerName, + ...(prefix ? { prefix } : {}), + ...(variable ? { variable } : {}), + }; + }), + }; +}; const oauthTemplateFromPreset = ( preset: OAuth2Preset, diff --git a/packages/plugins/openapi/src/sdk/real-specs.test.ts b/packages/plugins/openapi/src/sdk/real-specs.test.ts index 6f59c4e33..a2b3f895b 100644 --- a/packages/plugins/openapi/src/sdk/real-specs.test.ts +++ b/packages/plugins/openapi/src/sdk/real-specs.test.ts @@ -25,6 +25,7 @@ import type { ParsedDocument } from "./parse"; import { parse } from "./parse"; import { extract } from "./extract"; import { openApiPlugin } from "./plugin"; +import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; import { previewSpec as previewSpecRaw } from "./preview"; import type { ExtractionResult } from "./types"; @@ -240,6 +241,21 @@ describe("Real specs: Cloudflare API", { timeout: 60_000 }, () => { expect(keyEmailPreset).toBeDefined(); expect(keyEmailPreset!.headers["X-Auth-Email"]).toBeNull(); expect(keyEmailPreset!.headers["X-Auth-Key"]).toBeNull(); + + const templates = deriveAuthenticationTemplateFromPreview(preview, undefined); + const keyEmailTemplate = templates.find( + (template) => + template.kind === "apikey" && + template.placements.some((placement) => placement.name === "X-Auth-Email"), + ); + expect(keyEmailTemplate).toBeDefined(); + expect(keyEmailTemplate).toMatchObject({ + kind: "apikey", + placements: [ + { carrier: "header", name: "X-Auth-Email", variable: "x_auth_email" }, + { carrier: "header", name: "X-Auth-Key", variable: "x_auth_key" }, + ], + }); }), ); diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 918cb06fb..feb6f40d8 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -150,17 +150,48 @@ function PasteCredentialInputs(props: { readonly values: Readonly>; readonly onChange: (values: Record) => void; }) { + if (!props.singleInput) { + return ( +
+ {props.inputs.map((input) => ( +
+
+ +
+ ) => + props.onChange({ + ...props.values, + [input.variable]: e.target.value, + }) + } + className="h-9 font-mono text-sm" + data-ph-block + /> +
+ ))} +
+ ); + } + return (
{props.inputs.map((input) => (
- {!props.singleInput && ( - - )} ) => props.onChange({ @@ -1356,7 +1387,7 @@ function AddAccountModalView(props: AddAccountModalProps) { value={methodId} className="mt-0 min-w-0 space-y-5 rounded-md border border-border/60 bg-muted/15 p-4" > - {method?.placements && method.placements.length > 0 ? ( + {method?.placements && method.placements.length > 0 && singleInput ? (
{method.placements.map((placement, i: number) => (