Skip to content

Commit c58e6a2

Browse files
committed
feat: url based client metadata registration (SEP 991)
1 parent 2da89db commit c58e6a2

File tree

3 files changed

+301
-13
lines changed

3 files changed

+301
-13
lines changed

src/client/auth.test.ts

Lines changed: 260 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
extractWWWAuthenticateParams,
1212
auth,
1313
type OAuthClientProvider,
14-
selectClientAuthMethod
14+
selectClientAuthMethod,
15+
isHttpsUrl
1516
} from './auth.js';
1617
import { ServerError } from '../server/auth/errors.js';
1718
import { 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
});

src/client/auth.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,18 +378,31 @@ async function authInternal(
378378
throw new Error('Existing OAuth client information is required when exchanging an authorization code');
379379
}
380380

381-
if (!provider.saveClientInformation) {
382-
throw new Error('OAuth client information must be saveable for dynamic registration');
383-
}
381+
const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true;
382+
const clientUri = provider.clientMetadata.client_uri;
383+
const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientUri && isHttpsUrl(clientUri);
384+
385+
if (shouldUseUrlBasedClientId) {
386+
// SEP-991: URL-based Client IDs
387+
clientInformation = {
388+
client_id: clientUri
389+
};
390+
await provider.saveClientInformation?.(clientInformation);
391+
} else {
392+
// Fallback to dynamic registration
393+
if (!provider.saveClientInformation) {
394+
throw new Error('OAuth client information must be saveable for dynamic registration');
395+
}
384396

385-
const fullInformation = await registerClient(authorizationServerUrl, {
386-
metadata,
387-
clientMetadata: provider.clientMetadata,
388-
fetchFn
389-
});
397+
const fullInformation = await registerClient(authorizationServerUrl, {
398+
metadata,
399+
clientMetadata: provider.clientMetadata,
400+
fetchFn
401+
});
390402

391-
await provider.saveClientInformation(fullInformation);
392-
clientInformation = fullInformation;
403+
await provider.saveClientInformation(fullInformation);
404+
clientInformation = fullInformation;
405+
}
393406
}
394407

395408
// Exchange authorization code for tokens
@@ -455,6 +468,20 @@ async function authInternal(
455468
return 'REDIRECT';
456469
}
457470

471+
/**
472+
* SEP-991: URL-based Client IDs
473+
* Validate that the client_id is a valid URL with https scheme
474+
*/
475+
export function isHttpsUrl(value?: string): boolean {
476+
if (!value) return false;
477+
try {
478+
const url = new URL(value);
479+
return url.protocol === 'https:' && url.pathname !== '/';
480+
} catch {
481+
return false;
482+
}
483+
}
484+
458485
export async function selectResourceURL(
459486
serverUrl: string | URL,
460487
provider: OAuthClientProvider,

src/shared/auth.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export const OAuthMetadataSchema = z
6969
introspection_endpoint: z.string().optional(),
7070
introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(),
7171
introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
72-
code_challenge_methods_supported: z.array(z.string()).optional()
72+
code_challenge_methods_supported: z.array(z.string()).optional(),
73+
client_id_metadata_document_supported: z.boolean().optional()
7374
})
7475
.passthrough();
7576

@@ -113,7 +114,8 @@ export const OpenIdProviderMetadataSchema = z
113114
request_uri_parameter_supported: z.boolean().optional(),
114115
require_request_uri_registration: z.boolean().optional(),
115116
op_policy_uri: SafeUrlSchema.optional(),
116-
op_tos_uri: SafeUrlSchema.optional()
117+
op_tos_uri: SafeUrlSchema.optional(),
118+
client_id_metadata_document_supported: z.boolean().optional()
117119
})
118120
.passthrough();
119121

0 commit comments

Comments
 (0)