From 0f0becc4479ec91ad118e7a122ba41a756bf0cf8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 16 Oct 2026 16:17:38 -0700 Subject: [PATCH] fix(oauth): isolate verification-gated Google grants --- apps/sim/lib/auth/auth.ts | 17 + .../google-verification.integration.ts | 350 ++++++++++++++++++ .../auth/connectors/google-verification.ts | 41 ++ apps/sim/lib/auth/connectors/providers.ts | 21 +- .../core/config/env-capabilities.server.ts | 5 +- apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/core/config/feature-flags.ts | 5 + apps/sim/lib/core/config/google-oauth.ts | 21 ++ .../standard-oauth-provider.ts | 11 + apps/sim/lib/oauth/oauth.ts | 16 +- 10 files changed, 474 insertions(+), 15 deletions(-) create mode 100644 apps/sim/lib/auth/connectors/google-verification.integration.ts create mode 100644 apps/sim/lib/auth/connectors/google-verification.ts create mode 100644 apps/sim/lib/core/config/google-oauth.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 4efd9cf70c5..5d862951330 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -39,6 +39,7 @@ import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff' import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { getGoogleOAuthVerificationRefusal } from '@/lib/auth/connectors/google-verification' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { applyRegistrationGate, @@ -981,6 +982,22 @@ export const auth = betterAuth({ hooks: { before: createAuthMiddleware(async (ctx) => { try { + const googleOAuthProviderId = + ctx.path === '/oauth2/link' || ctx.path === '/sign-in/oauth2' + ? ctx.body?.providerId + : ctx.path === '/sign-in/social' || ctx.path === '/link-social' + ? ctx.body?.provider + : ctx.path.startsWith('/oauth2/callback/') + ? ctx.params?.providerId + : undefined + const googleOAuthRefusal = await getGoogleOAuthVerificationRefusal( + googleOAuthProviderId, + ctx.body?.scopes + ) + if (googleOAuthRefusal) { + throw new APIError('FORBIDDEN', { message: googleOAuthRefusal }) + } + /** Refuse provider calls when user authentication is disabled without blocking connector OAuth. */ if ( ((ctx.path.startsWith('/oauth2/') && diff --git a/apps/sim/lib/auth/connectors/google-verification.integration.ts b/apps/sim/lib/auth/connectors/google-verification.integration.ts new file mode 100644 index 00000000000..41e63b2cf2a --- /dev/null +++ b/apps/sim/lib/auth/connectors/google-verification.integration.ts @@ -0,0 +1,350 @@ +import { createServer } from 'node:http' +import { db } from '@sim/db' +import { account, user } from '@sim/db/schema' +import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock' +import { emailTemplatesMock } from '@sim/testing/mocks/email-templates.mock' +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { generateId } from '@sim/utils/id' +import { hashPassword } from 'better-auth/crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' + +const fixture = vi.hoisted(() => ({ enabled: false, baseUrl: '' })) + +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) +vi.mock('@/components/emails', () => emailTemplatesMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/core/config/env-flags', () => ({ + ...envFlagsMock, + isHosted: true, + isBillingEnabled: false, + isAppConfigEnabled: true, +})) +vi.mock('@/lib/core/config/appconfig', () => ({ + fetchAppConfigProfile: async (ids: { profile: string }, parse: (value: unknown) => unknown) => + parse( + ids.profile === 'feature-flags' + ? { 'google-unverified-oauth': { enabled: fixture.enabled } } + : {} + ), +})) +vi.mock('@/lib/auth/connectors/providers', async (importOriginal) => { + const original = await importOriginal() + return { + buildConnectorProviders: () => + original.buildConnectorProviders().map((provider) => + provider.providerId.startsWith('google-') + ? { + ...provider, + discoveryUrl: undefined, + authorizationUrl: `${fixture.baseUrl}/authorize`, + tokenUrl: `${fixture.baseUrl}/token`, + } + : provider + ), + } +}) + +describe('Google OAuth verification restrictions at the HTTP boundary', () => { + let auth: typeof import('@/lib/auth/auth').auth + let adapters: typeof import('@/lib/credential-groups/standard-oauth-provider') + let cookie: string + let refreshForm: URLSearchParams | undefined + const userId = generateId() + const password = 'Disposable-fixture-password-42!' + const email = `${userId}@oauth-verification.test` + const server = createServer(async (request, response) => { + if (request.url === '/token') { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(chunk) + refreshForm = new URLSearchParams(Buffer.concat(chunks).toString()) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end( + JSON.stringify({ + access_token: 'fixture-refreshed-access', + token_type: 'Bearer', + expires_in: 3600, + }) + ) + return + } + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(chunk) + const result = await auth.handler( + new Request(`http://localhost:3000${request.url}`, { + method: request.method, + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + ...(request.headers.cookie ? { cookie: request.headers.cookie } : {}), + }, + ...(chunks.length ? { body: Buffer.concat(chunks) } : {}), + }) + ) + response.statusCode = result.status + result.headers.forEach((value, key) => { + if (key !== 'set-cookie') response.setHeader(key, value) + }) + response.setHeader('set-cookie', result.headers.getSetCookie()) + response.end(Buffer.from(await result.arrayBuffer())) + }) + + beforeAll(async () => { + process.env.GOOGLE_CLIENT_ID = 'google-verification-fixture-client' + process.env.GOOGLE_CLIENT_SECRET = 'google-verification-fixture-secret' + process.env.GOOGLE_VERIFICATION_CLIENT_ID = 'isolated-review-fixture-client' + process.env.GOOGLE_VERIFICATION_CLIENT_SECRET = 'isolated-review-fixture-secret' + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Fixture did not bind') + fixture.baseUrl = `http://127.0.0.1:${address.port}` + ;({ auth } = await import('@/lib/auth/auth')) + adapters = await import('@/lib/credential-groups/standard-oauth-provider') + const now = new Date() + await db.insert(user).values({ + id: userId, + email, + name: 'OAuth verification fixture', + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await db.insert(account).values({ + id: generateId(), + accountId: userId, + userId, + providerId: 'credential', + password: await hashPassword(password), + createdAt: now, + updatedAt: now, + }) + const login = await post('/sign-in/email', { email, password }) + expect(login.status).toBe(200) + cookie = login.headers + .getSetCookie() + .map((value) => value.split(';')[0]) + .join('; ') + }, 90_000) + + beforeEach(() => { + fixture.enabled = false + }) + + afterAll(async () => { + await db.delete(user).where(eq(user.id, userId)) + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + server.closeAllConnections() + }) + }) + + function post(path: string, body: object) { + return fetch(`${fixture.baseUrl}/api/auth${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + redirect: 'manual', + }) + } + + it.each(['google-ads', 'google-meet', 'google-chat'])( + 'refuses a new %s connection before returning an authorization URL', + async (providerId) => { + const response = await post('/oauth2/link', { providerId, callbackURL: '/' }) + expect(response.status).toBe(403) + expect(await response.text()).not.toContain('/authorize?') + } + ) + + it.each([ + 'https://www.googleapis.com/auth/adwords', + 'https://www.googleapis.com/auth/meetings.space.created', + 'https://www.googleapis.com/auth/meetings.space.readonly', + 'https://www.googleapis.com/auth/chat.spaces.readonly', + 'https://www.googleapis.com/auth/chat.messages.readonly', + ])('refuses adding the unapproved %s scope through an approved connector', async (scope) => { + const response = await post('/oauth2/link', { + providerId: 'google-drive', + callbackURL: '/', + scopes: [scope], + }) + expect(response.status).toBe(403) + }) + + it('refuses an unapproved scope through Google sign-in', async () => { + const response = await post('/sign-in/social', { + provider: 'google', + callbackURL: '/', + scopes: ['https://www.googleapis.com/auth/adwords'], + }) + expect(response.status).toBe(403) + }) + + it('refuses space-delimited scope injection through social account linking', async () => { + const response = await post('/link-social', { + provider: 'google', + callbackURL: '/', + scopes: ['openid https://www.googleapis.com/auth/adwords'], + }) + expect(response.status).toBe(403) + }) + + it.each([ + [ + '/oauth2/link', + { providerId: 'google-drive', scopes: ['https://www.googleapis.com/auth/adwords'] }, + ], + [ + '/sign-in/social', + { provider: 'google', scopes: ['https://www.googleapis.com/auth/chat.messages.readonly'] }, + ], + [ + '/oauth2/link', + { providerId: 'google-chat', scopes: ['https://www.googleapis.com/auth/adwords'] }, + ], + [ + '/oauth2/link', + { providerId: 'google-meet', scopes: ['https://www.googleapis.com/auth/drive'] }, + ], + ])('keeps each client and service scope isolated when enabled: %s %j', async (path, body) => { + fixture.enabled = true + const response = await post(path, { ...body, callbackURL: '/' }) + expect(response.status).toBe(403) + }) + + it.each(['google-ads', 'google-meet', 'google-chat', 'google-drive'])( + 'refreshes %s with the same client used for authorization even while new grants are disabled', + async (providerId) => { + const originalFetch = globalThis.fetch + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation((input, init) => + originalFetch( + input === 'https://oauth2.googleapis.com/token' ? `${fixture.baseUrl}/token` : input, + init + ) + ) + try { + const { refreshOAuthToken } = await import('@/lib/oauth/oauth') + expect(await refreshOAuthToken(providerId, 'fixture-refresh-token')).toMatchObject({ + ok: true, + accessToken: 'fixture-refreshed-access', + }) + const prefix = providerId === 'google-drive' ? 'google-verification' : 'isolated-review' + expect(refreshForm?.get('client_id')).toBe(`${prefix}-fixture-client`) + expect(refreshForm?.get('client_secret')).toBe(`${prefix}-fixture-secret`) + } finally { + fetchSpy.mockRestore() + } + } + ) + + it('allows ordinary Google sign-in and approved connections', async () => { + const signIn = await post('/sign-in/social', { provider: 'google', callbackURL: '/' }) + expect(signIn.status).toBe(200) + expect(new URL((await signIn.json()).url).searchParams.get('client_id')).toBe( + 'google-verification-fixture-client' + ) + const response = await post('/oauth2/link', { providerId: 'google-drive', callbackURL: '/' }) + expect(response.status).toBe(200) + const body = await response.json() + expect(new URL(body.url).searchParams.get('scope')).toContain('auth/drive') + expect(new URL(body.url).searchParams.get('client_id')).toBe( + 'google-verification-fixture-client' + ) + }) + + it.each(['google-ads', 'google-meet', 'google-chat'])( + 'uses the isolated client for %s without granting unrelated scopes', + async (providerId) => { + fixture.enabled = true + const response = await post('/oauth2/link', { providerId, callbackURL: '/' }) + expect(response.status).toBe(200) + const redirect = new URL((await response.json()).url) + expect(redirect.searchParams.get('client_id')).toBe('isolated-review-fixture-client') + expect(redirect.searchParams.get('include_granted_scopes')).toBe('false') + expect(redirect.searchParams.get('scope')).not.toContain('auth/drive') + } + ) + + it('allows a new connection only while the deployment flag is enabled', async () => { + fixture.enabled = true + const response = await post('/oauth2/link', { providerId: 'google-meet', callbackURL: '/' }) + expect(response.status).toBe(200) + const body = await response.json() + const redirect = new URL(body.url) + expect(redirect.searchParams.get('scope')).toContain('auth/meetings.space.created') + fixture.enabled = false + const callback = await fetch( + `${fixture.baseUrl}/api/auth/oauth2/callback/google-meet?state=${encodeURIComponent(redirect.searchParams.get('state') ?? '')}&code=fixture-code`, + { headers: { cookie }, redirect: 'manual' } + ) + expect(callback.status).toBe(403) + }) + + it.each(['google-meet', 'google-chat'] as const)( + 'also refuses managed %s enrollment before redirecting', + async (provider) => { + const adapter = adapters.createStandardOAuthCredentialGroupProviderAdapter(provider) + const policy = await adapter.getPolicy(undefined, {}) + const context: CredentialGroupOAuthContext = { + enrollmentId: 'enrollment-fixture', + credentialGroupId: 'group-fixture', + credentialGroupName: 'Fixture', + workspaceId: 'workspace-fixture', + workspaceName: 'Fixture', + workspaceOwnerId: userId, + email, + enrollmentStatus: 'in_progress', + option: { + id: 'option-fixture', + provider, + label: 'Fixture', + authorizationAppId: policy.authorizationAppId, + requiredScopes: policy.requiredScopes, + scopeVersion: policy.scopeVersion, + required: true, + status: 'active', + }, + options: [], + } + await expect(adapter.prepareAuthorization(context, policy)).rejects.toMatchObject({ + statusCode: 403, + }) + fixture.enabled = true + const prepared = await adapter.prepareAuthorization(context, policy) + const authorization = new URL( + await prepared.buildAuthorizationUrl({ state: 'state-fixture', nonce: 'nonce-fixture' }) + ) + expect(authorization.searchParams.get('client_id')).toBe('isolated-review-fixture-client') + fixture.enabled = false + await expect( + adapter.exchangeAndVerify({ + context, + policy, + code: 'fixture-code', + attempt: { + userId, + workspaceId: context.workspaceId, + email, + state: 'state-fixture', + provider, + nonceHash: 'fixture-hash', + enrollmentId: context.enrollmentId, + credentialGroupId: context.credentialGroupId, + optionId: context.option.id, + authorizationAppId: policy.authorizationAppId, + scopeVersion: policy.scopeVersion, + requiredScopes: policy.requiredScopes, + redirectUri: prepared.redirectUri, + codeVerifier: prepared.codeVerifier, + invitationToken: 'invitation-fixture', + createdAt: Date.now(), + }, + }) + ).rejects.toMatchObject({ statusCode: 403 }) + } + ) +}) diff --git a/apps/sim/lib/auth/connectors/google-verification.ts b/apps/sim/lib/auth/connectors/google-verification.ts new file mode 100644 index 00000000000..d293fae4eb8 --- /dev/null +++ b/apps/sim/lib/auth/connectors/google-verification.ts @@ -0,0 +1,41 @@ +import { isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { isGoogleVerificationProvider } from '@/lib/core/config/google-oauth' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' + +const UNVERIFIED_SCOPES = new Set([ + 'https://www.googleapis.com/auth/adwords', + 'https://www.googleapis.com/auth/meetings.space.created', + 'https://www.googleapis.com/auth/meetings.space.readonly', + 'https://www.googleapis.com/auth/chat.spaces.readonly', + 'https://www.googleapis.com/auth/chat.messages.readonly', +]) + +/** Restricts new hosted grants without changing existing credentials or their refresh policy. */ +export async function getGoogleOAuthVerificationRefusal( + providerId: unknown, + scopes: unknown = [] +): Promise { + if ( + !isHosted || + typeof providerId !== 'string' || + (providerId !== 'google' && !providerId.startsWith('google-')) + ) + return undefined + + const requestedScopes = Array.isArray(scopes) + ? scopes.flatMap((scope) => + typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [] + ) + : [] + if (isGoogleVerificationProvider(providerId)) { + const allowedScopes = new Set(['openid', ...getCanonicalScopesForProvider(providerId)]) + if (requestedScopes.some((scope) => !allowedScopes.has(scope))) { + return 'This Google connection cannot request permissions for another service.' + } + if (await isFeatureEnabled('google-unverified-oauth')) return undefined + } else if (!requestedScopes.some((scope) => UNVERIFIED_SCOPES.has(scope))) { + return undefined + } + return 'This Google connection is awaiting verification and is not available in this environment.' +} diff --git a/apps/sim/lib/auth/connectors/providers.ts b/apps/sim/lib/auth/connectors/providers.ts index 4a1fce8f745..badbb6eac3a 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -8,7 +8,11 @@ import { getOAuth2Tokens } from 'better-auth/oauth2' import type { GenericOAuthConfig } from 'better-auth/plugins' import { syntheticConnectorEmail } from '@/lib/auth/connector-email' import { env } from '@/lib/core/config/env' -import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { + inspectConfiguredOAuthClient, + requireConfiguredOAuthClient, +} from '@/lib/core/config/env-capabilities.server' +import { isGoogleVerificationProvider } from '@/lib/core/config/google-oauth' import { DEFAULT_MAX_ERROR_BODY_BYTES, readResponseJsonWithLimit, @@ -2943,7 +2947,16 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { }, ] - return providers.filter( - ({ providerId }) => inspectConfiguredOAuthClient(providerId).state === 'ready' - ) + return providers + .filter(({ providerId }) => inspectConfiguredOAuthClient(providerId).state === 'ready') + .map((provider) => { + if (!isGoogleVerificationProvider(provider.providerId)) return provider + const { values } = requireConfiguredOAuthClient(provider.providerId) + return { + ...provider, + clientId: values.GOOGLE_CLIENT_ID, + clientSecret: values.GOOGLE_CLIENT_SECRET, + authorizationUrlParams: { include_granted_scopes: 'false' }, + } + }) } diff --git a/apps/sim/lib/core/config/env-capabilities.server.ts b/apps/sim/lib/core/config/env-capabilities.server.ts index 28af2740442..b98e19c92df 100644 --- a/apps/sim/lib/core/config/env-capabilities.server.ts +++ b/apps/sim/lib/core/config/env-capabilities.server.ts @@ -21,6 +21,7 @@ import { wireFallback, } from '@sim/deployment-config/env-capabilities' import { env } from '@/lib/core/config/env' +import { getGoogleOAuthClientValues } from '@/lib/core/config/google-oauth' export function getConfiguredStorageProviderId() { return requireCapability(STORAGE_CAPABILITY, env).providerId @@ -47,7 +48,7 @@ export function getConfiguredCacheProvider() { } export function inspectConfiguredOAuthClient(serviceId: string) { - return inspectOAuthClientCapability(serviceId, env) + return inspectOAuthClientCapability(serviceId, getGoogleOAuthClientValues(serviceId)) } export function requireConfiguredOAuthClient( @@ -55,7 +56,7 @@ export function requireConfiguredOAuthClient> export function requireConfiguredOAuthClient(serviceId: string): ConfiguredOAuthClient export function requireConfiguredOAuthClient(serviceId: string): ConfiguredOAuthClient { - return requireOAuthClientCapability(serviceId, env) + return requireOAuthClientCapability(serviceId, getGoogleOAuthClientValues(serviceId)) } export function wireServerFallback< diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8edac360a93..508eb232fb3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -500,6 +500,8 @@ export const env = createEnv({ // OAuth Integration Credentials - All optional, enables third-party integrations GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret + GOOGLE_VERIFICATION_CLIENT_ID: z.string().optional(), // Isolated Ads, Meet and Chat OAuth app; reconnect these credentials when changed + GOOGLE_VERIFICATION_CLIENT_SECRET: z.string().optional(), // Isolated Google OAuth secret; configure together with the client ID in app and worker GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret GITHUB_APP_CLIENT_ID: z.string().optional(), diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9a58894564e..023756d90d4 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -46,6 +46,11 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { + 'google-unverified-oauth': { + description: + 'Allow new Google Ads, Meet and Chat grants pending verification. Global deployment opt-in only; disabled by default.', + fallback: null, + }, dashboards: { description: 'Enable dashboard resources, rendering, analytics, and Mothership authoring. Supports global and organization rollout; disabled by default.', diff --git a/apps/sim/lib/core/config/google-oauth.ts b/apps/sim/lib/core/config/google-oauth.ts new file mode 100644 index 00000000000..0092c5044ab --- /dev/null +++ b/apps/sim/lib/core/config/google-oauth.ts @@ -0,0 +1,21 @@ +import { env } from '@/lib/core/config/env' + +/** Google integrations whose hosted scopes are still undergoing verification. */ +export function isGoogleVerificationProvider(providerId: string): boolean { + return providerId === 'google-ads' || providerId === 'google-meet' || providerId === 'google-chat' +} + +/** A partial isolated-client configuration must never fall back to the shared application. */ +export function getGoogleOAuthClientValues(providerId: string) { + if ( + isGoogleVerificationProvider(providerId) && + (env.GOOGLE_VERIFICATION_CLIENT_ID !== undefined || + env.GOOGLE_VERIFICATION_CLIENT_SECRET !== undefined) + ) { + return { + GOOGLE_CLIENT_ID: env.GOOGLE_VERIFICATION_CLIENT_ID, + GOOGLE_CLIENT_SECRET: env.GOOGLE_VERIFICATION_CLIENT_SECRET, + } + } + return env +} diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 5c4c343277d..7987446959e 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -6,6 +6,7 @@ import { type OAuth2Tokens, validateAuthorizationCode, } from 'better-auth/oauth2' +import { getGoogleOAuthVerificationRefusal } from '@/lib/auth/connectors/google-verification' import { type ConnectorProviderConfig, getManagedOAuthConnectorPolicy, @@ -249,6 +250,11 @@ export function createStandardOAuthCredentialGroupProviderAdapter( return getCurrentProvider(provider).policy }, async prepareAuthorization(_context, policy) { + const refusal = await getGoogleOAuthVerificationRefusal( + policy.providerId, + policy.requiredScopes + ) + if (refusal) throw new CredentialGroupOAuthError(refusal, 403) const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const managed = current.connector.managedOAuth @@ -292,6 +298,11 @@ export function createStandardOAuthCredentialGroupProviderAdapter( } }, async exchangeAndVerify({ attempt, code, policy }) { + const refusal = await getGoogleOAuthVerificationRefusal( + policy.providerId, + policy.requiredScopes + ) + if (refusal) throw new CredentialGroupOAuthError(refusal, 403) const current = getCurrentProvider(provider) assertCurrentPolicy(policy, current.policy) const redirectUri = getRedirectUri(provider, current) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index f209770922f..316985b7e05 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -80,6 +80,7 @@ import { ZoomIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' +import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' import { redactExactSensitiveValues } from '@/lib/core/security/redaction' import { @@ -1757,22 +1758,19 @@ function getConfiguredClientCredentials + clientOverride?: Pick, + serviceId = provider ): ProviderAuthConfig { if (clientOverride && provider !== 'quickbooks') { throw new Error(`OAuth client override is not supported for provider ${provider}`) } switch (provider) { case 'google': { - const { clientId, clientSecret } = getConfiguredClientCredentials( - 'google', - 'GOOGLE_CLIENT_ID', - 'GOOGLE_CLIENT_SECRET' - ) + const { values } = requireConfiguredOAuthClient(serviceId) return { tokenEndpoint: 'https://oauth2.googleapis.com/token', - clientId, - clientSecret, + clientId: values.GOOGLE_CLIENT_ID, + clientSecret: values.GOOGLE_CLIENT_SECRET, useBasicAuth: false, } } @@ -2513,7 +2511,7 @@ export async function refreshOAuthToken( try { const provider = getBaseProviderForService(providerId) - const config = getProviderAuthConfig(provider, clientOverride) + const config = getProviderAuthConfig(provider, clientOverride, providerId) if (config.clientSecret) exactSecrets.push(config.clientSecret) if (config.refreshStrategy === 'instagram_long_lived') {