@@ -23,13 +23,21 @@ const {
2323 mockGenerateInternalToken,
2424 mockResolveBillingAttribution,
2525 mockSerializeBillingAttributionHeader,
26+ mockVerifyOAuthAccessToken,
27+ MockInvalidOAuthAccessTokenError,
2628 fetchMock,
2729} = vi . hoisted ( ( ) => ( {
2830 mockExecuteWorkflowService : vi . fn ( ) ,
2931 mockAssertBillingAttributionSnapshot : vi . fn ( ) ,
3032 mockGenerateInternalToken : vi . fn ( ) ,
3133 mockResolveBillingAttribution : vi . fn ( ) ,
3234 mockSerializeBillingAttributionHeader : vi . fn ( ) ,
35+ mockVerifyOAuthAccessToken : vi . fn ( ) ,
36+ MockInvalidOAuthAccessTokenError : class extends Error {
37+ constructor ( readonly reason : string ) {
38+ super ( 'Invalid access token' )
39+ }
40+ } ,
3341 fetchMock : vi . fn ( ) ,
3442} ) )
3543
@@ -82,6 +90,41 @@ const PERSONAL_API_KEY_PRINCIPAL = {
8290 keyId : 'personal-key-1' ,
8391} as const
8492
93+ const OAUTH_WRITE_PRINCIPAL = {
94+ kind : 'oauth_access_token' ,
95+ userId : 'user-1' ,
96+ clientId : 'client-1' ,
97+ tokenId : 'token-1' ,
98+ scopes : [ 'api:write' , 'offline_access' ] ,
99+ expiresAt : new Date ( '2099-01-01T00:00:00.000Z' ) ,
100+ } as const
101+
102+ const PRIVATE_SERVER = {
103+ id : 'server-1' ,
104+ name : 'Private Server' ,
105+ workspaceId : 'ws-1' ,
106+ isPublic : false ,
107+ createdBy : 'owner-1' ,
108+ workspaceAllowsPersonalApiKeys : true ,
109+ }
110+
111+ const SERVER_RESOURCE = 'http://localhost:3000/api/mcp/serve/server-1'
112+ const SERVER_RESOURCE_METADATA =
113+ 'http://localhost:3000/.well-known/oauth-protected-resource/api/mcp/serve/server-1'
114+
115+ function toolCallRequest ( headers : Record < string , string > ) {
116+ return new NextRequest ( SERVER_RESOURCE , {
117+ method : 'POST' ,
118+ headers : { Accept : 'application/json' , ...headers } ,
119+ body : JSON . stringify ( {
120+ jsonrpc : '2.0' ,
121+ id : 1 ,
122+ method : 'tools/call' ,
123+ params : { name : 'tool_a' , arguments : { q : 'test' } } ,
124+ } ) ,
125+ } )
126+ }
127+
85128const WORKSPACE_API_KEY_PRINCIPAL = {
86129 kind : 'workspace_api_key' ,
87130 workspaceId : 'ws-1' ,
@@ -94,6 +137,13 @@ vi.mock('@/lib/auth/internal', () => ({
94137 generateInternalToken : mockGenerateInternalToken ,
95138} ) )
96139
140+ vi . mock ( '@/lib/auth/oauth-access-token' , ( ) => ( {
141+ InvalidOAuthAccessTokenError : MockInvalidOAuthAccessTokenError ,
142+ parseBearerToken : ( headers : Headers ) =>
143+ headers . get ( 'authorization' ) ?. replace ( / ^ B e a r e r + / i, '' ) || null ,
144+ verifyOAuthAccessToken : mockVerifyOAuthAccessToken ,
145+ } ) )
146+
97147vi . mock ( '@/lib/core/execution-limits' , ( ) => ( {
98148 getMaxExecutionTimeout : ( ) => 60_000 ,
99149} ) )
@@ -154,6 +204,188 @@ describe('MCP Serve Route', () => {
154204 expect ( response . status ) . toBe ( 401 )
155205 } )
156206
207+ describe ( 'OAuth access tokens' , ( ) => {
208+ it ( 'challenges an unauthenticated request with the server protected-resource metadata' , async ( ) => {
209+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
210+ hybridAuthMockFns . mockCheckHybridAuth . mockResolvedValueOnce ( {
211+ success : false ,
212+ error : 'Unauthorized' ,
213+ } )
214+
215+ const response = await POST ( toolCallRequest ( { } ) , {
216+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
217+ } )
218+
219+ expect ( response . status ) . toBe ( 401 )
220+ expect ( response . headers . get ( 'www-authenticate' ) ) . toBe (
221+ `Bearer resource_metadata="${ SERVER_RESOURCE_METADATA } ", scope="api:read api:write"`
222+ )
223+ } )
224+
225+ it ( 'executes as the token user when the token is bound to this server' , async ( ) => {
226+ dbChainMockFns . limit
227+ . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
228+ . mockResolvedValueOnce ( [ { toolName : 'tool_a' , workflowId : 'wf-1' } ] )
229+ . mockResolvedValueOnce ( [ { workspaceId : 'ws-1' , deploymentVersionId : 'deployment-1' } ] )
230+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( OAUTH_WRITE_PRINCIPAL )
231+ mockGetUserEntityPermissions . mockResolvedValueOnce ( 'write' )
232+ mockExecuteWorkflowService . mockResolvedValueOnce ( {
233+ ok : true ,
234+ executionId : 'exec-1' ,
235+ workflowId : 'wf-1' ,
236+ status : 'completed' ,
237+ aborted : null ,
238+ output : { ok : true } ,
239+ error : null ,
240+ hasResponseBlock : false ,
241+ resolvedSecretTraceProvenance : createResolvedSecretTraceProvenance ( 'user-1' ) ,
242+ } )
243+
244+ const response = await POST ( toolCallRequest ( { Authorization : 'Bearer sim_oat_valid' } ) , {
245+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
246+ } )
247+
248+ expect ( response . status ) . toBe ( 200 )
249+ expect ( mockVerifyOAuthAccessToken ) . toHaveBeenCalledWith ( 'sim_oat_valid' , {
250+ resource : SERVER_RESOURCE ,
251+ } )
252+ expect ( hybridAuthMockFns . mockCheckHybridAuth ) . not . toHaveBeenCalled ( )
253+ expect ( mockGetUserEntityPermissions ) . toHaveBeenCalledWith ( 'user-1' , 'workspace' , 'ws-1' )
254+ expect ( mockExecuteWorkflowService ) . toHaveBeenCalledWith (
255+ expect . objectContaining ( {
256+ userId : 'user-1' ,
257+ principal : OAUTH_WRITE_PRINCIPAL ,
258+ useAuthenticatedUserAsActor : true ,
259+ } )
260+ )
261+ } )
262+
263+ it ( 'answers a token bound elsewhere with invalid_token so the client re-authorizes' , async ( ) => {
264+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
265+ mockVerifyOAuthAccessToken . mockRejectedValueOnce (
266+ new MockInvalidOAuthAccessTokenError ( 'wrong_resource' )
267+ )
268+
269+ const response = await POST ( toolCallRequest ( { Authorization : 'Bearer sim_oat_other' } ) , {
270+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
271+ } )
272+
273+ expect ( response . status ) . toBe ( 401 )
274+ expect ( response . headers . get ( 'www-authenticate' ) ) . toBe (
275+ `Bearer error="invalid_token", resource_metadata="${ SERVER_RESOURCE_METADATA } ", scope="api:read api:write"`
276+ )
277+ expect ( mockExecuteWorkflowService ) . not . toHaveBeenCalled ( )
278+ } )
279+
280+ it ( 'asks a read-only token to step up to api:write before calling a tool' , async ( ) => {
281+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
282+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( {
283+ ...OAUTH_WRITE_PRINCIPAL ,
284+ scopes : [ 'api:read' ] ,
285+ } )
286+ mockGetUserEntityPermissions . mockResolvedValueOnce ( 'write' )
287+
288+ const response = await POST ( toolCallRequest ( { Authorization : 'Bearer sim_oat_read' } ) , {
289+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
290+ } )
291+
292+ expect ( response . status ) . toBe ( 403 )
293+ expect ( response . headers . get ( 'www-authenticate' ) ) . toBe (
294+ `Bearer error="insufficient_scope", resource_metadata="${ SERVER_RESOURCE_METADATA } ", scope="api:write"`
295+ )
296+ expect ( mockExecuteWorkflowService ) . not . toHaveBeenCalled ( )
297+ } )
298+
299+ it ( 'refuses a token without api:read before serving tool metadata' , async ( ) => {
300+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
301+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( {
302+ ...OAUTH_WRITE_PRINCIPAL ,
303+ scopes : [ 'offline_access' ] ,
304+ } )
305+
306+ const response = await POST (
307+ new NextRequest ( SERVER_RESOURCE , {
308+ method : 'POST' ,
309+ headers : { Authorization : 'Bearer sim_oat_offline' } ,
310+ body : JSON . stringify ( { jsonrpc : '2.0' , id : 1 , method : 'tools/list' , params : { } } ) ,
311+ } ) ,
312+ { params : Promise . resolve ( { serverId : 'server-1' } ) }
313+ )
314+
315+ expect ( response . status ) . toBe ( 403 )
316+ expect ( response . headers . get ( 'www-authenticate' ) ) . toBe (
317+ `Bearer error="insufficient_scope", resource_metadata="${ SERVER_RESOURCE_METADATA } ", scope="api:read"`
318+ )
319+ expect ( mockGetUserEntityPermissions ) . not . toHaveBeenCalled ( )
320+ } )
321+
322+ it ( 'lets a read-only token initialize the session' , async ( ) => {
323+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
324+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( {
325+ ...OAUTH_WRITE_PRINCIPAL ,
326+ scopes : [ 'api:read' ] ,
327+ } )
328+ mockGetUserEntityPermissions . mockResolvedValueOnce ( 'read' )
329+
330+ const response = await POST (
331+ new NextRequest ( SERVER_RESOURCE , {
332+ method : 'POST' ,
333+ headers : { Authorization : 'Bearer sim_oat_read' } ,
334+ body : JSON . stringify ( { jsonrpc : '2.0' , id : 1 , method : 'initialize' , params : { } } ) ,
335+ } ) ,
336+ { params : Promise . resolve ( { serverId : 'server-1' } ) }
337+ )
338+
339+ expect ( response . status ) . toBe ( 200 )
340+ } )
341+
342+ it ( 'refuses a token user who is no longer a workspace member' , async ( ) => {
343+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
344+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( OAUTH_WRITE_PRINCIPAL )
345+ mockGetUserEntityPermissions . mockResolvedValueOnce ( null )
346+
347+ const response = await POST ( toolCallRequest ( { Authorization : 'Bearer sim_oat_valid' } ) , {
348+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
349+ } )
350+
351+ expect ( response . status ) . toBe ( 403 )
352+ expect ( mockExecuteWorkflowService ) . not . toHaveBeenCalled ( )
353+ } )
354+
355+ it ( 'applies the workspace personal-key policy to OAuth tokens' , async ( ) => {
356+ dbChainMockFns . limit . mockResolvedValueOnce ( [
357+ { ...PRIVATE_SERVER , workspaceAllowsPersonalApiKeys : false } ,
358+ ] )
359+ mockVerifyOAuthAccessToken . mockResolvedValueOnce ( OAUTH_WRITE_PRINCIPAL )
360+ mockGetUserEntityPermissions . mockResolvedValueOnce ( 'write' )
361+
362+ const response = await POST ( toolCallRequest ( { Authorization : 'Bearer sim_oat_valid' } ) , {
363+ params : Promise . resolve ( { serverId : 'server-1' } ) ,
364+ } )
365+ const body = await response . json ( )
366+
367+ expect ( response . status ) . toBe ( 403 )
368+ expect ( body . error ) . toBe ( PERSONAL_KEY_DENIED )
369+ expect ( mockExecuteWorkflowService ) . not . toHaveBeenCalled ( )
370+ } )
371+
372+ it ( 'prefers an API key over a bearer token when both are sent' , async ( ) => {
373+ dbChainMockFns . limit . mockResolvedValueOnce ( [ PRIVATE_SERVER ] )
374+ hybridAuthMockFns . mockCheckHybridAuth . mockResolvedValueOnce ( {
375+ success : false ,
376+ error : 'Invalid API key' ,
377+ } )
378+
379+ const response = await POST (
380+ toolCallRequest ( { Authorization : 'Bearer sim_oat_valid' , 'X-API-Key' : 'bad-key' } ) ,
381+ { params : Promise . resolve ( { serverId : 'server-1' } ) }
382+ )
383+
384+ expect ( response . status ) . toBe ( 401 )
385+ expect ( mockVerifyOAuthAccessToken ) . not . toHaveBeenCalled ( )
386+ } )
387+ } )
388+
157389 it ( 'returns 401 on GET for private server when auth fails' , async ( ) => {
158390 dbChainMockFns . limit . mockResolvedValueOnce ( [
159391 {
0 commit comments