@@ -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
@@ -2558,4 +2559,306 @@ describe('OAuth Authorization', () => {
25582559 expect ( body . get ( 'refresh_token' ) ) . toBe ( 'refresh123' ) ;
25592560 } ) ;
25602561 } ) ;
2562+
2563+ describe ( 'isHttpsUrl' , ( ) => {
2564+ it ( 'returns true for valid HTTPS URL with path' , ( ) => {
2565+ expect ( isHttpsUrl ( 'https://example.com/client-metadata.json' ) ) . toBe ( true ) ;
2566+ } ) ;
2567+
2568+ it ( 'returns true for HTTPS URL with query params' , ( ) => {
2569+ expect ( isHttpsUrl ( 'https://example.com/metadata?version=1' ) ) . toBe ( true ) ;
2570+ } ) ;
2571+
2572+ it ( 'returns false for HTTPS URL without path' , ( ) => {
2573+ expect ( isHttpsUrl ( 'https://example.com' ) ) . toBe ( false ) ;
2574+ expect ( isHttpsUrl ( 'https://example.com/' ) ) . toBe ( false ) ;
2575+ } ) ;
2576+
2577+ it ( 'returns false for HTTP URL' , ( ) => {
2578+ expect ( isHttpsUrl ( 'http://example.com/metadata' ) ) . toBe ( false ) ;
2579+ } ) ;
2580+
2581+ it ( 'returns false for non-URL strings' , ( ) => {
2582+ expect ( isHttpsUrl ( 'not a url' ) ) . toBe ( false ) ;
2583+ } ) ;
2584+
2585+ it ( 'returns false for undefined' , ( ) => {
2586+ expect ( isHttpsUrl ( undefined ) ) . toBe ( false ) ;
2587+ } ) ;
2588+
2589+ it ( 'returns false for empty string' , ( ) => {
2590+ expect ( isHttpsUrl ( '' ) ) . toBe ( false ) ;
2591+ } ) ;
2592+
2593+ it ( 'returns false for javascript: scheme' , ( ) => {
2594+ expect ( isHttpsUrl ( 'javascript:alert(1)' ) ) . toBe ( false ) ;
2595+ } ) ;
2596+
2597+ it ( 'returns false for data: scheme' , ( ) => {
2598+ expect ( isHttpsUrl ( 'data:text/html,<script>alert(1)</script>' ) ) . toBe ( false ) ;
2599+ } ) ;
2600+ } ) ;
2601+
2602+ describe ( 'SEP-991: URL-based Client ID fallback logic' , ( ) => {
2603+ const validClientMetadata = {
2604+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
2605+ client_name : 'Test Client' ,
2606+ client_uri : 'https://example.com/client-metadata.json'
2607+ } ;
2608+
2609+ const mockProvider : OAuthClientProvider = {
2610+ get redirectUrl ( ) {
2611+ return 'http://localhost:3000/callback' ;
2612+ } ,
2613+ clientMetadataUrl : 'https://example.com/client-metadata.json' ,
2614+ get clientMetadata ( ) {
2615+ return validClientMetadata ;
2616+ } ,
2617+ clientInformation : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2618+ saveClientInformation : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2619+ tokens : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2620+ saveTokens : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2621+ redirectToAuthorization : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2622+ saveCodeVerifier : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2623+ codeVerifier : jest . fn ( ) . mockResolvedValue ( 'verifier123' )
2624+ } ;
2625+
2626+ beforeEach ( ( ) => {
2627+ jest . clearAllMocks ( ) ;
2628+ } ) ;
2629+
2630+ it ( 'uses URL-based client ID when server supports it' , async ( ) => {
2631+ // Mock protected resource metadata discovery (404 to skip)
2632+ mockFetch . mockResolvedValueOnce ( {
2633+ ok : false ,
2634+ status : 404 ,
2635+ json : async ( ) => ( { } )
2636+ } ) ;
2637+
2638+ // Mock authorization server metadata discovery to return support for URL-based client IDs
2639+ mockFetch . mockResolvedValueOnce ( {
2640+ ok : true ,
2641+ status : 200 ,
2642+ json : async ( ) => ( {
2643+ issuer : 'https://server.example.com' ,
2644+ authorization_endpoint : 'https://server.example.com/authorize' ,
2645+ token_endpoint : 'https://server.example.com/token' ,
2646+ response_types_supported : [ 'code' ] ,
2647+ code_challenge_methods_supported : [ 'S256' ] ,
2648+ client_id_metadata_document_supported : true // SEP-991 support
2649+ } )
2650+ } ) ;
2651+
2652+ await auth ( mockProvider , {
2653+ serverUrl : 'https://server.example.com'
2654+ } ) ;
2655+
2656+ // Should save URL-based client info
2657+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2658+ client_id : 'https://example.com/client-metadata.json'
2659+ } ) ;
2660+ } ) ;
2661+
2662+ it ( 'falls back to DCR when server does not support URL-based client IDs' , async ( ) => {
2663+ // Mock protected resource metadata discovery (404 to skip)
2664+ mockFetch . mockResolvedValueOnce ( {
2665+ ok : false ,
2666+ status : 404 ,
2667+ json : async ( ) => ( { } )
2668+ } ) ;
2669+
2670+ // Mock authorization server metadata discovery without SEP-991 support
2671+ mockFetch . mockResolvedValueOnce ( {
2672+ ok : true ,
2673+ status : 200 ,
2674+ json : async ( ) => ( {
2675+ issuer : 'https://server.example.com' ,
2676+ authorization_endpoint : 'https://server.example.com/authorize' ,
2677+ token_endpoint : 'https://server.example.com/token' ,
2678+ registration_endpoint : 'https://server.example.com/register' ,
2679+ response_types_supported : [ 'code' ] ,
2680+ code_challenge_methods_supported : [ 'S256' ]
2681+ // No client_id_metadata_document_supported
2682+ } )
2683+ } ) ;
2684+
2685+ // Mock DCR response
2686+ mockFetch . mockResolvedValueOnce ( {
2687+ ok : true ,
2688+ status : 201 ,
2689+ json : async ( ) => ( {
2690+ client_id : 'generated-uuid' ,
2691+ client_secret : 'generated-secret' ,
2692+ redirect_uris : [ 'http://localhost:3000/callback' ]
2693+ } )
2694+ } ) ;
2695+
2696+ await auth ( mockProvider , {
2697+ serverUrl : 'https://server.example.com'
2698+ } ) ;
2699+
2700+ // Should save DCR client info
2701+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2702+ client_id : 'generated-uuid' ,
2703+ client_secret : 'generated-secret' ,
2704+ redirect_uris : [ 'http://localhost:3000/callback' ]
2705+ } ) ;
2706+ } ) ;
2707+
2708+ it ( 'throws an error when clientMetadataUrl is not an HTTPS URL' , async ( ) => {
2709+ const providerWithInvalidUri = {
2710+ ...mockProvider ,
2711+ clientMetadataUrl : 'http://example.com/metadata'
2712+ } ;
2713+
2714+ // Mock protected resource metadata discovery (404 to skip)
2715+ mockFetch . mockResolvedValueOnce ( {
2716+ ok : false ,
2717+ status : 404 ,
2718+ json : async ( ) => ( { } )
2719+ } ) ;
2720+
2721+ // Mock authorization server metadata discovery with SEP-991 support
2722+ mockFetch . mockResolvedValueOnce ( {
2723+ ok : true ,
2724+ status : 200 ,
2725+ json : async ( ) => ( {
2726+ issuer : 'https://server.example.com' ,
2727+ authorization_endpoint : 'https://server.example.com/authorize' ,
2728+ token_endpoint : 'https://server.example.com/token' ,
2729+ registration_endpoint : 'https://server.example.com/register' ,
2730+ response_types_supported : [ 'code' ] ,
2731+ code_challenge_methods_supported : [ 'S256' ] ,
2732+ client_id_metadata_document_supported : true
2733+ } )
2734+ } ) ;
2735+
2736+ await expect (
2737+ auth ( providerWithInvalidUri , {
2738+ serverUrl : 'https://server.example.com'
2739+ } )
2740+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
2741+ } ) ;
2742+
2743+ it ( 'throws an error when clientMetadataUrl has root pathname' , async ( ) => {
2744+ const providerWithRootPathname = {
2745+ ...mockProvider ,
2746+ clientMetadataUrl : 'https://example.com/'
2747+ } ;
2748+
2749+ // Mock protected resource metadata discovery (404 to skip)
2750+ mockFetch . mockResolvedValueOnce ( {
2751+ ok : false ,
2752+ status : 404 ,
2753+ json : async ( ) => ( { } )
2754+ } ) ;
2755+
2756+ // Mock authorization server metadata discovery with SEP-991 support
2757+ mockFetch . mockResolvedValueOnce ( {
2758+ ok : true ,
2759+ status : 200 ,
2760+ json : async ( ) => ( {
2761+ issuer : 'https://server.example.com' ,
2762+ authorization_endpoint : 'https://server.example.com/authorize' ,
2763+ token_endpoint : 'https://server.example.com/token' ,
2764+ registration_endpoint : 'https://server.example.com/register' ,
2765+ response_types_supported : [ 'code' ] ,
2766+ code_challenge_methods_supported : [ 'S256' ] ,
2767+ client_id_metadata_document_supported : true
2768+ } )
2769+ } ) ;
2770+
2771+ await expect (
2772+ auth ( providerWithRootPathname , {
2773+ serverUrl : 'https://server.example.com'
2774+ } )
2775+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
2776+ } ) ;
2777+
2778+ it ( 'throws an error when clientMetadataUrl is not a valid URL' , async ( ) => {
2779+ const providerWithInvalidUrl = {
2780+ ...mockProvider ,
2781+ clientMetadataUrl : 'not-a-valid-url'
2782+ } ;
2783+
2784+ // Mock protected resource metadata discovery (404 to skip)
2785+ mockFetch . mockResolvedValueOnce ( {
2786+ ok : false ,
2787+ status : 404 ,
2788+ json : async ( ) => ( { } )
2789+ } ) ;
2790+
2791+ // Mock authorization server metadata discovery with SEP-991 support
2792+ mockFetch . mockResolvedValueOnce ( {
2793+ ok : true ,
2794+ status : 200 ,
2795+ json : async ( ) => ( {
2796+ issuer : 'https://server.example.com' ,
2797+ authorization_endpoint : 'https://server.example.com/authorize' ,
2798+ token_endpoint : 'https://server.example.com/token' ,
2799+ registration_endpoint : 'https://server.example.com/register' ,
2800+ response_types_supported : [ 'code' ] ,
2801+ code_challenge_methods_supported : [ 'S256' ] ,
2802+ client_id_metadata_document_supported : true
2803+ } )
2804+ } ) ;
2805+
2806+ await expect (
2807+ auth ( providerWithInvalidUrl , {
2808+ serverUrl : 'https://server.example.com'
2809+ } )
2810+ ) . rejects . toThrow ( InvalidClientMetadataError ) ;
2811+ } ) ;
2812+
2813+ it ( 'falls back to DCR when client_uri is missing' , async ( ) => {
2814+ const providerWithoutUri = {
2815+ ...mockProvider ,
2816+ clientMetadataUrl : undefined
2817+ } ;
2818+
2819+ // Mock protected resource metadata discovery (404 to skip)
2820+ mockFetch . mockResolvedValueOnce ( {
2821+ ok : false ,
2822+ status : 404 ,
2823+ json : async ( ) => ( { } )
2824+ } ) ;
2825+
2826+ // Mock authorization server metadata discovery with SEP-991 support
2827+ mockFetch . mockResolvedValueOnce ( {
2828+ ok : true ,
2829+ status : 200 ,
2830+ json : async ( ) => ( {
2831+ issuer : 'https://server.example.com' ,
2832+ authorization_endpoint : 'https://server.example.com/authorize' ,
2833+ token_endpoint : 'https://server.example.com/token' ,
2834+ registration_endpoint : 'https://server.example.com/register' ,
2835+ response_types_supported : [ 'code' ] ,
2836+ code_challenge_methods_supported : [ 'S256' ] ,
2837+ client_id_metadata_document_supported : true
2838+ } )
2839+ } ) ;
2840+
2841+ // Mock DCR response
2842+ mockFetch . mockResolvedValueOnce ( {
2843+ ok : true ,
2844+ status : 201 ,
2845+ json : async ( ) => ( {
2846+ client_id : 'generated-uuid' ,
2847+ client_secret : 'generated-secret' ,
2848+ redirect_uris : [ 'http://localhost:3000/callback' ]
2849+ } )
2850+ } ) ;
2851+
2852+ await auth ( providerWithoutUri , {
2853+ serverUrl : 'https://server.example.com'
2854+ } ) ;
2855+
2856+ // Should fall back to DCR
2857+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2858+ client_id : 'generated-uuid' ,
2859+ client_secret : 'generated-secret' ,
2860+ redirect_uris : [ 'http://localhost:3000/callback' ]
2861+ } ) ;
2862+ } ) ;
2863+ } ) ;
25612864} ) ;
0 commit comments