History

Improve OpenAPI OAuth account reuse (#873)

* Improve OpenAPI OAuth account reuse * Reuse OAuth clients for incremental source scopes * Request only missing Google OAuth scopes * Compact Google OAuth authorization scopes * Clarify connected OAuth account fallback * Refresh OAuth connection state after sign-in * Invalidate connections after OAuth completion

Rhys Sullivan committed 5 months ago

Commit 079243b1 parent 9374a1f19 files · +1131 −219Browse files

Unified · Split

19 files
  1. bun.lock +1 −0
  2. packages/core/sdk/src/http-source.ts +12 −0
  3. packages/core/sdk/src/oauth-service.ts +76 −6
  4. packages/core/sdk/src/oauth.ts +24 −0
  5. packages/core/sdk/src/testing.test.ts +90 −1
  6. packages/core/sdk/src/testing/oauth-test-server.ts +2 −0
  7. packages/plugins/openapi/package.json +1 −0
  8. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +627 −173
  9. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +17 −2
  10. packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx +4 −1
  11. packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +85 −22
  12. packages/plugins/openapi/src/sdk/credential-status.test.ts +34 −0
  13. packages/plugins/openapi/src/sdk/credential-status.ts +15 −10
  14. packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +94 −0
  15. packages/plugins/openapi/src/sdk/plugin.ts +6 −0
  16. packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +2 −0
  17. packages/plugins/openapi/src/sdk/preview.ts +8 −0
  18. packages/react/src/api/atoms.tsx +9 −1
  19. packages/react/src/plugins/oauth-sign-in.tsx +24 −3
bun.lock+1−0
@@ -774,6 +774,7 @@ · 773 unmodified lines
774774 "@executor-js/config": "workspace:*",
775775 "@executor-js/sdk": "workspace:*",
776776 "effect": "catalog:",
777+ "lucide-react": "^1.7.0",
777778 "openapi-types": "^12.1.3",
778779 "yaml": "^2.7.1",
779780 },
packages/core/sdk/src/http-source.ts+12−0
@@ -46,6 +46,14 @@ export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentia · 45 unmodified lines
4646 export type OAuth2Flow = typeof OAuth2Flow.Type;
4747 export type OAuth2FlowType = OAuth2Flow;
4848
49+export const OAuth2IdentityScopes = Schema.Union([
50+ Schema.Literal("auto"),
51+ Schema.Literal(false),
52+ Schema.Array(Schema.String),
53+]);
54+export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type;
55+export type OAuth2IdentityScopesType = OAuth2IdentityScopes;
56+
4957 export const OAuth2SourceConfig = Schema.Struct({
5058 kind: Schema.Literal("oauth2"),
5159 securitySchemeName: Schema.String,
@@ -60,6 +68,10 @@ export const OAuth2SourceConfig = Schema.Struct({ · 8 unmodified lines
6068 clientSecretSlot: Schema.NullOr(Schema.String),
6169 connectionSlot: Schema.String,
6270 scopes: Schema.Array(Schema.String),
71+ identityScopes: OAuth2IdentityScopes.pipe(
72+ Schema.optional,
73+ Schema.withDecodingDefault(Effect.succeed("auto" as const)),
74+ ),
6375 }).annotate({ identifier: "OAuth2SourceConfig" });
6476 export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type;
6577 export type OAuth2SourceConfigType = OAuth2SourceConfig;
packages/core/sdk/src/oauth-service.ts+76−6
@@ -61,6 +61,7 @@ import { · 60 unmodified lines
6161 OAuthSessionNotFoundError,
6262 OAuthStartError,
6363 type OAuthAuthorizationCodeStrategy,
64+ type OAuthAuthorizationCodeExistingClientStrategy,
6465 type OAuthClientCredentialsStrategy,
6566 type OAuthCompleteInput,
6667 type OAuthCompleteResult,
@@ -141,6 +142,8 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ · 74 unmodified lines
141142 Schema.withDecodingDefaultType(Effect.succeed(null)),
142143 ),
143144 scopes: Schema.Array(Schema.String),
145+ authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
146+ storedScope: Schema.optional(Schema.String),
144147 scopeSeparator: Schema.optional(Schema.String),
145148 clientAuth: Schema.Literals(["body", "basic"]),
146149 });
@@ -587,6 +590,10 @@ export const makeOAuth2Service = ( · 440 unmodified lines
587590 const startAuthorizationCode = (
588591 input: OAuthStartInput,
589592 strategy: OAuthAuthorizationCodeStrategy,
593+ options?: {
594+ readonly authorizationScopes?: readonly string[];
595+ readonly storedScope?: string;
596+ },
590597 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
591598 Effect.gen(function* () {
592599 const clientIdRef = yield* secretsGetResolvedAtScope({
@@ -609,12 +616,19 @@ export const makeOAuth2Service = ( · 16 unmodified lines
609616 const sessionId = scopedSessionId(input.tokenScope, newSessionId());
610617 const codeVerifier = createPkceCodeVerifier();
611618 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
619+ const authorizationScopes =
620+ options?.authorizationScopes ?? strategy.authorizationScopes ?? strategy.scopes;
621+ const storedScope =
622+ options?.storedScope ??
623+ (strategy.authorizationScopes
624+ ? strategy.scopes.join(strategy.scopeSeparator ?? " ")
625+ : undefined);
612626
613627 const authorizationUrl = buildAuthorizationUrl({
614628 authorizationUrl: strategy.authorizationEndpoint,
615629 clientId: clientIdRef.value,
616630 redirectUrl: input.redirectUrl,
617− scopes: strategy.scopes,
631+ scopes: authorizationScopes,
618632 state: sessionId,
619633 codeChallenge,
620634 scopeSeparator: strategy.scopeSeparator,
@@ -639,6 +653,8 @@ export const makeOAuth2Service = ( · 18 unmodified lines
639653 }))?.scopeId ?? null)
640654 : null,
641655 scopes: [...strategy.scopes],
656+ authorizationScopes: [...authorizationScopes],
657+ storedScope,
642658 scopeSeparator: strategy.scopeSeparator,
643659 clientAuth: strategy.clientAuth ?? "body",
644660 };
@@ -657,6 +673,53 @@ export const makeOAuth2Service = ( · 12 unmodified lines
657673 };
658674 });
659675
676+ const startAuthorizationCodeWithExistingClient = (
677+ input: OAuthStartInput,
678+ strategy: OAuthAuthorizationCodeExistingClientStrategy,
679+ ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
680+ Effect.gen(function* () {
681+ const existing = yield* connectionsGet(input.connectionId);
682+ if (!existing || existing.scopeId !== input.tokenScope) {
683+ return yield* new OAuthStartError({
684+ message: "Existing OAuth connection was not found at the selected scope",
685+ });
686+ }
687+ const state = existing.providerState
688+ ? Option.getOrNull(decodeProviderStateOption(coerceJson(existing.providerState)))
689+ : null;
690+ if (!state || state.kind !== "authorization-code") {
691+ return yield* new OAuthStartError({
692+ message: "Existing OAuth connection cannot be reused for authorization-code sign-in",
693+ });
694+ }
695+
696+ const scopeSeparator = strategy.scopeSeparator ?? state.scopeSeparator;
697+
698+ return yield* startAuthorizationCode(
699+ input,
700+ {
701+ kind: "authorization-code",
702+ authorizationEndpoint: strategy.authorizationEndpoint,
703+ tokenEndpoint: strategy.tokenEndpoint ?? state.tokenEndpoint,
704+ issuerUrl: strategy.issuerUrl ?? state.issuerUrl,
705+ clientIdSecretId: state.clientIdSecretId,
706+ clientIdSecretScopeId: state.clientIdSecretScopeId,
707+ clientSecretSecretId: state.clientSecretSecretId,
708+ clientSecretSecretScopeId: state.clientSecretSecretScopeId,
709+ scopes: [...strategy.scopes],
710+ scopeSeparator,
711+ extraAuthorizationParams: strategy.extraAuthorizationParams,
712+ clientAuth: state.clientAuth,
713+ },
714+ strategy.authorizationScopes
715+ ? {
716+ authorizationScopes: strategy.authorizationScopes,
717+ storedScope: strategy.scopes.join(scopeSeparator ?? " "),
718+ }
719+ : undefined,
720+ );
721+ });
722+
660723 const startClientCredentials = (
661724 input: OAuthStartInput,
662725 strategy: OAuthClientCredentialsStrategy,
@@ -765,6 +828,9 @@ export const makeOAuth2Service = ( · 102 unmodified lines
765828 Match.when({ kind: "authorization-code" }, (strategy) =>
766829 startAuthorizationCode(input, strategy),
767830 ),
831+ Match.when({ kind: "authorization-code-existing-client" }, (strategy) =>
832+ startAuthorizationCodeWithExistingClient(input, strategy),
833+ ),
768834 Match.when({ kind: "client-credentials" }, (strategy) =>
769835 startClientCredentials(input, strategy),
770836 ),
@@ -875,6 +941,10 @@ export const makeOAuth2Service = ( · 104 unmodified lines
875941 typeof exchangeResult.tokens.expires_in === "number"
876942 ? now() + exchangeResult.tokens.expires_in * 1000
877943 : null;
944+ const effectiveOAuthScope =
945+ payload.kind === "authorization-code" && payload.storedScope
946+ ? payload.storedScope
947+ : (exchangeResult.tokens.scope ?? null);
878948
879949 const dynamicClientSecretSecretId = yield* (() => {
880950 if (payload.kind !== "dynamic-dcr") return Effect.succeed(null);
@@ -938,7 +1008,7 @@ export const makeOAuth2Service = ( · 57 unmodified lines
9381008 : "body",
9391009 clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null,
9401010 scopes: [...payload.scopes],
941− scope: exchangeResult.tokens.scope ?? null,
1011+ scope: effectiveOAuthScope,
9421012 resource: payload.resource,
9431013 }
9441014 : {
@@ -950,9 +1020,9 @@ export const makeOAuth2Service = ( · 5 unmodified lines
9501020 clientSecretSecretId: payload.clientSecretSecretId,
9511021 clientSecretSecretScopeId: payload.clientSecretSecretScopeId,
9521022 clientAuth: payload.clientAuth,
953− scopes: [...payload.scopes],
1023+ scopes: [...(payload.authorizationScopes ?? payload.scopes)],
9541024 scopeSeparator: payload.scopeSeparator,
955− scope: exchangeResult.tokens.scope ?? null,
1025+ scope: effectiveOAuthScope,
9561026 };
9571027
9581028 yield* deps
@@ -977,7 +1047,7 @@ export const makeOAuth2Service = ( · 18 unmodified lines
9771047 })
9781048 : null,
9791049 expiresAt: connectionExpiresAt,
980− oauthScope: exchangeResult.tokens.scope ?? null,
1050+ oauthScope: effectiveOAuthScope,
9811051 providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,
9821052 }),
9831053 )
@@ -1009,7 +1079,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines
10091079 return {
10101080 connectionId,
10111081 expiresAt: connectionExpiresAt,
1012− scope: exchangeResult.tokens.scope ?? null,
1082+ scope: effectiveOAuthScope,
10131083 };
10141084 });
10151085
packages/core/sdk/src/oauth.ts+24−0
@@ -64,7 +64,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 63 unmodified lines
6464 * PKCE without a confidential secret. */
6565 clientSecretSecretId: Schema.NullOr(Schema.String),
6666 clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)),
67+ /** Final scope set Executor should remember for this connection. */
6768 scopes: Schema.Array(Schema.String),
69+ /** Optional smaller scope set to send to the authorization server. This is
70+ * useful when one provider scope covers many source-level operation scopes. */
71+ authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
6872 /** Separator between scopes. RFC 6749 says space; some providers
6973 * (GitHub classic) use comma. */
7074 scopeSeparator: Schema.optional(Schema.String),
@@ -77,6 +81,25 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 6 unmodified lines
7781 });
7882 export type OAuthAuthorizationCodeStrategy = typeof OAuthAuthorizationCodeStrategy.Type;
7983
84+/** Authorization-code flow that reuses the client credentials recorded on
85+ * an existing OAuth connection. Used for incremental authorization where
86+ * the user is granting more scopes to the same account/provider. */
87+export const OAuthAuthorizationCodeExistingClientStrategy = Schema.Struct({
88+ kind: Schema.Literal("authorization-code-existing-client"),
89+ authorizationEndpoint: Schema.String,
90+ tokenEndpoint: Schema.optional(Schema.String),
91+ issuerUrl: Schema.optional(Schema.NullOr(Schema.String)),
92+ /** Final scope set Executor should remember for this connection. */
93+ scopes: Schema.Array(Schema.String),
94+ /** Optional smaller scope set to send to the authorization server. This is
95+ * useful when one provider scope covers many source-level operation scopes. */
96+ authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
97+ scopeSeparator: Schema.optional(Schema.String),
98+ extraAuthorizationParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
99+});
100+export type OAuthAuthorizationCodeExistingClientStrategy =
101+ typeof OAuthAuthorizationCodeExistingClientStrategy.Type;
102+
80103 /** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used
81104 * for server-to-server integrations where the plugin has both
82105 * `client_id` and `client_secret` and the server will mint tokens
@@ -100,6 +123,7 @@ export type OAuthClientCredentialsStrategy = typeof OAuthClientCredentialsStrate · 17 unmodified lines
100123 export const OAuthStrategy = Schema.Union([
101124 OAuthDynamicDcrStrategy,
102125 OAuthAuthorizationCodeStrategy,
126+ OAuthAuthorizationCodeExistingClientStrategy,
103127 OAuthClientCredentialsStrategy,
104128 ]);
105129 export type OAuthStrategy = typeof OAuthStrategy.Type;
packages/core/sdk/src/testing.test.ts+90−1
@@ -61,12 +61,14 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 60 unmodified lines
6161 tokenEndpoint: oauth.tokenEndpoint,
6262 clientIdSecretId: "oauth-client-id",
6363 clientSecretSecretId: "oauth-client-secret",
64− scopes: ["read"],
64+ scopes: ["read", "write"],
65+ authorizationScopes: ["read"],
6566 },
6667 });
6768
6869 expect(started.authorizationUrl).not.toBeNull();
6970 const authorizationUrl = started.authorizationUrl ?? "";
71+ expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("read");
7072 const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl });
7173 const completed = yield* workspace.executor.oauth.complete({
7274 state: callback.state,
@@ -75,6 +77,93 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 2 unmodified lines
7577 });
7678
7779 expect(completed.connectionId).toBe("test-oauth-authorization-code");
80+ expect(completed.scope).toBe("read write");
81+ const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);
82+ expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);
83+ }),
84+ );
85+
86+ it.effect("authorization-code OAuth can reuse an existing connection client", () =>
87+ Effect.gen(function* () {
88+ const workspace = yield* TestWorkspace.current<typeof plugins>();
89+ const oauth = yield* OAuthTestServer;
90+ const scope = workspace.scopes[0]!;
91+
92+ yield* workspace.executor.secrets.set(
93+ SetSecretInput.make({
94+ id: SecretId.make("oauth-client-id"),
95+ scope: scope.id,
96+ name: "OAuth Client ID",
97+ value: "test-client",
98+ }),
99+ );
100+ yield* workspace.executor.secrets.set(
101+ SetSecretInput.make({
102+ id: SecretId.make("oauth-client-secret"),
103+ scope: scope.id,
104+ name: "OAuth Client Secret",
105+ value: "test-secret",
106+ }),
107+ );
108+
109+ const started = yield* workspace.executor.oauth.start({
110+ endpoint: oauth.resourceUrl,
111+ connectionId: "test-oauth-existing-client",
112+ tokenScope: String(scope.id),
113+ redirectUrl: "http://127.0.0.1/callback",
114+ pluginId: "test",
115+ identityLabel: "OAuth Test",
116+ strategy: {
117+ kind: "authorization-code",
118+ authorizationEndpoint: oauth.authorizationEndpoint,
119+ tokenEndpoint: oauth.tokenEndpoint,
120+ clientIdSecretId: "oauth-client-id",
121+ clientSecretSecretId: "oauth-client-secret",
122+ scopes: ["gmail.read"],
123+ },
124+ });
125+ const callback = yield* oauth.completeAuthorizationCodeFlow({
126+ authorizationUrl: started.authorizationUrl ?? "",
127+ });
128+ yield* workspace.executor.oauth.complete({
129+ state: callback.state,
130+ code: callback.code,
131+ tokenScope: String(scope.id),
132+ });
133+
134+ const incremental = yield* workspace.executor.oauth.start({
135+ endpoint: oauth.resourceUrl,
136+ connectionId: "test-oauth-existing-client",
137+ tokenScope: String(scope.id),
138+ redirectUrl: "http://127.0.0.1/callback",
139+ pluginId: "test",
140+ identityLabel: "OAuth Test",
141+ strategy: {
142+ kind: "authorization-code-existing-client",
143+ authorizationEndpoint: oauth.authorizationEndpoint,
144+ tokenEndpoint: oauth.tokenEndpoint,
145+ scopes: ["gmail.read", "calendar.read"],
146+ authorizationScopes: ["calendar.read"],
147+ extraAuthorizationParams: { include_granted_scopes: "true" },
148+ },
149+ });
150+
151+ const authorizationUrl = new URL(incremental.authorizationUrl ?? "");
152+ expect(authorizationUrl.searchParams.get("client_id")).toBe("test-client");
153+ expect(authorizationUrl.searchParams.get("scope")).toBe("calendar.read");
154+ expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBe("true");
155+
156+ const incrementalCallback = yield* oauth.completeAuthorizationCodeFlow({
157+ authorizationUrl: incremental.authorizationUrl ?? "",
158+ });
159+ const completed = yield* workspace.executor.oauth.complete({
160+ state: incrementalCallback.state,
161+ code: incrementalCallback.code,
162+ tokenScope: String(scope.id),
163+ });
164+
165+ expect(completed.connectionId).toBe("test-oauth-existing-client");
166+ expect(completed.scope).toBe("gmail.read calendar.read");
78167 const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);
79168 expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);
80169 }),
packages/core/sdk/src/testing/oauth-test-server.ts+2−0
@@ -40,6 +40,7 @@ export interface OAuthTestServerRequest { · 39 unmodified lines
4040 readonly path: string;
4141 readonly headers: Readonly<Record<string, string>>;
4242 readonly body: string;
43+ readonly query: Readonly<Record<string, string>>;
4344 }
4445
4546 export interface OAuthTestServerOptions {
@@ -435,6 +436,7 @@ export const serveOAuthTestServer = ( · 389 unmodified lines
435436 path: requestUrl.pathname,
436437 headers,
437438 body,
439+ query: Object.fromEntries(requestUrl.searchParams.entries()),
438440 },
439441 ]);
440442
packages/plugins/openapi/package.json+1−0
@@ -65,6 +65,7 @@ · 64 unmodified lines
6565 "@executor-js/config": "workspace:*",
6666 "@executor-js/sdk": "workspace:*",
6767 "effect": "catalog:",
68+ "lucide-react": "^1.7.0",
6869 "openapi-types": "^12.1.3",
6970 "yaml": "^2.7.1"
7071 },
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+627−173
@@ -1,10 +1,12 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
2+import { useAtomSet, useAtomValue } from "@effect/atom-react";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
34 import * as Effect from "effect/Effect";
45 import * as Exit from "effect/Exit";
56 import * as Match from "effect/Match";
67 import * as Option from "effect/Option";
78 import * as Schema from "effect/Schema";
9+import { ChevronDownIcon } from "lucide-react";
810
911 import {
1012 ConnectionId,
@@ -12,7 +14,12 @@ import { · 1 unmodified line
1214 SecretId,
1315 SetSourceCredentialBindingInput,
1416 } from "@executor-js/sdk/shared";
15−import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms";
17+import {
18+ connectionIdentityAtom,
19+ connectionsAtom,
20+ setSourceCredentialBinding,
21+ startOAuth,
22+} from "@executor-js/react/api/atoms";
1623 import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
1724 import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
1825
@@ -65,6 +72,7 @@ import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio- · 46 unmodified lines
6572 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
6673 import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms";
6774 import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
75+import { openApiPresets } from "../sdk/presets";
6876 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
6977 import {
7078 headerBindingSlot,
@@ -131,6 +139,123 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { · 60 unmodified lines
131139 }
132140 }
133141
142+const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
143+
144+const identityScopesForPreset = (
145+ identityScopes: OAuth2Preset["identityScopes"],
146+): readonly string[] => {
147+ if (identityScopes === false) return [];
148+ return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
149+};
150+
151+const resolvedOAuthScopes = (
152+ apiScopes: Iterable<string>,
153+ identityScopes: OAuth2Preset["identityScopes"],
154+): string[] => {
155+ const merged = new Set(apiScopes);
156+ for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
157+ return [...merged];
158+};
159+
160+const splitOAuthScopes = (value: string | null): Set<string> =>
161+ new Set(value?.split(/\s+/).filter(Boolean) ?? []);
162+
163+const mergeOAuthScopes = (...values: readonly Iterable<string>[]): string[] => {
164+ const merged = new Set<string>();
165+ for (const scopes of values) {
166+ for (const scope of scopes) {
167+ if (scope.trim()) merged.add(scope);
168+ }
169+ }
170+ return [...merged];
171+};
172+
173+const missingOAuthScopes = (
174+ connection: { readonly oauthScope: string | null },
175+ requiredApiScopes: Iterable<string>,
176+): readonly string[] => {
177+ const granted = splitOAuthScopes(connection.oauthScope);
178+ return [...requiredApiScopes].filter((scope) => !granted.has(scope));
179+};
180+
181+const isGoogleOAuthScope = (scope: string): boolean =>
182+ scope === "https://mail.google.com/" ||
183+ scope.startsWith("https://www.googleapis.com/auth/") ||
184+ scope.startsWith("https://www.google.com/m8/feeds/");
185+
186+const hasGoogleOAuthScope = (connection: { readonly oauthScope: string | null }): boolean =>
187+ [...splitOAuthScopes(connection.oauthScope)].some(isGoogleOAuthScope);
188+
189+const isGoogleOAuthUrl = (url: string): boolean => {
190+ if (!URL.canParse(url)) return false;
191+ const host = new URL(url).hostname.toLowerCase();
192+ return host === "accounts.google.com" || host === "oauth2.googleapis.com";
193+};
194+
195+const isGoogleOAuthTarget = (preset: OAuth2Preset, baseUrl: string, specUrl: string): boolean => {
196+ if (isGoogleDiscoveryUrl(specUrl)) return true;
197+ if (Object.keys(preset.scopes).some(isGoogleOAuthScope)) return true;
198+ if (isGoogleOAuthUrl(resolveOAuthUrl(preset.tokenUrl, baseUrl))) return true;
199+ const authorizationUrl = Option.getOrElse(preset.authorizationUrl, () => "");
200+ return authorizationUrl ? isGoogleOAuthUrl(resolveOAuthUrl(authorizationUrl, baseUrl)) : false;
201+};
202+
203+const googleAuthorizationParams = (enabled: boolean): Record<string, string> | undefined =>
204+ enabled
205+ ? {
206+ access_type: "offline",
207+ }
208+ : undefined;
209+
210+const googleBroadScopeGroups: readonly {
211+ readonly broad: string;
212+ readonly prefixes: readonly string[];
213+}[] = [
214+ {
215+ broad: "https://mail.google.com/",
216+ prefixes: ["https://www.googleapis.com/auth/gmail."],
217+ },
218+ {
219+ broad: "https://www.googleapis.com/auth/calendar",
220+ prefixes: ["https://www.googleapis.com/auth/calendar."],
221+ },
222+ {
223+ broad: "https://www.googleapis.com/auth/drive",
224+ prefixes: ["https://www.googleapis.com/auth/drive."],
225+ },
226+];
227+
228+const compactGoogleOAuthScopes = (scopes: Iterable<string>): string[] => {
229+ const ordered = mergeOAuthScopes(
230+ [...scopes].map((scope) =>
231+ scope === "https://www.googleapis.com/auth/userinfo.email"
232+ ? "email"
233+ : scope === "https://www.googleapis.com/auth/userinfo.profile"
234+ ? "profile"
235+ : scope,
236+ ),
237+ );
238+ const present = new Set(ordered);
239+ return ordered.filter(
240+ (scope) =>
241+ !googleBroadScopeGroups.some(
242+ (group) =>
243+ scope !== group.broad &&
244+ present.has(group.broad) &&
245+ group.prefixes.some((prefix) => scope.startsWith(prefix)),
246+ ),
247+ );
248+};
249+
250+type OAuthConnectionChoice = {
251+ readonly id: ConnectionId;
252+ readonly scopeId: ScopeId;
253+ readonly provider: string;
254+ readonly identityLabel: string | null;
255+ readonly oauthScope: string | null;
256+ readonly missingApiScopes: readonly string[];
257+};
258+
134259 const specInputForAdd = (input: string) => {
135260 const value = input.trim();
136261 const parsed = Effect.runSyncExit(
@@ -155,6 +280,15 @@ const isGoogleDiscoveryUrl = (url: string): boolean => { · 18 unmodified lines
155280 return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");
156281 };
157282
283+const normalizePresetUrl = (url: string): string => {
284+ const trimmed = url.trim();
285+ if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
286+ const parsed = new URL(trimmed);
287+ parsed.hash = "";
288+ parsed.searchParams.sort();
289+ return parsed.toString().replace(/\/$/, "");
290+};
291+
158292 type StrategySelection =
159293 | { readonly kind: "none" }
160294 | { readonly kind: "custom" }
@@ -214,6 +348,119 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 53 unmodified lines
214348 });
215349 }
216350
351+function OAuthConnectedAccount(props: {
352+ readonly scopeId: ScopeId;
353+ readonly connectionId: string;
354+ readonly scopeSummary: string;
355+ readonly sourceName: string;
356+ readonly onSetSourceName: (name: string) => void;
357+}) {
358+ const identityResult = useAtomValue(
359+ connectionIdentityAtom(props.scopeId, ConnectionId.make(props.connectionId)),
360+ );
361+ const identityResponse = AsyncResult.isSuccess(identityResult) ? identityResult.value : null;
362+ const identity = identityResponse?.status === "available" ? identityResponse : null;
363+ const accountLabel = identity?.email ?? identity?.name ?? identity?.username ?? null;
364+ const sourceNameWithAccount =
365+ accountLabel && !props.sourceName.includes(accountLabel)
366+ ? `${props.sourceName} - ${accountLabel}`
367+ : props.sourceName;
368+ const accountIsInSourceName = accountLabel !== null && props.sourceName === sourceNameWithAccount;
369+ return (
370+ <div className="flex min-w-0 flex-1 items-center gap-3">
371+ <div className="min-w-0 flex-1">
372+ <div className="flex min-w-0 items-center gap-2">
373+ {identity?.picture ? (
374+ <img
375+ src={identity.picture}
376+ alt=""
377+ referrerPolicy="no-referrer"
378+ className="size-5 shrink-0 rounded-full"
379+ />
380+ ) : null}
381+ <span className="min-w-0 truncate text-foreground">
382+ {accountLabel ? `Connected as ${accountLabel}` : "Connected"}
383+ <span className="text-muted-foreground"> · {props.scopeSummary}</span>
384+ </span>
385+ </div>
386+ {accountLabel ? (
387+ <div className="mt-0.5 truncate text-[11px] text-muted-foreground">
388+ Source name: {sourceNameWithAccount}
389+ </div>
390+ ) : null}
391+ </div>
392+ {accountLabel ? (
393+ <Button
394+ variant="secondary"
395+ size="sm"
396+ className="h-6 shrink-0 px-2 text-[11px]"
397+ disabled={accountIsInSourceName}
398+ onClick={() => props.onSetSourceName(sourceNameWithAccount)}
399+ >
400+ {accountIsInSourceName ? "Name includes account" : "Add account name to source name"}
401+ </Button>
402+ ) : null}
403+ </div>
404+ );
405+}
406+
407+function ExistingOAuthConnectionOption(props: {
408+ readonly connection: OAuthConnectionChoice;
409+ readonly selected: boolean;
410+ readonly onSelect: () => void;
411+ readonly providerLabel: string;
412+}) {
413+ const identityResult = useAtomValue(
414+ connectionIdentityAtom(props.connection.scopeId, props.connection.id),
415+ );
416+ const identity =
417+ AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available"
418+ ? identityResult.value
419+ : null;
420+ const label =
421+ identity?.email ?? identity?.name ?? props.connection.identityLabel ?? props.connection.id;
422+ const picture = identity?.picture;
423+ const needsPermission = props.connection.missingApiScopes.length > 0;
424+ return (
425+ <Button
426+ type="button"
427+ variant="ghost"
428+ onClick={props.onSelect}
429+ className={`h-auto w-full justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors ${
430+ props.selected
431+ ? "border-primary/40 bg-primary/10"
432+ : "border-border/60 bg-background hover:border-border hover:bg-muted/30"
433+ }`}
434+ >
435+ <span className="flex min-w-0 items-center gap-2">
436+ {picture ? (
437+ <img
438+ src={picture}
439+ alt=""
440+ referrerPolicy="no-referrer"
441+ className="size-5 shrink-0 rounded-full"
442+ />
443+ ) : (
444+ <span className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] text-muted-foreground">
445+ {label.slice(0, 1).toUpperCase()}
446+ </span>
447+ )}
448+ <span className="min-w-0">
449+ <span className="block truncate text-[12px] text-foreground">{label}</span>
450+ <span className="block truncate text-[10px] text-muted-foreground">
451+ {needsPermission
452+ ? `Needs ${props.providerLabel} permission`
453+ : (props.connection.identityLabel ?? "Already connected")}
454+ </span>
455+ </span>
456+ </span>
457+ <span className="shrink-0 text-[11px] font-medium text-foreground">
458+ {props.selected ? "Selected" : needsPermission ? "Continue" : "Use account"}
459+ </span>
460+ </Button>
461+ );
462+}
463+
217464 const secretStorageDescription = (label: string): string =>
218465 label === "Personal"
219466 ? "Only you can use this secret."
@@ -259,6 +506,8 @@ export default function AddOpenApiSource(props: { · 39 unmodified lines
259506 const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null);
260507 const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null);
261508 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());
509+ const [includeOAuth2IdentityScopes, setIncludeOAuth2IdentityScopes] = useState(true);
510+ const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false);
262511 const [oauth2AuthState, setOauth2AuthState] = useState<{
263512 readonly fingerprint: string;
264513 readonly auth: { readonly connectionId: string };
@@ -318,6 +567,7 @@ export default function AddOpenApiSource(props: { · 53 unmodified lines
318567 const doSetBinding = useAtomSet(setSourceCredentialBinding, {
319568 mode: "promiseExit",
320569 });
570+ const connectionsResult = useAtomValue(connectionsAtom(scopeId));
321571 const secretList = useSecretPickerSecrets();
322572 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
323573 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -355,6 +605,9 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines
355605 const baseUrlOptions = Array.from(
356606 new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
357607 );
608+ const previewPresetIcon =
609+ openApiPresets.find((preset) => normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl))
610+ ?.icon ?? null;
358611
359612 const resolvedBaseUrl = baseUrl.trim();
360613 const sourceScope = ScopeId.make(scopeId);
@@ -460,11 +713,13 @@ export default function AddOpenApiSource(props: { · 99 unmodified lines
460713 const resolvedSourceId =
461714 slugifyNamespace(identity.namespace) ||
462715 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
716+ const resolvedDisplayName =
717+ identity.name.trim() ||
718+ (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId);
463719 const selectedOAuth2Preset: OAuth2Preset | null =
464720 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;
465721 const selectedOAuth2Fingerprint = selectedOAuth2Preset
466722 ? [
467− resolvedSourceId,
468723 resolvedBaseUrl,
469724 selectedOAuth2Preset.securitySchemeName,
470725 selectedOAuth2Preset.flow,
@@ -474,6 +729,46 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
474729 : "";
475730 const oauth2Auth =
476731 oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null;
732+ const selectedOAuth2AvailableIdentityScopes = selectedOAuth2Preset
733+ ? identityScopesForPreset(selectedOAuth2Preset.identityScopes)
734+ : [];
735+ const selectedOAuth2IsGoogle = selectedOAuth2Preset
736+ ? isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl)
737+ : false;
738+ const selectedOAuth2ProviderLabel = selectedOAuth2IsGoogle ? "Google" : "OAuth";
739+ const configuredOAuth2IdentityScopes =
740+ selectedOAuth2Preset && includeOAuth2IdentityScopes
741+ ? selectedOAuth2Preset.identityScopes
742+ : false;
743+ const selectedOAuth2Scopes = useMemo(
744+ () =>
745+ selectedOAuth2Preset
746+ ? resolvedOAuthScopes(oauth2SelectedScopes, configuredOAuth2IdentityScopes)
747+ : [...oauth2SelectedScopes],
748+ [configuredOAuth2IdentityScopes, oauth2SelectedScopes, selectedOAuth2Preset],
749+ );
750+ const existingOAuthConnections = useMemo(() => {
751+ if (
752+ !selectedOAuth2Preset ||
753+ selectedOAuth2Preset.flow !== "authorizationCode" ||
754+ !AsyncResult.isSuccess(connectionsResult)
755+ ) {
756+ return [];
757+ }
758+ return connectionsResult.value
759+ .flatMap((connection) => {
760+ if (connection.provider !== "oauth2") return [];
761+ const missingApiScopes = missingOAuthScopes(connection, oauth2SelectedScopes);
762+ if (missingApiScopes.length === 0) {
763+ return [{ ...connection, missingApiScopes }];
764+ }
765+ if (selectedOAuth2IsGoogle && hasGoogleOAuthScope(connection)) {
766+ return [{ ...connection, missingApiScopes }];
767+ }
768+ return [];
769+ })
770+ .sort((a, b) => a.missingApiScopes.length - b.missingApiScopes.length);
771+ }, [connectionsResult, oauth2SelectedScopes, selectedOAuth2IsGoogle, selectedOAuth2Preset]);
477772
478773 const configuredOAuth2 =
479774 strategy.kind === "oauth2" && selectedOAuth2Preset
@@ -496,6 +791,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
496791 clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName),
497792 connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName),
498793 scopes: [...oauth2SelectedScopes],
794+ identityScopes: configuredOAuth2IdentityScopes,
499795 })
500796 : null;
501797 const hasHeaders = Object.keys(configuredHeaders).length > 0;
@@ -554,6 +850,7 @@ export default function AddOpenApiSource(props: { · 52 unmodified lines
554850 setStrategy({ kind: "oauth2", presetIndex: 0 });
555851 setCustomHeaders([]);
556852 setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes)));
853+ setIncludeOAuth2IdentityScopes(result.oauth2Presets[0].identityScopes !== false);
557854 } else {
558855 // No header presets — default to "custom" so the headers editor is
559856 // visible immediately. Specs with no `security` block (e.g. Microsoft
@@ -593,6 +890,7 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines
593890 const preset = preview?.oauth2Presets[n.presetIndex];
594891 if (preset) {
595892 setOauth2SelectedScopes(new Set(Object.keys(preset.scopes)));
893+ setIncludeOAuth2IdentityScopes(preset.identityScopes !== false);
596894 }
597895 }),
598896 Match.exhaustive,
@@ -617,141 +915,183 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
617915 setOauth2AuthState(null);
618916 };
619917
620− const handleConnectOAuth2 = useCallback(async () => {
621− if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return;
622− oauth.cancel();
623− setOauth2Error(null);
624− const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
625−
626− const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
627− const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope;
628− const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope;
629−
630− if (selectedOAuth2Preset.flow === "clientCredentials") {
631− // RFC 6749 §4.4: no user-interactive consent step. The client_secret
632− // is mandatory; the backend exchanges tokens inline and returns a
633− // completed Connection we bind to the source's connection slot.
634− if (!oauth2ClientSecretSecretId) {
635− setOauth2Error("client_credentials requires a client secret");
636− return;
637− }
638− setStartingOAuth(true);
639− const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
640− const exit = await doStartOAuth({
641− params: { scopeId: oauthTokenTargetScope },
642− payload: {
643− endpoint: tokenUrl,
644− redirectUrl: tokenUrl,
645− connectionId,
646− tokenScope: oauthTokenTargetScope,
647− strategy: {
648− kind: "client-credentials",
649− tokenEndpoint: tokenUrl,
650− clientIdSecretId: oauth2ClientIdSecretId,
651− clientIdSecretScopeId: String(clientIdSecretScope),
652− clientSecretSecretId: oauth2ClientSecretSecretId,
653− clientSecretSecretScopeId: String(clientSecretSecretScope),
654− scopes: [...oauth2SelectedScopes],
655− },
656− pluginId: "openapi",
657− identityLabel: `${displayName} OAuth`,
658− },
659− });
660− setStartingOAuth(false);
661− if (Exit.isFailure(exit)) {
662− setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth"));
663− return;
664− }
665− const response = exit.value;
666− if (!response.completedConnection) {
667− setOauth2Error("client_credentials flow did not mint a connection");
668− return;
669− }
670− setOauth2AuthState({
671− fingerprint: selectedOAuth2Fingerprint,
672− auth: { connectionId: response.completedConnection.connectionId },
673− });
918+ const handleConnectOAuth2 = useCallback(
919+ async (existingConnection?: OAuthConnectionChoice) => {
920+ if (!selectedOAuth2Preset || !preview) return;
921+ oauth.cancel();
674922 setOauth2Error(null);
675− return;
676− }
677−
678− const authorizationUrl = resolveOAuthUrl(
679− Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
680− resolvedBaseUrl,
681− );
682− const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
683−
684− await oauth.openAuthorization({
685− tokenScope: oauthTokenTargetScope,
686− run: async () => {
923+ const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
924+ const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope;
925+ const connectionId =
926+ existingConnection?.id ??
927+ openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
928+ const scopesForAuthorization = existingConnection
929+ ? mergeOAuthScopes(
930+ splitOAuthScopes(existingConnection.oauthScope),
931+ selectedOAuth2IsGoogle ? oauth2SelectedScopes : selectedOAuth2Scopes,
932+ )
933+ : selectedOAuth2Scopes;
934+ const scopesForProviderAuthorization = selectedOAuth2IsGoogle
935+ ? compactGoogleOAuthScopes(scopesForAuthorization)
936+ : undefined;
937+ const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle);
938+
939+ const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
940+ const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope;
941+ const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope;
942+
943+ if (selectedOAuth2Preset.flow === "clientCredentials") {
944+ if (!oauth2ClientIdSecretId) return;
945+ // RFC 6749 §4.4: no user-interactive consent step. The client_secret
946+ // is mandatory; the backend exchanges tokens inline and returns a
947+ // completed Connection we bind to the source's connection slot.
948+ if (!oauth2ClientSecretSecretId) {
949+ setOauth2Error("client_credentials requires a client secret");
950+ return;
951+ }
952+ setStartingOAuth(true);
687953 const exit = await doStartOAuth({
688− params: { scopeId: oauthTokenTargetScope },
954+ params: { scopeId: tokenTargetScope },
689955 payload: {
690− endpoint: authorizationUrl,
691− connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
692− tokenScope: oauthTokenTargetScope,
693− redirectUrl: oauth2RedirectUrl,
956+ endpoint: tokenUrl,
957+ redirectUrl: tokenUrl,
958+ connectionId,
959+ tokenScope: tokenTargetScope,
694960 strategy: {
695− kind: "authorization-code",
696− authorizationEndpoint: authorizationUrl,
961+ kind: "client-credentials",
697962 tokenEndpoint: tokenUrl,
698− issuerUrl,
699963 clientIdSecretId: oauth2ClientIdSecretId,
700964 clientIdSecretScopeId: String(clientIdSecretScope),
701− clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
702− clientSecretSecretScopeId: oauth2ClientSecretSecretId
703− ? String(clientSecretSecretScope)
704− : null,
705− scopes: [...oauth2SelectedScopes],
965+ clientSecretSecretId: oauth2ClientSecretSecretId,
966+ clientSecretSecretScopeId: String(clientSecretSecretScope),
967+ scopes: scopesForAuthorization,
706968 },
707969 pluginId: "openapi",
708970 identityLabel: `${displayName} OAuth`,
709971 },
710972 });
973+ setStartingOAuth(false);
711974 if (Exit.isFailure(exit)) {
712− // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
713− throw new Error(errorMessageFromExit(exit, "Failed to start OAuth"));
975+ setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth"));
976+ return;
714977 }
715978 const response = exit.value;
716− if (response.authorizationUrl === null) {
717− // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
718− throw new Error("Unexpected response flow from server");
979+ if (!response.completedConnection) {
980+ setOauth2Error("client_credentials flow did not mint a connection");
981+ return;
719982 }
720− return {
721− sessionId: response.sessionId,
722− authorizationUrl: response.authorizationUrl,
723− };
724− },
725− onSuccess: (result) => {
983+ setOAuthTokenTargetScope(tokenTargetScope);
726984 setOauth2AuthState({
727985 fingerprint: selectedOAuth2Fingerprint,
728− auth: { connectionId: result.connectionId },
986+ auth: { connectionId: response.completedConnection.connectionId },
729987 });
730988 setOauth2Error(null);
731− },
732− onError: (message) => {
733− setStartingOAuth(false);
734− setOauth2Error(message);
735− },
736− });
737− }, [
738− selectedOAuth2Preset,
739− oauth2ClientIdSecretId,
740− oauth2ClientSecretSecretId,
741− oauth2SelectedScopes,
742− oauth2RedirectUrl,
743− resolvedBaseUrl,
744− preview,
745− doStartOAuth,
746− identity.name,
747− resolvedSourceId,
748− selectedOAuth2Fingerprint,
749− oauth,
750− oauthTokenTargetScope,
751− oauth2ClientIdScope,
752− oauth2ClientSecretScope,
753− sourceScope,
754− ]);
989+ return;
990+ }
991+ if (!existingConnection && !oauth2ClientIdSecretId) return;
992+
993+ const authorizationUrl = resolveOAuthUrl(
994+ Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
995+ resolvedBaseUrl,
996+ );
997+ const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
998+ const authorizationStrategy = existingConnection
999+ ? {
1000+ kind: "authorization-code-existing-client" as const,
1001+ authorizationEndpoint: authorizationUrl,
1002+ tokenEndpoint: tokenUrl,
1003+ issuerUrl,
1004+ scopes: scopesForAuthorization,
1005+ ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0
1006+ ? { authorizationScopes: scopesForProviderAuthorization }
1007+ : {}),
1008+ extraAuthorizationParams,
1009+ }
1010+ : oauth2ClientIdSecretId
1011+ ? {
1012+ kind: "authorization-code" as const,
1013+ authorizationEndpoint: authorizationUrl,
1014+ tokenEndpoint: tokenUrl,
1015+ issuerUrl,
1016+ clientIdSecretId: oauth2ClientIdSecretId,
1017+ clientIdSecretScopeId: String(clientIdSecretScope),
1018+ clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
1019+ clientSecretSecretScopeId: oauth2ClientSecretSecretId
1020+ ? String(clientSecretSecretScope)
1021+ : null,
1022+ scopes: scopesForAuthorization,
1023+ ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0
1024+ ? { authorizationScopes: scopesForProviderAuthorization }
1025+ : {}),
1026+ extraAuthorizationParams,
1027+ }
1028+ : null;
1029+ if (!authorizationStrategy) return;
1030+
1031+ await oauth.openAuthorization({
1032+ tokenScope: tokenTargetScope,
1033+ run: async () => {
1034+ const exit = await doStartOAuth({
1035+ params: { scopeId: tokenTargetScope },
1036+ payload: {
1037+ endpoint: authorizationUrl,
1038+ connectionId,
1039+ tokenScope: tokenTargetScope,
1040+ redirectUrl: oauth2RedirectUrl,
1041+ strategy: authorizationStrategy,
1042+ pluginId: "openapi",
1043+ identityLabel: existingConnection?.identityLabel ?? `${displayName} OAuth`,
1044+ },
1045+ });
1046+ if (Exit.isFailure(exit)) {
1047+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
1048+ throw new Error(errorMessageFromExit(exit, "Failed to start OAuth"));
1049+ }
1050+ const response = exit.value;
1051+ if (response.authorizationUrl === null) {
1052+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run()
1053+ throw new Error("Unexpected response flow from server");
1054+ }
1055+ return {
1056+ sessionId: response.sessionId,
1057+ authorizationUrl: response.authorizationUrl,
1058+ };
1059+ },
1060+ onSuccess: (result) => {
1061+ setOAuthTokenTargetScope(tokenTargetScope);
1062+ setOauth2AuthState({
1063+ fingerprint: selectedOAuth2Fingerprint,
1064+ auth: { connectionId: result.connectionId },
1065+ });
1066+ setOauth2Error(null);
1067+ },
1068+ onError: (message) => {
1069+ setStartingOAuth(false);
1070+ setOauth2Error(message);
1071+ },
1072+ });
1073+ },
1074+ [
1075+ selectedOAuth2Preset,
1076+ oauth2ClientIdSecretId,
1077+ oauth2ClientSecretSecretId,
1078+ oauth2SelectedScopes,
1079+ selectedOAuth2Scopes,
1080+ selectedOAuth2IsGoogle,
1081+ oauth2RedirectUrl,
1082+ resolvedBaseUrl,
1083+ preview,
1084+ doStartOAuth,
1085+ identity.name,
1086+ resolvedSourceId,
1087+ selectedOAuth2Fingerprint,
1088+ oauth,
1089+ oauthTokenTargetScope,
1090+ oauth2ClientIdScope,
1091+ oauth2ClientSecretScope,
1092+ sourceScope,
1093+ ],
1094+ );
7551095
7561096 const handleCancelOAuth2 = useCallback(() => {
7571097 oauth.cancel();
@@ -759,18 +1099,29 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
7591099 setOauth2Error(null);
7601100 }, [oauth]);
7611101
1102+ const handleReuseOAuthConnection = useCallback(
1103+ (connection: OAuthConnectionChoice) => {
1104+ oauth.cancel();
1105+ setStartingOAuth(false);
1106+ setOAuthTokenTargetScope(connection.scopeId);
1107+ setOauth2AuthState({
1108+ fingerprint: selectedOAuth2Fingerprint,
1109+ auth: { connectionId: connection.id },
1110+ });
1111+ setOauth2Error(null);
1112+ },
1113+ [oauth, selectedOAuth2Fingerprint],
1114+ );
1115+
7621116 const handleAdd = async () => {
7631117 setAdding(true);
7641118 setAddError(null);
7651119 const namespace = resolvedSourceId;
766− const displayName =
767− identity.name.trim() ||
768− (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
7691120 const exit = await doAdd({
7701121 params: { scopeId },
7711122 payload: {
7721123 spec: specInputForAdd(specUrl),
773− name: displayName,
1124+ name: resolvedDisplayName,
7741125 namespace,
7751126 baseUrl: resolvedBaseUrl,
7761127 ...(configuredSpecFetchCredentials
@@ -1021,6 +1372,7 @@ export default function AddOpenApiSource(props: { · 244 unmodified lines
10211372 setOauth2AuthState(null);
10221373 setOauth2Error(null);
10231374 }}
1375+ faviconIcon={previewPresetIcon}
10241376 faviconUrl={resolvedBaseUrl}
10251377 baseUrlMissingMessage="A base URL is required to make requests."
10261378 />
@@ -1157,15 +1509,22 @@ export default function AddOpenApiSource(props: { · 130 unmodified lines
11571509 <CopyButton value={oauth2RedirectUrl} />
11581510 </div>
11591511 </div>
1160− <div className="space-y-1.5">
1161− <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1162− <div className="grid gap-2 md:grid-cols-2">
1512+ <div className="grid gap-3 md:grid-cols-2">
1513+ <div className="space-y-2">
11631514 <div className="space-y-1.5">
1164− <div className="flex items-center gap-1.5">
1165− <FieldLabel className="text-[11px]">Secret</FieldLabel>
1166− <HelpTooltip label="Client ID secret">
1167− Select or create the OAuth client ID secret.
1168− </HelpTooltip>
1515+ <div className="space-y-0.5">
1516+ <div className="flex items-center gap-1.5">
1517+ <FieldLabel className="text-[11px]">Client ID</FieldLabel>
1518+ <HelpTooltip label="Client ID secret">
1519+ Select or create the OAuth client ID secret.
1520+ </HelpTooltip>
1521+ </div>
1522+ <div
1523+ aria-hidden
1524+ className="invisible text-[10px] leading-tight text-muted-foreground"
1525+ >
1526+ Required OAuth client identifier.
1527+ </div>
11691528 </div>
11701529 <CreatableSecretPicker
11711530 value={oauth2ClientIdSecretId}
@@ -1194,21 +1553,18 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines
11941553 help="Choose where this OAuth client ID credential lives."
11951554 />
11961555 </div>
1197− </div>
1198− <div className="space-y-1.5">
1199− <FieldLabel className="text-[11px]">
1200− Client secret{" "}
1201− <span className="text-muted-foreground">
1202− · optional for public clients with PKCE
1203− </span>
1204− </FieldLabel>
1205− <div className="grid gap-2 md:grid-cols-2">
1556+ <div className="space-y-2">
12061557 <div className="space-y-1.5">
1207− <div className="flex items-center gap-1.5">
1208− <FieldLabel className="text-[11px]">Secret</FieldLabel>
1209− <HelpTooltip label="Client secret">
1210− Select or create the OAuth client secret.
1211− </HelpTooltip>
1558+ <div className="space-y-0.5">
1559+ <div className="flex items-center gap-1.5">
1560+ <FieldLabel className="text-[11px]">Client Secret</FieldLabel>
1561+ <HelpTooltip label="Client secret">
1562+ Select or create the OAuth client secret.
1563+ </HelpTooltip>
1564+ </div>
1565+ <div className="text-[10px] leading-tight text-muted-foreground">
1566+ Optional for public clients with PKCE.
1567+ </div>
12121568 </div>
12131569 <CreatableSecretPicker
12141570 value={oauth2ClientSecretSecretId}
@@ -1238,39 +1594,101 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
12381594 />
12391595 </div>
12401596 </div>
1241− <div className="space-y-1.5">
1242− <FieldLabel className="text-[11px]">Scopes</FieldLabel>
1243− <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">
1244− {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (
1245− <div className="text-[11px] italic text-muted-foreground">
1246− No scopes declared by the spec.
1247− </div>
1248− ) : (
1249− Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => (
1250− <Label key={scope} className="flex items-start gap-2 cursor-pointer py-1">
1597+ <Collapsible
1598+ open={oauth2ScopesOpen}
1599+ onOpenChange={setOauth2ScopesOpen}
1600+ className="space-y-1.5"
1601+ >
1602+ <CollapsibleTrigger asChild>
1603+ <Button
1604+ variant="ghost"
1605+ size="sm"
1606+ className="h-7 w-full justify-between !px-0 text-[11px] hover:bg-transparent hover:text-foreground dark:hover:bg-transparent"
1607+ >
1608+ <span className="flex min-w-0 items-center gap-1.5">
1609+ <span className="font-medium text-foreground">Scopes</span>
1610+ <span className="text-muted-foreground">
1611+ {selectedOAuth2Scopes.length} selected
1612+ </span>
1613+ </span>
1614+ <span className="flex size-3 shrink-0 items-center justify-center">
1615+ <ChevronDownIcon
1616+ className={`size-3 text-muted-foreground transition-transform ${
1617+ oauth2ScopesOpen ? "" : "-rotate-90"
1618+ }`}
1619+ />
1620+ </span>
1621+ </Button>
1622+ </CollapsibleTrigger>
1623+ <CollapsibleContent>
1624+ <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">
1625+ {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (
1626+ <div className="text-[11px] italic text-muted-foreground">
1627+ No scopes declared by the spec.
1628+ </div>
1629+ ) : (
1630+ Object.entries(selectedOAuth2Preset.scopes).map(
1631+ ([scope, description]) => (
1632+ <Label
1633+ key={scope}
1634+ className="flex items-start gap-2 cursor-pointer py-1"
1635+ >
1636+ <Checkbox
1637+ checked={oauth2SelectedScopes.has(scope)}
1638+ onCheckedChange={() => toggleOAuth2Scope(scope)}
1639+ />
1640+ <div className="min-w-0 flex-1">
1641+ <div className="font-mono text-[11px] text-foreground">
1642+ {scope}
1643+ </div>
1644+ {description && (
1645+ <div className="text-[10px] text-muted-foreground">
1646+ {description}
1647+ </div>
1648+ )}
1649+ </div>
1650+ </Label>
1651+ ),
1652+ )
1653+ )}
1654+ {selectedOAuth2AvailableIdentityScopes.length > 0 && (
1655+ <Label className="mt-2 flex cursor-pointer items-start gap-2 border-t border-border/50 pt-2">
12511656 <Checkbox
1252− checked={oauth2SelectedScopes.has(scope)}
1253− onCheckedChange={() => toggleOAuth2Scope(scope)}
1657+ checked={includeOAuth2IdentityScopes}
1658+ onCheckedChange={(checked) => {
1659+ setIncludeOAuth2IdentityScopes(checked === true);
1660+ setOauth2AuthState(null);
1661+ }}
12541662 />
12551663 <div className="min-w-0 flex-1">
1256− <div className="font-mono text-[11px] text-foreground">{scope}</div>
1257− {description && (
1258− <div className="text-[10px] text-muted-foreground">
1259− {description}
1260− </div>
1261− )}
1664+ <div className="text-[11px] font-medium text-foreground">
1665+ Account identity
1666+ </div>
1667+ <div className="font-mono text-[10px] text-muted-foreground">
1668+ {selectedOAuth2AvailableIdentityScopes.join(" · ")}
1669+ </div>
1670+ <div className="text-[10px] text-muted-foreground">
1671+ Lets the connections page show the signed-in account.
1672+ </div>
12621673 </div>
12631674 </Label>
1264− ))
1265− )}
1266− </div>
1267− </div>
1675+ )}
1676+ </div>
1677+ </CollapsibleContent>
1678+ </Collapsible>
12681679
12691680 {oauth2Auth ? (
1270− <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">
1271− <div className="text-[11px] text-green-700 dark:text-green-400">
1272− Connected · {oauth2SelectedScopes.size} scope
1273− {oauth2SelectedScopes.size === 1 ? "" : "s"} granted
1681+ <div className="flex items-center justify-between rounded-md border border-border/60 bg-background/50 px-3 py-2">
1682+ <div className="min-w-0 text-[12px]">
1683+ <OAuthConnectedAccount
1684+ scopeId={oauthTokenTargetScope}
1685+ connectionId={oauth2Auth.connectionId}
1686+ scopeSummary={`${selectedOAuth2Scopes.length} scope${
1687+ selectedOAuth2Scopes.length === 1 ? "" : "s"
1688+ } granted`}
1689+ sourceName={resolvedDisplayName}
1690+ onSetSourceName={identity.setName}
1691+ />
12741692 </div>
12751693 <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>
12761694 Disconnect
@@ -1285,23 +1703,59 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
12851703 <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>
12861704 Cancel
12871705 </Button>
1288− <Button variant="secondary" size="sm" onClick={handleConnectOAuth2}>
1706+ <Button
1707+ variant="secondary"
1708+ size="sm"
1709+ onClick={() => void handleConnectOAuth2()}
1710+ >
12891711 Retry
12901712 </Button>
12911713 </div>
12921714 ) : (
12931715 <div className="flex flex-col gap-1.5">
1716+ {existingOAuthConnections.length > 0 && (
1717+ <div className="space-y-2">
1718+ <div className="space-y-0.5">
1719+ <FieldLabel className="text-[11px]">
1720+ Use {selectedOAuth2ProviderLabel} account
1721+ </FieldLabel>
1722+ <div className="text-[10px] leading-tight text-muted-foreground">
1723+ Continue with an account you already connected. If permissions are
1724+ missing, Google will confirm access for this source.
1725+ </div>
1726+ </div>
1727+ <div className="space-y-1.5">
1728+ {existingOAuthConnections.map((connection) => (
1729+ <ExistingOAuthConnectionOption
1730+ key={`${connection.scopeId}:${connection.id}`}
1731+ connection={connection}
1732+ selected={false}
1733+ providerLabel={selectedOAuth2ProviderLabel}
1734+ onSelect={() =>
1735+ connection.missingApiScopes.length > 0
1736+ ? void handleConnectOAuth2(connection)
1737+ : handleReuseOAuthConnection(connection)
1738+ }
1739+ />
1740+ ))}
1741+ </div>
1742+ </div>
1743+ )}
12941744 <div className="grid gap-2 md:grid-cols-2">
12951745 <div className="space-y-1.5">
12961746 <div className="flex items-center gap-1.5">
1297− <FieldLabel className="text-[11px]">OAuth sign-in</FieldLabel>
1747+ <FieldLabel className="text-[11px]">
1748+ {existingOAuthConnections.length > 0
1749+ ? "Sign in with another account"
1750+ : "OAuth sign-in"}
1751+ </FieldLabel>
12981752 <HelpTooltip label="OAuth sign-in">
12991753 Start the provider OAuth flow.
13001754 </HelpTooltip>
13011755 </div>
13021756 <Button
13031757 variant="secondary"
1304− onClick={handleConnectOAuth2}
1758+ onClick={() => void handleConnectOAuth2()}
13051759 disabled={!canConnectOAuth2}
13061760 className={
13071761 canConnectOAuth2
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+17−2
@@ -73,6 +73,19 @@ import { OAuth2SourceConfig } from "../sdk/types"; · 72 unmodified lines
7373 const ErrorMessage = Schema.Struct({ message: Schema.String });
7474 const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
7575
76+const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
77+
78+const resolvedOAuthScopes = (oauth2: OAuth2SourceConfig): string[] => {
79+ const merged = new Set(oauth2.scopes);
80+ if (oauth2.identityScopes === false) return [...merged];
81+ const extras =
82+ oauth2.identityScopes === undefined || oauth2.identityScopes === "auto"
83+ ? standardOidcIdentityScopes
84+ : oauth2.identityScopes;
85+ for (const scope of extras) merged.add(scope);
86+ return [...merged];
87+};
88+
7689 const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
7790 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
7891 onNone: () => fallback,
@@ -422,6 +435,7 @@ export default function EditOpenApiSource(props: { · 343 unmodified lines
422435 };
423436 const displayName = source.name;
424437 const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? "");
438+ const scopes = resolvedOAuthScopes(oauth2);
425439 if (oauth2.flow === "clientCredentials") {
426440 const startOAuthExit = await doStartOAuth({
427441 params: { scopeId: targetScope },
@@ -439,7 +453,7 @@ export default function EditOpenApiSource(props: { · 11 unmodified lines
439453 clientSecretSecretScopeId: clientSecretSecretScopeId
440454 ? String(clientSecretSecretScopeId)
441455 : null,
442− scopes: [...oauth2.scopes],
456+ scopes,
443457 },
444458 pluginId: "openapi",
445459 identityLabel: `${displayName} OAuth`,
@@ -502,7 +516,7 @@ export default function EditOpenApiSource(props: { · 56 unmodified lines
502516 clientSecretSecretScopeId: clientSecretSecretScopeId
503517 ? String(clientSecretSecretScopeId)
504518 : null,
505− scopes: [...oauth2.scopes],
519+ scopes,
506520 },
507521 pluginId: "openapi",
508522 identityLabel: `${displayName} OAuth`,
@@ -648,6 +662,7 @@ export default function EditOpenApiSource(props: { · 139 unmodified lines
648662 clientSecretSlot: oauth2.clientSecretSlot,
649663 connectionSlot: oauth2.connectionSlot,
650664 scopes: [...oauth2.scopes],
665+ identityScopes: oauth2.identityScopes,
651666 }),
652667 },
653668 },
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+4−1
@@ -27,6 +27,7 @@ export function OpenApiSourceDetailsFields(props: { · 26 unmodified lines
2727 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
2828 readonly specUrl?: string;
2929 readonly onSpecUrlChange?: (value: string) => void;
30+ readonly faviconIcon?: string | null;
3031 readonly faviconUrl?: string;
3132 readonly namespaceReadOnly?: boolean;
3233 readonly specUrlDisabled?: boolean;
@@ -40,7 +41,9 @@ export function OpenApiSourceDetailsFields(props: { · 7 unmodified lines
4041 <CardStack>
4142 <CardStackContent className="border-t-0">
4243 <CardStackEntry>
43− {props.faviconUrl && <SourceFavicon url={props.faviconUrl} size={16} />}
44+ {(props.faviconIcon || props.faviconUrl) && (
45+ <SourceFavicon icon={props.faviconIcon} url={props.faviconUrl} size={16} />
46+ )}
4447 <CardStackEntryContent>
4548 <CardStackEntryTitle>{props.title}</CardStackEntryTitle>
4649 {props.description && (
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+85−22
@@ -1,10 +1,11 @@
11 import { useAtomValue } from "@effect/atom-react";
22 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
33
4−import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";
4+import { connectionIdentityAtom, connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";
55 import { Badge } from "@executor-js/react/components/badge";
6+import { Button } from "@executor-js/react/components/button";
67 import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
7−import { ScopeId } from "@executor-js/sdk/shared";
8+import { ConnectionId, ScopeId } from "@executor-js/sdk/shared";
89 import {
910 SourceCredentialLoadingBadge,
1011 SourceCredentialNotice,
@@ -21,12 +22,65 @@ function OAuthBadge() { · 10 unmodified lines
2122 return <Badge variant="secondary">OAuth</Badge>;
2223 }
2324
25+function SourceAccountNotice(props: {
26+ readonly connection: {
27+ readonly id: string;
28+ readonly scopeId: string;
29+ readonly identityLabel: string | null;
30+ };
31+ readonly onAction?: () => void;
32+}) {
33+ const identityResult = useAtomValue(
34+ connectionIdentityAtom(
35+ ScopeId.make(props.connection.scopeId),
36+ ConnectionId.make(props.connection.id),
37+ ),
38+ );
39+ const identity =
40+ AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available"
41+ ? identityResult.value
42+ : null;
43+ const label = identity?.email ?? identity?.name ?? props.connection.identityLabel ?? "Connected";
44+ return (
45+ <div className="shrink-0 border-b border-border bg-muted/20 px-4 py-3">
46+ <div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
47+ <div className="flex min-w-0 items-center gap-2">
48+ {identity?.picture ? (
49+ <img
50+ src={identity.picture}
51+ alt=""
52+ referrerPolicy="no-referrer"
53+ className="size-6 shrink-0 rounded-full"
54+ />
55+ ) : (
56+ <div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium text-muted-foreground">
57+ {label.slice(0, 1).toUpperCase()}
58+ </div>
59+ )}
60+ <div className="min-w-0">
61+ <div className="truncate text-sm font-medium text-foreground">{label}</div>
62+ <div className="text-xs text-muted-foreground">OAuth account for this source</div>
63+ </div>
64+ </div>
65+ {props.onAction ? (
66+ <Button variant="ghost" size="sm" onClick={props.onAction} className="shrink-0">
67+ Configure
68+ </Button>
69+ ) : null}
70+ </div>
71+ </div>
72+ );
73+}
74+
2475 const effectiveClientSecretSlot = (oauth2: {
2576 readonly securitySchemeName: string;
2677 readonly clientSecretSlot: string | null;
2778 }): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName);
2879
29−const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => {
80+const sourceCredentialSlots = (
81+ source: StoredSourceSchemaType,
82+ options?: { readonly hasLiveOAuthConnection?: boolean },
83+): readonly SourceCredentialSlot[] => {
3084 const slots: SourceCredentialSlot[] = [];
3185 for (const [name, value] of Object.entries(source.config.headers ?? {})) {
3286 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });
@@ -36,12 +90,14 @@ const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceC · 3 unmodified lines
3690 }
3791 const oauth2 = source.config.oauth2;
3892 if (oauth2) {
39− slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" });
40− slots.push({
41− kind: "secret",
42− slot: effectiveClientSecretSlot(oauth2),
43− label: "Client Secret",
44− });
93+ if (!options?.hasLiveOAuthConnection) {
94+ slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" });
95+ slots.push({
96+ kind: "secret",
97+ slot: effectiveClientSecretSlot(oauth2),
98+ label: "Client Secret",
99+ });
100+ }
45101 slots.push({
46102 kind: "connection",
47103 slot: oauth2.connectionSlot,
@@ -93,8 +149,21 @@ export default function OpenApiSourceSummary(props: { · 45 unmodified lines
93149 const liveConnectionIds = new Set(connections.map((connection) => connection.id));
94150 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
95151 const credentialTargetScope = userScope;
152+ const connectionBinding = effectiveBindingForScope(
153+ bindings,
154+ oauth2?.connectionSlot ?? "",
155+ credentialTargetScope,
156+ scopeRanks,
157+ );
158+ const connectionId =
159+ connectionBinding && connectionBinding.value.kind === "connection"
160+ ? connectionBinding.value.connectionId
161+ : null;
162+ const connection = connectionId
163+ ? (connections.find((candidate) => candidate.id === connectionId) ?? null)
164+ : null;
96165 const missing = missingSourceCredentialLabels({
97− slots: sourceCredentialSlots(source),
166+ slots: sourceCredentialSlots(source, { hasLiveOAuthConnection: connection !== null }),
98167 bindings,
99168 targetScope: credentialTargetScope,
100169 scopeRanks,
@@ -102,24 +171,18 @@ export default function OpenApiSourceSummary(props: { · 1 unmodified line
102171 });
103172
104173 if (props.variant === "panel") {
105− return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
174+ if (missing.length > 0)
175+ return <SourceCredentialNotice missing={missing} onAction={props.onAction} />;
176+ return connection ? (
177+ <SourceAccountNotice connection={connection} onAction={props.onAction} />
178+ ) : null;
106179 }
107180
108181 if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />;
109182
110183 if (!oauth2) return null;
111− const connectionBinding = effectiveBindingForScope(
112− bindings,
113− oauth2.connectionSlot,
114− credentialTargetScope,
115− scopeRanks,
116− );
117− const connectionId =
118− connectionBinding && connectionBinding.value.kind === "connection"
119− ? connectionBinding.value.connectionId
120− : null;
121184
122− if (connectionId && connections.some((connection) => connection.id === connectionId)) {
185+ if (connection) {
123186 return <SourceCredentialStatusBadge missing={[]} />;
124187 }
125188
packages/plugins/openapi/src/sdk/credential-status.test.ts+34−0
@@ -30,6 +30,18 @@ const source: SourceForCredentialStatus = { · 29 unmodified lines
3030 },
3131 };
3232
33+const authorizationCodeSource: SourceForCredentialStatus = {
34+ config: {
35+ oauth2: {
36+ securitySchemeName: "oauth2",
37+ flow: "authorizationCode",
38+ clientIdSlot: "oauth2:oauth2:client-id",
39+ clientSecretSlot: "oauth2:oauth2:client-secret",
40+ connectionSlot: "oauth2:oauth2:connection",
41+ },
42+ },
43+};
44+
3345 const bindings = (
3446 scopeId: ScopeId,
3547 slots: readonly string[],
@@ -81,6 +93,28 @@ describe("OpenAPI credential status", () => { · 45 unmodified lines
8193 ).toEqual(["OAuth client connection"]);
8294 });
8395
96+ it("treats a live authorization-code connection as ready without source client credentials", () => {
97+ expect(
98+ missingCredentialLabels(
99+ authorizationCodeSource,
100+ bindings(userScope, ["oauth2:oauth2:connection"]),
101+ userScope,
102+ scopeRanks,
103+ {
104+ liveConnectionIds: [ConnectionId.make("user-connection")],
105+ },
106+ ),
107+ ).toEqual([]);
108+ });
109+
110+ it("requires client credentials before starting authorization-code OAuth when no connection is bound", () => {
111+ expect(
112+ missingCredentialLabels(authorizationCodeSource, [], userScope, scopeRanks, {
113+ liveConnectionIds: [],
114+ }),
115+ ).toEqual(["Client ID", "Client Secret", "OAuth sign-in"]);
116+ });
117+
84118 it("does not treat personal bindings as satisfying org-level credential status", () => {
85119 expect(
86120 missingCredentialLabels(source, bindings(userScope, allSlots), orgScope, scopeRanks),
packages/plugins/openapi/src/sdk/credential-status.ts+15−10
@@ -88,18 +88,23 @@ export function missingCredentialLabels( · 87 unmodified lines
8888 const oauth2 = source.config.oauth2;
8989 if (!oauth2) return missing;
9090
91− if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) {
92− missing.push("Client ID");
93− }
91+ const hasLiveConnection = hasConnectionBinding(
92+ bindings,
93+ oauth2.connectionSlot,
94+ targetScope,
95+ ranks,
96+ liveConnectionIds,
97+ );
98+ if (!hasLiveConnection) {
99+ if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) {
100+ missing.push("Client ID");
101+ }
94102
95− const clientSecretSlot = effectiveClientSecretSlot(oauth2);
96− if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) {
97− missing.push("Client Secret");
98− }
103+ const clientSecretSlot = effectiveClientSecretSlot(oauth2);
104+ if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) {
105+ missing.push("Client Secret");
106+ }
99107
100− if (
101− !hasConnectionBinding(bindings, oauth2.connectionSlot, targetScope, ranks, liveConnectionIds)
102− ) {
103108 missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in");
104109 }
105110
packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+94−0
@@ -46,6 +46,7 @@ const makeOauth2SourceConfig = (params: { · 45 unmodified lines
4646 readonly tokenUrl: string;
4747 readonly authorizationUrl: string | null;
4848 readonly scopes: readonly string[];
49+ readonly identityScopes?: OAuth2SourceConfig["identityScopes"];
4950 }): OAuth2SourceConfig =>
5051 OAuth2SourceConfig.make({
5152 kind: "oauth2",
@@ -57,8 +58,20 @@ const makeOauth2SourceConfig = (params: { · 5 unmodified lines
5758 clientSecretSlot: "oauth2:oauth2:client-secret",
5859 connectionSlot: "oauth2:oauth2:connection",
5960 scopes: [...params.scopes],
61+ ...(params.identityScopes !== undefined ? { identityScopes: params.identityScopes } : {}),
6062 });
6163
64+const resolvedOAuthScopes = (oauth2: OAuth2SourceConfig): string[] => {
65+ const merged = new Set(oauth2.scopes);
66+ if (oauth2.identityScopes === false) return [...merged];
67+ const extras =
68+ oauth2.identityScopes === undefined || oauth2.identityScopes === "auto"
69+ ? ["openid", "email", "profile"]
70+ : oauth2.identityScopes;
71+ for (const scope of extras) merged.add(scope);
72+ return [...merged];
73+};
74+
6275 // ---------------------------------------------------------------------------
6376 // Test API — a single endpoint that echoes the Authorization header so the
6477 // test can assert which user's token got injected.
@@ -360,6 +373,87 @@ describe("OpenAPI multi-scope OAuth", () => { · 295 unmodified lines
360373 }),
361374 );
362375
376+ it.effect("authorization-code OpenAPI sources request identity scopes by default", () =>
377+ Effect.gen(function* () {
378+ const secretStore = new Map<string, string>();
379+ const key = (scope: string, id: string) => `${scope} ${id}`;
380+ const memoryProvider: SecretProvider = {
381+ key: "memory",
382+ writable: true,
383+ get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null),
384+ set: (id, value, scope) =>
385+ Effect.sync(() => {
386+ secretStore.set(key(scope, id), value);
387+ }),
388+ delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))),
389+ };
390+ const memorySecretsPlugin = definePlugin(() => ({
391+ id: "memory-secrets" as const,
392+ storage: () => ({}),
393+ secretProviders: [memoryProvider],
394+ }));
395+ const userScope = Scope.make({
396+ id: ScopeId.make("user-alice"),
397+ name: "Alice",
398+ createdAt: new Date(),
399+ });
400+ const executor = yield* createExecutor(
401+ makeTestConfig({
402+ plugins: [openApiPlugin(), memorySecretsPlugin()] as const,
403+ scopes: [userScope],
404+ }),
405+ );
406+ yield* executor.secrets.set(
407+ SetSecretInput.make({
408+ id: SecretId.make("petstore_client_id"),
409+ scope: userScope.id,
410+ name: "Petstore Client ID",
411+ value: "client-abc",
412+ }),
413+ );
414+ yield* executor.secrets.set(
415+ SetSecretInput.make({
416+ id: SecretId.make("petstore_client_secret"),
417+ scope: userScope.id,
418+ name: "Petstore Client Secret",
419+ value: "secret-xyz",
420+ }),
421+ );
422+ const oauth = yield* serveOAuthTestServer({
423+ defaultClientId: "client-abc",
424+ defaultClientSecret: "secret-xyz",
425+ });
426+ const oauth2 = makeOauth2SourceConfig({
427+ flow: "authorizationCode",
428+ authorizationUrl: oauth.authorizationEndpoint,
429+ tokenUrl: oauth.tokenEndpoint,
430+ scopes: ["read"],
431+ });
432+
433+ const start = yield* executor.oauth.start({
434+ endpoint: oauth.authorizationEndpoint,
435+ redirectUrl: "https://app.example.com/oauth/callback",
436+ connectionId: "openapi-oauth2-user-petstore",
437+ tokenScope: String(userScope.id),
438+ pluginId: "openapi",
439+ identityLabel: "Petstore OAuth",
440+ strategy: {
441+ kind: "authorization-code",
442+ authorizationEndpoint: oauth.authorizationEndpoint,
443+ tokenEndpoint: oauth.tokenEndpoint,
444+ issuerUrl: oauth.issuerUrl,
445+ clientIdSecretId: "petstore_client_id",
446+ clientSecretSecretId: "petstore_client_secret",
447+ scopes: resolvedOAuthScopes(oauth2),
448+ },
449+ });
450+
451+ expect(start.authorizationUrl).not.toBeNull();
452+ const authorizationUrl = new URL(start.authorizationUrl ?? "");
453+ expect(authorizationUrl.searchParams.get("scope")).toBe("read openid email profile");
454+ }),
455+ );
456+
363457 // -------------------------------------------------------------------------
364458 // Regression: repeated `clientCredentials` sign-ins used to mint a fresh
365459 // random UUID per call AND rewrite `source.oauth2.connectionId` to that
packages/plugins/openapi/src/sdk/plugin.ts+6−0
@@ -303,6 +303,11 @@ const StaticPreviewOAuth2PresetSchema = Schema.Struct({ · 302 unmodified lines
303303 tokenUrl: Schema.String,
304304 refreshUrl: Schema.NullOr(Schema.String),
305305 scopes: Schema.Record(Schema.String, Schema.String),
306+ identityScopes: Schema.Union([
307+ Schema.Literal("auto"),
308+ Schema.Literal(false),
309+ Schema.Array(Schema.String),
310+ ]),
306311 });
307312 const StaticPreviewSpecOutputSchema = Schema.Struct({
308313 title: Schema.NullOr(Schema.String),
@@ -535,6 +540,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 226 unmodified lines
535540 tokenUrl: preset.tokenUrl,
536541 refreshUrl: Option.getOrNull(preset.refreshUrl),
537542 scopes: preset.scopes,
543+ identityScopes: preset.identityScopes,
538544 })),
539545 });
540546
packages/plugins/openapi/src/sdk/preview-oauth2.test.ts+2−0
@@ -96,6 +96,7 @@ describe("previewSpec OAuth2 extraction", () => { · 95 unmodified lines
9696 read: "Read access",
9797 write: "Write access",
9898 });
99+ expect(preset.identityScopes).toBe("auto");
99100 }),
100101 );
101102
@@ -126,6 +127,7 @@ describe("previewSpec OAuth2 extraction", () => { · 24 unmodified lines
126127 const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!;
127128 expect(Option.isNone(cc.authorizationUrl)).toBe(true);
128129 expect(cc.scopes).toEqual({ "admin:read": "Admin read" });
130+ expect(cc.identityScopes).toBe(false);
129131 }),
130132 );
131133
packages/plugins/openapi/src/sdk/preview.ts+8−0
@@ -106,6 +106,12 @@ export const OAuth2Preset = Schema.Struct({ · 105 unmodified lines
106106 refreshUrl: Schema.OptionFromOptional(Schema.String),
107107 /** Declared scopes for this flow: `{ scope: description }`. */
108108 scopes: Schema.Record(Schema.String, Schema.String),
109+ /** Identity scopes to request alongside API scopes. `"auto"` discovers standard OIDC scopes. */
110+ identityScopes: Schema.Union([
111+ Schema.Literal("auto"),
112+ Schema.Literal(false),
113+ Schema.Array(Schema.String),
114+ ]),
109115 });
110116 export type OAuth2Preset = typeof OAuth2Preset.Type;
111117
@@ -328,6 +334,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] · 216 unmodified lines
328334 tokenUrl: flow.tokenUrl,
329335 refreshUrl: flow.refreshUrl,
330336 scopes: flow.scopes,
337+ identityScopes: "auto",
331338 }),
332339 );
333340 }
@@ -343,6 +350,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] · 9 unmodified lines
343350 tokenUrl: flow.tokenUrl,
344351 refreshUrl: flow.refreshUrl,
345352 scopes: flow.scopes,
353+ identityScopes: false,
346354 }),
347355 );
348356 }
packages/react/src/api/atoms.tsx+9−1
@@ -7,9 +7,10 @@ import { · 6 unmodified lines
77 } from "@executor-js/sdk/shared";
88 import * as Atom from "effect/unstable/reactivity/Atom";
99 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
10+import * as Effect from "effect/Effect";
1011
1112 import { ExecutorApiClient } from "./client";
12−import { ReactivityKey } from "./reactivity-keys";
13+import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys";
1314
1415 // ---------------------------------------------------------------------------
1516 // Scope — fetched from the server
@@ -178,6 +179,13 @@ export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); · 162 unmodified lines
178179
179180 export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel");
180181
182+export const oauthConnectionCompleted = ExecutorApiClient.runtime.fn<{
183+ readonly tokenScope: string;
184+ readonly reactivityKeys: typeof connectionWriteKeys;
185+}>()(() => Effect.void, {
186+ reactivityKeys: connectionWriteKeys,
187+});
188+
181189 export const createPolicy = ExecutorApiClient.mutation("policies", "create");
182190
183191 export const updatePolicy = ExecutorApiClient.mutation("policies", "update");
packages/react/src/plugins/oauth-sign-in.tsx+24−3
@@ -4,7 +4,7 @@ import * as Data from "effect/Data"; · 3 unmodified lines
44 import * as Effect from "effect/Effect";
55 import * as Exit from "effect/Exit";
66
7−import { cancelOAuth, startOAuth } from "../api/atoms";
7+import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms";
88 import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";
99 import {
1010 openOAuthPopup,
@@ -12,6 +12,7 @@ import { · 1 unmodified line
1212 reserveOAuthPopup,
1313 type OAuthPopupResult,
1414 } from "../api/oauth-popup";
15+import { connectionWriteKeys } from "../api/reactivity-keys";
1516
1617 type DesktopBridge = {
1718 readonly openExternal: (url: string) => Promise<void>;
@@ -73,7 +74,7 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 55 unmodified lines
7374 readonly tokenScope: string;
7475 readonly run: () => Promise<OAuthAuthorizationStartResult>;
7576 readonly onSuccess: (payload: TPayload) => void | Promise<void>;
76− readonly onError?: (error: string) => void;
77+ readonly onError?: (error: string, details?: string) => void;
7778 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;
7879 readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;
7980 };
@@ -119,6 +120,7 @@ export function useOAuthPopupFlow< · 39 unmodified lines
119120 } = options;
120121 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
121122 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
123+ const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" });
122124 const reportHandledError = useReportHandledError();
123125 const [busy, setBusy] = useState(false);
124126 const [error, setError] = useState<string | null>(null);
@@ -222,7 +224,25 @@ export function useOAuthPopupFlow< · 97 unmodified lines
222224 if (!result.ok) {
223225 setBusy(false);
224226 setError(result.error);
225− input.onError?.(result.error);
227+ input.onError?.(result.error, result.errorDetails);
228+ return;
229+ }
230+
231+ const refreshExit = await doOAuthConnectionCompleted({
232+ tokenScope: input.tokenScope,
233+ reactivityKeys: connectionWriteKeys,
234+ });
235+ if (Exit.isFailure(refreshExit)) {
236+ const message = messageFromExit(refreshExit, "Failed to refresh connection");
237+ reportHandledError(refreshExit.cause, {
238+ surface: "oauth",
239+ action: "refresh_connection",
240+ message,
241+ metadata: input.reportMetadata,
242+ });
243+ setBusy(false);
244+ setError(message);
245+ input.onError?.(message);
226246 return;
227247 }
228248
@@ -292,6 +312,7 @@ export function useOAuthPopupFlow< · 63 unmodified lines
292312 cancel,
293313 cancelSession,
294314 detectPopupClosed,
315+ doOAuthConnectionCompleted,
295316 noAuthorizationUrlMessage,
296317 popupBlockedMessage,
297318 popupClosedMessage,