Update demoInMemoryOAuthProvider.test.ts
This commit is contained in:
parent
551a43942e
commit
6e4fc52c7e
1 changed files with 78 additions and 267 deletions
|
|
@ -1,13 +1,12 @@
|
|||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore, DemoOAuthProviderConfig } from './demoInMemoryOAuthProvider.js';
|
||||
import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js';
|
||||
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js';
|
||||
import { OAuthClientInformationFull } from '../../shared/auth.js';
|
||||
import { Response } from 'express';
|
||||
|
||||
describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => {
|
||||
describe('DemoInMemoryOAuthProvider', () => {
|
||||
let provider: DemoInMemoryAuthProvider;
|
||||
let clientsStore: DemoInMemoryClientsStore;
|
||||
let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] };
|
||||
let mockClient: OAuthClientInformationFull;
|
||||
let mockResponse: Partial<Response>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -30,132 +29,104 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => {
|
|||
};
|
||||
});
|
||||
|
||||
describe('Authorization with resource parameter', () => {
|
||||
it('should allow authorization when no resources are configured', async () => {
|
||||
describe('Basic authorization flow', () => {
|
||||
it('should handle authorization successfully', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.authorize(mockClient, {
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
resource: new URL('https://api.example.com/v1'),
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
}, mockResponse as Response);
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
expect(redirectCall).toContain('code=');
|
||||
});
|
||||
|
||||
it('should handle authorization without resource', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
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'];
|
||||
it('should preserve state parameter', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.authorize(mockClient, {
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/v1',
|
||||
state: 'test-state',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
}, mockResponse as Response);
|
||||
|
||||
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);
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
expect(redirectCall).toContain('state=test-state');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token exchange with resource validation', () => {
|
||||
describe('Token exchange', () => {
|
||||
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')!;
|
||||
|
||||
it('should exchange authorization code for tokens', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
codeWithResource,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.example.com/v1'
|
||||
authorizationCode
|
||||
);
|
||||
|
||||
expect(tokens).toHaveProperty('access_token');
|
||||
expect(tokens.token_type).toBe('bearer');
|
||||
expect(tokens.expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it('should reject token exchange when resource does not match', async () => {
|
||||
// First authorize with a specific resource
|
||||
it('should reject invalid authorization code', async () => {
|
||||
await expect(provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
'invalid-code'
|
||||
)).rejects.toThrow('Invalid authorization code');
|
||||
});
|
||||
|
||||
it('should reject code from different client', async () => {
|
||||
const otherClient: OAuthClientInformationFull = {
|
||||
...mockClient,
|
||||
client_id: 'other-client'
|
||||
};
|
||||
|
||||
await clientsStore.registerClient(otherClient);
|
||||
|
||||
await expect(provider.exchangeAuthorizationCode(
|
||||
otherClient,
|
||||
authorizationCode
|
||||
)).rejects.toThrow('Authorization code was not issued to this client');
|
||||
});
|
||||
|
||||
it('should store resource in token when provided during authorization', async () => {
|
||||
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',
|
||||
resource: new URL('https://api.example.com/v1'),
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
|
|
@ -165,225 +136,65 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => {
|
|||
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
codeWithResource,
|
||||
undefined,
|
||||
undefined,
|
||||
'https://api.example.com/v1'
|
||||
codeWithResource
|
||||
);
|
||||
|
||||
// Verify token has resource information
|
||||
const tokenDetails = provider.getTokenDetails(tokens.access_token);
|
||||
expect(tokenDetails?.resource).toBe('https://api.example.com/v1');
|
||||
expect(tokenDetails?.resource).toEqual(new URL('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'];
|
||||
describe('Token verification', () => {
|
||||
it('should verify valid access token', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await provider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const code = url.searchParams.get('code')!;
|
||||
|
||||
const tokens = await provider.exchangeAuthorizationCode(mockClient, code);
|
||||
const tokenInfo = await provider.verifyAccessToken(tokens.access_token);
|
||||
|
||||
expect(tokenInfo.clientId).toBe(mockClient.client_id);
|
||||
expect(tokenInfo.scopes).toEqual(['mcp:tools']);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
await expect(provider.verifyAccessToken('invalid-token'))
|
||||
.rejects.toThrow('Invalid or expired token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh token', () => {
|
||||
it('should throw error for refresh token (not implemented)', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
|
||||
await expect(provider.exchangeRefreshToken(
|
||||
mockClient,
|
||||
'refresh-token',
|
||||
undefined,
|
||||
'https://api.forbidden.com'
|
||||
)).rejects.toThrow(InvalidTargetError);
|
||||
'refresh-token'
|
||||
)).rejects.toThrow('Refresh tokens not implemented for example demo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Allowed resources management', () => {
|
||||
it('should update allowed resources for a client', async () => {
|
||||
await clientsStore.registerClient(mockClient);
|
||||
describe('Server URL validation', () => {
|
||||
it('should accept mcpServerUrl configuration', () => {
|
||||
const serverUrl = new URL('https://api.example.com/mcp');
|
||||
const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl });
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server URL validation configuration', () => {
|
||||
it('should throw error when validateResourceMatchesServer is true but serverUrl is not set', () => {
|
||||
const invalidConfig: DemoOAuthProviderConfig = {
|
||||
validateResourceMatchesServer: true
|
||||
// serverUrl is missing
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
new DemoInMemoryAuthProvider(invalidConfig);
|
||||
}).toThrow("serverUrl must be configured when validateResourceMatchesServer is true");
|
||||
expect(providerWithUrl).toBeDefined();
|
||||
});
|
||||
|
||||
describe('with server URL validation enabled', () => {
|
||||
let strictProvider: DemoInMemoryAuthProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
const config: DemoOAuthProviderConfig = {
|
||||
serverUrl: 'https://api.example.com/mcp',
|
||||
validateResourceMatchesServer: true
|
||||
};
|
||||
strictProvider = new DemoInMemoryAuthProvider(config);
|
||||
|
||||
strictProvider.clientsStore.registerClient(mockClient);
|
||||
});
|
||||
|
||||
it('should reject authorization without resource parameter', async () => {
|
||||
await expect(strictProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
// resource is missing
|
||||
}, mockResponse as Response)).rejects.toThrow(InvalidRequestError);
|
||||
});
|
||||
|
||||
it('should reject authorization with non-matching resource', async () => {
|
||||
await expect(strictProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://different.api.com/mcp',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).rejects.toThrow(InvalidTargetError);
|
||||
});
|
||||
|
||||
it('should accept authorization with matching resource', async () => {
|
||||
await expect(strictProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/mcp',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle server URL with fragment correctly', async () => {
|
||||
const configWithFragment: DemoOAuthProviderConfig = {
|
||||
serverUrl: 'https://api.example.com/mcp#fragment',
|
||||
validateResourceMatchesServer: true
|
||||
};
|
||||
const providerWithFragment = new DemoInMemoryAuthProvider(configWithFragment);
|
||||
|
||||
await providerWithFragment.clientsStore.registerClient(mockClient);
|
||||
|
||||
// Should accept resource without fragment
|
||||
await expect(providerWithFragment.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/mcp',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject token exchange without resource parameter', async () => {
|
||||
// First authorize with resource
|
||||
mockResponse.redirect = jest.fn();
|
||||
await strictProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/mcp',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const authCode = url.searchParams.get('code')!;
|
||||
|
||||
await expect(strictProvider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
authCode,
|
||||
undefined,
|
||||
undefined
|
||||
// resource is missing
|
||||
)).rejects.toThrow(InvalidRequestError);
|
||||
});
|
||||
|
||||
it('should reject refresh token without resource parameter', async () => {
|
||||
await expect(strictProvider.exchangeRefreshToken(
|
||||
mockClient,
|
||||
'refresh-token',
|
||||
undefined
|
||||
// resource is missing
|
||||
)).rejects.toThrow(InvalidRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with server URL validation disabled (warning mode)', () => {
|
||||
let warnProvider: DemoInMemoryAuthProvider;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
warnProvider = new DemoInMemoryAuthProvider(); // No config = warnings enabled
|
||||
|
||||
warnProvider.clientsStore.registerClient(mockClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should log warning when resource is missing from authorization', async () => {
|
||||
await warnProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
// resource is missing
|
||||
}, mockResponse as Response);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-client is missing the resource parameter')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not log warning when resource is present', async () => {
|
||||
await warnProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
resource: 'https://api.example.com/mcp',
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log warning when resource is missing from token exchange', async () => {
|
||||
// First authorize without resource
|
||||
await warnProvider.authorize(mockClient, {
|
||||
codeChallenge: 'test-challenge',
|
||||
redirectUri: mockClient.redirect_uris[0],
|
||||
scopes: ['mcp:tools']
|
||||
}, mockResponse as Response);
|
||||
|
||||
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
|
||||
const url = new URL(redirectCall);
|
||||
const authCode = url.searchParams.get('code')!;
|
||||
|
||||
await warnProvider.exchangeAuthorizationCode(
|
||||
mockClient,
|
||||
authCode,
|
||||
undefined,
|
||||
undefined
|
||||
// resource is missing
|
||||
);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-client is missing the resource parameter')
|
||||
);
|
||||
});
|
||||
it('should handle server URL with fragment', () => {
|
||||
const serverUrl = new URL('https://api.example.com/mcp#fragment');
|
||||
const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl });
|
||||
|
||||
expect(providerWithUrl).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue