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
69 return out;69 return out;
70};70};
71 71
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 */
79const openApiOAuthConnectionId = (namespaceSlug: string): string =>
80 `openapi-oauth2-pending-${namespaceSlug || "default"}`;
81
72/**82/**
73 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,83 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,
74 * but some specs ship relative paths like `/api/rest/v1/oauth/token`.84 * but some specs ship relative paths like `/api/rest/v1/oauth/token`.
@@ -270,8 +280,18 @@ export default function AddOpenApiSource(props: { · 195 unmodified lines
270 const selectedOAuth2Preset: OAuth2Preset | null =280 const selectedOAuth2Preset: OAuth2Preset | null =
271 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;281 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;
272 282
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);
273 const oauth2Ready =293 const oauth2Ready =
274 strategy.kind !== "oauth2" || oauth2Auth !== null;294 strategy.kind !== "oauth2" || oauth2Auth !== null || oauth2DeferrableReady;
275 295
276 const canAdd =296 const canAdd =
277 preview !== null &&297 preview !== null &&
@@ -523,6 +543,40 @@ export default function AddOpenApiSource(props: { · 245 unmodified lines
523 kind: "openapi",543 kind: "openapi",
524 url: resolvedBaseUrl || undefined,544 url: resolvedBaseUrl || undefined,
525 });545 });
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 }
526 try {580 try {
527 await doAdd({581 await doAdd({
528 path: { scopeId },582 path: { scopeId },
@@ -532,7 +586,7 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
532 namespace: slugifyNamespace(identity.namespace) || undefined,586 namespace: slugifyNamespace(identity.namespace) || undefined,
533 baseUrl: resolvedBaseUrl || undefined,587 baseUrl: resolvedBaseUrl || undefined,
534 ...(hasHeaders ? { headers: allHeaders } : {}),588 ...(hasHeaders ? { headers: allHeaders } : {}),
535 ...(oauth2Auth ? { oauth2: oauth2Auth } : {}),589 ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}),
536 },590 },
537 reactivityKeys: sourceWriteKeys,591 reactivityKeys: sourceWriteKeys,
538 });592 });
@@ -960,14 +1014,20 @@ export default function AddOpenApiSource(props: { · 421 unmodified lines
960 </Button>1014 </Button>
961 </div>1015 </div>
962 ) : (1016 ) : (
963 <Button1017 <div className="flex flex-col gap-1.5">
964 variant="secondary"1018 <Button
965 onClick={handleConnectOAuth2}1019 variant="secondary"
966 disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}1020 onClick={handleConnectOAuth2}
967 className="w-full"1021 disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
968 >1022 className="w-full"
969 Connect via OAuth1023 >
970 </Button>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>
971 )}1031 )}
972 1032
973 {oauth2Error && (1033 {oauth2Error && (
packages/plugins/openapi/src/sdk/plugin.test.ts+66−0
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 25
26const TEST_SCOPE = "test-scope";26const TEST_SCOPE = "test-scope";
27import { openApiPlugin } from "./plugin";27import { openApiPlugin } from "./plugin";
28import { OAuth2Auth } from "./types";
28 29
29const autoApprove: InvokeOptions = { onElicitation: "accept-all" };30const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
30 31
@@ -603,4 +604,69 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 572 unmodified lines
603 expect(orgView?.config.baseUrl).toBe("https://org.example.com");604 expect(orgView?.config.baseUrl).toBe("https://org.example.com");
604 }),605 }),
605 );606 );
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 );
606});672});