diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 46d38b126..d30fc1b83 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -37,6 +37,10 @@ const OpenApiSpecInputPayload = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }), + Schema.Struct({ + kind: Schema.Literal("googleDiscoveryBundle"), + urls: Schema.Array(Schema.String), + }), ]); const PreviewSpecFetchCredentialsPayload = Schema.Struct({ diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 3be0cd284..ac50dcd88 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -97,6 +97,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope name: source.name, config: { sourceUrl: source.config.sourceUrl, + googleDiscoveryUrls: source.config.googleDiscoveryUrls, baseUrl: source.config.baseUrl, namespace: source.config.namespace, headers: source.config.headers, diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index f95404120..4eb425572 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -65,6 +65,7 @@ import { import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; +import { Info, InfoDescription, InfoTitle } from "@executor-js/react/components/info"; import { Label } from "@executor-js/react/components/label"; import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; @@ -72,7 +73,12 @@ 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 { + googleOpenApiPresets, + googleStandardUserOAuthPresets, + openApiPresets, +} from "../sdk/presets"; +import { GOOGLE_BUNDLE_PRESET_ID } from "../sdk/google-presets"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { headerBindingSlot, @@ -85,9 +91,19 @@ import { } from "../sdk/source-contracts"; import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; +import { + compactGoogleOAuthScopes, + filterGoogleUserConsentOAuthScopes, +} from "../sdk/google-oauth-scopes"; +import { googleOAuthConsentBatches } from "../sdk/google-oauth-batches"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; +const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; +const GOOGLE_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; +const GOOGLE_BUNDLE_DEFAULT_PRESET = googleOpenApiPresets[0]!; +const GOOGLE_STANDARD_SERVICE_IDS = googleStandardUserOAuthPresets.map((preset) => preset.id); +const GOOGLE_BROAD_SELECTION_WARNING_THRESHOLD = 6; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -98,6 +114,22 @@ const errorMessageFromExit = (exit: Exit.Exit, fallback: strin onSome: ({ message }) => message, }); +const googleOAuthErrorMessage = + "Google did not approve this permission request. Try selecting fewer services, or add sensitive services as separate Google sources."; + +const oauthErrorMessage = (providerLabel: string, message: string, details?: string): string => { + if (providerLabel !== "Google") return details ? `${message}: ${details}` : message; + const combined = `${message}\n${details ?? ""}`; + if ( + /Authorization server returned error|Token exchange failed|invalid_grant|access_denied|Something went wrong|unknownerror/i.test( + combined, + ) + ) { + return googleOAuthErrorMessage; + } + return details ? `${message}: ${details}` : message; +}; + export const openApiOAuthConnectionId = ( namespaceSlug: string, flow: OAuth2Preset["flow"], @@ -170,11 +202,10 @@ const mergeOAuthScopes = (...values: readonly Iterable[]): string[] => { return [...merged]; }; -const missingOAuthScopes = ( - connection: { readonly oauthScope: string | null }, +const missingOAuthScopesFromGranted = ( + granted: ReadonlySet, requiredApiScopes: Iterable, ): readonly string[] => { - const granted = splitOAuthScopes(connection.oauthScope); return [...requiredApiScopes].filter((scope) => !granted.has(scope)); }; @@ -207,46 +238,6 @@ const googleAuthorizationParams = (enabled: boolean): Record | u } : 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; @@ -256,6 +247,25 @@ type OAuthConnectionChoice = { readonly missingApiScopes: readonly string[]; }; +type GoogleServicePreviewState = + | { readonly status: "loading" } + | { + readonly status: "success"; + readonly preview: SpecPreview; + readonly baseUrl: string; + } + | { readonly status: "error"; readonly message: string }; + +type GoogleServiceAddItem = { + readonly preset: (typeof googleOpenApiPresets)[number]; + readonly preview: SpecPreview; +}; + +const scopesForGoogleServiceItem = (item: GoogleServiceAddItem): readonly string[] => { + const oauth2Preset = item.preview.oauth2Presets[0]; + return filterGoogleUserConsentOAuthScopes(Object.keys(oauth2Preset?.scopes ?? {})); +}; + const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( @@ -289,6 +299,25 @@ const normalizePresetUrl = (url: string): string => { return parsed.toString().replace(/\/$/, ""); }; +const googlePresetForSpec = ( + presetId: string | undefined, + specUrl: string, +): (typeof googleOpenApiPresets)[number] | null => { + const normalizedSpecUrl = normalizePresetUrl(specUrl); + const byUrl = googleOpenApiPresets.find( + (preset) => preset.url && normalizePresetUrl(preset.url) === normalizedSpecUrl, + ); + if (byUrl) return byUrl; + const byId = presetId ? googleOpenApiPresets.find((preset) => preset.id === presetId) : undefined; + if (byId) return byId; + return null; +}; + +const firstBaseUrlForPreview = (preview: SpecPreview): string => { + const firstServer = preview.servers[0]; + return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; +}; + type StrategySelection = | { readonly kind: "none" } | { readonly kind: "custom" } @@ -348,6 +377,31 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { }); } +const oauth2ConfigForPreset = (input: { + readonly preset: OAuth2Preset; + readonly baseUrl: string; + readonly scopes: Iterable; + readonly identityScopes: OAuth2Preset["identityScopes"]; +}): OAuth2SourceConfig => + OAuth2SourceConfig.make({ + kind: "oauth2", + securitySchemeName: input.preset.securitySchemeName, + flow: input.preset.flow, + tokenUrl: resolveOAuthUrl(input.preset.tokenUrl, input.baseUrl), + authorizationUrl: + input.preset.flow === "authorizationCode" + ? resolveOAuthUrl( + Option.getOrElse(input.preset.authorizationUrl, () => ""), + input.baseUrl, + ) || null + : null, + clientIdSlot: oauth2ClientIdSlot(input.preset.securitySchemeName), + clientSecretSlot: oauth2ClientSecretSlot(input.preset.securitySchemeName), + connectionSlot: oauth2ConnectionSlot(input.preset.securitySchemeName), + scopes: [...input.scopes], + identityScopes: input.identityScopes, + }); + function OAuthConnectedAccount(props: { readonly scopeId: ScopeId; readonly connectionId: string; @@ -474,19 +528,30 @@ export default function AddOpenApiSource(props: { onComplete: () => void; onCancel: () => void; initialUrl?: string; + initialPreset?: string; initialNamespace?: string; }) { // Spec input - const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); + const isGoogleBundlePreset = props.initialPreset === GOOGLE_BUNDLE_PRESET_ID; + const [specUrl, setSpecUrl] = useState( + props.initialUrl ?? (isGoogleBundlePreset ? (GOOGLE_BUNDLE_DEFAULT_PRESET.url ?? "") : ""), + ); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); + const initialGooglePreset = + preview && isGoogleBundlePreset ? googlePresetForSpec(undefined, specUrl) : null; + const identityFallbackName = preview + ? isGoogleBundlePreset + ? "Google" + : Option.getOrElse(preview.title, () => "") + : ""; const identity = useSourceIdentity({ - fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", - fallbackNamespace: props.initialNamespace, + fallbackName: identityFallbackName, + fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined), }); // Auth @@ -510,10 +575,21 @@ export default function AddOpenApiSource(props: { const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false); const [oauth2AuthState, setOauth2AuthState] = useState<{ readonly fingerprint: string; - readonly auth: { readonly connectionId: string }; + readonly auth: { + readonly connectionId: string; + readonly grantedScopes: readonly string[]; + readonly scopeId: ScopeId; + }; } | null>(null); const [startingOAuth, setStartingOAuth] = useState(false); + const [oauth2ProgressLabel, setOauth2ProgressLabel] = useState(null); const [oauth2Error, setOauth2Error] = useState(null); + const [selectedGoogleServiceIds, setSelectedGoogleServiceIds] = useState>( + () => new Set(), + ); + const [googleServicePreviews, setGoogleServicePreviews] = useState< + Record + >({}); // Submit const [adding, setAdding] = useState(false); @@ -579,6 +655,7 @@ export default function AddOpenApiSource(props: { // Keep the latest handleAnalyze in a ref so the debounced effect doesn't // need it as a dependency (it closes over fresh state). const handleAnalyzeRef = useRef<() => void>(() => {}); + const googleBundleSelectionSeeded = useRef(false); // Auto-analyze whenever the spec input changes, with a short debounce so // typing/pasting doesn't fire a request on every keystroke. @@ -606,8 +683,38 @@ export default function AddOpenApiSource(props: { new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); const previewPresetIcon = - openApiPresets.find((preset) => normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl)) - ?.icon ?? null; + openApiPresets.find( + (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), + )?.icon ?? null; + const primaryGooglePreset = initialGooglePreset; + const selectedGoogleServiceIdList = useMemo(() => { + return [...selectedGoogleServiceIds]; + }, [selectedGoogleServiceIds]); + const allStandardGoogleServicesSelected = GOOGLE_STANDARD_SERVICE_IDS.every((presetId) => + selectedGoogleServiceIds.has(presetId), + ); + const selectedGoogleWorkspaceAdminServiceCount = selectedGoogleServiceIdList.filter((presetId) => + googleOpenApiPresets.some( + (preset) => preset.id === presetId && preset.oauthAudience === "workspace-admin", + ), + ).length; + const selectedGoogleAdvancedServiceCount = selectedGoogleServiceIdList.filter((presetId) => + googleOpenApiPresets.some( + (preset) => preset.id === presetId && preset.oauthAudience === "advanced-user", + ), + ).length; + const showGoogleSelectionWarning = + selectedGoogleServiceIdList.length >= GOOGLE_BROAD_SELECTION_WARNING_THRESHOLD || + selectedGoogleWorkspaceAdminServiceCount > 0 || + selectedGoogleAdvancedServiceCount > 0; + const selectedGooglePresets = useMemo( + () => + selectedGoogleServiceIdList.flatMap((presetId) => { + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); + return preset ? [preset] : []; + }), + [selectedGoogleServiceIdList], + ); const resolvedBaseUrl = baseUrl.trim(); const sourceScope = ScopeId.make(scopeId); @@ -727,15 +834,94 @@ export default function AddOpenApiSource(props: { Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), ].join("\n") : ""; - const oauth2Auth = + const activeOAuth2AuthState = oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null; const selectedOAuth2AvailableIdentityScopes = selectedOAuth2Preset ? identityScopesForPreset(selectedOAuth2Preset.identityScopes) : []; const selectedOAuth2IsGoogle = selectedOAuth2Preset - ? isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl) + ? isGoogleBundlePreset || isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl) : false; const selectedOAuth2ProviderLabel = selectedOAuth2IsGoogle ? "Google" : "OAuth"; + const googleServicePickerEnabled = Boolean( + preview && isGoogleBundlePreset && selectedOAuth2IsGoogle, + ); + useEffect(() => { + if (!selectedOAuth2IsGoogle) return; + if (!oauth2ClientIdSecretId) { + const clientIdSecret = secretList.find((secret) => secret.id === "google-client-id"); + if (clientIdSecret) { + setOauth2ClientIdSecretId(clientIdSecret.id); + setOauth2ClientIdScope(ScopeId.make(clientIdSecret.scopeId)); + } + } + if (!oauth2ClientSecretSecretId) { + const clientSecretSecret = secretList.find((secret) => secret.id === "google-client-secret"); + if (clientSecretSecret) { + setOauth2ClientSecretSecretId(clientSecretSecret.id); + setOauth2ClientSecretScope(ScopeId.make(clientSecretSecret.scopeId)); + } + } + }, [oauth2ClientIdSecretId, oauth2ClientSecretSecretId, secretList, selectedOAuth2IsGoogle]); + const googleBatchAddItems = useMemo((): readonly GoogleServiceAddItem[] => { + if (!preview || !primaryGooglePreset || !googleServicePickerEnabled) return []; + const items: GoogleServiceAddItem[] = []; + for (const presetId of selectedGoogleServiceIdList) { + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); + if (!preset) continue; + if (preset.id === primaryGooglePreset.id) { + items.push({ + preset, + preview, + }); + continue; + } + const previewState = googleServicePreviews[preset.id]; + if (previewState?.status === "success") { + items.push({ + preset, + preview: previewState.preview, + }); + } + } + return items; + }, [ + googleServicePickerEnabled, + googleServicePreviews, + preview, + primaryGooglePreset, + selectedGoogleServiceIdList, + ]); + const googleBatchPendingCount = googleServicePickerEnabled + ? selectedGoogleServiceIdList.length - googleBatchAddItems.length + : 0; + const googleBatchError = googleServicePickerEnabled + ? selectedGoogleServiceIdList + .map((presetId) => googleServicePreviews[presetId]) + .find((state) => state?.status === "error") + : undefined; + const effectiveOAuth2SelectedApiScopes = useMemo(() => { + if (!googleServicePickerEnabled) { + return new Set( + selectedOAuth2IsGoogle + ? filterGoogleUserConsentOAuthScopes(oauth2SelectedScopes) + : oauth2SelectedScopes, + ); + } + const scopes = new Set(); + for (const item of googleBatchAddItems) { + const preset = item.preview.oauth2Presets[0]; + for (const scope of filterGoogleUserConsentOAuthScopes(Object.keys(preset?.scopes ?? {}))) { + scopes.add(scope); + } + } + return scopes; + }, [ + googleBatchAddItems, + googleServicePickerEnabled, + oauth2SelectedScopes, + selectedOAuth2IsGoogle, + ]); const configuredOAuth2IdentityScopes = selectedOAuth2Preset && includeOAuth2IdentityScopes ? selectedOAuth2Preset.identityScopes @@ -743,22 +929,130 @@ export default function AddOpenApiSource(props: { const selectedOAuth2Scopes = useMemo( () => selectedOAuth2Preset - ? resolvedOAuthScopes(oauth2SelectedScopes, configuredOAuth2IdentityScopes) - : [...oauth2SelectedScopes], - [configuredOAuth2IdentityScopes, oauth2SelectedScopes, selectedOAuth2Preset], + ? resolvedOAuthScopes(effectiveOAuth2SelectedApiScopes, configuredOAuth2IdentityScopes) + : [...effectiveOAuth2SelectedApiScopes], + [configuredOAuth2IdentityScopes, effectiveOAuth2SelectedApiScopes, selectedOAuth2Preset], + ); + const oauth2Auth = useMemo(() => { + if (!activeOAuth2AuthState) return null; + const granted = new Set(activeOAuth2AuthState.grantedScopes); + return selectedOAuth2Scopes.every((scope) => granted.has(scope)) ? activeOAuth2AuthState : null; + }, [activeOAuth2AuthState, selectedOAuth2Scopes]); + const grantedScopesForConnection = useCallback( + (connection: { + readonly id: ConnectionId; + readonly oauthScope: string | null; + }): Set => { + const granted = splitOAuthScopes(connection.oauthScope); + if (activeOAuth2AuthState?.connectionId === connection.id) { + for (const scope of activeOAuth2AuthState.grantedScopes) granted.add(scope); + } + return granted; + }, + [activeOAuth2AuthState], ); + const googleConsentBatches = useMemo( + () => + googleServicePickerEnabled + ? googleOAuthConsentBatches( + googleBatchAddItems.map((item) => ({ + id: item.preset.id, + name: item.preset.name, + oauthAudience: item.preset.oauthAudience, + scopes: scopesForGoogleServiceItem(item), + })), + ) + : [], + [googleBatchAddItems, googleServicePickerEnabled], + ); + useEffect(() => { + if (!googleServicePickerEnabled || !primaryGooglePreset) { + googleBundleSelectionSeeded.current = false; + setSelectedGoogleServiceIds((previous) => + previous.size === 0 ? previous : new Set(), + ); + setGoogleServicePreviews((previous) => (Object.keys(previous).length === 0 ? previous : {})); + return; + } + setBaseUrl((previous) => + previous === GOOGLE_BUNDLE_BASE_URL ? previous : GOOGLE_BUNDLE_BASE_URL, + ); + if (!googleBundleSelectionSeeded.current) { + googleBundleSelectionSeeded.current = true; + setSelectedGoogleServiceIds((previous) => { + if (previous.size > 0 || previous.has(primaryGooglePreset.id)) return previous; + return new Set([primaryGooglePreset.id]); + }); + } + }, [googleServicePickerEnabled, primaryGooglePreset]); + + useEffect(() => { + if (!googleServicePickerEnabled || !primaryGooglePreset) return; + const missingPresetIds = selectedGoogleServiceIdList.filter( + (presetId) => presetId !== primaryGooglePreset.id && !googleServicePreviews[presetId], + ); + if (missingPresetIds.length === 0) return; + setGoogleServicePreviews((previous) => { + const next = { ...previous }; + for (const presetId of missingPresetIds) next[presetId] = { status: "loading" }; + return next; + }); + void (async () => { + for (const presetId of missingPresetIds) { + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); + if (!preset) continue; + if (!preset.url) continue; + const exit = await doPreview({ + params: { scopeId }, + payload: { + spec: preset.url, + }, + }); + setGoogleServicePreviews((previous) => ({ + ...previous, + [preset.id]: Exit.isSuccess(exit) + ? { + status: "success", + preview: exit.value, + baseUrl: firstBaseUrlForPreview(exit.value), + } + : { + status: "error", + message: errorMessageFromExit(exit, `Failed to preview ${preset.name}`), + }, + })); + } + })(); + }, [ + doPreview, + googleServicePickerEnabled, + googleServicePreviews, + primaryGooglePreset, + scopeId, + selectedGoogleServiceIdList, + ]); + + const googleBundleOperationCount = googleBatchAddItems.reduce( + (count, item) => count + item.preview.operationCount, + 0, + ); + const existingOAuthConnections = useMemo(() => { if ( !selectedOAuth2Preset || selectedOAuth2Preset.flow !== "authorizationCode" || - !AsyncResult.isSuccess(connectionsResult) + !AsyncResult.isSuccess(connectionsResult) || + (googleServicePickerEnabled && selectedGoogleServiceIdList.length === 0) ) { return []; } return connectionsResult.value .flatMap((connection) => { if (connection.provider !== "oauth2") return []; - const missingApiScopes = missingOAuthScopes(connection, oauth2SelectedScopes); + const missingApiScopes = missingOAuthScopesFromGranted( + grantedScopesForConnection(connection), + effectiveOAuth2SelectedApiScopes, + ); if (missingApiScopes.length === 0) { return [{ ...connection, missingApiScopes }]; } @@ -768,35 +1062,35 @@ export default function AddOpenApiSource(props: { return []; }) .sort((a, b) => a.missingApiScopes.length - b.missingApiScopes.length); - }, [connectionsResult, oauth2SelectedScopes, selectedOAuth2IsGoogle, selectedOAuth2Preset]); - + }, [ + connectionsResult, + effectiveOAuth2SelectedApiScopes, + grantedScopesForConnection, + googleServicePickerEnabled, + selectedOAuth2IsGoogle, + selectedOAuth2Preset, + selectedGoogleServiceIdList.length, + ]); + + const effectiveResolvedBaseUrl = resolvedBaseUrl; const configuredOAuth2 = strategy.kind === "oauth2" && selectedOAuth2Preset - ? OAuth2SourceConfig.make({ - kind: "oauth2", - securitySchemeName: selectedOAuth2Preset.securitySchemeName, - flow: selectedOAuth2Preset.flow, - tokenUrl: resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl), - authorizationUrl: - selectedOAuth2Preset.flow === "authorizationCode" - ? resolveOAuthUrl( - Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), - resolvedBaseUrl, - ) || null - : null, - clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName), - // Authorization-code specs can still be confidential clients - // (Spotify is one example). Persist the slot even when the value is - // deferred so the edit screen can collect the secret later. - clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), - connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), - scopes: [...oauth2SelectedScopes], + ? oauth2ConfigForPreset({ + preset: selectedOAuth2Preset, + baseUrl: effectiveResolvedBaseUrl, + scopes: effectiveOAuth2SelectedApiScopes, identityScopes: configuredOAuth2IdentityScopes, }) : null; const hasHeaders = Object.keys(configuredHeaders).length > 0; const oauth2Busy = startingOAuth || oauth.busy; - const canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; + const canConnectOAuth2 = + Boolean(oauth2ClientIdSecretId) && + effectiveResolvedBaseUrl.length > 0 && + (!googleServicePickerEnabled || + (selectedGoogleServiceIdList.length > 0 && + googleBatchPendingCount === 0 && + !googleBatchError)); const hasIncompleteHeaderCredentials = strategy.kind !== "none" && strategy.kind !== "oauth2" && @@ -815,7 +1109,13 @@ export default function AddOpenApiSource(props: { hasIncompleteHeaderCredentials || hasIncompleteQueryCredentials; - const canAdd = preview !== null && resolvedBaseUrl.length > 0; + const canAdd = + preview !== null && + effectiveResolvedBaseUrl.length > 0 && + (!googleServicePickerEnabled || + (selectedGoogleServiceIdList.length > 0 && + googleBatchPendingCount === 0 && + !googleBatchError)); // ---- Handlers ---- @@ -920,23 +1220,46 @@ export default function AddOpenApiSource(props: { if (!selectedOAuth2Preset || !preview) return; oauth.cancel(); setOauth2Error(null); + setOauth2ProgressLabel(null); 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 existingGrantedScopes = existingConnection + ? grantedScopesForConnection(existingConnection) + : null; + const selectedGoogleConnectionScopes = + selectedOAuth2IsGoogle && googleServicePickerEnabled + ? resolvedOAuthScopes( + new Set(googleBatchAddItems.flatMap((item) => scopesForGoogleServiceItem(item))), + configuredOAuth2IdentityScopes, + ) + : selectedOAuth2Scopes; + const scopesForConnection = existingConnection + ? mergeOAuthScopes(existingGrantedScopes ?? [], selectedGoogleConnectionScopes) + : selectedGoogleConnectionScopes; + const providerAuthorizationInput = + selectedOAuth2IsGoogle && googleServicePickerEnabled + ? [ + ...googleConsentBatches.flatMap((batch) => batch.apiScopes), + ...identityScopesForPreset(configuredOAuth2IdentityScopes), + ] + : existingConnection && selectedOAuth2IsGoogle + ? resolvedOAuthScopes( + missingOAuthScopesFromGranted( + existingGrantedScopes ?? new Set(), + effectiveOAuth2SelectedApiScopes, + ), + configuredOAuth2IdentityScopes, + ) + : scopesForConnection; const scopesForProviderAuthorization = selectedOAuth2IsGoogle - ? compactGoogleOAuthScopes(scopesForAuthorization) + ? compactGoogleOAuthScopes(providerAuthorizationInput) : undefined; const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle); - const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); + const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, effectiveResolvedBaseUrl); const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; @@ -964,7 +1287,7 @@ export default function AddOpenApiSource(props: { clientIdSecretScopeId: String(clientIdSecretScope), clientSecretSecretId: oauth2ClientSecretSecretId, clientSecretSecretScopeId: String(clientSecretSecretScope), - scopes: scopesForAuthorization, + scopes: scopesForConnection, }, pluginId: "openapi", identityLabel: `${displayName} OAuth`, @@ -983,7 +1306,11 @@ export default function AddOpenApiSource(props: { setOAuthTokenTargetScope(tokenTargetScope); setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, - auth: { connectionId: response.completedConnection.connectionId }, + auth: { + connectionId: response.completedConnection.connectionId, + grantedScopes: scopesForConnection, + scopeId: tokenTargetScope, + }, }); setOauth2Error(null); return; @@ -992,16 +1319,181 @@ export default function AddOpenApiSource(props: { const authorizationUrl = resolveOAuthUrl( Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), - resolvedBaseUrl, + effectiveResolvedBaseUrl, ); const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); + + const startAuthorizationCodeFlow = async (input: { + readonly connectionId: string; + readonly scopesForConnection: readonly string[]; + readonly authorizationScopes: readonly string[] | undefined; + readonly identityLabel: string; + readonly onSuccess: (result: OAuthCompletionPayload) => void | Promise; + }) => { + const authorizationStrategy = existingConnection + ? { + kind: "authorization-code-existing-client" as const, + authorizationEndpoint: authorizationUrl, + tokenEndpoint: tokenUrl, + issuerUrl, + scopes: input.scopesForConnection, + ...(input.authorizationScopes && input.authorizationScopes.length > 0 + ? { authorizationScopes: input.authorizationScopes } + : {}), + 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: input.scopesForConnection, + ...(input.authorizationScopes && input.authorizationScopes.length > 0 + ? { authorizationScopes: input.authorizationScopes } + : {}), + extraAuthorizationParams, + } + : null; + if (!authorizationStrategy) return; + + await oauth.openAuthorization({ + tokenScope: tokenTargetScope, + run: async () => { + const exit = await doStartOAuth({ + params: { scopeId: tokenTargetScope }, + payload: { + endpoint: authorizationUrl, + connectionId: input.connectionId, + tokenScope: tokenTargetScope, + redirectUrl: oauth2RedirectUrl, + strategy: authorizationStrategy, + pluginId: "openapi", + identityLabel: input.identityLabel, + }, + }); + 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: input.onSuccess, + onError: (message, details) => { + setStartingOAuth(false); + setOauth2ProgressLabel(null); + setOauth2Error(oauthErrorMessage(selectedOAuth2ProviderLabel, message, details)); + }, + }); + }; + + if (selectedOAuth2IsGoogle && googleConsentBatches.length > 2) { + const currentStoredScopes = existingConnection + ? (existingGrantedScopes ?? splitOAuthScopes(existingConnection.oauthScope)) + : activeOAuth2AuthState?.connectionId === connectionId + ? new Set(activeOAuth2AuthState.grantedScopes) + : new Set(); + const hasStoredConnection = Boolean(existingConnection) || currentStoredScopes.size > 0; + const identityScopes = identityScopesForPreset(configuredOAuth2IdentityScopes); + const missingBatches = googleConsentBatches + .map((batch) => { + const missingApiScopes = batch.apiScopes.filter( + (scope) => !currentStoredScopes.has(scope), + ); + return { ...batch, apiScopes: missingApiScopes }; + }) + .filter((batch) => batch.apiScopes.length > 0); + if (missingBatches.length === 0 && existingConnection) { + setOAuthTokenTargetScope(existingConnection.scopeId); + setOauth2AuthState({ + fingerprint: selectedOAuth2Fingerprint, + auth: { + connectionId: existingConnection.id, + grantedScopes: [...currentStoredScopes], + scopeId: existingConnection.scopeId, + }, + }); + setOauth2Error(null); + return; + } + + const runBatch = async ( + batchIndex: number, + accumulatedScopes: readonly string[], + currentConnectionId: string, + ): Promise => { + const batch = missingBatches[batchIndex]; + if (!batch) { + setOAuthTokenTargetScope(tokenTargetScope); + setOauth2AuthState({ + fingerprint: selectedOAuth2Fingerprint, + auth: { + connectionId: currentConnectionId, + grantedScopes: accumulatedScopes, + scopeId: tokenTargetScope, + }, + }); + setOauth2ProgressLabel(null); + setOauth2Error(null); + return; + } + + const batchAuthorizationScopes = compactGoogleOAuthScopes([ + ...accumulatedScopes, + ...batch.apiScopes, + ...identityScopes, + ]); + const scopesForBatchConnection = + batchIndex === missingBatches.length - 1 + ? scopesForConnection + : batchIndex === 0 && !hasStoredConnection + ? batchAuthorizationScopes + : mergeOAuthScopes(accumulatedScopes, batchAuthorizationScopes); + setOauth2ProgressLabel( + `Connect ${batch.label} (${batchIndex + 1} of ${missingBatches.length})`, + ); + + await startAuthorizationCodeFlow({ + connectionId: currentConnectionId, + scopesForConnection: scopesForBatchConnection, + authorizationScopes: batchAuthorizationScopes, + identityLabel: existingConnection?.identityLabel ?? `${displayName} OAuth`, + onSuccess: (result) => { + const nextAccumulatedScopes = result.scope + ? mergeOAuthScopes(accumulatedScopes, splitOAuthScopes(result.scope)) + : scopesForBatchConnection; + window.setTimeout(() => { + void runBatch(batchIndex + 1, nextAccumulatedScopes, result.connectionId); + }, 0); + }, + }); + }; + + await runBatch(0, [...currentStoredScopes], connectionId); + return; + } + const authorizationStrategy = existingConnection ? { kind: "authorization-code-existing-client" as const, authorizationEndpoint: authorizationUrl, tokenEndpoint: tokenUrl, issuerUrl, - scopes: scopesForAuthorization, + scopes: scopesForConnection, ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 ? { authorizationScopes: scopesForProviderAuthorization } : {}), @@ -1019,7 +1511,7 @@ export default function AddOpenApiSource(props: { clientSecretSecretScopeId: oauth2ClientSecretSecretId ? String(clientSecretSecretScope) : null, - scopes: scopesForAuthorization, + scopes: scopesForConnection, ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 ? { authorizationScopes: scopesForProviderAuthorization } : {}), @@ -1061,13 +1553,21 @@ export default function AddOpenApiSource(props: { setOAuthTokenTargetScope(tokenTargetScope); setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, - auth: { connectionId: result.connectionId }, + auth: { + connectionId: result.connectionId, + grantedScopes: result.scope + ? [...splitOAuthScopes(result.scope)] + : scopesForConnection, + scopeId: tokenTargetScope, + }, }); + setOauth2ProgressLabel(null); setOauth2Error(null); }, - onError: (message) => { + onError: (message, details) => { setStartingOAuth(false); - setOauth2Error(message); + setOauth2ProgressLabel(null); + setOauth2Error(oauthErrorMessage(selectedOAuth2ProviderLabel, message, details)); }, }); }, @@ -1075,11 +1575,15 @@ export default function AddOpenApiSource(props: { selectedOAuth2Preset, oauth2ClientIdSecretId, oauth2ClientSecretSecretId, - oauth2SelectedScopes, + effectiveOAuth2SelectedApiScopes, selectedOAuth2Scopes, selectedOAuth2IsGoogle, oauth2RedirectUrl, - resolvedBaseUrl, + effectiveResolvedBaseUrl, + configuredOAuth2IdentityScopes, + googleBatchAddItems, + googleConsentBatches, + googleServicePickerEnabled, preview, doStartOAuth, identity.name, @@ -1089,7 +1593,10 @@ export default function AddOpenApiSource(props: { oauthTokenTargetScope, oauth2ClientIdScope, oauth2ClientSecretScope, + activeOAuth2AuthState, + grantedScopesForConnection, sourceScope, + selectedOAuth2ProviderLabel, ], ); @@ -1106,7 +1613,11 @@ export default function AddOpenApiSource(props: { setOAuthTokenTargetScope(connection.scopeId); setOauth2AuthState({ fingerprint: selectedOAuth2Fingerprint, - auth: { connectionId: connection.id }, + auth: { + connectionId: connection.id, + grantedScopes: [...splitOAuthScopes(connection.oauthScope)], + scopeId: connection.scopeId, + }, }); setOauth2Error(null); }, @@ -1116,14 +1627,35 @@ export default function AddOpenApiSource(props: { const handleAdd = async () => { setAdding(true); setAddError(null); + const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); + const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; + + if (googleServicePickerEnabled && (googleBatchPendingCount > 0 || googleBatchError)) { + setAddError( + googleBatchError?.status === "error" + ? googleBatchError.message + : "Still loading selected Google services", + ); + setAdding(false); + return; + } + const namespace = resolvedSourceId; + const specForAdd = + googleServicePickerEnabled && selectedGooglePresets.length > 0 + ? { + kind: "googleDiscoveryBundle" as const, + urls: selectedGooglePresets.flatMap((preset) => (preset.url ? [preset.url] : [])), + } + : specInputForAdd(specUrl); const exit = await doAdd({ params: { scopeId }, payload: { - spec: specInputForAdd(specUrl), + spec: specForAdd, name: resolvedDisplayName, namespace, - baseUrl: resolvedBaseUrl, + baseUrl: effectiveResolvedBaseUrl, ...(configuredSpecFetchCredentials ? { specFetchCredentials: configuredSpecFetchCredentials } : {}), @@ -1142,9 +1674,6 @@ export default function AddOpenApiSource(props: { } const sourceId = exit.value.namespace; - const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); - const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; - const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; for (const binding of headerBindings) { const bindingExit = await doSetBinding({ @@ -1280,6 +1809,19 @@ export default function AddOpenApiSource(props: { props.onComplete(); }; + const handleToggleAllGoogleServices = () => { + setSelectedGoogleServiceIds((previous) => { + const next = new Set(previous); + if (allStandardGoogleServicesSelected) { + for (const presetId of GOOGLE_STANDARD_SERVICE_IDS) next.delete(presetId); + return next; + } + for (const presetId of GOOGLE_STANDARD_SERVICE_IDS) next.add(presetId); + return next; + }); + setOauth2AuthState(null); + }; + // ---- Render ---- return ( @@ -1349,33 +1891,164 @@ export default function AddOpenApiSource(props: { {/* ── Source information card (shown after analysis) ── */} {preview ? ( - "API")} - description={`${Option.getOrElse(preview.version, () => "")}${ - Option.isSome(preview.version) ? " · " : "" - }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ - preview.tags.length > 0 - ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` - : "" - }`} - identity={identity} - baseUrl={resolvedBaseUrl} - onBaseUrlChange={setBaseUrl} - baseUrlOptions={baseUrlOptions} - specUrl={specUrl} - onSpecUrlChange={(value) => { - setSpecUrl(value); - setPreview(null); - setBaseUrl(""); - setCustomHeaders([]); - setStrategy({ kind: "none" }); - setOauth2AuthState(null); - setOauth2Error(null); - }} - faviconIcon={previewPresetIcon} - faviconUrl={resolvedBaseUrl} - baseUrlMissingMessage="A base URL is required to make requests." - /> + googleServicePickerEnabled && primaryGooglePreset ? ( + 0 + ? ` · ${googleBundleOperationCount} operation${ + googleBundleOperationCount !== 1 ? "s" : "" + }` + : "" + }`} + identity={identity} + baseUrl={effectiveResolvedBaseUrl} + onBaseUrlChange={setBaseUrl} + faviconIcon={GOOGLE_ICON} + faviconUrl={GOOGLE_BUNDLE_BASE_URL} + baseUrlMissingMessage="A base URL is required to make requests." + /> + ) : ( + "API")} + description={`${Option.getOrElse(preview.version, () => "")}${ + Option.isSome(preview.version) ? " · " : "" + }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ + preview.tags.length > 0 + ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` + : "" + }`} + identity={identity} + baseUrl={resolvedBaseUrl} + onBaseUrlChange={setBaseUrl} + baseUrlOptions={baseUrlOptions} + specUrl={specUrl} + onSpecUrlChange={(value) => { + setSpecUrl(value); + setPreview(null); + setBaseUrl(""); + setCustomHeaders([]); + setStrategy({ kind: "none" }); + setOauth2AuthState(null); + setOauth2Error(null); + }} + faviconIcon={previewPresetIcon} + faviconUrl={resolvedBaseUrl} + baseUrlMissingMessage="A base URL is required to make requests." + /> + ) + ) : null} + + {googleServicePickerEnabled && primaryGooglePreset ? ( +
+
+ Google services +
+ + {selectedGoogleServiceIdList.length} selected + + +
+
+
+
+ {googleOpenApiPresets.map((preset) => { + const selected = selectedGoogleServiceIdList.includes(preset.id); + const userOAuthUnsupported = preset.oauthAudience === "unsupported-user"; + const previewState = + preset.id === primaryGooglePreset.id + ? ({ status: "success", preview: preview as SpecPreview } as const) + : googleServicePreviews[preset.id]; + return ( + + ); + })} +
+
+ {googleBatchError?.status === "error" ? ( +
+

{googleBatchError.message}

+
+ ) : null} + {showGoogleSelectionWarning ? ( + + Google may reject broad permission requests + + Large Google selections and admin or developer APIs can ask for permission + combinations Google will not approve in one sign-in. If OAuth fails, select fewer + services or add sensitive services as separate Google sources. + + + ) : null} +
) : null} {analyzeError && ( @@ -1423,7 +2096,10 @@ export default function AddOpenApiSource(props: { })} {oauth2Presets.map((preset, i) => { const selected = strategy.kind === "oauth2" && strategy.presetIndex === i; - const scopeCount = Object.keys(preset.scopes).length; + const scopeCount = + selected && googleServicePickerEnabled + ? selectedOAuth2Scopes.length + : Object.keys(preset.scopes).length; return (