typescript-sdk/test/client/auth.test.ts
Paul Carleton 4cbcec0edb
[v1.x backport] Default to client_secret_basic when server omits token_endpoint_auth_methods_supported (#1611)
Co-authored-by: Basil Hosmer <basil@anthropic.com>
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
2026-03-02 11:46:30 +00:00

3685 lines
155 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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']
});
});
});
});