@@ -254,6 +254,164 @@ 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+
257415 it ( 'threads the NetSuite SuiteTalk instance URL into the token response' , async ( ) => {
258416 const instanceUrl = 'https://1234567.suitetalk.api.netsuite.com'
259417 authOAuthUtilsMockFns . mockResolveOAuthAccountId . mockResolvedValueOnce ( {
0 commit comments