Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/docs/content/docs/en/self-hosting/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-sim-host>/api/auth/oauth2/callback/google-sheets
https://<your-sim-host>/api/auth/oauth2/callback/google-drive
https://<your-sim-host>/api/auth/oauth2/callback/google-docs
https://<your-sim-host>/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 |
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/api/auth/oauth/provider-config/route.ts
Original file line number Diff line number Diff line change
@@ -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))
})
20 changes: 19 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated Google provider predicate may diverge

Medium Severity

shouldPreflightOAuthProvider in the modal duplicates the exact logic of isGoogleProvider in provider-config.ts. The client-side gate decides whether to call the preflight API, while the server-side function decides what to validate — if a new Google-family provider is added to one but not the other, the preflight will silently be skipped or return a false positive. Extracting the predicate into a shared, dependency-free module (or exporting isGoogleProvider) would keep the two sides in sync.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd08394. Configure here.


/**
* Generates a default credential display name.
* Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available.
Expand Down Expand Up @@ -152,13 +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 (isConnect) {
await createDraft.mutateAsync({
workspaceId,
providerId,
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/lib/api/contracts/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -123,3 +136,17 @@ export const getAuthProvidersContract = defineRouteContract({
})

export type AuthProviderStatusResponse = ContractJsonResponse<typeof getAuthProvidersContract>

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
>
80 changes: 80 additions & 0 deletions apps/sim/lib/oauth/provider-config.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
97 changes: 97 additions & 0 deletions apps/sim/lib/oauth/provider-config.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The PLACEHOLDER_PATTERN regex contains an empty first alternative (^(|your-|...)$), which makes the pattern match the empty string. The earlier if (!value) return false guard prevents this from causing a false positive, but the empty alternative is confusing and could mask intent. Consider removing it so the regex documents only the actual placeholder patterns it's designed to catch.

Suggested change
const PLACEHOLDER_PATTERN = /^(|your-|change-me|changeme|example|<.*>)$/i
const PLACEHOLDER_PATTERN = /^(your-|change-me|changeme|example|<.*>)$/i

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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,
}
}