Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/openapi-multi-key-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

Derive separate credential inputs for OpenAPI auth strategies that require multiple API key headers.
100 changes: 100 additions & 0 deletions e2e/scenarios/openapi-multi-key-auth-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +82 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tautological visibility assertions after waitFor()

waitFor() with its default state ("visible") already confirms each element is present and visible. The two isVisible() checks below add no assertion value and do not improve failure messages over the waitFor() lines above them. Per the AGENTS.md quality bar ("no tautologies — don't assert what the setup already guarantees"), these can be removed; the count() assertion plus waitFor() already pin the exact UI state the test needs to prove.

Context Used: e2e/AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});
});
}),
apiClient.openapi
.removeSpec({ params: { slug: IntegrationSlug.make(slug) } })
.pipe(Effect.ignore),
);
}),
);
59 changes: 46 additions & 13 deletions packages/plugins/openapi/src/sdk/derive-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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<string, string> => {
const variables = new Map<string, string>();
if (headerNames.length <= 1) return variables;

const taken = new Set<string>();
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,
Expand Down
16 changes: 16 additions & 0 deletions packages/plugins/openapi/src/sdk/real-specs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" },
],
});
}),
);

Expand Down
41 changes: 36 additions & 5 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,48 @@ function PasteCredentialInputs(props: {
readonly values: Readonly<Record<string, string>>;
readonly onChange: (values: Record<string, string>) => void;
}) {
if (!props.singleInput) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{props.inputs.map((input) => (
<div key={input.variable} className="min-w-0 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<Label
htmlFor={`credential-input-${input.variable}`}
className="min-w-0 truncate font-mono text-xs font-medium text-muted-foreground"
>
{input.label}
</Label>
</div>
<Input
id={`credential-input-${input.variable}`}
type="password"
autoComplete="new-password"
placeholder={`paste ${input.label}`}
value={props.values[input.variable] ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
props.onChange({
...props.values,
[input.variable]: e.target.value,
})
}
className="h-9 font-mono text-sm"
data-ph-block
/>
</div>
))}
</div>
);
}

return (
<div className="space-y-2">
{props.inputs.map((input) => (
<div key={input.variable} className="space-y-1">
{!props.singleInput && (
<Label className="text-xs text-muted-foreground">{input.label}</Label>
)}
<Input
type="password"
autoComplete="new-password"
placeholder={props.singleInput ? "paste the value / token" : `paste ${input.label}`}
placeholder="paste the value / token"
value={props.values[input.variable] ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
props.onChange({
Expand Down Expand Up @@ -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 ? (
<div className="flex flex-wrap gap-x-3.5 gap-y-1">
{method.placements.map((placement, i: number) => (
<PlacementLine key={i} placement={placement} />
Expand Down
Loading