From cd083947b13bb8e2c2553b818936e65c15445688 Mon Sep 17 00:00:00 2001 From: Ritwij Aryan Parmar Date: Fri, 29 May 2026 15:08:14 -0400 Subject: [PATCH 1/2] fix(oauth): preflight google integration config --- .../en/self-hosting/environment-variables.mdx | 11 +++ .../api/auth/oauth/provider-config/route.ts | 21 ++++ .../[workspaceId]/components/oauth-modal.tsx | 16 +++ apps/sim/lib/api/contracts/auth.ts | 27 ++++++ apps/sim/lib/oauth/provider-config.test.ts | 80 +++++++++++++++ apps/sim/lib/oauth/provider-config.ts | 97 +++++++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 apps/sim/app/api/auth/oauth/provider-config/route.ts create mode 100644 apps/sim/lib/oauth/provider-config.test.ts create mode 100644 apps/sim/lib/oauth/provider-config.ts diff --git a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx index 8d481b0d62f..0064e2a647d 100644 --- a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx @@ -59,6 +59,17 @@ import { Callout } from 'fumadocs-ui/components/callout' | `GITHUB_CLIENT_ID` | GitHub OAuth client ID | | `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | +For Google integrations in self-hosted deployments, create a **Web application** OAuth client in Google Cloud and add the Sim callback for every Google service you plan to connect: + +```text +https:///api/auth/oauth2/callback/google-sheets +https:///api/auth/oauth2/callback/google-drive +https:///api/auth/oauth2/callback/google-docs +https:///api/auth/oauth2/callback/google-calendar +``` + +The host must match `NEXT_PUBLIC_APP_URL`. If the client ID, secret, or redirect URI do not match, Google returns `invalid_client` before Sim can complete the connection. + ## Optional | Variable | Description | diff --git a/apps/sim/app/api/auth/oauth/provider-config/route.ts b/apps/sim/app/api/auth/oauth/provider-config/route.ts new file mode 100644 index 00000000000..019b577177b --- /dev/null +++ b/apps/sim/app/api/auth/oauth/provider-config/route.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getOAuthProviderConfigContract } from '@/lib/api/contracts/auth' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getOAuthProviderConfigStatus } from '@/lib/oauth/provider-config' + +export const dynamic = 'force-dynamic' + +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getOAuthProviderConfigContract, request, {}) + if (!parsed.success) return parsed.response + + return NextResponse.json(getOAuthProviderConfigStatus(parsed.data.query.providerId)) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx index 4f028948794..54a73221d69 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx @@ -15,6 +15,8 @@ import { ModalFooter, ModalHeader, } from '@/components/emcn' +import { requestJson } from '@/lib/api/client/request' +import { getOAuthProviderConfigContract } from '@/lib/api/contracts/auth' import { client, useSession } from '@/lib/auth/auth-client' import type { OAuthReturnContext } from '@/lib/credentials/client-state' import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state' @@ -31,6 +33,10 @@ import { useCreateCredentialDraft } from '@/hooks/queries/credentials' const logger = createLogger('OAuthModal') const EMPTY_SCOPES: string[] = [] +function shouldPreflightOAuthProvider(providerId: string): boolean { + return providerId === 'google' || providerId.startsWith('google-') || providerId === 'vertex-ai' +} + /** * Generates a default credential display name. * Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available. @@ -159,6 +165,16 @@ export function OAuthModal(props: OAuthModalProps) { return } + if (shouldPreflightOAuthProvider(providerId)) { + const providerConfig = await requestJson(getOAuthProviderConfigContract, { + query: { providerId }, + }) + if (!providerConfig.available) { + setError(providerConfig.message) + return + } + } + await createDraft.mutateAsync({ workspaceId, providerId, diff --git a/apps/sim/lib/api/contracts/auth.ts b/apps/sim/lib/api/contracts/auth.ts index 1a95e7cd620..6c8a9c0e6d5 100644 --- a/apps/sim/lib/api/contracts/auth.ts +++ b/apps/sim/lib/api/contracts/auth.ts @@ -12,6 +12,19 @@ export const authProviderStatusResponseSchema = z.object({ registrationDisabled: z.boolean(), }) +export const oauthProviderConfigQuerySchema = z.object({ + providerId: z.string().min(1), +}) + +export const oauthProviderConfigResponseSchema = z.object({ + providerId: z.string(), + available: z.boolean(), + status: z.enum(['ready', 'missing_env', 'placeholder_env', 'invalid_env']), + message: z.string(), + redirectUri: z.string().optional(), + requiredEnv: z.array(z.string()), +}) + const ssoMappingSchema = z .object({ id: z.string().default('sub'), @@ -123,3 +136,17 @@ export const getAuthProvidersContract = defineRouteContract({ }) export type AuthProviderStatusResponse = ContractJsonResponse + +export const getOAuthProviderConfigContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/oauth/provider-config', + query: oauthProviderConfigQuerySchema, + response: { + mode: 'json', + schema: oauthProviderConfigResponseSchema, + }, +}) + +export type OAuthProviderConfigResponse = ContractJsonResponse< + typeof getOAuthProviderConfigContract +> diff --git a/apps/sim/lib/oauth/provider-config.test.ts b/apps/sim/lib/oauth/provider-config.test.ts new file mode 100644 index 00000000000..0e0c65d1926 --- /dev/null +++ b/apps/sim/lib/oauth/provider-config.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it } from 'vitest' +import { getOAuthProviderConfigStatus, getOAuthRedirectUri } from '@/lib/oauth/provider-config' + +const ORIGINAL_ENV = { ...process.env } + +afterEach(() => { + process.env = { ...ORIGINAL_ENV } +}) + +describe('getOAuthProviderConfigStatus', () => { + it('reports missing Google OAuth env vars with the provider redirect URI', () => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + process.env.GOOGLE_CLIENT_ID = '' + process.env.GOOGLE_CLIENT_SECRET = '' + + const status = getOAuthProviderConfigStatus('google-sheets') + + expect(status.available).toBe(false) + expect(status.status).toBe('missing_env') + expect(status.requiredEnv).toEqual(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET']) + expect(status.redirectUri).toBe('http://localhost:3000/api/auth/oauth2/callback/google-sheets') + expect(status.message).toContain('GOOGLE_CLIENT_ID') + expect(status.message).toContain('GOOGLE_CLIENT_SECRET') + }) + + it('blocks placeholder Google OAuth credentials', () => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + process.env.GOOGLE_CLIENT_ID = 'your-google-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'your-google-client-secret' + + const status = getOAuthProviderConfigStatus('google-drive') + + expect(status.available).toBe(false) + expect(status.status).toBe('placeholder_env') + }) + + it('rejects malformed Google OAuth client IDs before redirecting to Google', () => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + process.env.GOOGLE_CLIENT_ID = 'not-a-google-client' + process.env.GOOGLE_CLIENT_SECRET = 'real-secret' + + const status = getOAuthProviderConfigStatus('google-sheets') + + expect(status.available).toBe(false) + expect(status.status).toBe('invalid_env') + expect(status.message).toContain('.apps.googleusercontent.com') + }) + + it('accepts configured Google OAuth credentials', () => { + process.env.NEXT_PUBLIC_APP_URL = 'https://sim.example.com/' + process.env.GOOGLE_CLIENT_ID = '123.apps.googleusercontent.com' + process.env.GOOGLE_CLIENT_SECRET = 'real-secret' + + const status = getOAuthProviderConfigStatus('google-sheets') + + expect(status.available).toBe(true) + expect(status.status).toBe('ready') + expect(status.redirectUri).toBe( + 'https://sim.example.com/api/auth/oauth2/callback/google-sheets' + ) + }) + + it('does not block non-Google providers', () => { + const status = getOAuthProviderConfigStatus('slack') + + expect(status.available).toBe(true) + expect(status.requiredEnv).toEqual([]) + }) +}) + +describe('getOAuthRedirectUri', () => { + it('normalizes a trailing slash on the base URL', () => { + expect(getOAuthRedirectUri('google-sheets', 'https://sim.example.com/')).toBe( + 'https://sim.example.com/api/auth/oauth2/callback/google-sheets' + ) + }) +}) diff --git a/apps/sim/lib/oauth/provider-config.ts b/apps/sim/lib/oauth/provider-config.ts new file mode 100644 index 00000000000..9acafbad40b --- /dev/null +++ b/apps/sim/lib/oauth/provider-config.ts @@ -0,0 +1,97 @@ +import { getEnv } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { OAuthProvider } from '@/lib/oauth/types' + +export type OAuthProviderConfigStatus = { + providerId: OAuthProvider | string + available: boolean + status: 'ready' | 'missing_env' | 'placeholder_env' | 'invalid_env' + message: string + redirectUri?: string + requiredEnv: string[] +} + +const GOOGLE_CLIENT_ID_SUFFIX = '.apps.googleusercontent.com' +const PLACEHOLDER_PATTERN = /^(|your-|change-me|changeme|example|<.*>)$/i + +function hasPlaceholderValue(value: string | undefined): boolean { + if (!value) return false + const normalized = value.trim() + return PLACEHOLDER_PATTERN.test(normalized) || normalized.includes('your-google-client') +} + +function isGoogleProvider(providerId: string): boolean { + return providerId === 'google' || providerId.startsWith('google-') || providerId === 'vertex-ai' +} + +export function getOAuthRedirectUri(providerId: string, baseUrl = getBaseUrl()): string { + return `${baseUrl.replace(/\/$/, '')}/api/auth/oauth2/callback/${providerId}` +} + +export function getOAuthProviderConfigStatus( + providerId: OAuthProvider | string +): OAuthProviderConfigStatus { + const requiredEnv = isGoogleProvider(providerId) + ? ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'] + : [] + + if (!isGoogleProvider(providerId)) { + return { + providerId, + available: true, + status: 'ready', + message: 'OAuth provider configuration is ready.', + requiredEnv, + } + } + + const clientId = getEnv('GOOGLE_CLIENT_ID')?.trim() + const clientSecret = getEnv('GOOGLE_CLIENT_SECRET')?.trim() + const redirectUri = getOAuthRedirectUri(providerId) + + if (!clientId || !clientSecret) { + const missing = [ + !clientId ? 'GOOGLE_CLIENT_ID' : null, + !clientSecret ? 'GOOGLE_CLIENT_SECRET' : null, + ].filter(Boolean) + return { + providerId, + available: false, + status: 'missing_env', + message: `Google OAuth is not configured. Set ${missing.join(' and ')} and add ${redirectUri} as an authorized redirect URI in Google Cloud.`, + redirectUri, + requiredEnv, + } + } + + if (hasPlaceholderValue(clientId) || hasPlaceholderValue(clientSecret)) { + return { + providerId, + available: false, + status: 'placeholder_env', + message: `Google OAuth still has placeholder credentials. Replace GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then add ${redirectUri} as an authorized redirect URI in Google Cloud.`, + redirectUri, + requiredEnv, + } + } + + if (!clientId.endsWith(GOOGLE_CLIENT_ID_SUFFIX)) { + return { + providerId, + available: false, + status: 'invalid_env', + message: `GOOGLE_CLIENT_ID does not look like a Google OAuth web client ID. It should end with ${GOOGLE_CLIENT_ID_SUFFIX}, and ${redirectUri} must be registered as an authorized redirect URI.`, + redirectUri, + requiredEnv, + } + } + + return { + providerId, + available: true, + status: 'ready', + message: 'Google OAuth provider configuration is ready.', + redirectUri, + requiredEnv, + } +} From b9d0ad6d2969b8b04e23f3db04d5874f1e389702 Mon Sep 17 00:00:00 2001 From: Ritwij Aryan Parmar Date: Fri, 29 May 2026 16:07:34 -0400 Subject: [PATCH 2/2] fix(oauth): preflight reauthorization flows --- .../[workspaceId]/components/oauth-modal.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx index 54a73221d69..a7db5970411 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx @@ -158,23 +158,25 @@ export function OAuthModal(props: OAuthModalProps) { setError(null) try { + const trimmedName = isConnect ? displayName.trim() : '' if (isConnect) { - const trimmedName = displayName.trim() if (!trimmedName) { setError('Display name is required.') return } + } - if (shouldPreflightOAuthProvider(providerId)) { - const providerConfig = await requestJson(getOAuthProviderConfigContract, { - query: { providerId }, - }) - if (!providerConfig.available) { - setError(providerConfig.message) - return - } + if (shouldPreflightOAuthProvider(providerId)) { + const providerConfig = await requestJson(getOAuthProviderConfigContract, { + query: { providerId }, + }) + if (!providerConfig.available) { + setError(providerConfig.message) + return } + } + if (isConnect) { await createDraft.mutateAsync({ workspaceId, providerId,