Skip to content

Commit fdd0d28

Browse files
committed
feat: url based client metadata registration (SEP 991)
1 parent 5bcf53f commit fdd0d28

File tree

5 files changed

+383
-25
lines changed

5 files changed

+383
-25
lines changed

src/client/auth.test.ts

Lines changed: 305 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
1718
import { AuthorizationServerMetadata } from '../shared/auth.js';
1819
import { 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

Comments
 (0)