@@ -11,7 +11,8 @@ import {
1111 extractWWWAuthenticateParams ,
1212 auth ,
1313 type OAuthClientProvider ,
14- selectClientAuthMethod
14+ selectClientAuthMethod ,
15+ isHttpsUrl
1516} from './auth.js' ;
1617import { ServerError } from '../server/auth/errors.js' ;
1718import { AuthorizationServerMetadata } from '../shared/auth.js' ;
@@ -2476,4 +2477,262 @@ describe('OAuth Authorization', () => {
24762477 expect ( body . get ( 'refresh_token' ) ) . toBe ( 'refresh123' ) ;
24772478 } ) ;
24782479 } ) ;
2480+
2481+ describe ( 'isHttpsUrl' , ( ) => {
2482+ it ( 'returns true for valid HTTPS URL with path' , ( ) => {
2483+ expect ( isHttpsUrl ( 'https://example.com/client-metadata.json' ) ) . toBe ( true ) ;
2484+ } ) ;
2485+
2486+ it ( 'returns true for HTTPS URL with query params' , ( ) => {
2487+ expect ( isHttpsUrl ( 'https://example.com/metadata?version=1' ) ) . toBe ( true ) ;
2488+ } ) ;
2489+
2490+ it ( 'returns false for HTTPS URL without path' , ( ) => {
2491+ expect ( isHttpsUrl ( 'https://example.com' ) ) . toBe ( false ) ;
2492+ expect ( isHttpsUrl ( 'https://example.com/' ) ) . toBe ( false ) ;
2493+ } ) ;
2494+
2495+ it ( 'returns false for HTTP URL' , ( ) => {
2496+ expect ( isHttpsUrl ( 'http://example.com/metadata' ) ) . toBe ( false ) ;
2497+ } ) ;
2498+
2499+ it ( 'returns false for non-URL strings' , ( ) => {
2500+ expect ( isHttpsUrl ( 'not a url' ) ) . toBe ( false ) ;
2501+ } ) ;
2502+
2503+ it ( 'returns false for undefined' , ( ) => {
2504+ expect ( isHttpsUrl ( undefined ) ) . toBe ( false ) ;
2505+ } ) ;
2506+
2507+ it ( 'returns false for empty string' , ( ) => {
2508+ expect ( isHttpsUrl ( '' ) ) . toBe ( false ) ;
2509+ } ) ;
2510+
2511+ it ( 'returns false for javascript: scheme' , ( ) => {
2512+ expect ( isHttpsUrl ( 'javascript:alert(1)' ) ) . toBe ( false ) ;
2513+ } ) ;
2514+
2515+ it ( 'returns false for data: scheme' , ( ) => {
2516+ expect ( isHttpsUrl ( 'data:text/html,<script>alert(1)</script>' ) ) . toBe ( false ) ;
2517+ } ) ;
2518+ } ) ;
2519+
2520+ describe ( 'SEP-991: URL-based Client ID fallback logic' , ( ) => {
2521+ const validClientMetadata = {
2522+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
2523+ client_name : 'Test Client' ,
2524+ client_uri : 'https://example.com/client-metadata.json'
2525+ } ;
2526+
2527+ const mockProvider : OAuthClientProvider = {
2528+ get redirectUrl ( ) {
2529+ return 'http://localhost:3000/callback' ;
2530+ } ,
2531+ get clientMetadata ( ) {
2532+ return validClientMetadata ;
2533+ } ,
2534+ clientInformation : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2535+ saveClientInformation : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2536+ tokens : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2537+ saveTokens : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2538+ redirectToAuthorization : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2539+ saveCodeVerifier : jest . fn ( ) . mockResolvedValue ( undefined ) ,
2540+ codeVerifier : jest . fn ( ) . mockResolvedValue ( 'verifier123' )
2541+ } ;
2542+
2543+ beforeEach ( ( ) => {
2544+ jest . clearAllMocks ( ) ;
2545+ } ) ;
2546+
2547+ it ( 'uses URL-based client ID when server supports it' , async ( ) => {
2548+ // Mock protected resource metadata discovery (404 to skip)
2549+ mockFetch . mockResolvedValueOnce ( {
2550+ ok : false ,
2551+ status : 404 ,
2552+ json : async ( ) => ( { } )
2553+ } ) ;
2554+
2555+ // Mock authorization server metadata discovery to return support for URL-based client IDs
2556+ mockFetch . mockResolvedValueOnce ( {
2557+ ok : true ,
2558+ status : 200 ,
2559+ json : async ( ) => ( {
2560+ issuer : 'https://server.example.com' ,
2561+ authorization_endpoint : 'https://server.example.com/authorize' ,
2562+ token_endpoint : 'https://server.example.com/token' ,
2563+ response_types_supported : [ 'code' ] ,
2564+ code_challenge_methods_supported : [ 'S256' ] ,
2565+ client_id_metadata_document_supported : true // SEP-991 support
2566+ } )
2567+ } ) ;
2568+
2569+ await auth ( mockProvider , {
2570+ serverUrl : 'https://server.example.com'
2571+ } ) ;
2572+
2573+ // Should save URL-based client info
2574+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2575+ client_id : 'https://example.com/client-metadata.json'
2576+ } ) ;
2577+ } ) ;
2578+
2579+ it ( 'falls back to DCR when server does not support URL-based client IDs' , async ( ) => {
2580+ // Mock protected resource metadata discovery (404 to skip)
2581+ mockFetch . mockResolvedValueOnce ( {
2582+ ok : false ,
2583+ status : 404 ,
2584+ json : async ( ) => ( { } )
2585+ } ) ;
2586+
2587+ // Mock authorization server metadata discovery without SEP-991 support
2588+ mockFetch . mockResolvedValueOnce ( {
2589+ ok : true ,
2590+ status : 200 ,
2591+ json : async ( ) => ( {
2592+ issuer : 'https://server.example.com' ,
2593+ authorization_endpoint : 'https://server.example.com/authorize' ,
2594+ token_endpoint : 'https://server.example.com/token' ,
2595+ registration_endpoint : 'https://server.example.com/register' ,
2596+ response_types_supported : [ 'code' ] ,
2597+ code_challenge_methods_supported : [ 'S256' ]
2598+ // No client_id_metadata_document_supported
2599+ } )
2600+ } ) ;
2601+
2602+ // Mock DCR response
2603+ mockFetch . mockResolvedValueOnce ( {
2604+ ok : true ,
2605+ status : 201 ,
2606+ json : async ( ) => ( {
2607+ client_id : 'generated-uuid' ,
2608+ client_secret : 'generated-secret' ,
2609+ redirect_uris : [ 'http://localhost:3000/callback' ]
2610+ } )
2611+ } ) ;
2612+
2613+ await auth ( mockProvider , {
2614+ serverUrl : 'https://server.example.com'
2615+ } ) ;
2616+
2617+ // Should save DCR client info
2618+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2619+ client_id : 'generated-uuid' ,
2620+ client_secret : 'generated-secret' ,
2621+ redirect_uris : [ 'http://localhost:3000/callback' ]
2622+ } ) ;
2623+ } ) ;
2624+
2625+ it ( 'falls back to DCR when client_uri is not an HTTPS URL' , async ( ) => {
2626+ const providerWithInvalidUri = {
2627+ ...mockProvider ,
2628+ get clientMetadata ( ) {
2629+ return {
2630+ ...validClientMetadata ,
2631+ client_uri : 'http://example.com/metadata' // HTTP not HTTPS
2632+ } ;
2633+ }
2634+ } ;
2635+
2636+ // Mock protected resource metadata discovery (404 to skip)
2637+ mockFetch . mockResolvedValueOnce ( {
2638+ ok : false ,
2639+ status : 404 ,
2640+ json : async ( ) => ( { } )
2641+ } ) ;
2642+
2643+ // Mock authorization server metadata discovery with SEP-991 support
2644+ mockFetch . mockResolvedValueOnce ( {
2645+ ok : true ,
2646+ status : 200 ,
2647+ json : async ( ) => ( {
2648+ issuer : 'https://server.example.com' ,
2649+ authorization_endpoint : 'https://server.example.com/authorize' ,
2650+ token_endpoint : 'https://server.example.com/token' ,
2651+ registration_endpoint : 'https://server.example.com/register' ,
2652+ response_types_supported : [ 'code' ] ,
2653+ code_challenge_methods_supported : [ 'S256' ] ,
2654+ client_id_metadata_document_supported : true
2655+ } )
2656+ } ) ;
2657+
2658+ // Mock DCR response
2659+ mockFetch . mockResolvedValueOnce ( {
2660+ ok : true ,
2661+ status : 201 ,
2662+ json : async ( ) => ( {
2663+ client_id : 'generated-uuid' ,
2664+ client_secret : 'generated-secret' ,
2665+ redirect_uris : [ 'http://localhost:3000/callback' ]
2666+ } )
2667+ } ) ;
2668+
2669+ await auth ( providerWithInvalidUri , {
2670+ serverUrl : 'https://server.example.com'
2671+ } ) ;
2672+
2673+ // Should fall back to DCR despite server supporting URL-based client IDs
2674+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2675+ client_id : 'generated-uuid' ,
2676+ client_secret : 'generated-secret' ,
2677+ redirect_uris : [ 'http://localhost:3000/callback' ]
2678+ } ) ;
2679+ } ) ;
2680+
2681+ it ( 'falls back to DCR when client_uri is missing' , async ( ) => {
2682+ const providerWithoutUri = {
2683+ ...mockProvider ,
2684+ get clientMetadata ( ) {
2685+ return {
2686+ redirect_uris : [ 'http://localhost:3000/callback' ] ,
2687+ client_name : 'Test Client'
2688+ // No client_uri
2689+ } ;
2690+ }
2691+ } ;
2692+
2693+ // Mock protected resource metadata discovery (404 to skip)
2694+ mockFetch . mockResolvedValueOnce ( {
2695+ ok : false ,
2696+ status : 404 ,
2697+ json : async ( ) => ( { } )
2698+ } ) ;
2699+
2700+ // Mock authorization server metadata discovery with SEP-991 support
2701+ mockFetch . mockResolvedValueOnce ( {
2702+ ok : true ,
2703+ status : 200 ,
2704+ json : async ( ) => ( {
2705+ issuer : 'https://server.example.com' ,
2706+ authorization_endpoint : 'https://server.example.com/authorize' ,
2707+ token_endpoint : 'https://server.example.com/token' ,
2708+ registration_endpoint : 'https://server.example.com/register' ,
2709+ response_types_supported : [ 'code' ] ,
2710+ code_challenge_methods_supported : [ 'S256' ] ,
2711+ client_id_metadata_document_supported : true
2712+ } )
2713+ } ) ;
2714+
2715+ // Mock DCR response
2716+ mockFetch . mockResolvedValueOnce ( {
2717+ ok : true ,
2718+ status : 201 ,
2719+ json : async ( ) => ( {
2720+ client_id : 'generated-uuid' ,
2721+ client_secret : 'generated-secret' ,
2722+ redirect_uris : [ 'http://localhost:3000/callback' ]
2723+ } )
2724+ } ) ;
2725+
2726+ await auth ( providerWithoutUri , {
2727+ serverUrl : 'https://server.example.com'
2728+ } ) ;
2729+
2730+ // Should fall back to DCR
2731+ expect ( mockProvider . saveClientInformation ) . toHaveBeenCalledWith ( {
2732+ client_id : 'generated-uuid' ,
2733+ client_secret : 'generated-secret' ,
2734+ redirect_uris : [ 'http://localhost:3000/callback' ]
2735+ } ) ;
2736+ } ) ;
2737+ } ) ;
24792738} ) ;
0 commit comments