Skip to content

Commit 05ca5a8

Browse files
j15zSg312
authored andcommitted
feat(credentials): agent-initiated oauth credential reconnect (#5488)
* feat(credentials): agent-initiated oauth credential reconnect * fix(credentials): address reconnect review findings * improvement(credentials): log when connect draft name lookups degrade
1 parent 30ffe0b commit 05ca5a8

13 files changed

Lines changed: 921 additions & 96 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockGetSession,
9+
mockOAuth2LinkAccount,
10+
mockCheckWorkspaceAccess,
11+
mockGetCredentialActorContext,
12+
} = vi.hoisted(() => ({
13+
mockGetSession: vi.fn(),
14+
mockOAuth2LinkAccount: vi.fn(),
15+
mockCheckWorkspaceAccess: vi.fn(),
16+
mockGetCredentialActorContext: vi.fn(),
17+
}))
18+
19+
vi.mock('@sim/db', () => dbChainMock)
20+
21+
vi.mock('@/lib/auth/auth', () => ({
22+
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
23+
getSession: mockGetSession,
24+
}))
25+
26+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
27+
checkWorkspaceAccess: mockCheckWorkspaceAccess,
28+
}))
29+
30+
vi.mock('@/lib/credentials/access', () => ({
31+
getCredentialActorContext: mockGetCredentialActorContext,
32+
}))
33+
34+
vi.mock('@/lib/oauth/utils', () => ({
35+
getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
36+
}))
37+
38+
import { GET } from '@/app/api/auth/oauth2/authorize/route'
39+
40+
const BASE_URL = 'https://sim.test'
41+
const WORKSPACE_ID = 'ws-1'
42+
const USER_ID = 'user-1'
43+
const CREDENTIAL_ID = 'cred-1'
44+
const LINK_URL = 'https://provider.example/authorize?state=abc'
45+
46+
function authorizeRequest(query: Record<string, string>) {
47+
const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
48+
for (const [key, value] of Object.entries(query)) {
49+
url.searchParams.set(key, value)
50+
}
51+
return createMockRequest('GET', undefined, {}, url.toString())
52+
}
53+
54+
function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
55+
return {
56+
credential: {
57+
id: CREDENTIAL_ID,
58+
workspaceId: WORKSPACE_ID,
59+
type: 'oauth',
60+
providerId: 'google-email',
61+
displayName: 'Work Gmail',
62+
...((overrides.credential as Record<string, unknown>) ?? {}),
63+
},
64+
member: null,
65+
hasWorkspaceAccess: true,
66+
canWriteWorkspace: true,
67+
isAdmin: true,
68+
...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
69+
}
70+
}
71+
72+
describe('OAuth2 authorize route', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
resetDbChainMock()
76+
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
77+
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
78+
mockCheckWorkspaceAccess.mockResolvedValue({
79+
hasAccess: true,
80+
canWrite: true,
81+
canAdmin: false,
82+
workspace: { id: WORKSPACE_ID },
83+
})
84+
mockOAuth2LinkAccount.mockResolvedValue({
85+
ok: true,
86+
status: 200,
87+
json: async () => ({ url: LINK_URL }),
88+
headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
89+
})
90+
})
91+
92+
describe('plain connect (no credentialId)', () => {
93+
it('creates a draft with credentialId null and redirects to the provider', async () => {
94+
const response = await GET(
95+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
96+
)
97+
98+
expect(response.headers.get('location')).toBe(LINK_URL)
99+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
100+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
userId: USER_ID,
103+
workspaceId: WORKSPACE_ID,
104+
providerId: 'google-email',
105+
credentialId: null,
106+
})
107+
)
108+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
set: expect.objectContaining({ credentialId: null }),
111+
})
112+
)
113+
})
114+
115+
it('numbers the draft display name when the default collides with an existing credential', async () => {
116+
dbChainMockFns.where
117+
.mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
118+
.mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))
119+
120+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
121+
122+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
123+
expect.objectContaining({ displayName: "Justin's Gmail 2" })
124+
)
125+
})
126+
127+
it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
128+
await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
129+
130+
const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
131+
expect(set).toHaveProperty('credentialId', null)
132+
})
133+
134+
it('redirects to login when unauthenticated', async () => {
135+
mockGetSession.mockResolvedValue(null)
136+
137+
const response = await GET(
138+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
139+
)
140+
141+
expect(response.headers.get('location')).toContain('/login')
142+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
143+
})
144+
145+
it('rejects without workspace write access', async () => {
146+
mockCheckWorkspaceAccess.mockResolvedValue({
147+
hasAccess: true,
148+
canWrite: false,
149+
canAdmin: false,
150+
workspace: { id: WORKSPACE_ID },
151+
})
152+
153+
const response = await GET(
154+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
155+
)
156+
157+
expect(response.headers.get('location')).toBe(
158+
`${BASE_URL}/workspace?error=workspace_access_denied`
159+
)
160+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
161+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
162+
})
163+
})
164+
165+
describe('reconnect (credentialId present)', () => {
166+
it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
167+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
168+
169+
const response = await GET(
170+
authorizeRequest({
171+
providerId: 'google-email',
172+
workspaceId: WORKSPACE_ID,
173+
credentialId: CREDENTIAL_ID,
174+
})
175+
)
176+
177+
expect(response.headers.get('location')).toBe(LINK_URL)
178+
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
179+
CREDENTIAL_ID,
180+
USER_ID,
181+
expect.objectContaining({ workspaceAccess: expect.anything() })
182+
)
183+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
184+
expect.objectContaining({ credentialId: CREDENTIAL_ID })
185+
)
186+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
187+
expect.objectContaining({
188+
set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
189+
})
190+
)
191+
})
192+
193+
it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => {
194+
mockGetCredentialActorContext.mockResolvedValue(
195+
oauthCredentialActor({ credential: { displayName: 'Renamed By User' } })
196+
)
197+
198+
await GET(
199+
authorizeRequest({
200+
providerId: 'google-email',
201+
workspaceId: WORKSPACE_ID,
202+
credentialId: CREDENTIAL_ID,
203+
})
204+
)
205+
206+
expect(dbChainMockFns.values).toHaveBeenCalledWith(
207+
expect.objectContaining({ displayName: 'Renamed By User' })
208+
)
209+
})
210+
211+
it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => {
212+
for (const providerId of ['trello', 'shopify']) {
213+
const response = await GET(
214+
authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID })
215+
)
216+
217+
expect(response.headers.get('location')).toBe(
218+
`${BASE_URL}/workspace?error=credential_reconnect_unsupported`
219+
)
220+
}
221+
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
222+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
223+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
224+
})
225+
226+
it('rejects when the caller is not a credential admin and writes no draft', async () => {
227+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
228+
229+
const response = await GET(
230+
authorizeRequest({
231+
providerId: 'google-email',
232+
workspaceId: WORKSPACE_ID,
233+
credentialId: CREDENTIAL_ID,
234+
})
235+
)
236+
237+
expect(response.headers.get('location')).toBe(
238+
`${BASE_URL}/workspace?error=credential_access_denied`
239+
)
240+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
241+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
242+
})
243+
244+
it('rejects when the credential belongs to a different workspace', async () => {
245+
mockGetCredentialActorContext.mockResolvedValue(
246+
oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
247+
)
248+
249+
const response = await GET(
250+
authorizeRequest({
251+
providerId: 'google-email',
252+
workspaceId: WORKSPACE_ID,
253+
credentialId: CREDENTIAL_ID,
254+
})
255+
)
256+
257+
expect(response.headers.get('location')).toBe(
258+
`${BASE_URL}/workspace?error=credential_access_denied`
259+
)
260+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
261+
})
262+
263+
it('rejects when the credential does not exist', async () => {
264+
mockGetCredentialActorContext.mockResolvedValue({
265+
credential: null,
266+
member: null,
267+
hasWorkspaceAccess: false,
268+
canWriteWorkspace: false,
269+
isAdmin: false,
270+
})
271+
272+
const response = await GET(
273+
authorizeRequest({
274+
providerId: 'google-email',
275+
workspaceId: WORKSPACE_ID,
276+
credentialId: 'cred-missing',
277+
})
278+
)
279+
280+
expect(response.headers.get('location')).toBe(
281+
`${BASE_URL}/workspace?error=credential_access_denied`
282+
)
283+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
284+
})
285+
286+
it('rejects a non-oauth credential', async () => {
287+
mockGetCredentialActorContext.mockResolvedValue(
288+
oauthCredentialActor({ credential: { type: 'env_workspace' } })
289+
)
290+
291+
const response = await GET(
292+
authorizeRequest({
293+
providerId: 'google-email',
294+
workspaceId: WORKSPACE_ID,
295+
credentialId: CREDENTIAL_ID,
296+
})
297+
)
298+
299+
expect(response.headers.get('location')).toBe(
300+
`${BASE_URL}/workspace?error=credential_access_denied`
301+
)
302+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
303+
})
304+
305+
it('rejects when the query providerId does not match the credential provider', async () => {
306+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
307+
308+
const response = await GET(
309+
authorizeRequest({
310+
providerId: 'slack',
311+
workspaceId: WORKSPACE_ID,
312+
credentialId: CREDENTIAL_ID,
313+
})
314+
)
315+
316+
expect(response.headers.get('location')).toBe(
317+
`${BASE_URL}/workspace?error=credential_provider_mismatch`
318+
)
319+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
320+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
321+
})
322+
})
323+
})

0 commit comments

Comments
 (0)