This commit is contained in:
parent
f822c1255e
commit
7b5bfcee52
5 changed files with 2311 additions and 33 deletions
218
src/examples/server/demoInMemoryOAuthProvider.test.ts
Normal file
218
src/examples/server/demoInMemoryOAuthProvider.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
|
||||
import { InvalidTargetError } from '../../server/auth/errors.js';
|
||||
import { OAuthClientInformationFull } from '../../shared/auth.js';
|
||||
import { Response } from 'express';
|
||||
|
||||
describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => {
|
||||
let provider: DemoInMemoryAuthProvider;
|
||||
let clientsStore: DemoInMemoryClientsStore;
|
||||
let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] };
|
||||
let mockResponse: Partial<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new DemoInMemoryAuthProvider();
|
||||
clientsStore = provider.clientsStore as DemoInMemoryClientsStore;
|
||||
|
||||
mockClient = {
|
||||
client_id: 'test-client',
|
||||
client_name: 'Test Client',
|
||||
client_uri: 'https://example.com',
|
||||
redirect_uris: ['https://example.com/callback'],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
scope: 'mcp:tools',
|
||||
token_endpoint_auth_method: 'none',
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
redirect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Authorization with resource parameter', () => {
|
||||
it('should allow authorization when no resources are configured', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow authorization when resource is in allowed list', async () => {
|
||||
mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2'];
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject authorization when resource is not in allowed list', async () => {
|
||||
mockClient.allowed_resources = ['https://api.example.com/v1'];
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.forbidden.com',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token exchange with resource validation', () => {
|
||||
let authorizationCode: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
// Authorize without resource first
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
// Extract authorization code from redirect call
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
authorizationCode = url.searchParams.get('code')!;
|
||||
});
|
||||
|
||||
it('should exchange code successfully when resource matches', async () => {
|
||||
// First authorize with a specific resource
|
||||
mockResponse.redirect = jest.fn();
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const codeWithResource = url.searchParams.get('code')!;
|
||||
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
codeWithResource,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.example.com/v1'
|
||||
);
|
||||
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens.token_type).toBe('bearer');
|
||||
});
|
||||
|
||||
it('should reject token exchange when resource does not match', async () => {
|
||||
// First authorize with a specific resource
|
||||
mockResponse.redirect = jest.fn();
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const codeWithResource = url.searchParams.get('code')!;
|
||||
|
||||
await expect(provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
codeWithResource,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.different.com'
|
||||
)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it('should reject token exchange when resource was not authorized but is requested', async () => {
|
||||
await expect(provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
authorizationCode,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.example.com/v1'
|
||||
)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it('should store resource in token data', async () => {
|
||||
// Authorize with resource
|
||||
mockResponse.redirect = jest.fn();
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const codeWithResource = url.searchParams.get('code')!;
|
||||
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
codeWithResource,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.example.com/v1'
|
||||
);
|
||||
|
||||
// Verify token has resource information
|
||||
const tokenDetails = provider.getTokenDetails(tokens.access_token);
|
||||
expect(tokenDetails?.resource).toBe('https://api.example.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh token with resource validation', () => {
|
||||
it('should validate resource when exchanging refresh token', async () => {
|
||||
mockClient.allowed_resources = ['https://api.example.com/v1'];
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.exchangeRefreshToken(
|
||||
mockClient,
|
||||
'refresh-token',
|
||||
undefined,
|
||||
'https://api.forbidden.com'
|
||||
)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Allowed resources management', () => {
|
||||
it('should update allowed resources for a client', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
// Initially no resources configured
|
||||
await expect(provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://any.api.com',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
|
||||
// Set allowed resources
|
||||
clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']);
|
||||
|
||||
// Now should reject unauthorized resources
|
||||
await expect(provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://any.api.com',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +1,38 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js';
|
||||
import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js';
|
||||
import express, { Request, Response } from "express";
|
||||
import { AuthInfo } from 'src/server/auth/types.js';
|
||||
import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js';
|
||||
import { AuthInfo } from '../../server/auth/types.js';
|
||||
import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js';
|
||||
import { InvalidTargetError } from '../../server/auth/errors.js';
|
||||
|
||||
|
||||
interface ExtendedClientInformation extends OAuthClientInformationFull {
|
||||
allowed_resources?: string[];
|
||||
}
|
||||
|
||||
export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
|
||||
private clients = new Map<string, OAuthClientInformationFull>();
|
||||
private clients = new Map<string, ExtendedClientInformation>();
|
||||
|
||||
async getClient(clientId: string) {
|
||||
return this.clients.get(clientId);
|
||||
}
|
||||
|
||||
async registerClient(clientMetadata: OAuthClientInformationFull) {
|
||||
async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) {
|
||||
this.clients.set(clientMetadata.client_id, clientMetadata);
|
||||
return clientMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo method to set allowed resources for a client
|
||||
*/
|
||||
setAllowedResources(clientId: string, resources: string[]) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.allowed_resources = resources;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -28,18 +43,28 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore {
|
|||
* - Persistent token storage
|
||||
* - Rate limiting
|
||||
*/
|
||||
interface ExtendedAuthInfo extends AuthInfo {
|
||||
resource?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
||||
clientsStore = new DemoInMemoryClientsStore();
|
||||
private codes = new Map<string, {
|
||||
params: AuthorizationParams,
|
||||
client: OAuthClientInformationFull}>();
|
||||
private tokens = new Map<string, AuthInfo>();
|
||||
private tokens = new Map<string, ExtendedAuthInfo>();
|
||||
|
||||
async authorize(
|
||||
client: OAuthClientInformationFull,
|
||||
params: AuthorizationParams,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
// Validate resource parameter if provided
|
||||
if (params.resource) {
|
||||
await this.validateResource(client, params.resource);
|
||||
}
|
||||
|
||||
const code = randomUUID();
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
|
|
@ -78,7 +103,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
authorizationCode: string,
|
||||
// Note: code verifier is checked in token.ts by default
|
||||
// it's unused here for that reason.
|
||||
_codeVerifier?: string
|
||||
_codeVerifier?: string,
|
||||
_redirectUri?: string,
|
||||
resource?: string
|
||||
): Promise<OAuthTokens> {
|
||||
const codeData = this.codes.get(authorizationCode);
|
||||
if (!codeData) {
|
||||
|
|
@ -89,15 +116,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`);
|
||||
}
|
||||
|
||||
// Validate that the resource matches what was authorized
|
||||
if (resource !== codeData.params.resource) {
|
||||
throw new InvalidTargetError('Resource parameter does not match the authorized resource');
|
||||
}
|
||||
|
||||
// If resource was specified during authorization, validate it's still allowed
|
||||
if (codeData.params.resource) {
|
||||
await this.validateResource(client, codeData.params.resource);
|
||||
}
|
||||
|
||||
this.codes.delete(authorizationCode);
|
||||
const token = randomUUID();
|
||||
|
||||
const tokenData = {
|
||||
const tokenData: ExtendedAuthInfo = {
|
||||
token,
|
||||
clientId: client.client_id,
|
||||
scopes: codeData.params.scopes || [],
|
||||
expiresAt: Date.now() + 3600000, // 1 hour
|
||||
type: 'access'
|
||||
type: 'access',
|
||||
resource: codeData.params.resource
|
||||
};
|
||||
|
||||
this.tokens.set(token, tokenData);
|
||||
|
|
@ -111,11 +149,16 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
}
|
||||
|
||||
async exchangeRefreshToken(
|
||||
_client: OAuthClientInformationFull,
|
||||
client: OAuthClientInformationFull,
|
||||
_refreshToken: string,
|
||||
_scopes?: string[]
|
||||
_scopes?: string[],
|
||||
resource?: string
|
||||
): Promise<OAuthTokens> {
|
||||
throw new Error('Not implemented for example demo');
|
||||
// Validate resource parameter if provided
|
||||
if (resource) {
|
||||
await this.validateResource(client, resource);
|
||||
}
|
||||
throw new Error('Refresh tokens not implemented for example demo');
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
|
|
@ -131,6 +174,33 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider {
|
|||
expiresAt: Math.floor(tokenData.expiresAt / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the client is allowed to access the requested resource.
|
||||
* In a real implementation, this would check against a database or configuration.
|
||||
*/
|
||||
private async validateResource(client: OAuthClientInformationFull, resource: string): Promise<void> {
|
||||
const extendedClient = client as ExtendedClientInformation;
|
||||
|
||||
// If no resources are configured, allow any resource (for demo purposes)
|
||||
if (!extendedClient.allowed_resources) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the requested resource is in the allowed list
|
||||
if (!extendedClient.allowed_resources.includes(resource)) {
|
||||
throw new InvalidTargetError(
|
||||
`Client is not authorized to access resource: ${resource}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token details including resource information (for demo introspection endpoint)
|
||||
*/
|
||||
getTokenDetails(token: string): ExtendedAuthInfo | undefined {
|
||||
return this.tokens.get(token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -164,11 +234,14 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => {
|
|||
}
|
||||
|
||||
const tokenInfo = await provider.verifyAccessToken(token);
|
||||
// For demo purposes, we'll add a method to get token details
|
||||
const tokenDetails = provider.getTokenDetails(token);
|
||||
res.json({
|
||||
active: true,
|
||||
client_id: tokenInfo.clientId,
|
||||
scope: tokenInfo.scopes.join(' '),
|
||||
exp: tokenInfo.expiresAt
|
||||
exp: tokenInfo.expiresAt,
|
||||
...(tokenDetails?.resource && { aud: tokenDetails.resource })
|
||||
});
|
||||
return
|
||||
} catch (error) {
|
||||
|
|
|
|||
152
src/examples/server/resourceValidationExample.ts
Normal file
152
src/examples/server/resourceValidationExample.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* 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);
|
||||
103
src/examples/server/serverUrlValidationExample.ts
Normal file
103
src/examples/server/serverUrlValidationExample.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* 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 };
|
||||
85
src/examples/server/strictModeExample.ts
Normal file
85
src/examples/server/strictModeExample.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* 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 };
|
||||
Loading…
Reference in a new issue