Unified · Split
| @@ -126,6 +126,7 @@ export type IntegrationPresetAuthentication = · 125 unmodified lines | ||
| 126 | 126 | | { |
| 127 | 127 | readonly slug: string; |
| 128 | 128 | readonly kind: "oauth2"; |
| 129 | + readonly label?: string; | |
| 129 | 130 | readonly authorizationUrl: string; |
| 130 | 131 | readonly tokenUrl: string; |
| 131 | 132 | readonly resource?: string | null; |
| @@ -31,6 +31,10 @@ export type OAuthGrant = "authorization_code" | "client_credentials"; · 30 unmodified lines | ||
| 31 | 31 | export interface OAuthAuthentication { |
| 32 | 32 | readonly slug: AuthTemplateSlug; |
| 33 | 33 | readonly kind: "oauth2"; |
| 34 | + /** Display label distinguishing this method when an integration declares | |
| 35 | + * several oauth2 templates (e.g. delegated vs app-only). UIs fall back to | |
| 36 | + * "OAuth2" when absent. */ | |
| 37 | + readonly label?: string; | |
| 34 | 38 | readonly authorizationUrl: string; |
| 35 | 39 | readonly tokenUrl: string; |
| 36 | 40 | /** RFC 8707 Resource Indicator to bind the OAuth flow to this protected |
| @@ -582,6 +582,7 @@ export type IntegrationPresetAuthentication = · 581 unmodified lines | ||
| 582 | 582 | | { |
| 583 | 583 | readonly slug: string; |
| 584 | 584 | readonly kind: "oauth2"; |
| 585 | + readonly label?: string; | |
| 585 | 586 | readonly authorizationUrl: string; |
| 586 | 587 | readonly tokenUrl: string; |
| 587 | 588 | readonly resource?: string | null; |
| @@ -62,6 +62,7 @@ const OpenApiSpecInputPayload = Schema.Union([ · 61 unmodified lines | ||
| 62 | 62 | const OAuthTemplatePayload = Schema.Struct({ |
| 63 | 63 | slug: Schema.String, |
| 64 | 64 | kind: Schema.Literal("oauth2"), |
| 65 | + label: Schema.optional(Schema.String), | |
| 65 | 66 | authorizationUrl: Schema.String, |
| 66 | 67 | tokenUrl: Schema.String, |
| 67 | 68 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -23,7 +23,9 @@ import { · 22 unmodified lines | ||
| 23 | 23 | import { |
| 24 | 24 | MICROSOFT_AUTHORIZATION_URL, |
| 25 | 25 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 26 | + MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL, | |
| 26 | 27 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 28 | + MICROSOFT_DELEGATED_AUTH_LABEL, | |
| 27 | 29 | MICROSOFT_GRAPH_BASE_SCOPES, |
| 28 | 30 | MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES, |
| 29 | 31 | MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, |
| @@ -173,6 +175,7 @@ const microsoftOAuthTemplate = ( · 143 unmodified lines | ||
| 173 | 175 | { |
| 174 | 176 | slug: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), |
| 175 | 177 | kind: "oauth2", |
| 178 | + label: MICROSOFT_DELEGATED_AUTH_LABEL, | |
| 176 | 179 | authorizationUrl: endpoints.authorizationUrl, |
| 177 | 180 | tokenUrl: endpoints.tokenUrl, |
| 178 | 181 | scopes, |
| @@ -180,6 +183,7 @@ const microsoftOAuthTemplate = ( · 1 unmodified line | ||
| 180 | 183 | { |
| 181 | 184 | slug: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), |
| 182 | 185 | kind: "oauth2", |
| 186 | + label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL, | |
| 183 | 187 | authorizationUrl: endpoints.authorizationUrl, |
| 184 | 188 | tokenUrl: endpoints.clientCredentialsTokenUrl, |
| 185 | 189 | scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], |
| @@ -288,6 +288,13 @@ describe("Microsoft Graph scope presets", () => { · 287 unmodified lines | ||
| 288 | 288 | expect(preset.defaultSlug).toBeTruthy(); |
| 289 | 289 | expect(preset.authTemplate).toHaveLength(2); |
| 290 | 290 | expect(preset.authTemplate?.map((template) => template.kind)).toEqual(["oauth2", "oauth2"]); |
| 291 | + // Both methods are oauth2, so distinct labels are what keeps the | |
| 292 | + // delegated and app-only tabs tellable apart in the connection dialog. | |
| 293 | + expect( | |
| 294 | + preset.authTemplate?.map((template) => | |
| 295 | + template.kind === "oauth2" ? template.label : null, | |
| 296 | + ), | |
| 297 | + ).toEqual(["OAuth2 (user)", "OAuth2 (app-only)"]); | |
| 291 | 298 | } |
| 292 | 299 | }); |
| 293 | 300 | |
| @@ -44,6 +44,11 @@ export const MICROSOFT_AUTHORIZATION_URL = · 43 unmodified lines | ||
| 44 | 44 | export const MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; |
| 45 | 45 | export const MICROSOFT_AUTH_TEMPLATE_SLUG = "azureAdDelegated"; |
| 46 | 46 | export const MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG = "azureAdClientCredentials"; |
| 47 | +// Both templates are plain oauth2, so without labels they'd render as two | |
| 48 | +// identical "OAuth2" methods. Microsoft's terms: delegated (signed-in user) | |
| 49 | +// vs app-only (client credentials, no user). | |
| 50 | +export const MICROSOFT_DELEGATED_AUTH_LABEL = "OAuth2 (user)"; | |
| 51 | +export const MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL = "OAuth2 (app-only)"; | |
| 47 | 52 | export const MICROSOFT_GRAPH_BASE_SCOPES: readonly string[] = ["offline_access"]; |
| 48 | 53 | export const MICROSOFT_GRAPH_IDENTITY_SCOPE = "User.Read"; |
| 49 | 54 | export const MICROSOFT_GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"; |
| @@ -539,6 +544,7 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => · 489 unmodified lines | ||
| 539 | 544 | { |
| 540 | 545 | slug: MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 541 | 546 | kind: "oauth2" as const, |
| 547 | + label: MICROSOFT_DELEGATED_AUTH_LABEL, | |
| 542 | 548 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, |
| 543 | 549 | tokenUrl: MICROSOFT_TOKEN_URL, |
| 544 | 550 | scopes: microsoftGraphScopesForPresetIds([preset.id]), |
| @@ -546,6 +552,7 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => · 1 unmodified line | ||
| 546 | 552 | { |
| 547 | 553 | slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 548 | 554 | kind: "oauth2" as const, |
| 555 | + label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL, | |
| 549 | 556 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, |
| 550 | 557 | tokenUrl: MICROSOFT_TOKEN_URL, |
| 551 | 558 | scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], |
| @@ -91,6 +91,22 @@ describe("editor round-trip", () => { · 90 unmodified lines | ||
| 91 | 91 | }); |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | + it("oauth stored → editor → stored preserves the display label", () => { | |
| 95 | + const stored: Authentication = { | |
| 96 | + slug: AuthTemplateSlug.make("azureAdDelegated"), | |
| 97 | + kind: "oauth2", | |
| 98 | + label: "OAuth2 (user)", | |
| 99 | + authorizationUrl: "https://x.example/auth", | |
| 100 | + tokenUrl: "https://x.example/token", | |
| 101 | + resource: null, | |
| 102 | + scopes: ["a"], | |
| 103 | + }; | |
| 104 | + const editor = editorValueFromAuthentication(stored); | |
| 105 | + const back = authenticationFromEditorValue(editor, "azureAdDelegated"); | |
| 106 | + expect(back).toEqual(stored); | |
| 107 | + expect(authMethodsFromConfig([stored])[0]?.label).toBe("OAuth2 (user)"); | |
| 108 | + }); | |
| 109 | + | |
| 94 | 110 | it("none editor value yields no method", () => { |
| 95 | 111 | expect(authenticationFromEditorValue({ kind: "none" })).toBeNull(); |
| 96 | 112 | }); |
| @@ -31,7 +31,7 @@ const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): · 30 unmodified lines | ||
| 31 | 31 | const slug = String(template.slug); |
| 32 | 32 | return { |
| 33 | 33 | id: slug, |
| 34 | − label: "OAuth2", | |
| 34 | + label: template.label ?? "OAuth2", | |
| 35 | 35 | kind: "oauth", |
| 36 | 36 | source: slug.startsWith("custom_") ? "custom" : "spec", |
| 37 | 37 | template: AuthTemplateSlug.make(slug), |
| @@ -78,6 +78,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 40 unmodified lines | ||
| 78 | 78 | if (template.kind === "oauth2") { |
| 79 | 79 | return { |
| 80 | 80 | kind: "oauth", |
| 81 | + ...(template.label !== undefined ? { label: template.label } : {}), | |
| 81 | 82 | authorizationUrl: template.authorizationUrl ?? "", |
| 82 | 83 | tokenUrl: template.tokenUrl ?? "", |
| 83 | 84 | resource: template.resource ?? null, |
| @@ -95,6 +96,7 @@ const oauthTemplateFromEditorValue = ( · 11 unmodified lines | ||
| 95 | 96 | ): Authentication => ({ |
| 96 | 97 | slug: AuthTemplateSlug.make(slug ?? ""), |
| 97 | 98 | kind: "oauth2", |
| 99 | + ...(value.label !== undefined ? { label: value.label } : {}), | |
| 98 | 100 | authorizationUrl: value.authorizationUrl, |
| 99 | 101 | tokenUrl: value.tokenUrl, |
| 100 | 102 | resource: value.resource ?? null, |
| @@ -32,6 +32,7 @@ import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides"; · 31 unmodified lines | ||
| 32 | 32 | const OAuthAuthenticationSchema = Schema.Struct({ |
| 33 | 33 | slug: Schema.String, |
| 34 | 34 | kind: Schema.Literal("oauth2"), |
| 35 | + label: Schema.optional(Schema.String), | |
| 35 | 36 | authorizationUrl: Schema.String, |
| 36 | 37 | tokenUrl: Schema.String, |
| 37 | 38 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -29,7 +29,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 28 unmodified lines | ||
| 29 | 29 | |
| 30 | 30 | import { openApiPlugin } from "./plugin"; |
| 31 | 31 | import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "./types"; |
| 32 | −import { variable } from "@executor-js/sdk/http-auth"; | |
| 32 | +import { variable, type ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | |
| 33 | 33 | import { |
| 34 | 34 | makeOpenApiHttpApiTestIntegrationConfig, |
| 35 | 35 | serveOpenApiHttpApiTestServer, |
| @@ -83,7 +83,7 @@ const customApiKey: AuthenticationInput = { · 47 unmodified lines | ||
| 83 | 83 | // omits it, which `configure` should backfill with a generated `custom_<id>`. |
| 84 | 84 | // The cast is confined to this one boundary helper. |
| 85 | 85 | const sluglessApiKey = ( |
| 86 | − template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">, | |
| 86 | + template: Omit<ApiKeyAuthTemplate, "slug" | "type">, | |
| 87 | 87 | ): AuthenticationInput => ({ type: "apiKey", ...template }); |
| 88 | 88 | |
| 89 | 89 | describe("OpenAPI Plugin — configure (custom auth method)", () => { |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { Option } from "effect"; | |
| 2 | 3 | |
| 3 | −import { resolvedOAuthScopes } from "./derive-auth"; | |
| 4 | +import { detectedAuthenticationTemplates, resolvedOAuthScopes } from "./derive-auth"; | |
| 5 | +import { OAuth2Preset } from "./preview"; | |
| 4 | 6 | |
| 5 | 7 | describe("resolvedOAuthScopes", () => { |
| 6 | 8 | it("does not synthesize OIDC scopes for a plain OAuth provider", () => { |
| @@ -26,3 +28,46 @@ describe("resolvedOAuthScopes", () => { · 19 unmodified lines | ||
| 26 | 28 | ]); |
| 27 | 29 | }); |
| 28 | 30 | }); |
| 31 | + | |
| 32 | +const oauth2Preset = ( | |
| 33 | + securitySchemeName: string, | |
| 34 | + flow: "authorizationCode" | "clientCredentials", | |
| 35 | +) => | |
| 36 | + OAuth2Preset.make({ | |
| 37 | + label: `OAuth2 ${flow === "authorizationCode" ? "Authorization Code" : "Client Credentials"} · ${securitySchemeName}`, | |
| 38 | + securitySchemeName, | |
| 39 | + flow, | |
| 40 | + authorizationUrl: | |
| 41 | + flow === "authorizationCode" ? Option.some("https://example.com/auth") : Option.none(), | |
| 42 | + tokenUrl: "https://example.com/token", | |
| 43 | + resource: Option.none(), | |
| 44 | + refreshUrl: Option.none(), | |
| 45 | + scopes: { read: "Read access" }, | |
| 46 | + identityScopes: "auto", | |
| 47 | + }); | |
| 48 | + | |
| 49 | +describe("detectedAuthenticationTemplates", () => { | |
| 50 | + it("stores no label for a lone oauth2 method", () => { | |
| 51 | + const templates = detectedAuthenticationTemplates( | |
| 52 | + [], | |
| 53 | + [oauth2Preset("oauth_app", "authorizationCode")], | |
| 54 | + "https://example.com", | |
| 55 | + ); | |
| 56 | + expect(templates).toHaveLength(1); | |
| 57 | + expect(templates[0]).not.toHaveProperty("label"); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("labels each oauth2 method from its preset when several are detected", () => { | |
| 61 | + const templates = detectedAuthenticationTemplates( | |
| 62 | + [], | |
| 63 | + [ | |
| 64 | + oauth2Preset("oauth_app", "authorizationCode"), | |
| 65 | + oauth2Preset("oauth_app", "clientCredentials"), | |
| 66 | + ], | |
| 67 | + "https://example.com", | |
| 68 | + ); | |
| 69 | + expect( | |
| 70 | + templates.map((template) => (template.kind === "oauth2" ? template.label : null)), | |
| 71 | + ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]); | |
| 72 | + }); | |
| 73 | +}); | |
| @@ -126,9 +126,11 @@ const oauthTemplateFromPreset = ( · 125 unmodified lines | ||
| 126 | 126 | baseUrl: string, |
| 127 | 127 | slug: AuthTemplateSlug, |
| 128 | 128 | scopes: readonly string[], |
| 129 | + label?: string, | |
| 129 | 130 | ): OAuthAuthentication => ({ |
| 130 | 131 | slug, |
| 131 | 132 | kind: "oauth2", |
| 133 | + ...(label !== undefined ? { label } : {}), | |
| 132 | 134 | authorizationUrl: resolveOAuthUrl( |
| 133 | 135 | Option.getOrElse(preset.authorizationUrl, () => ""), |
| 134 | 136 | baseUrl, |
| @@ -161,6 +163,10 @@ export const detectedAuthenticationTemplates = ( · 26 unmodified lines | ||
| 161 | 163 | apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), |
| 162 | 164 | ); |
| 163 | 165 | }); |
| 166 | + // A lone oauth2 method needs no disambiguating label (it renders as plain | |
| 167 | + // "OAuth2"); with several, each carries its preset label so the stored | |
| 168 | + // methods stay tellable apart in the UI. | |
| 169 | + const labelled = oauth2Presets.length > 1; | |
| 164 | 170 | for (const preset of oauth2Presets) { |
| 165 | 171 | const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); |
| 166 | 172 | templates.push( |
| @@ -169,6 +175,7 @@ export const detectedAuthenticationTemplates = ( · 2 unmodified lines | ||
| 169 | 175 | baseUrl, |
| 170 | 176 | AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), |
| 171 | 177 | scopes, |
| 178 | + labelled ? preset.label : undefined, | |
| 172 | 179 | ), |
| 173 | 180 | ); |
| 174 | 181 | } |
| @@ -84,6 +84,23 @@ describe("describeOpenApiAuthMethods", () => { · 83 unmodified lines | ||
| 84 | 84 | ]); |
| 85 | 85 | }); |
| 86 | 86 | |
| 87 | + it("prefers a stored oauth label over the generic OAuth2 fallback", () => { | |
| 88 | + const methods = describeOpenApiAuthMethods( | |
| 89 | + recordWith([ | |
| 90 | + { | |
| 91 | + slug: AuthTemplateSlug.make("azureAdDelegated"), | |
| 92 | + kind: "oauth2", | |
| 93 | + label: "OAuth2 (user)", | |
| 94 | + authorizationUrl: "https://auth.example/authorize", | |
| 95 | + tokenUrl: "https://auth.example/token", | |
| 96 | + scopes: ["read"], | |
| 97 | + }, | |
| 98 | + ]), | |
| 99 | + ); | |
| 100 | + | |
| 101 | + expect(methods.map((method) => method.label)).toEqual(["OAuth2 (user)"]); | |
| 102 | + }); | |
| 103 | + | |
| 87 | 104 | it("returns [] when no auth template is declared and for a foreign config", () => { |
| 88 | 105 | expect(describeOpenApiAuthMethods(recordWith([]))).toEqual([]); |
| 89 | 106 | expect( |
| @@ -288,6 +288,7 @@ const AuthenticationSchema = Schema.Union([ · 287 unmodified lines | ||
| 288 | 288 | Schema.Struct({ |
| 289 | 289 | slug: Schema.String, |
| 290 | 290 | kind: Schema.Literal("oauth2"), |
| 291 | + label: Schema.optional(Schema.String), | |
| 291 | 292 | authorizationUrl: Schema.String, |
| 292 | 293 | tokenUrl: Schema.String, |
| 293 | 294 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -580,7 +581,7 @@ export const describeOpenApiAuthMethods = ( · 286 unmodified lines | ||
| 580 | 581 | if (template.kind === "oauth2") { |
| 581 | 582 | return { |
| 582 | 583 | id: String(template.slug), |
| 583 | − label: "OAuth2", | |
| 584 | + label: template.label ?? "OAuth2", | |
| 584 | 585 | kind: "oauth", |
| 585 | 586 | template: String(template.slug), |
| 586 | 587 | oauth: { |
| @@ -31,6 +31,9 @@ export type AuthTemplateEditorValue = · 30 unmodified lines | ||
| 31 | 31 | | { readonly kind: "apikey"; readonly placements: readonly Placement[] } |
| 32 | 32 | | { |
| 33 | 33 | readonly kind: "oauth"; |
| 34 | + /** Display label of the stored method, carried through untouched so an | |
| 35 | + * editor round-trip doesn't strip it. Not edited here. */ | |
| 36 | + readonly label?: string; | |
| 34 | 37 | readonly authorizationUrl: string; |
| 35 | 38 | readonly tokenUrl: string; |
| 36 | 39 | readonly resource?: string | null; |