@@ -8,13 +8,14 @@ import {
88 refreshAuthorization ,
99 registerClient ,
1010 discoverOAuthProtectedResourceMetadata ,
11+ extractFieldFromWwwAuth ,
1112 extractWWWAuthenticateParams ,
1213 auth ,
1314 type OAuthClientProvider ,
1415 selectClientAuthMethod
1516} from './auth.js' ;
1617import { ServerError } from '../server/auth/errors.js' ;
17- import { AuthorizationServerMetadata } from '../shared/auth.js' ;
18+ import { AuthorizationServerMetadata , OAuthClientMetadata } from '../shared/auth.js' ;
1819
1920// Mock fetch globally
2021const mockFetch = jest . fn ( ) ;
@@ -25,6 +26,50 @@ describe('OAuth Authorization', () => {
2526 mockFetch . mockReset ( ) ;
2627 } ) ;
2728
29+ describe ( 'extractFieldFromWwwAuth' , ( ) => {
30+ function mockResponseWithWWWAuthenticate ( headerValue : string ) : Response {
31+ return {
32+ headers : {
33+ get : jest . fn ( name => ( name === 'WWW-Authenticate' ? headerValue : null ) )
34+ }
35+ } as unknown as Response ;
36+ }
37+
38+ it ( 'returns the value of a quoted field' , ( ) => {
39+ const mockResponse = mockResponseWithWWWAuthenticate ( `Bearer realm="example", field="value"` ) ;
40+ expect ( extractFieldFromWwwAuth ( mockResponse , 'field' ) ) . toBe ( 'value' ) ;
41+ } ) ;
42+
43+ it ( 'returns the value of an unquoted field' , ( ) => {
44+ const mockResponse = mockResponseWithWWWAuthenticate ( `Bearer realm=example, field=value` ) ;
45+ expect ( extractFieldFromWwwAuth ( mockResponse , 'field' ) ) . toBe ( 'value' ) ;
46+ } ) ;
47+
48+ it ( 'returns the correct value when multiple parameters are present' , ( ) => {
49+ const mockResponse = mockResponseWithWWWAuthenticate (
50+ `Bearer realm="api", error="invalid_token", field="test_value", scope="admin"`
51+ ) ;
52+ expect ( extractFieldFromWwwAuth ( mockResponse , 'field' ) ) . toBe ( 'test_value' ) ;
53+ } ) ;
54+
55+ it ( 'returns null if the field is not present' , ( ) => {
56+ const mockResponse = mockResponseWithWWWAuthenticate ( `Bearer realm="api", scope="admin"` ) ;
57+ expect ( extractFieldFromWwwAuth ( mockResponse , 'missing_field' ) ) . toBeNull ( ) ;
58+ } ) ;
59+
60+ it ( 'returns null if the WWW-Authenticate header is missing' , ( ) => {
61+ const mockResponse = { headers : new Headers ( ) } as unknown as Response ;
62+ expect ( extractFieldFromWwwAuth ( mockResponse , 'field' ) ) . toBeNull ( ) ;
63+ } ) ;
64+
65+ it ( 'handles fields with special characters in quotes' , ( ) => {
66+ const mockResponse = mockResponseWithWWWAuthenticate (
67+ `Bearer error="invalid_token", error_description="The token has expired, please re-authenticate."`
68+ ) ;
69+ expect ( extractFieldFromWwwAuth ( mockResponse , 'error_description' ) ) . toBe ( 'The token has expired, please re-authenticate.' ) ;
70+ } ) ;
71+ } ) ;
72+
2873 describe ( 'extractWWWAuthenticateParams' , ( ) => {
2974 it ( 'returns resource metadata url when present' , async ( ) => {
3075 const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource' ;
@@ -1496,14 +1541,17 @@ describe('OAuth Authorization', () => {
14961541 } ) ;
14971542
14981543 describe ( 'auth function' , ( ) => {
1544+ let clientMetadataScope : string | undefined = undefined ;
1545+
14991546 const mockProvider : OAuthClientProvider = {
15001547 get redirectUrl ( ) {
15011548 return 'http://localhost:3000/callback' ;
15021549 } ,
15031550 get clientMetadata ( ) {
15041551 return {
15051552 redirect_uris : [ 'http://localhost:3000/callback' ] ,
1506- client_name : 'Test Client'
1553+ client_name : 'Test Client' ,
1554+ scope : clientMetadataScope
15071555 } ;
15081556 } ,
15091557 clientInformation : jest . fn ( ) ,
@@ -2284,6 +2332,91 @@ describe('OAuth Authorization', () => {
22842332 // Verify custom fetch was called for AS metadata discovery
22852333 expect ( customFetch . mock . calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( 'https://auth.example.com/.well-known/oauth-authorization-server' ) ;
22862334 } ) ;
2335+
2336+ it ( 'prioritizes provided scope over resourceMetadata.scope' , async ( ) => {
2337+ const providedScope = 'provided_scope' ;
2338+ ( mockProvider . clientMetadata as OAuthClientMetadata ) . scope = 'client_metadata_scope' ;
2339+
2340+ mockFetch . mockImplementation ( url => {
2341+ if ( url . toString ( ) . includes ( '/.well-known/oauth-protected-resource' ) ) {
2342+ return Promise . resolve ( {
2343+ ok : true ,
2344+ status : 200 ,
2345+ json : async ( ) => ( {
2346+ resource : 'https://api.example.com/mcp-server' ,
2347+ scopes_supported : [ 'read' , 'write' ] ,
2348+ authorization_servers : [ 'https://auth.example.com' ]
2349+ } )
2350+ } ) ;
2351+ }
2352+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2353+ } ) ;
2354+
2355+ await auth ( mockProvider , {
2356+ serverUrl : 'https://api.example.com/mcp-server' ,
2357+ scope : providedScope
2358+ } ) ;
2359+
2360+ const redirectCall = ( mockProvider . redirectToAuthorization as jest . Mock ) . mock . calls [ 0 ] ;
2361+ const authUrl : URL = redirectCall [ 0 ] ;
2362+ expect ( authUrl . searchParams . get ( 'scope' ) ) . toBe ( providedScope ) ;
2363+ } ) ;
2364+
2365+ it ( 'uses resourceMetadata.scope when provided scope is missing' , async ( ) => {
2366+ const resourceScope = 'resource_metadata_scope' ;
2367+ ( mockProvider . clientMetadata as OAuthClientMetadata ) . scope = 'client_metadata_scope' ;
2368+
2369+ mockFetch . mockImplementation ( url => {
2370+ if ( url . toString ( ) . includes ( '/.well-known/oauth-protected-resource' ) ) {
2371+ return Promise . resolve ( {
2372+ ok : true ,
2373+ status : 200 ,
2374+ json : async ( ) => ( {
2375+ resource : 'https://api.example.com/mcp-server' ,
2376+ scopes_supported : [ 'resource_metadata_scope' ] ,
2377+ authorization_servers : [ 'https://auth.example.com' ]
2378+ } )
2379+ } ) ;
2380+ }
2381+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2382+ } ) ;
2383+
2384+ await auth ( mockProvider , {
2385+ serverUrl : 'https://api.example.com/mcp-server'
2386+ } ) ;
2387+
2388+ const redirectCall = ( mockProvider . redirectToAuthorization as jest . Mock ) . mock . calls [ 0 ] ;
2389+ const authUrl : URL = redirectCall [ 0 ] ;
2390+ expect ( authUrl . searchParams . get ( 'scope' ) ) . toBe ( resourceScope ) ;
2391+ } ) ;
2392+
2393+ it ( 'falls back to clientMetadata.scope when provided and resourceMetadata scopes are missing' , async ( ) => {
2394+ const expectedScope = 'client_metadata_scope' ;
2395+ clientMetadataScope = expectedScope ;
2396+
2397+ mockFetch . mockImplementation ( url => {
2398+ if ( url . toString ( ) . includes ( '/.well-known/oauth-protected-resource' ) ) {
2399+ return Promise . resolve ( {
2400+ ok : true ,
2401+ status : 200 ,
2402+ json : async ( ) => ( {
2403+ resource : 'https://api.example.com/mcp-server' ,
2404+ resource_metadata_scope : [ ] ,
2405+ authorization_servers : [ 'https://auth.example.com' ]
2406+ } )
2407+ } ) ;
2408+ }
2409+ return Promise . resolve ( { ok : false , status : 404 } ) ;
2410+ } ) ;
2411+
2412+ await auth ( mockProvider , {
2413+ serverUrl : 'https://api.example.com/mcp-server'
2414+ } ) ;
2415+
2416+ const redirectCall = ( mockProvider . redirectToAuthorization as jest . Mock ) . mock . calls [ 0 ] ;
2417+ const authUrl : URL = redirectCall [ 0 ] ;
2418+ expect ( authUrl . searchParams . get ( 'scope' ) ) . toBe ( clientMetadataScope ) ;
2419+ } ) ;
22872420 } ) ;
22882421
22892422 describe ( 'exchangeAuthorization with multiple client authentication methods' , ( ) => {
0 commit comments