diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index d0fc0ee977..34e3ded3bf 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -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; diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index ea2c71a388..5b1800e1b6 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -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 diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 6d4dbc23b2..c30a3f43e4 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -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; diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index d1ab562547..0b8077bc42 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -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)), diff --git a/packages/plugins/openapi/src/providers/microsoft/graph.ts b/packages/plugins/openapi/src/providers/microsoft/graph.ts index ebb9d900fe..7b38921a35 100644 --- a/packages/plugins/openapi/src/providers/microsoft/graph.ts +++ b/packages/plugins/openapi/src/providers/microsoft/graph.ts @@ -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, @@ -173,6 +175,7 @@ const microsoftOAuthTemplate = ( { slug: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), kind: "oauth2", + label: MICROSOFT_DELEGATED_AUTH_LABEL, authorizationUrl: endpoints.authorizationUrl, tokenUrl: endpoints.tokenUrl, scopes, @@ -180,6 +183,7 @@ const microsoftOAuthTemplate = ( { 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], diff --git a/packages/plugins/openapi/src/providers/microsoft/presets.test.ts b/packages/plugins/openapi/src/providers/microsoft/presets.test.ts index 30aa13c0ec..56dc87b68b 100644 --- a/packages/plugins/openapi/src/providers/microsoft/presets.test.ts +++ b/packages/plugins/openapi/src/providers/microsoft/presets.test.ts @@ -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)"]); } }); diff --git a/packages/plugins/openapi/src/providers/microsoft/presets.ts b/packages/plugins/openapi/src/providers/microsoft/presets.ts index 33cbcf0e6c..901eb61d41 100644 --- a/packages/plugins/openapi/src/providers/microsoft/presets.ts +++ b/packages/plugins/openapi/src/providers/microsoft/presets.ts @@ -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"; @@ -539,6 +544,7 @@ 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]), @@ -546,6 +552,7 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => { 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], diff --git a/packages/plugins/openapi/src/react/auth-method-config.test.ts b/packages/plugins/openapi/src/react/auth-method-config.test.ts index 7c419881b0..89aec1be18 100644 --- a/packages/plugins/openapi/src/react/auth-method-config.test.ts +++ b/packages/plugins/openapi/src/react/auth-method-config.test.ts @@ -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(); }); diff --git a/packages/plugins/openapi/src/react/auth-method-config.ts b/packages/plugins/openapi/src/react/auth-method-config.ts index f344215562..764c23d79a 100644 --- a/packages/plugins/openapi/src/react/auth-method-config.ts +++ b/packages/plugins/openapi/src/react/auth-method-config.ts @@ -31,7 +31,7 @@ const oauthAuthMethod = (template: Extract): 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), @@ -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, @@ -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, diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index 78c639f4a7..55918fbaf4 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -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)), diff --git a/packages/plugins/openapi/src/sdk/configure.test.ts b/packages/plugins/openapi/src/sdk/configure.test.ts index abdafc1e36..d8b73f2ac8 100644 --- a/packages/plugins/openapi/src/sdk/configure.test.ts +++ b/packages/plugins/openapi/src/sdk/configure.test.ts @@ -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, @@ -83,7 +83,7 @@ const customApiKey: AuthenticationInput = { // omits it, which `configure` should backfill with a generated `custom_`. // The cast is confined to this one boundary helper. const sluglessApiKey = ( - template: Omit, + template: Omit, ): AuthenticationInput => ({ type: "apiKey", ...template }); describe("OpenAPI Plugin — configure (custom auth method)", () => { diff --git a/packages/plugins/openapi/src/sdk/derive-auth.test.ts b/packages/plugins/openapi/src/sdk/derive-auth.test.ts index ba22b1eac2..1b3aea9032 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.test.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.test.ts @@ -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", () => { @@ -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"]); + }); +}); diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts index ac76295724..0f51762309 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.ts @@ -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, @@ -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( @@ -169,6 +175,7 @@ export const detectedAuthenticationTemplates = ( baseUrl, AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), scopes, + labelled ? preset.label : undefined, ), ); } diff --git a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts index cd4ddd9612..f997788275 100644 --- a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts @@ -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( diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index fab5c3b1a7..d92a2ae140 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -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)), @@ -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: { diff --git a/packages/react/src/components/auth-template-editor.tsx b/packages/react/src/components/auth-template-editor.tsx index 6afa2dc2e1..02b2906c0c 100644 --- a/packages/react/src/components/auth-template-editor.tsx +++ b/packages/react/src/components/auth-template-editor.tsx @@ -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;