Skip to content

Commit cf2df15

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(plaid): isolate credential execution
1 parent 407bb35 commit cf2df15

35 files changed

Lines changed: 1098 additions & 1026 deletions

apps/docs/content/docs/en/integrations/plaid.mdx

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ Incrementally sync transactions for a linked Item. Omit the cursor on the first
4646

4747
| Parameter | Type | Required | Description |
4848
| --------- | ---- | -------- | ----------- |
49-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
50-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
51-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
52-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
5349
| `cursor` | string | No | Cursor from a previous sync \(nextCursor\); omit to start from the beginning |
5450
| `count` | number | No | Number of updates to fetch per page \(1-500, default 100\) |
5551
| `accountId` | string | No | Scope the sync \(and cursor\) to a single account ID |
@@ -77,10 +73,6 @@ List the accounts linked to an Item with their names, types, and balances. Balan
7773

7874
| Parameter | Type | Required | Description |
7975
| --------- | ---- | -------- | ----------- |
80-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
81-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
82-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
83-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
8476
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |
8577

8678
#### Output
@@ -98,10 +90,6 @@ Get real-time balances for the accounts linked to an Item. The live institution
9890

9991
| Parameter | Type | Required | Description |
10092
| --------- | ---- | -------- | ----------- |
101-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
102-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
103-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
104-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
10593
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |
10694
| `minLastUpdatedDatetime` | string | No | Oldest acceptable balance timestamp \(ISO 8601\). Only required for Capital One non-depository accounts |
10795

@@ -120,10 +108,6 @@ Get account-holder identity information (names, emails, phone numbers, and addre
120108

121109
| Parameter | Type | Required | Description |
122110
| --------- | ---- | -------- | ----------- |
123-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
124-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
125-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
126-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
127111
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |
128112

129113
#### Output
@@ -142,10 +126,6 @@ Get account and routing numbers for depository accounts linked to an Item (ACH f
142126

143127
| Parameter | Type | Required | Description |
144128
| --------- | ---- | -------- | ----------- |
145-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
146-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
147-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
148-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
149129
| `accountIds` | string | No | Comma-separated account IDs to filter to \(defaults to all accounts; Sim safety limit 500\) |
150130

151131
#### Output
@@ -163,10 +143,6 @@ Get metadata and health status for a linked Item, including its institution, ena
163143

164144
| Parameter | Type | Required | Description |
165145
| --------- | ---- | -------- | ----------- |
166-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
167-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
168-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
169-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
170146

171147
#### Output
172148

@@ -183,10 +159,6 @@ Search financial institutions supported by Plaid by name, returning at most 10
183159

184160
| Parameter | Type | Required | Description |
185161
| --------- | ---- | -------- | ----------- |
186-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
187-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
188-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
189-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
190162
| `query` | string | Yes | Institution name to search for, e.g. 'Chase' |
191163
| `countryCodes` | string | No | Comma-separated ISO country codes to search in \(defaults to 'US'\) |
192164
| `products` | string | No | Comma-separated products the institutions must support, e.g. 'transactions,auth' |
@@ -206,10 +178,6 @@ Get details for a financial institution by its Plaid institution ID
206178

207179
| Parameter | Type | Required | Description |
208180
| --------- | ---- | -------- | ----------- |
209-
| `oauthCredential` | string | Yes | Reusable encrypted Plaid Item credential |
210-
| `clientId` | string | No | Plaid client ID injected from the selected credential at execution time |
211-
| `secret` | string | No | Plaid API secret injected from the selected credential at execution time |
212-
| `environment` | string | No | Plaid environment injected from the selected credential at execution time |
213181
| `institutionId` | string | Yes | Plaid institution ID, e.g. 'ins_109508' |
214182
| `countryCodes` | string | No | Comma-separated ISO country codes \(defaults to 'US'\) |
215183

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 0 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -254,164 +254,6 @@ describe('OAuth Token API Routes', () => {
254254
})
255255

256256
describe('service account path', () => {
257-
it('does not return Plaid compound credentials to session-authenticated callers', async () => {
258-
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
259-
accountId: '',
260-
credentialId: 'plaid-credential-id',
261-
credentialType: 'service_account',
262-
providerId: 'plaid-service-account',
263-
workspaceId: 'workspace-id',
264-
usedCredentialTable: true,
265-
})
266-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
267-
success: true,
268-
authType: 'session',
269-
userId: 'test-user-id',
270-
})
271-
mockAuthorizeCredentialUse.mockResolvedValueOnce({
272-
ok: true,
273-
authType: 'session',
274-
requesterUserId: 'test-user-id',
275-
workspaceId: 'workspace-id',
276-
})
277-
278-
const response = await POST(
279-
createMockRequest('POST', {
280-
credentialId: 'plaid-credential-id',
281-
toolId: 'plaid_get_item',
282-
})
283-
)
284-
const data = await response.json()
285-
286-
expect(response.status).toBe(403)
287-
expect(data).toEqual({
288-
code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY',
289-
error: 'Plaid Item credentials can only be used by server-side workflow execution',
290-
})
291-
expect(mockResolveServiceAccountToken).not.toHaveBeenCalled()
292-
})
293-
294-
it('does not reveal the Plaid-only policy before credential authorization', async () => {
295-
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
296-
accountId: '',
297-
credentialId: 'plaid-credential-id',
298-
credentialType: 'service_account',
299-
providerId: 'plaid-service-account',
300-
workspaceId: 'workspace-id',
301-
usedCredentialTable: true,
302-
})
303-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
304-
success: true,
305-
authType: 'session',
306-
userId: 'other-user-id',
307-
})
308-
mockAuthorizeCredentialUse.mockResolvedValueOnce({
309-
ok: false,
310-
error: 'You do not have access to this credential.',
311-
})
312-
313-
const response = await POST(
314-
createMockRequest('POST', {
315-
credentialId: 'plaid-credential-id',
316-
toolId: 'plaid_get_item',
317-
})
318-
)
319-
320-
expect(response.status).toBe(403)
321-
await expect(response.json()).resolves.toEqual({
322-
error: 'You do not have access to this credential.',
323-
})
324-
expect(mockResolveServiceAccountToken).not.toHaveBeenCalled()
325-
})
326-
327-
it('returns Plaid compound credentials to a verified internal executor JWT', async () => {
328-
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
329-
accountId: '',
330-
credentialId: 'plaid-credential-id',
331-
credentialType: 'service_account',
332-
providerId: 'plaid-service-account',
333-
workspaceId: 'workspace-id',
334-
usedCredentialTable: true,
335-
})
336-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
337-
success: true,
338-
authType: 'internal_jwt',
339-
userId: 'test-user-id',
340-
})
341-
mockAuthorizeCredentialUse.mockResolvedValueOnce({
342-
ok: true,
343-
authType: 'internal_jwt',
344-
requesterUserId: 'test-user-id',
345-
workspaceId: 'workspace-id',
346-
})
347-
mockResolveServiceAccountToken.mockResolvedValueOnce({
348-
accessToken: 'access-production-item',
349-
plaid: {
350-
clientId: 'client-id',
351-
secret: 'environment-secret',
352-
environment: 'production',
353-
},
354-
})
355-
mockGetToolMetadata.mockReturnValueOnce({ id: 'plaid_get_item', params: {} })
356-
357-
const response = await POST(
358-
createMockRequest('POST', {
359-
credentialId: 'plaid-credential-id',
360-
toolId: 'plaid_get_item',
361-
})
362-
)
363-
const data = await response.json()
364-
365-
expect(response.status).toBe(200)
366-
expect(data).toEqual({
367-
accessToken: 'access-production-item',
368-
plaid: {
369-
clientId: 'client-id',
370-
secret: 'environment-secret',
371-
environment: 'production',
372-
},
373-
})
374-
})
375-
376-
it.each(['gmail_read', 'plaid_fake'])(
377-
'rejects a Plaid credential selected for untrusted tool %s before resolving secrets',
378-
async (toolId) => {
379-
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
380-
accountId: '',
381-
credentialId: 'plaid-credential-id',
382-
credentialType: 'service_account',
383-
providerId: 'plaid-service-account',
384-
workspaceId: 'workspace-id',
385-
usedCredentialTable: true,
386-
})
387-
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
388-
success: true,
389-
authType: 'internal_jwt',
390-
userId: 'test-user-id',
391-
})
392-
mockAuthorizeCredentialUse.mockResolvedValueOnce({
393-
ok: true,
394-
authType: 'internal_jwt',
395-
requesterUserId: 'test-user-id',
396-
workspaceId: 'workspace-id',
397-
})
398-
399-
const response = await POST(
400-
createMockRequest('POST', {
401-
credentialId: 'plaid-credential-id',
402-
toolId,
403-
})
404-
)
405-
406-
expect(response.status).toBe(403)
407-
await expect(response.json()).resolves.toEqual({
408-
code: 'PLAID_CREDENTIAL_TOOL_MISMATCH',
409-
error: 'Plaid Item credentials can only be used with Plaid tools',
410-
})
411-
expect(mockResolveServiceAccountToken).not.toHaveBeenCalled()
412-
}
413-
)
414-
415257
it('threads the NetSuite SuiteTalk instance URL into the token response', async () => {
416258
const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com'
417259
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ import {
99
oauthTokenPostContract,
1010
} from '@/lib/api/contracts/oauth-connections'
1111
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
12-
import {
13-
authorizeCredentialUse,
14-
authorizeCredentialUseForAuth,
15-
type CredentialAccessResult,
16-
} from '@/lib/auth/credential-access'
12+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
1713
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1814
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
1915
import { generateRequestId } from '@/lib/core/utils/request'
@@ -26,7 +22,6 @@ import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/applicatio
2622
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
2723
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
2824
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
29-
import { PLAID_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
3025
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
3126
import { captureServerEvent } from '@/lib/posthog/server'
3227
import { getToolMetadata } from '@/tools/metadata'
@@ -256,52 +251,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
256251
}
257252

258253
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
259-
let preauthorizedCredentialAccess: CredentialAccessResult | undefined
260-
261-
/**
262-
* Plaid requires both its application secret and long-lived Item access
263-
* token to stay server-side. Unlike the ordinary OAuth/service-account
264-
* payloads used by browser-backed selectors, this compound credential may
265-
* therefore cross this route only for a verified internal executor JWT.
266-
* Authorize first so the rejection cannot be used to probe whether an
267-
* arbitrary credential id belongs to Plaid.
268-
*/
269-
if (
270-
resolved?.credentialType === 'service_account' &&
271-
resolved.providerId === PLAID_SERVICE_ACCOUNT_PROVIDER_ID
272-
) {
273-
const authz = credentialId
274-
? await authorizeCredentialUseForAuth(auth, {
275-
credentialId,
276-
workflowId: workflowId ?? undefined,
277-
callerUserId,
278-
})
279-
: { ok: false, error: 'Credential ID is required' }
280-
if (!authz.ok) {
281-
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
282-
}
283-
preauthorizedCredentialAccess = authz
284-
if (auth.authType !== AuthType.INTERNAL_JWT) {
285-
return NextResponse.json(
286-
{
287-
code: 'PLAID_CREDENTIAL_EXECUTOR_ONLY',
288-
error: 'Plaid Item credentials can only be used by server-side workflow execution',
289-
},
290-
{ status: 403 }
291-
)
292-
}
293-
const plaidToolMetadata = toolId ? getToolMetadata(toolId) : undefined
294-
if (!plaidToolMetadata?.id.startsWith('plaid_')) {
295-
return NextResponse.json(
296-
{
297-
code: 'PLAID_CREDENTIAL_TOOL_MISMATCH',
298-
error: 'Plaid Item credentials can only be used with Plaid tools',
299-
},
300-
{ status: 403 }
301-
)
302-
}
303-
}
304-
305254
const result = await resolveCredentialToken(auth, {
306255
requestId,
307256
credentialId,
@@ -311,7 +260,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
311260
callerUserId,
312261
auditRequest: request,
313262
resolvedCredential: resolved,
314-
preauthorizedCredentialAccess,
315263
})
316264

317265
if (!result.ok) {

0 commit comments

Comments
 (0)