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
774 "@executor-js/config": "workspace:*",774 "@executor-js/config": "workspace:*",
775 "@executor-js/sdk": "workspace:*",775 "@executor-js/sdk": "workspace:*",
776 "effect": "catalog:",776 "effect": "catalog:",
777 "lucide-react": "^1.7.0",
777 "openapi-types": "^12.1.3",778 "openapi-types": "^12.1.3",
778 "yaml": "^2.7.1",779 "yaml": "^2.7.1",
779 },780 },
packages/core/sdk/src/http-source.ts+12−0
@@ -46,6 +46,14 @@ export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentia · 45 unmodified lines
46export type OAuth2Flow = typeof OAuth2Flow.Type;46export type OAuth2Flow = typeof OAuth2Flow.Type;
47export type OAuth2FlowType = OAuth2Flow;47export type OAuth2FlowType = OAuth2Flow;
48 48
49export const OAuth2IdentityScopes = Schema.Union([
50 Schema.Literal("auto"),
51 Schema.Literal(false),
52 Schema.Array(Schema.String),
53]);
54export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type;
55export type OAuth2IdentityScopesType = OAuth2IdentityScopes;
56
49export const OAuth2SourceConfig = Schema.Struct({57export const OAuth2SourceConfig = Schema.Struct({
50 kind: Schema.Literal("oauth2"),58 kind: Schema.Literal("oauth2"),
51 securitySchemeName: Schema.String,59 securitySchemeName: Schema.String,
@@ -60,6 +68,10 @@ export const OAuth2SourceConfig = Schema.Struct({ · 8 unmodified lines
60 clientSecretSlot: Schema.NullOr(Schema.String),68 clientSecretSlot: Schema.NullOr(Schema.String),
61 connectionSlot: Schema.String,69 connectionSlot: Schema.String,
62 scopes: Schema.Array(Schema.String),70 scopes: Schema.Array(Schema.String),
71 identityScopes: OAuth2IdentityScopes.pipe(
72 Schema.optional,
73 Schema.withDecodingDefault(Effect.succeed("auto" as const)),
74 ),
63}).annotate({ identifier: "OAuth2SourceConfig" });75}).annotate({ identifier: "OAuth2SourceConfig" });
64export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type;76export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type;
65export type OAuth2SourceConfigType = OAuth2SourceConfig;77export type OAuth2SourceConfigType = OAuth2SourceConfig;
packages/core/sdk/src/oauth-service.ts+76−6
@@ -61,6 +61,7 @@ import { · 60 unmodified lines
61 OAuthSessionNotFoundError,61 OAuthSessionNotFoundError,
62 OAuthStartError,62 OAuthStartError,
63 type OAuthAuthorizationCodeStrategy,63 type OAuthAuthorizationCodeStrategy,
64 type OAuthAuthorizationCodeExistingClientStrategy,
64 type OAuthClientCredentialsStrategy,65 type OAuthClientCredentialsStrategy,
65 type OAuthCompleteInput,66 type OAuthCompleteInput,
66 type OAuthCompleteResult,67 type OAuthCompleteResult,
@@ -141,6 +142,8 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ · 74 unmodified lines
141 Schema.withDecodingDefaultType(Effect.succeed(null)),142 Schema.withDecodingDefaultType(Effect.succeed(null)),
142 ),143 ),
143 scopes: Schema.Array(Schema.String),144 scopes: Schema.Array(Schema.String),
145 authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
146 storedScope: Schema.optional(Schema.String),
144 scopeSeparator: Schema.optional(Schema.String),147 scopeSeparator: Schema.optional(Schema.String),
145 clientAuth: Schema.Literals(["body", "basic"]),148 clientAuth: Schema.Literals(["body", "basic"]),
146});149});
@@ -587,6 +590,10 @@ export const makeOAuth2Service = ( · 440 unmodified lines
587 const startAuthorizationCode = (590 const startAuthorizationCode = (
588 input: OAuthStartInput,591 input: OAuthStartInput,
589 strategy: OAuthAuthorizationCodeStrategy,592 strategy: OAuthAuthorizationCodeStrategy,
593 options?: {
594 readonly authorizationScopes?: readonly string[];
595 readonly storedScope?: string;
596 },
590 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>597 ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
591 Effect.gen(function* () {598 Effect.gen(function* () {
592 const clientIdRef = yield* secretsGetResolvedAtScope({599 const clientIdRef = yield* secretsGetResolvedAtScope({
@@ -609,12 +616,19 @@ export const makeOAuth2Service = ( · 16 unmodified lines
609 const sessionId = scopedSessionId(input.tokenScope, newSessionId());616 const sessionId = scopedSessionId(input.tokenScope, newSessionId());
610 const codeVerifier = createPkceCodeVerifier();617 const codeVerifier = createPkceCodeVerifier();
611 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));618 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);
612 626
613 const authorizationUrl = buildAuthorizationUrl({627 const authorizationUrl = buildAuthorizationUrl({
614 authorizationUrl: strategy.authorizationEndpoint,628 authorizationUrl: strategy.authorizationEndpoint,
615 clientId: clientIdRef.value,629 clientId: clientIdRef.value,
616 redirectUrl: input.redirectUrl,630 redirectUrl: input.redirectUrl,
617 scopes: strategy.scopes,631 scopes: authorizationScopes,
618 state: sessionId,632 state: sessionId,
619 codeChallenge,633 codeChallenge,
620 scopeSeparator: strategy.scopeSeparator,634 scopeSeparator: strategy.scopeSeparator,
@@ -639,6 +653,8 @@ export const makeOAuth2Service = ( · 18 unmodified lines
639 }))?.scopeId ?? null)653 }))?.scopeId ?? null)
640 : null,654 : null,
641 scopes: [...strategy.scopes],655 scopes: [...strategy.scopes],
656 authorizationScopes: [...authorizationScopes],
657 storedScope,
642 scopeSeparator: strategy.scopeSeparator,658 scopeSeparator: strategy.scopeSeparator,
643 clientAuth: strategy.clientAuth ?? "body",659 clientAuth: strategy.clientAuth ?? "body",
644 };660 };
@@ -657,6 +673,53 @@ export const makeOAuth2Service = ( · 12 unmodified lines
657 };673 };
658 });674 });
659 675
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
660 const startClientCredentials = (723 const startClientCredentials = (
661 input: OAuthStartInput,724 input: OAuthStartInput,
662 strategy: OAuthClientCredentialsStrategy,725 strategy: OAuthClientCredentialsStrategy,
@@ -765,6 +828,9 @@ export const makeOAuth2Service = ( · 102 unmodified lines
765 Match.when({ kind: "authorization-code" }, (strategy) =>828 Match.when({ kind: "authorization-code" }, (strategy) =>
766 startAuthorizationCode(input, strategy),829 startAuthorizationCode(input, strategy),
767 ),830 ),
831 Match.when({ kind: "authorization-code-existing-client" }, (strategy) =>
832 startAuthorizationCodeWithExistingClient(input, strategy),
833 ),
768 Match.when({ kind: "client-credentials" }, (strategy) =>834 Match.when({ kind: "client-credentials" }, (strategy) =>
769 startClientCredentials(input, strategy),835 startClientCredentials(input, strategy),
770 ),836 ),
@@ -875,6 +941,10 @@ export const makeOAuth2Service = ( · 104 unmodified lines
875 typeof exchangeResult.tokens.expires_in === "number"941 typeof exchangeResult.tokens.expires_in === "number"
876 ? now() + exchangeResult.tokens.expires_in * 1000942 ? now() + exchangeResult.tokens.expires_in * 1000
877 : null;943 : null;
944 const effectiveOAuthScope =
945 payload.kind === "authorization-code" && payload.storedScope
946 ? payload.storedScope
947 : (exchangeResult.tokens.scope ?? null);
878 948
879 const dynamicClientSecretSecretId = yield* (() => {949 const dynamicClientSecretSecretId = yield* (() => {
880 if (payload.kind !== "dynamic-dcr") return Effect.succeed(null);950 if (payload.kind !== "dynamic-dcr") return Effect.succeed(null);
@@ -938,7 +1008,7 @@ export const makeOAuth2Service = ( · 57 unmodified lines
938 : "body",1008 : "body",
939 clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null,1009 clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null,
940 scopes: [...payload.scopes],1010 scopes: [...payload.scopes],
941 scope: exchangeResult.tokens.scope ?? null,1011 scope: effectiveOAuthScope,
942 resource: payload.resource,1012 resource: payload.resource,
943 }1013 }
944 : {1014 : {
@@ -950,9 +1020,9 @@ export const makeOAuth2Service = ( · 5 unmodified lines
950 clientSecretSecretId: payload.clientSecretSecretId,1020 clientSecretSecretId: payload.clientSecretSecretId,
951 clientSecretSecretScopeId: payload.clientSecretSecretScopeId,1021 clientSecretSecretScopeId: payload.clientSecretSecretScopeId,
952 clientAuth: payload.clientAuth,1022 clientAuth: payload.clientAuth,
953 scopes: [...payload.scopes],1023 scopes: [...(payload.authorizationScopes ?? payload.scopes)],
954 scopeSeparator: payload.scopeSeparator,1024 scopeSeparator: payload.scopeSeparator,
955 scope: exchangeResult.tokens.scope ?? null,1025 scope: effectiveOAuthScope,
956 };1026 };
957 1027
958 yield* deps1028 yield* deps
@@ -977,7 +1047,7 @@ export const makeOAuth2Service = ( · 18 unmodified lines
977 })1047 })
978 : null,1048 : null,
979 expiresAt: connectionExpiresAt,1049 expiresAt: connectionExpiresAt,
980 oauthScope: exchangeResult.tokens.scope ?? null,1050 oauthScope: effectiveOAuthScope,
981 providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,1051 providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,
982 }),1052 }),
983 )1053 )
@@ -1009,7 +1079,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines
1009 return {1079 return {
1010 connectionId,1080 connectionId,
1011 expiresAt: connectionExpiresAt,1081 expiresAt: connectionExpiresAt,
1012 scope: exchangeResult.tokens.scope ?? null,1082 scope: effectiveOAuthScope,
1013 };1083 };
1014 });1084 });
1015 1085
packages/core/sdk/src/oauth.ts+24−0
@@ -64,7 +64,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 63 unmodified lines
64 * PKCE without a confidential secret. */64 * PKCE without a confidential secret. */
65 clientSecretSecretId: Schema.NullOr(Schema.String),65 clientSecretSecretId: Schema.NullOr(Schema.String),
66 clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)),66 clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)),
67 /** Final scope set Executor should remember for this connection. */
67 scopes: Schema.Array(Schema.String),68 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)),
68 /** Separator between scopes. RFC 6749 says space; some providers72 /** Separator between scopes. RFC 6749 says space; some providers
69 * (GitHub classic) use comma. */73 * (GitHub classic) use comma. */
70 scopeSeparator: Schema.optional(Schema.String),74 scopeSeparator: Schema.optional(Schema.String),
@@ -77,6 +81,25 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 6 unmodified lines
77});81});
78export type OAuthAuthorizationCodeStrategy = typeof OAuthAuthorizationCodeStrategy.Type;82export type OAuthAuthorizationCodeStrategy = typeof OAuthAuthorizationCodeStrategy.Type;
79 83
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. */
87export 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});
100export type OAuthAuthorizationCodeExistingClientStrategy =
101 typeof OAuthAuthorizationCodeExistingClientStrategy.Type;
102
80/** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used103/** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used
81 * for server-to-server integrations where the plugin has both104 * for server-to-server integrations where the plugin has both
82 * `client_id` and `client_secret` and the server will mint tokens105 * `client_id` and `client_secret` and the server will mint tokens
@@ -100,6 +123,7 @@ export type OAuthClientCredentialsStrategy = typeof OAuthClientCredentialsStrate · 17 unmodified lines
100export const OAuthStrategy = Schema.Union([123export const OAuthStrategy = Schema.Union([
101 OAuthDynamicDcrStrategy,124 OAuthDynamicDcrStrategy,
102 OAuthAuthorizationCodeStrategy,125 OAuthAuthorizationCodeStrategy,
126 OAuthAuthorizationCodeExistingClientStrategy,
103 OAuthClientCredentialsStrategy,127 OAuthClientCredentialsStrategy,
104]);128]);
105export type OAuthStrategy = typeof OAuthStrategy.Type;129export 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
61 tokenEndpoint: oauth.tokenEndpoint,61 tokenEndpoint: oauth.tokenEndpoint,
62 clientIdSecretId: "oauth-client-id",62 clientIdSecretId: "oauth-client-id",
63 clientSecretSecretId: "oauth-client-secret",63 clientSecretSecretId: "oauth-client-secret",
64 scopes: ["read"],64 scopes: ["read", "write"],
65 authorizationScopes: ["read"],
65 },66 },
66 });67 });
67 68
68 expect(started.authorizationUrl).not.toBeNull();69 expect(started.authorizationUrl).not.toBeNull();
69 const authorizationUrl = started.authorizationUrl ?? "";70 const authorizationUrl = started.authorizationUrl ?? "";
71 expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("read");
70 const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl });72 const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl });
71 const completed = yield* workspace.executor.oauth.complete({73 const completed = yield* workspace.executor.oauth.complete({
72 state: callback.state,74 state: callback.state,
@@ -75,6 +77,93 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 2 unmodified lines
75 });77 });
76 78
77 expect(completed.connectionId).toBe("test-oauth-authorization-code");79 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");
78 const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);167 const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);
79 expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);168 expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);
80 }),169 }),
packages/core/sdk/src/testing/oauth-test-server.ts+2−0
@@ -40,6 +40,7 @@ export interface OAuthTestServerRequest { · 39 unmodified lines
40 readonly path: string;40 readonly path: string;
41 readonly headers: Readonly<Record<string, string>>;41 readonly headers: Readonly<Record<string, string>>;
42 readonly body: string;42 readonly body: string;
43 readonly query: Readonly<Record<string, string>>;
43}44}
44 45
45export interface OAuthTestServerOptions {46export interface OAuthTestServerOptions {
@@ -435,6 +436,7 @@ export const serveOAuthTestServer = ( · 389 unmodified lines
435 path: requestUrl.pathname,436 path: requestUrl.pathname,
436 headers,437 headers,
437 body,438 body,
439 query: Object.fromEntries(requestUrl.searchParams.entries()),
438 },440 },
439 ]);441 ]);
440 442
packages/plugins/openapi/package.json+1−0
@@ -65,6 +65,7 @@ · 64 unmodified lines
65 "@executor-js/config": "workspace:*",65 "@executor-js/config": "workspace:*",
66 "@executor-js/sdk": "workspace:*",66 "@executor-js/sdk": "workspace:*",
67 "effect": "catalog:",67 "effect": "catalog:",
68 "lucide-react": "^1.7.0",
68 "openapi-types": "^12.1.3",69 "openapi-types": "^12.1.3",
69 "yaml": "^2.7.1"70 "yaml": "^2.7.1"
70 },71 },
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+627−173
@@ -1,10 +1,12 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Match from "effect/Match";6import * as Match from "effect/Match";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { ChevronDownIcon } from "lucide-react";
8 10
9import {11import {
10 ConnectionId,12 ConnectionId,
@@ -12,7 +14,12 @@ import { · 1 unmodified line
12 SecretId,14 SecretId,
13 SetSourceCredentialBindingInput,15 SetSourceCredentialBindingInput,
14} from "@executor-js/sdk/shared";16} from "@executor-js/sdk/shared";
15import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms";17import {
18 connectionIdentityAtom,
19 connectionsAtom,
20 setSourceCredentialBinding,
21 startOAuth,
22} from "@executor-js/react/api/atoms";
16import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";23import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
17import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";24import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
18 25
@@ -65,6 +72,7 @@ import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio- · 46 unmodified lines
65import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";72import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
66import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms";73import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms";
67import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";74import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
75import { openApiPresets } from "../sdk/presets";
68import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";76import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
69import {77import {
70 headerBindingSlot,78 headerBindingSlot,
@@ -131,6 +139,123 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { · 60 unmodified lines
131 }139 }
132}140}
133 141
142const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
143
144const identityScopesForPreset = (
145 identityScopes: OAuth2Preset["identityScopes"],
146): readonly string[] => {
147 if (identityScopes === false) return [];
148 return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
149};
150
151const 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
160const splitOAuthScopes = (value: string | null): Set<string> =>
161 new Set(value?.split(/\s+/).filter(Boolean) ?? []);
162
163const 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
173const 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
181const 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
186const hasGoogleOAuthScope = (connection: { readonly oauthScope: string | null }): boolean =>
187 [...splitOAuthScopes(connection.oauthScope)].some(isGoogleOAuthScope);
188
189const 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
195const 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
203const googleAuthorizationParams = (enabled: boolean): Record<string, string> | undefined =>
204 enabled
205 ? {
206 access_type: "offline",
207 }
208 : undefined;
209
210const 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
228const 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
250type 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
134const specInputForAdd = (input: string) => {259const specInputForAdd = (input: string) => {
135 const value = input.trim();260 const value = input.trim();
136 const parsed = Effect.runSyncExit(261 const parsed = Effect.runSyncExit(
@@ -155,6 +280,15 @@ const isGoogleDiscoveryUrl = (url: string): boolean => { · 18 unmodified lines
155 return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");280 return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");
156};281};
157 282
283const 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
158type StrategySelection =292type StrategySelection =
159 | { readonly kind: "none" }293 | { readonly kind: "none" }
160 | { readonly kind: "custom" }294 | { readonly kind: "custom" }
@@ -214,6 +348,119 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 53 unmodified lines
214 });348 });
215}349}
216 350
351function 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
407function 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
217const secretStorageDescription = (label: string): string =>464const secretStorageDescription = (label: string): string =>
218 label === "Personal"465 label === "Personal"
219 ? "Only you can use this secret."466 ? "Only you can use this secret."
@@ -259,6 +506,8 @@ export default function AddOpenApiSource(props: { · 39 unmodified lines
259 const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null);506 const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null);
260 const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null);507 const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null);
261 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());508 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());
509 const [includeOAuth2IdentityScopes, setIncludeOAuth2IdentityScopes] = useState(true);
510 const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false);
262 const [oauth2AuthState, setOauth2AuthState] = useState<{511 const [oauth2AuthState, setOauth2AuthState] = useState<{
263 readonly fingerprint: string;512 readonly fingerprint: string;
264 readonly auth: { readonly connectionId: string };513 readonly auth: { readonly connectionId: string };
@@ -318,6 +567,7 @@ export default function AddOpenApiSource(props: { · 53 unmodified lines
318 const doSetBinding = useAtomSet(setSourceCredentialBinding, {567 const doSetBinding = useAtomSet(setSourceCredentialBinding, {
319 mode: "promiseExit",568 mode: "promiseExit",
320 });569 });
570 const connectionsResult = useAtomValue(connectionsAtom(scopeId));
321 const secretList = useSecretPickerSecrets();571 const secretList = useSecretPickerSecrets();
322 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({572 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
323 popupName: OPENAPI_OAUTH_POPUP_NAME,573 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -355,6 +605,9 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines
355 const baseUrlOptions = Array.from(605 const baseUrlOptions = Array.from(
356 new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),606 new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
357 );607 );
608 const previewPresetIcon =
609 openApiPresets.find((preset) => normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl))
610 ?.icon ?? null;
358 611
359 const resolvedBaseUrl = baseUrl.trim();612 const resolvedBaseUrl = baseUrl.trim();
360 const sourceScope = ScopeId.make(scopeId);613 const sourceScope = ScopeId.make(scopeId);
@@ -460,11 +713,13 @@ export default function AddOpenApiSource(props: { · 99 unmodified lines
460 const resolvedSourceId =713 const resolvedSourceId =
461 slugifyNamespace(identity.namespace) ||714 slugifyNamespace(identity.namespace) ||
462 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");715 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
716 const resolvedDisplayName =
717 identity.name.trim() ||
718 (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId);
463 const selectedOAuth2Preset: OAuth2Preset | null =719 const selectedOAuth2Preset: OAuth2Preset | null =
464 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;720 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;
465 const selectedOAuth2Fingerprint = selectedOAuth2Preset721 const selectedOAuth2Fingerprint = selectedOAuth2Preset
466 ? [722 ? [
467 resolvedSourceId,
468 resolvedBaseUrl,723 resolvedBaseUrl,
469 selectedOAuth2Preset.securitySchemeName,724 selectedOAuth2Preset.securitySchemeName,
470 selectedOAuth2Preset.flow,725 selectedOAuth2Preset.flow,
@@ -474,6 +729,46 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
474 : "";729 : "";
475 const oauth2Auth =730 const oauth2Auth =
476 oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null;731 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]);
477 772
478 const configuredOAuth2 =773 const configuredOAuth2 =
479 strategy.kind === "oauth2" && selectedOAuth2Preset774 strategy.kind === "oauth2" && selectedOAuth2Preset
@@ -496,6 +791,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
496 clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName),791 clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName),
497 connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName),792 connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName),
498 scopes: [...oauth2SelectedScopes],793 scopes: [...oauth2SelectedScopes],
794 identityScopes: configuredOAuth2IdentityScopes,
499 })795 })
500 : null;796 : null;
501 const hasHeaders = Object.keys(configuredHeaders).length > 0;797 const hasHeaders = Object.keys(configuredHeaders).length > 0;
@@ -554,6 +850,7 @@ export default function AddOpenApiSource(props: { · 52 unmodified lines
554 setStrategy({ kind: "oauth2", presetIndex: 0 });850 setStrategy({ kind: "oauth2", presetIndex: 0 });
555 setCustomHeaders([]);851 setCustomHeaders([]);
556 setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes)));852 setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes)));
853 setIncludeOAuth2IdentityScopes(result.oauth2Presets[0].identityScopes !== false);
557 } else {854 } else {
558 // No header presets — default to "custom" so the headers editor is855 // No header presets — default to "custom" so the headers editor is
559 // visible immediately. Specs with no `security` block (e.g. Microsoft856 // visible immediately. Specs with no `security` block (e.g. Microsoft
@@ -593,6 +890,7 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines
593 const preset = preview?.oauth2Presets[n.presetIndex];890 const preset = preview?.oauth2Presets[n.presetIndex];
594 if (preset) {891 if (preset) {
595 setOauth2SelectedScopes(new Set(Object.keys(preset.scopes)));892 setOauth2SelectedScopes(new Set(Object.keys(preset.scopes)));
893 setIncludeOAuth2IdentityScopes(preset.identityScopes !== false);
596 }894 }
597 }),895 }),
598 Match.exhaustive,896 Match.exhaustive,
@@ -617,141 +915,183 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
617 setOauth2AuthState(null);915 setOauth2AuthState(null);
618 };916 };
619 917
620 const handleConnectOAuth2 = useCallback(async () => {918 const handleConnectOAuth2 = useCallback(
621 if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return;919 async (existingConnection?: OAuthConnectionChoice) => {
622 oauth.cancel();920 if (!selectedOAuth2Preset || !preview) return;
623 setOauth2Error(null);921 oauth.cancel();
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 });
674 setOauth2Error(null);922 setOauth2Error(null);
675 return;923 const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
676 }924 const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope;
677 925 const connectionId =
678 const authorizationUrl = resolveOAuthUrl(926 existingConnection?.id ??
679 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),927 openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
680 resolvedBaseUrl,928 const scopesForAuthorization = existingConnection
681 );929 ? mergeOAuthScopes(
682 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);930 splitOAuthScopes(existingConnection.oauthScope),
683 931 selectedOAuth2IsGoogle ? oauth2SelectedScopes : selectedOAuth2Scopes,
684 await oauth.openAuthorization({932 )
685 tokenScope: oauthTokenTargetScope,933 : selectedOAuth2Scopes;
686 run: async () => {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);
687 const exit = await doStartOAuth({953 const exit = await doStartOAuth({
688 params: { scopeId: oauthTokenTargetScope },954 params: { scopeId: tokenTargetScope },
689 payload: {955 payload: {
690 endpoint: authorizationUrl,956 endpoint: tokenUrl,
691 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),957 redirectUrl: tokenUrl,
692 tokenScope: oauthTokenTargetScope,958 connectionId,
693 redirectUrl: oauth2RedirectUrl,959 tokenScope: tokenTargetScope,
694 strategy: {960 strategy: {
695 kind: "authorization-code",961 kind: "client-credentials",
696 authorizationEndpoint: authorizationUrl,
697 tokenEndpoint: tokenUrl,962 tokenEndpoint: tokenUrl,
698 issuerUrl,
699 clientIdSecretId: oauth2ClientIdSecretId,963 clientIdSecretId: oauth2ClientIdSecretId,
700 clientIdSecretScopeId: String(clientIdSecretScope),964 clientIdSecretScopeId: String(clientIdSecretScope),
701 clientSecretSecretId: oauth2ClientSecretSecretId ?? null,965 clientSecretSecretId: oauth2ClientSecretSecretId,
702 clientSecretSecretScopeId: oauth2ClientSecretSecretId966 clientSecretSecretScopeId: String(clientSecretSecretScope),
703 ? String(clientSecretSecretScope)967 scopes: scopesForAuthorization,
704 : null,
705 scopes: [...oauth2SelectedScopes],
706 },968 },
707 pluginId: "openapi",969 pluginId: "openapi",
708 identityLabel: `${displayName} OAuth`,970 identityLabel: `${displayName} OAuth`,
709 },971 },
710 });972 });
973 setStartingOAuth(false);
711 if (Exit.isFailure(exit)) {974 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()975 setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth"));
713 throw new Error(errorMessageFromExit(exit, "Failed to start OAuth"));976 return;
714 }977 }
715 const response = exit.value;978 const response = exit.value;
716 if (response.authorizationUrl === null) {979 if (!response.completedConnection) {
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()980 setOauth2Error("client_credentials flow did not mint a connection");
718 throw new Error("Unexpected response flow from server");981 return;
719 }982 }
720 return {983 setOAuthTokenTargetScope(tokenTargetScope);
721 sessionId: response.sessionId,
722 authorizationUrl: response.authorizationUrl,
723 };
724 },
725 onSuccess: (result) => {
726 setOauth2AuthState({984 setOauth2AuthState({
727 fingerprint: selectedOAuth2Fingerprint,985 fingerprint: selectedOAuth2Fingerprint,
728 auth: { connectionId: result.connectionId },986 auth: { connectionId: response.completedConnection.connectionId },
729 });987 });
730 setOauth2Error(null);988 setOauth2Error(null);
731 },989 return;
732 onError: (message) => {990 }
733 setStartingOAuth(false);991 if (!existingConnection && !oauth2ClientIdSecretId) return;
734 setOauth2Error(message);992
735 },993 const authorizationUrl = resolveOAuthUrl(
736 });994 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
737 }, [995 resolvedBaseUrl,
738 selectedOAuth2Preset,996 );
739 oauth2ClientIdSecretId,997 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
740 oauth2ClientSecretSecretId,998 const authorizationStrategy = existingConnection
741 oauth2SelectedScopes,999 ? {
742 oauth2RedirectUrl,1000 kind: "authorization-code-existing-client" as const,
743 resolvedBaseUrl,1001 authorizationEndpoint: authorizationUrl,
744 preview,1002 tokenEndpoint: tokenUrl,
745 doStartOAuth,1003 issuerUrl,
746 identity.name,1004 scopes: scopesForAuthorization,
747 resolvedSourceId,1005 ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0
748 selectedOAuth2Fingerprint,1006 ? { authorizationScopes: scopesForProviderAuthorization }
749 oauth,1007 : {}),
750 oauthTokenTargetScope,1008 extraAuthorizationParams,
751 oauth2ClientIdScope,1009 }
752 oauth2ClientSecretScope,1010 : oauth2ClientIdSecretId
753 sourceScope,1011 ? {
754 ]);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 );
755 1095
756 const handleCancelOAuth2 = useCallback(() => {1096 const handleCancelOAuth2 = useCallback(() => {
757 oauth.cancel();1097 oauth.cancel();
@@ -759,18 +1099,29 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
759 setOauth2Error(null);1099 setOauth2Error(null);
760 }, [oauth]);1100 }, [oauth]);
761 1101
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
762 const handleAdd = async () => {1116 const handleAdd = async () => {
763 setAdding(true);1117 setAdding(true);
764 setAddError(null);1118 setAddError(null);
765 const namespace = resolvedSourceId;1119 const namespace = resolvedSourceId;
766 const displayName =
767 identity.name.trim() ||
768 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
769 const exit = await doAdd({1120 const exit = await doAdd({
770 params: { scopeId },1121 params: { scopeId },
771 payload: {1122 payload: {
772 spec: specInputForAdd(specUrl),1123 spec: specInputForAdd(specUrl),
773 name: displayName,1124 name: resolvedDisplayName,
774 namespace,1125 namespace,
775 baseUrl: resolvedBaseUrl,1126 baseUrl: resolvedBaseUrl,
776 ...(configuredSpecFetchCredentials1127 ...(configuredSpecFetchCredentials
@@ -1021,6 +1372,7 @@ export default function AddOpenApiSource(props: { · 244 unmodified lines
1021 setOauth2AuthState(null);1372 setOauth2AuthState(null);
1022 setOauth2Error(null);1373 setOauth2Error(null);
1023 }}1374 }}
1375 faviconIcon={previewPresetIcon}
1024 faviconUrl={resolvedBaseUrl}1376 faviconUrl={resolvedBaseUrl}
1025 baseUrlMissingMessage="A base URL is required to make requests."1377 baseUrlMissingMessage="A base URL is required to make requests."
1026 />1378 />
@@ -1157,15 +1509,22 @@ export default function AddOpenApiSource(props: { · 130 unmodified lines
1157 <CopyButton value={oauth2RedirectUrl} />1509 <CopyButton value={oauth2RedirectUrl} />
1158 </div>1510 </div>
1159 </div>1511 </div>
1160 <div className="space-y-1.5">1512 <div className="grid gap-3 md:grid-cols-2">
1161 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>1513 <div className="space-y-2">
1162 <div className="grid gap-2 md:grid-cols-2">
1163 <div className="space-y-1.5">1514 <div className="space-y-1.5">
1164 <div className="flex items-center gap-1.5">1515 <div className="space-y-0.5">
1165 <FieldLabel className="text-[11px]">Secret</FieldLabel>1516 <div className="flex items-center gap-1.5">
1166 <HelpTooltip label="Client ID secret">1517 <FieldLabel className="text-[11px]">Client ID</FieldLabel>
1167 Select or create the OAuth client ID secret.1518 <HelpTooltip label="Client ID secret">
1168 </HelpTooltip>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>
1169 </div>1528 </div>
1170 <CreatableSecretPicker1529 <CreatableSecretPicker
1171 value={oauth2ClientIdSecretId}1530 value={oauth2ClientIdSecretId}
@@ -1194,21 +1553,18 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines
1194 help="Choose where this OAuth client ID credential lives."1553 help="Choose where this OAuth client ID credential lives."
1195 />1554 />
1196 </div>1555 </div>
1197 </div>1556 <div className="space-y-2">
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">
1206 <div className="space-y-1.5">1557 <div className="space-y-1.5">
1207 <div className="flex items-center gap-1.5">1558 <div className="space-y-0.5">
1208 <FieldLabel className="text-[11px]">Secret</FieldLabel>1559 <div className="flex items-center gap-1.5">
1209 <HelpTooltip label="Client secret">1560 <FieldLabel className="text-[11px]">Client Secret</FieldLabel>
1210 Select or create the OAuth client secret.1561 <HelpTooltip label="Client secret">
1211 </HelpTooltip>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>
1212 </div>1568 </div>
1213 <CreatableSecretPicker1569 <CreatableSecretPicker
1214 value={oauth2ClientSecretSecretId}1570 value={oauth2ClientSecretSecretId}
@@ -1238,39 +1594,101 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
1238 />1594 />
1239 </div>1595 </div>
1240 </div>1596 </div>
1241 <div className="space-y-1.5">1597 <Collapsible
1242 <FieldLabel className="text-[11px]">Scopes</FieldLabel>1598 open={oauth2ScopesOpen}
1243 <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2">1599 onOpenChange={setOauth2ScopesOpen}
1244 {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? (1600 className="space-y-1.5"
1245 <div className="text-[11px] italic text-muted-foreground">1601 >
1246 No scopes declared by the spec.1602 <CollapsibleTrigger asChild>
1247 </div>1603 <Button
1248 ) : (1604 variant="ghost"
1249 Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => (1605 size="sm"
1250 <Label key={scope} className="flex items-start gap-2 cursor-pointer py-1">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">
1251 <Checkbox1656 <Checkbox
1252 checked={oauth2SelectedScopes.has(scope)}1657 checked={includeOAuth2IdentityScopes}
1253 onCheckedChange={() => toggleOAuth2Scope(scope)}1658 onCheckedChange={(checked) => {
1659 setIncludeOAuth2IdentityScopes(checked === true);
1660 setOauth2AuthState(null);
1661 }}
1254 />1662 />
1255 <div className="min-w-0 flex-1">1663 <div className="min-w-0 flex-1">
1256 <div className="font-mono text-[11px] text-foreground">{scope}</div>1664 <div className="text-[11px] font-medium text-foreground">
1257 {description && (1665 Account identity
1258 <div className="text-[10px] text-muted-foreground">1666 </div>
1259 {description}1667 <div className="font-mono text-[10px] text-muted-foreground">
1260 </div>1668 {selectedOAuth2AvailableIdentityScopes.join(" · ")}
1261 )}1669 </div>
1670 <div className="text-[10px] text-muted-foreground">
1671 Lets the connections page show the signed-in account.
1672 </div>
1262 </div>1673 </div>
1263 </Label>1674 </Label>
1264 ))1675 )}
1265 )}1676 </div>
1266 </div>1677 </CollapsibleContent>
1267 </div>1678 </Collapsible>
1268 1679
1269 {oauth2Auth ? (1680 {oauth2Auth ? (
1270 <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2">1681 <div className="flex items-center justify-between rounded-md border border-border/60 bg-background/50 px-3 py-2">
1271 <div className="text-[11px] text-green-700 dark:text-green-400">1682 <div className="min-w-0 text-[12px]">
1272 Connected · {oauth2SelectedScopes.size} scope1683 <OAuthConnectedAccount
1273 {oauth2SelectedScopes.size === 1 ? "" : "s"} granted1684 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 />
1274 </div>1692 </div>
1275 <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>1693 <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}>
1276 Disconnect1694 Disconnect
@@ -1285,23 +1703,59 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
1285 <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>1703 <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>
1286 Cancel1704 Cancel
1287 </Button>1705 </Button>
1288 <Button variant="secondary" size="sm" onClick={handleConnectOAuth2}>1706 <Button
1707 variant="secondary"
1708 size="sm"
1709 onClick={() => void handleConnectOAuth2()}
1710 >
1289 Retry1711 Retry
1290 </Button>1712 </Button>
1291 </div>1713 </div>
1292 ) : (1714 ) : (
1293 <div className="flex flex-col gap-1.5">1715 <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 )}
1294 <div className="grid gap-2 md:grid-cols-2">1744 <div className="grid gap-2 md:grid-cols-2">
1295 <div className="space-y-1.5">1745 <div className="space-y-1.5">
1296 <div className="flex items-center gap-1.5">1746 <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>
1298 <HelpTooltip label="OAuth sign-in">1752 <HelpTooltip label="OAuth sign-in">
1299 Start the provider OAuth flow.1753 Start the provider OAuth flow.
1300 </HelpTooltip>1754 </HelpTooltip>
1301 </div>1755 </div>
1302 <Button1756 <Button
1303 variant="secondary"1757 variant="secondary"
1304 onClick={handleConnectOAuth2}1758 onClick={() => void handleConnectOAuth2()}
1305 disabled={!canConnectOAuth2}1759 disabled={!canConnectOAuth2}
1306 className={1760 className={
1307 canConnectOAuth21761 canConnectOAuth2
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+17−2
@@ -73,6 +73,19 @@ import { OAuth2SourceConfig } from "../sdk/types"; · 72 unmodified lines
73const ErrorMessage = Schema.Struct({ message: Schema.String });73const ErrorMessage = Schema.Struct({ message: Schema.String });
74const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);74const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
75 75
76const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
77
78const 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
76const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>89const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
77 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {90 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
78 onNone: () => fallback,91 onNone: () => fallback,
@@ -422,6 +435,7 @@ export default function EditOpenApiSource(props: { · 343 unmodified lines
422 };435 };
423 const displayName = source.name;436 const displayName = source.name;
424 const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? "");437 const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? "");
438 const scopes = resolvedOAuthScopes(oauth2);
425 if (oauth2.flow === "clientCredentials") {439 if (oauth2.flow === "clientCredentials") {
426 const startOAuthExit = await doStartOAuth({440 const startOAuthExit = await doStartOAuth({
427 params: { scopeId: targetScope },441 params: { scopeId: targetScope },
@@ -439,7 +453,7 @@ export default function EditOpenApiSource(props: { · 11 unmodified lines
439 clientSecretSecretScopeId: clientSecretSecretScopeId453 clientSecretSecretScopeId: clientSecretSecretScopeId
440 ? String(clientSecretSecretScopeId)454 ? String(clientSecretSecretScopeId)
441 : null,455 : null,
442 scopes: [...oauth2.scopes],456 scopes,
443 },457 },
444 pluginId: "openapi",458 pluginId: "openapi",
445 identityLabel: `${displayName} OAuth`,459 identityLabel: `${displayName} OAuth`,
@@ -502,7 +516,7 @@ export default function EditOpenApiSource(props: { · 56 unmodified lines
502 clientSecretSecretScopeId: clientSecretSecretScopeId516 clientSecretSecretScopeId: clientSecretSecretScopeId
503 ? String(clientSecretSecretScopeId)517 ? String(clientSecretSecretScopeId)
504 : null,518 : null,
505 scopes: [...oauth2.scopes],519 scopes,
506 },520 },
507 pluginId: "openapi",521 pluginId: "openapi",
508 identityLabel: `${displayName} OAuth`,522 identityLabel: `${displayName} OAuth`,
@@ -648,6 +662,7 @@ export default function EditOpenApiSource(props: { · 139 unmodified lines
648 clientSecretSlot: oauth2.clientSecretSlot,662 clientSecretSlot: oauth2.clientSecretSlot,
649 connectionSlot: oauth2.connectionSlot,663 connectionSlot: oauth2.connectionSlot,
650 scopes: [...oauth2.scopes],664 scopes: [...oauth2.scopes],
665 identityScopes: oauth2.identityScopes,
651 }),666 }),
652 },667 },
653 },668 },
packages/plugins/openapi/src/react/OpenApiSourceDetailsFields.tsx+4−1
@@ -27,6 +27,7 @@ export function OpenApiSourceDetailsFields(props: { · 26 unmodified lines
27 readonly baseUrlOptions?: readonly FreeformComboboxOption[];27 readonly baseUrlOptions?: readonly FreeformComboboxOption[];
28 readonly specUrl?: string;28 readonly specUrl?: string;
29 readonly onSpecUrlChange?: (value: string) => void;29 readonly onSpecUrlChange?: (value: string) => void;
30 readonly faviconIcon?: string | null;
30 readonly faviconUrl?: string;31 readonly faviconUrl?: string;
31 readonly namespaceReadOnly?: boolean;32 readonly namespaceReadOnly?: boolean;
32 readonly specUrlDisabled?: boolean;33 readonly specUrlDisabled?: boolean;
@@ -40,7 +41,9 @@ export function OpenApiSourceDetailsFields(props: { · 7 unmodified lines
40 <CardStack>41 <CardStack>
41 <CardStackContent className="border-t-0">42 <CardStackContent className="border-t-0">
42 <CardStackEntry>43 <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 )}
44 <CardStackEntryContent>47 <CardStackEntryContent>
45 <CardStackEntryTitle>{props.title}</CardStackEntryTitle>48 <CardStackEntryTitle>{props.title}</CardStackEntryTitle>
46 {props.description && (49 {props.description && (
packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx+85−22
@@ -1,10 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3 3
4import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";4import { connectionIdentityAtom, connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms";
5import { Badge } from "@executor-js/react/components/badge";5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
6import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";7import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context";
7import { ScopeId } from "@executor-js/sdk/shared";8import { ConnectionId, ScopeId } from "@executor-js/sdk/shared";
8import {9import {
9 SourceCredentialLoadingBadge,10 SourceCredentialLoadingBadge,
10 SourceCredentialNotice,11 SourceCredentialNotice,
@@ -21,12 +22,65 @@ function OAuthBadge() { · 10 unmodified lines
21 return <Badge variant="secondary">OAuth</Badge>;22 return <Badge variant="secondary">OAuth</Badge>;
22}23}
23 24
25function 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
24const effectiveClientSecretSlot = (oauth2: {75const effectiveClientSecretSlot = (oauth2: {
25 readonly securitySchemeName: string;76 readonly securitySchemeName: string;
26 readonly clientSecretSlot: string | null;77 readonly clientSecretSlot: string | null;
27}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName);78}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName);
28 79
29const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => {80const sourceCredentialSlots = (
81 source: StoredSourceSchemaType,
82 options?: { readonly hasLiveOAuthConnection?: boolean },
83): readonly SourceCredentialSlot[] => {
30 const slots: SourceCredentialSlot[] = [];84 const slots: SourceCredentialSlot[] = [];
31 for (const [name, value] of Object.entries(source.config.headers ?? {})) {85 for (const [name, value] of Object.entries(source.config.headers ?? {})) {
32 if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name });86 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
36 }90 }
37 const oauth2 = source.config.oauth2;91 const oauth2 = source.config.oauth2;
38 if (oauth2) {92 if (oauth2) {
39 slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" });93 if (!options?.hasLiveOAuthConnection) {
40 slots.push({94 slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" });
41 kind: "secret",95 slots.push({
42 slot: effectiveClientSecretSlot(oauth2),96 kind: "secret",
43 label: "Client Secret",97 slot: effectiveClientSecretSlot(oauth2),
44 });98 label: "Client Secret",
99 });
100 }
45 slots.push({101 slots.push({
46 kind: "connection",102 kind: "connection",
47 slot: oauth2.connectionSlot,103 slot: oauth2.connectionSlot,
@@ -93,8 +149,21 @@ export default function OpenApiSourceSummary(props: { · 45 unmodified lines
93 const liveConnectionIds = new Set(connections.map((connection) => connection.id));149 const liveConnectionIds = new Set(connections.map((connection) => connection.id));
94 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));150 const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const));
95 const credentialTargetScope = userScope;151 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;
96 const missing = missingSourceCredentialLabels({165 const missing = missingSourceCredentialLabels({
97 slots: sourceCredentialSlots(source),166 slots: sourceCredentialSlots(source, { hasLiveOAuthConnection: connection !== null }),
98 bindings,167 bindings,
99 targetScope: credentialTargetScope,168 targetScope: credentialTargetScope,
100 scopeRanks,169 scopeRanks,
@@ -102,24 +171,18 @@ export default function OpenApiSourceSummary(props: { · 1 unmodified line
102 });171 });
103 172
104 if (props.variant === "panel") {173 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;
106 }179 }
107 180
108 if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />;181 if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />;
109 182
110 if (!oauth2) return null;183 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;
121 184
122 if (connectionId && connections.some((connection) => connection.id === connectionId)) {185 if (connection) {
123 return <SourceCredentialStatusBadge missing={[]} />;186 return <SourceCredentialStatusBadge missing={[]} />;
124 }187 }
125 188
packages/plugins/openapi/src/sdk/credential-status.test.ts+34−0
@@ -30,6 +30,18 @@ const source: SourceForCredentialStatus = { · 29 unmodified lines
30 },30 },
31};31};
32 32
33const 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
33const bindings = (45const bindings = (
34 scopeId: ScopeId,46 scopeId: ScopeId,
35 slots: readonly string[],47 slots: readonly string[],
@@ -81,6 +93,28 @@ describe("OpenAPI credential status", () => { · 45 unmodified lines
81 ).toEqual(["OAuth client connection"]);93 ).toEqual(["OAuth client connection"]);
82 });94 });
83 95
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
84 it("does not treat personal bindings as satisfying org-level credential status", () => {118 it("does not treat personal bindings as satisfying org-level credential status", () => {
85 expect(119 expect(
86 missingCredentialLabels(source, bindings(userScope, allSlots), orgScope, scopeRanks),120 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
88 const oauth2 = source.config.oauth2;88 const oauth2 = source.config.oauth2;
89 if (!oauth2) return missing;89 if (!oauth2) return missing;
90 90
91 if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) {91 const hasLiveConnection = hasConnectionBinding(
92 missing.push("Client ID");92 bindings,
93 }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 }
94 102
95 const clientSecretSlot = effectiveClientSecretSlot(oauth2);103 const clientSecretSlot = effectiveClientSecretSlot(oauth2);
96 if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) {104 if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) {
97 missing.push("Client Secret");105 missing.push("Client Secret");
98 }106 }
99 107
100 if (
101 !hasConnectionBinding(bindings, oauth2.connectionSlot, targetScope, ranks, liveConnectionIds)
102 ) {
103 missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in");108 missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in");
104 }109 }
105 110
packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+94−0
@@ -46,6 +46,7 @@ const makeOauth2SourceConfig = (params: { · 45 unmodified lines
46 readonly tokenUrl: string;46 readonly tokenUrl: string;
47 readonly authorizationUrl: string | null;47 readonly authorizationUrl: string | null;
48 readonly scopes: readonly string[];48 readonly scopes: readonly string[];
49 readonly identityScopes?: OAuth2SourceConfig["identityScopes"];
49}): OAuth2SourceConfig =>50}): OAuth2SourceConfig =>
50 OAuth2SourceConfig.make({51 OAuth2SourceConfig.make({
51 kind: "oauth2",52 kind: "oauth2",
@@ -57,8 +58,20 @@ const makeOauth2SourceConfig = (params: { · 5 unmodified lines
57 clientSecretSlot: "oauth2:oauth2:client-secret",58 clientSecretSlot: "oauth2:oauth2:client-secret",
58 connectionSlot: "oauth2:oauth2:connection",59 connectionSlot: "oauth2:oauth2:connection",
59 scopes: [...params.scopes],60 scopes: [...params.scopes],
61 ...(params.identityScopes !== undefined ? { identityScopes: params.identityScopes } : {}),
60 });62 });
61 63
64const 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
62// ---------------------------------------------------------------------------75// ---------------------------------------------------------------------------
63// Test API — a single endpoint that echoes the Authorization header so the76// Test API — a single endpoint that echoes the Authorization header so the
64// test can assert which user's token got injected.77// test can assert which user's token got injected.
@@ -360,6 +373,87 @@ describe("OpenAPI multi-scope OAuth", () => { · 295 unmodified lines
360 }),373 }),
361 );374 );
362 375
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
363 // -------------------------------------------------------------------------457 // -------------------------------------------------------------------------
364 // Regression: repeated `clientCredentials` sign-ins used to mint a fresh458 // Regression: repeated `clientCredentials` sign-ins used to mint a fresh
365 // random UUID per call AND rewrite `source.oauth2.connectionId` to that459 // 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
303 tokenUrl: Schema.String,303 tokenUrl: Schema.String,
304 refreshUrl: Schema.NullOr(Schema.String),304 refreshUrl: Schema.NullOr(Schema.String),
305 scopes: Schema.Record(Schema.String, Schema.String),305 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 ]),
306});311});
307const StaticPreviewSpecOutputSchema = Schema.Struct({312const StaticPreviewSpecOutputSchema = Schema.Struct({
308 title: Schema.NullOr(Schema.String),313 title: Schema.NullOr(Schema.String),
@@ -535,6 +540,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 226 unmodified lines
535 tokenUrl: preset.tokenUrl,540 tokenUrl: preset.tokenUrl,
536 refreshUrl: Option.getOrNull(preset.refreshUrl),541 refreshUrl: Option.getOrNull(preset.refreshUrl),
537 scopes: preset.scopes,542 scopes: preset.scopes,
543 identityScopes: preset.identityScopes,
538 })),544 })),
539});545});
540 546
packages/plugins/openapi/src/sdk/preview-oauth2.test.ts+2−0
@@ -96,6 +96,7 @@ describe("previewSpec OAuth2 extraction", () => { · 95 unmodified lines
96 read: "Read access",96 read: "Read access",
97 write: "Write access",97 write: "Write access",
98 });98 });
99 expect(preset.identityScopes).toBe("auto");
99 }),100 }),
100 );101 );
101 102
@@ -126,6 +127,7 @@ describe("previewSpec OAuth2 extraction", () => { · 24 unmodified lines
126 const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!;127 const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!;
127 expect(Option.isNone(cc.authorizationUrl)).toBe(true);128 expect(Option.isNone(cc.authorizationUrl)).toBe(true);
128 expect(cc.scopes).toEqual({ "admin:read": "Admin read" });129 expect(cc.scopes).toEqual({ "admin:read": "Admin read" });
130 expect(cc.identityScopes).toBe(false);
129 }),131 }),
130 );132 );
131 133
packages/plugins/openapi/src/sdk/preview.ts+8−0
@@ -106,6 +106,12 @@ export const OAuth2Preset = Schema.Struct({ · 105 unmodified lines
106 refreshUrl: Schema.OptionFromOptional(Schema.String),106 refreshUrl: Schema.OptionFromOptional(Schema.String),
107 /** Declared scopes for this flow: `{ scope: description }`. */107 /** Declared scopes for this flow: `{ scope: description }`. */
108 scopes: Schema.Record(Schema.String, Schema.String),108 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 ]),
109});115});
110export type OAuth2Preset = typeof OAuth2Preset.Type;116export type OAuth2Preset = typeof OAuth2Preset.Type;
111 117
@@ -328,6 +334,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] · 216 unmodified lines
328 tokenUrl: flow.tokenUrl,334 tokenUrl: flow.tokenUrl,
329 refreshUrl: flow.refreshUrl,335 refreshUrl: flow.refreshUrl,
330 scopes: flow.scopes,336 scopes: flow.scopes,
337 identityScopes: "auto",
331 }),338 }),
332 );339 );
333 }340 }
@@ -343,6 +350,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] · 9 unmodified lines
343 tokenUrl: flow.tokenUrl,350 tokenUrl: flow.tokenUrl,
344 refreshUrl: flow.refreshUrl,351 refreshUrl: flow.refreshUrl,
345 scopes: flow.scopes,352 scopes: flow.scopes,
353 identityScopes: false,
346 }),354 }),
347 );355 );
348 }356 }
packages/react/src/api/atoms.tsx+9−1
@@ -7,9 +7,10 @@ import { · 6 unmodified lines
7} from "@executor-js/sdk/shared";7} from "@executor-js/sdk/shared";
8import * as Atom from "effect/unstable/reactivity/Atom";8import * as Atom from "effect/unstable/reactivity/Atom";
9import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";9import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
10import * as Effect from "effect/Effect";
10 11
11import { ExecutorApiClient } from "./client";12import { ExecutorApiClient } from "./client";
12import { ReactivityKey } from "./reactivity-keys";13import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys";
13 14
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
15// Scope — fetched from the server16// Scope — fetched from the server
@@ -178,6 +179,13 @@ export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); · 162 unmodified lines
178 179
179export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel");180export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel");
180 181
182export const oauthConnectionCompleted = ExecutorApiClient.runtime.fn<{
183 readonly tokenScope: string;
184 readonly reactivityKeys: typeof connectionWriteKeys;
185}>()(() => Effect.void, {
186 reactivityKeys: connectionWriteKeys,
187});
188
181export const createPolicy = ExecutorApiClient.mutation("policies", "create");189export const createPolicy = ExecutorApiClient.mutation("policies", "create");
182 190
183export const updatePolicy = ExecutorApiClient.mutation("policies", "update");191export 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
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
6 6
7import { cancelOAuth, startOAuth } from "../api/atoms";7import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms";
8import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";8import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting";
9import {9import {
10 openOAuthPopup,10 openOAuthPopup,
@@ -12,6 +12,7 @@ import { · 1 unmodified line
12 reserveOAuthPopup,12 reserveOAuthPopup,
13 type OAuthPopupResult,13 type OAuthPopupResult,
14} from "../api/oauth-popup";14} from "../api/oauth-popup";
15import { connectionWriteKeys } from "../api/reactivity-keys";
15 16
16type DesktopBridge = {17type DesktopBridge = {
17 readonly openExternal: (url: string) => Promise<void>;18 readonly openExternal: (url: string) => Promise<void>;
@@ -73,7 +74,7 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 55 unmodified lines
73 readonly tokenScope: string;74 readonly tokenScope: string;
74 readonly run: () => Promise<OAuthAuthorizationStartResult>;75 readonly run: () => Promise<OAuthAuthorizationStartResult>;
75 readonly onSuccess: (payload: TPayload) => void | Promise<void>;76 readonly onSuccess: (payload: TPayload) => void | Promise<void>;
76 readonly onError?: (error: string) => void;77 readonly onError?: (error: string, details?: string) => void;
77 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;78 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;
78 readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;79 readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;
79};80};
@@ -119,6 +120,7 @@ export function useOAuthPopupFlow< · 39 unmodified lines
119 } = options;120 } = options;
120 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });121 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
121 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });122 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
123 const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" });
122 const reportHandledError = useReportHandledError();124 const reportHandledError = useReportHandledError();
123 const [busy, setBusy] = useState(false);125 const [busy, setBusy] = useState(false);
124 const [error, setError] = useState<string | null>(null);126 const [error, setError] = useState<string | null>(null);
@@ -222,7 +224,25 @@ export function useOAuthPopupFlow< · 97 unmodified lines
222 if (!result.ok) {224 if (!result.ok) {
223 setBusy(false);225 setBusy(false);
224 setError(result.error);226 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);
226 return;246 return;
227 }247 }
228 248
@@ -292,6 +312,7 @@ export function useOAuthPopupFlow< · 63 unmodified lines
292 cancel,312 cancel,
293 cancelSession,313 cancelSession,
294 detectPopupClosed,314 detectPopupClosed,
315 doOAuthConnectionCompleted,
295 noAuthorizationUrlMessage,316 noAuthorizationUrlMessage,
296 popupBlockedMessage,317 popupBlockedMessage,
297 popupClosedMessage,318 popupClosedMessage,