Skip to content

Commit 24ef2cf

Browse files
committed
fix(credentials): address reconnect review findings
1 parent dd83be8 commit 24ef2cf

5 files changed

Lines changed: 124 additions & 34 deletions

File tree

apps/sim/app/api/auth/oauth2/authorize/route.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
5858
workspaceId: WORKSPACE_ID,
5959
type: 'oauth',
6060
providerId: 'google-email',
61+
displayName: 'Work Gmail',
6162
...((overrides.credential as Record<string, unknown>) ?? {}),
6263
},
6364
member: null,
@@ -189,6 +190,39 @@ describe('OAuth2 authorize route', () => {
189190
)
190191
})
191192

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+
192226
it('rejects when the caller is not a credential admin and writes no draft', async () => {
193227
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
194228

apps/sim/app/api/auth/oauth2/authorize/route.ts

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,37 +33,42 @@ async function createConnectDraft(params: {
3333
workspaceId: string
3434
providerId: string
3535
credentialId?: string
36+
/** Reconnect only: the credential's actual name, so audit records stay accurate. */
37+
displayName?: string
3638
}): Promise<void> {
3739
const { userId, workspaceId, providerId, credentialId } = params
3840

39-
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
40-
const serviceName = service?.name ?? providerId
41+
let displayName = params.displayName
42+
if (!displayName) {
43+
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
44+
const serviceName = service?.name ?? providerId
45+
46+
let userName: string | null = null
47+
try {
48+
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
49+
userName = row?.name ?? null
50+
} catch {
51+
// Fall back to the "My {Service}" default
52+
}
4153

42-
let userName: string | null = null
43-
try {
44-
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
45-
userName = row?.name ?? null
46-
} catch {
47-
// Fall back to the "My {Service}" default
48-
}
54+
// Auto-number against existing workspace credentials so repeat connects for
55+
// the same provider stay distinguishable — same behavior as the connect
56+
// modal, which computes this client-side. Best effort: on failure the name
57+
// simply skips deduplication.
58+
let takenNames: ReadonlySet<string> = new Set<string>()
59+
try {
60+
const rows = await db
61+
.select({ displayName: credential.displayName })
62+
.from(credential)
63+
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
64+
takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
65+
} catch {
66+
// Best effort — proceed without collision numbering
67+
}
4968

50-
// Auto-number against existing workspace credentials so repeat connects for
51-
// the same provider stay distinguishable — same behavior as the connect
52-
// modal, which computes this client-side. Best effort: on failure the name
53-
// simply skips deduplication.
54-
let takenNames: ReadonlySet<string> = new Set<string>()
55-
try {
56-
const rows = await db
57-
.select({ displayName: credential.displayName })
58-
.from(credential)
59-
.where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
60-
takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
61-
} catch {
62-
// Best effort — proceed without collision numbering
69+
displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)
6370
}
6471

65-
const displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)
66-
6772
const now = new Date()
6873
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
6974
await db
@@ -141,7 +146,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
141146
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
142147
}
143148

149+
let reconnectDisplayName: string | undefined
144150
if (credentialId) {
151+
// Trello and Shopify authorize through their own custom flows that bypass
152+
// this endpoint, so a reconnect draft written here would linger unconsumed
153+
// and could later be picked up by their token-store callbacks, silently
154+
// rebinding the credential. Mirror the copilot tool and reject reconnect.
155+
if (providerId === 'trello' || providerId === 'shopify') {
156+
logger.warn('Reconnect not supported for custom-flow provider', {
157+
userId,
158+
workspaceId,
159+
providerId,
160+
credentialId,
161+
})
162+
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`)
163+
}
164+
145165
// Reconnect: the OAuth callback will rebind this credential to the fresh
146166
// account, so require the same credential-admin access as the draft POST
147167
// route — workspace write alone must not be enough to swap someone's tokens.
@@ -172,12 +192,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
172192
})
173193
return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
174194
}
195+
reconnectDisplayName = actor.credential.displayName
175196
}
176197

177198
// Create the draft before initiating the link so it is guaranteed to exist
178199
// (and freshly clocked) when the OAuth callback's `account.create.after`
179200
// hook runs. If this throws, we never start the OAuth flow.
180-
await createConnectDraft({ userId, workspaceId, providerId, credentialId })
201+
await createConnectDraft({
202+
userId,
203+
workspaceId,
204+
providerId,
205+
credentialId,
206+
displayName: reconnectDisplayName,
207+
})
181208

182209
const linkResponse = await auth.api.oAuth2LinkAccount({
183210
body: { providerId, callbackURL },

apps/sim/lib/copilot/tools/handlers/access.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
22
import type { getWorkflowById } from '@/lib/workflows/utils'
3-
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
3+
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
44
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
55

66
type WorkflowRecord = NonNullable<Awaited<ReturnType<typeof getWorkflowById>>>
@@ -47,22 +47,23 @@ export async function ensureWorkspaceAccess(
4747
workspaceId: string,
4848
userId: string,
4949
level: 'read' | 'write' | 'admin' = 'read'
50-
): Promise<void> {
50+
): Promise<WorkspaceAccess> {
5151
const access = await checkWorkspaceAccess(workspaceId, userId)
5252
if (!access.exists || !access.hasAccess) {
5353
throw new Error(`Workspace ${workspaceId} not found`)
5454
}
5555

56-
if (level === 'read') return
56+
if (level === 'read') return access
5757

5858
if (level === 'admin') {
5959
if (!access.canAdmin) {
6060
throw new Error('Admin access required for this workspace')
6161
}
62-
return
62+
return access
6363
}
6464

6565
if (!access.canWrite) {
6666
throw new Error('Write or admin access required for this workspace')
6767
}
68+
return access
6869
}

apps/sim/lib/copilot/tools/handlers/oauth.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ const context = {
3939
chatId: 'chat-1',
4040
} as unknown as ExecutionContext
4141

42+
const WORKSPACE_ACCESS = {
43+
exists: true,
44+
hasAccess: true,
45+
canWrite: true,
46+
canAdmin: false,
47+
workspace: { id: WORKSPACE_ID },
48+
}
49+
4250
function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
4351
return {
4452
credential: {
@@ -60,7 +68,7 @@ describe('executeOAuthGetAuthLink', () => {
6068
beforeEach(() => {
6169
vi.clearAllMocks()
6270
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
63-
mockEnsureWorkspaceAccess.mockResolvedValue(undefined)
71+
mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS)
6472
})
6573

6674
describe('connect (no credentialId)', () => {
@@ -93,6 +101,19 @@ describe('executeOAuthGetAuthLink', () => {
93101
expect(output.message).toContain(CREDENTIAL_ID)
94102
})
95103

104+
it('reuses the already-resolved workspace access for the credential lookup', async () => {
105+
mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
106+
107+
await executeOAuthGetAuthLink(
108+
{ providerName: 'google-email', credentialId: CREDENTIAL_ID },
109+
context
110+
)
111+
112+
expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, {
113+
workspaceAccess: WORKSPACE_ACCESS,
114+
})
115+
})
116+
96117
it('fails with an agent-visible error for a nonexistent credential', async () => {
97118
mockGetCredentialActorContext.mockResolvedValue({
98119
credential: null,

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
44
import { getBaseUrl } from '@/lib/core/utils/urls'
55
import { getCredentialActorContext } from '@/lib/credentials/access'
66
import { getAllOAuthServices } from '@/lib/oauth/utils'
7+
import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
78

89
export async function executeOAuthGetAuthLink(
910
rawParams: Record<string, unknown>,
@@ -17,14 +18,18 @@ export async function executeOAuthGetAuthLink(
1718
if (!context.workspaceId || !context.userId) {
1819
throw new Error('workspaceId and userId are required to generate an OAuth link')
1920
}
20-
await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write')
21+
const workspaceAccess = await ensureWorkspaceAccess(
22+
context.workspaceId,
23+
context.userId,
24+
'write'
25+
)
2126
const result = await generateOAuthLink(
2227
context.workspaceId,
2328
context.workflowId,
2429
context.chatId,
2530
providerName,
2631
baseUrl,
27-
credentialId ? { credentialId, userId: context.userId } : undefined
32+
credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined
2833
)
2934
const action = credentialId ? 'reconnect' : 'connect'
3035
return {
@@ -92,7 +97,7 @@ async function generateOAuthLink(
9297
chatId: string | undefined,
9398
providerName: string,
9499
baseUrl: string,
95-
reconnect?: { credentialId: string; userId: string }
100+
reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess }
96101
): Promise<{ url: string; providerId: string; serviceName: string }> {
97102
if (!workspaceId) {
98103
throw new Error('workspaceId is required to generate an OAuth link')
@@ -127,7 +132,9 @@ async function generateOAuthLink(
127132
`integrations page and press Reconnect on the credential there.`
128133
)
129134
}
130-
const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId)
135+
const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, {
136+
workspaceAccess: reconnect.workspaceAccess,
137+
})
131138
if (!actor.credential || actor.credential.workspaceId !== workspaceId) {
132139
throw new Error(
133140
`Credential "${reconnect.credentialId}" was not found in this workspace. Read ` +

0 commit comments

Comments
 (0)