11import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest'
2+ import open from 'open'
23import { NodeOAuthClientProvider } from './node-oauth-client-provider'
34import * as mcpAuthConfig from './mcp-auth-config'
45import type { OAuthProviderOptions } from './types'
@@ -22,6 +23,7 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
2223 let mockReadJsonFile : any
2324 let mockWriteJsonFile : any
2425 let mockDeleteConfigFile : any
26+ let mockFetch : ReturnType < typeof vi . fn >
2527
2628 const defaultOptions : OAuthProviderOptions = {
2729 serverUrl : 'https://example.com' ,
@@ -41,6 +43,7 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
4143 } )
4244
4345 afterEach ( ( ) => {
46+ vi . unstubAllGlobals ( )
4447 vi . clearAllMocks ( )
4548 } )
4649
@@ -96,14 +99,133 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
9699 expect ( authUrl . searchParams . get ( 'scope' ) ) . toBe ( 'github read:user' )
97100 } )
98101
99- it ( 'should include default scope in authorization URL when none specified' , async ( ) => {
102+ it ( 'should replace an existing authorization URL scope with the default scope when none is specified' , async ( ) => {
100103 provider = new NodeOAuthClientProvider ( defaultOptions )
101104
102- const authUrl = new URL ( 'https://auth.example.com/authorize' )
105+ const authUrl = new URL ( 'https://auth.example.com/authorize?scope=existing ' )
103106 await provider . redirectToAuthorization ( authUrl )
104107
105108 expect ( authUrl . searchParams . get ( 'scope' ) ) . toBe ( 'openid email profile' )
106109 } )
110+
111+ it ( 'invalidates a cached dynamic client when authorization reports it is no longer registered' , async ( ) => {
112+ provider = new NodeOAuthClientProvider ( defaultOptions )
113+ mockReadJsonFile . mockResolvedValueOnce ( {
114+ client_id : 'stale-client' ,
115+ redirect_uris : [ 'http://localhost:8080/oauth/callback' ] ,
116+ } )
117+ await provider . clientInformation ( )
118+ mockFetch = vi . fn ( ) . mockResolvedValue ( {
119+ status : 400 ,
120+ json : async ( ) => ( {
121+ registration_endpoint : 'https://auth.example.com/register' ,
122+ error : 'invalid_request' ,
123+ error_description : "Client ID 'stale-client' is not registered with this server" ,
124+ } ) ,
125+ } )
126+ vi . stubGlobal ( 'fetch' , mockFetch )
127+
128+ await expect (
129+ provider . redirectToAuthorization ( new URL ( 'https://auth.example.com/authorize?client_id=stale-client' ) ) ,
130+ ) . rejects . toMatchObject ( {
131+ name : 'StaleClientRegistrationError' ,
132+ message : 'Cached OAuth client registration is no longer valid' ,
133+ } )
134+
135+ expect ( mockFetch ) . toHaveBeenCalledWith (
136+ 'https://auth.example.com/authorize?client_id=stale-client&scope=openid+email+profile' ,
137+ expect . objectContaining ( {
138+ redirect : 'manual' ,
139+ headers : { Accept : 'application/json' } ,
140+ signal : expect . any ( AbortSignal ) ,
141+ } ) ,
142+ )
143+ expect ( mockDeleteConfigFile ) . toHaveBeenCalledTimes ( 3 )
144+ expect ( mockDeleteConfigFile . mock . calls . map ( ( [ , fileName ] : [ string , string ] ) => fileName ) ) . toEqual (
145+ expect . arrayContaining ( [ 'client_info.json' , 'tokens.json' , 'code_verifier.txt' ] ) ,
146+ )
147+ expect ( open ) . not . toHaveBeenCalled ( )
148+ } )
149+
150+ it ( 'retains cached credentials and opens the browser when authorization redirects' , async ( ) => {
151+ provider = new NodeOAuthClientProvider ( defaultOptions )
152+ mockReadJsonFile . mockResolvedValueOnce ( {
153+ client_id : 'active-client' ,
154+ redirect_uris : [ 'http://localhost:8080/oauth/callback' ] ,
155+ } )
156+ await provider . clientInformation ( )
157+ mockFetch = vi . fn ( ) . mockResolvedValue ( { status : 302 } )
158+ vi . stubGlobal ( 'fetch' , mockFetch )
159+
160+ await provider . redirectToAuthorization ( new URL ( 'https://auth.example.com/authorize?client_id=active-client' ) )
161+
162+ expect ( mockFetch ) . toHaveBeenCalledWith (
163+ 'https://auth.example.com/authorize?client_id=active-client&scope=openid+email+profile' ,
164+ expect . objectContaining ( {
165+ redirect : 'manual' ,
166+ headers : { Accept : 'application/json' } ,
167+ signal : expect . any ( AbortSignal ) ,
168+ } ) ,
169+ )
170+ expect ( mockDeleteConfigFile ) . not . toHaveBeenCalled ( )
171+ expect ( open ) . toHaveBeenCalledOnce ( )
172+ } )
173+
174+ it ( 'does not invalidate a cached client when its redirect URI is described as not registered' , async ( ) => {
175+ provider = new NodeOAuthClientProvider ( defaultOptions )
176+ mockReadJsonFile . mockResolvedValueOnce ( {
177+ client_id : 'active-client' ,
178+ redirect_uris : [ 'http://localhost:8080/oauth/callback' ] ,
179+ } )
180+ await provider . clientInformation ( )
181+ mockFetch = vi . fn ( ) . mockResolvedValue ( {
182+ status : 400 ,
183+ json : async ( ) => ( {
184+ registration_endpoint : 'https://auth.example.com/register' ,
185+ error : 'invalid_request' ,
186+ error_description : 'The client redirect URI is not registered' ,
187+ } ) ,
188+ } )
189+ vi . stubGlobal ( 'fetch' , mockFetch )
190+
191+ await provider . redirectToAuthorization ( new URL ( 'https://auth.example.com/authorize?client_id=active-client' ) )
192+
193+ expect ( mockDeleteConfigFile ) . not . toHaveBeenCalled ( )
194+ expect ( open ) . toHaveBeenCalledOnce ( )
195+ } )
196+
197+ it ( 'does not preflight a freshly dynamically registered client' , async ( ) => {
198+ provider = new NodeOAuthClientProvider ( defaultOptions )
199+ await provider . saveClientInformation ( {
200+ client_id : 'fresh-client' ,
201+ redirect_uris : [ 'http://localhost:8080/oauth/callback' ] ,
202+ } )
203+ mockFetch = vi . fn ( )
204+ vi . stubGlobal ( 'fetch' , mockFetch )
205+
206+ await provider . redirectToAuthorization ( new URL ( 'https://auth.example.com/authorize?client_id=fresh-client' ) )
207+
208+ expect ( mockFetch ) . not . toHaveBeenCalled ( )
209+ expect ( open ) . toHaveBeenCalledOnce ( )
210+ } )
211+
212+ it ( 'does not preflight a static client registration' , async ( ) => {
213+ provider = new NodeOAuthClientProvider ( {
214+ ...defaultOptions ,
215+ staticOAuthClientInfo : {
216+ client_id : 'static-client' ,
217+ redirect_uris : [ 'http://localhost:8080/oauth/callback' ] ,
218+ } ,
219+ } )
220+ mockFetch = vi . fn ( )
221+ vi . stubGlobal ( 'fetch' , mockFetch )
222+
223+ await provider . clientInformation ( )
224+ await provider . redirectToAuthorization ( new URL ( 'https://auth.example.com/authorize?client_id=static-client' ) )
225+
226+ expect ( mockFetch ) . not . toHaveBeenCalled ( )
227+ expect ( open ) . toHaveBeenCalledOnce ( )
228+ } )
107229 } )
108230
109231 describe ( 'backward compatibility' , ( ) => {
0 commit comments