diff --git a/bun.lock b/bun.lock index 7adc2b1b7..a1dadb514 100644 --- a/bun.lock +++ b/bun.lock @@ -774,6 +774,7 @@ "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", + "lucide-react": "^1.7.0", "openapi-types": "^12.1.3", "yaml": "^2.7.1", }, diff --git a/packages/core/sdk/src/http-source.ts b/packages/core/sdk/src/http-source.ts index 9216fda04..c85f4206a 100644 --- a/packages/core/sdk/src/http-source.ts +++ b/packages/core/sdk/src/http-source.ts @@ -46,6 +46,14 @@ export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentia export type OAuth2Flow = typeof OAuth2Flow.Type; export type OAuth2FlowType = OAuth2Flow; +export const OAuth2IdentityScopes = Schema.Union([ + Schema.Literal("auto"), + Schema.Literal(false), + Schema.Array(Schema.String), +]); +export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type; +export type OAuth2IdentityScopesType = OAuth2IdentityScopes; + export const OAuth2SourceConfig = Schema.Struct({ kind: Schema.Literal("oauth2"), securitySchemeName: Schema.String, @@ -60,6 +68,10 @@ export const OAuth2SourceConfig = Schema.Struct({ clientSecretSlot: Schema.NullOr(Schema.String), connectionSlot: Schema.String, scopes: Schema.Array(Schema.String), + identityScopes: OAuth2IdentityScopes.pipe( + Schema.optional, + Schema.withDecodingDefault(Effect.succeed("auto" as const)), + ), }).annotate({ identifier: "OAuth2SourceConfig" }); export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; export type OAuth2SourceConfigType = OAuth2SourceConfig; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 4d880f5f6..86a4797aa 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -61,6 +61,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, type OAuthAuthorizationCodeStrategy, + type OAuthAuthorizationCodeExistingClientStrategy, type OAuthClientCredentialsStrategy, type OAuthCompleteInput, type OAuthCompleteResult, @@ -141,6 +142,8 @@ const AuthorizationCodeSessionPayload = Schema.Struct({ Schema.withDecodingDefaultType(Effect.succeed(null)), ), scopes: Schema.Array(Schema.String), + authorizationScopes: Schema.optional(Schema.Array(Schema.String)), + storedScope: Schema.optional(Schema.String), scopeSeparator: Schema.optional(Schema.String), clientAuth: Schema.Literals(["body", "basic"]), }); @@ -587,6 +590,10 @@ export const makeOAuth2Service = ( const startAuthorizationCode = ( input: OAuthStartInput, strategy: OAuthAuthorizationCodeStrategy, + options?: { + readonly authorizationScopes?: readonly string[]; + readonly storedScope?: string; + }, ): Effect.Effect => Effect.gen(function* () { const clientIdRef = yield* secretsGetResolvedAtScope({ @@ -609,12 +616,19 @@ export const makeOAuth2Service = ( const sessionId = scopedSessionId(input.tokenScope, newSessionId()); const codeVerifier = createPkceCodeVerifier(); const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); + const authorizationScopes = + options?.authorizationScopes ?? strategy.authorizationScopes ?? strategy.scopes; + const storedScope = + options?.storedScope ?? + (strategy.authorizationScopes + ? strategy.scopes.join(strategy.scopeSeparator ?? " ") + : undefined); const authorizationUrl = buildAuthorizationUrl({ authorizationUrl: strategy.authorizationEndpoint, clientId: clientIdRef.value, redirectUrl: input.redirectUrl, - scopes: strategy.scopes, + scopes: authorizationScopes, state: sessionId, codeChallenge, scopeSeparator: strategy.scopeSeparator, @@ -639,6 +653,8 @@ export const makeOAuth2Service = ( }))?.scopeId ?? null) : null, scopes: [...strategy.scopes], + authorizationScopes: [...authorizationScopes], + storedScope, scopeSeparator: strategy.scopeSeparator, clientAuth: strategy.clientAuth ?? "body", }; @@ -657,6 +673,53 @@ export const makeOAuth2Service = ( }; }); + const startAuthorizationCodeWithExistingClient = ( + input: OAuthStartInput, + strategy: OAuthAuthorizationCodeExistingClientStrategy, + ): Effect.Effect => + Effect.gen(function* () { + const existing = yield* connectionsGet(input.connectionId); + if (!existing || existing.scopeId !== input.tokenScope) { + return yield* new OAuthStartError({ + message: "Existing OAuth connection was not found at the selected scope", + }); + } + const state = existing.providerState + ? Option.getOrNull(decodeProviderStateOption(coerceJson(existing.providerState))) + : null; + if (!state || state.kind !== "authorization-code") { + return yield* new OAuthStartError({ + message: "Existing OAuth connection cannot be reused for authorization-code sign-in", + }); + } + + const scopeSeparator = strategy.scopeSeparator ?? state.scopeSeparator; + + return yield* startAuthorizationCode( + input, + { + kind: "authorization-code", + authorizationEndpoint: strategy.authorizationEndpoint, + tokenEndpoint: strategy.tokenEndpoint ?? state.tokenEndpoint, + issuerUrl: strategy.issuerUrl ?? state.issuerUrl, + clientIdSecretId: state.clientIdSecretId, + clientIdSecretScopeId: state.clientIdSecretScopeId, + clientSecretSecretId: state.clientSecretSecretId, + clientSecretSecretScopeId: state.clientSecretSecretScopeId, + scopes: [...strategy.scopes], + scopeSeparator, + extraAuthorizationParams: strategy.extraAuthorizationParams, + clientAuth: state.clientAuth, + }, + strategy.authorizationScopes + ? { + authorizationScopes: strategy.authorizationScopes, + storedScope: strategy.scopes.join(scopeSeparator ?? " "), + } + : undefined, + ); + }); + const startClientCredentials = ( input: OAuthStartInput, strategy: OAuthClientCredentialsStrategy, @@ -765,6 +828,9 @@ export const makeOAuth2Service = ( Match.when({ kind: "authorization-code" }, (strategy) => startAuthorizationCode(input, strategy), ), + Match.when({ kind: "authorization-code-existing-client" }, (strategy) => + startAuthorizationCodeWithExistingClient(input, strategy), + ), Match.when({ kind: "client-credentials" }, (strategy) => startClientCredentials(input, strategy), ), @@ -875,6 +941,10 @@ export const makeOAuth2Service = ( typeof exchangeResult.tokens.expires_in === "number" ? now() + exchangeResult.tokens.expires_in * 1000 : null; + const effectiveOAuthScope = + payload.kind === "authorization-code" && payload.storedScope + ? payload.storedScope + : (exchangeResult.tokens.scope ?? null); const dynamicClientSecretSecretId = yield* (() => { if (payload.kind !== "dynamic-dcr") return Effect.succeed(null); @@ -938,7 +1008,7 @@ export const makeOAuth2Service = ( : "body", clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null, scopes: [...payload.scopes], - scope: exchangeResult.tokens.scope ?? null, + scope: effectiveOAuthScope, resource: payload.resource, } : { @@ -950,9 +1020,9 @@ export const makeOAuth2Service = ( clientSecretSecretId: payload.clientSecretSecretId, clientSecretSecretScopeId: payload.clientSecretSecretScopeId, clientAuth: payload.clientAuth, - scopes: [...payload.scopes], + scopes: [...(payload.authorizationScopes ?? payload.scopes)], scopeSeparator: payload.scopeSeparator, - scope: exchangeResult.tokens.scope ?? null, + scope: effectiveOAuthScope, }; yield* deps @@ -977,7 +1047,7 @@ export const makeOAuth2Service = ( }) : null, expiresAt: connectionExpiresAt, - oauthScope: exchangeResult.tokens.scope ?? null, + oauthScope: effectiveOAuthScope, providerState: encodeProviderStateSync(providerState) as Record, }), ) @@ -1009,7 +1079,7 @@ export const makeOAuth2Service = ( return { connectionId, expiresAt: connectionExpiresAt, - scope: exchangeResult.tokens.scope ?? null, + scope: effectiveOAuthScope, }; }); diff --git a/packages/core/sdk/src/oauth.ts b/packages/core/sdk/src/oauth.ts index e0b216f96..e348e9f6c 100644 --- a/packages/core/sdk/src/oauth.ts +++ b/packages/core/sdk/src/oauth.ts @@ -64,7 +64,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ * PKCE without a confidential secret. */ clientSecretSecretId: Schema.NullOr(Schema.String), clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), + /** Final scope set Executor should remember for this connection. */ scopes: Schema.Array(Schema.String), + /** Optional smaller scope set to send to the authorization server. This is + * useful when one provider scope covers many source-level operation scopes. */ + authorizationScopes: Schema.optional(Schema.Array(Schema.String)), /** Separator between scopes. RFC 6749 says space; some providers * (GitHub classic) use comma. */ scopeSeparator: Schema.optional(Schema.String), @@ -77,6 +81,25 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ }); export type OAuthAuthorizationCodeStrategy = typeof OAuthAuthorizationCodeStrategy.Type; +/** Authorization-code flow that reuses the client credentials recorded on + * an existing OAuth connection. Used for incremental authorization where + * the user is granting more scopes to the same account/provider. */ +export const OAuthAuthorizationCodeExistingClientStrategy = Schema.Struct({ + kind: Schema.Literal("authorization-code-existing-client"), + authorizationEndpoint: Schema.String, + tokenEndpoint: Schema.optional(Schema.String), + issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** Final scope set Executor should remember for this connection. */ + scopes: Schema.Array(Schema.String), + /** Optional smaller scope set to send to the authorization server. This is + * useful when one provider scope covers many source-level operation scopes. */ + authorizationScopes: Schema.optional(Schema.Array(Schema.String)), + scopeSeparator: Schema.optional(Schema.String), + extraAuthorizationParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); +export type OAuthAuthorizationCodeExistingClientStrategy = + typeof OAuthAuthorizationCodeExistingClientStrategy.Type; + /** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used * for server-to-server integrations where the plugin has both * `client_id` and `client_secret` and the server will mint tokens @@ -100,6 +123,7 @@ export type OAuthClientCredentialsStrategy = typeof OAuthClientCredentialsStrate export const OAuthStrategy = Schema.Union([ OAuthDynamicDcrStrategy, OAuthAuthorizationCodeStrategy, + OAuthAuthorizationCodeExistingClientStrategy, OAuthClientCredentialsStrategy, ]); export type OAuthStrategy = typeof OAuthStrategy.Type; diff --git a/packages/core/sdk/src/testing.test.ts b/packages/core/sdk/src/testing.test.ts index a8bc7fd25..d54745e37 100644 --- a/packages/core/sdk/src/testing.test.ts +++ b/packages/core/sdk/src/testing.test.ts @@ -61,12 +61,14 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { tokenEndpoint: oauth.tokenEndpoint, clientIdSecretId: "oauth-client-id", clientSecretSecretId: "oauth-client-secret", - scopes: ["read"], + scopes: ["read", "write"], + authorizationScopes: ["read"], }, }); expect(started.authorizationUrl).not.toBeNull(); const authorizationUrl = started.authorizationUrl ?? ""; + expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("read"); const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl }); const completed = yield* workspace.executor.oauth.complete({ state: callback.state, @@ -75,6 +77,93 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => { }); expect(completed.connectionId).toBe("test-oauth-authorization-code"); + expect(completed.scope).toBe("read write"); + const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId); + expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true); + }), + ); + + it.effect("authorization-code OAuth can reuse an existing connection client", () => + Effect.gen(function* () { + const workspace = yield* TestWorkspace.current(); + const oauth = yield* OAuthTestServer; + const scope = workspace.scopes[0]!; + + yield* workspace.executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("oauth-client-id"), + scope: scope.id, + name: "OAuth Client ID", + value: "test-client", + }), + ); + yield* workspace.executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("oauth-client-secret"), + scope: scope.id, + name: "OAuth Client Secret", + value: "test-secret", + }), + ); + + const started = yield* workspace.executor.oauth.start({ + endpoint: oauth.resourceUrl, + connectionId: "test-oauth-existing-client", + tokenScope: String(scope.id), + redirectUrl: "http://127.0.0.1/callback", + pluginId: "test", + identityLabel: "OAuth Test", + strategy: { + kind: "authorization-code", + authorizationEndpoint: oauth.authorizationEndpoint, + tokenEndpoint: oauth.tokenEndpoint, + clientIdSecretId: "oauth-client-id", + clientSecretSecretId: "oauth-client-secret", + scopes: ["gmail.read"], + }, + }); + const callback = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl ?? "", + }); + yield* workspace.executor.oauth.complete({ + state: callback.state, + code: callback.code, + tokenScope: String(scope.id), + }); + + const incremental = yield* workspace.executor.oauth.start({ + endpoint: oauth.resourceUrl, + connectionId: "test-oauth-existing-client", + tokenScope: String(scope.id), + redirectUrl: "http://127.0.0.1/callback", + pluginId: "test", + identityLabel: "OAuth Test", + strategy: { + kind: "authorization-code-existing-client", + authorizationEndpoint: oauth.authorizationEndpoint, + tokenEndpoint: oauth.tokenEndpoint, + scopes: ["gmail.read", "calendar.read"], + authorizationScopes: ["calendar.read"], + extraAuthorizationParams: { include_granted_scopes: "true" }, + }, + }); + + const authorizationUrl = new URL(incremental.authorizationUrl ?? ""); + expect(authorizationUrl.searchParams.get("client_id")).toBe("test-client"); + expect(authorizationUrl.searchParams.get("scope")).toBe("calendar.read"); + expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBe("true"); + + const incrementalCallback = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: incremental.authorizationUrl ?? "", + }); + const completed = yield* workspace.executor.oauth.complete({ + state: incrementalCallback.state, + code: incrementalCallback.code, + tokenScope: String(scope.id), + }); + + expect(completed.connectionId).toBe("test-oauth-existing-client"); + expect(completed.scope).toBe("gmail.read calendar.read"); const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId); expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true); }), diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index d4b4adcad..91c18aa70 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -40,6 +40,7 @@ export interface OAuthTestServerRequest { readonly path: string; readonly headers: Readonly>; readonly body: string; + readonly query: Readonly>; } export interface OAuthTestServerOptions { @@ -435,6 +436,7 @@ export const serveOAuthTestServer = ( path: requestUrl.pathname, headers, body, + query: Object.fromEntries(requestUrl.searchParams.entries()), }, ]); diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index dc95bc3dd..4b05dc10e 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -65,6 +65,7 @@ "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", + "lucide-react": "^1.7.0", "openapi-types": "^12.1.3", "yaml": "^2.7.1" }, diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 27387eb83..f95404120 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useAtomSet } from "@effect/atom-react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { ChevronDownIcon } from "lucide-react"; import { ConnectionId, @@ -12,7 +14,12 @@ import { SecretId, SetSourceCredentialBindingInput, } from "@executor-js/sdk/shared"; -import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms"; +import { + connectionIdentityAtom, + connectionsAtom, + setSourceCredentialBinding, + startOAuth, +} from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -65,6 +72,7 @@ import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio- import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; +import { openApiPresets } from "../sdk/presets"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { headerBindingSlot, @@ -131,6 +139,123 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { } } +const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; + +const identityScopesForPreset = ( + identityScopes: OAuth2Preset["identityScopes"], +): readonly string[] => { + if (identityScopes === false) return []; + return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; +}; + +const resolvedOAuthScopes = ( + apiScopes: Iterable, + identityScopes: OAuth2Preset["identityScopes"], +): string[] => { + const merged = new Set(apiScopes); + for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); + return [...merged]; +}; + +const splitOAuthScopes = (value: string | null): Set => + new Set(value?.split(/\s+/).filter(Boolean) ?? []); + +const mergeOAuthScopes = (...values: readonly Iterable[]): string[] => { + const merged = new Set(); + for (const scopes of values) { + for (const scope of scopes) { + if (scope.trim()) merged.add(scope); + } + } + return [...merged]; +}; + +const missingOAuthScopes = ( + connection: { readonly oauthScope: string | null }, + requiredApiScopes: Iterable, +): readonly string[] => { + const granted = splitOAuthScopes(connection.oauthScope); + return [...requiredApiScopes].filter((scope) => !granted.has(scope)); +}; + +const isGoogleOAuthScope = (scope: string): boolean => + scope === "https://mail.google.com/" || + scope.startsWith("https://www.googleapis.com/auth/") || + scope.startsWith("https://www.google.com/m8/feeds/"); + +const hasGoogleOAuthScope = (connection: { readonly oauthScope: string | null }): boolean => + [...splitOAuthScopes(connection.oauthScope)].some(isGoogleOAuthScope); + +const isGoogleOAuthUrl = (url: string): boolean => { + if (!URL.canParse(url)) return false; + const host = new URL(url).hostname.toLowerCase(); + return host === "accounts.google.com" || host === "oauth2.googleapis.com"; +}; + +const isGoogleOAuthTarget = (preset: OAuth2Preset, baseUrl: string, specUrl: string): boolean => { + if (isGoogleDiscoveryUrl(specUrl)) return true; + if (Object.keys(preset.scopes).some(isGoogleOAuthScope)) return true; + if (isGoogleOAuthUrl(resolveOAuthUrl(preset.tokenUrl, baseUrl))) return true; + const authorizationUrl = Option.getOrElse(preset.authorizationUrl, () => ""); + return authorizationUrl ? isGoogleOAuthUrl(resolveOAuthUrl(authorizationUrl, baseUrl)) : false; +}; + +const googleAuthorizationParams = (enabled: boolean): Record | undefined => + enabled + ? { + access_type: "offline", + } + : undefined; + +const googleBroadScopeGroups: readonly { + readonly broad: string; + readonly prefixes: readonly string[]; +}[] = [ + { + broad: "https://mail.google.com/", + prefixes: ["https://www.googleapis.com/auth/gmail."], + }, + { + broad: "https://www.googleapis.com/auth/calendar", + prefixes: ["https://www.googleapis.com/auth/calendar."], + }, + { + broad: "https://www.googleapis.com/auth/drive", + prefixes: ["https://www.googleapis.com/auth/drive."], + }, +]; + +const compactGoogleOAuthScopes = (scopes: Iterable): string[] => { + const ordered = mergeOAuthScopes( + [...scopes].map((scope) => + scope === "https://www.googleapis.com/auth/userinfo.email" + ? "email" + : scope === "https://www.googleapis.com/auth/userinfo.profile" + ? "profile" + : scope, + ), + ); + const present = new Set(ordered); + return ordered.filter( + (scope) => + !googleBroadScopeGroups.some( + (group) => + scope !== group.broad && + present.has(group.broad) && + group.prefixes.some((prefix) => scope.startsWith(prefix)), + ), + ); +}; + +type OAuthConnectionChoice = { + readonly id: ConnectionId; + readonly scopeId: ScopeId; + readonly provider: string; + readonly identityLabel: string | null; + readonly oauthScope: string | null; + readonly missingApiScopes: readonly string[]; +}; + const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( @@ -155,6 +280,15 @@ const isGoogleDiscoveryUrl = (url: string): boolean => { return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); }; +const normalizePresetUrl = (url: string): string => { + const trimmed = url.trim(); + if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); + const parsed = new URL(trimmed); + parsed.hash = ""; + parsed.searchParams.sort(); + return parsed.toString().replace(/\/$/, ""); +}; + type StrategySelection = | { readonly kind: "none" } | { readonly kind: "custom" } @@ -214,6 +348,119 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { }); } +function OAuthConnectedAccount(props: { + readonly scopeId: ScopeId; + readonly connectionId: string; + readonly scopeSummary: string; + readonly sourceName: string; + readonly onSetSourceName: (name: string) => void; +}) { + const identityResult = useAtomValue( + connectionIdentityAtom(props.scopeId, ConnectionId.make(props.connectionId)), + ); + const identityResponse = AsyncResult.isSuccess(identityResult) ? identityResult.value : null; + const identity = identityResponse?.status === "available" ? identityResponse : null; + const accountLabel = identity?.email ?? identity?.name ?? identity?.username ?? null; + const sourceNameWithAccount = + accountLabel && !props.sourceName.includes(accountLabel) + ? `${props.sourceName} - ${accountLabel}` + : props.sourceName; + const accountIsInSourceName = accountLabel !== null && props.sourceName === sourceNameWithAccount; + return ( +
+
+
+ {identity?.picture ? ( + + ) : null} + + {accountLabel ? `Connected as ${accountLabel}` : "Connected"} + · {props.scopeSummary} + +
+ {accountLabel ? ( +
+ Source name: {sourceNameWithAccount} +
+ ) : null} +
+ {accountLabel ? ( + + ) : null} +
+ ); +} + +function ExistingOAuthConnectionOption(props: { + readonly connection: OAuthConnectionChoice; + readonly selected: boolean; + readonly onSelect: () => void; + readonly providerLabel: string; +}) { + const identityResult = useAtomValue( + connectionIdentityAtom(props.connection.scopeId, props.connection.id), + ); + const identity = + AsyncResult.isSuccess(identityResult) && identityResult.value.status === "available" + ? identityResult.value + : null; + const label = + identity?.email ?? identity?.name ?? props.connection.identityLabel ?? props.connection.id; + const picture = identity?.picture; + const needsPermission = props.connection.missingApiScopes.length > 0; + return ( + + ); +} + const secretStorageDescription = (label: string): string => label === "Personal" ? "Only you can use this secret." @@ -259,6 +506,8 @@ export default function AddOpenApiSource(props: { const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState(null); const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState(null); const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState>(new Set()); + const [includeOAuth2IdentityScopes, setIncludeOAuth2IdentityScopes] = useState(true); + const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false); const [oauth2AuthState, setOauth2AuthState] = useState<{ readonly fingerprint: string; readonly auth: { readonly connectionId: string }; @@ -318,6 +567,7 @@ export default function AddOpenApiSource(props: { const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, @@ -355,6 +605,9 @@ export default function AddOpenApiSource(props: { const baseUrlOptions = Array.from( new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); + const previewPresetIcon = + openApiPresets.find((preset) => normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl)) + ?.icon ?? null; const resolvedBaseUrl = baseUrl.trim(); const sourceScope = ScopeId.make(scopeId); @@ -460,11 +713,13 @@ export default function AddOpenApiSource(props: { const resolvedSourceId = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); + const resolvedDisplayName = + identity.name.trim() || + (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); const selectedOAuth2Preset: OAuth2Preset | null = strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; const selectedOAuth2Fingerprint = selectedOAuth2Preset ? [ - resolvedSourceId, resolvedBaseUrl, selectedOAuth2Preset.securitySchemeName, selectedOAuth2Preset.flow, @@ -474,6 +729,46 @@ export default function AddOpenApiSource(props: { : ""; const oauth2Auth = oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null; + const selectedOAuth2AvailableIdentityScopes = selectedOAuth2Preset + ? identityScopesForPreset(selectedOAuth2Preset.identityScopes) + : []; + const selectedOAuth2IsGoogle = selectedOAuth2Preset + ? isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl) + : false; + const selectedOAuth2ProviderLabel = selectedOAuth2IsGoogle ? "Google" : "OAuth"; + const configuredOAuth2IdentityScopes = + selectedOAuth2Preset && includeOAuth2IdentityScopes + ? selectedOAuth2Preset.identityScopes + : false; + const selectedOAuth2Scopes = useMemo( + () => + selectedOAuth2Preset + ? resolvedOAuthScopes(oauth2SelectedScopes, configuredOAuth2IdentityScopes) + : [...oauth2SelectedScopes], + [configuredOAuth2IdentityScopes, oauth2SelectedScopes, selectedOAuth2Preset], + ); + const existingOAuthConnections = useMemo(() => { + if ( + !selectedOAuth2Preset || + selectedOAuth2Preset.flow !== "authorizationCode" || + !AsyncResult.isSuccess(connectionsResult) + ) { + return []; + } + return connectionsResult.value + .flatMap((connection) => { + if (connection.provider !== "oauth2") return []; + const missingApiScopes = missingOAuthScopes(connection, oauth2SelectedScopes); + if (missingApiScopes.length === 0) { + return [{ ...connection, missingApiScopes }]; + } + if (selectedOAuth2IsGoogle && hasGoogleOAuthScope(connection)) { + return [{ ...connection, missingApiScopes }]; + } + return []; + }) + .sort((a, b) => a.missingApiScopes.length - b.missingApiScopes.length); + }, [connectionsResult, oauth2SelectedScopes, selectedOAuth2IsGoogle, selectedOAuth2Preset]); const configuredOAuth2 = strategy.kind === "oauth2" && selectedOAuth2Preset @@ -496,6 +791,7 @@ export default function AddOpenApiSource(props: { clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), scopes: [...oauth2SelectedScopes], + identityScopes: configuredOAuth2IdentityScopes, }) : null; const hasHeaders = Object.keys(configuredHeaders).length > 0; @@ -554,6 +850,7 @@ export default function AddOpenApiSource(props: { setStrategy({ kind: "oauth2", presetIndex: 0 }); setCustomHeaders([]); setOauth2SelectedScopes(new Set(Object.keys(result.oauth2Presets[0].scopes))); + setIncludeOAuth2IdentityScopes(result.oauth2Presets[0].identityScopes !== false); } else { // No header presets — default to "custom" so the headers editor is // visible immediately. Specs with no `security` block (e.g. Microsoft @@ -593,6 +890,7 @@ export default function AddOpenApiSource(props: { const preset = preview?.oauth2Presets[n.presetIndex]; if (preset) { setOauth2SelectedScopes(new Set(Object.keys(preset.scopes))); + setIncludeOAuth2IdentityScopes(preset.identityScopes !== false); } }), Match.exhaustive, @@ -617,141 +915,183 @@ export default function AddOpenApiSource(props: { setOauth2AuthState(null); }; - const handleConnectOAuth2 = useCallback(async () => { - if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return; - oauth.cancel(); - setOauth2Error(null); - const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; - - const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); - const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; - const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; - - if (selectedOAuth2Preset.flow === "clientCredentials") { - // RFC 6749 §4.4: no user-interactive consent step. The client_secret - // is mandatory; the backend exchanges tokens inline and returns a - // completed Connection we bind to the source's connection slot. - if (!oauth2ClientSecretSecretId) { - setOauth2Error("client_credentials requires a client secret"); - return; - } - setStartingOAuth(true); - const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); - const exit = await doStartOAuth({ - params: { scopeId: oauthTokenTargetScope }, - payload: { - endpoint: tokenUrl, - redirectUrl: tokenUrl, - connectionId, - tokenScope: oauthTokenTargetScope, - strategy: { - kind: "client-credentials", - tokenEndpoint: tokenUrl, - clientIdSecretId: oauth2ClientIdSecretId, - clientIdSecretScopeId: String(clientIdSecretScope), - clientSecretSecretId: oauth2ClientSecretSecretId, - clientSecretSecretScopeId: String(clientSecretSecretScope), - scopes: [...oauth2SelectedScopes], - }, - pluginId: "openapi", - identityLabel: `${displayName} OAuth`, - }, - }); - setStartingOAuth(false); - if (Exit.isFailure(exit)) { - setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); - return; - } - const response = exit.value; - if (!response.completedConnection) { - setOauth2Error("client_credentials flow did not mint a connection"); - return; - } - setOauth2AuthState({ - fingerprint: selectedOAuth2Fingerprint, - auth: { connectionId: response.completedConnection.connectionId }, - }); + const handleConnectOAuth2 = useCallback( + async (existingConnection?: OAuthConnectionChoice) => { + if (!selectedOAuth2Preset || !preview) return; + oauth.cancel(); setOauth2Error(null); - return; - } - - const authorizationUrl = resolveOAuthUrl( - Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), - resolvedBaseUrl, - ); - const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); - - await oauth.openAuthorization({ - tokenScope: oauthTokenTargetScope, - run: async () => { + const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; + const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope; + const connectionId = + existingConnection?.id ?? + openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); + const scopesForAuthorization = existingConnection + ? mergeOAuthScopes( + splitOAuthScopes(existingConnection.oauthScope), + selectedOAuth2IsGoogle ? oauth2SelectedScopes : selectedOAuth2Scopes, + ) + : selectedOAuth2Scopes; + const scopesForProviderAuthorization = selectedOAuth2IsGoogle + ? compactGoogleOAuthScopes(scopesForAuthorization) + : undefined; + const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle); + + const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); + const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; + + if (selectedOAuth2Preset.flow === "clientCredentials") { + if (!oauth2ClientIdSecretId) return; + // RFC 6749 §4.4: no user-interactive consent step. The client_secret + // is mandatory; the backend exchanges tokens inline and returns a + // completed Connection we bind to the source's connection slot. + if (!oauth2ClientSecretSecretId) { + setOauth2Error("client_credentials requires a client secret"); + return; + } + setStartingOAuth(true); const exit = await doStartOAuth({ - params: { scopeId: oauthTokenTargetScope }, + params: { scopeId: tokenTargetScope }, payload: { - endpoint: authorizationUrl, - connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), - tokenScope: oauthTokenTargetScope, - redirectUrl: oauth2RedirectUrl, + endpoint: tokenUrl, + redirectUrl: tokenUrl, + connectionId, + tokenScope: tokenTargetScope, strategy: { - kind: "authorization-code", - authorizationEndpoint: authorizationUrl, + kind: "client-credentials", tokenEndpoint: tokenUrl, - issuerUrl, clientIdSecretId: oauth2ClientIdSecretId, clientIdSecretScopeId: String(clientIdSecretScope), - clientSecretSecretId: oauth2ClientSecretSecretId ?? null, - clientSecretSecretScopeId: oauth2ClientSecretSecretId - ? String(clientSecretSecretScope) - : null, - scopes: [...oauth2SelectedScopes], + clientSecretSecretId: oauth2ClientSecretSecretId, + clientSecretSecretScopeId: String(clientSecretSecretScope), + scopes: scopesForAuthorization, }, pluginId: "openapi", identityLabel: `${displayName} OAuth`, }, }); + setStartingOAuth(false); if (Exit.isFailure(exit)) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() - throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); + setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); + return; } const response = exit.value; - if (response.authorizationUrl === null) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() - throw new Error("Unexpected response flow from server"); + if (!response.completedConnection) { + setOauth2Error("client_credentials flow did not mint a connection"); + return; } - return { - sessionId: response.sessionId, - authorizationUrl: response.authorizationUrl, - }; - }, - onSuccess: (result) => { + setOAuthTokenTargetScope(tokenTargetScope); setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, - auth: { connectionId: result.connectionId }, + auth: { connectionId: response.completedConnection.connectionId }, }); setOauth2Error(null); - }, - onError: (message) => { - setStartingOAuth(false); - setOauth2Error(message); - }, - }); - }, [ - selectedOAuth2Preset, - oauth2ClientIdSecretId, - oauth2ClientSecretSecretId, - oauth2SelectedScopes, - oauth2RedirectUrl, - resolvedBaseUrl, - preview, - doStartOAuth, - identity.name, - resolvedSourceId, - selectedOAuth2Fingerprint, - oauth, - oauthTokenTargetScope, - oauth2ClientIdScope, - oauth2ClientSecretScope, - sourceScope, - ]); + return; + } + if (!existingConnection && !oauth2ClientIdSecretId) return; + + const authorizationUrl = resolveOAuthUrl( + Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), + resolvedBaseUrl, + ); + const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); + const authorizationStrategy = existingConnection + ? { + kind: "authorization-code-existing-client" as const, + authorizationEndpoint: authorizationUrl, + tokenEndpoint: tokenUrl, + issuerUrl, + scopes: scopesForAuthorization, + ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 + ? { authorizationScopes: scopesForProviderAuthorization } + : {}), + extraAuthorizationParams, + } + : oauth2ClientIdSecretId + ? { + kind: "authorization-code" as const, + authorizationEndpoint: authorizationUrl, + tokenEndpoint: tokenUrl, + issuerUrl, + clientIdSecretId: oauth2ClientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScope), + clientSecretSecretId: oauth2ClientSecretSecretId ?? null, + clientSecretSecretScopeId: oauth2ClientSecretSecretId + ? String(clientSecretSecretScope) + : null, + scopes: scopesForAuthorization, + ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 + ? { authorizationScopes: scopesForProviderAuthorization } + : {}), + extraAuthorizationParams, + } + : null; + if (!authorizationStrategy) return; + + await oauth.openAuthorization({ + tokenScope: tokenTargetScope, + run: async () => { + const exit = await doStartOAuth({ + params: { scopeId: tokenTargetScope }, + payload: { + endpoint: authorizationUrl, + connectionId, + tokenScope: tokenTargetScope, + redirectUrl: oauth2RedirectUrl, + strategy: authorizationStrategy, + pluginId: "openapi", + identityLabel: existingConnection?.identityLabel ?? `${displayName} OAuth`, + }, + }); + if (Exit.isFailure(exit)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() + throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); + } + const response = exit.value; + if (response.authorizationUrl === null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() + throw new Error("Unexpected response flow from server"); + } + return { + sessionId: response.sessionId, + authorizationUrl: response.authorizationUrl, + }; + }, + onSuccess: (result) => { + setOAuthTokenTargetScope(tokenTargetScope); + setOauth2AuthState({ + fingerprint: selectedOAuth2Fingerprint, + auth: { connectionId: result.connectionId }, + }); + setOauth2Error(null); + }, + onError: (message) => { + setStartingOAuth(false); + setOauth2Error(message); + }, + }); + }, + [ + selectedOAuth2Preset, + oauth2ClientIdSecretId, + oauth2ClientSecretSecretId, + oauth2SelectedScopes, + selectedOAuth2Scopes, + selectedOAuth2IsGoogle, + oauth2RedirectUrl, + resolvedBaseUrl, + preview, + doStartOAuth, + identity.name, + resolvedSourceId, + selectedOAuth2Fingerprint, + oauth, + oauthTokenTargetScope, + oauth2ClientIdScope, + oauth2ClientSecretScope, + sourceScope, + ], + ); const handleCancelOAuth2 = useCallback(() => { oauth.cancel(); @@ -759,18 +1099,29 @@ export default function AddOpenApiSource(props: { setOauth2Error(null); }, [oauth]); + const handleReuseOAuthConnection = useCallback( + (connection: OAuthConnectionChoice) => { + oauth.cancel(); + setStartingOAuth(false); + setOAuthTokenTargetScope(connection.scopeId); + setOauth2AuthState({ + fingerprint: selectedOAuth2Fingerprint, + auth: { connectionId: connection.id }, + }); + setOauth2Error(null); + }, + [oauth, selectedOAuth2Fingerprint], + ); + const handleAdd = async () => { setAdding(true); setAddError(null); const namespace = resolvedSourceId; - const displayName = - identity.name.trim() || - (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); const exit = await doAdd({ params: { scopeId }, payload: { spec: specInputForAdd(specUrl), - name: displayName, + name: resolvedDisplayName, namespace, baseUrl: resolvedBaseUrl, ...(configuredSpecFetchCredentials @@ -1021,6 +1372,7 @@ export default function AddOpenApiSource(props: { setOauth2AuthState(null); setOauth2Error(null); }} + faviconIcon={previewPresetIcon} faviconUrl={resolvedBaseUrl} baseUrlMissingMessage="A base URL is required to make requests." /> @@ -1157,15 +1509,22 @@ export default function AddOpenApiSource(props: { -
- Client ID secret -
+
+
-
- Secret - - Select or create the OAuth client ID secret. - +
+
+ Client ID + + Select or create the OAuth client ID secret. + +
+
+ Required OAuth client identifier. +
-
-
- - Client secret{" "} - - · optional for public clients with PKCE - - -
+
-
- Secret - - Select or create the OAuth client secret. - +
+
+ Client Secret + + Select or create the OAuth client secret. + +
+
+ Optional for public clients with PKCE. +
-
- Scopes -
- {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? ( -
- No scopes declared by the spec. -
- ) : ( - Object.entries(selectedOAuth2Preset.scopes).map(([scope, description]) => ( -
+ )} +
+ + {oauth2Auth ? ( -
-
- Connected · {oauth2SelectedScopes.size} scope - {oauth2SelectedScopes.size === 1 ? "" : "s"} granted +
+
+
-
) : (
+ {existingOAuthConnections.length > 0 && ( +
+
+ + Use {selectedOAuth2ProviderLabel} account + +
+ Continue with an account you already connected. If permissions are + missing, Google will confirm access for this source. +
+
+
+ {existingOAuthConnections.map((connection) => ( + + connection.missingApiScopes.length > 0 + ? void handleConnectOAuth2(connection) + : handleReuseOAuthConnection(connection) + } + /> + ))} +
+
+ )}
- OAuth sign-in + + {existingOAuthConnections.length > 0 + ? "Sign in with another account" + : "OAuth sign-in"} + Start the provider OAuth flow.
+ ) : null} +
+
+ ); +} + const effectiveClientSecretSlot = (oauth2: { readonly securitySchemeName: string; readonly clientSecretSlot: string | null; }): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName); -const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => { +const sourceCredentialSlots = ( + source: StoredSourceSchemaType, + options?: { readonly hasLiveOAuthConnection?: boolean }, +): readonly SourceCredentialSlot[] => { const slots: SourceCredentialSlot[] = []; for (const [name, value] of Object.entries(source.config.headers ?? {})) { if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); @@ -36,12 +90,14 @@ const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceC } const oauth2 = source.config.oauth2; if (oauth2) { - slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); - slots.push({ - kind: "secret", - slot: effectiveClientSecretSlot(oauth2), - label: "Client Secret", - }); + if (!options?.hasLiveOAuthConnection) { + slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); + slots.push({ + kind: "secret", + slot: effectiveClientSecretSlot(oauth2), + label: "Client Secret", + }); + } slots.push({ kind: "connection", slot: oauth2.connectionSlot, @@ -93,8 +149,21 @@ export default function OpenApiSourceSummary(props: { const liveConnectionIds = new Set(connections.map((connection) => connection.id)); const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); const credentialTargetScope = userScope; + const connectionBinding = effectiveBindingForScope( + bindings, + oauth2?.connectionSlot ?? "", + credentialTargetScope, + scopeRanks, + ); + const connectionId = + connectionBinding && connectionBinding.value.kind === "connection" + ? connectionBinding.value.connectionId + : null; + const connection = connectionId + ? (connections.find((candidate) => candidate.id === connectionId) ?? null) + : null; const missing = missingSourceCredentialLabels({ - slots: sourceCredentialSlots(source), + slots: sourceCredentialSlots(source, { hasLiveOAuthConnection: connection !== null }), bindings, targetScope: credentialTargetScope, scopeRanks, @@ -102,24 +171,18 @@ export default function OpenApiSourceSummary(props: { }); if (props.variant === "panel") { - return ; + if (missing.length > 0) + return ; + return connection ? ( + + ) : null; } if (missing.length > 0) return ; if (!oauth2) return null; - const connectionBinding = effectiveBindingForScope( - bindings, - oauth2.connectionSlot, - credentialTargetScope, - scopeRanks, - ); - const connectionId = - connectionBinding && connectionBinding.value.kind === "connection" - ? connectionBinding.value.connectionId - : null; - if (connectionId && connections.some((connection) => connection.id === connectionId)) { + if (connection) { return ; } diff --git a/packages/plugins/openapi/src/sdk/credential-status.test.ts b/packages/plugins/openapi/src/sdk/credential-status.test.ts index 9539a5909..3aec3ffdb 100644 --- a/packages/plugins/openapi/src/sdk/credential-status.test.ts +++ b/packages/plugins/openapi/src/sdk/credential-status.test.ts @@ -30,6 +30,18 @@ const source: SourceForCredentialStatus = { }, }; +const authorizationCodeSource: SourceForCredentialStatus = { + config: { + oauth2: { + securitySchemeName: "oauth2", + flow: "authorizationCode", + clientIdSlot: "oauth2:oauth2:client-id", + clientSecretSlot: "oauth2:oauth2:client-secret", + connectionSlot: "oauth2:oauth2:connection", + }, + }, +}; + const bindings = ( scopeId: ScopeId, slots: readonly string[], @@ -81,6 +93,28 @@ describe("OpenAPI credential status", () => { ).toEqual(["OAuth client connection"]); }); + it("treats a live authorization-code connection as ready without source client credentials", () => { + expect( + missingCredentialLabels( + authorizationCodeSource, + bindings(userScope, ["oauth2:oauth2:connection"]), + userScope, + scopeRanks, + { + liveConnectionIds: [ConnectionId.make("user-connection")], + }, + ), + ).toEqual([]); + }); + + it("requires client credentials before starting authorization-code OAuth when no connection is bound", () => { + expect( + missingCredentialLabels(authorizationCodeSource, [], userScope, scopeRanks, { + liveConnectionIds: [], + }), + ).toEqual(["Client ID", "Client Secret", "OAuth sign-in"]); + }); + it("does not treat personal bindings as satisfying org-level credential status", () => { expect( missingCredentialLabels(source, bindings(userScope, allSlots), orgScope, scopeRanks), diff --git a/packages/plugins/openapi/src/sdk/credential-status.ts b/packages/plugins/openapi/src/sdk/credential-status.ts index e5a177cd6..c4c2eaf70 100644 --- a/packages/plugins/openapi/src/sdk/credential-status.ts +++ b/packages/plugins/openapi/src/sdk/credential-status.ts @@ -88,18 +88,23 @@ export function missingCredentialLabels( const oauth2 = source.config.oauth2; if (!oauth2) return missing; - if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) { - missing.push("Client ID"); - } + const hasLiveConnection = hasConnectionBinding( + bindings, + oauth2.connectionSlot, + targetScope, + ranks, + liveConnectionIds, + ); + if (!hasLiveConnection) { + if (!hasSecretBinding(bindings, oauth2.clientIdSlot, targetScope, ranks)) { + missing.push("Client ID"); + } - const clientSecretSlot = effectiveClientSecretSlot(oauth2); - if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) { - missing.push("Client Secret"); - } + const clientSecretSlot = effectiveClientSecretSlot(oauth2); + if (!hasSecretBinding(bindings, clientSecretSlot, targetScope, ranks)) { + missing.push("Client Secret"); + } - if ( - !hasConnectionBinding(bindings, oauth2.connectionSlot, targetScope, ranks, liveConnectionIds) - ) { missing.push(oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in"); } diff --git a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts index cdfce81e2..630ff7940 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts @@ -46,6 +46,7 @@ const makeOauth2SourceConfig = (params: { readonly tokenUrl: string; readonly authorizationUrl: string | null; readonly scopes: readonly string[]; + readonly identityScopes?: OAuth2SourceConfig["identityScopes"]; }): OAuth2SourceConfig => OAuth2SourceConfig.make({ kind: "oauth2", @@ -57,8 +58,20 @@ const makeOauth2SourceConfig = (params: { clientSecretSlot: "oauth2:oauth2:client-secret", connectionSlot: "oauth2:oauth2:connection", scopes: [...params.scopes], + ...(params.identityScopes !== undefined ? { identityScopes: params.identityScopes } : {}), }); +const resolvedOAuthScopes = (oauth2: OAuth2SourceConfig): string[] => { + const merged = new Set(oauth2.scopes); + if (oauth2.identityScopes === false) return [...merged]; + const extras = + oauth2.identityScopes === undefined || oauth2.identityScopes === "auto" + ? ["openid", "email", "profile"] + : oauth2.identityScopes; + for (const scope of extras) merged.add(scope); + return [...merged]; +}; + // --------------------------------------------------------------------------- // Test API — a single endpoint that echoes the Authorization header so the // test can assert which user's token got injected. @@ -360,6 +373,87 @@ describe("OpenAPI multi-scope OAuth", () => { }), ); + it.effect("authorization-code OpenAPI sources request identity scopes by default", () => + Effect.gen(function* () { + const secretStore = new Map(); + const key = (scope: string, id: string) => `${scope} ${id}`; + const memoryProvider: SecretProvider = { + key: "memory", + writable: true, + get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null), + set: (id, value, scope) => + Effect.sync(() => { + secretStore.set(key(scope, id), value); + }), + delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))), + }; + const memorySecretsPlugin = definePlugin(() => ({ + id: "memory-secrets" as const, + storage: () => ({}), + secretProviders: [memoryProvider], + })); + const userScope = Scope.make({ + id: ScopeId.make("user-alice"), + name: "Alice", + createdAt: new Date(), + }); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [openApiPlugin(), memorySecretsPlugin()] as const, + scopes: [userScope], + }), + ); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("petstore_client_id"), + scope: userScope.id, + name: "Petstore Client ID", + value: "client-abc", + }), + ); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("petstore_client_secret"), + scope: userScope.id, + name: "Petstore Client Secret", + value: "secret-xyz", + }), + ); + const oauth = yield* serveOAuthTestServer({ + defaultClientId: "client-abc", + defaultClientSecret: "secret-xyz", + }); + const oauth2 = makeOauth2SourceConfig({ + flow: "authorizationCode", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["read"], + }); + + const start = yield* executor.oauth.start({ + endpoint: oauth.authorizationEndpoint, + redirectUrl: "https://app.example.com/oauth/callback", + connectionId: "openapi-oauth2-user-petstore", + tokenScope: String(userScope.id), + pluginId: "openapi", + identityLabel: "Petstore OAuth", + strategy: { + kind: "authorization-code", + authorizationEndpoint: oauth.authorizationEndpoint, + tokenEndpoint: oauth.tokenEndpoint, + issuerUrl: oauth.issuerUrl, + clientIdSecretId: "petstore_client_id", + clientSecretSecretId: "petstore_client_secret", + scopes: resolvedOAuthScopes(oauth2), + }, + }); + + expect(start.authorizationUrl).not.toBeNull(); + const authorizationUrl = new URL(start.authorizationUrl ?? ""); + expect(authorizationUrl.searchParams.get("scope")).toBe("read openid email profile"); + }), + ); + // ------------------------------------------------------------------------- // Regression: repeated `clientCredentials` sign-ins used to mint a fresh // random UUID per call AND rewrite `source.oauth2.connectionId` to that diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 9913b6263..c7b39e4dc 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -303,6 +303,11 @@ const StaticPreviewOAuth2PresetSchema = Schema.Struct({ tokenUrl: Schema.String, refreshUrl: Schema.NullOr(Schema.String), scopes: Schema.Record(Schema.String, Schema.String), + identityScopes: Schema.Union([ + Schema.Literal("auto"), + Schema.Literal(false), + Schema.Array(Schema.String), + ]), }); const StaticPreviewSpecOutputSchema = Schema.Struct({ title: Schema.NullOr(Schema.String), @@ -535,6 +540,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( tokenUrl: preset.tokenUrl, refreshUrl: Option.getOrNull(preset.refreshUrl), scopes: preset.scopes, + identityScopes: preset.identityScopes, })), }); diff --git a/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts b/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts index 8d3b563f1..cf9064805 100644 --- a/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +++ b/packages/plugins/openapi/src/sdk/preview-oauth2.test.ts @@ -96,6 +96,7 @@ describe("previewSpec OAuth2 extraction", () => { read: "Read access", write: "Write access", }); + expect(preset.identityScopes).toBe("auto"); }), ); @@ -126,6 +127,7 @@ describe("previewSpec OAuth2 extraction", () => { const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!; expect(Option.isNone(cc.authorizationUrl)).toBe(true); expect(cc.scopes).toEqual({ "admin:read": "Admin read" }); + expect(cc.identityScopes).toBe(false); }), ); diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 34d251d86..e38d86f51 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -106,6 +106,12 @@ export const OAuth2Preset = Schema.Struct({ refreshUrl: Schema.OptionFromOptional(Schema.String), /** Declared scopes for this flow: `{ scope: description }`. */ scopes: Schema.Record(Schema.String, Schema.String), + /** Identity scopes to request alongside API scopes. `"auto"` discovers standard OIDC scopes. */ + identityScopes: Schema.Union([ + Schema.Literal("auto"), + Schema.Literal(false), + Schema.Array(Schema.String), + ]), }); export type OAuth2Preset = typeof OAuth2Preset.Type; @@ -328,6 +334,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] tokenUrl: flow.tokenUrl, refreshUrl: flow.refreshUrl, scopes: flow.scopes, + identityScopes: "auto", }), ); } @@ -343,6 +350,7 @@ const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] tokenUrl: flow.tokenUrl, refreshUrl: flow.refreshUrl, scopes: flow.scopes, + identityScopes: false, }), ); } diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 44386a483..e5959b446 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -7,9 +7,10 @@ import { } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import * as Effect from "effect/Effect"; import { ExecutorApiClient } from "./client"; -import { ReactivityKey } from "./reactivity-keys"; +import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; // --------------------------------------------------------------------------- // Scope — fetched from the server @@ -178,6 +179,13 @@ export const completeOAuth = ExecutorApiClient.mutation("oauth", "complete"); export const cancelOAuth = ExecutorApiClient.mutation("oauth", "cancel"); +export const oauthConnectionCompleted = ExecutorApiClient.runtime.fn<{ + readonly tokenScope: string; + readonly reactivityKeys: typeof connectionWriteKeys; +}>()(() => Effect.void, { + reactivityKeys: connectionWriteKeys, +}); + export const createPolicy = ExecutorApiClient.mutation("policies", "create"); export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx index ab20c77c7..5f280dc2a 100644 --- a/packages/react/src/plugins/oauth-sign-in.tsx +++ b/packages/react/src/plugins/oauth-sign-in.tsx @@ -4,7 +4,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import { cancelOAuth, startOAuth } from "../api/atoms"; +import { cancelOAuth, oauthConnectionCompleted, startOAuth } from "../api/atoms"; import { messageFromExit, messageFromUnknown, useReportHandledError } from "../api/error-reporting"; import { openOAuthPopup, @@ -12,6 +12,7 @@ import { reserveOAuthPopup, type OAuthPopupResult, } from "../api/oauth-popup"; +import { connectionWriteKeys } from "../api/reactivity-keys"; type DesktopBridge = { readonly openExternal: (url: string) => Promise; @@ -73,7 +74,7 @@ export type StartOAuthAuthorizationInput Promise; readonly onSuccess: (payload: TPayload) => void | Promise; - readonly onError?: (error: string) => void; + readonly onError?: (error: string, details?: string) => void; readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void; readonly reportMetadata?: Record; }; @@ -119,6 +120,7 @@ export function useOAuthPopupFlow< } = options; const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" }); + const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" }); const reportHandledError = useReportHandledError(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -222,7 +224,25 @@ export function useOAuthPopupFlow< if (!result.ok) { setBusy(false); setError(result.error); - input.onError?.(result.error); + input.onError?.(result.error, result.errorDetails); + return; + } + + const refreshExit = await doOAuthConnectionCompleted({ + tokenScope: input.tokenScope, + reactivityKeys: connectionWriteKeys, + }); + if (Exit.isFailure(refreshExit)) { + const message = messageFromExit(refreshExit, "Failed to refresh connection"); + reportHandledError(refreshExit.cause, { + surface: "oauth", + action: "refresh_connection", + message, + metadata: input.reportMetadata, + }); + setBusy(false); + setError(message); + input.onError?.(message); return; } @@ -292,6 +312,7 @@ export function useOAuthPopupFlow< cancel, cancelSession, detectPopupClosed, + doOAuthConnectionCompleted, noAuthorizationUrlMessage, popupBlockedMessage, popupClosedMessage,