@@ -11,9 +11,10 @@ import {
1111 extractWWWAuthenticateParams ,
1212 auth ,
1313 type OAuthClientProvider ,
14- selectClientAuthMethod
14+ selectClientAuthMethod ,
15+ isHttpsUrl
1516} from './auth.js' ;
16- import { ServerError } from '../server/auth/errors.js' ;
17+ import { InvalidClientMetadataError , ServerError } from '../server/auth/errors.js' ;
1718import { AuthorizationServerMetadata } from '../shared/auth.js' ;
1819import { expect , vi , type Mock } from 'vitest' ;
1920
@@ -2796,4 +2797,306 @@ describe('OAuth Authorization', () => {
27962797 } ) ;
27972798 } ) ;
27982799 } ) ;
2800+
2801+ describe ( 'isHttpsUrl' , ( ) => {
2802+ it ( 'returns true for valid HTTPS URL with path' , ( ) => {
2803+ expect ( isHttpsUrl ( 'https://example.com/client-metadata.json' ) ) . toBe ( true ) ;
2804+ } ) ;
2805+
2806+ it ( 'returns true for HTTPS URL with query params' , ( ) => {
2807+ expect ( isHttpsUrl ( 'https://example.com/metadata?version=1' ) ) . toBe ( true ) ;
2808+ } ) ;
2809+
2810+ it ( 'returns false for HTTPS URL without path' , ( ) => {
2811+ expect ( isHttpsUrl ( 'https://example.com' ) ) . toBe ( false ) ;
2812+ expect ( isHttpsUrl ( 'https://example.com/' ) ) . toBe ( false ) ;
2813+ } ) ;
2814+
2815+ it ( 'returns false for HTTP URL' , ( ) => {
2816+ expect ( isHttpsUrl ( 'http://example.com/metadata' ) ) . toBe ( false ) ;
2817+ } ) ;
2818+
2819+ it ( 'returns false for non-URL strings' , ( ) => {
2820+ expect ( isHttpsUrl ( 'not a url' ) ) . toBe ( false ) ;
2821+ } ) ;
2822+
2823+ it ( 'returns false for undefined' , ( ) => {
2824+ expect ( isHttpsUrl ( undefined ) ) . toBe ( false ) ;
2825+ } ) ;
2826+
2827+ it ( 'returns false for empty string' , ( ) => {
2828+ expect ( isHttpsUrl ( '' ) ) . toBe ( false ) ;
2829+ } ) ;
2830+
2831+ it ( 'returns false for javascript: scheme' , ( ) => {
2832+ expect ( isHttpsUrl ( 'javascript:alert(1)' ) ) . toBe ( false ) ;
2833+ } ) ;
2834+
2835+ it ( 'returns false for data: scheme' , ( ) => {
2836+ expect ( isHttpsUrl ( 'data:text/html,<script>alert(1)</script>' ) ) . toBe ( false ) ;
2837+ } ) ;
2838+ } ) ;
2839+
2840+ describe ( 'SEP-991: URL-based Client ID fallback logic' , ( ) => {
2841+ const validClientMetadata = {
2842+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
2843+ client_name : 'Test Client' ,
2844+ client_uri : 'https://example.com/client-metadata.json'
2845+ } ;
2846+
2847+ const mockProvider : OAuthClientProvider = {
2848+ get redirectUrl ( ) {
2849+ return 'http://localhost:3000/callback' ;
2850+ } ,
2851+ clientMetadataUrl : 'https://example.com/client-metadata.json' ,
2852+ get clientMetadata ( ) {
2853+ return validClientMetadata ;
2854+ } ,
2855+ clientInformation : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2856+ saveClientInformation : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2857+ tokens : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2858+ saveTokens : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2859+ redirectToAuthorization : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2860+ saveCodeVerifier : vi . fn ( ) . mockResolvedValue ( undefined ) ,
2861+ codeVerifier : vi . fn ( ) . mockResolvedValue ( 'verifier123' )
2862+ } ;
2863+
2864+ beforeEach ( ( ) => {
2865+ vi . clearAllMocks ( ) ;
2866+ } ) ;
2867+
2868+ it ( 'uses URL-based client ID when server supports it' , async ( ) => {
2869+ // Mock protected resource metadata discovery (404 to skip)
2870+ mockFetch . mockResolvedValueOnce ( {
2871+ ok : false ,
2872+ status : 404 ,
2873+ json : async ( ) => ( { } )
2874+ } ) ;
2875+
2876+ // Mock authorization server metadata discovery to return support for URL-based client IDs
2877+ mockFetch . mockResolvedValueOnce ( {
2878+ ok : true ,
2879+ status : 200 ,
2880+ json : async ( ) => ( {
2881+ issuer : 'https://server.example.com' ,
2882+ authorization_endpoint : 'https://server.example.com/authorize' ,
2883+ token_endpoint : 'https://server.example.com/token' ,
2884+ response_types_supported : [ 'code' ] ,
2885+ code_challenge_methods_supported : [ 'S256' ] ,
2886+ client_id_metadata_document_supported : true // SEP-991 support
2887+ } )
2888+ } ) ;
2889+
2890+ await auth ( mockProvider , {
2891+ serverUrl : 'https://server.example.com'
2892+ } ) ;
2893+
2894+ // Should save URL-based client info
2895+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2896+ client_id : 'https://example.com/client-metadata.json'
2897+ } ) ;
2898+ } ) ;
2899+
2900+ it ( 'falls back to DCR when server does not support URL-based client IDs' , async ( ) => {
2901+ // Mock protected resource metadata discovery (404 to skip)
2902+ mockFetch . mockResolvedValueOnce ( {
2903+ ok : false ,
2904+ status : 404 ,
2905+ json : async ( ) => ( { } )
2906+ } ) ;
2907+
2908+ // Mock authorization server metadata discovery without SEP-991 support
2909+ mockFetch . mockResolvedValueOnce ( {
2910+ ok : true ,
2911+ status : 200 ,
2912+ json : async ( ) => ( {
2913+ issuer : 'https://server.example.com' ,
2914+ authorization_endpoint : 'https://server.example.com/authorize' ,
2915+ token_endpoint : 'https://server.example.com/token' ,
2916+ registration_endpoint : 'https://server.example.com/register' ,
2917+ response_types_supported : [ 'code' ] ,
2918+ code_challenge_methods_supported : [ 'S256' ]
2919+ // No client_id_metadata_document_supported
2920+ } )
2921+ } ) ;
2922+
2923+ // Mock DCR response
2924+ mockFetch . mockResolvedValueOnce ( {
2925+ ok : true ,
2926+ status : 201 ,
2927+ json : async ( ) => ( {
2928+ client_id : 'generated-uuid' ,
2929+ client_secret : 'generated-secret' ,
2930+ redirect_uris : [ 'http://localhost:3000/callback' ]
2931+ } )
2932+ } ) ;
2933+
2934+ await auth ( mockProvider , {
2935+ serverUrl : 'https://server.example.com'
2936+ } ) ;
2937+
2938+ // Should save DCR client info
2939+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2940+ client_id : 'generated-uuid' ,
2941+ client_secret : 'generated-secret' ,
2942+ redirect_uris : [ 'http://localhost:3000/callback' ]
2943+ } ) ;
2944+ } ) ;
2945+
2946+ it ( 'throws an error when clientMetadataUrl is not an HTTPS URL' , async ( ) => {
2947+ const providerWithInvalidUri = {
2948+ ...mockProvider ,
2949+ clientMetadataUrl : 'http://example.com/metadata'
2950+ } ;
2951+
2952+ // Mock protected resource metadata discovery (404 to skip)
2953+ mockFetch . mockResolvedValueOnce ( {
2954+ ok : false ,
2955+ status : 404 ,
2956+ json : async ( ) => ( { } )
2957+ } ) ;
2958+
2959+ // Mock authorization server metadata discovery with SEP-991 support
2960+ mockFetch . mockResolvedValueOnce ( {
2961+ ok : true ,
2962+ status : 200 ,
2963+ json : async ( ) => ( {
2964+ issuer : 'https://server.example.com' ,
2965+ authorization_endpoint : 'https://server.example.com/authorize' ,
2966+ token_endpoint : 'https://server.example.com/token' ,
2967+ registration_endpoint : 'https://server.example.com/register' ,
2968+ response_types_supported : [ 'code' ] ,
2969+ code_challenge_methods_supported : [ 'S256' ] ,
2970+ client_id_metadata_document_supported : true
2971+ } )
2972+ } ) ;
2973+
2974+ await expect (
2975+ auth ( providerWithInvalidUri , {
2976+ serverUrl : 'https://server.example.com'
2977+ } )
2978+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
2979+ } ) ;
2980+
2981+ it ( 'throws an error when clientMetadataUrl has root pathname' , async ( ) => {
2982+ const providerWithRootPathname = {
2983+ ...mockProvider ,
2984+ clientMetadataUrl : 'https://example.com/'
2985+ } ;
2986+
2987+ // Mock protected resource metadata discovery (404 to skip)
2988+ mockFetch . mockResolvedValueOnce ( {
2989+ ok : false ,
2990+ status : 404 ,
2991+ json : async ( ) => ( { } )
2992+ } ) ;
2993+
2994+ // Mock authorization server metadata discovery with SEP-991 support
2995+ mockFetch . mockResolvedValueOnce ( {
2996+ ok : true ,
2997+ status : 200 ,
2998+ json : async ( ) => ( {
2999+ issuer : 'https://server.example.com' ,
3000+ authorization_endpoint : 'https://server.example.com/authorize' ,
3001+ token_endpoint : 'https://server.example.com/token' ,
3002+ registration_endpoint : 'https://server.example.com/register' ,
3003+ response_types_supported : [ 'code' ] ,
3004+ code_challenge_methods_supported : [ 'S256' ] ,
3005+ client_id_metadata_document_supported : true
3006+ } )
3007+ } ) ;
3008+
3009+ await expect (
3010+ auth ( providerWithRootPathname , {
3011+ serverUrl : 'https://server.example.com'
3012+ } )
3013+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
3014+ } ) ;
3015+
3016+ it ( 'throws an error when clientMetadataUrl is not a valid URL' , async ( ) => {
3017+ const providerWithInvalidUrl = {
3018+ ...mockProvider ,
3019+ clientMetadataUrl : 'not-a-valid-url'
3020+ } ;
3021+
3022+ // Mock protected resource metadata discovery (404 to skip)
3023+ mockFetch . mockResolvedValueOnce ( {
3024+ ok : false ,
3025+ status : 404 ,
3026+ json : async ( ) => ( { } )
3027+ } ) ;
3028+
3029+ // Mock authorization server metadata discovery with SEP-991 support
3030+ mockFetch . mockResolvedValueOnce ( {
3031+ ok : true ,
3032+ status : 200 ,
3033+ json : async ( ) => ( {
3034+ issuer : 'https://server.example.com' ,
3035+ authorization_endpoint : 'https://server.example.com/authorize' ,
3036+ token_endpoint : 'https://server.example.com/token' ,
3037+ registration_endpoint : 'https://server.example.com/register' ,
3038+ response_types_supported : [ 'code' ] ,
3039+ code_challenge_methods_supported : [ 'S256' ] ,
3040+ client_id_metadata_document_supported : true
3041+ } )
3042+ } ) ;
3043+
3044+ await expect (
3045+ auth ( providerWithInvalidUrl , {
3046+ serverUrl : 'https://server.example.com'
3047+ } )
3048+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
3049+ } ) ;
3050+
3051+ it ( 'falls back to DCR when client_uri is missing' , async ( ) => {
3052+ const providerWithoutUri = {
3053+ ...mockProvider ,
3054+ clientMetadataUrl : undefined
3055+ } ;
3056+
3057+ // Mock protected resource metadata discovery (404 to skip)
3058+ mockFetch . mockResolvedValueOnce ( {
3059+ ok : false ,
3060+ status : 404 ,
3061+ json : async ( ) => ( { } )
3062+ } ) ;
3063+
3064+ // Mock authorization server metadata discovery with SEP-991 support
3065+ mockFetch . mockResolvedValueOnce ( {
3066+ ok : true ,
3067+ status : 200 ,
3068+ json : async ( ) => ( {
3069+ issuer : 'https://server.example.com' ,
3070+ authorization_endpoint : 'https://server.example.com/authorize' ,
3071+ token_endpoint : 'https://server.example.com/token' ,
3072+ registration_endpoint : 'https://server.example.com/register' ,
3073+ response_types_supported : [ 'code' ] ,
3074+ code_challenge_methods_supported : [ 'S256' ] ,
3075+ client_id_metadata_document_supported : true
3076+ } )
3077+ } ) ;
3078+
3079+ // Mock DCR response
3080+ mockFetch . mockResolvedValueOnce ( {
3081+ ok : true ,
3082+ status : 201 ,
3083+ json : async ( ) => ( {
3084+ client_id : 'generated-uuid' ,
3085+ client_secret : 'generated-secret' ,
3086+ redirect_uris : [ 'http://localhost:3000/callback' ]
3087+ } )
3088+ } ) ;
3089+
3090+ await auth ( providerWithoutUri , {
3091+ serverUrl : 'https://server.example.com'
3092+ } ) ;
3093+
3094+ // Should fall back to DCR
3095+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
3096+ client_id : 'generated-uuid' ,
3097+ client_secret : 'generated-secret' ,
3098+ redirect_uris : [ 'http://localhost:3000/callback' ]
3099+ } ) ;
3100+ } ) ;
3101+ } ) ;
27993102} ) ;
0 commit comments