For dynamic client registration - use registration endpoint in config if available instead of performing OAuth discovery again (#9231)

This commit is contained in:
jleong-stripe
2025-09-24 16:53:42 -04:00
committed by GitHub
parent 3660d4ecc0
commit e0ba7e4ffb
3 changed files with 221 additions and 27 deletions

View File

@@ -39,6 +39,10 @@ import type {
import { MCPOAuthProvider } from './oauth-provider.js';
import type { OAuthToken } from './token-storage/types.js';
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
import type {
OAuthAuthorizationServerMetadata,
OAuthProtectedResourceMetadata,
} from './oauth-utils.js';
// Mock fetch globally
const mockFetch = vi.fn();
@@ -329,8 +333,11 @@ describe('MCPOAuthProvider', () => {
);
});
it('should perform dynamic client registration when no client ID provided', async () => {
const configWithoutClient = { ...mockConfig };
it('should perform dynamic client registration when no client ID is provided but registration URL is provided', async () => {
const configWithoutClient: MCPOAuthConfig = {
...mockConfig,
registrationUrl: 'https://auth.example.com/register',
};
delete configWithoutClient.clientId;
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
@@ -342,7 +349,80 @@ describe('MCPOAuthProvider', () => {
token_endpoint_auth_method: 'none',
};
const mockAuthServerMetadata = {
mockFetch.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockRegistrationResponse),
json: mockRegistrationResponse,
}),
);
// Setup callback handler
let callbackHandler: unknown;
vi.mocked(http.createServer).mockImplementation((handler) => {
callbackHandler = handler;
return mockHttpServer as unknown as http.Server;
});
mockHttpServer.listen.mockImplementation((port, callback) => {
callback?.();
setTimeout(() => {
const mockReq = {
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
};
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
};
(callbackHandler as (req: unknown, res: unknown) => void)(
mockReq,
mockRes,
);
}, 10);
});
// Mock token exchange
mockFetch.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const authProvider = new MCPOAuthProvider();
const result = await authProvider.authenticate(
'test-server',
configWithoutClient,
);
expect(result).toBeDefined();
expect(mockFetch).toHaveBeenCalledWith(
'https://auth.example.com/register',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
});
it('should perform OAuth discovery and dynamic client registration when no client ID or registration URL provided', async () => {
const configWithoutClient: MCPOAuthConfig = { ...mockConfig };
delete configWithoutClient.clientId;
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
client_id: 'dynamic_client_id',
client_secret: 'dynamic_client_secret',
redirect_uris: ['http://localhost:7777/oauth/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
@@ -416,6 +496,117 @@ describe('MCPOAuthProvider', () => {
);
});
it('should perform OAuth discovery once and dynamic client registration when no client ID, authorization URL or registration URL provided', async () => {
const configWithoutClientAndAuthorizationUrl: MCPOAuthConfig = {
...mockConfig,
};
delete configWithoutClientAndAuthorizationUrl.clientId;
delete configWithoutClientAndAuthorizationUrl.authorizationUrl;
const mockResourceMetadata: OAuthProtectedResourceMetadata = {
resource: 'https://api.example.com',
authorization_servers: ['https://auth.example.com'],
};
const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
};
const mockRegistrationResponse: OAuthClientRegistrationResponse = {
client_id: 'dynamic_client_id',
client_secret: 'dynamic_client_secret',
redirect_uris: ['http://localhost:7777/oauth/callback'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
};
mockFetch
.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
}),
)
.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockResourceMetadata),
json: mockResourceMetadata,
}),
)
.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockAuthServerMetadata),
json: mockAuthServerMetadata,
}),
)
.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockRegistrationResponse),
json: mockRegistrationResponse,
}),
);
// Setup callback handler
let callbackHandler: unknown;
vi.mocked(http.createServer).mockImplementation((handler) => {
callbackHandler = handler;
return mockHttpServer as unknown as http.Server;
});
mockHttpServer.listen.mockImplementation((port, callback) => {
callback?.();
setTimeout(() => {
const mockReq = {
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
};
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
};
(callbackHandler as (req: unknown, res: unknown) => void)(
mockReq,
mockRes,
);
}, 10);
});
// Mock token exchange
mockFetch.mockResolvedValueOnce(
createMockResponse({
ok: true,
contentType: 'application/json',
text: JSON.stringify(mockTokenResponse),
json: mockTokenResponse,
}),
);
const authProvider = new MCPOAuthProvider();
const result = await authProvider.authenticate(
'test-server',
configWithoutClientAndAuthorizationUrl,
'https://api.example.com',
);
expect(result).toBeDefined();
expect(mockFetch).toHaveBeenCalledWith(
'https://auth.example.com/register',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
});
it('should handle OAuth callback errors', async () => {
let callbackHandler: unknown;
vi.mocked(http.createServer).mockImplementation((handler) => {

View File

@@ -29,6 +29,7 @@ export interface MCPOAuthConfig {
audiences?: string[];
redirectUri?: string;
tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
registrationUrl?: string;
}
/**
@@ -651,6 +652,7 @@ export class MCPOAuthProvider {
authorizationUrl: discoveredConfig.authorizationUrl,
tokenUrl: discoveredConfig.tokenUrl,
scopes: discoveredConfig.scopes || config.scopes || [],
registrationUrl: discoveredConfig.registrationUrl,
// Preserve existing client credentials
clientId: config.clientId,
clientSecret: config.clientSecret,
@@ -665,38 +667,38 @@ export class MCPOAuthProvider {
// If no client ID is provided, try dynamic client registration
if (!config.clientId) {
// Extract server URL from authorization URL
if (!config.authorizationUrl) {
throw new Error(
'Cannot perform dynamic registration without authorization URL',
);
}
let registrationUrl = config.registrationUrl;
const authUrl = new URL(config.authorizationUrl);
const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
// If no registration URL was previously discovered, try to discover it
if (!registrationUrl) {
// Extract server URL from authorization URL
if (!config.authorizationUrl) {
throw new Error(
'Cannot perform dynamic registration without authorization URL',
);
}
console.debug('→ Attempting dynamic client registration...');
const authUrl = new URL(config.authorizationUrl);
const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
// Get the authorization server metadata for registration
const authServerMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
serverUrl,
).toString();
console.debug('→ Attempting dynamic client registration...');
const authServerMetadata =
await OAuthUtils.fetchAuthorizationServerMetadata(
authServerMetadataUrl,
);
if (!authServerMetadata) {
throw new Error(
'Failed to fetch authorization server metadata for client registration',
);
// Get the authorization server metadata for registration
const authServerMetadata =
await OAuthUtils.discoverAuthorizationServerMetadata(serverUrl);
if (!authServerMetadata) {
throw new Error(
'Failed to fetch authorization server metadata for client registration',
);
}
registrationUrl = authServerMetadata.registration_endpoint;
}
// Register client if registration endpoint is available
if (authServerMetadata.registration_endpoint) {
if (registrationUrl) {
const clientRegistration = await this.registerClient(
authServerMetadata.registration_endpoint,
registrationUrl,
config,
);

View File

@@ -137,6 +137,7 @@ export class OAuthUtils {
authorizationUrl: metadata.authorization_endpoint,
tokenUrl: metadata.token_endpoint,
scopes: metadata.scopes_supported || [],
registrationUrl: metadata.registration_endpoint,
};
}