Skip to content

Commit 0f0becc

Browse files
committed
fix(oauth): isolate verification-gated Google grants
1 parent 7561e0f commit 0f0becc

10 files changed

Lines changed: 474 additions & 15 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff'
3939
import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
4040
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
4141
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
42+
import { getGoogleOAuthVerificationRefusal } from '@/lib/auth/connectors/google-verification'
4243
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
4344
import {
4445
applyRegistrationGate,
@@ -981,6 +982,22 @@ export const auth = betterAuth({
981982
hooks: {
982983
before: createAuthMiddleware(async (ctx) => {
983984
try {
985+
const googleOAuthProviderId =
986+
ctx.path === '/oauth2/link' || ctx.path === '/sign-in/oauth2'
987+
? ctx.body?.providerId
988+
: ctx.path === '/sign-in/social' || ctx.path === '/link-social'
989+
? ctx.body?.provider
990+
: ctx.path.startsWith('/oauth2/callback/')
991+
? ctx.params?.providerId
992+
: undefined
993+
const googleOAuthRefusal = await getGoogleOAuthVerificationRefusal(
994+
googleOAuthProviderId,
995+
ctx.body?.scopes
996+
)
997+
if (googleOAuthRefusal) {
998+
throw new APIError('FORBIDDEN', { message: googleOAuthRefusal })
999+
}
1000+
9841001
/** Refuse provider calls when user authentication is disabled without blocking connector OAuth. */
9851002
if (
9861003
((ctx.path.startsWith('/oauth2/') &&
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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-')
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+
process.env.GOOGLE_VERIFICATION_CLIENT_ID = 'isolated-review-fixture-client'
98+
process.env.GOOGLE_VERIFICATION_CLIENT_SECRET = 'isolated-review-fixture-secret'
99+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
100+
const address = server.address()
101+
if (!address || typeof address === 'string') throw new Error('Fixture did not bind')
102+
fixture.baseUrl = `http://127.0.0.1:${address.port}`
103+
;({ auth } = await import('@/lib/auth/auth'))
104+
adapters = await import('@/lib/credential-groups/standard-oauth-provider')
105+
const now = new Date()
106+
await db.insert(user).values({
107+
id: userId,
108+
email,
109+
name: 'OAuth verification fixture',
110+
emailVerified: true,
111+
createdAt: now,
112+
updatedAt: now,
113+
})
114+
await db.insert(account).values({
115+
id: generateId(),
116+
accountId: userId,
117+
userId,
118+
providerId: 'credential',
119+
password: await hashPassword(password),
120+
createdAt: now,
121+
updatedAt: now,
122+
})
123+
const login = await post('/sign-in/email', { email, password })
124+
expect(login.status).toBe(200)
125+
cookie = login.headers
126+
.getSetCookie()
127+
.map((value) => value.split(';')[0])
128+
.join('; ')
129+
}, 90_000)
130+
131+
beforeEach(() => {
132+
fixture.enabled = false
133+
})
134+
135+
afterAll(async () => {
136+
await db.delete(user).where(eq(user.id, userId))
137+
await new Promise<void>((resolve, reject) => {
138+
server.close((error) => (error ? reject(error) : resolve()))
139+
server.closeAllConnections()
140+
})
141+
})
142+
143+
function post(path: string, body: object) {
144+
return fetch(`${fixture.baseUrl}/api/auth${path}`, {
145+
method: 'POST',
146+
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) },
147+
body: JSON.stringify(body),
148+
redirect: 'manual',
149+
})
150+
}
151+
152+
it.each(['google-ads', 'google-meet', 'google-chat'])(
153+
'refuses a new %s connection before returning an authorization URL',
154+
async (providerId) => {
155+
const response = await post('/oauth2/link', { providerId, callbackURL: '/' })
156+
expect(response.status).toBe(403)
157+
expect(await response.text()).not.toContain('/authorize?')
158+
}
159+
)
160+
161+
it.each([
162+
'https://www.googleapis.com/auth/adwords',
163+
'https://www.googleapis.com/auth/meetings.space.created',
164+
'https://www.googleapis.com/auth/meetings.space.readonly',
165+
'https://www.googleapis.com/auth/chat.spaces.readonly',
166+
'https://www.googleapis.com/auth/chat.messages.readonly',
167+
])('refuses adding the unapproved %s scope through an approved connector', async (scope) => {
168+
const response = await post('/oauth2/link', {
169+
providerId: 'google-drive',
170+
callbackURL: '/',
171+
scopes: [scope],
172+
})
173+
expect(response.status).toBe(403)
174+
})
175+
176+
it('refuses an unapproved scope through Google sign-in', async () => {
177+
const response = await post('/sign-in/social', {
178+
provider: 'google',
179+
callbackURL: '/',
180+
scopes: ['https://www.googleapis.com/auth/adwords'],
181+
})
182+
expect(response.status).toBe(403)
183+
})
184+
185+
it('refuses space-delimited scope injection through social account linking', async () => {
186+
const response = await post('/link-social', {
187+
provider: 'google',
188+
callbackURL: '/',
189+
scopes: ['openid https://www.googleapis.com/auth/adwords'],
190+
})
191+
expect(response.status).toBe(403)
192+
})
193+
194+
it.each([
195+
[
196+
'/oauth2/link',
197+
{ providerId: 'google-drive', scopes: ['https://www.googleapis.com/auth/adwords'] },
198+
],
199+
[
200+
'/sign-in/social',
201+
{ provider: 'google', scopes: ['https://www.googleapis.com/auth/chat.messages.readonly'] },
202+
],
203+
[
204+
'/oauth2/link',
205+
{ providerId: 'google-chat', scopes: ['https://www.googleapis.com/auth/adwords'] },
206+
],
207+
[
208+
'/oauth2/link',
209+
{ providerId: 'google-meet', scopes: ['https://www.googleapis.com/auth/drive'] },
210+
],
211+
])('keeps each client and service scope isolated when enabled: %s %j', async (path, body) => {
212+
fixture.enabled = true
213+
const response = await post(path, { ...body, callbackURL: '/' })
214+
expect(response.status).toBe(403)
215+
})
216+
217+
it.each(['google-ads', 'google-meet', 'google-chat', 'google-drive'])(
218+
'refreshes %s with the same client used for authorization even while new grants are disabled',
219+
async (providerId) => {
220+
const originalFetch = globalThis.fetch
221+
const fetchSpy = vi
222+
.spyOn(globalThis, 'fetch')
223+
.mockImplementation((input, init) =>
224+
originalFetch(
225+
input === 'https://oauth2.googleapis.com/token' ? `${fixture.baseUrl}/token` : input,
226+
init
227+
)
228+
)
229+
try {
230+
const { refreshOAuthToken } = await import('@/lib/oauth/oauth')
231+
expect(await refreshOAuthToken(providerId, 'fixture-refresh-token')).toMatchObject({
232+
ok: true,
233+
accessToken: 'fixture-refreshed-access',
234+
})
235+
const prefix = providerId === 'google-drive' ? 'google-verification' : 'isolated-review'
236+
expect(refreshForm?.get('client_id')).toBe(`${prefix}-fixture-client`)
237+
expect(refreshForm?.get('client_secret')).toBe(`${prefix}-fixture-secret`)
238+
} finally {
239+
fetchSpy.mockRestore()
240+
}
241+
}
242+
)
243+
244+
it('allows ordinary Google sign-in and approved connections', async () => {
245+
const signIn = await post('/sign-in/social', { provider: 'google', callbackURL: '/' })
246+
expect(signIn.status).toBe(200)
247+
expect(new URL((await signIn.json()).url).searchParams.get('client_id')).toBe(
248+
'google-verification-fixture-client'
249+
)
250+
const response = await post('/oauth2/link', { providerId: 'google-drive', callbackURL: '/' })
251+
expect(response.status).toBe(200)
252+
const body = await response.json()
253+
expect(new URL(body.url).searchParams.get('scope')).toContain('auth/drive')
254+
expect(new URL(body.url).searchParams.get('client_id')).toBe(
255+
'google-verification-fixture-client'
256+
)
257+
})
258+
259+
it.each(['google-ads', 'google-meet', 'google-chat'])(
260+
'uses the isolated client for %s without granting unrelated scopes',
261+
async (providerId) => {
262+
fixture.enabled = true
263+
const response = await post('/oauth2/link', { providerId, callbackURL: '/' })
264+
expect(response.status).toBe(200)
265+
const redirect = new URL((await response.json()).url)
266+
expect(redirect.searchParams.get('client_id')).toBe('isolated-review-fixture-client')
267+
expect(redirect.searchParams.get('include_granted_scopes')).toBe('false')
268+
expect(redirect.searchParams.get('scope')).not.toContain('auth/drive')
269+
}
270+
)
271+
272+
it('allows a new connection only while the deployment flag is enabled', async () => {
273+
fixture.enabled = true
274+
const response = await post('/oauth2/link', { providerId: 'google-meet', callbackURL: '/' })
275+
expect(response.status).toBe(200)
276+
const body = await response.json()
277+
const redirect = new URL(body.url)
278+
expect(redirect.searchParams.get('scope')).toContain('auth/meetings.space.created')
279+
fixture.enabled = false
280+
const callback = await fetch(
281+
`${fixture.baseUrl}/api/auth/oauth2/callback/google-meet?state=${encodeURIComponent(redirect.searchParams.get('state') ?? '')}&code=fixture-code`,
282+
{ headers: { cookie }, redirect: 'manual' }
283+
)
284+
expect(callback.status).toBe(403)
285+
})
286+
287+
it.each(['google-meet', 'google-chat'] as const)(
288+
'also refuses managed %s enrollment before redirecting',
289+
async (provider) => {
290+
const adapter = adapters.createStandardOAuthCredentialGroupProviderAdapter(provider)
291+
const policy = await adapter.getPolicy(undefined, {})
292+
const context: CredentialGroupOAuthContext = {
293+
enrollmentId: 'enrollment-fixture',
294+
credentialGroupId: 'group-fixture',
295+
credentialGroupName: 'Fixture',
296+
workspaceId: 'workspace-fixture',
297+
workspaceName: 'Fixture',
298+
workspaceOwnerId: userId,
299+
email,
300+
enrollmentStatus: 'in_progress',
301+
option: {
302+
id: 'option-fixture',
303+
provider,
304+
label: 'Fixture',
305+
authorizationAppId: policy.authorizationAppId,
306+
requiredScopes: policy.requiredScopes,
307+
scopeVersion: policy.scopeVersion,
308+
required: true,
309+
status: 'active',
310+
},
311+
options: [],
312+
}
313+
await expect(adapter.prepareAuthorization(context, policy)).rejects.toMatchObject({
314+
statusCode: 403,
315+
})
316+
fixture.enabled = true
317+
const prepared = await adapter.prepareAuthorization(context, policy)
318+
const authorization = new URL(
319+
await prepared.buildAuthorizationUrl({ state: 'state-fixture', nonce: 'nonce-fixture' })
320+
)
321+
expect(authorization.searchParams.get('client_id')).toBe('isolated-review-fixture-client')
322+
fixture.enabled = false
323+
await expect(
324+
adapter.exchangeAndVerify({
325+
context,
326+
policy,
327+
code: 'fixture-code',
328+
attempt: {
329+
userId,
330+
workspaceId: context.workspaceId,
331+
email,
332+
state: 'state-fixture',
333+
provider,
334+
nonceHash: 'fixture-hash',
335+
enrollmentId: context.enrollmentId,
336+
credentialGroupId: context.credentialGroupId,
337+
optionId: context.option.id,
338+
authorizationAppId: policy.authorizationAppId,
339+
scopeVersion: policy.scopeVersion,
340+
requiredScopes: policy.requiredScopes,
341+
redirectUri: prepared.redirectUri,
342+
codeVerifier: prepared.codeVerifier,
343+
invitationToken: 'invitation-fixture',
344+
createdAt: Date.now(),
345+
},
346+
})
347+
).rejects.toMatchObject({ statusCode: 403 })
348+
}
349+
)
350+
})

0 commit comments

Comments
 (0)