Skip to content

Commit cd08394

Browse files
committed
fix(oauth): preflight google integration config
1 parent 8d68c8a commit cd08394

6 files changed

Lines changed: 252 additions & 0 deletions

File tree

apps/docs/content/docs/en/self-hosting/environment-variables.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ import { Callout } from 'fumadocs-ui/components/callout'
5959
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID |
6060
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret |
6161

62+
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:
63+
64+
```text
65+
https://<your-sim-host>/api/auth/oauth2/callback/google-sheets
66+
https://<your-sim-host>/api/auth/oauth2/callback/google-drive
67+
https://<your-sim-host>/api/auth/oauth2/callback/google-docs
68+
https://<your-sim-host>/api/auth/oauth2/callback/google-calendar
69+
```
70+
71+
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.
72+
6273
## Optional
6374

6475
| Variable | Description |
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { NextRequest } from 'next/server'
2+
import { NextResponse } from 'next/server'
3+
import { getOAuthProviderConfigContract } from '@/lib/api/contracts/auth'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { getSession } from '@/lib/auth'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { getOAuthProviderConfigStatus } from '@/lib/oauth/provider-config'
8+
9+
export const dynamic = 'force-dynamic'
10+
11+
export const GET = withRouteHandler(async (request: NextRequest) => {
12+
const session = await getSession()
13+
if (!session?.user?.id) {
14+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
15+
}
16+
17+
const parsed = await parseRequest(getOAuthProviderConfigContract, request, {})
18+
if (!parsed.success) return parsed.response
19+
20+
return NextResponse.json(getOAuthProviderConfigStatus(parsed.data.query.providerId))
21+
})

apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
ModalFooter,
1616
ModalHeader,
1717
} from '@/components/emcn'
18+
import { requestJson } from '@/lib/api/client/request'
19+
import { getOAuthProviderConfigContract } from '@/lib/api/contracts/auth'
1820
import { client, useSession } from '@/lib/auth/auth-client'
1921
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
2022
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -31,6 +33,10 @@ import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
3133
const logger = createLogger('OAuthModal')
3234
const EMPTY_SCOPES: string[] = []
3335

36+
function shouldPreflightOAuthProvider(providerId: string): boolean {
37+
return providerId === 'google' || providerId.startsWith('google-') || providerId === 'vertex-ai'
38+
}
39+
3440
/**
3541
* Generates a default credential display name.
3642
* Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available.
@@ -159,6 +165,16 @@ export function OAuthModal(props: OAuthModalProps) {
159165
return
160166
}
161167

168+
if (shouldPreflightOAuthProvider(providerId)) {
169+
const providerConfig = await requestJson(getOAuthProviderConfigContract, {
170+
query: { providerId },
171+
})
172+
if (!providerConfig.available) {
173+
setError(providerConfig.message)
174+
return
175+
}
176+
}
177+
162178
await createDraft.mutateAsync({
163179
workspaceId,
164180
providerId,

apps/sim/lib/api/contracts/auth.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ export const authProviderStatusResponseSchema = z.object({
1212
registrationDisabled: z.boolean(),
1313
})
1414

15+
export const oauthProviderConfigQuerySchema = z.object({
16+
providerId: z.string().min(1),
17+
})
18+
19+
export const oauthProviderConfigResponseSchema = z.object({
20+
providerId: z.string(),
21+
available: z.boolean(),
22+
status: z.enum(['ready', 'missing_env', 'placeholder_env', 'invalid_env']),
23+
message: z.string(),
24+
redirectUri: z.string().optional(),
25+
requiredEnv: z.array(z.string()),
26+
})
27+
1528
const ssoMappingSchema = z
1629
.object({
1730
id: z.string().default('sub'),
@@ -123,3 +136,17 @@ export const getAuthProvidersContract = defineRouteContract({
123136
})
124137

125138
export type AuthProviderStatusResponse = ContractJsonResponse<typeof getAuthProvidersContract>
139+
140+
export const getOAuthProviderConfigContract = defineRouteContract({
141+
method: 'GET',
142+
path: '/api/auth/oauth/provider-config',
143+
query: oauthProviderConfigQuerySchema,
144+
response: {
145+
mode: 'json',
146+
schema: oauthProviderConfigResponseSchema,
147+
},
148+
})
149+
150+
export type OAuthProviderConfigResponse = ContractJsonResponse<
151+
typeof getOAuthProviderConfigContract
152+
>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it } from 'vitest'
5+
import { getOAuthProviderConfigStatus, getOAuthRedirectUri } from '@/lib/oauth/provider-config'
6+
7+
const ORIGINAL_ENV = { ...process.env }
8+
9+
afterEach(() => {
10+
process.env = { ...ORIGINAL_ENV }
11+
})
12+
13+
describe('getOAuthProviderConfigStatus', () => {
14+
it('reports missing Google OAuth env vars with the provider redirect URI', () => {
15+
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
16+
process.env.GOOGLE_CLIENT_ID = ''
17+
process.env.GOOGLE_CLIENT_SECRET = ''
18+
19+
const status = getOAuthProviderConfigStatus('google-sheets')
20+
21+
expect(status.available).toBe(false)
22+
expect(status.status).toBe('missing_env')
23+
expect(status.requiredEnv).toEqual(['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'])
24+
expect(status.redirectUri).toBe('http://localhost:3000/api/auth/oauth2/callback/google-sheets')
25+
expect(status.message).toContain('GOOGLE_CLIENT_ID')
26+
expect(status.message).toContain('GOOGLE_CLIENT_SECRET')
27+
})
28+
29+
it('blocks placeholder Google OAuth credentials', () => {
30+
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
31+
process.env.GOOGLE_CLIENT_ID = 'your-google-client-id'
32+
process.env.GOOGLE_CLIENT_SECRET = 'your-google-client-secret'
33+
34+
const status = getOAuthProviderConfigStatus('google-drive')
35+
36+
expect(status.available).toBe(false)
37+
expect(status.status).toBe('placeholder_env')
38+
})
39+
40+
it('rejects malformed Google OAuth client IDs before redirecting to Google', () => {
41+
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
42+
process.env.GOOGLE_CLIENT_ID = 'not-a-google-client'
43+
process.env.GOOGLE_CLIENT_SECRET = 'real-secret'
44+
45+
const status = getOAuthProviderConfigStatus('google-sheets')
46+
47+
expect(status.available).toBe(false)
48+
expect(status.status).toBe('invalid_env')
49+
expect(status.message).toContain('.apps.googleusercontent.com')
50+
})
51+
52+
it('accepts configured Google OAuth credentials', () => {
53+
process.env.NEXT_PUBLIC_APP_URL = 'https://sim.example.com/'
54+
process.env.GOOGLE_CLIENT_ID = '123.apps.googleusercontent.com'
55+
process.env.GOOGLE_CLIENT_SECRET = 'real-secret'
56+
57+
const status = getOAuthProviderConfigStatus('google-sheets')
58+
59+
expect(status.available).toBe(true)
60+
expect(status.status).toBe('ready')
61+
expect(status.redirectUri).toBe(
62+
'https://sim.example.com/api/auth/oauth2/callback/google-sheets'
63+
)
64+
})
65+
66+
it('does not block non-Google providers', () => {
67+
const status = getOAuthProviderConfigStatus('slack')
68+
69+
expect(status.available).toBe(true)
70+
expect(status.requiredEnv).toEqual([])
71+
})
72+
})
73+
74+
describe('getOAuthRedirectUri', () => {
75+
it('normalizes a trailing slash on the base URL', () => {
76+
expect(getOAuthRedirectUri('google-sheets', 'https://sim.example.com/')).toBe(
77+
'https://sim.example.com/api/auth/oauth2/callback/google-sheets'
78+
)
79+
})
80+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { getEnv } from '@/lib/core/config/env'
2+
import { getBaseUrl } from '@/lib/core/utils/urls'
3+
import type { OAuthProvider } from '@/lib/oauth/types'
4+
5+
export type OAuthProviderConfigStatus = {
6+
providerId: OAuthProvider | string
7+
available: boolean
8+
status: 'ready' | 'missing_env' | 'placeholder_env' | 'invalid_env'
9+
message: string
10+
redirectUri?: string
11+
requiredEnv: string[]
12+
}
13+
14+
const GOOGLE_CLIENT_ID_SUFFIX = '.apps.googleusercontent.com'
15+
const PLACEHOLDER_PATTERN = /^(|your-|change-me|changeme|example|<.*>)$/i
16+
17+
function hasPlaceholderValue(value: string | undefined): boolean {
18+
if (!value) return false
19+
const normalized = value.trim()
20+
return PLACEHOLDER_PATTERN.test(normalized) || normalized.includes('your-google-client')
21+
}
22+
23+
function isGoogleProvider(providerId: string): boolean {
24+
return providerId === 'google' || providerId.startsWith('google-') || providerId === 'vertex-ai'
25+
}
26+
27+
export function getOAuthRedirectUri(providerId: string, baseUrl = getBaseUrl()): string {
28+
return `${baseUrl.replace(/\/$/, '')}/api/auth/oauth2/callback/${providerId}`
29+
}
30+
31+
export function getOAuthProviderConfigStatus(
32+
providerId: OAuthProvider | string
33+
): OAuthProviderConfigStatus {
34+
const requiredEnv = isGoogleProvider(providerId)
35+
? ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET']
36+
: []
37+
38+
if (!isGoogleProvider(providerId)) {
39+
return {
40+
providerId,
41+
available: true,
42+
status: 'ready',
43+
message: 'OAuth provider configuration is ready.',
44+
requiredEnv,
45+
}
46+
}
47+
48+
const clientId = getEnv('GOOGLE_CLIENT_ID')?.trim()
49+
const clientSecret = getEnv('GOOGLE_CLIENT_SECRET')?.trim()
50+
const redirectUri = getOAuthRedirectUri(providerId)
51+
52+
if (!clientId || !clientSecret) {
53+
const missing = [
54+
!clientId ? 'GOOGLE_CLIENT_ID' : null,
55+
!clientSecret ? 'GOOGLE_CLIENT_SECRET' : null,
56+
].filter(Boolean)
57+
return {
58+
providerId,
59+
available: false,
60+
status: 'missing_env',
61+
message: `Google OAuth is not configured. Set ${missing.join(' and ')} and add ${redirectUri} as an authorized redirect URI in Google Cloud.`,
62+
redirectUri,
63+
requiredEnv,
64+
}
65+
}
66+
67+
if (hasPlaceholderValue(clientId) || hasPlaceholderValue(clientSecret)) {
68+
return {
69+
providerId,
70+
available: false,
71+
status: 'placeholder_env',
72+
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.`,
73+
redirectUri,
74+
requiredEnv,
75+
}
76+
}
77+
78+
if (!clientId.endsWith(GOOGLE_CLIENT_ID_SUFFIX)) {
79+
return {
80+
providerId,
81+
available: false,
82+
status: 'invalid_env',
83+
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.`,
84+
redirectUri,
85+
requiredEnv,
86+
}
87+
}
88+
89+
return {
90+
providerId,
91+
available: true,
92+
status: 'ready',
93+
message: 'Google OAuth provider configuration is ready.',
94+
redirectUri,
95+
requiredEnv,
96+
}
97+
}

0 commit comments

Comments
 (0)