History

openapi: allow saving oauth2 source before sign-in

Mirror the recent MCP change for OpenAPI sources. Selecting OAuth2 as the auth strategy no longer forces the admin to complete the authorization flow before Save — the source is persisted with its OAuth config intact and a deterministic placeholder connectionId, and each user later mints their own backing connection via OpenApiSignInButton on the source detail page. The sign-in handler already rewrites `oauth2.connectionId` on successful sign-in, so the placeholder just needs to be distinct from any real `openapi-oauth2-\${uuid}` and stable per-namespace. UI: `oauth2DeferrableReady` now gates Save on having a preset + a client id secret (plus a client secret for clientCredentials) — enough metadata for the deferred sign-in path to run. The Connect via OAuth button grows a one-line hint clarifying it's optional. Plugin side needs no changes: `addSpec` → `rebuildSource` parses the spec and persists tool defs without ever resolving the connection. The access token is only fetched at invoke time.

Rhys Sullivan committed 6 months ago

Commit 323a52e1 parent e9fad472 files · +136 −10Browse files

Unified · Split

2 files
  1. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +70 −10
  2. packages/plugins/openapi/src/sdk/plugin.test.ts +66 −0
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+70−10
@@ -69,6 +69,16 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 68 unmodified lines
6969 return out;
7070 };
7171
72+/**
73+ * Stable placeholder connection id used when saving a source without
74+ * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId`
75+ * to a freshly minted id on first sign-in, so this only needs to be
76+ * deterministic per-namespace and distinct from any real connection id
77+ * (real ids are `openapi-oauth2-${uuid}`).
78+ */
79+const openApiOAuthConnectionId = (namespaceSlug: string): string =>
80+ `openapi-oauth2-pending-${namespaceSlug || "default"}`;
81+
7282 /**
7383 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,
7484 * but some specs ship relative paths like `/api/rest/v1/oauth/token`.
@@ -270,8 +280,18 @@ export default function AddOpenApiSource(props: { · 195 unmodified lines
270280 const selectedOAuth2Preset: OAuth2Preset | null =
271281 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;
272282
283+ // OAuth is "ready to save" without completing the flow as long as we have
284+ // enough metadata to run sign-in later: a preset, a client id secret, and —
285+ // for clientCredentials — a client secret. Each user signs in via
286+ // OpenApiSignInButton on the source detail page; innermost-wins scope
287+ // shadowing resolves tokens per-user at invoke time.
288+ const oauth2DeferrableReady =
289+ selectedOAuth2Preset !== null &&
290+ oauth2ClientIdSecretId !== null &&
291+ (selectedOAuth2Preset.flow !== "clientCredentials" ||
292+ oauth2ClientSecretSecretId !== null);
273293 const oauth2Ready =
274− strategy.kind !== "oauth2" || oauth2Auth !== null;
294+ strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady;
275295
276296 const canAdd =
277297 preview !== null &&
@@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines
523543 kind: "openapi",
524544 url: resolvedBaseUrl || undefined,
525545 });
546+ // If the user picked oauth2 but didn't complete sign-in, persist a
547+ // deferred OAuth2Auth with a stable placeholder connectionId so the
548+ // source lands with its OAuth config intact. OpenApiSignInButton
549+ // rewrites the pointer once a user actually signs in.
550+ let oauth2ToSave: OAuth2Auth | null = oauth2Auth;
551+ if (
552+ !oauth2ToSave &&
553+ strategy.kind === "oauth2" &&
554+ selectedOAuth2Preset &&
555+ oauth2ClientIdSecretId
556+ ) {
557+ const tokenUrl = resolveOAuthUrl(
558+ selectedOAuth2Preset.tokenUrl,
559+ resolvedBaseUrl,
560+ );
561+ const authorizationUrl =
562+ selectedOAuth2Preset.flow === "authorizationCode"
563+ ? resolveOAuthUrl(
564+ Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
565+ resolvedBaseUrl,
566+ ) || null
567+ : null;
568+ oauth2ToSave = new OAuth2Auth({
569+ kind: "oauth2",
570+ connectionId: openApiOAuthConnectionId(namespace),
571+ securitySchemeName: selectedOAuth2Preset.securitySchemeName,
572+ flow: selectedOAuth2Preset.flow,
573+ tokenUrl,
574+ authorizationUrl,
575+ clientIdSecretId: oauth2ClientIdSecretId,
576+ clientSecretSecretId: oauth2ClientSecretSecretId,
577+ scopes: [...oauth2SelectedScopes],
578+ });
579+ }
526580 try {
527581 await doAdd({
528582 path: { scopeId },
@@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
532586 namespace: slugifyNamespace(identity.namespace) || undefined,
533587 baseUrl: resolvedBaseUrl || undefined,
534588 ...(hasHeaders ? { headers: allHeaders } : {}),
535− ...(oauth2Auth ? { oauth2: oauth2Auth } : {}),
589+ ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}),
536590 },
537591 reactivityKeys: sourceWriteKeys,
538592 });
@@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines
9601014 </Button>
9611015 </div>
9621016 ) : (
963− <Button
964− variant="secondary"
965− onClick={handleConnectOAuth2}
966− disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
967− className="w-full"
968− >
969− Connect via OAuth
970− </Button>
1017+ <div className="flex flex-col gap-1.5">
1018+ <Button
1019+ variant="secondary"
1020+ onClick={handleConnectOAuth2}
1021+ disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
1022+ className="w-full"
1023+ >
1024+ Connect via OAuth
1025+ </Button>
1026+ <p className="text-[11px] text-muted-foreground">
1027+ Optional — you can save the source now and each user can sign
1028+ in from the source detail page later.
1029+ </p>
1030+ </div>
9711031 )}
9721032
9731033 {oauth2Error && (
packages/plugins/openapi/src/sdk/plugin.test.ts+66−0
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525
2626 const TEST_SCOPE = "test-scope";
2727 import { openApiPlugin } from "./plugin";
28+import { OAuth2Auth } from "./types";
2829
2930 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
3031
@@ -603,4 +604,69 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 572 unmodified lines
603604 expect(orgView?.config.baseUrl).toBe("https://org.example.com");
604605 }),
605606 );
607+
608+ it.effect(
609+ "addSpec persists a source with deferred OAuth2Auth (no live connection yet)",
610+ () =>
611+ Effect.gen(function* () {
612+ const httpClient = yield* HttpClient.HttpClient;
613+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
614+
615+ const executor = yield* createExecutor(
616+ makeTestConfig({
617+ plugins: [
618+ openApiPlugin({ httpClientLayer: clientLayer }),
619+ memorySecretsPlugin(),
620+ ] as const,
621+ }),
622+ );
623+
624+ // A team-shared client id secret, but no live connection for this
625+ // scope — the admin is saving the source and deferring sign-in
626+ // to individual users.
627+ yield* executor.secrets.set(
628+ new SetSecretInput({
629+ id: SecretId.make("acme-client-id"),
630+ scope: ScopeId.make(TEST_SCOPE),
631+ name: "Acme Client ID",
632+ value: "client-abc",
633+ }),
634+ );
635+
636+ const deferredAuth = new OAuth2Auth({
637+ kind: "oauth2",
638+ connectionId: "openapi-oauth2-pending-deferred",
639+ securitySchemeName: "oauth2",
640+ flow: "authorizationCode",
641+ tokenUrl: "https://auth.example.com/token",
642+ authorizationUrl: "https://auth.example.com/authorize",
643+ clientIdSecretId: "acme-client-id",
644+ clientSecretSecretId: null,
645+ scopes: ["read:items"],
646+ });
647+
648+ const result = yield* executor.openapi.addSpec({
649+ spec: specJson,
650+ scope: TEST_SCOPE,
651+ namespace: "deferred",
652+ baseUrl: "https://api.example.com",
653+ oauth2: deferredAuth,
654+ });
655+
656+ expect(result.toolCount).toBeGreaterThan(0);
657+
658+ const stored = yield* executor.openapi.getSource("deferred", TEST_SCOPE);
659+ expect(stored).not.toBeNull();
660+ expect(stored?.config.oauth2?.connectionId).toBe(
661+ "openapi-oauth2-pending-deferred",
662+ );
663+ expect(stored?.config.oauth2?.clientIdSecretId).toBe("acme-client-id");
664+ expect(stored?.config.oauth2?.flow).toBe("authorizationCode");
665+
666+ // Tools should be listed even without a live connection; invocation
667+ // is what requires the token, not registration.
668+ const tools = yield* executor.tools.list();
669+ expect(tools.some((t) => t.id.startsWith("deferred."))).toBe(true);
670+ }),
671+ );
606672 });