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