cleanup auth-utils and remove example files

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2025-06-16 20:05:02 +01:00
commit 1f4e42c09e
No known key found for this signature in database
10 changed files with 29 additions and 389 deletions

View file

@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
import { canonicalizeResourceUri } from "../shared/auth-utils.js";
import { resourceUrlFromServerUrl } from "../shared/auth-utils.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
@ -105,7 +105,7 @@ export async function auth(
// Remove fragment from resource parameter if provided
let canonicalResource: string | undefined;
if (resource) {
canonicalResource = canonicalizeResourceUri(resource);
canonicalResource = resourceUrlFromServerUrl(resource);
}
let authorizationServerUrl = serverUrl;

View file

@ -2,7 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"
import { Transport } from "../shared/transport.js";
import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { extractCanonicalResourceUri } from "../shared/auth-utils.js";
import { extractResourceUri } from "../shared/auth-utils.js";
export class SseError extends Error {
constructor(
@ -90,7 +90,7 @@ export class SSEClientTransport implements Transport {
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
} catch (error) {
this.onerror?.(error as Error);
@ -210,7 +210,7 @@ export class SSEClientTransport implements Transport {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
@ -249,7 +249,7 @@ export class SSEClientTransport implements Transport {
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();

View file

@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js";
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js";
import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
import { extractCanonicalResourceUri } from "../shared/auth-utils.js";
import { extractResourceUri } from "../shared/auth-utils.js";
// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
@ -153,7 +153,7 @@ export class StreamableHTTPClientTransport implements Transport {
result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
} catch (error) {
this.onerror?.(error as Error);
@ -371,7 +371,7 @@ export class StreamableHTTPClientTransport implements Transport {
serverUrl: this._url,
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError("Failed to authorize");
@ -423,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport {
const result = await auth(this._authProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
resource: extractCanonicalResourceUri(this._url)
resource: extractResourceUri(this._url)
});
if (result !== "AUTHORIZED") {
throw new UnauthorizedError();

View file

@ -1,152 +0,0 @@
/**
* Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0
*
* This example shows how to configure and use resource validation in the MCP OAuth flow.
* RFC 8707 allows OAuth clients to specify which protected resource they intend to access,
* and enables authorization servers to restrict tokens to specific resources.
*/
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthClientInformationFull } from '../../shared/auth.js';
async function demonstrateResourceValidation() {
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
const clientsStore = provider.clientsStore;
// Register a client with specific allowed resources
const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = {
client_id: 'resource-aware-client',
client_name: 'Resource-Aware MCP Client',
client_uri: 'https://example.com',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'mcp:tools mcp:resources',
token_endpoint_auth_method: 'none',
// RFC 8707: Specify which resources this client can access
allowed_resources: [
'https://api.example.com/mcp/v1',
'https://api.example.com/mcp/v2',
'https://tools.example.com/mcp'
]
};
await clientsStore.registerClient(clientWithResources);
console.log('Registered client with allowed resources:', clientWithResources.allowed_resources);
// Example 1: Authorization request with valid resource
try {
const mockResponse = {
redirect: (url: string) => {
console.log('✅ Authorization successful, redirecting to:', url);
}
};
await provider.authorize(clientWithResources, {
codeChallenge: 'S256-challenge-here',
redirectUri: clientWithResources.redirect_uris[0],
resource: 'https://api.example.com/mcp/v1', // Valid resource
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('Authorization failed:', error);
}
// Example 2: Authorization request with invalid resource
try {
const mockResponse = {
redirect: (url: string) => {
console.log('Redirecting to:', url);
}
};
await provider.authorize(clientWithResources, {
codeChallenge: 'S256-challenge-here',
redirectUri: clientWithResources.redirect_uris[0],
resource: 'https://unauthorized.api.com/mcp', // Invalid resource
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('❌ Authorization failed as expected:', error instanceof Error ? error.message : String(error));
}
// Example 3: Client without resource restrictions
const openClient: OAuthClientInformationFull = {
client_id: 'open-client',
client_name: 'Open MCP Client',
client_uri: 'https://open.example.com',
redirect_uris: ['https://open.example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'mcp:tools',
token_endpoint_auth_method: 'none',
// No allowed_resources specified - can access any resource
};
await clientsStore.registerClient(openClient);
try {
const mockResponse = {
redirect: (url: string) => {
console.log('✅ Open client can access any resource, redirecting to:', url);
}
};
await provider.authorize(openClient, {
codeChallenge: 'S256-challenge-here',
redirectUri: openClient.redirect_uris[0],
resource: 'https://any.api.com/mcp', // Any resource is allowed
scopes: ['mcp:tools']
}, mockResponse as any);
} catch (error) {
console.error('Authorization failed:', error);
}
// Example 4: Token introspection with resource information
// First, simulate getting a token with resource restriction
const mockAuthCode = 'demo-auth-code';
const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode);
if (mockTokenResponse) {
const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token);
console.log('\n📋 Token introspection result:');
console.log('- Client ID:', tokenDetails?.clientId);
console.log('- Scopes:', tokenDetails?.scopes);
console.log('- Resource (aud):', tokenDetails?.resource);
console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction');
}
}
async function simulateTokenExchange(
provider: DemoInMemoryAuthProvider,
client: OAuthClientInformationFull,
authCode: string
) {
// This is a simplified simulation - in real usage, the auth code would come from the authorization flow
console.log('\n🔄 Simulating token exchange with resource validation...');
// Note: In a real implementation, you would:
// 1. Get the authorization code from the redirect after authorize()
// 2. Exchange it for tokens using the token endpoint
// 3. The resource parameter in the token request must match the one from authorization
return {
access_token: 'demo-token-with-resource',
token_type: 'bearer',
expires_in: 3600,
scope: 'mcp:tools'
};
}
// Usage instructions
console.log('🚀 RFC 8707 Resource Indicators Demo\n');
console.log('This example demonstrates how to:');
console.log('1. Register clients with allowed resources');
console.log('2. Validate resource parameters during authorization');
console.log('3. Include resource information in tokens');
console.log('4. Handle invalid_target errors\n');
// Run the demonstration
demonstrateResourceValidation().catch(console.error);

View file

@ -1,103 +0,0 @@
/**
* Example demonstrating server URL validation for RFC 8707 compliance
*
* This example shows how to configure an OAuth server to validate that
* the resource parameter in requests matches the server's own URL,
* ensuring tokens are only issued for this specific server.
*/
import express from 'express';
import { authorizationHandler } from '../../server/auth/handlers/authorize.js';
import { tokenHandler } from '../../server/auth/handlers/token.js';
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthServerConfig } from '../../server/auth/types.js';
// The canonical URL where this MCP server is accessible
const SERVER_URL = 'https://api.example.com/mcp';
// Configuration that validates resource matches this server
const serverValidationConfig: OAuthServerConfig = {
// The server's canonical URL (without fragment)
serverUrl: SERVER_URL,
// Enable validation that resource parameter matches serverUrl
// This also makes the resource parameter required
validateResourceMatchesServer: true
};
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
// Create Express app
const app = express();
// Configure authorization endpoint with server URL validation
app.use('/oauth/authorize', authorizationHandler({
provider,
config: serverValidationConfig
}));
// Configure token endpoint with server URL validation
app.use('/oauth/token', tokenHandler({
provider,
config: serverValidationConfig
}));
// Example scenarios
console.log('🔐 Server URL Validation Example\n');
console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`);
console.log('✅ Valid request examples:');
console.log(`1. Resource matches server URL:
GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&...
Result: Authorization proceeds normally\n`);
console.log(`2. Resource with query parameters (exact match required):
GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&...
Result: Rejected - resource must match exactly\n`);
console.log('❌ Invalid request examples:');
console.log(`1. Different domain:
GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&...
Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`);
console.log(`2. Different path:
GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&...
Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`);
console.log(`3. Missing resource (with validateResourceMatchesServer: true):
GET /oauth/authorize?client_id=my-client&...
Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`);
console.log('🛡️ Security Benefits:');
console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers');
console.log('2. Ensures all tokens are scoped to this specific MCP server');
console.log('3. Provides clear audit trail of resource access attempts');
console.log('4. Protects against malicious clients trying to obtain tokens for other services\n');
console.log('📝 Configuration Notes:');
console.log('- serverUrl should be the exact URL clients use to connect');
console.log('- Fragments are automatically removed from both serverUrl and resource');
console.log('- When validateResourceMatchesServer is true, resource parameter is required');
console.log('- Validation ensures exact match between resource and serverUrl\n');
console.log('🔧 Implementation Tips:');
console.log('1. Set serverUrl from environment variable for different deployments:');
console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n');
console.log('2. For development environments, you might disable validation:');
console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n');
console.log('3. Consider logging failed validation attempts for security monitoring:');
console.log(' Monitor logs for patterns of invalid_target errors\n');
// Example of dynamic configuration based on environment
const productionConfig: OAuthServerConfig = {
serverUrl: process.env.MCP_SERVER_URL || SERVER_URL,
validateResourceMatchesServer: process.env.NODE_ENV === 'production'
};
console.log('🚀 Production configuration example:');
console.log(JSON.stringify(productionConfig, null, 2));
export { app, provider, serverValidationConfig };

View file

@ -1,85 +0,0 @@
/**
* Example demonstrating strict RFC 8707 enforcement mode
*
* This example shows how to configure an OAuth server that requires
* all requests to include a resource parameter, ensuring maximum
* security against token confusion attacks.
*/
import express from 'express';
import { authorizationHandler } from '../../server/auth/handlers/authorize.js';
import { tokenHandler } from '../../server/auth/handlers/token.js';
import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js';
import { OAuthServerConfig } from '../../server/auth/types.js';
// Strict mode configuration - validates resource matches server URL
const SERVER_URL = 'https://api.example.com/mcp';
const strictConfig: OAuthServerConfig = {
serverUrl: SERVER_URL,
validateResourceMatchesServer: true
};
// Create the OAuth provider
const provider = new DemoInMemoryAuthProvider();
// Create Express app
const app = express();
// Configure authorization endpoint with strict mode
app.use('/oauth/authorize', authorizationHandler({
provider,
config: strictConfig,
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10 // limit each IP to 10 requests per window
}
}));
// Configure token endpoint with strict mode
app.use('/oauth/token', tokenHandler({
provider,
config: strictConfig,
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20 // limit each IP to 20 requests per window
}
}));
// Example of what happens with different requests:
console.log('🔒 Strict RFC 8707 Mode Example\n');
console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`);
console.log('✅ Valid request example:');
console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`);
console.log('❌ Invalid request examples:');
console.log('1. Missing resource:');
console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256');
console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n');
console.log('2. Wrong resource:');
console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp');
console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`);
console.log('📋 Benefits of server URL validation:');
console.log('1. Prevents token confusion attacks - tokens can only be issued for this server');
console.log('2. Ensures all tokens are properly scoped to this specific MCP server');
console.log('3. No accidental token leakage to other services');
console.log('4. Clear security boundary enforcement\n');
console.log('⚠️ Migration considerations:');
console.log('1. Server must know its canonical URL (configure via environment variable)');
console.log('2. All clients must send the exact matching resource parameter');
console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)');
console.log('4. Monitor logs to track adoption before enabling validation\n');
// Example middleware to track resource parameter usage
app.use((req, res, next) => {
if (req.path.includes('/oauth/')) {
const hasResource = req.query.resource || req.body?.resource;
console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`);
}
next();
});
export { app, provider, strictConfig };

View file

@ -14,6 +14,7 @@ import {
OAuthError
} from "../errors.js";
import { OAuthServerConfig } from "../types.js";
import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js";
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
@ -137,7 +138,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig, con
}
// Remove fragment from server URL if present (though it shouldn't have one)
const canonicalServerUrl = config.serverUrl!.split('#')[0];
const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!);
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(

View file

@ -16,6 +16,7 @@ import {
InvalidTargetError
} from "../errors.js";
import { OAuthServerConfig } from "../types.js";
import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js";
export type TokenHandlerOptions = {
provider: OAuthServerProvider;
@ -110,7 +111,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T
throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled");
}
const canonicalServerUrl = config.serverUrl!.split('#')[0];
const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!);
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(
@ -161,7 +162,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T
throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled");
}
const canonicalServerUrl = config.serverUrl!.split('#')[0];
const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!);
if (resource !== canonicalServerUrl) {
throw new InvalidTargetError(

View file

@ -1,4 +1,4 @@
import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js';
import { validateResourceUri, extractResourceUri, resourceUrlFromServerUrl } from './auth-utils.js';
describe('auth-utils', () => {
describe('resourceUrlFromServerUrl', () => {
@ -28,17 +28,6 @@ describe('auth-utils', () => {
});
});
describe('canonicalizeResourceUri', () => {
it('should remove fragments', () => {
expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path');
});
it('should keep everything else unchanged', () => {
expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path');
expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
});
});
describe('validateResourceUri', () => {
it('should accept valid resource URIs without fragments', () => {
@ -64,34 +53,34 @@ describe('auth-utils', () => {
});
});
describe('extractCanonicalResourceUri', () => {
describe('extractResourceUri', () => {
it('should remove fragments from URLs', () => {
expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path');
expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1');
expect(extractResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path');
expect(extractResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1');
});
it('should handle URL object', () => {
const url = new URL('https://example.com:8443/path?query=1#fragment');
expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1');
expect(extractResourceUri(url)).toBe('https://example.com:8443/path?query=1');
});
it('should keep everything else unchanged', () => {
// Preserves case
expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path');
expect(extractResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path');
// Preserves all ports
expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path');
expect(extractResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path');
expect(extractResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path');
// Preserves query parameters
expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
expect(extractResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1');
// Preserves trailing slashes
expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/');
expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/');
expect(extractResourceUri('https://example.com/')).toBe('https://example.com/');
expect(extractResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/');
});
it('should distinguish between different paths on same domain', () => {
// This is the key test for the security concern mentioned
const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1');
const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2');
const app1 = extractResourceUri('https://api.example.com/mcp-server-1');
const app2 = extractResourceUri('https://api.example.com/mcp-server-2');
expect(app1).not.toBe(app2);
expect(app1).toBe('https://api.example.com/mcp-server-1');
expect(app2).toBe('https://api.example.com/mcp-server-2');

View file

@ -23,14 +23,6 @@ export function validateResourceUri(resourceUri: string): void {
}
}
/**
* Removes fragment from URI to make it RFC 8707 compliant.
* @deprecated Use resourceUrlFromServerUrl instead
*/
export function canonicalizeResourceUri(resourceUri: string): string {
return resourceUrlFromServerUrl(resourceUri);
}
/**
* Extracts resource URI from server URL by removing fragment.
* @param serverUrl The server URL to extract from
@ -38,7 +30,4 @@ export function canonicalizeResourceUri(resourceUri: string): string {
*/
export function extractResourceUri(serverUrl: string | URL): string {
return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href);
}
// Backward compatibility alias
export const extractCanonicalResourceUri = extractResourceUri;
}