diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 31ea8978ad5..d176b6ac422 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -42,6 +42,7 @@ import { enqueueAdminRemovedChatCleanup } from '@/lib/auth/admin-removal-chat-cl import { holdAdminRemovalStops, stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { stableExternalAccountId } from '@/lib/auth/connectors/account-id' +import { getGoogleOAuthVerificationRefusal } from '@/lib/auth/connectors/google-verification' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { applyRegistrationGate, @@ -1023,6 +1024,26 @@ export const auth = betterAuth({ hooks: { before: createAuthMiddleware(async (ctx) => { try { + let grantProviderId: unknown + switch (ctx.path) { + case '/oauth2/link': + case '/sign-in/oauth2': + grantProviderId = ctx.body?.providerId + break + case '/link-social': + case '/sign-in/social': + grantProviderId = ctx.body?.provider + break + default: + if (ctx.path.startsWith('/oauth2/callback/') || ctx.path.startsWith('/callback/')) { + grantProviderId = ctx.params?.providerId ?? ctx.params?.id + } + } + if (grantProviderId) { + const refusal = await getGoogleOAuthVerificationRefusal(grantProviderId, ctx.body?.scopes) + if (refusal) throw new APIError('FORBIDDEN', { message: refusal }) + } + /** 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..8b3741e6603 --- /dev/null +++ b/apps/sim/lib/auth/connectors/google-verification.integration.ts @@ -0,0 +1,351 @@ +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.providerId === 'vertex-ai' + ? { + ...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' + 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'] }, + ], + [ + '/oauth2/link', + { providerId: 'vertex-ai', 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', + }) + expect(refreshForm?.get('client_id')).toBe('google-verification-fixture-client') + expect(refreshForm?.get('client_secret')).toBe('google-verification-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'])( + 'requests only the service scopes for %s on staging', + 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('google-verification-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('google-verification-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..01f99183b5c --- /dev/null +++ b/apps/sim/lib/auth/connectors/google-verification.ts @@ -0,0 +1,43 @@ +import { resolveOAuthClientCapabilityId } from '@sim/deployment-config/env-capabilities' +import { isHosted } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' + +const REVIEW_PROVIDERS = new Set(['google-ads', 'google-chat', 'google-meet']) + +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' || + resolveOAuthClientCapabilityId(providerId) !== 'google' + ) + return undefined + + const requestedScopes = Array.isArray(scopes) + ? scopes.flatMap((scope) => + typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [] + ) + : [] + if (REVIEW_PROVIDERS.has(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 4e9b89522ac..9e46cce4cc8 100644 --- a/apps/sim/lib/auth/connectors/providers.ts +++ b/apps/sim/lib/auth/connectors/providers.ts @@ -466,6 +466,7 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', scopes: getCanonicalScopesForProvider('google-ads'), + authorizationUrlParams: { include_granted_scopes: 'false' }, prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`, getUserInfo: async (tokens) => { @@ -612,6 +613,7 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', scopes: getCanonicalScopesForProvider('google-chat'), + authorizationUrlParams: { include_granted_scopes: 'false' }, prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-chat`, getUserInfo: async (tokens) => { @@ -649,6 +651,7 @@ export function buildConnectorProviders(): GenericOAuthConfig[] { discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', accessType: 'offline', scopes: getCanonicalScopesForProvider('google-meet'), + authorizationUrlParams: { include_granted_scopes: 'false' }, prompt: 'consent', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`, getUserInfo: async (tokens) => { diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 5394541ae6e..1c63e7cfa30 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/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 575aef31263..119875832b2 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)