Co-authored-by: Basil Hosmer <basil@anthropic.com> Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
3685 lines
155 KiB
TypeScript
3685 lines
155 KiB
TypeScript
import { LATEST_PROTOCOL_VERSION } from '../../src/types.js';
|
||
import {
|
||
discoverOAuthMetadata,
|
||
discoverAuthorizationServerMetadata,
|
||
buildDiscoveryUrls,
|
||
startAuthorization,
|
||
exchangeAuthorization,
|
||
refreshAuthorization,
|
||
registerClient,
|
||
discoverOAuthProtectedResourceMetadata,
|
||
discoverOAuthServerInfo,
|
||
extractWWWAuthenticateParams,
|
||
auth,
|
||
type OAuthClientProvider,
|
||
selectClientAuthMethod,
|
||
isHttpsUrl
|
||
} from '../../src/client/auth.js';
|
||
import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js';
|
||
import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js';
|
||
import { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '../../src/shared/auth.js';
|
||
import { expect, vi, type Mock } from 'vitest';
|
||
|
||
// Mock pkce-challenge
|
||
vi.mock('pkce-challenge', () => ({
|
||
default: () => ({
|
||
code_verifier: 'test_verifier',
|
||
code_challenge: 'test_challenge'
|
||
})
|
||
}));
|
||
|
||
// Mock fetch globally
|
||
const mockFetch = vi.fn();
|
||
global.fetch = mockFetch;
|
||
|
||
describe('OAuth Authorization', () => {
|
||
beforeEach(() => {
|
||
mockFetch.mockReset();
|
||
});
|
||
|
||
describe('extractWWWAuthenticateParams', () => {
|
||
it('returns resource metadata url when present', async () => {
|
||
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null))
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl) });
|
||
});
|
||
|
||
it('returns scope when present', async () => {
|
||
const scope = 'read';
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null))
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
|
||
});
|
||
|
||
it('returns empty object if not bearer', async () => {
|
||
const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource';
|
||
const scope = 'read';
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name =>
|
||
name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
|
||
)
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
|
||
});
|
||
|
||
it('returns empty object if resource_metadata and scope not present', async () => {
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null))
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({});
|
||
});
|
||
|
||
it('returns undefined resourceMetadataUrl on invalid url', async () => {
|
||
const resourceUrl = 'invalid-url';
|
||
const scope = 'read';
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name =>
|
||
name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null
|
||
)
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope });
|
||
});
|
||
|
||
it('returns error when present', async () => {
|
||
const mockResponse = {
|
||
headers: {
|
||
get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null))
|
||
}
|
||
} as unknown as Response;
|
||
|
||
expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' });
|
||
});
|
||
});
|
||
|
||
describe('discoverOAuthProtectedResourceMetadata', () => {
|
||
const validMetadata = {
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
};
|
||
|
||
it('returns metadata when discovery succeeds', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com');
|
||
expect(metadata).toEqual(validMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1);
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
});
|
||
|
||
it('returns metadata when first fetch fails but second without MCP header succeeds', async () => {
|
||
// Set up a counter to control behavior
|
||
let callCount = 0;
|
||
|
||
// Mock implementation that changes behavior based on call count
|
||
mockFetch.mockImplementation((_url, _options) => {
|
||
callCount++;
|
||
|
||
if (callCount === 1) {
|
||
// First call with MCP header - fail with TypeError (simulating CORS error)
|
||
// We need to use TypeError specifically because that's what the implementation checks for
|
||
return Promise.reject(new TypeError('Network error'));
|
||
} else {
|
||
// Second call without header - succeed
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
}
|
||
});
|
||
|
||
// Should succeed with the second call
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
// Verify both calls were made
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
|
||
// Verify first call had MCP header
|
||
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version');
|
||
});
|
||
|
||
it('throws an error when all fetch attempts fail', async () => {
|
||
// Set up a counter to control behavior
|
||
let callCount = 0;
|
||
|
||
// Mock implementation that changes behavior based on call count
|
||
mockFetch.mockImplementation((_url, _options) => {
|
||
callCount++;
|
||
|
||
if (callCount === 1) {
|
||
// First call - fail with TypeError
|
||
return Promise.reject(new TypeError('First failure'));
|
||
} else {
|
||
// Second call - fail with different error
|
||
return Promise.reject(new Error('Second failure'));
|
||
}
|
||
});
|
||
|
||
// Should fail with the second error
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('Second failure');
|
||
|
||
// Verify both calls were made
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('throws on 404 errors', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(
|
||
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
|
||
);
|
||
});
|
||
|
||
it('throws on non-404 errors', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 500
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('HTTP 500');
|
||
});
|
||
|
||
it('validates metadata schema', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
// Missing required fields
|
||
scopes_supported: ['email', 'mcp']
|
||
})
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow();
|
||
});
|
||
|
||
it('returns metadata when discovery succeeds with path', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name');
|
||
expect(metadata).toEqual(validMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1);
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name');
|
||
});
|
||
|
||
it('preserves query parameters in path-aware discovery', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path?param=value');
|
||
expect(metadata).toEqual(validMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1);
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path?param=value');
|
||
});
|
||
|
||
it.each([400, 401, 403, 404, 410, 422, 429])(
|
||
'falls back to root discovery when path-aware discovery returns %d',
|
||
async statusCode => {
|
||
// First call (path-aware) returns 4xx
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: statusCode
|
||
});
|
||
|
||
// Second call (root fallback) succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
|
||
// First call should be path-aware
|
||
const [firstUrl, firstOptions] = calls[0];
|
||
expect(firstUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name');
|
||
expect(firstOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
|
||
// Second call should be root fallback
|
||
const [secondUrl, secondOptions] = calls[1];
|
||
expect(secondUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
expect(secondOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
}
|
||
);
|
||
|
||
it('throws error when both path-aware and root discovery return 404', async () => {
|
||
// First call (path-aware) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second call (root fallback) also returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow(
|
||
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
|
||
);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
});
|
||
|
||
it('throws error on 500 status and does not fallback', async () => {
|
||
// First call (path-aware) returns 500
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 500
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow();
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||
});
|
||
|
||
it('does not fallback when the original URL is already at root path', async () => {
|
||
// First call (path-aware for root) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/')).rejects.toThrow(
|
||
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
|
||
);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
});
|
||
|
||
it('does not fallback when the original URL has no path', async () => {
|
||
// First call (path-aware for no path) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(
|
||
'Resource server does not implement OAuth 2.0 Protected Resource Metadata.'
|
||
);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
});
|
||
|
||
it('falls back when path-aware discovery encounters CORS error', async () => {
|
||
// First call (path-aware) fails with TypeError (CORS)
|
||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));
|
||
|
||
// Retry path-aware without headers (simulating CORS retry)
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second call (root fallback) succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/deep/path');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(3);
|
||
|
||
// Final call should be root fallback
|
||
const [lastUrl, lastOptions] = calls[2];
|
||
expect(lastUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
expect(lastOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('does not fallback when resourceMetadataUrl is provided', async () => {
|
||
// Call with explicit URL returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
await expect(
|
||
discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', {
|
||
resourceMetadataUrl: 'https://custom.example.com/metadata'
|
||
})
|
||
).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.');
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
|
||
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://custom.example.com/metadata');
|
||
});
|
||
|
||
it('supports overriding the fetch function used for requests', async () => {
|
||
const validMetadata = {
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
};
|
||
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, customFetch);
|
||
|
||
expect(metadata).toEqual(validMetadata);
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
|
||
const [url, options] = customFetch.mock.calls[0];
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
expect(options.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('discoverOAuthMetadata', () => {
|
||
const validMetadata = {
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
it('returns metadata when discovery succeeds', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com');
|
||
expect(metadata).toEqual(validMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1);
|
||
const [url, options] = calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
expect(options.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('returns metadata when discovery succeeds with path', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name');
|
||
expect(metadata).toEqual(validMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1);
|
||
const [url, options] = calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name');
|
||
expect(options.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('falls back to root discovery when path-aware discovery returns 404', async () => {
|
||
// First call (path-aware) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second call (root fallback) succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
|
||
// First call should be path-aware
|
||
const [firstUrl, firstOptions] = calls[0];
|
||
expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name');
|
||
expect(firstOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
|
||
// Second call should be root fallback
|
||
const [secondUrl, secondOptions] = calls[1];
|
||
expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
expect(secondOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('returns undefined when both path-aware and root discovery return 404', async () => {
|
||
// First call (path-aware) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second call (root fallback) also returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name');
|
||
expect(metadata).toBeUndefined();
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
});
|
||
|
||
it('does not fallback when the original URL is already at root path', async () => {
|
||
// First call (path-aware for root) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/');
|
||
expect(metadata).toBeUndefined();
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
});
|
||
|
||
it('does not fallback when the original URL has no path', async () => {
|
||
// First call (path-aware for no path) returns 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com');
|
||
expect(metadata).toBeUndefined();
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||
|
||
const [url] = calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
});
|
||
|
||
it('falls back when path-aware discovery encounters CORS error', async () => {
|
||
// First call (path-aware) fails with TypeError (CORS)
|
||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));
|
||
|
||
// Retry path-aware without headers (simulating CORS retry)
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second call (root fallback) succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/deep/path');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(3);
|
||
|
||
// Final call should be root fallback
|
||
const [lastUrl, lastOptions] = calls[2];
|
||
expect(lastUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
expect(lastOptions.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('returns metadata when first fetch fails but second without MCP header succeeds', async () => {
|
||
// Set up a counter to control behavior
|
||
let callCount = 0;
|
||
|
||
// Mock implementation that changes behavior based on call count
|
||
mockFetch.mockImplementation((_url, _options) => {
|
||
callCount++;
|
||
|
||
if (callCount === 1) {
|
||
// First call with MCP header - fail with TypeError (simulating CORS error)
|
||
// We need to use TypeError specifically because that's what the implementation checks for
|
||
return Promise.reject(new TypeError('Network error'));
|
||
} else {
|
||
// Second call without header - succeed
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
}
|
||
});
|
||
|
||
// Should succeed with the second call
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com');
|
||
expect(metadata).toEqual(validMetadata);
|
||
|
||
// Verify both calls were made
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
|
||
// Verify first call had MCP header
|
||
expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version');
|
||
});
|
||
|
||
it('throws an error when all fetch attempts fail', async () => {
|
||
// Set up a counter to control behavior
|
||
let callCount = 0;
|
||
|
||
// Mock implementation that changes behavior based on call count
|
||
mockFetch.mockImplementation((_url, _options) => {
|
||
callCount++;
|
||
|
||
if (callCount === 1) {
|
||
// First call - fail with TypeError
|
||
return Promise.reject(new TypeError('First failure'));
|
||
} else {
|
||
// Second call - fail with different error
|
||
return Promise.reject(new Error('Second failure'));
|
||
}
|
||
});
|
||
|
||
// Should fail with the second error
|
||
await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('Second failure');
|
||
|
||
// Verify both calls were made
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('returns undefined when both CORS requests fail in fetchWithCorsRetry', async () => {
|
||
// fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS)
|
||
// simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError
|
||
mockFetch.mockImplementation(() => {
|
||
// Both the initial request with headers and retry without headers fail with CORS TypeError
|
||
return Promise.reject(new TypeError('Failed to fetch'));
|
||
});
|
||
|
||
// This should return undefined (the desired behavior after the fix)
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com/path');
|
||
expect(metadata).toBeUndefined();
|
||
});
|
||
|
||
it('returns undefined when discovery endpoint returns 404', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com');
|
||
expect(metadata).toBeUndefined();
|
||
});
|
||
|
||
it('throws on non-404 errors', async () => {
|
||
mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 }));
|
||
|
||
await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500');
|
||
});
|
||
|
||
it('validates metadata schema', async () => {
|
||
mockFetch.mockResolvedValueOnce(
|
||
Response.json(
|
||
{
|
||
// Missing required fields
|
||
issuer: 'https://auth.example.com'
|
||
},
|
||
{ status: 200 }
|
||
)
|
||
);
|
||
|
||
await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow();
|
||
});
|
||
|
||
it('supports overriding the fetch function used for requests', async () => {
|
||
const validMetadata = {
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validMetadata
|
||
});
|
||
|
||
const metadata = await discoverOAuthMetadata('https://auth.example.com', {}, customFetch);
|
||
|
||
expect(metadata).toEqual(validMetadata);
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
|
||
const [url, options] = customFetch.mock.calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
expect(options.headers).toEqual({
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('buildDiscoveryUrls', () => {
|
||
it('generates correct URLs for server without path', () => {
|
||
const urls = buildDiscoveryUrls('https://auth.example.com');
|
||
|
||
expect(urls).toHaveLength(2);
|
||
expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([
|
||
{
|
||
url: 'https://auth.example.com/.well-known/oauth-authorization-server',
|
||
type: 'oauth'
|
||
},
|
||
{
|
||
url: 'https://auth.example.com/.well-known/openid-configuration',
|
||
type: 'oidc'
|
||
}
|
||
]);
|
||
});
|
||
|
||
it('generates correct URLs for server with path', () => {
|
||
const urls = buildDiscoveryUrls('https://auth.example.com/tenant1');
|
||
|
||
expect(urls).toHaveLength(3);
|
||
expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([
|
||
{
|
||
url: 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1',
|
||
type: 'oauth'
|
||
},
|
||
{
|
||
url: 'https://auth.example.com/.well-known/openid-configuration/tenant1',
|
||
type: 'oidc'
|
||
},
|
||
{
|
||
url: 'https://auth.example.com/tenant1/.well-known/openid-configuration',
|
||
type: 'oidc'
|
||
}
|
||
]);
|
||
});
|
||
|
||
it('handles URL object input', () => {
|
||
const urls = buildDiscoveryUrls(new URL('https://auth.example.com/tenant1'));
|
||
|
||
expect(urls).toHaveLength(3);
|
||
expect(urls[0].url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1');
|
||
});
|
||
});
|
||
|
||
describe('discoverAuthorizationServerMetadata', () => {
|
||
const validOAuthMetadata = {
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
const validOpenIdMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
jwks_uri: 'https://auth.example.com/jwks',
|
||
subject_types_supported: ['public'],
|
||
id_token_signing_alg_values_supported: ['RS256'],
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
it('tries URLs in order and returns first successful metadata', async () => {
|
||
// First OAuth URL (path before well-known) fails with 404
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
|
||
// Second OIDC URL (path before well-known) succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validOpenIdMetadata
|
||
});
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1');
|
||
|
||
expect(metadata).toEqual(validOpenIdMetadata);
|
||
|
||
// Verify it tried the URLs in the correct order
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
expect(calls[0][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1');
|
||
expect(calls[1][0].toString()).toBe('https://auth.example.com/.well-known/openid-configuration/tenant1');
|
||
});
|
||
|
||
it('continues on 4xx errors', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 400
|
||
});
|
||
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validOpenIdMetadata
|
||
});
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com');
|
||
|
||
expect(metadata).toEqual(validOpenIdMetadata);
|
||
});
|
||
|
||
it('throws on non-4xx errors', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 500
|
||
});
|
||
|
||
await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500');
|
||
});
|
||
|
||
it('handles CORS errors with retry', async () => {
|
||
// First call fails with CORS
|
||
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error')));
|
||
|
||
// Retry without headers succeeds
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validOAuthMetadata
|
||
});
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com');
|
||
|
||
expect(metadata).toEqual(validOAuthMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
expect(calls.length).toBe(2);
|
||
|
||
// First call should have headers
|
||
expect(calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version');
|
||
|
||
// Second call should not have headers (CORS retry)
|
||
expect(calls[1][1]?.headers).toBeUndefined();
|
||
});
|
||
|
||
it('supports custom fetch function', async () => {
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validOAuthMetadata
|
||
});
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { fetchFn: customFetch });
|
||
|
||
expect(metadata).toEqual(validOAuthMetadata);
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('supports custom protocol version', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validOAuthMetadata
|
||
});
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { protocolVersion: '2025-01-01' });
|
||
|
||
expect(metadata).toEqual(validOAuthMetadata);
|
||
const calls = mockFetch.mock.calls;
|
||
const [, options] = calls[0];
|
||
expect(options.headers).toEqual({
|
||
'MCP-Protocol-Version': '2025-01-01',
|
||
Accept: 'application/json'
|
||
});
|
||
});
|
||
|
||
it('returns undefined when all URLs fail with CORS errors', async () => {
|
||
// All fetch attempts fail with CORS errors (TypeError)
|
||
mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error')));
|
||
|
||
const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1');
|
||
|
||
expect(metadata).toBeUndefined();
|
||
|
||
// Verify that all discovery URLs were attempted
|
||
expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers)
|
||
});
|
||
});
|
||
|
||
describe('discoverOAuthServerInfo', () => {
|
||
const validResourceMetadata = {
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
};
|
||
|
||
const validAuthMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code']
|
||
};
|
||
|
||
it('returns auth server from RFC 9728 protected resource metadata', async () => {
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validResourceMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validAuthMetadata
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
const result = await discoverOAuthServerInfo('https://resource.example.com');
|
||
|
||
expect(result.authorizationServerUrl).toBe('https://auth.example.com');
|
||
expect(result.resourceMetadata).toEqual(validResourceMetadata);
|
||
expect(result.authorizationServerMetadata).toEqual(validAuthMetadata);
|
||
});
|
||
|
||
it('falls back to server URL when RFC 9728 is not supported', async () => {
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
// RFC 9728 returns 404
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
...validAuthMetadata,
|
||
issuer: 'https://resource.example.com'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
const result = await discoverOAuthServerInfo('https://resource.example.com');
|
||
|
||
// Should fall back to server URL origin
|
||
expect(result.authorizationServerUrl).toBe('https://resource.example.com/');
|
||
expect(result.resourceMetadata).toBeUndefined();
|
||
expect(result.authorizationServerMetadata).toBeDefined();
|
||
});
|
||
|
||
it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => {
|
||
const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource');
|
||
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString === overrideUrl.toString()) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validResourceMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validAuthMetadata
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
const result = await discoverOAuthServerInfo('https://resource.example.com', {
|
||
resourceMetadataUrl: overrideUrl
|
||
});
|
||
|
||
expect(result.resourceMetadata).toEqual(validResourceMetadata);
|
||
// Verify the override URL was used instead of the default well-known path
|
||
expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString());
|
||
});
|
||
});
|
||
|
||
describe('auth with provider authorization server URL caching', () => {
|
||
const validResourceMetadata = {
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
};
|
||
|
||
const validAuthMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
function createMockProvider(overrides: Partial<OAuthClientProvider> = {}): OAuthClientProvider {
|
||
return {
|
||
get redirectUrl() {
|
||
return 'http://localhost:3000/callback';
|
||
},
|
||
get clientMetadata() {
|
||
return {
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
},
|
||
clientInformation: vi.fn().mockResolvedValue({
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret'
|
||
}),
|
||
tokens: vi.fn().mockResolvedValue(undefined),
|
||
saveTokens: vi.fn(),
|
||
redirectToAuthorization: vi.fn(),
|
||
saveCodeVerifier: vi.fn(),
|
||
codeVerifier: vi.fn(),
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('calls saveDiscoveryState after discovery when provider implements it', async () => {
|
||
const saveDiscoveryState = vi.fn();
|
||
const provider = createMockProvider({ saveDiscoveryState });
|
||
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validResourceMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validAuthMetadata
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
await auth(provider, { serverUrl: 'https://resource.example.com' });
|
||
|
||
expect(saveDiscoveryState).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
authorizationServerUrl: 'https://auth.example.com',
|
||
resourceMetadata: validResourceMetadata,
|
||
authorizationServerMetadata: validAuthMetadata
|
||
})
|
||
);
|
||
});
|
||
|
||
it('restores full discovery state from cache including resource metadata', async () => {
|
||
const provider = createMockProvider({
|
||
discoveryState: vi.fn().mockResolvedValue({
|
||
authorizationServerUrl: 'https://auth.example.com',
|
||
resourceMetadata: validResourceMetadata,
|
||
authorizationServerMetadata: validAuthMetadata
|
||
}),
|
||
tokens: vi.fn().mockResolvedValue({
|
||
access_token: 'valid-token',
|
||
refresh_token: 'refresh-token',
|
||
token_type: 'bearer'
|
||
})
|
||
});
|
||
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'new-token',
|
||
token_type: 'bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'new-refresh-token'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
const result = await auth(provider, {
|
||
serverUrl: 'https://resource.example.com'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Should NOT have called any discovery endpoints -- all from cache
|
||
const discoveryCalls = mockFetch.mock.calls.filter(
|
||
call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server')
|
||
);
|
||
expect(discoveryCalls).toHaveLength(0);
|
||
|
||
// Verify the token request includes the resource parameter from cached metadata
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
const body = tokenCall![1].body as URLSearchParams;
|
||
expect(body.get('resource')).toBe('https://resource.example.com/');
|
||
});
|
||
|
||
it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => {
|
||
const saveDiscoveryState = vi.fn();
|
||
const provider = createMockProvider({
|
||
// Partial cache: auth server URL only, no metadata
|
||
discoveryState: vi.fn().mockResolvedValue({
|
||
authorizationServerUrl: 'https://auth.example.com'
|
||
}),
|
||
saveDiscoveryState,
|
||
tokens: vi.fn().mockResolvedValue({
|
||
access_token: 'valid-token',
|
||
refresh_token: 'refresh-token',
|
||
token_type: 'bearer'
|
||
})
|
||
});
|
||
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validResourceMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validAuthMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'new-token',
|
||
token_type: 'bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'new-refresh-token'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
await auth(provider, { serverUrl: 'https://resource.example.com' });
|
||
|
||
// Should re-save with the enriched state including fetched metadata
|
||
expect(saveDiscoveryState).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
authorizationServerUrl: 'https://auth.example.com',
|
||
authorizationServerMetadata: validAuthMetadata,
|
||
resourceMetadata: validResourceMetadata
|
||
})
|
||
);
|
||
});
|
||
|
||
it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => {
|
||
const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource';
|
||
const provider = createMockProvider({
|
||
// Cache has auth server URL + resourceMetadataUrl but no resourceMetadata
|
||
// (simulates browser redirect where PRM URL was saved but metadata wasn't)
|
||
discoveryState: vi.fn().mockResolvedValue({
|
||
authorizationServerUrl: 'https://auth.example.com',
|
||
resourceMetadataUrl: cachedPrmUrl,
|
||
authorizationServerMetadata: validAuthMetadata
|
||
}),
|
||
tokens: vi.fn().mockResolvedValue({
|
||
access_token: 'valid-token',
|
||
refresh_token: 'refresh-token',
|
||
token_type: 'bearer'
|
||
})
|
||
});
|
||
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
// The cached PRM URL should be used for resource metadata discovery
|
||
if (urlString === cachedPrmUrl) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validResourceMetadata
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'new-token',
|
||
token_type: 'bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'new-refresh-token'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch: ${urlString}`));
|
||
});
|
||
|
||
const result = await auth(provider, {
|
||
serverUrl: 'https://resource.example.com'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Should have used the cached PRM URL, not the default well-known path
|
||
const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource'));
|
||
expect(prmCalls).toHaveLength(1);
|
||
expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl);
|
||
});
|
||
});
|
||
|
||
describe('selectClientAuthMethod', () => {
|
||
it('selects the correct client authentication method from client information', () => {
|
||
const clientInfo = {
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
token_endpoint_auth_method: 'client_secret_basic'
|
||
};
|
||
const supportedMethods = ['client_secret_post', 'client_secret_basic', 'none'];
|
||
const authMethod = selectClientAuthMethod(clientInfo, supportedMethods);
|
||
expect(authMethod).toBe('client_secret_basic');
|
||
});
|
||
it('selects the correct client authentication method from supported methods', () => {
|
||
const clientInfo = { client_id: 'test-client-id' };
|
||
const supportedMethods = ['client_secret_post', 'client_secret_basic', 'none'];
|
||
const authMethod = selectClientAuthMethod(clientInfo, supportedMethods);
|
||
expect(authMethod).toBe('none');
|
||
});
|
||
it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => {
|
||
// RFC 8414 §2: if omitted, the default is client_secret_basic.
|
||
// RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret.
|
||
const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' };
|
||
const authMethod = selectClientAuthMethod(clientInfo, []);
|
||
expect(authMethod).toBe('client_secret_basic');
|
||
});
|
||
it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => {
|
||
const clientInfo = { client_id: 'test-client-id' };
|
||
const authMethod = selectClientAuthMethod(clientInfo, []);
|
||
expect(authMethod).toBe('none');
|
||
});
|
||
it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => {
|
||
const clientInfo = {
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
token_endpoint_auth_method: 'client_secret_post'
|
||
};
|
||
const authMethod = selectClientAuthMethod(clientInfo, []);
|
||
expect(authMethod).toBe('client_secret_post');
|
||
});
|
||
});
|
||
|
||
describe('startAuthorization', () => {
|
||
const validMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/auth',
|
||
token_endpoint: 'https://auth.example.com/tkn',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
const validOpenIdMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/auth',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
jwks_uri: 'https://auth.example.com/jwks',
|
||
subject_types_supported: ['public'],
|
||
id_token_signing_alg_values_supported: ['RS256'],
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
it('generates authorization URL with PKCE challenge', async () => {
|
||
const { authorizationUrl, codeVerifier } = await startAuthorization('https://auth.example.com', {
|
||
metadata: undefined,
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback',
|
||
resource: new URL('https://api.example.com/mcp-server')
|
||
});
|
||
|
||
expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/authorize\?/);
|
||
expect(authorizationUrl.searchParams.get('response_type')).toBe('code');
|
||
expect(authorizationUrl.searchParams.get('code_challenge')).toBe('test_challenge');
|
||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('http://localhost:3000/callback');
|
||
expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
expect(codeVerifier).toBe('test_verifier');
|
||
});
|
||
|
||
it('includes scope parameter when provided', async () => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback',
|
||
scope: 'read write profile'
|
||
});
|
||
|
||
expect(authorizationUrl.searchParams.get('scope')).toBe('read write profile');
|
||
});
|
||
|
||
it('excludes scope parameter when not provided', async () => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
});
|
||
|
||
expect(authorizationUrl.searchParams.has('scope')).toBe(false);
|
||
});
|
||
|
||
it('includes state parameter when provided', async () => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback',
|
||
state: 'foobar'
|
||
});
|
||
|
||
expect(authorizationUrl.searchParams.get('state')).toBe('foobar');
|
||
});
|
||
|
||
it('excludes state parameter when not provided', async () => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
});
|
||
|
||
expect(authorizationUrl.searchParams.has('state')).toBe(false);
|
||
});
|
||
|
||
// OpenID Connect requires that the user is prompted for consent if the scope includes 'offline_access'
|
||
it("includes consent prompt parameter if scope includes 'offline_access'", async () => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback',
|
||
scope: 'read write profile offline_access'
|
||
});
|
||
|
||
expect(authorizationUrl.searchParams.get('prompt')).toBe('consent');
|
||
});
|
||
|
||
it.each([validMetadata, validOpenIdMetadata])('uses metadata authorization_endpoint when provided', async baseMetadata => {
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
metadata: baseMetadata,
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
});
|
||
|
||
expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/auth\?/);
|
||
});
|
||
|
||
it.each([validMetadata, validOpenIdMetadata])('validates response type support', async baseMetadata => {
|
||
const metadata = {
|
||
...baseMetadata,
|
||
response_types_supported: ['token'] // Does not support 'code'
|
||
};
|
||
|
||
await expect(
|
||
startAuthorization('https://auth.example.com', {
|
||
metadata,
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
})
|
||
).rejects.toThrow(/does not support response type/);
|
||
});
|
||
|
||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/832
|
||
it.each([validMetadata, validOpenIdMetadata])(
|
||
'assumes supported code challenge methods includes S256 if absent',
|
||
async baseMetadata => {
|
||
const metadata = {
|
||
...baseMetadata,
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: undefined
|
||
};
|
||
|
||
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
|
||
metadata,
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
});
|
||
|
||
expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/auth\?.+&code_challenge_method=S256/);
|
||
}
|
||
);
|
||
|
||
it.each([validMetadata, validOpenIdMetadata])(
|
||
'validates supported code challenge methods includes S256 if present',
|
||
async baseMetadata => {
|
||
const metadata = {
|
||
...baseMetadata,
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['plain'] // Does not support 'S256'
|
||
};
|
||
|
||
await expect(
|
||
startAuthorization('https://auth.example.com', {
|
||
metadata,
|
||
clientInformation: validClientInfo,
|
||
redirectUrl: 'http://localhost:3000/callback'
|
||
})
|
||
).rejects.toThrow(/does not support code challenge method/);
|
||
}
|
||
);
|
||
});
|
||
|
||
describe('exchangeAuthorization', () => {
|
||
const validTokens: OAuthTokens = {
|
||
access_token: 'access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'refresh123'
|
||
};
|
||
|
||
const validMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code']
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
it('exchanges code for tokens', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
resource: new URL('https://api.example.com/mcp-server')
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/token'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST'
|
||
})
|
||
);
|
||
|
||
const options = mockFetch.mock.calls[0][1];
|
||
expect(options.headers).toBeInstanceOf(Headers);
|
||
expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
||
expect(options.body).toBeInstanceOf(URLSearchParams);
|
||
|
||
const body = options.body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('authorization_code');
|
||
expect(body.get('code')).toBe('code123');
|
||
expect(body.get('code_verifier')).toBe('verifier123');
|
||
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
|
||
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
|
||
it('allows for string "expires_in" values', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ ...validTokens, expires_in: '3600' })
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
resource: new URL('https://api.example.com/mcp-server')
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/token'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST'
|
||
})
|
||
);
|
||
|
||
const options = mockFetch.mock.calls[0][1];
|
||
expect(options.headers).toBeInstanceOf(Headers);
|
||
expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
||
|
||
const body = options.body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('authorization_code');
|
||
expect(body.get('code')).toBe('code123');
|
||
expect(body.get('code_verifier')).toBe('verifier123');
|
||
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
|
||
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
it('exchanges code for tokens with auth', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
metadata: validMetadata,
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
addClientAuthentication: (
|
||
headers: Headers,
|
||
params: URLSearchParams,
|
||
url: string | URL,
|
||
metadata?: AuthorizationServerMetadata
|
||
) => {
|
||
headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret));
|
||
params.set('example_url', typeof url === 'string' ? url : url.toString());
|
||
params.set('example_metadata', metadata?.authorization_endpoint ?? '');
|
||
params.set('example_param', 'example_value');
|
||
}
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/token'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST'
|
||
})
|
||
);
|
||
|
||
const headers = mockFetch.mock.calls[0][1].headers as Headers;
|
||
expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
||
expect(headers.get('Authorization')).toBe('Basic Y2xpZW50MTIzOnNlY3JldDEyMw==');
|
||
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('authorization_code');
|
||
expect(body.get('code')).toBe('code123');
|
||
expect(body.get('code_verifier')).toBe('verifier123');
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
|
||
expect(body.get('example_url')).toBe('https://auth.example.com/token');
|
||
expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize');
|
||
expect(body.get('example_param')).toBe('example_value');
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
|
||
it('validates token response schema', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
// Missing required fields
|
||
access_token: 'access123'
|
||
})
|
||
});
|
||
|
||
await expect(
|
||
exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback'
|
||
})
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('throws on error response', async () => {
|
||
mockFetch.mockResolvedValueOnce(Response.json(new ServerError('Token exchange failed').toResponseObject(), { status: 400 }));
|
||
|
||
await expect(
|
||
exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback'
|
||
})
|
||
).rejects.toThrow('Token exchange failed');
|
||
});
|
||
|
||
it('supports overriding the fetch function used for requests', async () => {
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
codeVerifier: 'verifier123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
resource: new URL('https://api.example.com/mcp-server'),
|
||
fetchFn: customFetch
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
|
||
const [url, options] = customFetch.mock.calls[0];
|
||
expect(url.toString()).toBe('https://auth.example.com/token');
|
||
expect(options).toEqual(
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: expect.any(Headers),
|
||
body: expect.any(URLSearchParams)
|
||
})
|
||
);
|
||
|
||
const body = options.body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('authorization_code');
|
||
expect(body.get('code')).toBe('code123');
|
||
expect(body.get('code_verifier')).toBe('verifier123');
|
||
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
|
||
expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback');
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
});
|
||
|
||
describe('refreshAuthorization', () => {
|
||
const validTokens = {
|
||
access_token: 'newaccess123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600
|
||
};
|
||
const validTokensWithNewRefreshToken = {
|
||
...validTokens,
|
||
refresh_token: 'newrefresh123'
|
||
};
|
||
|
||
const validMetadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code']
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
it('exchanges refresh token for new tokens', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokensWithNewRefreshToken
|
||
});
|
||
|
||
const tokens = await refreshAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123',
|
||
resource: new URL('https://api.example.com/mcp-server')
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokensWithNewRefreshToken);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/token'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST'
|
||
})
|
||
);
|
||
|
||
const headers = mockFetch.mock.calls[0][1].headers as Headers;
|
||
expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
||
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('refresh_token');
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
// Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2)
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123'));
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
|
||
it('exchanges refresh token for new tokens with auth', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokensWithNewRefreshToken
|
||
});
|
||
|
||
const tokens = await refreshAuthorization('https://auth.example.com', {
|
||
metadata: validMetadata,
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123',
|
||
addClientAuthentication: (
|
||
headers: Headers,
|
||
params: URLSearchParams,
|
||
url: string | URL,
|
||
metadata?: AuthorizationServerMetadata
|
||
) => {
|
||
headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret));
|
||
params.set('example_url', typeof url === 'string' ? url : url.toString());
|
||
params.set('example_metadata', metadata?.authorization_endpoint ?? '?');
|
||
params.set('example_param', 'example_value');
|
||
}
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokensWithNewRefreshToken);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/token'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST'
|
||
})
|
||
);
|
||
|
||
const headers = mockFetch.mock.calls[0][1].headers as Headers;
|
||
expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded');
|
||
expect(headers.get('Authorization')).toBe('Basic Y2xpZW50MTIzOnNlY3JldDEyMw==');
|
||
const body = mockFetch.mock.calls[0][1].body as URLSearchParams;
|
||
expect(body.get('grant_type')).toBe('refresh_token');
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('example_url')).toBe('https://auth.example.com/token');
|
||
expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize');
|
||
expect(body.get('example_param')).toBe('example_value');
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
|
||
it('exchanges refresh token for new tokens and keep existing refresh token if none is returned', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const refreshToken = 'refresh123';
|
||
const tokens = await refreshAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
refreshToken
|
||
});
|
||
|
||
expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens });
|
||
});
|
||
|
||
it('validates token response schema', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
// Missing required fields
|
||
access_token: 'newaccess123'
|
||
})
|
||
});
|
||
|
||
await expect(
|
||
refreshAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123'
|
||
})
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('throws on error response', async () => {
|
||
mockFetch.mockResolvedValueOnce(Response.json(new ServerError('Token refresh failed').toResponseObject(), { status: 400 }));
|
||
|
||
await expect(
|
||
refreshAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123'
|
||
})
|
||
).rejects.toThrow('Token refresh failed');
|
||
});
|
||
});
|
||
|
||
describe('registerClient', () => {
|
||
const validClientMetadata = {
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
client_id_issued_at: 1612137600,
|
||
client_secret_expires_at: 1612224000,
|
||
...validClientMetadata
|
||
};
|
||
|
||
it('registers client and returns client information', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validClientInfo
|
||
});
|
||
|
||
const clientInfo = await registerClient('https://auth.example.com', {
|
||
clientMetadata: validClientMetadata
|
||
});
|
||
|
||
expect(clientInfo).toEqual(validClientInfo);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/register'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(validClientMetadata)
|
||
})
|
||
);
|
||
});
|
||
|
||
it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => {
|
||
const clientMetadataWithScope: OAuthClientMetadata = {
|
||
...validClientMetadata,
|
||
scope: 'should-be-overridden'
|
||
};
|
||
|
||
const expectedClientInfo = {
|
||
...validClientInfo,
|
||
scope: 'openid profile'
|
||
};
|
||
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => expectedClientInfo
|
||
});
|
||
|
||
const clientInfo = await registerClient('https://auth.example.com', {
|
||
clientMetadata: clientMetadataWithScope,
|
||
scope: 'openid profile'
|
||
});
|
||
|
||
expect(clientInfo).toEqual(expectedClientInfo);
|
||
expect(mockFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
href: 'https://auth.example.com/register'
|
||
}),
|
||
expect.objectContaining({
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' })
|
||
})
|
||
);
|
||
});
|
||
|
||
it('validates client information response schema', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
// Missing required fields
|
||
client_secret: 'secret123'
|
||
})
|
||
});
|
||
|
||
await expect(
|
||
registerClient('https://auth.example.com', {
|
||
clientMetadata: validClientMetadata
|
||
})
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('throws when registration endpoint not available in metadata', async () => {
|
||
const metadata = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code']
|
||
};
|
||
|
||
await expect(
|
||
registerClient('https://auth.example.com', {
|
||
metadata,
|
||
clientMetadata: validClientMetadata
|
||
})
|
||
).rejects.toThrow(/does not support dynamic client registration/);
|
||
});
|
||
|
||
it('throws on error response', async () => {
|
||
mockFetch.mockResolvedValueOnce(
|
||
Response.json(new ServerError('Dynamic client registration failed').toResponseObject(), { status: 400 })
|
||
);
|
||
|
||
await expect(
|
||
registerClient('https://auth.example.com', {
|
||
clientMetadata: validClientMetadata
|
||
})
|
||
).rejects.toThrow('Dynamic client registration failed');
|
||
});
|
||
});
|
||
|
||
describe('auth function', () => {
|
||
const mockProvider: OAuthClientProvider = {
|
||
get redirectUrl() {
|
||
return 'http://localhost:3000/callback';
|
||
},
|
||
get clientMetadata() {
|
||
return {
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
},
|
||
clientInformation: vi.fn(),
|
||
tokens: vi.fn(),
|
||
saveTokens: vi.fn(),
|
||
redirectToAuthorization: vi.fn(),
|
||
saveCodeVerifier: vi.fn(),
|
||
codeVerifier: vi.fn()
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('performs client_credentials with private_key_jwt when provider has addClientAuthentication', async () => {
|
||
// Arrange: metadata discovery for PRM and AS
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/mcp-server',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
|
||
if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'cc_jwt_token',
|
||
token_type: 'bearer',
|
||
expires_in: 3600
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
|
||
});
|
||
|
||
// Create a provider with client_credentials grant and addClientAuthentication
|
||
// redirectUrl returns undefined to indicate non-interactive flow
|
||
const ccProvider: OAuthClientProvider = {
|
||
get redirectUrl() {
|
||
return undefined;
|
||
},
|
||
get clientMetadata() {
|
||
return {
|
||
redirect_uris: [],
|
||
client_name: 'Test Client',
|
||
grant_types: ['client_credentials']
|
||
};
|
||
},
|
||
clientInformation: vi.fn().mockResolvedValue({
|
||
client_id: 'client-id'
|
||
}),
|
||
tokens: vi.fn().mockResolvedValue(undefined),
|
||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||
redirectToAuthorization: vi.fn(),
|
||
saveCodeVerifier: vi.fn(),
|
||
codeVerifier: vi.fn(),
|
||
prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }),
|
||
addClientAuthentication: createPrivateKeyJwtAuth({
|
||
issuer: 'client-id',
|
||
subject: 'client-id',
|
||
privateKey: 'a-string-secret-at-least-256-bits-long',
|
||
alg: 'HS256'
|
||
})
|
||
};
|
||
|
||
const result = await auth(ccProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Find the token request
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
|
||
const [, init] = tokenCall!;
|
||
const body = init.body as URLSearchParams;
|
||
|
||
// grant_type MUST be client_credentials, not the JWT-bearer grant
|
||
expect(body.get('grant_type')).toBe('client_credentials');
|
||
// private_key_jwt client authentication parameters
|
||
expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
||
expect(body.get('client_assertion')).toBeTruthy();
|
||
// resource parameter included based on PRM
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
|
||
it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => {
|
||
// Setup: First call to protected resource metadata fails (404)
|
||
// Second call to auth server metadata succeeds
|
||
let callCount = 0;
|
||
mockFetch.mockImplementation(url => {
|
||
callCount++;
|
||
|
||
const urlString = url.toString();
|
||
|
||
if (callCount === 1 && urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
// First call - protected resource metadata fails with 404
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
} else if (callCount === 2 && urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
// Second call - auth server metadata succeeds
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (callCount === 3 && urlString.includes('/register')) {
|
||
// Third call - client registration succeeds
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
client_id_issued_at: 1612137600,
|
||
client_secret_expires_at: 1612224000,
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`));
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
mockProvider.saveClientInformation = vi.fn();
|
||
|
||
// Call the auth function
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://resource.example.com'
|
||
});
|
||
|
||
// Verify the result
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the sequence of calls
|
||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||
|
||
// First call should be to protected resource metadata
|
||
expect(mockFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
|
||
// Second call should be to oauth metadata at the root path
|
||
expect(mockFetch.mock.calls[1][0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
|
||
});
|
||
|
||
it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => {
|
||
// Setup: First call to protected resource metadata fails (404)
|
||
// When no authorization_servers are found in protected resource metadata,
|
||
// the auth server URL should be set to the base URL with "/" path
|
||
let callCount = 0;
|
||
mockFetch.mockImplementation(url => {
|
||
callCount++;
|
||
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
// Protected resource metadata discovery attempts (both path-aware and root) fail with 404
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
} else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') {
|
||
// Should fetch from base URL with root path, not the full serverUrl path
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://resource.example.com/',
|
||
authorization_endpoint: 'https://resource.example.com/authorize',
|
||
token_endpoint: 'https://resource.example.com/token',
|
||
registration_endpoint: 'https://resource.example.com/register',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/register')) {
|
||
// Client registration succeeds
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
client_id_issued_at: 1612137600,
|
||
client_secret_expires_at: 1612224000,
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.reject(new Error(`Unexpected fetch call #${callCount}: ${urlString}`));
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
mockProvider.saveClientInformation = vi.fn();
|
||
|
||
// Call the auth function with a server URL that has a path
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://resource.example.com/path/to/server'
|
||
});
|
||
|
||
// Verify the result
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify that the oauth-authorization-server call uses the base URL
|
||
// This proves the fix: using new URL("/", serverUrl) instead of serverUrl
|
||
const authServerCall = mockFetch.mock.calls.find(call =>
|
||
call[0].toString().includes('/.well-known/oauth-authorization-server')
|
||
);
|
||
expect(authServerCall).toBeDefined();
|
||
expect(authServerCall![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server');
|
||
});
|
||
|
||
it('passes resource parameter through authorization flow', async () => {
|
||
// Mock successful metadata discovery - need to include protected resource metadata
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/mcp-server',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods for authorization flow
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth without authorization code (should trigger redirect)
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the authorization URL includes the resource parameter
|
||
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
searchParams: expect.any(URLSearchParams)
|
||
})
|
||
);
|
||
|
||
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
|
||
const authUrl: URL = redirectCall[0];
|
||
expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
});
|
||
|
||
it('includes resource in token exchange when authorization code is provided', async () => {
|
||
// Mock successful metadata discovery and token exchange - need protected resource metadata
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/mcp-server',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'refresh123'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods for token exchange
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier');
|
||
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with authorization code
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server',
|
||
authorizationCode: 'auth-code-123'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Find the token exchange call
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
|
||
const body = tokenCall![1].body as URLSearchParams;
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
expect(body.get('code')).toBe('auth-code-123');
|
||
});
|
||
|
||
it('includes resource in token refresh', async () => {
|
||
// Mock successful metadata discovery and token refresh - need protected resource metadata
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/mcp-server',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'new-access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods for token refresh
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue({
|
||
access_token: 'old-access',
|
||
refresh_token: 'refresh123'
|
||
});
|
||
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with existing tokens (should trigger refresh)
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Find the token refresh call
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
|
||
const body = tokenCall![1].body as URLSearchParams;
|
||
expect(body.get('resource')).toBe('https://api.example.com/mcp-server');
|
||
expect(body.get('grant_type')).toBe('refresh_token');
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
});
|
||
|
||
it('skips default PRM resource validation when custom validateResourceURL is provided', async () => {
|
||
const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined);
|
||
const providerWithCustomValidation = {
|
||
...mockProvider,
|
||
validateResourceURL: mockValidateResourceURL
|
||
};
|
||
|
||
// Mock protected resource metadata with mismatched resource URL
|
||
// This would normally throw an error in default validation, but should be skipped
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://different-resource.example.com/mcp-server', // Mismatched resource
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods
|
||
(providerWithCustomValidation.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(providerWithCustomValidation.tokens as Mock).mockResolvedValue(undefined);
|
||
(providerWithCustomValidation.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(providerWithCustomValidation.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth - should succeed despite resource mismatch because custom validation overrides default
|
||
const result = await auth(providerWithCustomValidation, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify custom validation method was called
|
||
expect(mockValidateResourceURL).toHaveBeenCalledWith(
|
||
new URL('https://api.example.com/mcp-server'),
|
||
'https://different-resource.example.com/mcp-server'
|
||
);
|
||
});
|
||
|
||
it('uses prefix of server URL from PRM resource as resource parameter', async () => {
|
||
// Mock successful metadata discovery with resource URL that is a prefix of requested URL
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
// Resource is a prefix of the requested server URL
|
||
resource: 'https://api.example.com/',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with a URL that has the resource as prefix
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server/endpoint'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the authorization URL includes the resource parameter from PRM
|
||
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
searchParams: expect.any(URLSearchParams)
|
||
})
|
||
);
|
||
|
||
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
|
||
const authUrl: URL = redirectCall[0];
|
||
// Should use the PRM's resource value, not the full requested URL
|
||
expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/');
|
||
});
|
||
|
||
it('excludes resource parameter when Protected Resource Metadata is not present', async () => {
|
||
// Mock metadata discovery where protected resource metadata is not available (404)
|
||
// but authorization server metadata is available
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
// Protected resource metadata not available
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth - should not include resource parameter
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the authorization URL does NOT include the resource parameter
|
||
expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
searchParams: expect.any(URLSearchParams)
|
||
})
|
||
);
|
||
|
||
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
|
||
const authUrl: URL = redirectCall[0];
|
||
// Resource parameter should not be present when PRM is not available
|
||
expect(authUrl.searchParams.has('resource')).toBe(false);
|
||
});
|
||
|
||
it('excludes resource parameter in token exchange when Protected Resource Metadata is not present', async () => {
|
||
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'refresh123'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods for token exchange
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier');
|
||
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with authorization code
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server',
|
||
authorizationCode: 'auth-code-123'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Find the token exchange call
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
|
||
const body = tokenCall![1].body as URLSearchParams;
|
||
// Resource parameter should not be present when PRM is not available
|
||
expect(body.has('resource')).toBe(false);
|
||
expect(body.get('code')).toBe('auth-code-123');
|
||
});
|
||
|
||
it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => {
|
||
// Mock metadata discovery - no protected resource metadata, but auth server metadata available
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: false,
|
||
status: 404
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/token')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
access_token: 'new-access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods for token refresh
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue({
|
||
access_token: 'old-access',
|
||
refresh_token: 'refresh123'
|
||
});
|
||
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with existing tokens (should trigger refresh)
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/mcp-server'
|
||
});
|
||
|
||
expect(result).toBe('AUTHORIZED');
|
||
|
||
// Find the token refresh call
|
||
const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token'));
|
||
expect(tokenCall).toBeDefined();
|
||
|
||
const body = tokenCall![1].body as URLSearchParams;
|
||
// Resource parameter should not be present when PRM is not available
|
||
expect(body.has('resource')).toBe(false);
|
||
expect(body.get('grant_type')).toBe('refresh_token');
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
});
|
||
|
||
it('uses scopes_supported from PRM when scope is not provided', async () => {
|
||
// Mock PRM with scopes_supported
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/',
|
||
authorization_servers: ['https://auth.example.com'],
|
||
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/register')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods - no scope in clientMetadata
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
mockProvider.saveClientInformation = vi.fn();
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth without scope parameter
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the authorization URL includes the scopes from PRM
|
||
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
|
||
const authUrl: URL = redirectCall[0];
|
||
expect(authUrl.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin');
|
||
|
||
// Verify the same scope was also used in the DCR request body
|
||
const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register'));
|
||
expect(registerCall).toBeDefined();
|
||
const registerBody = JSON.parse(registerCall![1].body);
|
||
expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin');
|
||
});
|
||
|
||
it('prefers explicit scope parameter over scopes_supported from PRM', async () => {
|
||
// Mock PRM with scopes_supported
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString.includes('/.well-known/oauth-protected-resource')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://api.example.com/',
|
||
authorization_servers: ['https://auth.example.com'],
|
||
scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/.well-known/oauth-authorization-server')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
} else if (urlString.includes('/register')) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
client_id: 'test-client-id',
|
||
client_secret: 'test-client-secret',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
mockProvider.saveClientInformation = vi.fn();
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with explicit scope parameter
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://api.example.com/',
|
||
scope: 'mcp:read'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the authorization URL uses the explicit scope, not scopes_supported
|
||
const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0];
|
||
const authUrl: URL = redirectCall[0];
|
||
expect(authUrl.searchParams.get('scope')).toBe('mcp:read');
|
||
});
|
||
|
||
it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => {
|
||
// Mock PRM discovery that returns an external AS
|
||
mockFetch.mockImplementation(url => {
|
||
const urlString = url.toString();
|
||
|
||
if (urlString === 'https://my.resource.com/.well-known/oauth-protected-resource/path/name') {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://my.resource.com/',
|
||
authorization_servers: ['https://auth.example.com/oauth']
|
||
})
|
||
});
|
||
} else if (urlString === 'https://auth.example.com/.well-known/oauth-authorization-server/path/name') {
|
||
// Path-aware discovery on AS with path from serverUrl
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
}
|
||
|
||
return Promise.resolve({ ok: false, status: 404 });
|
||
});
|
||
|
||
// Mock provider methods
|
||
(mockProvider.clientInformation as Mock).mockResolvedValue({
|
||
client_id: 'test-client',
|
||
client_secret: 'test-secret'
|
||
});
|
||
(mockProvider.tokens as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined);
|
||
(mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined);
|
||
|
||
// Call auth with serverUrl that has a path
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://my.resource.com/path/name'
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
|
||
// Verify the correct URLs were fetched
|
||
const calls = mockFetch.mock.calls;
|
||
|
||
// First call should be to PRM
|
||
expect(calls[0][0].toString()).toBe('https://my.resource.com/.well-known/oauth-protected-resource/path/name');
|
||
|
||
// Second call should be to AS metadata with the path from authorization server
|
||
expect(calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/oauth');
|
||
});
|
||
|
||
it('supports overriding the fetch function used for requests', async () => {
|
||
const customFetch = vi.fn();
|
||
|
||
// Mock PRM discovery
|
||
customFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
|
||
// Mock AS metadata discovery
|
||
customFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
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',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
|
||
const mockProvider: OAuthClientProvider = {
|
||
get redirectUrl() {
|
||
return 'http://localhost:3000/callback';
|
||
},
|
||
get clientMetadata() {
|
||
return {
|
||
client_name: 'Test Client',
|
||
redirect_uris: ['http://localhost:3000/callback']
|
||
};
|
||
},
|
||
clientInformation: vi.fn().mockResolvedValue({
|
||
client_id: 'client123',
|
||
client_secret: 'secret123'
|
||
}),
|
||
tokens: vi.fn().mockResolvedValue(undefined),
|
||
saveTokens: vi.fn(),
|
||
redirectToAuthorization: vi.fn(),
|
||
saveCodeVerifier: vi.fn(),
|
||
codeVerifier: vi.fn().mockResolvedValue('verifier123')
|
||
};
|
||
|
||
const result = await auth(mockProvider, {
|
||
serverUrl: 'https://resource.example.com',
|
||
fetchFn: customFetch
|
||
});
|
||
|
||
expect(result).toBe('REDIRECT');
|
||
expect(customFetch).toHaveBeenCalledTimes(2);
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
|
||
// Verify custom fetch was called for PRM discovery
|
||
expect(customFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
|
||
// Verify custom fetch was called for AS metadata discovery
|
||
expect(customFetch.mock.calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||
});
|
||
});
|
||
|
||
describe('exchangeAuthorization with multiple client authentication methods', () => {
|
||
const validTokens = {
|
||
access_token: 'access123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'refresh123'
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
const metadataWithBasicOnly = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/auth',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256'],
|
||
token_endpoint_auth_methods_supported: ['client_secret_basic']
|
||
};
|
||
|
||
const metadataWithPostOnly = {
|
||
...metadataWithBasicOnly,
|
||
token_endpoint_auth_methods_supported: ['client_secret_post']
|
||
};
|
||
|
||
const metadataWithNoneOnly = {
|
||
...metadataWithBasicOnly,
|
||
token_endpoint_auth_methods_supported: ['none']
|
||
};
|
||
|
||
const metadataWithAllBuiltinMethods = {
|
||
...metadataWithBasicOnly,
|
||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none']
|
||
};
|
||
|
||
it('uses HTTP Basic authentication when client_secret_basic is supported', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithBasicOnly,
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
codeVerifier: 'verifier123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check Authorization header
|
||
const authHeader = request.headers.get('Authorization');
|
||
const expected = 'Basic ' + btoa('client123:secret123');
|
||
expect(authHeader).toBe(expected);
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
|
||
it('includes credentials in request body when client_secret_post is supported', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithPostOnly,
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
codeVerifier: 'verifier123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check no Authorization header
|
||
expect(request.headers.get('Authorization')).toBeNull();
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBe('client123');
|
||
expect(body.get('client_secret')).toBe('secret123');
|
||
});
|
||
|
||
it('it picks client_secret_basic when all builtin methods are supported', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithAllBuiltinMethods,
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
codeVerifier: 'verifier123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check Authorization header - should use Basic auth as it's the most secure
|
||
const authHeader = request.headers.get('Authorization');
|
||
const expected = 'Basic ' + btoa('client123:secret123');
|
||
expect(authHeader).toBe(expected);
|
||
|
||
// Credentials should not be in body when using Basic auth
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
|
||
it('uses public client authentication when none method is specified', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const clientInfoWithoutSecret = {
|
||
client_id: 'client123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithNoneOnly,
|
||
clientInformation: clientInfoWithoutSecret,
|
||
authorizationCode: 'code123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
codeVerifier: 'verifier123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check no Authorization header
|
||
expect(request.headers.get('Authorization')).toBeNull();
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBe('client123');
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
|
||
it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await exchangeAuthorization('https://auth.example.com', {
|
||
clientInformation: validClientInfo,
|
||
authorizationCode: 'code123',
|
||
redirectUri: 'http://localhost:3000/callback',
|
||
codeVerifier: 'verifier123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted,
|
||
// the default is client_secret_basic (HTTP Basic auth, not body params)
|
||
const authHeader = request.headers.get('Authorization');
|
||
const expected = 'Basic ' + btoa('client123:secret123');
|
||
expect(authHeader).toBe(expected);
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBeNull();
|
||
expect(body.get('client_secret')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('refreshAuthorization with multiple client authentication methods', () => {
|
||
const validTokens = {
|
||
access_token: 'newaccess123',
|
||
token_type: 'Bearer',
|
||
expires_in: 3600,
|
||
refresh_token: 'newrefresh123'
|
||
};
|
||
|
||
const validClientInfo = {
|
||
client_id: 'client123',
|
||
client_secret: 'secret123',
|
||
redirect_uris: ['http://localhost:3000/callback'],
|
||
client_name: 'Test Client'
|
||
};
|
||
|
||
const metadataWithBasicOnly = {
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/auth',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
token_endpoint_auth_methods_supported: ['client_secret_basic']
|
||
};
|
||
|
||
const metadataWithPostOnly = {
|
||
...metadataWithBasicOnly,
|
||
token_endpoint_auth_methods_supported: ['client_secret_post']
|
||
};
|
||
|
||
it('uses client_secret_basic for refresh token', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await refreshAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithBasicOnly,
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check Authorization header
|
||
const authHeader = request.headers.get('Authorization');
|
||
const expected = 'Basic ' + btoa('client123:secret123');
|
||
expect(authHeader).toBe(expected);
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBeNull(); // should not be in body
|
||
expect(body.get('client_secret')).toBeNull(); // should not be in body
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
});
|
||
|
||
it('uses client_secret_post for refresh token', async () => {
|
||
mockFetch.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => validTokens
|
||
});
|
||
|
||
const tokens = await refreshAuthorization('https://auth.example.com', {
|
||
metadata: metadataWithPostOnly,
|
||
clientInformation: validClientInfo,
|
||
refreshToken: 'refresh123'
|
||
});
|
||
|
||
expect(tokens).toEqual(validTokens);
|
||
const request = mockFetch.mock.calls[0][1];
|
||
|
||
// Check no Authorization header
|
||
expect(request.headers.get('Authorization')).toBeNull();
|
||
|
||
const body = request.body as URLSearchParams;
|
||
expect(body.get('client_id')).toBe('client123');
|
||
expect(body.get('client_secret')).toBe('secret123');
|
||
expect(body.get('refresh_token')).toBe('refresh123');
|
||
});
|
||
});
|
||
|
||
describe('RequestInit headers passthrough', () => {
|
||
it('custom headers from RequestInit are passed to auth discovery requests', async () => {
|
||
const { createFetchWithInit } = await import('../../src/shared/transport.js');
|
||
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
|
||
// Create a wrapped fetch with custom headers
|
||
const wrappedFetch = createFetchWithInit(customFetch, {
|
||
headers: {
|
||
'user-agent': 'MyApp/1.0',
|
||
'x-custom-header': 'test-value'
|
||
}
|
||
});
|
||
|
||
await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch);
|
||
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
const [url, options] = customFetch.mock.calls[0];
|
||
|
||
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
|
||
expect(options.headers).toMatchObject({
|
||
'user-agent': 'MyApp/1.0',
|
||
'x-custom-header': 'test-value',
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('auth-specific headers override base headers from RequestInit', async () => {
|
||
const { createFetchWithInit } = await import('../../src/shared/transport.js');
|
||
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
issuer: 'https://auth.example.com',
|
||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||
token_endpoint: 'https://auth.example.com/token',
|
||
response_types_supported: ['code'],
|
||
code_challenge_methods_supported: ['S256']
|
||
})
|
||
});
|
||
|
||
// Create a wrapped fetch with a custom Accept header
|
||
const wrappedFetch = createFetchWithInit(customFetch, {
|
||
headers: {
|
||
Accept: 'text/plain',
|
||
'user-agent': 'MyApp/1.0'
|
||
}
|
||
});
|
||
|
||
await discoverAuthorizationServerMetadata('https://auth.example.com', {
|
||
fetchFn: wrappedFetch
|
||
});
|
||
|
||
expect(customFetch).toHaveBeenCalled();
|
||
const [, options] = customFetch.mock.calls[0];
|
||
|
||
// Auth-specific Accept header should override base Accept header
|
||
expect(options.headers).toMatchObject({
|
||
Accept: 'application/json', // Auth-specific value wins
|
||
'user-agent': 'MyApp/1.0', // Base value preserved
|
||
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
|
||
});
|
||
});
|
||
|
||
it('other RequestInit options are passed through', async () => {
|
||
const { createFetchWithInit } = await import('../../src/shared/transport.js');
|
||
|
||
const customFetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
resource: 'https://resource.example.com',
|
||
authorization_servers: ['https://auth.example.com']
|
||
})
|
||
});
|
||
|
||
// Create a wrapped fetch with various RequestInit options
|
||
const wrappedFetch = createFetchWithInit(customFetch, {
|
||
credentials: 'include',
|
||
mode: 'cors',
|
||
cache: 'no-cache',
|
||
headers: {
|
||
'user-agent': 'MyApp/1.0'
|
||
}
|
||
});
|
||
|
||
await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch);
|
||
|
||
expect(customFetch).toHaveBeenCalledTimes(1);
|
||
const [, options] = customFetch.mock.calls[0];
|
||
|
||
// All RequestInit options should be preserved
|
||
expect(options.credentials).toBe('include');
|
||
expect(options.mode).toBe('cors');
|
||
expect(options.cache).toBe('no-cache');
|
||
expect(options.headers).toMatchObject({
|
||
'user-agent': 'MyApp/1.0'
|
||
});
|
||
});
|
||
});
|
||
|
||
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']
|
||
});
|
||
});
|
||
});
|
||
});
|