* 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
Unified · Split
| @@ -774,6 +774,7 @@ · 773 unmodified lines | ||
| 774 | 774 | "@executor-js/config": "workspace:*", |
| 775 | 775 | "@executor-js/sdk": "workspace:*", |
| 776 | 776 | "effect": "catalog:", |
| 777 | + "lucide-react": "^1.7.0", | |
| 777 | 778 | "openapi-types": "^12.1.3", |
| 778 | 779 | "yaml": "^2.7.1", |
| 779 | 780 | }, |
| @@ -46,6 +46,14 @@ export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentia · 45 unmodified lines | ||
| 46 | 46 | export type OAuth2Flow = typeof OAuth2Flow.Type; |
| 47 | 47 | export type OAuth2FlowType = OAuth2Flow; |
| 48 | 48 | |
| 49 | +export const OAuth2IdentityScopes = Schema.Union([ | |
| 50 | + Schema.Literal("auto"), | |
| 51 | + Schema.Literal(false), | |
| 52 | + Schema.Array(Schema.String), | |
| 53 | +]); | |
| 54 | +export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type; | |
| 55 | +export type OAuth2IdentityScopesType = OAuth2IdentityScopes; | |
| 56 | + | |
| 49 | 57 | export const OAuth2SourceConfig = Schema.Struct({ |
| 50 | 58 | kind: Schema.Literal("oauth2"), |
| 51 | 59 | securitySchemeName: Schema.String, |
| @@ -60,6 +68,10 @@ export const OAuth2SourceConfig = Schema.Struct({ · 8 unmodified lines | ||
| 60 | 68 | clientSecretSlot: Schema.NullOr(Schema.String), |
| 61 | 69 | connectionSlot: Schema.String, |
| 62 | 70 | scopes: Schema.Array(Schema.String), |
| 71 | + identityScopes: OAuth2IdentityScopes.pipe( | |
| 72 | + Schema.optional, | |
| 73 | + Schema.withDecodingDefault(Effect.succeed("auto" as const)), | |
| 74 | + ), | |
| 63 | 75 | }).annotate({ identifier: "OAuth2SourceConfig" }); |
| 64 | 76 | export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; |
| 65 | 77 | export type OAuth2SourceConfigType = OAuth2SourceConfig; |
| @@ -61,6 +61,7 @@ import { · 60 unmodified lines | ||
| 61 | 61 | OAuthSessionNotFoundError, |
| 62 | 62 | OAuthStartError, |
| 63 | 63 | type OAuthAuthorizationCodeStrategy, |
| 64 | + type OAuthAuthorizationCodeExistingClientStrategy, | |
| 64 | 65 | type OAuthClientCredentialsStrategy, |
| 65 | 66 | type OAuthCompleteInput, |
| 66 | 67 | type OAuthCompleteResult, |
| @@ -141,6 +142,8 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ · 74 unmodified lines | ||
| 141 | 142 | Schema.withDecodingDefaultType(Effect.succeed(null)), |
| 142 | 143 | ), |
| 143 | 144 | scopes: Schema.Array(Schema.String), |
| 145 | + authorizationScopes: Schema.optional(Schema.Array(Schema.String)), | |
| 146 | + storedScope: Schema.optional(Schema.String), | |
| 144 | 147 | scopeSeparator: Schema.optional(Schema.String), |
| 145 | 148 | clientAuth: Schema.Literals(["body", "basic"]), |
| 146 | 149 | }); |
| @@ -587,6 +590,10 @@ export const makeOAuth2Service = ( · 440 unmodified lines | ||
| 587 | 590 | const startAuthorizationCode = ( |
| 588 | 591 | input: OAuthStartInput, |
| 589 | 592 | strategy: OAuthAuthorizationCodeStrategy, |
| 593 | + options?: { | |
| 594 | + readonly authorizationScopes?: readonly string[]; | |
| 595 | + readonly storedScope?: string; | |
| 596 | + }, | |
| 590 | 597 | ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> => |
| 591 | 598 | Effect.gen(function* () { |
| 592 | 599 | const clientIdRef = yield* secretsGetResolvedAtScope({ |
| @@ -609,12 +616,19 @@ export const makeOAuth2Service = ( · 16 unmodified lines | ||
| 609 | 616 | const sessionId = scopedSessionId(input.tokenScope, newSessionId()); |
| 610 | 617 | const codeVerifier = createPkceCodeVerifier(); |
| 611 | 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 | 627 | const authorizationUrl = buildAuthorizationUrl({ |
| 614 | 628 | authorizationUrl: strategy.authorizationEndpoint, |
| 615 | 629 | clientId: clientIdRef.value, |
| 616 | 630 | redirectUrl: input.redirectUrl, |
| 617 | − scopes: strategy.scopes, | |
| 631 | + scopes: authorizationScopes, | |
| 618 | 632 | state: sessionId, |
| 619 | 633 | codeChallenge, |
| 620 | 634 | scopeSeparator: strategy.scopeSeparator, |
| @@ -639,6 +653,8 @@ export const makeOAuth2Service = ( · 18 unmodified lines | ||
| 639 | 653 | }))?.scopeId ?? null) |
| 640 | 654 | : null, |
| 641 | 655 | scopes: [...strategy.scopes], |
| 656 | + authorizationScopes: [...authorizationScopes], | |
| 657 | + storedScope, | |
| 642 | 658 | scopeSeparator: strategy.scopeSeparator, |
| 643 | 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 | 723 | const startClientCredentials = ( |
| 661 | 724 | input: OAuthStartInput, |
| 662 | 725 | strategy: OAuthClientCredentialsStrategy, |
| @@ -765,6 +828,9 @@ export const makeOAuth2Service = ( · 102 unmodified lines | ||
| 765 | 828 | Match.when({ kind: "authorization-code" }, (strategy) => |
| 766 | 829 | startAuthorizationCode(input, strategy), |
| 767 | 830 | ), |
| 831 | + Match.when({ kind: "authorization-code-existing-client" }, (strategy) => | |
| 832 | + startAuthorizationCodeWithExistingClient(input, strategy), | |
| 833 | + ), | |
| 768 | 834 | Match.when({ kind: "client-credentials" }, (strategy) => |
| 769 | 835 | startClientCredentials(input, strategy), |
| 770 | 836 | ), |
| @@ -875,6 +941,10 @@ export const makeOAuth2Service = ( · 104 unmodified lines | ||
| 875 | 941 | typeof exchangeResult.tokens.expires_in === "number" |
| 876 | 942 | ? now() + exchangeResult.tokens.expires_in * 1000 |
| 877 | 943 | : null; |
| 944 | + const effectiveOAuthScope = | |
| 945 | + payload.kind === "authorization-code" && payload.storedScope | |
| 946 | + ? payload.storedScope | |
| 947 | + : (exchangeResult.tokens.scope ?? null); | |
| 878 | 948 | |
| 879 | 949 | const dynamicClientSecretSecretId = yield* (() => { |
| 880 | 950 | if (payload.kind !== "dynamic-dcr") return Effect.succeed(null); |
| @@ -938,7 +1008,7 @@ export const makeOAuth2Service = ( · 57 unmodified lines | ||
| 938 | 1008 | : "body", |
| 939 | 1009 | clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null, |
| 940 | 1010 | scopes: [...payload.scopes], |
| 941 | − scope: exchangeResult.tokens.scope ?? null, | |
| 1011 | + scope: effectiveOAuthScope, | |
| 942 | 1012 | resource: payload.resource, |
| 943 | 1013 | } |
| 944 | 1014 | : { |
| @@ -950,9 +1020,9 @@ export const makeOAuth2Service = ( · 5 unmodified lines | ||
| 950 | 1020 | clientSecretSecretId: payload.clientSecretSecretId, |
| 951 | 1021 | clientSecretSecretScopeId: payload.clientSecretSecretScopeId, |
| 952 | 1022 | clientAuth: payload.clientAuth, |
| 953 | − scopes: [...payload.scopes], | |
| 1023 | + scopes: [...(payload.authorizationScopes ?? payload.scopes)], | |
| 954 | 1024 | scopeSeparator: payload.scopeSeparator, |
| 955 | − scope: exchangeResult.tokens.scope ?? null, | |
| 1025 | + scope: effectiveOAuthScope, | |
| 956 | 1026 | }; |
| 957 | 1027 | |
| 958 | 1028 | yield* deps |
| @@ -977,7 +1047,7 @@ export const makeOAuth2Service = ( · 18 unmodified lines | ||
| 977 | 1047 | }) |
| 978 | 1048 | : null, |
| 979 | 1049 | expiresAt: connectionExpiresAt, |
| 980 | − oauthScope: exchangeResult.tokens.scope ?? null, | |
| 1050 | + oauthScope: effectiveOAuthScope, | |
| 981 | 1051 | providerState: encodeProviderStateSync(providerState) as Record<string, unknown>, |
| 982 | 1052 | }), |
| 983 | 1053 | ) |
| @@ -1009,7 +1079,7 @@ export const makeOAuth2Service = ( · 25 unmodified lines | ||
| 1009 | 1079 | return { |
| 1010 | 1080 | connectionId, |
| 1011 | 1081 | expiresAt: connectionExpiresAt, |
| 1012 | − scope: exchangeResult.tokens.scope ?? null, | |
| 1082 | + scope: effectiveOAuthScope, | |
| 1013 | 1083 | }; |
| 1014 | 1084 | }); |
| 1015 | 1085 | |
| @@ -64,7 +64,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 63 unmodified lines | ||
| 64 | 64 | * PKCE without a confidential secret. */ |
| 65 | 65 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 66 | 66 | clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), |
| 67 | + /** Final scope set Executor should remember for this connection. */ | |
| 67 | 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 | 72 | /** Separator between scopes. RFC 6749 says space; some providers |
| 69 | 73 | * (GitHub classic) use comma. */ |
| 70 | 74 | scopeSeparator: Schema.optional(Schema.String), |
| @@ -77,6 +81,25 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ · 6 unmodified lines | ||
| 77 | 81 | }); |
| 78 | 82 | export 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. */ | |
| 87 | +export const OAuthAuthorizationCodeExistingClientStrategy = Schema.Struct({ | |
| 88 | + kind: Schema.Literal("authorization-code-existing-client"), | |
| 89 | + authorizationEndpoint: Schema.String, | |
| 90 | + tokenEndpoint: Schema.optional(Schema.String), | |
| 91 | + issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 92 | + /** Final scope set Executor should remember for this connection. */ | |
| 93 | + scopes: Schema.Array(Schema.String), | |
| 94 | + /** Optional smaller scope set to send to the authorization server. This is | |
| 95 | + * useful when one provider scope covers many source-level operation scopes. */ | |
| 96 | + authorizationScopes: Schema.optional(Schema.Array(Schema.String)), | |
| 97 | + scopeSeparator: Schema.optional(Schema.String), | |
| 98 | + extraAuthorizationParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | |
| 99 | +}); | |
| 100 | +export type OAuthAuthorizationCodeExistingClientStrategy = | |
| 101 | + typeof OAuthAuthorizationCodeExistingClientStrategy.Type; | |
| 102 | + | |
| 80 | 103 | /** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used |
| 81 | 104 | * for server-to-server integrations where the plugin has both |
| 82 | 105 | * `client_id` and `client_secret` and the server will mint tokens |
| @@ -100,6 +123,7 @@ export type OAuthClientCredentialsStrategy = typeof OAuthClientCredentialsStrate · 17 unmodified lines | ||
| 100 | 123 | export const OAuthStrategy = Schema.Union([ |
| 101 | 124 | OAuthDynamicDcrStrategy, |
| 102 | 125 | OAuthAuthorizationCodeStrategy, |
| 126 | + OAuthAuthorizationCodeExistingClientStrategy, | |
| 103 | 127 | OAuthClientCredentialsStrategy, |
| 104 | 128 | ]); |
| 105 | 129 | export type OAuthStrategy = typeof OAuthStrategy.Type; |
| @@ -61,12 +61,14 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 60 unmodified lines | ||
| 61 | 61 | tokenEndpoint: oauth.tokenEndpoint, |
| 62 | 62 | clientIdSecretId: "oauth-client-id", |
| 63 | 63 | clientSecretSecretId: "oauth-client-secret", |
| 64 | − scopes: ["read"], | |
| 64 | + scopes: ["read", "write"], | |
| 65 | + authorizationScopes: ["read"], | |
| 65 | 66 | }, |
| 66 | 67 | }); |
| 67 | 68 | |
| 68 | 69 | expect(started.authorizationUrl).not.toBeNull(); |
| 69 | 70 | const authorizationUrl = started.authorizationUrl ?? ""; |
| 71 | + expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("read"); | |
| 70 | 72 | const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl }); |
| 71 | 73 | const completed = yield* workspace.executor.oauth.complete({ |
| 72 | 74 | state: callback.state, |
| @@ -75,6 +77,93 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { · 2 unmodified lines | ||
| 75 | 77 | }); |
| 76 | 78 | |
| 77 | 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 | 167 | const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId); |
| 79 | 168 | expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true); |
| 80 | 169 | }), |
| @@ -40,6 +40,7 @@ export interface OAuthTestServerRequest { · 39 unmodified lines | ||
| 40 | 40 | readonly path: string; |
| 41 | 41 | readonly headers: Readonly<Record<string, string>>; |
| 42 | 42 | readonly body: string; |
| 43 | + readonly query: Readonly<Record<string, string>>; | |
| 43 | 44 | } |
| 44 | 45 | |
| 45 | 46 | export interface OAuthTestServerOptions { |
| @@ -435,6 +436,7 @@ export const serveOAuthTestServer = ( · 389 unmodified lines | ||
| 435 | 436 | path: requestUrl.pathname, |
| 436 | 437 | headers, |
| 437 | 438 | body, |
| 439 | + query: Object.fromEntries(requestUrl.searchParams.entries()), | |
| 438 | 440 | }, |
| 439 | 441 | ]); |
| 440 | 442 | |
| @@ -65,6 +65,7 @@ · 64 unmodified lines | ||
| 65 | 65 | "@executor-js/config": "workspace:*", |
| 66 | 66 | "@executor-js/sdk": "workspace:*", |
| 67 | 67 | "effect": "catalog:", |
| 68 | + "lucide-react": "^1.7.0", | |
| 68 | 69 | "openapi-types": "^12.1.3", |
| 69 | 70 | "yaml": "^2.7.1" |
| 70 | 71 | }, |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | −import { useAtomSet } from "@effect/atom-react"; | |
| 2 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Exit from "effect/Exit"; |
| 5 | 6 | import * as Match from "effect/Match"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 9 | +import { ChevronDownIcon } from "lucide-react"; | |
| 8 | 10 | |
| 9 | 11 | import { |
| 10 | 12 | ConnectionId, |
| @@ -12,7 +14,12 @@ import { · 1 unmodified line | ||
| 12 | 14 | SecretId, |
| 13 | 15 | SetSourceCredentialBindingInput, |
| 14 | 16 | } from "@executor-js/sdk/shared"; |
| 15 | −import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms"; | |
| 17 | +import { | |
| 18 | + connectionIdentityAtom, | |
| 19 | + connectionsAtom, | |
| 20 | + setSourceCredentialBinding, | |
| 21 | + startOAuth, | |
| 22 | +} from "@executor-js/react/api/atoms"; | |
| 16 | 23 | import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; |
| 17 | 24 | import { 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 | ||
| 65 | 72 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 66 | 73 | import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms"; |
| 67 | 74 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 75 | +import { openApiPresets } from "../sdk/presets"; | |
| 68 | 76 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 69 | 77 | import { |
| 70 | 78 | headerBindingSlot, |
| @@ -131,6 +139,123 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { · 60 unmodified lines | ||
| 131 | 139 | } |
| 132 | 140 | } |
| 133 | 141 | |
| 142 | +const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; | |
| 143 | + | |
| 144 | +const identityScopesForPreset = ( | |
| 145 | + identityScopes: OAuth2Preset["identityScopes"], | |
| 146 | +): readonly string[] => { | |
| 147 | + if (identityScopes === false) return []; | |
| 148 | + return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; | |
| 149 | +}; | |
| 150 | + | |
| 151 | +const resolvedOAuthScopes = ( | |
| 152 | + apiScopes: Iterable<string>, | |
| 153 | + identityScopes: OAuth2Preset["identityScopes"], | |
| 154 | +): string[] => { | |
| 155 | + const merged = new Set(apiScopes); | |
| 156 | + for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); | |
| 157 | + return [...merged]; | |
| 158 | +}; | |
| 159 | + | |
| 160 | +const splitOAuthScopes = (value: string | null): Set<string> => | |
| 161 | + new Set(value?.split(/\s+/).filter(Boolean) ?? []); | |
| 162 | + | |
| 163 | +const mergeOAuthScopes = (...values: readonly Iterable<string>[]): string[] => { | |
| 164 | + const merged = new Set<string>(); | |
| 165 | + for (const scopes of values) { | |
| 166 | + for (const scope of scopes) { | |
| 167 | + if (scope.trim()) merged.add(scope); | |
| 168 | + } | |
| 169 | + } | |
| 170 | + return [...merged]; | |
| 171 | +}; | |
| 172 | + | |
| 173 | +const missingOAuthScopes = ( | |
| 174 | + connection: { readonly oauthScope: string | null }, | |
| 175 | + requiredApiScopes: Iterable<string>, | |
| 176 | +): readonly string[] => { | |
| 177 | + const granted = splitOAuthScopes(connection.oauthScope); | |
| 178 | + return [...requiredApiScopes].filter((scope) => !granted.has(scope)); | |
| 179 | +}; | |
| 180 | + | |
| 181 | +const isGoogleOAuthScope = (scope: string): boolean => | |
| 182 | + scope === "https://mail.google.com/" || | |
| 183 | + scope.startsWith("https://www.googleapis.com/auth/") || | |
| 184 | + scope.startsWith("https://www.google.com/m8/feeds/"); | |
| 185 | + | |
| 186 | +const hasGoogleOAuthScope = (connection: { readonly oauthScope: string | null }): boolean => | |
| 187 | + [...splitOAuthScopes(connection.oauthScope)].some(isGoogleOAuthScope); | |
| 188 | + | |
| 189 | +const isGoogleOAuthUrl = (url: string): boolean => { | |
| 190 | + if (!URL.canParse(url)) return false; | |
| 191 | + const host = new URL(url).hostname.toLowerCase(); | |
| 192 | + return host === "accounts.google.com" || host === "oauth2.googleapis.com"; | |
| 193 | +}; | |
| 194 | + | |
| 195 | +const isGoogleOAuthTarget = (preset: OAuth2Preset, baseUrl: string, specUrl: string): boolean => { | |
| 196 | + if (isGoogleDiscoveryUrl(specUrl)) return true; | |
| 197 | + if (Object.keys(preset.scopes).some(isGoogleOAuthScope)) return true; | |
| 198 | + if (isGoogleOAuthUrl(resolveOAuthUrl(preset.tokenUrl, baseUrl))) return true; | |
| 199 | + const authorizationUrl = Option.getOrElse(preset.authorizationUrl, () => ""); | |
| 200 | + return authorizationUrl ? isGoogleOAuthUrl(resolveOAuthUrl(authorizationUrl, baseUrl)) : false; | |
| 201 | +}; | |
| 202 | + | |
| 203 | +const googleAuthorizationParams = (enabled: boolean): Record<string, string> | undefined => | |
| 204 | + enabled | |
| 205 | + ? { | |
| 206 | + access_type: "offline", | |
| 207 | + } | |
| 208 | + : undefined; | |
| 209 | + | |
| 210 | +const googleBroadScopeGroups: readonly { | |
| 211 | + readonly broad: string; | |
| 212 | + readonly prefixes: readonly string[]; | |
| 213 | +}[] = [ | |
| 214 | + { | |
| 215 | + broad: "https://mail.google.com/", | |
| 216 | + prefixes: ["https://www.googleapis.com/auth/gmail."], | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + broad: "https://www.googleapis.com/auth/calendar", | |
| 220 | + prefixes: ["https://www.googleapis.com/auth/calendar."], | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + broad: "https://www.googleapis.com/auth/drive", | |
| 224 | + prefixes: ["https://www.googleapis.com/auth/drive."], | |
| 225 | + }, | |
| 226 | +]; | |
| 227 | + | |
| 228 | +const compactGoogleOAuthScopes = (scopes: Iterable<string>): string[] => { | |
| 229 | + const ordered = mergeOAuthScopes( | |
| 230 | + [...scopes].map((scope) => | |
| 231 | + scope === "https://www.googleapis.com/auth/userinfo.email" | |
| 232 | + ? "email" | |
| 233 | + : scope === "https://www.googleapis.com/auth/userinfo.profile" | |
| 234 | + ? "profile" | |
| 235 | + : scope, | |
| 236 | + ), | |
| 237 | + ); | |
| 238 | + const present = new Set(ordered); | |
| 239 | + return ordered.filter( | |
| 240 | + (scope) => | |
| 241 | + !googleBroadScopeGroups.some( | |
| 242 | + (group) => | |
| 243 | + scope !== group.broad && | |
| 244 | + present.has(group.broad) && | |
| 245 | + group.prefixes.some((prefix) => scope.startsWith(prefix)), | |
| 246 | + ), | |
| 247 | + ); | |
| 248 | +}; | |
| 249 | + | |
| 250 | +type OAuthConnectionChoice = { | |
| 251 | + readonly id: ConnectionId; | |
| 252 | + readonly scopeId: ScopeId; | |
| 253 | + readonly provider: string; | |
| 254 | + readonly identityLabel: string | null; | |
| 255 | + readonly oauthScope: string | null; | |
| 256 | + readonly missingApiScopes: readonly string[]; | |
| 257 | +}; | |
| 258 | + | |
| 134 | 259 | const specInputForAdd = (input: string) => { |
| 135 | 260 | const value = input.trim(); |
| 136 | 261 | const parsed = Effect.runSyncExit( |
| @@ -155,6 +280,15 @@ const isGoogleDiscoveryUrl = (url: string): boolean => { · 18 unmodified lines | ||
| 155 | 280 | return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); |
| 156 | 281 | }; |
| 157 | 282 | |
| 283 | +const normalizePresetUrl = (url: string): string => { | |
| 284 | + const trimmed = url.trim(); | |
| 285 | + if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); | |
| 286 | + const parsed = new URL(trimmed); | |
| 287 | + parsed.hash = ""; | |
| 288 | + parsed.searchParams.sort(); | |
| 289 | + return parsed.toString().replace(/\/$/, ""); | |
| 290 | +}; | |
| 291 | + | |
| 158 | 292 | type StrategySelection = |
| 159 | 293 | | { readonly kind: "none" } |
| 160 | 294 | | { readonly kind: "custom" } |
| @@ -214,6 +348,119 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 53 unmodified lines | ||
| 214 | 348 | }); |
| 215 | 349 | } |
| 216 | 350 | |
| 351 | +function OAuthConnectedAccount(props: { | |
| 352 | + readonly scopeId: ScopeId; | |
| 353 | + readonly connectionId: string; | |
| 354 | + readonly scopeSummary: string; | |
| 355 | + readonly sourceName: string; | |
| 356 | + readonly onSetSourceName: (name: string) => void; | |
| 357 | +}) { | |
| 358 | + const identityResult = useAtomValue( | |
| 359 | + connectionIdentityAtom(props.scopeId, ConnectionId.make(props.connectionId)), | |
| 360 | + ); | |
| 361 | + const identityResponse = AsyncResult.isSuccess(identityResult) ? identityResult.value : null; | |
| 362 | + const identity = identityResponse?.status === "available" ? identityResponse : null; | |
| 363 | + const accountLabel = identity?.email ?? identity?.name ?? identity?.username ?? null; | |
| 364 | + const sourceNameWithAccount = | |
| 365 | + accountLabel && !props.sourceName.includes(accountLabel) | |
| 366 | + ? `${props.sourceName} - ${accountLabel}` | |
| 367 | + : props.sourceName; | |
| 368 | + const accountIsInSourceName = accountLabel !== null && props.sourceName === sourceNameWithAccount; | |
| 369 | + return ( | |
| 370 | + <div className="flex min-w-0 flex-1 items-center gap-3"> | |
| 371 | + <div className="min-w-0 flex-1"> | |
| 372 | + <div className="flex min-w-0 items-center gap-2"> | |
| 373 | + {identity?.picture ? ( | |
| 374 | + <img | |
| 375 | + src={identity.picture} | |
| 376 | + alt="" | |
| 377 | + referrerPolicy="no-referrer" | |
| 378 | + className="size-5 shrink-0 rounded-full" | |
| 379 | + /> | |
| 380 | + ) : null} | |
| 381 | + <span className="min-w-0 truncate text-foreground"> | |
| 382 | + {accountLabel ? `Connected as ${accountLabel}` : "Connected"} | |
| 383 | + <span className="text-muted-foreground"> · {props.scopeSummary}</span> | |
| 384 | + </span> | |
| 385 | + </div> | |
| 386 | + {accountLabel ? ( | |
| 387 | + <div className="mt-0.5 truncate text-[11px] text-muted-foreground"> | |
| 388 | + Source name: {sourceNameWithAccount} | |
| 389 | + </div> | |
| 390 | + ) : null} | |
| 391 | + </div> | |
| 392 | + {accountLabel ? ( | |
| 393 | + <Button | |
| 394 | + variant="secondary" | |
| 395 | + size="sm" | |
| 396 | + className="h-6 shrink-0 px-2 text-[11px]" | |
| 397 | + disabled={accountIsInSourceName} | |
| 398 | + onClick={() => props.onSetSourceName(sourceNameWithAccount)} | |
| 399 | + > | |
| 400 | + {accountIsInSourceName ? "Name includes account" : "Add account name to source name"} | |
| 401 | + </Button> | |
| 402 | + ) : null} | |
| 403 | + </div> | |
| 404 | + ); | |
| 405 | +} | |
| 406 | + | |
| 407 | +function ExistingOAuthConnectionOption(props: { | |
| 408 | + readonly connection: OAuthConnectionChoice; | |
| 409 | + readonly selected: boolean; | |
| 410 | + readonly onSelect: () => void; | |
| 411 | + readonly providerLabel: string; | |
| 412 | +}) { | |
| 413 | + const identityResult = useAtomValue( | |
| 414 | + connectionIdentityAtom(props.connection.scopeId, props.connection.id), | |
| 415 | + ); | |
| 416 | + const identity = | |
| 417 | + AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available" | |
| 418 | + ? identityResult.value | |
| 419 | + : null; | |
| 420 | + const label = | |
| 421 | + identity?.email ?? identity?.name ?? props.connection.identityLabel ?? props.connection.id; | |
| 422 | + const picture = identity?.picture; | |
| 423 | + const needsPermission = props.connection.missingApiScopes.length > 0; | |
| 424 | + return ( | |
| 425 | + <Button | |
| 426 | + type="button" | |
| 427 | + variant="ghost" | |
| 428 | + onClick={props.onSelect} | |
| 429 | + className={`h-auto w-full justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors ${ | |
| 430 | + props.selected | |
| 431 | + ? "border-primary/40 bg-primary/10" | |
| 432 | + : "border-border/60 bg-background hover:border-border hover:bg-muted/30" | |
| 433 | + }`} | |
| 434 | + > | |
| 435 | + <span className="flex min-w-0 items-center gap-2"> | |
| 436 | + {picture ? ( | |
| 437 | + <img | |
| 438 | + src={picture} | |
| 439 | + alt="" | |
| 440 | + referrerPolicy="no-referrer" | |
| 441 | + className="size-5 shrink-0 rounded-full" | |
| 442 | + /> | |
| 443 | + ) : ( | |
| 444 | + <span className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] text-muted-foreground"> | |
| 445 | + {label.slice(0, 1).toUpperCase()} | |
| 446 | + </span> | |
| 447 | + )} | |
| 448 | + <span className="min-w-0"> | |
| 449 | + <span className="block truncate text-[12px] text-foreground">{label}</span> | |
| 450 | + <span className="block truncate text-[10px] text-muted-foreground"> | |
| 451 | + {needsPermission | |
| 452 | + ? `Needs ${props.providerLabel} permission` | |
| 453 | + : (props.connection.identityLabel ?? "Already connected")} | |
| 454 | + </span> | |
| 455 | + </span> | |
| 456 | + </span> | |
| 457 | + <span className="shrink-0 text-[11px] font-medium text-foreground"> | |
| 458 | + {props.selected ? "Selected" : needsPermission ? "Continue" : "Use account"} | |
| 459 | + </span> | |
| 460 | + </Button> | |
| 461 | + ); | |
| 462 | +} | |
| 463 | + | |
| 217 | 464 | const secretStorageDescription = (label: string): string => |
| 218 | 465 | label === "Personal" |
| 219 | 466 | ? "Only you can use this secret." |
| @@ -259,6 +506,8 @@ export default function AddOpenApiSource(props: { · 39 unmodified lines | ||
| 259 | 506 | const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null); |
| 260 | 507 | const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null); |
| 261 | 508 | const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set()); |
| 509 | + const [includeOAuth2IdentityScopes, setIncludeOAuth2IdentityScopes] = useState(true); | |
| 510 | + const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false); | |
| 262 | 511 | const [oauth2AuthState, setOauth2AuthState] = useState<{ |
| 263 | 512 | readonly fingerprint: string; |
| 264 | 513 | readonly auth: { readonly connectionId: string }; |
| @@ -318,6 +567,7 @@ export default function AddOpenApiSource(props: { · 53 unmodified lines | ||
| 318 | 567 | const doSetBinding = useAtomSet(setSourceCredentialBinding, { |
| 319 | 568 | mode: "promiseExit", |
| 320 | 569 | }); |
| 570 | + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); | |
| 321 | 571 | const secretList = useSecretPickerSecrets(); |
| 322 | 572 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 323 | 573 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -355,6 +605,9 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines | ||
| 355 | 605 | const baseUrlOptions = Array.from( |
| 356 | 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 | 612 | const resolvedBaseUrl = baseUrl.trim(); |
| 360 | 613 | const sourceScope = ScopeId.make(scopeId); |
| @@ -460,11 +713,13 @@ export default function AddOpenApiSource(props: { · 99 unmodified lines | ||
| 460 | 713 | const resolvedSourceId = |
| 461 | 714 | slugifyNamespace(identity.namespace) || |
| 462 | 715 | (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); |
| 716 | + const resolvedDisplayName = | |
| 717 | + identity.name.trim() || | |
| 718 | + (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); | |
| 463 | 719 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 464 | 720 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; |
| 465 | 721 | const selectedOAuth2Fingerprint = selectedOAuth2Preset |
| 466 | 722 | ? [ |
| 467 | − resolvedSourceId, | |
| 468 | 723 | resolvedBaseUrl, |
| 469 | 724 | selectedOAuth2Preset.securitySchemeName, |
| 470 | 725 | selectedOAuth2Preset.flow, |
| @@ -474,6 +729,46 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 474 | 729 | : ""; |
| 475 | 730 | const oauth2Auth = |
| 476 | 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 | 773 | const configuredOAuth2 = |
| 479 | 774 | strategy.kind === "oauth2" && selectedOAuth2Preset |
| @@ -496,6 +791,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | ||
| 496 | 791 | clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), |
| 497 | 792 | connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), |
| 498 | 793 | scopes: [...oauth2SelectedScopes], |
| 794 | + identityScopes: configuredOAuth2IdentityScopes, | |
| 499 | 795 | }) |
| 500 | 796 | : null; |
| 501 | 797 | const hasHeaders = Object.keys(configuredHeaders).length > 0; |
| @@ -554,6 +850,7 @@ export default function AddOpenApiSource(props: { · 52 unmodified lines | ||
| 554 | 850 | setStrategy({ kind: "oauth2", presetIndex: 0 }); |
| 555 | 851 | setCustomHeaders([]); |
| 556 | 852 | setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes))); |
| 853 | + setIncludeOAuth2IdentityScopes(result.oauth2Presets[0].identityScopes !== false); | |
| 557 | 854 | } else { |
| 558 | 855 | // No header presets — default to "custom" so the headers editor is |
| 559 | 856 | // visible immediately. Specs with no `security` block (e.g. Microsoft |
| @@ -593,6 +890,7 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines | ||
| 593 | 890 | const preset = preview?.oauth2Presets[n.presetIndex]; |
| 594 | 891 | if (preset) { |
| 595 | 892 | setOauth2SelectedScopes(new Set(Object.keys(preset.scopes))); |
| 893 | + setIncludeOAuth2IdentityScopes(preset.identityScopes !== false); | |
| 596 | 894 | } |
| 597 | 895 | }), |
| 598 | 896 | Match.exhaustive, |
| @@ -617,141 +915,183 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines | ||
| 617 | 915 | setOauth2AuthState(null); |
| 618 | 916 | }; |
| 619 | 917 | |
| 620 | − const handleConnectOAuth2 = useCallback(async () => { | |
| 621 | − if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return; | |
| 622 | − oauth.cancel(); | |
| 623 | − setOauth2Error(null); | |
| 624 | − const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; | |
| 625 | − | |
| 626 | − const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); | |
| 627 | − const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; | |
| 628 | − const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; | |
| 629 | − | |
| 630 | − if (selectedOAuth2Preset.flow === "clientCredentials") { | |
| 631 | − // RFC 6749 §4.4: no user-interactive consent step. The client_secret | |
| 632 | − // is mandatory; the backend exchanges tokens inline and returns a | |
| 633 | − // completed Connection we bind to the source's connection slot. | |
| 634 | − if (!oauth2ClientSecretSecretId) { | |
| 635 | − setOauth2Error("client_credentials requires a client secret"); | |
| 636 | − return; | |
| 637 | − } | |
| 638 | − setStartingOAuth(true); | |
| 639 | − const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); | |
| 640 | − const exit = await doStartOAuth({ | |
| 641 | − params: { scopeId: oauthTokenTargetScope }, | |
| 642 | − payload: { | |
| 643 | − endpoint: tokenUrl, | |
| 644 | − redirectUrl: tokenUrl, | |
| 645 | − connectionId, | |
| 646 | − tokenScope: oauthTokenTargetScope, | |
| 647 | − strategy: { | |
| 648 | − kind: "client-credentials", | |
| 649 | − tokenEndpoint: tokenUrl, | |
| 650 | − clientIdSecretId: oauth2ClientIdSecretId, | |
| 651 | − clientIdSecretScopeId: String(clientIdSecretScope), | |
| 652 | − clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 653 | − clientSecretSecretScopeId: String(clientSecretSecretScope), | |
| 654 | − scopes: [...oauth2SelectedScopes], | |
| 655 | − }, | |
| 656 | − pluginId: "openapi", | |
| 657 | − identityLabel: `${displayName} OAuth`, | |
| 658 | − }, | |
| 659 | − }); | |
| 660 | − setStartingOAuth(false); | |
| 661 | − if (Exit.isFailure(exit)) { | |
| 662 | − setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 663 | − return; | |
| 664 | − } | |
| 665 | − const response = exit.value; | |
| 666 | − if (!response.completedConnection) { | |
| 667 | − setOauth2Error("client_credentials flow did not mint a connection"); | |
| 668 | − return; | |
| 669 | − } | |
| 670 | − setOauth2AuthState({ | |
| 671 | − fingerprint: selectedOAuth2Fingerprint, | |
| 672 | − auth: { connectionId: response.completedConnection.connectionId }, | |
| 673 | − }); | |
| 918 | + const handleConnectOAuth2 = useCallback( | |
| 919 | + async (existingConnection?: OAuthConnectionChoice) => { | |
| 920 | + if (!selectedOAuth2Preset || !preview) return; | |
| 921 | + oauth.cancel(); | |
| 674 | 922 | setOauth2Error(null); |
| 675 | − return; | |
| 676 | − } | |
| 677 | − | |
| 678 | − const authorizationUrl = resolveOAuthUrl( | |
| 679 | − Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), | |
| 680 | − resolvedBaseUrl, | |
| 681 | − ); | |
| 682 | − const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); | |
| 683 | − | |
| 684 | − await oauth.openAuthorization({ | |
| 685 | − tokenScope: oauthTokenTargetScope, | |
| 686 | − run: async () => { | |
| 923 | + const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; | |
| 924 | + const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope; | |
| 925 | + const connectionId = | |
| 926 | + existingConnection?.id ?? | |
| 927 | + openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); | |
| 928 | + const scopesForAuthorization = existingConnection | |
| 929 | + ? mergeOAuthScopes( | |
| 930 | + splitOAuthScopes(existingConnection.oauthScope), | |
| 931 | + selectedOAuth2IsGoogle ? oauth2SelectedScopes : selectedOAuth2Scopes, | |
| 932 | + ) | |
| 933 | + : selectedOAuth2Scopes; | |
| 934 | + const scopesForProviderAuthorization = selectedOAuth2IsGoogle | |
| 935 | + ? compactGoogleOAuthScopes(scopesForAuthorization) | |
| 936 | + : undefined; | |
| 937 | + const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle); | |
| 938 | + | |
| 939 | + const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); | |
| 940 | + const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; | |
| 941 | + const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; | |
| 942 | + | |
| 943 | + if (selectedOAuth2Preset.flow === "clientCredentials") { | |
| 944 | + if (!oauth2ClientIdSecretId) return; | |
| 945 | + // RFC 6749 §4.4: no user-interactive consent step. The client_secret | |
| 946 | + // is mandatory; the backend exchanges tokens inline and returns a | |
| 947 | + // completed Connection we bind to the source's connection slot. | |
| 948 | + if (!oauth2ClientSecretSecretId) { | |
| 949 | + setOauth2Error("client_credentials requires a client secret"); | |
| 950 | + return; | |
| 951 | + } | |
| 952 | + setStartingOAuth(true); | |
| 687 | 953 | const exit = await doStartOAuth({ |
| 688 | − params: { scopeId: oauthTokenTargetScope }, | |
| 954 | + params: { scopeId: tokenTargetScope }, | |
| 689 | 955 | payload: { |
| 690 | − endpoint: authorizationUrl, | |
| 691 | − connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), | |
| 692 | − tokenScope: oauthTokenTargetScope, | |
| 693 | − redirectUrl: oauth2RedirectUrl, | |
| 956 | + endpoint: tokenUrl, | |
| 957 | + redirectUrl: tokenUrl, | |
| 958 | + connectionId, | |
| 959 | + tokenScope: tokenTargetScope, | |
| 694 | 960 | strategy: { |
| 695 | − kind: "authorization-code", | |
| 696 | − authorizationEndpoint: authorizationUrl, | |
| 961 | + kind: "client-credentials", | |
| 697 | 962 | tokenEndpoint: tokenUrl, |
| 698 | − issuerUrl, | |
| 699 | 963 | clientIdSecretId: oauth2ClientIdSecretId, |
| 700 | 964 | clientIdSecretScopeId: String(clientIdSecretScope), |
| 701 | − clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | |
| 702 | − clientSecretSecretScopeId: oauth2ClientSecretSecretId | |
| 703 | − ? String(clientSecretSecretScope) | |
| 704 | − : null, | |
| 705 | − scopes: [...oauth2SelectedScopes], | |
| 965 | + clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 966 | + clientSecretSecretScopeId: String(clientSecretSecretScope), | |
| 967 | + scopes: scopesForAuthorization, | |
| 706 | 968 | }, |
| 707 | 969 | pluginId: "openapi", |
| 708 | 970 | identityLabel: `${displayName} OAuth`, |
| 709 | 971 | }, |
| 710 | 972 | }); |
| 973 | + setStartingOAuth(false); | |
| 711 | 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() | |
| 713 | − throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 975 | + setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 976 | + return; | |
| 714 | 977 | } |
| 715 | 978 | const response = exit.value; |
| 716 | − if (response.authorizationUrl === null) { | |
| 717 | − // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 718 | − throw new Error("Unexpected response flow from server"); | |
| 979 | + if (!response.completedConnection) { | |
| 980 | + setOauth2Error("client_credentials flow did not mint a connection"); | |
| 981 | + return; | |
| 719 | 982 | } |
| 720 | − return { | |
| 721 | − sessionId: response.sessionId, | |
| 722 | − authorizationUrl: response.authorizationUrl, | |
| 723 | − }; | |
| 724 | − }, | |
| 725 | − onSuccess: (result) => { | |
| 983 | + setOAuthTokenTargetScope(tokenTargetScope); | |
| 726 | 984 | setOauth2AuthState({ |
| 727 | 985 | fingerprint: selectedOAuth2Fingerprint, |
| 728 | − auth: { connectionId: result.connectionId }, | |
| 986 | + auth: { connectionId: response.completedConnection.connectionId }, | |
| 729 | 987 | }); |
| 730 | 988 | setOauth2Error(null); |
| 731 | − }, | |
| 732 | − onError: (message) => { | |
| 733 | − setStartingOAuth(false); | |
| 734 | − setOauth2Error(message); | |
| 735 | − }, | |
| 736 | − }); | |
| 737 | − }, [ | |
| 738 | − selectedOAuth2Preset, | |
| 739 | − oauth2ClientIdSecretId, | |
| 740 | − oauth2ClientSecretSecretId, | |
| 741 | − oauth2SelectedScopes, | |
| 742 | − oauth2RedirectUrl, | |
| 743 | − resolvedBaseUrl, | |
| 744 | − preview, | |
| 745 | − doStartOAuth, | |
| 746 | − identity.name, | |
| 747 | − resolvedSourceId, | |
| 748 | − selectedOAuth2Fingerprint, | |
| 749 | − oauth, | |
| 750 | − oauthTokenTargetScope, | |
| 751 | − oauth2ClientIdScope, | |
| 752 | − oauth2ClientSecretScope, | |
| 753 | − sourceScope, | |
| 754 | − ]); | |
| 989 | + return; | |
| 990 | + } | |
| 991 | + if (!existingConnection && !oauth2ClientIdSecretId) return; | |
| 992 | + | |
| 993 | + const authorizationUrl = resolveOAuthUrl( | |
| 994 | + Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), | |
| 995 | + resolvedBaseUrl, | |
| 996 | + ); | |
| 997 | + const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); | |
| 998 | + const authorizationStrategy = existingConnection | |
| 999 | + ? { | |
| 1000 | + kind: "authorization-code-existing-client" as const, | |
| 1001 | + authorizationEndpoint: authorizationUrl, | |
| 1002 | + tokenEndpoint: tokenUrl, | |
| 1003 | + issuerUrl, | |
| 1004 | + scopes: scopesForAuthorization, | |
| 1005 | + ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 | |
| 1006 | + ? { authorizationScopes: scopesForProviderAuthorization } | |
| 1007 | + : {}), | |
| 1008 | + extraAuthorizationParams, | |
| 1009 | + } | |
| 1010 | + : oauth2ClientIdSecretId | |
| 1011 | + ? { | |
| 1012 | + kind: "authorization-code" as const, | |
| 1013 | + authorizationEndpoint: authorizationUrl, | |
| 1014 | + tokenEndpoint: tokenUrl, | |
| 1015 | + issuerUrl, | |
| 1016 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 1017 | + clientIdSecretScopeId: String(clientIdSecretScope), | |
| 1018 | + clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | |
| 1019 | + clientSecretSecretScopeId: oauth2ClientSecretSecretId | |
| 1020 | + ? String(clientSecretSecretScope) | |
| 1021 | + : null, | |
| 1022 | + scopes: scopesForAuthorization, | |
| 1023 | + ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 | |
| 1024 | + ? { authorizationScopes: scopesForProviderAuthorization } | |
| 1025 | + : {}), | |
| 1026 | + extraAuthorizationParams, | |
| 1027 | + } | |
| 1028 | + : null; | |
| 1029 | + if (!authorizationStrategy) return; | |
| 1030 | + | |
| 1031 | + await oauth.openAuthorization({ | |
| 1032 | + tokenScope: tokenTargetScope, | |
| 1033 | + run: async () => { | |
| 1034 | + const exit = await doStartOAuth({ | |
| 1035 | + params: { scopeId: tokenTargetScope }, | |
| 1036 | + payload: { | |
| 1037 | + endpoint: authorizationUrl, | |
| 1038 | + connectionId, | |
| 1039 | + tokenScope: tokenTargetScope, | |
| 1040 | + redirectUrl: oauth2RedirectUrl, | |
| 1041 | + strategy: authorizationStrategy, | |
| 1042 | + pluginId: "openapi", | |
| 1043 | + identityLabel: existingConnection?.identityLabel ?? `${displayName} OAuth`, | |
| 1044 | + }, | |
| 1045 | + }); | |
| 1046 | + if (Exit.isFailure(exit)) { | |
| 1047 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 1048 | + throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 1049 | + } | |
| 1050 | + const response = exit.value; | |
| 1051 | + if (response.authorizationUrl === null) { | |
| 1052 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 1053 | + throw new Error("Unexpected response flow from server"); | |
| 1054 | + } | |
| 1055 | + return { | |
| 1056 | + sessionId: response.sessionId, | |
| 1057 | + authorizationUrl: response.authorizationUrl, | |
| 1058 | + }; | |
| 1059 | + }, | |
| 1060 | + onSuccess: (result) => { | |
| 1061 | + setOAuthTokenTargetScope(tokenTargetScope); | |
| 1062 | + setOauth2AuthState({ | |
| 1063 | + fingerprint: selectedOAuth2Fingerprint, | |
| 1064 | + auth: { connectionId: result.connectionId }, | |
| 1065 | + }); | |
| 1066 | + setOauth2Error(null); | |
| 1067 | + }, | |
| 1068 | + onError: (message) => { | |
| 1069 | + setStartingOAuth(false); | |
| 1070 | + setOauth2Error(message); | |
| 1071 | + }, | |
| 1072 | + }); | |
| 1073 | + }, | |
| 1074 | + [ | |
| 1075 | + selectedOAuth2Preset, | |
| 1076 | + oauth2ClientIdSecretId, | |
| 1077 | + oauth2ClientSecretSecretId, | |
| 1078 | + oauth2SelectedScopes, | |
| 1079 | + selectedOAuth2Scopes, | |
| 1080 | + selectedOAuth2IsGoogle, | |
| 1081 | + oauth2RedirectUrl, | |
| 1082 | + resolvedBaseUrl, | |
| 1083 | + preview, | |
| 1084 | + doStartOAuth, | |
| 1085 | + identity.name, | |
| 1086 | + resolvedSourceId, | |
| 1087 | + selectedOAuth2Fingerprint, | |
| 1088 | + oauth, | |
| 1089 | + oauthTokenTargetScope, | |
| 1090 | + oauth2ClientIdScope, | |
| 1091 | + oauth2ClientSecretScope, | |
| 1092 | + sourceScope, | |
| 1093 | + ], | |
| 1094 | + ); | |
| 755 | 1095 | |
| 756 | 1096 | const handleCancelOAuth2 = useCallback(() => { |
| 757 | 1097 | oauth.cancel(); |
| @@ -759,18 +1099,29 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 759 | 1099 | setOauth2Error(null); |
| 760 | 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 | 1116 | const handleAdd = async () => { |
| 763 | 1117 | setAdding(true); |
| 764 | 1118 | setAddError(null); |
| 765 | 1119 | const namespace = resolvedSourceId; |
| 766 | − const displayName = | |
| 767 | − identity.name.trim() || | |
| 768 | − (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); | |
| 769 | 1120 | const exit = await doAdd({ |
| 770 | 1121 | params: { scopeId }, |
| 771 | 1122 | payload: { |
| 772 | 1123 | spec: specInputForAdd(specUrl), |
| 773 | − name: displayName, | |
| 1124 | + name: resolvedDisplayName, | |
| 774 | 1125 | namespace, |
| 775 | 1126 | baseUrl: resolvedBaseUrl, |
| 776 | 1127 | ...(configuredSpecFetchCredentials |
| @@ -1021,6 +1372,7 @@ export default function AddOpenApiSource(props: { · 244 unmodified lines | ||
| 1021 | 1372 | setOauth2AuthState(null); |
| 1022 | 1373 | setOauth2Error(null); |
| 1023 | 1374 | }} |
| 1375 | + faviconIcon={previewPresetIcon} | |
| 1024 | 1376 | faviconUrl={resolvedBaseUrl} |
| 1025 | 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 | 1509 | <CopyButton value={oauth2RedirectUrl} /> |
| 1158 | 1510 | </div> |
| 1159 | 1511 | </div> |
| 1160 | − <div className="space-y-1.5"> | |
| 1161 | − <FieldLabel className="text-[11px]">Client ID secret</FieldLabel> | |
| 1162 | − <div className="grid gap-2 md:grid-cols-2"> | |
| 1512 | + <div className="grid gap-3 md:grid-cols-2"> | |
| 1513 | + <div className="space-y-2"> | |
| 1163 | 1514 | <div className="space-y-1.5"> |
| 1164 | − <div className="flex items-center gap-1.5"> | |
| 1165 | − <FieldLabel className="text-[11px]">Secret</FieldLabel> | |
| 1166 | − <HelpTooltip label="Client ID secret"> | |
| 1167 | − Select or create the OAuth client ID secret. | |
| 1168 | − </HelpTooltip> | |
| 1515 | + <div className="space-y-0.5"> | |
| 1516 | + <div className="flex items-center gap-1.5"> | |
| 1517 | + <FieldLabel className="text-[11px]">Client ID</FieldLabel> | |
| 1518 | + <HelpTooltip label="Client ID secret"> | |
| 1519 | + Select or create the OAuth client ID secret. | |
| 1520 | + </HelpTooltip> | |
| 1521 | + </div> | |
| 1522 | + <div | |
| 1523 | + aria-hidden | |
| 1524 | + className="invisible text-[10px] leading-tight text-muted-foreground" | |
| 1525 | + > | |
| 1526 | + Required OAuth client identifier. | |
| 1527 | + </div> | |
| 1169 | 1528 | </div> |
| 1170 | 1529 | <CreatableSecretPicker |
| 1171 | 1530 | value={oauth2ClientIdSecretId} |
| @@ -1194,21 +1553,18 @@ export default function AddOpenApiSource(props: { · 22 unmodified lines | ||
| 1194 | 1553 | help="Choose where this OAuth client ID credential lives." |
| 1195 | 1554 | /> |
| 1196 | 1555 | </div> |
| 1197 | − </div> | |
| 1198 | − <div className="space-y-1.5"> | |
| 1199 | − <FieldLabel className="text-[11px]"> | |
| 1200 | − Client secret{" "} | |
| 1201 | − <span className="text-muted-foreground"> | |
| 1202 | − · optional for public clients with PKCE | |
| 1203 | − </span> | |
| 1204 | − </FieldLabel> | |
| 1205 | − <div className="grid gap-2 md:grid-cols-2"> | |
| 1556 | + <div className="space-y-2"> | |
| 1206 | 1557 | <div className="space-y-1.5"> |
| 1207 | − <div className="flex items-center gap-1.5"> | |
| 1208 | − <FieldLabel className="text-[11px]">Secret</FieldLabel> | |
| 1209 | − <HelpTooltip label="Client secret"> | |
| 1210 | − Select or create the OAuth client secret. | |
| 1211 | − </HelpTooltip> | |
| 1558 | + <div className="space-y-0.5"> | |
| 1559 | + <div className="flex items-center gap-1.5"> | |
| 1560 | + <FieldLabel className="text-[11px]">Client Secret</FieldLabel> | |
| 1561 | + <HelpTooltip label="Client secret"> | |
| 1562 | + Select or create the OAuth client secret. | |
| 1563 | + </HelpTooltip> | |
| 1564 | + </div> | |
| 1565 | + <div className="text-[10px] leading-tight text-muted-foreground"> | |
| 1566 | + Optional for public clients with PKCE. | |
| 1567 | + </div> | |
| 1212 | 1568 | </div> |
| 1213 | 1569 | <CreatableSecretPicker |
| 1214 | 1570 | value={oauth2ClientSecretSecretId} |
| @@ -1238,39 +1594,101 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | ||
| 1238 | 1594 | /> |
| 1239 | 1595 | </div> |
| 1240 | 1596 | </div> |
| 1241 | − <div className="space-y-1.5"> | |
| 1242 | − <FieldLabel className="text-[11px]">Scopes</FieldLabel> | |
| 1243 | − <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2"> | |
| 1244 | − {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? ( | |
| 1245 | − <div className="text-[11px] italic text-muted-foreground"> | |
| 1246 | − No scopes declared by the spec. | |
| 1247 | − </div> | |
| 1248 | − ) : ( | |
| 1249 | − Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => ( | |
| 1250 | − <Label key={scope} className="flex items-start gap-2 cursor-pointer py-1"> | |
| 1597 | + <Collapsible | |
| 1598 | + open={oauth2ScopesOpen} | |
| 1599 | + onOpenChange={setOauth2ScopesOpen} | |
| 1600 | + className="space-y-1.5" | |
| 1601 | + > | |
| 1602 | + <CollapsibleTrigger asChild> | |
| 1603 | + <Button | |
| 1604 | + variant="ghost" | |
| 1605 | + size="sm" | |
| 1606 | + className="h-7 w-full justify-between !px-0 text-[11px] hover:bg-transparent hover:text-foreground dark:hover:bg-transparent" | |
| 1607 | + > | |
| 1608 | + <span className="flex min-w-0 items-center gap-1.5"> | |
| 1609 | + <span className="font-medium text-foreground">Scopes</span> | |
| 1610 | + <span className="text-muted-foreground"> | |
| 1611 | + {selectedOAuth2Scopes.length} selected | |
| 1612 | + </span> | |
| 1613 | + </span> | |
| 1614 | + <span className="flex size-3 shrink-0 items-center justify-center"> | |
| 1615 | + <ChevronDownIcon | |
| 1616 | + className={`size-3 text-muted-foreground transition-transform ${ | |
| 1617 | + oauth2ScopesOpen ? "" : "-rotate-90" | |
| 1618 | + }`} | |
| 1619 | + /> | |
| 1620 | + </span> | |
| 1621 | + </Button> | |
| 1622 | + </CollapsibleTrigger> | |
| 1623 | + <CollapsibleContent> | |
| 1624 | + <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2"> | |
| 1625 | + {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? ( | |
| 1626 | + <div className="text-[11px] italic text-muted-foreground"> | |
| 1627 | + No scopes declared by the spec. | |
| 1628 | + </div> | |
| 1629 | + ) : ( | |
| 1630 | + Object.entries(selectedOAuth2Preset.scopes).map( | |
| 1631 | + ([scope, description]) => ( | |
| 1632 | + <Label | |
| 1633 | + key={scope} | |
| 1634 | + className="flex items-start gap-2 cursor-pointer py-1" | |
| 1635 | + > | |
| 1636 | + <Checkbox | |
| 1637 | + checked={oauth2SelectedScopes.has(scope)} | |
| 1638 | + onCheckedChange={() => toggleOAuth2Scope(scope)} | |
| 1639 | + /> | |
| 1640 | + <div className="min-w-0 flex-1"> | |
| 1641 | + <div className="font-mono text-[11px] text-foreground"> | |
| 1642 | + {scope} | |
| 1643 | + </div> | |
| 1644 | + {description && ( | |
| 1645 | + <div className="text-[10px] text-muted-foreground"> | |
| 1646 | + {description} | |
| 1647 | + </div> | |
| 1648 | + )} | |
| 1649 | + </div> | |
| 1650 | + </Label> | |
| 1651 | + ), | |
| 1652 | + ) | |
| 1653 | + )} | |
| 1654 | + {selectedOAuth2AvailableIdentityScopes.length > 0 && ( | |
| 1655 | + <Label className="mt-2 flex cursor-pointer items-start gap-2 border-t border-border/50 pt-2"> | |
| 1251 | 1656 | <Checkbox |
| 1252 | − checked={oauth2SelectedScopes.has(scope)} | |
| 1253 | − onCheckedChange={() => toggleOAuth2Scope(scope)} | |
| 1657 | + checked={includeOAuth2IdentityScopes} | |
| 1658 | + onCheckedChange={(checked) => { | |
| 1659 | + setIncludeOAuth2IdentityScopes(checked === true); | |
| 1660 | + setOauth2AuthState(null); | |
| 1661 | + }} | |
| 1254 | 1662 | /> |
| 1255 | 1663 | <div className="min-w-0 flex-1"> |
| 1256 | − <div className="font-mono text-[11px] text-foreground">{scope}</div> | |
| 1257 | − {description && ( | |
| 1258 | − <div className="text-[10px] text-muted-foreground"> | |
| 1259 | − {description} | |
| 1260 | − </div> | |
| 1261 | − )} | |
| 1664 | + <div className="text-[11px] font-medium text-foreground"> | |
| 1665 | + Account identity | |
| 1666 | + </div> | |
| 1667 | + <div className="font-mono text-[10px] text-muted-foreground"> | |
| 1668 | + {selectedOAuth2AvailableIdentityScopes.join(" · ")} | |
| 1669 | + </div> | |
| 1670 | + <div className="text-[10px] text-muted-foreground"> | |
| 1671 | + Lets the connections page show the signed-in account. | |
| 1672 | + </div> | |
| 1262 | 1673 | </div> |
| 1263 | 1674 | </Label> |
| 1264 | − )) | |
| 1265 | − )} | |
| 1266 | − </div> | |
| 1267 | − </div> | |
| 1675 | + )} | |
| 1676 | + </div> | |
| 1677 | + </CollapsibleContent> | |
| 1678 | + </Collapsible> | |
| 1268 | 1679 | |
| 1269 | 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"> | |
| 1271 | − <div className="text-[11px] text-green-700 dark:text-green-400"> | |
| 1272 | − Connected · {oauth2SelectedScopes.size} scope | |
| 1273 | − {oauth2SelectedScopes.size === 1 ? "" : "s"} granted | |
| 1681 | + <div className="flex items-center justify-between rounded-md border border-border/60 bg-background/50 px-3 py-2"> | |
| 1682 | + <div className="min-w-0 text-[12px]"> | |
| 1683 | + <OAuthConnectedAccount | |
| 1684 | + scopeId={oauthTokenTargetScope} | |
| 1685 | + connectionId={oauth2Auth.connectionId} | |
| 1686 | + scopeSummary={`${selectedOAuth2Scopes.length} scope${ | |
| 1687 | + selectedOAuth2Scopes.length === 1 ? "" : "s" | |
| 1688 | + } granted`} | |
| 1689 | + sourceName={resolvedDisplayName} | |
| 1690 | + onSetSourceName={identity.setName} | |
| 1691 | + /> | |
| 1274 | 1692 | </div> |
| 1275 | 1693 | <Button variant="ghost" size="sm" onClick={() => setOauth2AuthState(null)}> |
| 1276 | 1694 | Disconnect |
| @@ -1285,23 +1703,59 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 1285 | 1703 | <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}> |
| 1286 | 1704 | Cancel |
| 1287 | 1705 | </Button> |
| 1288 | − <Button variant="secondary" size="sm" onClick={handleConnectOAuth2}> | |
| 1706 | + <Button | |
| 1707 | + variant="secondary" | |
| 1708 | + size="sm" | |
| 1709 | + onClick={() => void handleConnectOAuth2()} | |
| 1710 | + > | |
| 1289 | 1711 | Retry |
| 1290 | 1712 | </Button> |
| 1291 | 1713 | </div> |
| 1292 | 1714 | ) : ( |
| 1293 | 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 | 1744 | <div className="grid gap-2 md:grid-cols-2"> |
| 1295 | 1745 | <div className="space-y-1.5"> |
| 1296 | 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 | 1752 | <HelpTooltip label="OAuth sign-in"> |
| 1299 | 1753 | Start the provider OAuth flow. |
| 1300 | 1754 | </HelpTooltip> |
| 1301 | 1755 | </div> |
| 1302 | 1756 | <Button |
| 1303 | 1757 | variant="secondary" |
| 1304 | − onClick={handleConnectOAuth2} | |
| 1758 | + onClick={() => void handleConnectOAuth2()} | |
| 1305 | 1759 | disabled={!canConnectOAuth2} |
| 1306 | 1760 | className={ |
| 1307 | 1761 | canConnectOAuth2 |
| @@ -73,6 +73,19 @@ import { OAuth2SourceConfig } from "../sdk/types"; · 72 unmodified lines | ||
| 73 | 73 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 74 | 74 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| 75 | 75 | |
| 76 | +const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; | |
| 77 | + | |
| 78 | +const resolvedOAuthScopes = (oauth2: OAuth2SourceConfig): string[] => { | |
| 79 | + const merged = new Set(oauth2.scopes); | |
| 80 | + if (oauth2.identityScopes === false) return [...merged]; | |
| 81 | + const extras = | |
| 82 | + oauth2.identityScopes === undefined || oauth2.identityScopes === "auto" | |
| 83 | + ? standardOidcIdentityScopes | |
| 84 | + : oauth2.identityScopes; | |
| 85 | + for (const scope of extras) merged.add(scope); | |
| 86 | + return [...merged]; | |
| 87 | +}; | |
| 88 | + | |
| 76 | 89 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| 77 | 90 | Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { |
| 78 | 91 | onNone: () => fallback, |
| @@ -422,6 +435,7 @@ export default function EditOpenApiSource(props: { · 343 unmodified lines | ||
| 422 | 435 | }; |
| 423 | 436 | const displayName = source.name; |
| 424 | 437 | const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? ""); |
| 438 | + const scopes = resolvedOAuthScopes(oauth2); | |
| 425 | 439 | if (oauth2.flow === "clientCredentials") { |
| 426 | 440 | const startOAuthExit = await doStartOAuth({ |
| 427 | 441 | params: { scopeId: targetScope }, |
| @@ -439,7 +453,7 @@ export default function EditOpenApiSource(props: { · 11 unmodified lines | ||
| 439 | 453 | clientSecretSecretScopeId: clientSecretSecretScopeId |
| 440 | 454 | ? String(clientSecretSecretScopeId) |
| 441 | 455 | : null, |
| 442 | − scopes: [...oauth2.scopes], | |
| 456 | + scopes, | |
| 443 | 457 | }, |
| 444 | 458 | pluginId: "openapi", |
| 445 | 459 | identityLabel: `${displayName} OAuth`, |
| @@ -502,7 +516,7 @@ export default function EditOpenApiSource(props: { · 56 unmodified lines | ||
| 502 | 516 | clientSecretSecretScopeId: clientSecretSecretScopeId |
| 503 | 517 | ? String(clientSecretSecretScopeId) |
| 504 | 518 | : null, |
| 505 | − scopes: [...oauth2.scopes], | |
| 519 | + scopes, | |
| 506 | 520 | }, |
| 507 | 521 | pluginId: "openapi", |
| 508 | 522 | identityLabel: `${displayName} OAuth`, |
| @@ -648,6 +662,7 @@ export default function EditOpenApiSource(props: { · 139 unmodified lines | ||
| 648 | 662 | clientSecretSlot: oauth2.clientSecretSlot, |
| 649 | 663 | connectionSlot: oauth2.connectionSlot, |
| 650 | 664 | scopes: [...oauth2.scopes], |
| 665 | + identityScopes: oauth2.identityScopes, | |
| 651 | 666 | }), |
| 652 | 667 | }, |
| 653 | 668 | }, |
| @@ -27,6 +27,7 @@ export function OpenApiSourceDetailsFields(props: { · 26 unmodified lines | ||
| 27 | 27 | readonly baseUrlOptions?: readonly FreeformComboboxOption[]; |
| 28 | 28 | readonly specUrl?: string; |
| 29 | 29 | readonly onSpecUrlChange?: (value: string) => void; |
| 30 | + readonly faviconIcon?: string | null; | |
| 30 | 31 | readonly faviconUrl?: string; |
| 31 | 32 | readonly namespaceReadOnly?: boolean; |
| 32 | 33 | readonly specUrlDisabled?: boolean; |
| @@ -40,7 +41,9 @@ export function OpenApiSourceDetailsFields(props: { · 7 unmodified lines | ||
| 40 | 41 | <CardStack> |
| 41 | 42 | <CardStackContent className="border-t-0"> |
| 42 | 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 | 47 | <CardStackEntryContent> |
| 45 | 48 | <CardStackEntryTitle>{props.title}</CardStackEntryTitle> |
| 46 | 49 | {props.description && ( |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 3 | 3 | |
| 4 | −import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; | |
| 4 | +import { connectionIdentityAtom, connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; | |
| 5 | 5 | import { Badge } from "@executor-js/react/components/badge"; |
| 6 | +import { Button } from "@executor-js/react/components/button"; | |
| 6 | 7 | import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; |
| 7 | −import { ScopeId } from "@executor-js/sdk/shared"; | |
| 8 | +import { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; | |
| 8 | 9 | import { |
| 9 | 10 | SourceCredentialLoadingBadge, |
| 10 | 11 | SourceCredentialNotice, |
| @@ -21,12 +22,65 @@ function OAuthBadge() { · 10 unmodified lines | ||
| 21 | 22 | return <Badge variant="secondary">OAuth</Badge>; |
| 22 | 23 | } |
| 23 | 24 | |
| 25 | +function SourceAccountNotice(props: { | |
| 26 | + readonly connection: { | |
| 27 | + readonly id: string; | |
| 28 | + readonly scopeId: string; | |
| 29 | + readonly identityLabel: string | null; | |
| 30 | + }; | |
| 31 | + readonly onAction?: () => void; | |
| 32 | +}) { | |
| 33 | + const identityResult = useAtomValue( | |
| 34 | + connectionIdentityAtom( | |
| 35 | + ScopeId.make(props.connection.scopeId), | |
| 36 | + ConnectionId.make(props.connection.id), | |
| 37 | + ), | |
| 38 | + ); | |
| 39 | + const identity = | |
| 40 | + AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available" | |
| 41 | + ? identityResult.value | |
| 42 | + : null; | |
| 43 | + const label = identity?.email ?? identity?.name ?? props.connection.identityLabel ?? "Connected"; | |
| 44 | + return ( | |
| 45 | + <div className="shrink-0 border-b border-border bg-muted/20 px-4 py-3"> | |
| 46 | + <div className="mx-auto flex max-w-4xl items-center justify-between gap-4"> | |
| 47 | + <div className="flex min-w-0 items-center gap-2"> | |
| 48 | + {identity?.picture ? ( | |
| 49 | + <img | |
| 50 | + src={identity.picture} | |
| 51 | + alt="" | |
| 52 | + referrerPolicy="no-referrer" | |
| 53 | + className="size-6 shrink-0 rounded-full" | |
| 54 | + /> | |
| 55 | + ) : ( | |
| 56 | + <div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium text-muted-foreground"> | |
| 57 | + {label.slice(0, 1).toUpperCase()} | |
| 58 | + </div> | |
| 59 | + )} | |
| 60 | + <div className="min-w-0"> | |
| 61 | + <div className="truncate text-sm font-medium text-foreground">{label}</div> | |
| 62 | + <div className="text-xs text-muted-foreground">OAuth account for this source</div> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + {props.onAction ? ( | |
| 66 | + <Button variant="ghost" size="sm" onClick={props.onAction} className="shrink-0"> | |
| 67 | + Configure | |
| 68 | + </Button> | |
| 69 | + ) : null} | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} | |
| 74 | + | |
| 24 | 75 | const effectiveClientSecretSlot = (oauth2: { |
| 25 | 76 | readonly securitySchemeName: string; |
| 26 | 77 | readonly clientSecretSlot: string | null; |
| 27 | 78 | }): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName); |
| 28 | 79 | |
| 29 | −const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => { | |
| 80 | +const sourceCredentialSlots = ( | |
| 81 | + source: StoredSourceSchemaType, | |
| 82 | + options?: { readonly hasLiveOAuthConnection?: boolean }, | |
| 83 | +): readonly SourceCredentialSlot[] => { | |
| 30 | 84 | const slots: SourceCredentialSlot[] = []; |
| 31 | 85 | for (const [name, value] of Object.entries(source.config.headers ?? {})) { |
| 32 | 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 | 91 | const oauth2 = source.config.oauth2; |
| 38 | 92 | if (oauth2) { |
| 39 | − slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); | |
| 40 | − slots.push({ | |
| 41 | − kind: "secret", | |
| 42 | − slot: effectiveClientSecretSlot(oauth2), | |
| 43 | − label: "Client Secret", | |
| 44 | − }); | |
| 93 | + if (!options?.hasLiveOAuthConnection) { | |
| 94 | + slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); | |
| 95 | + slots.push({ | |
| 96 | + kind: "secret", | |
| 97 | + slot: effectiveClientSecretSlot(oauth2), | |
| 98 | + label: "Client Secret", | |
| 99 | + }); | |
| 100 | + } | |
| 45 | 101 | slots.push({ |
| 46 | 102 | kind: "connection", |
| 47 | 103 | slot: oauth2.connectionSlot, |
| @@ -93,8 +149,21 @@ export default function OpenApiSourceSummary(props: { · 45 unmodified lines | ||
| 93 | 149 | const liveConnectionIds = new Set(connections.map((connection) => connection.id)); |
| 94 | 150 | const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); |
| 95 | 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 | 165 | const missing = missingSourceCredentialLabels({ |
| 97 | − slots: sourceCredentialSlots(source), | |
| 166 | + slots: sourceCredentialSlots(source, { hasLiveOAuthConnection: connection !== null }), | |
| 98 | 167 | bindings, |
| 99 | 168 | targetScope: credentialTargetScope, |
| 100 | 169 | scopeRanks, |
| @@ -102,24 +171,18 @@ export default function OpenApiSourceSummary(props: { · 1 unmodified line | ||
| 102 | 171 | }); |
| 103 | 172 | |
| 104 | 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 | 181 | if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />; |
| 109 | 182 | |
| 110 | 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 | 186 | return <SourceCredentialStatusBadge missing={[]} />; |
| 124 | 187 | } |
| 125 | 188 | |
| @@ -30,6 +30,18 @@ const source: SourceForCredentialStatus = { · 29 unmodified lines | ||
| 30 | 30 | }, |
| 31 | 31 | }; |
| 32 | 32 | |
| 33 | +const authorizationCodeSource: SourceForCredentialStatus = { | |
| 34 | + config: { | |
| 35 | + oauth2: { | |
| 36 | + securitySchemeName: "oauth2", | |
| 37 | + flow: "authorizationCode", | |
| 38 | + clientIdSlot: "oauth2:oauth2:client-id", | |
| 39 | + clientSecretSlot: "oauth2:oauth2:client-secret", | |
| 40 | + connectionSlot: "oauth2:oauth2:connection", | |
| 41 | + }, | |
| 42 | + }, | |
| 43 | +}; | |
| 44 | + | |
| 33 | 45 | const bindings = ( |
| 34 | 46 | scopeId: ScopeId, |
| 35 | 47 | slots: readonly string[], |
| @@ -81,6 +93,28 @@ describe("OpenAPI credential status", () => { · 45 unmodified lines | ||
| 81 | 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 | 118 | it("does not treat personal bindings as satisfying org-level credential status", () => { |
| 85 | 119 | expect( |
| 86 | 120 | missingCredentialLabels(source, bindings(userScope, allSlots), orgScope, scopeRanks), |
| @@ -88,18 +88,23 @@ export function missingCredentialLabels( · 87 unmodified lines | ||
| 88 | 88 | const oauth2 = source.config.oauth2; |
| 89 | 89 | if (!oauth2) return missing; |
| 90 | 90 | |
| 91 | − if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) { | |
| 92 | − missing.push("Client ID"); | |
| 93 | − } | |
| 91 | + const hasLiveConnection = hasConnectionBinding( | |
| 92 | + bindings, | |
| 93 | + oauth2.connectionSlot, | |
| 94 | + targetScope, | |
| 95 | + ranks, | |
| 96 | + liveConnectionIds, | |
| 97 | + ); | |
| 98 | + if (!hasLiveConnection) { | |
| 99 | + if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) { | |
| 100 | + missing.push("Client ID"); | |
| 101 | + } | |
| 94 | 102 | |
| 95 | − const clientSecretSlot = effectiveClientSecretSlot(oauth2); | |
| 96 | − if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) { | |
| 97 | − missing.push("Client Secret"); | |
| 98 | − } | |
| 103 | + const clientSecretSlot = effectiveClientSecretSlot(oauth2); | |
| 104 | + if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) { | |
| 105 | + missing.push("Client Secret"); | |
| 106 | + } | |
| 99 | 107 | |
| 100 | − if ( | |
| 101 | − !hasConnectionBinding(bindings, oauth2.connectionSlot, targetScope, ranks, liveConnectionIds) | |
| 102 | − ) { | |
| 103 | 108 | missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in"); |
| 104 | 109 | } |
| 105 | 110 | |
| @@ -46,6 +46,7 @@ const makeOauth2SourceConfig = (params: { · 45 unmodified lines | ||
| 46 | 46 | readonly tokenUrl: string; |
| 47 | 47 | readonly authorizationUrl: string | null; |
| 48 | 48 | readonly scopes: readonly string[]; |
| 49 | + readonly identityScopes?: OAuth2SourceConfig["identityScopes"]; | |
| 49 | 50 | }): OAuth2SourceConfig => |
| 50 | 51 | OAuth2SourceConfig.make({ |
| 51 | 52 | kind: "oauth2", |
| @@ -57,8 +58,20 @@ const makeOauth2SourceConfig = (params: { · 5 unmodified lines | ||
| 57 | 58 | clientSecretSlot: "oauth2:oauth2:client-secret", |
| 58 | 59 | connectionSlot: "oauth2:oauth2:connection", |
| 59 | 60 | scopes: [...params.scopes], |
| 61 | + ...(params.identityScopes !== undefined ? { identityScopes: params.identityScopes } : {}), | |
| 60 | 62 | }); |
| 61 | 63 | |
| 64 | +const resolvedOAuthScopes = (oauth2: OAuth2SourceConfig): string[] => { | |
| 65 | + const merged = new Set(oauth2.scopes); | |
| 66 | + if (oauth2.identityScopes === false) return [...merged]; | |
| 67 | + const extras = | |
| 68 | + oauth2.identityScopes === undefined || oauth2.identityScopes === "auto" | |
| 69 | + ? ["openid", "email", "profile"] | |
| 70 | + : oauth2.identityScopes; | |
| 71 | + for (const scope of extras) merged.add(scope); | |
| 72 | + return [...merged]; | |
| 73 | +}; | |
| 74 | + | |
| 62 | 75 | // --------------------------------------------------------------------------- |
| 63 | 76 | // Test API — a single endpoint that echoes the Authorization header so the |
| 64 | 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 | 458 | // Regression: repeated `clientCredentials` sign-ins used to mint a fresh |
| 365 | 459 | // random UUID per call AND rewrite `source.oauth2.connectionId` to that |
| @@ -303,6 +303,11 @@ const StaticPreviewOAuth2PresetSchema = Schema.Struct({ · 302 unmodified lines | ||
| 303 | 303 | tokenUrl: Schema.String, |
| 304 | 304 | refreshUrl: Schema.NullOr(Schema.String), |
| 305 | 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 | }); |
| 307 | 312 | const StaticPreviewSpecOutputSchema = Schema.Struct({ |
| 308 | 313 | title: Schema.NullOr(Schema.String), |
| @@ -535,6 +540,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 226 unmodified lines | ||
| 535 | 540 | tokenUrl: preset.tokenUrl, |
| 536 | 541 | refreshUrl: Option.getOrNull(preset.refreshUrl), |
| 537 | 542 | scopes: preset.scopes, |
| 543 | + identityScopes: preset.identityScopes, | |
| 538 | 544 | })), |
| 539 | 545 | }); |
| 540 | 546 | |
| @@ -96,6 +96,7 @@ describe("previewSpec OAuth2 extraction", () => { · 95 unmodified lines | ||
| 96 | 96 | read: "Read access", |
| 97 | 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 | 127 | const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!; |
| 127 | 128 | expect(Option.isNone(cc.authorizationUrl)).toBe(true); |
| 128 | 129 | expect(cc.scopes).toEqual({ "admin:read": "Admin read" }); |
| 130 | + expect(cc.identityScopes).toBe(false); | |
| 129 | 131 | }), |
| 130 | 132 | ); |
| 131 | 133 | |
| @@ -106,6 +106,12 @@ export const OAuth2Preset = Schema.Struct({ · 105 unmodified lines | ||
| 106 | 106 | refreshUrl: Schema.OptionFromOptional(Schema.String), |
| 107 | 107 | /** Declared scopes for this flow: `{ scope: description }`. */ |
| 108 | 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 | }); |
| 110 | 116 | export type OAuth2Preset = typeof OAuth2Preset.Type; |
| 111 | 117 | |
| @@ -328,6 +334,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] · 216 unmodified lines | ||
| 328 | 334 | tokenUrl: flow.tokenUrl, |
| 329 | 335 | refreshUrl: flow.refreshUrl, |
| 330 | 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 | 350 | tokenUrl: flow.tokenUrl, |
| 344 | 351 | refreshUrl: flow.refreshUrl, |
| 345 | 352 | scopes: flow.scopes, |
| 353 | + identityScopes: false, | |
| 346 | 354 | }), |
| 347 | 355 | ); |
| 348 | 356 | } |
| @@ -7,9 +7,10 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@executor-js/sdk/shared"; |
| 8 | 8 | import * as Atom from "effect/unstable/reactivity/Atom"; |
| 9 | 9 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 10 | +import * as Effect from "effect/Effect"; | |
| 10 | 11 | |
| 11 | 12 | import { ExecutorApiClient } from "./client"; |
| 12 | −import { ReactivityKey } from "./reactivity-keys"; | |
| 13 | +import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; | |
| 13 | 14 | |
| 14 | 15 | // --------------------------------------------------------------------------- |
| 15 | 16 | // Scope — fetched from the server |
| @@ -178,6 +179,13 @@ export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); · 162 unmodified lines | ||
| 178 | 179 | |
| 179 | 180 | export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel"); |
| 180 | 181 | |
| 182 | +export const oauthConnectionCompleted = ExecutorApiClient.runtime.fn<{ | |
| 183 | + readonly tokenScope: string; | |
| 184 | + readonly reactivityKeys: typeof connectionWriteKeys; | |
| 185 | +}>()(() => Effect.void, { | |
| 186 | + reactivityKeys: connectionWriteKeys, | |
| 187 | +}); | |
| 188 | + | |
| 181 | 189 | export const createPolicy = ExecutorApiClient.mutation("policies", "create"); |
| 182 | 190 | |
| 183 | 191 | export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); |
| @@ -4,7 +4,7 @@ import * as Data from "effect/Data"; · 3 unmodified lines | ||
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Exit from "effect/Exit"; |
| 6 | 6 | |
| 7 | −import { cancelOAuth, startOAuth } from "../api/atoms"; | |
| 7 | +import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms"; | |
| 8 | 8 | import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting"; |
| 9 | 9 | import { |
| 10 | 10 | openOAuthPopup, |
| @@ -12,6 +12,7 @@ import { · 1 unmodified line | ||
| 12 | 12 | reserveOAuthPopup, |
| 13 | 13 | type OAuthPopupResult, |
| 14 | 14 | } from "../api/oauth-popup"; |
| 15 | +import { connectionWriteKeys } from "../api/reactivity-keys"; | |
| 15 | 16 | |
| 16 | 17 | type DesktopBridge = { |
| 17 | 18 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -73,7 +74,7 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 55 unmodified lines | ||
| 73 | 74 | readonly tokenScope: string; |
| 74 | 75 | readonly run: () => Promise<OAuthAuthorizationStartResult>; |
| 75 | 76 | readonly onSuccess: (payload: TPayload) => void | Promise<void>; |
| 76 | − readonly onError?: (error: string) => void; | |
| 77 | + readonly onError?: (error: string, details?: string) => void; | |
| 77 | 78 | readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; |
| 78 | 79 | readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>; |
| 79 | 80 | }; |
| @@ -119,6 +120,7 @@ export function useOAuthPopupFlow< · 39 unmodified lines | ||
| 119 | 120 | } = options; |
| 120 | 121 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 121 | 122 | const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); |
| 123 | + const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" }); | |
| 122 | 124 | const reportHandledError = useReportHandledError(); |
| 123 | 125 | const [busy, setBusy] = useState(false); |
| 124 | 126 | const [error, setError] = useState<string | null>(null); |
| @@ -222,7 +224,25 @@ export function useOAuthPopupFlow< · 97 unmodified lines | ||
| 222 | 224 | if (!result.ok) { |
| 223 | 225 | setBusy(false); |
| 224 | 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 | 246 | return; |
| 227 | 247 | } |
| 228 | 248 | |
| @@ -292,6 +312,7 @@ export function useOAuthPopupFlow< · 63 unmodified lines | ||
| 292 | 312 | cancel, |
| 293 | 313 | cancelSession, |
| 294 | 314 | detectPopupClosed, |
| 315 | + doOAuthConnectionCompleted, | |
| 295 | 316 | noAuthorizationUrlMessage, |
| 296 | 317 | popupBlockedMessage, |
| 297 | 318 | popupClosedMessage, |