History

Label oauth2 auth methods so multiple templates stay distinguishable (#1756)

Rhys Sullivan committed 2 months ago

Commit a7888741 parent ea5ae6116 files · +122 −5Browse files

Unified · Split

16 files
  1. packages/core/sdk/src/client.ts +1 −0
  2. packages/core/sdk/src/oauth-client.ts +4 −0
  3. packages/core/sdk/src/plugin.ts +1 −0
  4. packages/plugins/openapi/src/api/group.ts +1 −0
  5. packages/plugins/openapi/src/providers/microsoft/graph.ts +4 −0
  6. packages/plugins/openapi/src/providers/microsoft/presets.test.ts +7 −0
  7. packages/plugins/openapi/src/providers/microsoft/presets.ts +7 −0
  8. packages/plugins/openapi/src/react/auth-method-config.test.ts +16 −0
  9. packages/plugins/openapi/src/react/auth-method-config.ts +3 −1
  10. packages/plugins/openapi/src/sdk/config.ts +1 −0
  11. packages/plugins/openapi/src/sdk/configure.test.ts +2 −2
  12. packages/plugins/openapi/src/sdk/derive-auth.test.ts +46 −1
  13. packages/plugins/openapi/src/sdk/derive-auth.ts +7 −0
  14. packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +17 −0
  15. packages/plugins/openapi/src/sdk/plugin.ts +2 −1
  16. packages/react/src/components/auth-template-editor.tsx +3 −0
packages/core/sdk/src/client.ts+1−0
@@ -126,6 +126,7 @@ export type IntegrationPresetAuthentication = · 125 unmodified lines
126126 | {
127127 readonly slug: string;
128128 readonly kind: "oauth2";
129+ readonly label?: string;
129130 readonly authorizationUrl: string;
130131 readonly tokenUrl: string;
131132 readonly resource?: string | null;
packages/core/sdk/src/oauth-client.ts+4−0
@@ -31,6 +31,10 @@ export type OAuthGrant = "authorization_code" | "client_credentials"; · 30 unmodified lines
3131 export interface OAuthAuthentication {
3232 readonly slug: AuthTemplateSlug;
3333 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;
3438 readonly authorizationUrl: string;
3539 readonly tokenUrl: string;
3640 /** RFC 8707 Resource Indicator to bind the OAuth flow to this protected
packages/core/sdk/src/plugin.ts+1−0
@@ -582,6 +582,7 @@ export type IntegrationPresetAuthentication = · 581 unmodified lines
582582 | {
583583 readonly slug: string;
584584 readonly kind: "oauth2";
585+ readonly label?: string;
585586 readonly authorizationUrl: string;
586587 readonly tokenUrl: string;
587588 readonly resource?: string | null;
packages/plugins/openapi/src/api/group.ts+1−0
@@ -62,6 +62,7 @@ const OpenApiSpecInputPayload = Schema.Union([ · 61 unmodified lines
6262 const OAuthTemplatePayload = Schema.Struct({
6363 slug: Schema.String,
6464 kind: Schema.Literal("oauth2"),
65+ label: Schema.optional(Schema.String),
6566 authorizationUrl: Schema.String,
6667 tokenUrl: Schema.String,
6768 resource: Schema.optional(Schema.NullOr(Schema.String)),
packages/plugins/openapi/src/providers/microsoft/graph.ts+4−0
@@ -23,7 +23,9 @@ import { · 22 unmodified lines
2323 import {
2424 MICROSOFT_AUTHORIZATION_URL,
2525 MICROSOFT_AUTH_TEMPLATE_SLUG,
26+ MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
2627 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
28+ MICROSOFT_DELEGATED_AUTH_LABEL,
2729 MICROSOFT_GRAPH_BASE_SCOPES,
2830 MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES,
2931 MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
@@ -173,6 +175,7 @@ const microsoftOAuthTemplate = ( · 143 unmodified lines
173175 {
174176 slug: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
175177 kind: "oauth2",
178+ label: MICROSOFT_DELEGATED_AUTH_LABEL,
176179 authorizationUrl: endpoints.authorizationUrl,
177180 tokenUrl: endpoints.tokenUrl,
178181 scopes,
@@ -180,6 +183,7 @@ const microsoftOAuthTemplate = ( · 1 unmodified line
180183 {
181184 slug: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
182185 kind: "oauth2",
186+ label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
183187 authorizationUrl: endpoints.authorizationUrl,
184188 tokenUrl: endpoints.clientCredentialsTokenUrl,
185189 scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
packages/plugins/openapi/src/providers/microsoft/presets.test.ts+7−0
@@ -288,6 +288,13 @@ describe("Microsoft Graph scope presets", () => { · 287 unmodified lines
288288 expect(preset.defaultSlug).toBeTruthy();
289289 expect(preset.authTemplate).toHaveLength(2);
290290 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)"]);
291298 }
292299 });
293300
packages/plugins/openapi/src/providers/microsoft/presets.ts+7−0
@@ -44,6 +44,11 @@ export const MICROSOFT_AUTHORIZATION_URL = · 43 unmodified lines
4444 export const MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
4545 export const MICROSOFT_AUTH_TEMPLATE_SLUG = "azureAdDelegated";
4646 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)";
4752 export const MICROSOFT_GRAPH_BASE_SCOPES: readonly string[] = ["offline_access"];
4853 export const MICROSOFT_GRAPH_IDENTITY_SCOPE = "User.Read";
4954 export const MICROSOFT_GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default";
@@ -539,6 +544,7 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => · 489 unmodified lines
539544 {
540545 slug: MICROSOFT_AUTH_TEMPLATE_SLUG,
541546 kind: "oauth2" as const,
547+ label: MICROSOFT_DELEGATED_AUTH_LABEL,
542548 authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
543549 tokenUrl: MICROSOFT_TOKEN_URL,
544550 scopes: microsoftGraphScopesForPresetIds([preset.id]),
@@ -546,6 +552,7 @@ const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => · 1 unmodified line
546552 {
547553 slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
548554 kind: "oauth2" as const,
555+ label: MICROSOFT_CLIENT_CREDENTIALS_AUTH_LABEL,
549556 authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
550557 tokenUrl: MICROSOFT_TOKEN_URL,
551558 scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
packages/plugins/openapi/src/react/auth-method-config.test.ts+16−0
@@ -91,6 +91,22 @@ describe("editor round-trip", () => { · 90 unmodified lines
9191 });
9292 });
9393
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+
94110 it("none editor value yields no method", () => {
95111 expect(authenticationFromEditorValue({ kind: "none" })).toBeNull();
96112 });
packages/plugins/openapi/src/react/auth-method-config.ts+3−1
@@ -31,7 +31,7 @@ const oauthAuthMethod = (template: Extract<Authentication, { kind: "oauth2" }>): · 30 unmodified lines
3131 const slug = String(template.slug);
3232 return {
3333 id: slug,
34− label: "OAuth2",
34+ label: template.label ?? "OAuth2",
3535 kind: "oauth",
3636 source: slug.startsWith("custom_") ? "custom" : "spec",
3737 template: AuthTemplateSlug.make(slug),
@@ -78,6 +78,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem · 40 unmodified lines
7878 if (template.kind === "oauth2") {
7979 return {
8080 kind: "oauth",
81+ ...(template.label !== undefined ? { label: template.label } : {}),
8182 authorizationUrl: template.authorizationUrl ?? "",
8283 tokenUrl: template.tokenUrl ?? "",
8384 resource: template.resource ?? null,
@@ -95,6 +96,7 @@ const oauthTemplateFromEditorValue = ( · 11 unmodified lines
9596 ): Authentication => ({
9697 slug: AuthTemplateSlug.make(slug ?? ""),
9798 kind: "oauth2",
99+ ...(value.label !== undefined ? { label: value.label } : {}),
98100 authorizationUrl: value.authorizationUrl,
99101 tokenUrl: value.tokenUrl,
100102 resource: value.resource ?? null,
packages/plugins/openapi/src/sdk/config.ts+1−0
@@ -32,6 +32,7 @@ import { SpecOverridesSchema, type SpecOverrides } from "./spec-overrides"; · 31 unmodified lines
3232 const OAuthAuthenticationSchema = Schema.Struct({
3333 slug: Schema.String,
3434 kind: Schema.Literal("oauth2"),
35+ label: Schema.optional(Schema.String),
3536 authorizationUrl: Schema.String,
3637 tokenUrl: Schema.String,
3738 resource: Schema.optional(Schema.NullOr(Schema.String)),
packages/plugins/openapi/src/sdk/configure.test.ts+2−2
@@ -29,7 +29,7 @@ import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testin · 28 unmodified lines
2929
3030 import { openApiPlugin } from "./plugin";
3131 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";
3333 import {
3434 makeOpenApiHttpApiTestIntegrationConfig,
3535 serveOpenApiHttpApiTestServer,
@@ -83,7 +83,7 @@ const customApiKey: AuthenticationInput = { · 47 unmodified lines
8383 // omits it, which `configure` should backfill with a generated `custom_<id>`.
8484 // The cast is confined to this one boundary helper.
8585 const sluglessApiKey = (
86− template: Omit<AuthenticationInput & { type: "apiKey" }, "slug" | "type">,
86+ template: Omit<ApiKeyAuthTemplate, "slug" | "type">,
8787 ): AuthenticationInput => ({ type: "apiKey", ...template });
8888
8989 describe("OpenAPI Plugin — configure (custom auth method)", () => {
packages/plugins/openapi/src/sdk/derive-auth.test.ts+46−1
@@ -1,6 +1,8 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { Option } from "effect";
23
3−import { resolvedOAuthScopes } from "./derive-auth";
4+import { detectedAuthenticationTemplates, resolvedOAuthScopes } from "./derive-auth";
5+import { OAuth2Preset } from "./preview";
46
57 describe("resolvedOAuthScopes", () => {
68 it("does not synthesize OIDC scopes for a plain OAuth provider", () => {
@@ -26,3 +28,46 @@ describe("resolvedOAuthScopes", () => { · 19 unmodified lines
2628 ]);
2729 });
2830 });
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+});
packages/plugins/openapi/src/sdk/derive-auth.ts+7−0
@@ -126,9 +126,11 @@ const oauthTemplateFromPreset = ( · 125 unmodified lines
126126 baseUrl: string,
127127 slug: AuthTemplateSlug,
128128 scopes: readonly string[],
129+ label?: string,
129130 ): OAuthAuthentication => ({
130131 slug,
131132 kind: "oauth2",
133+ ...(label !== undefined ? { label } : {}),
132134 authorizationUrl: resolveOAuthUrl(
133135 Option.getOrElse(preset.authorizationUrl, () => ""),
134136 baseUrl,
@@ -161,6 +163,10 @@ export const detectedAuthenticationTemplates = ( · 26 unmodified lines
161163 apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
162164 );
163165 });
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;
164170 for (const preset of oauth2Presets) {
165171 const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
166172 templates.push(
@@ -169,6 +175,7 @@ export const detectedAuthenticationTemplates = ( · 2 unmodified lines
169175 baseUrl,
170176 AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
171177 scopes,
178+ labelled ? preset.label : undefined,
172179 ),
173180 );
174181 }
packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts+17−0
@@ -84,6 +84,23 @@ describe("describeOpenApiAuthMethods", () => { · 83 unmodified lines
8484 ]);
8585 });
8686
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+
87104 it("returns [] when no auth template is declared and for a foreign config", () => {
88105 expect(describeOpenApiAuthMethods(recordWith([]))).toEqual([]);
89106 expect(
packages/plugins/openapi/src/sdk/plugin.ts+2−1
@@ -288,6 +288,7 @@ const AuthenticationSchema = Schema.Union([ · 287 unmodified lines
288288 Schema.Struct({
289289 slug: Schema.String,
290290 kind: Schema.Literal("oauth2"),
291+ label: Schema.optional(Schema.String),
291292 authorizationUrl: Schema.String,
292293 tokenUrl: Schema.String,
293294 resource: Schema.optional(Schema.NullOr(Schema.String)),
@@ -580,7 +581,7 @@ export const describeOpenApiAuthMethods = ( · 286 unmodified lines
580581 if (template.kind === "oauth2") {
581582 return {
582583 id: String(template.slug),
583− label: "OAuth2",
584+ label: template.label ?? "OAuth2",
584585 kind: "oauth",
585586 template: String(template.slug),
586587 oauth: {
packages/react/src/components/auth-template-editor.tsx+3−0
@@ -31,6 +31,9 @@ export type AuthTemplateEditorValue = · 30 unmodified lines
3131 | { readonly kind: "apikey"; readonly placements: readonly Placement[] }
3232 | {
3333 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;
3437 readonly authorizationUrl: string;
3538 readonly tokenUrl: string;
3639 readonly resource?: string | null;

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t