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
1 change: 1 addition & 0 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export type IntegrationPresetAuthentication =
| {
readonly slug: string;
readonly kind: "oauth2";
readonly label?: string;
readonly authorizationUrl: string;
readonly tokenUrl: string;
readonly resource?: string | null;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/sdk/src/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type OAuthGrant = "authorization_code" | "client_credentials";
export interface OAuthAuthentication {
readonly slug: AuthTemplateSlug;
readonly kind: "oauth2";
/** Display label distinguishing this method when an integration declares
* several oauth2 templates (e.g. delegated vs app-only). UIs fall back to
* "OAuth2" when absent. */
readonly label?: string;
readonly authorizationUrl: string;
readonly tokenUrl: string;
/** RFC 8707 Resource Indicator to bind the OAuth flow to this protected
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ export type IntegrationPresetAuthentication =
| {
readonly slug: string;
readonly kind: "oauth2";
readonly label?: string;
readonly authorizationUrl: string;
readonly tokenUrl: string;
readonly resource?: string | null;
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const OpenApiSpecInputPayload = Schema.Union([
const OAuthTemplatePayload = Schema.Struct({
slug: Schema.String,
kind: Schema.Literal("oauth2"),
label: Schema.optional(Schema.String),
authorizationUrl: Schema.String,
tokenUrl: Schema.String,
resource: Schema.optional(Schema.NullOr(Schema.String)),
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/openapi/src/providers/microsoft/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
import {
MICROSOFT_AUTHORIZATION_URL,
MICROSOFT_AUTH_TEMPLATE_SLUG,
MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
MICROSOFT_DELEGATED_AUTH_LABEL,
MICROSOFT_GRAPH_BASE_SCOPES,
MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES,
MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
Expand Down Expand Up @@ -173,13 +175,15 @@ const microsoftOAuthTemplate = (
{
slug: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
kind: "oauth2",
label: MICROSOFT_DELEGATED_AUTH_LABEL,
authorizationUrl: endpoints.authorizationUrl,
tokenUrl: endpoints.tokenUrl,
scopes,
},
{
slug: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
kind: "oauth2",
label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
authorizationUrl: endpoints.authorizationUrl,
tokenUrl: endpoints.clientCredentialsTokenUrl,
scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ describe("Microsoft Graph scope presets", () => {
expect(preset.defaultSlug).toBeTruthy();
expect(preset.authTemplate).toHaveLength(2);
expect(preset.authTemplate?.map((template) => template.kind)).toEqual(["oauth2", "oauth2"]);
// Both methods are oauth2, so distinct labels are what keeps the
// delegated and app-only tabs tellable apart in the connection dialog.
expect(
preset.authTemplate?.map((template) =>
template.kind === "oauth2" ? template.label : null,
),
).toEqual(["OAuth2 (user)", "OAuth2 (app-only)"]);
}
});

Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/openapi/src/providers/microsoft/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export const MICROSOFT_AUTHORIZATION_URL =
export const MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
export const MICROSOFT_AUTH_TEMPLATE_SLUG = "azureAdDelegated";
export const MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG = "azureAdClientCredentials";
// Both templates are plain oauth2, so without labels they'd render as two
// identical "OAuth2" methods. Microsoft's terms: delegated (signed-in user)
// vs app-only (client credentials, no user).
export const MICROSOFT_DELEGATED_AUTH_LABEL = "OAuth2 (user)";
export const MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL = "OAuth2 (app-only)";
export const MICROSOFT_GRAPH_BASE_SCOPES: readonly string[] = ["offline_access"];
export const MICROSOFT_GRAPH_IDENTITY_SCOPE = "User.Read";
export const MICROSOFT_GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default";
Expand Down Expand Up @@ -539,13 +544,15 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) =>
{
slug: MICROSOFT_AUTH_TEMPLATE_SLUG,
kind: "oauth2" as const,
label: MICROSOFT_DELEGATED_AUTH_LABEL,
authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
tokenUrl: MICROSOFT_TOKEN_URL,
scopes: microsoftGraphScopesForPresetIds([preset.id]),
},
{
slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
kind: "oauth2" as const,
label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
tokenUrl: MICROSOFT_TOKEN_URL,
scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
Expand Down
16 changes: 16 additions & 0 deletions packages/plugins/openapi/src/react/auth-method-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ describe("editor round-trip", () => {
});
});

it("oauth stored → editor → stored preserves the display label", () => {
const stored: Authentication = {
slug: AuthTemplateSlug.make("azureAdDelegated"),
kind: "oauth2",
label: "OAuth2 (user)",
authorizationUrl: "https://x.example/auth",
tokenUrl: "https://x.example/token",
resource: null,
scopes: ["a"],
};
const editor = editorValueFromAuthentication(stored);
const back = authenticationFromEditorValue(editor, "azureAdDelegated");
expect(back).toEqual(stored);
expect(authMethodsFromConfig([stored])[0]?.label).toBe("OAuth2 (user)");
});

it("none editor value yields no method", () => {
expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
});
Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/openapi/src/react/auth-method-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>):
const slug = String(template.slug);
return {
id: slug,
label: "OAuth2",
label: template.label ?? "OAuth2",
kind: "oauth",
source: slug.startsWith("custom_") ? "custom" : "spec",
template: AuthTemplateSlug.make(slug),
Expand Down Expand Up @@ -78,6 +78,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem
if (template.kind === "oauth2") {
return {
kind: "oauth",
...(template.label !== undefined ? { label: template.label } : {}),
authorizationUrl: template.authorizationUrl ?? "",
tokenUrl: template.tokenUrl ?? "",
resource: template.resource ?? null,
Expand All @@ -95,6 +96,7 @@ const oauthTemplateFromEditorValue = (
): Authentication => ({
slug: AuthTemplateSlug.make(slug ?? ""),
kind: "oauth2",
...(value.label !== undefined ? { label: value.label } : {}),
authorizationUrl: value.authorizationUrl,
tokenUrl: value.tokenUrl,
resource: value.resource ?? null,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/sdk/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
const OAuthAuthenticationSchema = Schema.Struct({
slug: Schema.String,
kind: Schema.Literal("oauth2"),
label: Schema.optional(Schema.String),
authorizationUrl: Schema.String,
tokenUrl: Schema.String,
resource: Schema.optional(Schema.NullOr(Schema.String)),
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/openapi/src/sdk/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin

import { openApiPlugin } from "./plugin";
import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types";
import { variable } from "@executor-js/sdk/http-auth";
import { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
import {
makeOpenApiHttpApiTestIntegrationConfig,
serveOpenApiHttpApiTestServer,
Expand Down Expand Up @@ -83,7 +83,7 @@ const customApiKey: AuthenticationInput = {
// omits it, which `configure` should backfill with a generated `custom_<id>`.
// The cast is confined to this one boundary helper.
const sluglessApiKey = (
template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
template: Omit<ApiKeyAuthTemplate, "slug" | "type">,
): AuthenticationInput => ({ type: "apiKey", ...template });

describe("OpenAPI Plugin — configure (custom auth method)", () => {
Expand Down
47 changes: 46 additions & 1 deletion packages/plugins/openapi/src/sdk/derive-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "@effect/vitest";
import { Option } from "effect";

import { resolvedOAuthScopes } from "./derive-auth";
import { detectedAuthenticationTemplates, resolvedOAuthScopes } from "./derive-auth";
import { OAuth2Preset } from "./preview";

describe("resolvedOAuthScopes", () => {
it("does not synthesize OIDC scopes for a plain OAuth provider", () => {
Expand All @@ -26,3 +28,46 @@ describe("resolvedOAuthScopes", () => {
]);
});
});

const oauth2Preset = (
securitySchemeName: string,
flow: "authorizationCode" | "clientCredentials",
) =>
OAuth2Preset.make({
label: `OAuth2 ${flow === "authorizationCode" ? "Authorization Code" : "Client Credentials"} · ${securitySchemeName}`,
securitySchemeName,
flow,
authorizationUrl:
flow === "authorizationCode" ? Option.some("https://example.com/auth") : Option.none(),
tokenUrl: "https://example.com/token",
resource: Option.none(),
refreshUrl: Option.none(),
scopes: { read: "Read access" },
identityScopes: "auto",
});

describe("detectedAuthenticationTemplates", () => {
it("stores no label for a lone oauth2 method", () => {
const templates = detectedAuthenticationTemplates(
[],
[oauth2Preset("oauth_app", "authorizationCode")],
"https://example.com",
);
expect(templates).toHaveLength(1);
expect(templates[0]).not.toHaveProperty("label");
});

it("labels each oauth2 method from its preset when several are detected", () => {
const templates = detectedAuthenticationTemplates(
[],
[
oauth2Preset("oauth_app", "authorizationCode"),
oauth2Preset("oauth_app", "clientCredentials"),
],
"https://example.com",
);
expect(
templates.map((template) => (template.kind === "oauth2" ? template.label : null)),
).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);
});
});
7 changes: 7 additions & 0 deletions packages/plugins/openapi/src/sdk/derive-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,11 @@ const oauthTemplateFromPreset = (
baseUrl: string,
slug: AuthTemplateSlug,
scopes: readonly string[],
label?: string,
): OAuthAuthentication => ({
slug,
kind: "oauth2",
...(label !== undefined ? { label } : {}),
authorizationUrl: resolveOAuthUrl(
Option.getOrElse(preset.authorizationUrl, () => ""),
baseUrl,
Expand Down Expand Up @@ -161,6 +163,10 @@ export const detectedAuthenticationTemplates = (
apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
);
});
// A lone oauth2 method needs no disambiguating label (it renders as plain
// "OAuth2"); with several, each carries its preset label so the stored
// methods stay tellable apart in the UI.
const labelled = oauth2Presets.length > 1;
for (const preset of oauth2Presets) {
const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
templates.push(
Expand All @@ -169,6 +175,7 @@ export const detectedAuthenticationTemplates = (
baseUrl,
AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
scopes,
labelled ? preset.label : undefined,
),
);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ describe("describeOpenApiAuthMethods", () => {
]);
});

it("prefers a stored oauth label over the generic OAuth2 fallback", () => {
const methods = describeOpenApiAuthMethods(
recordWith([
{
slug: AuthTemplateSlug.make("azureAdDelegated"),
kind: "oauth2",
label: "OAuth2 (user)",
authorizationUrl: "https://auth.example/authorize",
tokenUrl: "https://auth.example/token",
scopes: ["read"],
},
]),
);

expect(methods.map((method) => method.label)).toEqual(["OAuth2 (user)"]);
});

it("returns [] when no auth template is declared and for a foreign config", () => {
expect(describeOpenApiAuthMethods(recordWith([]))).toEqual([]);
expect(
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/openapi/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ const AuthenticationSchema = Schema.Union([
Schema.Struct({
slug: Schema.String,
kind: Schema.Literal("oauth2"),
label: Schema.optional(Schema.String),
authorizationUrl: Schema.String,
tokenUrl: Schema.String,
resource: Schema.optional(Schema.NullOr(Schema.String)),
Expand Down Expand Up @@ -580,7 +581,7 @@ export const describeOpenApiAuthMethods = (
if (template.kind === "oauth2") {
return {
id: String(template.slug),
label: "OAuth2",
label: template.label ?? "OAuth2",
kind: "oauth",
template: String(template.slug),
oauth: {
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/components/auth-template-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export type AuthTemplateEditorValue =
| { readonly kind: "apikey"; readonly placements: readonly Placement[] }
| {
readonly kind: "oauth";
/** Display label of the stored method, carried through untouched so an
* editor round-trip doesn't strip it. Not edited here. */
readonly label?: string;
readonly authorizationUrl: string;
readonly tokenUrl: string;
readonly resource?: string | null;
Expand Down
Loading