11import {
2+ GrantType ,
23 OAuthError as ProviderOAuthError ,
34 type OAuthHelpers
45} from '@cloudflare/workers-oauth-provider'
@@ -47,20 +48,10 @@ function deferred<T>(): {
4748 return { promise, resolve, reject }
4849}
4950
50- interface MockGrant {
51- id : string
52- clientId : string
53- userId : string
54- }
55-
56- /**
57- * Minimal OAuthHelpers mock backing the revoke-on-invalid_grant path. Only
58- * listUserGrants/revokeGrant are exercised; cast to the full type since the
59- * guard never touches the other members.
60- */
61- function mockOAuthHelpers ( grants : MockGrant [ ] ) {
51+ /** Minimal OAuthHelpers mock backing the revoke-on-invalid_grant path. */
52+ function mockOAuthHelpers ( ) {
6253 return {
63- listUserGrants : vi . fn ( async ( ) => ( { items : grants as never [ ] , cursor : undefined } ) ) ,
54+ listUserGrants : vi . fn ( ) ,
6455 revokeGrant : vi . fn ( async ( ) => undefined )
6556 } as unknown as OAuthHelpers & {
6657 listUserGrants : ReturnType < typeof vi . fn >
@@ -510,29 +501,26 @@ describe('guardRefreshTokenExchange', () => {
510501 expect ( refreshFn ) . toHaveBeenCalledTimes ( 1 )
511502 } )
512503
513- it ( 'revokes the grant for this user+client on upstream invalid_grant' , async ( ) => {
504+ it ( 'revokes the exact grant on upstream invalid_grant' , async ( ) => {
514505 const kv = env . OAUTH_KV
515506 const refreshFn = vi
516507 . fn ( )
517508 . mockRejectedValueOnce ( new OAuthError ( 'invalid_grant' , 'refresh token reused' , 400 ) )
518- const helpers = mockOAuthHelpers ( [
519- { id : 'grant-keep' , clientId : 'other-client' , userId : 'user-1' } ,
520- { id : 'grant-kill' , clientId : 'mcp-client' , userId : 'user-1' }
521- ] )
509+ const helpers = mockOAuthHelpers ( )
522510 const getHelpers = vi . fn ( ( ) => helpers )
523511
524512 await expectOAuthError (
525513 guardRefreshTokenExchange ( kv , 'dead-token' , refreshFn , {
526514 userId : 'user-1' ,
527515 clientId : 'mcp-client' ,
516+ grantId : 'grant-kill' ,
528517 getHelpers
529518 } ) ,
530519 'invalid_grant' ,
531520 400
532521 )
533522
534- // Only the matching user+client grant is killed; other clients untouched.
535- expect ( helpers . listUserGrants ) . toHaveBeenCalledWith ( 'user-1' , undefined )
523+ expect ( helpers . listUserGrants ) . not . toHaveBeenCalled ( )
536524 expect ( helpers . revokeGrant ) . toHaveBeenCalledTimes ( 1 )
537525 expect ( helpers . revokeGrant ) . toHaveBeenCalledWith ( 'grant-kill' , 'user-1' )
538526 } )
@@ -544,7 +532,7 @@ describe('guardRefreshTokenExchange', () => {
544532 . mockRejectedValueOnce (
545533 new OAuthError ( 'temporarily_unavailable' , 'rate limited' , 429 , { 'Retry-After' : '30' } )
546534 )
547- const helpers = mockOAuthHelpers ( [ { id : 'grant-1' , clientId : 'mcp-client' , userId : 'user-1' } ] )
535+ const helpers = mockOAuthHelpers ( )
548536 const getHelpers = vi . fn ( ( ) => helpers )
549537
550538 await expectOAuthError (
@@ -566,7 +554,7 @@ describe('guardRefreshTokenExchange', () => {
566554 const refreshFn = vi
567555 . fn ( )
568556 . mockRejectedValueOnce ( new OAuthError ( 'invalid_client' , 'bad client creds' , 401 ) )
569- const helpers = mockOAuthHelpers ( [ { id : 'grant-1' , clientId : 'mcp-client' , userId : 'user-1' } ] )
557+ const helpers = mockOAuthHelpers ( )
570558 const getHelpers = vi . fn ( ( ) => helpers )
571559
572560 await expectOAuthError (
@@ -588,99 +576,45 @@ describe('guardRefreshTokenExchange', () => {
588576 const refreshFn = vi
589577 . fn ( )
590578 . mockRejectedValueOnce ( new OAuthError ( 'invalid_grant' , 'refresh token reused' , 400 ) )
591- const helpers = mockOAuthHelpers ( [
592- { id : 'grant-kill' , clientId : 'mcp-client' , userId : 'user-1' }
593- ] )
579+ const helpers = mockOAuthHelpers ( )
594580 vi . mocked ( helpers . revokeGrant ) . mockRejectedValueOnce ( new Error ( 'KV unavailable' ) )
595581
596582 await expectOAuthError (
597583 guardRefreshTokenExchange ( kv , 'dead-token-revoke-fails' , refreshFn , {
598584 userId : 'user-1' ,
599585 clientId : 'mcp-client' ,
586+ grantId : 'grant-kill' ,
600587 getHelpers : ( ) => helpers
601588 } ) ,
602589 'invalid_grant' ,
603590 400
604591 )
605592 } )
606-
607- it ( 'paginates listUserGrants when revoking' , async ( ) => {
608- const kv = env . OAUTH_KV
609- const refreshFn = vi
610- . fn ( )
611- . mockRejectedValueOnce ( new OAuthError ( 'invalid_grant' , 'refresh token reused' , 400 ) )
612- const helpers = mockOAuthHelpers ( [ ] )
613- vi . mocked ( helpers . listUserGrants )
614- . mockResolvedValueOnce ( {
615- items : [ { id : 'grant-a' , clientId : 'mcp-client' , userId : 'user-1' } as never ] ,
616- cursor : 'next'
617- } as never )
618- . mockResolvedValueOnce ( {
619- items : [ { id : 'grant-b' , clientId : 'mcp-client' , userId : 'user-1' } as never ] ,
620- cursor : undefined
621- } as never )
622-
623- await expectOAuthError (
624- guardRefreshTokenExchange ( kv , 'paginated-token' , refreshFn , {
625- userId : 'user-1' ,
626- clientId : 'mcp-client' ,
627- getHelpers : ( ) => helpers
628- } ) ,
629- 'invalid_grant' ,
630- 400
631- )
632-
633- expect ( helpers . listUserGrants ) . toHaveBeenCalledTimes ( 2 )
634- expect ( helpers . revokeGrant ) . toHaveBeenCalledWith ( 'grant-a' , 'user-1' )
635- expect ( helpers . revokeGrant ) . toHaveBeenCalledWith ( 'grant-b' , 'user-1' )
636- } )
637-
638- it ( 'never revokes another user grant for the same client (defense-in-depth)' , async ( ) => {
639- const kv = env . OAUTH_KV
640- const refreshFn = vi
641- . fn ( )
642- . mockRejectedValueOnce ( new OAuthError ( 'invalid_grant' , 'refresh token reused' , 400 ) )
643- // listUserGrants is supposed to scope by userId, but simulate a provider
644- // returning a same-client grant belonging to a DIFFERENT user. We must not
645- // revoke it — only the calling user's matching grant.
646- const helpers = mockOAuthHelpers ( [
647- { id : 'grant-mine' , clientId : 'mcp-client' , userId : 'user-1' } ,
648- { id : 'grant-other-user' , clientId : 'mcp-client' , userId : 'user-2' }
649- ] )
650-
651- await expectOAuthError (
652- guardRefreshTokenExchange ( kv , 'cross-user-token' , refreshFn , {
653- userId : 'user-1' ,
654- clientId : 'mcp-client' ,
655- getHelpers : ( ) => helpers
656- } ) ,
657- 'invalid_grant' ,
658- 400
659- )
660-
661- expect ( helpers . revokeGrant ) . toHaveBeenCalledTimes ( 1 )
662- expect ( helpers . revokeGrant ) . toHaveBeenCalledWith ( 'grant-mine' , 'user-1' )
663- expect ( helpers . revokeGrant ) . not . toHaveBeenCalledWith ( 'grant-other-user' , 'user-1' )
664- } )
665593} )
666594
667595describe ( 'handleTokenExchangeCallback' , ( ) => {
668596 const OAUTH_TOKEN_URL = 'https://dash.cloudflare.com/oauth2/token'
669597
670- const refreshCallback = ( refreshToken = 'old-refresh-token' ) =>
598+ const refreshCallback = ( refreshToken = 'old-refresh-token' , getHelpers ?: ( ) => OAuthHelpers ) =>
671599 handleTokenExchangeCallback (
672600 {
673- grantType : 'refresh_token' ,
601+ grantType : GrantType . REFRESH_TOKEN ,
602+ clientId : 'mcp-client' ,
603+ userId : 'user-1' ,
604+ grantId : 'grant-exact' ,
605+ scope : [ ] ,
606+ requestedScope : [ ] ,
674607 props : {
675608 type : 'user_token' ,
676609 accessToken : 'old-access-token' ,
677610 user : { id : 'user-1' , email : 'user@example.com' } ,
678611 accounts : [ { id : 'account-1' , name : 'Account 1' } ] ,
679612 refreshToken
680613 }
681- } as never ,
614+ } ,
682615 'client-id' ,
683- 'client-secret'
616+ 'client-secret' ,
617+ getHelpers
684618 )
685619
686620 it ( 'refreshes upstream tokens and returns updated auth props' , async ( ) => {
@@ -715,17 +649,20 @@ describe('handleTokenExchangeCallback', () => {
715649 expect ( form ?. get ( 'refresh_token' ) ) . toBe ( 'old-refresh-token' )
716650 } )
717651
718- it ( 'throws the local OAuthError that extends the provider OAuthError ' , async ( ) => {
652+ it ( 'revokes the callback grant when upstream returns invalid_grant ' , async ( ) => {
719653 // Upstream 400 -> real refreshAuthToken maps it to invalid_grant.
720654 server . use (
721655 http . post ( OAUTH_TOKEN_URL , ( ) => HttpResponse . text ( 'invalid grant' , { status : 400 } ) )
722656 )
657+ const helpers = mockOAuthHelpers ( )
723658
724- await expect ( refreshCallback ( ) ) . rejects . toMatchObject ( {
659+ await expect ( refreshCallback ( 'old-refresh-token' , ( ) => helpers ) ) . rejects . toMatchObject ( {
725660 name : 'OAuthError' ,
726661 code : 'invalid_grant' ,
727662 statusCode : 400
728663 } )
664+ expect ( helpers . listUserGrants ) . not . toHaveBeenCalled ( )
665+ expect ( helpers . revokeGrant ) . toHaveBeenCalledWith ( 'grant-exact' , 'user-1' )
729666 } )
730667
731668 it ( 'preserves Retry-After on a local in-flight collision (429)' , async ( ) => {
0 commit comments