feat: url based client metadata registration (SEP 991) (#1127)
This commit is contained in:
parent
61db19537b
commit
7fca1f2b15
6 changed files with 383 additions and 25 deletions
|
|
@ -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']
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ npx tsx src/examples/client/simpleStreamableHttp.ts
|
|||
Example client with OAuth:
|
||||
|
||||
```bash
|
||||
npx tsx src/examples/client/simpleOAuthClient.js
|
||||
npx tsx src/examples/client/simpleOAuthClient.ts <optional-server-url> <optional-client-metadata-url>
|
||||
```
|
||||
|
||||
### Backwards Compatible Client
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ class InteractiveOAuthClient {
|
|||
output: process.stdout
|
||||
});
|
||||
|
||||
constructor(private serverUrl: string) {}
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private clientMetadataUrl?: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prompts user for input via readline
|
||||
|
|
@ -155,16 +158,20 @@ class InteractiveOAuthClient {
|
|||
redirect_uris: [CALLBACK_URL],
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
scope: 'mcp:tools'
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
};
|
||||
|
||||
console.log('🔐 Creating OAuth provider...');
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, (redirectUrl: URL) => {
|
||||
console.log(`📌 OAuth redirect handler called - opening browser`);
|
||||
console.log(`Opening browser to: ${redirectUrl.toString()}`);
|
||||
this.openBrowser(redirectUrl.toString());
|
||||
});
|
||||
const oauthProvider = new InMemoryOAuthClientProvider(
|
||||
CALLBACK_URL,
|
||||
clientMetadata,
|
||||
(redirectUrl: URL) => {
|
||||
console.log(`📌 OAuth redirect handler called - opening browser`);
|
||||
console.log(`Opening browser to: ${redirectUrl.toString()}`);
|
||||
this.openBrowser(redirectUrl.toString());
|
||||
},
|
||||
this.clientMetadataUrl
|
||||
);
|
||||
console.log('🔐 OAuth provider created');
|
||||
|
||||
console.log('👤 Creating MCP client...');
|
||||
|
|
@ -327,13 +334,18 @@ class InteractiveOAuthClient {
|
|||
* Main entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL;
|
||||
const args = process.argv.slice(2);
|
||||
const serverUrl = args[0] || DEFAULT_SERVER_URL;
|
||||
const clientMetadataUrl = args[1];
|
||||
|
||||
console.log('🚀 Simple MCP OAuth Client');
|
||||
console.log(`Connecting to: ${serverUrl}`);
|
||||
if (clientMetadataUrl) {
|
||||
console.log(`Client Metadata URL: ${clientMetadataUrl}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const client = new InteractiveOAuthClient(serverUrl);
|
||||
const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export class InMemoryOAuthClientProvider implements OAuthClientProvider {
|
|||
constructor(
|
||||
private readonly _redirectUrl: string | URL,
|
||||
private readonly _clientMetadata: OAuthClientMetadata,
|
||||
onRedirect?: (url: URL) => void
|
||||
onRedirect?: (url: URL) => void,
|
||||
public readonly clientMetadataUrl?: string
|
||||
) {
|
||||
this._onRedirect =
|
||||
onRedirect ||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ export const OAuthMetadataSchema = z
|
|||
introspection_endpoint: z.string().optional(),
|
||||
introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(),
|
||||
introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
|
||||
code_challenge_methods_supported: z.array(z.string()).optional()
|
||||
code_challenge_methods_supported: z.array(z.string()).optional(),
|
||||
client_id_metadata_document_supported: z.boolean().optional()
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
@ -113,7 +114,8 @@ export const OpenIdProviderMetadataSchema = z
|
|||
request_uri_parameter_supported: z.boolean().optional(),
|
||||
require_request_uri_registration: z.boolean().optional(),
|
||||
op_policy_uri: SafeUrlSchema.optional(),
|
||||
op_tos_uri: SafeUrlSchema.optional()
|
||||
op_tos_uri: SafeUrlSchema.optional(),
|
||||
client_id_metadata_document_supported: z.boolean().optional()
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue