Skip to content

Commit e092519

Browse files
committed
feat: url based client metadata registration (SEP 991)
1 parent fe7a938 commit e092519

File tree

6 files changed

+383
-25
lines changed

6 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

@@ -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

Comments
 (0)