@@ -12,8 +12,17 @@ import {
1212import { NextRequest } from 'next/server'
1313import { beforeEach , describe , expect , it , vi } from 'vitest'
1414
15- const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi . hoisted ( ( ) => ( {
15+ const {
16+ mockAuthenticateManagedOAuthDelegation,
17+ mockAuthorizeCredentialUse,
18+ mockGetToolMetadata,
19+ mockResolveManagedOAuthCredentialToken,
20+ mockResolveServiceAccountToken,
21+ } = vi . hoisted ( ( ) => ( {
22+ mockAuthenticateManagedOAuthDelegation : vi . fn ( ) ,
1623 mockAuthorizeCredentialUse : vi . fn ( ) ,
24+ mockGetToolMetadata : vi . fn ( ) ,
25+ mockResolveManagedOAuthCredentialToken : vi . fn ( ) ,
1726 mockResolveServiceAccountToken : vi . fn ( ) ,
1827} ) )
1928
@@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({
2736 authorizeCredentialUseForAuth : mockAuthorizeCredentialUse ,
2837} ) )
2938
39+ vi . mock ( '@/lib/credentials/application/managed-oauth-delegation' , ( ) => ( {
40+ authenticateManagedOAuthDelegation : mockAuthenticateManagedOAuthDelegation ,
41+ InvalidManagedOAuthDelegationError : class InvalidManagedOAuthDelegationError extends Error { } ,
42+ } ) )
43+
44+ vi . mock ( '@/lib/credentials/application/resolve-managed-oauth-token' , ( ) => ( {
45+ resolveManagedOAuthCredentialToken : { execute : mockResolveManagedOAuthCredentialToken } ,
46+ } ) )
47+
48+ vi . mock ( '@/tools/metadata' , ( ) => ( { getToolMetadata : mockGetToolMetadata } ) )
49+
3050import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
3151import { GET , POST } from '@/app/api/auth/oauth/token/route'
3252
@@ -332,6 +352,140 @@ describe('OAuth Token API Routes', () => {
332352 )
333353 } )
334354
355+ describe ( 'managed OAuth path' , ( ) => {
356+ const managedCredential = {
357+ accountId : '' ,
358+ credentialId : 'managed-credential-id' ,
359+ credentialType : 'managed_oauth' ,
360+ providerId : 'google-email' ,
361+ workspaceId : 'workspace-id' ,
362+ usedCredentialTable : true ,
363+ }
364+
365+ beforeEach ( ( ) => {
366+ authOAuthUtilsMockFns . mockResolveOAuthAccountId . mockResolvedValueOnce ( managedCredential )
367+ mockGetToolMetadata . mockReturnValue ( {
368+ oauth : {
369+ required : true ,
370+ provider : 'google-email' ,
371+ requiredScopes : [ 'https://www.googleapis.com/auth/gmail.readonly' ] ,
372+ } ,
373+ } )
374+ } )
375+
376+ it ( 'fails closed when workflow delegation is missing' , async ( ) => {
377+ const response = await POST (
378+ createMockRequest ( 'POST' , {
379+ credentialId : 'managed-credential-id' ,
380+ toolId : 'gmail_read' ,
381+ } )
382+ )
383+
384+ expect ( response . status ) . toBe ( 403 )
385+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
386+ code : 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED' ,
387+ } )
388+ expect ( mockResolveManagedOAuthCredentialToken ) . not . toHaveBeenCalled ( )
389+ } )
390+
391+ it ( 'resolves a manually supplied managed credential ID with scoped delegation' , async ( ) => {
392+ const principal = {
393+ kind : 'delegated' as const ,
394+ serviceId : 'executor' as const ,
395+ subjectUserId : 'user-id' ,
396+ workspaceId : 'workspace-id' ,
397+ delegationId : 'delegation-id' ,
398+ audience : 'sim:managed-oauth-credentials' ,
399+ issuedAt : new Date ( Date . now ( ) - 1_000 ) ,
400+ expiresAt : new Date ( Date . now ( ) + 60_000 ) ,
401+ resourceScope : { credentialId : 'managed-credential-id' } ,
402+ delegationContext : {
403+ kind : 'workflow_execution' as const ,
404+ workflowId : 'workflow-id' ,
405+ } ,
406+ }
407+ mockAuthenticateManagedOAuthDelegation . mockResolvedValueOnce ( principal )
408+ mockResolveManagedOAuthCredentialToken . mockResolvedValueOnce ( {
409+ accessToken : 'managed-access-token' ,
410+ refreshed : false ,
411+ } )
412+
413+ const response = await POST (
414+ createMockRequest (
415+ 'POST' ,
416+ { credentialId : 'managed-credential-id' , toolId : 'gmail_read' } ,
417+ { 'x-sim-managed-oauth-delegation' : 'Bearer delegated-token' }
418+ )
419+ )
420+
421+ expect ( response . status ) . toBe ( 200 )
422+ await expect ( response . json ( ) ) . resolves . toEqual ( { accessToken : 'managed-access-token' } )
423+ expect ( mockResolveManagedOAuthCredentialToken ) . toHaveBeenCalledWith ( {
424+ principal,
425+ input : {
426+ credentialId : 'managed-credential-id' ,
427+ expectedProviderId : 'google-email' ,
428+ requiredScopes : [ 'https://www.googleapis.com/auth/gmail.readonly' ] ,
429+ toolId : 'gmail_read' ,
430+ } ,
431+ request : expect . any ( NextRequest ) ,
432+ } )
433+ } )
434+
435+ it ( 'uses the trusted provider scope policy when a Slack tool omits narrower scopes' , async ( ) => {
436+ mockGetToolMetadata . mockReturnValueOnce ( {
437+ oauth : {
438+ required : true ,
439+ provider : 'slack' ,
440+ } ,
441+ } )
442+ const principal = {
443+ kind : 'delegated' as const ,
444+ serviceId : 'executor' as const ,
445+ subjectUserId : 'user-id' ,
446+ workspaceId : 'workspace-id' ,
447+ delegationId : 'delegation-id' ,
448+ audience : 'sim:managed-oauth-credentials' ,
449+ issuedAt : new Date ( Date . now ( ) - 1_000 ) ,
450+ expiresAt : new Date ( Date . now ( ) + 60_000 ) ,
451+ resourceScope : { credentialId : 'managed-credential-id' } ,
452+ delegationContext : {
453+ kind : 'workflow_execution' as const ,
454+ workflowId : 'workflow-id' ,
455+ } ,
456+ }
457+ mockAuthenticateManagedOAuthDelegation . mockResolvedValueOnce ( principal )
458+ mockResolveManagedOAuthCredentialToken . mockResolvedValueOnce ( {
459+ accessToken : 'managed-slack-token' ,
460+ refreshed : false ,
461+ } )
462+
463+ const response = await POST (
464+ createMockRequest (
465+ 'POST' ,
466+ { credentialId : 'managed-credential-id' , toolId : 'slack_message' } ,
467+ { 'x-sim-managed-oauth-delegation' : 'Bearer delegated-token' }
468+ )
469+ )
470+
471+ expect ( response . status ) . toBe ( 200 )
472+ expect ( mockResolveManagedOAuthCredentialToken ) . toHaveBeenCalledWith ( {
473+ principal,
474+ input : {
475+ credentialId : 'managed-credential-id' ,
476+ expectedProviderId : 'slack' ,
477+ requiredScopes : expect . arrayContaining ( [
478+ 'channels:read' ,
479+ 'channels:history' ,
480+ 'chat:write' ,
481+ ] ) ,
482+ toolId : 'slack_message' ,
483+ } ,
484+ request : expect . any ( NextRequest ) ,
485+ } )
486+ } )
487+ } )
488+
335489 describe ( 'credentialAccountUserId + providerId path' , ( ) => {
336490 it ( 'should reject unauthenticated requests' , async ( ) => {
337491 hybridAuthMockFns . mockCheckSessionOrInternalAuth . mockResolvedValueOnce ( {
0 commit comments