11import { LATEST_PROTOCOL_VERSION } from '../types.js' ;
22import {
33 discoverOAuthMetadata ,
4+ discoverAuthorizationServerMetadata ,
45 startAuthorization ,
56 exchangeAuthorization ,
67 refreshAuthorization ,
@@ -11,7 +12,7 @@ import {
1112 type OAuthClientProvider ,
1213} from "./auth.js" ;
1314import { ServerError } from "../server/auth/errors.js" ;
14- import { OAuthMetadata } from '../shared/auth.js' ;
15+ import { AuthorizationServerMetadata } from '../shared/auth.js' ;
1516
1617// Mock fetch globally
1718const mockFetch = jest . fn ( ) ;
@@ -683,6 +684,302 @@ describe("OAuth Authorization", () => {
683684 } ) ;
684685 } ) ;
685686
687+ describe ( "discoverAuthorizationServerMetadata" , ( ) => {
688+ const validOAuthMetadata = {
689+ issuer : "https://auth.example.com" ,
690+ authorization_endpoint : "https://auth.example.com/authorize" ,
691+ token_endpoint : "https://auth.example.com/token" ,
692+ registration_endpoint : "https://auth.example.com/register" ,
693+ response_types_supported : [ "code" ] ,
694+ code_challenge_methods_supported : [ "S256" ] ,
695+ } ;
696+
697+ const validOpenIdMetadata = {
698+ issuer : "https://auth.example.com" ,
699+ authorization_endpoint : "https://auth.example.com/authorize" ,
700+ token_endpoint : "https://auth.example.com/token" ,
701+ jwks_uri : "https://auth.example.com/jwks" ,
702+ subject_types_supported : [ "public" ] ,
703+ id_token_signing_alg_values_supported : [ "RS256" ] ,
704+ response_types_supported : [ "code" ] ,
705+ code_challenge_methods_supported : [ "S256" ] ,
706+ } ;
707+
708+ it ( "returns OAuth metadata when authorizationServerUrl is provided and OAuth discovery succeeds" , async ( ) => {
709+ mockFetch . mockResolvedValueOnce ( {
710+ ok : true ,
711+ status : 200 ,
712+ json : async ( ) => validOAuthMetadata ,
713+ } ) ;
714+
715+ const metadata = await discoverAuthorizationServerMetadata (
716+ "https://mcp.example.com" ,
717+ "https://auth.example.com"
718+ ) ;
719+
720+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
721+ const calls = mockFetch . mock . calls ;
722+ expect ( calls . length ) . toBe ( 1 ) ;
723+ const [ url , options ] = calls [ 0 ] ;
724+ expect ( url . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/oauth-authorization-server" ) ;
725+ expect ( options . headers ) . toEqual ( {
726+ "MCP-Protocol-Version" : LATEST_PROTOCOL_VERSION
727+ } ) ;
728+ } ) ;
729+
730+ it ( "falls back to OpenID Connect discovery when OAuth discovery fails" , async ( ) => {
731+ // First call (OAuth) returns 404
732+ mockFetch . mockResolvedValueOnce ( {
733+ ok : false ,
734+ status : 404 ,
735+ } ) ;
736+
737+ // Second call (OpenID Connect) succeeds
738+ mockFetch . mockResolvedValueOnce ( {
739+ ok : true ,
740+ status : 200 ,
741+ json : async ( ) => validOpenIdMetadata ,
742+ } ) ;
743+
744+ const metadata = await discoverAuthorizationServerMetadata (
745+ "https://mcp.example.com" ,
746+ "https://auth.example.com"
747+ ) ;
748+
749+ expect ( metadata ) . toEqual ( validOpenIdMetadata ) ;
750+ const calls = mockFetch . mock . calls ;
751+ expect ( calls . length ) . toBe ( 2 ) ;
752+
753+ // First call should be OAuth discovery
754+ expect ( calls [ 0 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/oauth-authorization-server" ) ;
755+
756+ // Second call should be OpenID Connect discovery
757+ expect ( calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/openid-configuration" ) ;
758+ } ) ;
759+
760+ it ( "returns undefined when authorizationServerUrl is provided but both discoveries fail" , async ( ) => {
761+ // Both calls return 404
762+ mockFetch . mockResolvedValue ( {
763+ ok : false ,
764+ status : 404 ,
765+ } ) ;
766+
767+ const metadata = await discoverAuthorizationServerMetadata (
768+ "https://mcp.example.com" ,
769+ "https://auth.example.com"
770+ ) ;
771+
772+ expect ( metadata ) . toBeUndefined ( ) ;
773+ const calls = mockFetch . mock . calls ;
774+ expect ( calls . length ) . toBe ( 2 ) ;
775+ } ) ;
776+
777+ it ( "handles authorization server URL with path in OAuth discovery" , async ( ) => {
778+ mockFetch . mockResolvedValueOnce ( {
779+ ok : true ,
780+ status : 200 ,
781+ json : async ( ) => validOAuthMetadata ,
782+ } ) ;
783+
784+ const metadata = await discoverAuthorizationServerMetadata (
785+ "https://mcp.example.com" ,
786+ "https://auth.example.com/tenant1"
787+ ) ;
788+
789+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
790+ const calls = mockFetch . mock . calls ;
791+ expect ( calls . length ) . toBe ( 1 ) ;
792+ const [ url ] = calls [ 0 ] ;
793+ expect ( url . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" ) ;
794+ } ) ;
795+
796+ it ( "handles authorization server URL with path in OpenID Connect discovery" , async ( ) => {
797+ // OAuth discovery fails
798+ mockFetch . mockResolvedValueOnce ( {
799+ ok : false ,
800+ status : 404 ,
801+ } ) ;
802+
803+ // OpenID Connect discovery succeeds with path insertion
804+ mockFetch . mockResolvedValueOnce ( {
805+ ok : true ,
806+ status : 200 ,
807+ json : async ( ) => validOpenIdMetadata ,
808+ } ) ;
809+
810+ const metadata = await discoverAuthorizationServerMetadata (
811+ "https://mcp.example.com" ,
812+ "https://auth.example.com/tenant1"
813+ ) ;
814+
815+ expect ( metadata ) . toEqual ( validOpenIdMetadata ) ;
816+ const calls = mockFetch . mock . calls ;
817+ expect ( calls . length ) . toBe ( 2 ) ;
818+
819+ // First call should be OAuth with path
820+ expect ( calls [ 0 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" ) ;
821+
822+ // Second call should be OpenID Connect with path insertion
823+ expect ( calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/openid-configuration/tenant1" ) ;
824+ } ) ;
825+
826+ it ( "tries multiple OpenID Connect endpoints when path is present" , async ( ) => {
827+ // OAuth discovery fails
828+ mockFetch . mockResolvedValueOnce ( {
829+ ok : false ,
830+ status : 404 ,
831+ } ) ;
832+
833+ // First OpenID Connect attempt (path insertion) fails
834+ mockFetch . mockResolvedValueOnce ( {
835+ ok : false ,
836+ status : 404 ,
837+ } ) ;
838+
839+ // Second OpenID Connect attempt (path prepending) succeeds
840+ mockFetch . mockResolvedValueOnce ( {
841+ ok : true ,
842+ status : 200 ,
843+ json : async ( ) => validOpenIdMetadata ,
844+ } ) ;
845+
846+ const metadata = await discoverAuthorizationServerMetadata (
847+ "https://mcp.example.com" ,
848+ "https://auth.example.com/tenant1"
849+ ) ;
850+
851+ expect ( metadata ) . toEqual ( validOpenIdMetadata ) ;
852+ const calls = mockFetch . mock . calls ;
853+ expect ( calls . length ) . toBe ( 3 ) ;
854+
855+ // First call should be OAuth with path
856+ expect ( calls [ 0 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/oauth-authorization-server/tenant1" ) ;
857+
858+ // Second call should be OpenID Connect with path insertion
859+ expect ( calls [ 1 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/.well-known/openid-configuration/tenant1" ) ;
860+
861+ // Third call should be OpenID Connect with path prepending
862+ expect ( calls [ 2 ] [ 0 ] . toString ( ) ) . toBe ( "https://auth.example.com/tenant1/.well-known/openid-configuration" ) ;
863+ } ) ;
864+
865+ it ( "falls back to legacy MCP server when authorizationServerUrl is undefined" , async ( ) => {
866+ mockFetch . mockResolvedValueOnce ( {
867+ ok : true ,
868+ status : 200 ,
869+ json : async ( ) => validOAuthMetadata ,
870+ } ) ;
871+
872+ const metadata = await discoverAuthorizationServerMetadata (
873+ "https://mcp.example.com" ,
874+ undefined
875+ ) ;
876+
877+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
878+ const calls = mockFetch . mock . calls ;
879+ expect ( calls . length ) . toBe ( 1 ) ;
880+ const [ url ] = calls [ 0 ] ;
881+ expect ( url . toString ( ) ) . toBe ( "https://mcp.example.com/.well-known/oauth-authorization-server" ) ;
882+ } ) ;
883+
884+ it ( "returns fallback metadata when legacy MCP server returns 404" , async ( ) => {
885+ mockFetch . mockResolvedValueOnce ( {
886+ ok : false ,
887+ status : 404 ,
888+ } ) ;
889+
890+ const metadata = await discoverAuthorizationServerMetadata (
891+ "https://mcp.example.com" ,
892+ undefined
893+ ) ;
894+
895+ expect ( metadata ) . toEqual ( {
896+ issuer : "https://mcp.example.com" ,
897+ authorization_endpoint : "https://mcp.example.com/authorize" ,
898+ token_endpoint : "https://mcp.example.com/token" ,
899+ registration_endpoint : "https://mcp.example.com/register" ,
900+ response_types_supported : [ "code" ] ,
901+ code_challenge_methods_supported : [ "S256" ] ,
902+ } ) ;
903+ } ) ;
904+
905+ it ( "throws on non-404 errors in legacy mode" , async ( ) => {
906+ mockFetch . mockResolvedValueOnce ( {
907+ ok : false ,
908+ status : 500 ,
909+ } ) ;
910+
911+ await expect (
912+ discoverAuthorizationServerMetadata ( "https://mcp.example.com" , undefined )
913+ ) . rejects . toThrow ( "HTTP 500" ) ;
914+ } ) ;
915+
916+ it ( "handles CORS errors with retry" , async ( ) => {
917+ // First call fails with CORS
918+ mockFetch . mockImplementationOnce ( ( ) => Promise . reject ( new TypeError ( "CORS error" ) ) ) ;
919+
920+ // Retry without headers succeeds
921+ mockFetch . mockResolvedValueOnce ( {
922+ ok : true ,
923+ status : 200 ,
924+ json : async ( ) => validOAuthMetadata ,
925+ } ) ;
926+
927+ const metadata = await discoverAuthorizationServerMetadata (
928+ "https://mcp.example.com" ,
929+ "https://auth.example.com"
930+ ) ;
931+
932+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
933+ const calls = mockFetch . mock . calls ;
934+ expect ( calls . length ) . toBe ( 2 ) ;
935+
936+ // First call should have headers
937+ expect ( calls [ 0 ] [ 1 ] ?. headers ) . toHaveProperty ( "MCP-Protocol-Version" ) ;
938+
939+ // Second call should not have headers (CORS retry)
940+ expect ( calls [ 1 ] [ 1 ] ?. headers ) . toBeUndefined ( ) ;
941+ } ) ;
942+
943+ it ( "supports custom fetch function" , async ( ) => {
944+ const customFetch = jest . fn ( ) . mockResolvedValue ( {
945+ ok : true ,
946+ status : 200 ,
947+ json : async ( ) => validOAuthMetadata ,
948+ } ) ;
949+
950+ const metadata = await discoverAuthorizationServerMetadata (
951+ "https://mcp.example.com" ,
952+ "https://auth.example.com" ,
953+ { fetchFn : customFetch }
954+ ) ;
955+
956+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
957+ expect ( customFetch ) . toHaveBeenCalledTimes ( 1 ) ;
958+ expect ( mockFetch ) . not . toHaveBeenCalled ( ) ;
959+ } ) ;
960+
961+ it ( "supports custom protocol version" , async ( ) => {
962+ mockFetch . mockResolvedValueOnce ( {
963+ ok : true ,
964+ status : 200 ,
965+ json : async ( ) => validOAuthMetadata ,
966+ } ) ;
967+
968+ const metadata = await discoverAuthorizationServerMetadata (
969+ "https://mcp.example.com" ,
970+ "https://auth.example.com" ,
971+ { protocolVersion : "2025-01-01" }
972+ ) ;
973+
974+ expect ( metadata ) . toEqual ( validOAuthMetadata ) ;
975+ const calls = mockFetch . mock . calls ;
976+ const [ , options ] = calls [ 0 ] ;
977+ expect ( options . headers ) . toEqual ( {
978+ "MCP-Protocol-Version" : "2025-01-01"
979+ } ) ;
980+ } ) ;
981+ } ) ;
982+
686983 describe ( "startAuthorization" , ( ) => {
687984 const validMetadata = {
688985 issuer : "https://auth.example.com" ,
@@ -909,7 +1206,7 @@ describe("OAuth Authorization", () => {
9091206 authorizationCode : "code123" ,
9101207 codeVerifier : "verifier123" ,
9111208 redirectUri : "http://localhost:3000/callback" ,
912- addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata : OAuthMetadata ) => {
1209+ addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata : AuthorizationServerMetadata ) => {
9131210 headers . set ( "Authorization" , "Basic " + btoa ( validClientInfo . client_id + ":" + validClientInfo . client_secret ) ) ;
9141211 params . set ( "example_url" , typeof url === 'string' ? url : url . toString ( ) ) ;
9151212 params . set ( "example_metadata" , metadata . authorization_endpoint ) ;
@@ -1091,7 +1388,7 @@ describe("OAuth Authorization", () => {
10911388 metadata : validMetadata ,
10921389 clientInformation : validClientInfo ,
10931390 refreshToken : "refresh123" ,
1094- addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata ?: OAuthMetadata ) => {
1391+ addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata ?: AuthorizationServerMetadata ) => {
10951392 headers . set ( "Authorization" , "Basic " + btoa ( validClientInfo . client_id + ":" + validClientInfo . client_secret ) ) ;
10961393 params . set ( "example_url" , typeof url === 'string' ? url : url . toString ( ) ) ;
10971394 params . set ( "example_metadata" , metadata ?. authorization_endpoint ?? '?' ) ;
0 commit comments