1
1
import { LATEST_PROTOCOL_VERSION } from '../types.js' ;
2
2
import {
3
3
discoverOAuthMetadata ,
4
+ discoverAuthorizationServerMetadata ,
4
5
startAuthorization ,
5
6
exchangeAuthorization ,
6
7
refreshAuthorization ,
@@ -11,7 +12,7 @@ import {
11
12
type OAuthClientProvider ,
12
13
} from "./auth.js" ;
13
14
import { ServerError } from "../server/auth/errors.js" ;
14
- import { OAuthMetadata } from '../shared/auth.js' ;
15
+ import { AuthorizationServerMetadata } from '../shared/auth.js' ;
15
16
16
17
// Mock fetch globally
17
18
const mockFetch = jest . fn ( ) ;
@@ -683,6 +684,302 @@ describe("OAuth Authorization", () => {
683
684
} ) ;
684
685
} ) ;
685
686
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
+
686
983
describe ( "startAuthorization" , ( ) => {
687
984
const validMetadata = {
688
985
issuer : "https://auth.example.com" ,
@@ -909,7 +1206,7 @@ describe("OAuth Authorization", () => {
909
1206
authorizationCode : "code123" ,
910
1207
codeVerifier : "verifier123" ,
911
1208
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 ) => {
913
1210
headers . set ( "Authorization" , "Basic " + btoa ( validClientInfo . client_id + ":" + validClientInfo . client_secret ) ) ;
914
1211
params . set ( "example_url" , typeof url === 'string' ? url : url . toString ( ) ) ;
915
1212
params . set ( "example_metadata" , metadata . authorization_endpoint ) ;
@@ -1091,7 +1388,7 @@ describe("OAuth Authorization", () => {
1091
1388
metadata : validMetadata ,
1092
1389
clientInformation : validClientInfo ,
1093
1390
refreshToken : "refresh123" ,
1094
- addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata ?: OAuthMetadata ) => {
1391
+ addClientAuthentication : ( headers : Headers , params : URLSearchParams , url : string | URL , metadata ?: AuthorizationServerMetadata ) => {
1095
1392
headers . set ( "Authorization" , "Basic " + btoa ( validClientInfo . client_id + ":" + validClientInfo . client_secret ) ) ;
1096
1393
params . set ( "example_url" , typeof url === 'string' ? url : url . toString ( ) ) ;
1097
1394
params . set ( "example_metadata" , metadata ?. authorization_endpoint ?? '?' ) ;
0 commit comments