Skip to content

Commit 2116909

Browse files
committed
fix(oauth): restrict new unverified Google grants
1 parent e820b58 commit 2116909

6 files changed

Lines changed: 434 additions & 0 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { enqueueAdminRemovedChatCleanup } from '@/lib/auth/admin-removal-chat-cl
4242
import { holdAdminRemovalStops, stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops'
4343
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
4444
import { stableExternalAccountId } from '@/lib/auth/connectors/account-id'
45+
import { getGoogleOAuthVerificationRefusal } from '@/lib/auth/connectors/google-verification'
4546
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
4647
import {
4748
applyRegistrationGate,
@@ -1023,6 +1024,26 @@ export const auth = betterAuth({
10231024
hooks: {
10241025
before: createAuthMiddleware(async (ctx) => {
10251026
try {
1027+
let grantProviderId: unknown
1028+
switch (ctx.path) {
1029+
case '/oauth2/link':
1030+
case '/sign-in/oauth2':
1031+
grantProviderId = ctx.body?.providerId
1032+
break
1033+
case '/link-social':
1034+
case '/sign-in/social':
1035+
grantProviderId = ctx.body?.provider
1036+
break
1037+
default:
1038+
if (ctx.path.startsWith('/oauth2/callback/') || ctx.path.startsWith('/callback/')) {
1039+
grantProviderId = ctx.params?.providerId ?? ctx.params?.id
1040+
}
1041+
}
1042+
if (grantProviderId) {
1043+
const refusal = await getGoogleOAuthVerificationRefusal(grantProviderId, ctx.body?.scopes)
1044+
if (refusal) throw new APIError('FORBIDDEN', { message: refusal })
1045+
}
1046+
10261047
/** Refuse provider calls when user authentication is disabled without blocking connector OAuth. */
10271048
if (
10281049
((ctx.path.startsWith('/oauth2/') &&
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import { createServer } from 'node:http'
2+
import { db } from '@sim/db'
3+
import { account, user } from '@sim/db/schema'
4+
import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock'
5+
import { emailTemplatesMock } from '@sim/testing/mocks/email-templates.mock'
6+
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
7+
import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
8+
import { generateId } from '@sim/utils/id'
9+
import { hashPassword } from 'better-auth/crypto'
10+
import { eq } from 'drizzle-orm'
11+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments'
13+
14+
const fixture = vi.hoisted(() => ({ enabled: false, baseUrl: '' }))
15+
16+
vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock)
17+
vi.mock('@/components/emails', () => emailTemplatesMock)
18+
vi.mock('@/lib/posthog/server', () => posthogServerMock)
19+
vi.mock('@/lib/core/config/env-flags', () => ({
20+
...envFlagsMock,
21+
isHosted: true,
22+
isBillingEnabled: false,
23+
isAppConfigEnabled: true,
24+
}))
25+
vi.mock('@/lib/core/config/appconfig', () => ({
26+
fetchAppConfigProfile: async (ids: { profile: string }, parse: (value: unknown) => unknown) =>
27+
parse(
28+
ids.profile === 'feature-flags'
29+
? { 'google-unverified-oauth': { enabled: fixture.enabled } }
30+
: {}
31+
),
32+
}))
33+
vi.mock('@/lib/auth/connectors/providers', async (importOriginal) => {
34+
const original = await importOriginal<typeof import('@/lib/auth/connectors/providers')>()
35+
return {
36+
buildConnectorProviders: () =>
37+
original.buildConnectorProviders().map((provider) =>
38+
provider.providerId.startsWith('google-') || provider.providerId === 'vertex-ai'
39+
? {
40+
...provider,
41+
discoveryUrl: undefined,
42+
authorizationUrl: `${fixture.baseUrl}/authorize`,
43+
tokenUrl: `${fixture.baseUrl}/token`,
44+
}
45+
: provider
46+
),
47+
}
48+
})
49+
50+
describe('Google OAuth verification restrictions at the HTTP boundary', () => {
51+
let auth: typeof import('@/lib/auth/auth').auth
52+
let adapters: typeof import('@/lib/credential-groups/standard-oauth-provider')
53+
let cookie: string
54+
let refreshForm: URLSearchParams | undefined
55+
const userId = generateId()
56+
const password = 'Disposable-fixture-password-42!'
57+
const email = `${userId}@oauth-verification.test`
58+
const server = createServer(async (request, response) => {
59+
if (request.url === '/token') {
60+
const chunks: Buffer[] = []
61+
for await (const chunk of request) chunks.push(chunk)
62+
refreshForm = new URLSearchParams(Buffer.concat(chunks).toString())
63+
response.writeHead(200, { 'content-type': 'application/json' })
64+
response.end(
65+
JSON.stringify({
66+
access_token: 'fixture-refreshed-access',
67+
token_type: 'Bearer',
68+
expires_in: 3600,
69+
})
70+
)
71+
return
72+
}
73+
const chunks: Buffer[] = []
74+
for await (const chunk of request) chunks.push(chunk)
75+
const result = await auth.handler(
76+
new Request(`http://localhost:3000${request.url}`, {
77+
method: request.method,
78+
headers: {
79+
'content-type': 'application/json',
80+
origin: 'http://localhost:3000',
81+
...(request.headers.cookie ? { cookie: request.headers.cookie } : {}),
82+
},
83+
...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
84+
})
85+
)
86+
response.statusCode = result.status
87+
result.headers.forEach((value, key) => {
88+
if (key !== 'set-cookie') response.setHeader(key, value)
89+
})
90+
response.setHeader('set-cookie', result.headers.getSetCookie())
91+
response.end(Buffer.from(await result.arrayBuffer()))
92+
})
93+
94+
beforeAll(async () => {
95+
process.env.GOOGLE_CLIENT_ID = 'google-verification-fixture-client'
96+
process.env.GOOGLE_CLIENT_SECRET = 'google-verification-fixture-secret'
97+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
98+
const address = server.address()
99+
if (!address || typeof address === 'string') throw new Error('Fixture did not bind')
100+
fixture.baseUrl = `http://127.0.0.1:${address.port}`
101+
;({ auth } = await import('@/lib/auth/auth'))
102+
adapters = await import('@/lib/credential-groups/standard-oauth-provider')
103+
const now = new Date()
104+
await db.insert(user).values({
105+
id: userId,
106+
email,
107+
name: 'OAuth verification fixture',
108+
emailVerified: true,
109+
createdAt: now,
110+
updatedAt: now,
111+
})
112+
await db.insert(account).values({
113+
id: generateId(),
114+
accountId: userId,
115+
userId,
116+
providerId: 'credential',
117+
password: await hashPassword(password),
118+
createdAt: now,
119+
updatedAt: now,
120+
})
121+
const login = await post('/sign-in/email', { email, password })
122+
expect(login.status).toBe(200)
123+
cookie = login.headers
124+
.getSetCookie()
125+
.map((value) => value.split(';')[0])
126+
.join('; ')
127+
}, 90_000)
128+
129+
beforeEach(() => {
130+
fixture.enabled = false
131+
})
132+
133+
afterAll(async () => {
134+
await db.delete(user).where(eq(user.id, userId))
135+
await new Promise<void>((resolve, reject) => {
136+
server.close((error) => (error ? reject(error) : resolve()))
137+
server.closeAllConnections()
138+
})
139+
})
140+
141+
function post(path: string, body: object) {
142+
return fetch(`${fixture.baseUrl}/api/auth${path}`, {
143+
method: 'POST',
144+
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) },
145+
body: JSON.stringify(body),
146+
redirect: 'manual',
147+
})
148+
}
149+
150+
it.each(['google-ads', 'google-meet', 'google-chat'])(
151+
'refuses a new %s connection before returning an authorization URL',
152+
async (providerId) => {
153+
const response = await post('/oauth2/link', { providerId, callbackURL: '/' })
154+
expect(response.status).toBe(403)
155+
expect(await response.text()).not.toContain('/authorize?')
156+
}
157+
)
158+
159+
it.each([
160+
'https://www.googleapis.com/auth/adwords',
161+
'https://www.googleapis.com/auth/meetings.space.created',
162+
'https://www.googleapis.com/auth/meetings.space.readonly',
163+
'https://www.googleapis.com/auth/chat.spaces.readonly',
164+
'https://www.googleapis.com/auth/chat.messages.readonly',
165+
])('refuses adding the unapproved %s scope through an approved connector', async (scope) => {
166+
const response = await post('/oauth2/link', {
167+
providerId: 'google-drive',
168+
callbackURL: '/',
169+
scopes: [scope],
170+
})
171+
expect(response.status).toBe(403)
172+
})
173+
174+
it('refuses an unapproved scope through Google sign-in', async () => {
175+
const response = await post('/sign-in/social', {
176+
provider: 'google',
177+
callbackURL: '/',
178+
scopes: ['https://www.googleapis.com/auth/adwords'],
179+
})
180+
expect(response.status).toBe(403)
181+
})
182+
183+
it('refuses space-delimited scope injection through social account linking', async () => {
184+
const response = await post('/link-social', {
185+
provider: 'google',
186+
callbackURL: '/',
187+
scopes: ['openid https://www.googleapis.com/auth/adwords'],
188+
})
189+
expect(response.status).toBe(403)
190+
})
191+
192+
it.each([
193+
[
194+
'/oauth2/link',
195+
{ providerId: 'google-drive', scopes: ['https://www.googleapis.com/auth/adwords'] },
196+
],
197+
[
198+
'/oauth2/link',
199+
{ providerId: 'vertex-ai', scopes: ['https://www.googleapis.com/auth/adwords'] },
200+
],
201+
[
202+
'/sign-in/social',
203+
{ provider: 'google', scopes: ['https://www.googleapis.com/auth/chat.messages.readonly'] },
204+
],
205+
[
206+
'/oauth2/link',
207+
{ providerId: 'google-chat', scopes: ['https://www.googleapis.com/auth/adwords'] },
208+
],
209+
[
210+
'/oauth2/link',
211+
{ providerId: 'google-meet', scopes: ['https://www.googleapis.com/auth/drive'] },
212+
],
213+
])('keeps each client and service scope isolated when enabled: %s %j', async (path, body) => {
214+
fixture.enabled = true
215+
const response = await post(path, { ...body, callbackURL: '/' })
216+
expect(response.status).toBe(403)
217+
})
218+
219+
it.each(['google-ads', 'google-meet', 'google-chat', 'google-drive'])(
220+
'refreshes %s with the same client used for authorization even while new grants are disabled',
221+
async (providerId) => {
222+
const originalFetch = globalThis.fetch
223+
const fetchSpy = vi
224+
.spyOn(globalThis, 'fetch')
225+
.mockImplementation((input, init) =>
226+
originalFetch(
227+
input === 'https://oauth2.googleapis.com/token' ? `${fixture.baseUrl}/token` : input,
228+
init
229+
)
230+
)
231+
try {
232+
const { refreshOAuthToken } = await import('@/lib/oauth/oauth')
233+
expect(await refreshOAuthToken(providerId, 'fixture-refresh-token')).toMatchObject({
234+
ok: true,
235+
accessToken: 'fixture-refreshed-access',
236+
})
237+
expect(refreshForm?.get('client_id')).toBe('google-verification-fixture-client')
238+
expect(refreshForm?.get('client_secret')).toBe('google-verification-fixture-secret')
239+
} finally {
240+
fetchSpy.mockRestore()
241+
}
242+
}
243+
)
244+
245+
it('allows ordinary Google sign-in and approved connections', async () => {
246+
const signIn = await post('/sign-in/social', { provider: 'google', callbackURL: '/' })
247+
expect(signIn.status).toBe(200)
248+
expect(new URL((await signIn.json()).url).searchParams.get('client_id')).toBe(
249+
'google-verification-fixture-client'
250+
)
251+
const response = await post('/oauth2/link', { providerId: 'google-drive', callbackURL: '/' })
252+
expect(response.status).toBe(200)
253+
const body = await response.json()
254+
expect(new URL(body.url).searchParams.get('scope')).toContain('auth/drive')
255+
expect(new URL(body.url).searchParams.get('client_id')).toBe(
256+
'google-verification-fixture-client'
257+
)
258+
})
259+
260+
it.each(['google-ads', 'google-meet', 'google-chat'])(
261+
'requests only the service scopes for %s on staging',
262+
async (providerId) => {
263+
fixture.enabled = true
264+
const response = await post('/oauth2/link', { providerId, callbackURL: '/' })
265+
expect(response.status).toBe(200)
266+
const redirect = new URL((await response.json()).url)
267+
expect(redirect.searchParams.get('client_id')).toBe('google-verification-fixture-client')
268+
expect(redirect.searchParams.get('include_granted_scopes')).toBe('false')
269+
expect(redirect.searchParams.get('scope')).not.toContain('auth/drive')
270+
}
271+
)
272+
273+
it('allows a new connection only while the deployment flag is enabled', async () => {
274+
fixture.enabled = true
275+
const response = await post('/oauth2/link', { providerId: 'google-meet', callbackURL: '/' })
276+
expect(response.status).toBe(200)
277+
const body = await response.json()
278+
const redirect = new URL(body.url)
279+
expect(redirect.searchParams.get('scope')).toContain('auth/meetings.space.created')
280+
fixture.enabled = false
281+
const callback = await fetch(
282+
`${fixture.baseUrl}/api/auth/oauth2/callback/google-meet?state=${encodeURIComponent(redirect.searchParams.get('state') ?? '')}&code=fixture-code`,
283+
{ headers: { cookie }, redirect: 'manual' }
284+
)
285+
expect(callback.status).toBe(403)
286+
})
287+
288+
it.each(['google-meet', 'google-chat'] as const)(
289+
'also refuses managed %s enrollment before redirecting',
290+
async (provider) => {
291+
const adapter = adapters.createStandardOAuthCredentialGroupProviderAdapter(provider)
292+
const policy = await adapter.getPolicy(undefined, {})
293+
const context: CredentialGroupOAuthContext = {
294+
enrollmentId: 'enrollment-fixture',
295+
credentialGroupId: 'group-fixture',
296+
credentialGroupName: 'Fixture',
297+
workspaceId: 'workspace-fixture',
298+
workspaceName: 'Fixture',
299+
workspaceOwnerId: userId,
300+
email,
301+
enrollmentStatus: 'in_progress',
302+
option: {
303+
id: 'option-fixture',
304+
provider,
305+
label: 'Fixture',
306+
authorizationAppId: policy.authorizationAppId,
307+
requiredScopes: policy.requiredScopes,
308+
scopeVersion: policy.scopeVersion,
309+
required: true,
310+
status: 'active',
311+
},
312+
options: [],
313+
}
314+
await expect(adapter.prepareAuthorization(context, policy)).rejects.toMatchObject({
315+
statusCode: 403,
316+
})
317+
fixture.enabled = true
318+
const prepared = await adapter.prepareAuthorization(context, policy)
319+
const authorization = new URL(
320+
await prepared.buildAuthorizationUrl({ state: 'state-fixture', nonce: 'nonce-fixture' })
321+
)
322+
expect(authorization.searchParams.get('client_id')).toBe('google-verification-fixture-client')
323+
fixture.enabled = false
324+
await expect(
325+
adapter.exchangeAndVerify({
326+
context,
327+
policy,
328+
code: 'fixture-code',
329+
attempt: {
330+
userId,
331+
workspaceId: context.workspaceId,
332+
email,
333+
state: 'state-fixture',
334+
provider,
335+
nonceHash: 'fixture-hash',
336+
enrollmentId: context.enrollmentId,
337+
credentialGroupId: context.credentialGroupId,
338+
optionId: context.option.id,
339+
authorizationAppId: policy.authorizationAppId,
340+
scopeVersion: policy.scopeVersion,
341+
requiredScopes: policy.requiredScopes,
342+
redirectUri: prepared.redirectUri,
343+
codeVerifier: prepared.codeVerifier,
344+
invitationToken: 'invitation-fixture',
345+
createdAt: Date.now(),
346+
},
347+
})
348+
).rejects.toMatchObject({ statusCode: 403 })
349+
}
350+
)
351+
})

0 commit comments

Comments
 (0)