feat: url based client metadata registration (SEP 991) (#1127)

This commit is contained in:
Matt 2025-11-19 16:44:53 +00:00 • committed by GitHub
commit 7fca1f2b15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 383 additions and 25 deletions

View file

@ -11,9 +11,10 @@ import {
extractWWWAuthenticateParams,
auth,
type OAuthClientProvider,
selectClientAuthMethod
selectClientAuthMethod,
isHttpsUrl
} from './auth.js';
import { ServerError } from '../server/auth/errors.js';
import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js';
import { AuthorizationServerMetadata } from '../shared/auth.js';
import { expect, vi, type Mock } from 'vitest';
@ -2796,4 +2797,306 @@ describe('OAuth Authorization', () => {
});
});
});
describe('isHttpsUrl', () => {
it('returns true for valid HTTPS URL with path', () => {
expect(isHttpsUrl('https://example.com/client-metadata.json')).toBe(true);
});
it('returns true for HTTPS URL with query params', () => {
expect(isHttpsUrl('https://example.com/metadata?version=1')).toBe(true);
});
it('returns false for HTTPS URL without path', () => {
expect(isHttpsUrl('https://example.com')).toBe(false);
expect(isHttpsUrl('https://example.com/')).toBe(false);
});
it('returns false for HTTP URL', () => {
expect(isHttpsUrl('http://example.com/metadata')).toBe(false);
});
it('returns false for non-URL strings', () => {
expect(isHttpsUrl('not a url')).toBe(false);
});
it('returns false for undefined', () => {
expect(isHttpsUrl(undefined)).toBe(false);
});
it('returns false for empty string', () => {
expect(isHttpsUrl('')).toBe(false);
});
it('returns false for javascript: scheme', () => {
expect(isHttpsUrl('javascript:alert(1)')).toBe(false);
});
it('returns false for data: scheme', () => {
expect(isHttpsUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
});
});
describe('SEP-991: URL-based Client ID fallback logic', () => {
const validClientMetadata = {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client',
client_uri: 'https://example.com/client-metadata.json'
};
const mockProvider: OAuthClientProvider = {
get redirectUrl() {
return 'http://localhost:3000/callback';
},
clientMetadataUrl: 'https://example.com/client-metadata.json',
get clientMetadata() {
return validClientMetadata;
},
clientInformation: vi.fn().mockResolvedValue(undefined),
saveClientInformation: vi.fn().mockResolvedValue(undefined),
tokens: vi.fn().mockResolvedValue(undefined),
saveTokens: vi.fn().mockResolvedValue(undefined),
redirectToAuthorization: vi.fn().mockResolvedValue(undefined),
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
codeVerifier: vi.fn().mockResolvedValue('verifier123')
};
beforeEach(() => {
vi.clearAllMocks();
});
it('uses URL-based client ID when server supports it', async () => {
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery to return support for URL-based client IDs
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true // SEP-991 support
})
});
await auth(mockProvider, {
serverUrl: 'https://server.example.com'
});
// Should save URL-based client info
expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
client_id: 'https://example.com/client-metadata.json'
});
});
it('falls back to DCR when server does not support URL-based client IDs', async () => {
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery without SEP-991 support
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
registration_endpoint: 'https://server.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
// No client_id_metadata_document_supported
})
});
// Mock DCR response
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
client_id: 'generated-uuid',
client_secret: 'generated-secret',
redirect_uris: ['http://localhost:3000/callback']
})
});
await auth(mockProvider, {
serverUrl: 'https://server.example.com'
});
// Should save DCR client info
expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
client_id: 'generated-uuid',
client_secret: 'generated-secret',
redirect_uris: ['http://localhost:3000/callback']
});
});
it('throws an error when clientMetadataUrl is not an HTTPS URL', async () => {
const providerWithInvalidUri = {
...mockProvider,
clientMetadataUrl: 'http://example.com/metadata'
};
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery with SEP-991 support
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
registration_endpoint: 'https://server.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true
})
});
await expect(
auth(providerWithInvalidUri, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
});
it('throws an error when clientMetadataUrl has root pathname', async () => {
const providerWithRootPathname = {
...mockProvider,
clientMetadataUrl: 'https://example.com/'
};
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery with SEP-991 support
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
registration_endpoint: 'https://server.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true
})
});
await expect(
auth(providerWithRootPathname, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
});
it('throws an error when clientMetadataUrl is not a valid URL', async () => {
const providerWithInvalidUrl = {
...mockProvider,
clientMetadataUrl: 'not-a-valid-url'
};
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery with SEP-991 support
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
registration_endpoint: 'https://server.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true
})
});
await expect(
auth(providerWithInvalidUrl, {
serverUrl: 'https://server.example.com'
})
).rejects.toThrow(InvalidClientMetadataError);
});
it('falls back to DCR when client_uri is missing', async () => {
const providerWithoutUri = {
...mockProvider,
clientMetadataUrl: undefined
};
// Mock protected resource metadata discovery (404 to skip)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({})
});
// Mock authorization server metadata discovery with SEP-991 support
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://server.example.com',
authorization_endpoint: 'https://server.example.com/authorize',
token_endpoint: 'https://server.example.com/token',
registration_endpoint: 'https://server.example.com/register',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true
})
});
// Mock DCR response
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => ({
client_id: 'generated-uuid',
client_secret: 'generated-secret',
redirect_uris: ['http://localhost:3000/callback']
})
});
await auth(providerWithoutUri, {
serverUrl: 'https://server.example.com'
});
// Should fall back to DCR
expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({
client_id: 'generated-uuid',
client_secret: 'generated-secret',
redirect_uris: ['http://localhost:3000/callback']
});
});
});
});

View file

@ -21,6 +21,7 @@ import {
import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js';
import {
InvalidClientError,
InvalidClientMetadataError,
InvalidGrantError,
OAUTH_ERRORS,
OAuthError,
@ -42,6 +43,11 @@ export interface OAuthClientProvider {
*/
get redirectUrl(): string | URL;
/**
* External URL the server should use to fetch client metadata document
*/
clientMetadataUrl?: string;
/**
* Metadata about this OAuth client.
*/
@ -379,18 +385,38 @@ async function authInternal(
throw new Error('Existing OAuth client information is required when exchanging an authorization code');
}
if (!provider.saveClientInformation) {
throw new Error('OAuth client information must be saveable for dynamic registration');
const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true;
const clientMetadataUrl = provider.clientMetadataUrl;
if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) {
throw new InvalidClientMetadataError(
`clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`
);
}
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
fetchFn
});
const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl;
await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
if (shouldUseUrlBasedClientId) {
// SEP-991: URL-based Client IDs
clientInformation = {
client_id: clientMetadataUrl
};
await provider.saveClientInformation?.(clientInformation);
} else {
// Fallback to dynamic registration
if (!provider.saveClientInformation) {
throw new Error('OAuth client information must be saveable for dynamic registration');
}
const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
fetchFn
});
await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
}
}
// Exchange authorization code for tokens
@ -456,6 +482,20 @@ async function authInternal(
return 'REDIRECT';
}
/**
* SEP-991: URL-based Client IDs
* Validate that the client_id is a valid URL with https scheme
*/
export function isHttpsUrl(value?: string): boolean {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === 'https:' && url.pathname !== '/';
} catch {
return false;
}
}
export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,