Implement SEP-990 Enterprise Managed OAuth (#1593)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
sagar-okta 2026-03-15 19:48:14 +05:30 • committed by GitHub
commit ccb78f2043
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1518 additions and 8 deletions

View file

@ -1,10 +1,11 @@
import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { auth } from '../../src/client/auth.js';
import {
ClientCredentialsProvider,
createPrivateKeyJwtAuth,
CrossAppAccessProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider
} from '../../src/client/authExtensions.js';
@ -330,3 +331,301 @@ describe('createPrivateKeyJwtAuth', () => {
);
});
});
describe('CrossAppAccessProvider', () => {
const RESOURCE_SERVER_URL = 'https://mcp.chat.example/';
const AUTH_SERVER_URL = 'https://auth.chat.example';
const IDP_URL = 'https://idp.example.com';
it('successfully authenticates using Cross-App Access flow', async () => {
let assertionCallbackInvoked = false;
let jwtGrantUsed = '';
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
assertionCallbackInvoked = true;
expect(ctx.authorizationServerUrl).toBe(AUTH_SERVER_URL);
expect(ctx.resourceUrl).toBe(RESOURCE_SERVER_URL);
expect(ctx.scope).toBeUndefined();
expect(ctx.fetchFn).toBeDefined();
return 'jwt-authorization-grant-token';
},
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
clientName: 'xaa-test-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
jwtGrantUsed = params.get('assertion') || '';
expect(jwtGrantUsed).toBe('jwt-authorization-grant-token');
// Verify client authentication
const headers = new Headers(init?.headers);
const authHeader = headers.get('Authorization');
expect(authHeader).toBeTruthy();
const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64');
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
expect(assertionCallbackInvoked).toBe(true);
expect(jwtGrantUsed).toBe('jwt-authorization-grant-token');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
it('passes scope to assertion callback', async () => {
let capturedScope: string | undefined;
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
capturedScope = ctx.scope;
return 'jwt-grant';
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
scope: 'chat.read chat.history',
fetchFn: fetchMock
});
expect(capturedScope).toBe('chat.read chat.history');
});
it('passes custom fetchFn to assertion callback', async () => {
let capturedFetchFn: unknown;
const customFetch = vi.fn(fetch);
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
// Wrap the mock to track calls
const wrappedFetch = vi.fn((...args: Parameters<typeof fetchMock>) => fetchMock(...args));
const provider = new CrossAppAccessProvider({
assertion: async ctx => {
capturedFetchFn = ctx.fetchFn;
return 'jwt-grant';
},
clientId: 'client',
clientSecret: 'secret',
fetchFn: customFetch
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: wrappedFetch
});
// The assertion callback should receive the custom fetch function
expect(capturedFetchFn).toBe(customFetch);
});
it('throws error when authorization server URL is not available', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
// Try to call prepareTokenRequest without going through auth()
await expect(provider.prepareTokenRequest()).rejects.toThrow(
'Authorization server URL not available. Ensure auth() has been called first.'
);
});
it('throws error when resource URL is not available', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
// Manually set authorization server URL but not resource URL
provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL);
await expect(provider.prepareTokenRequest()).rejects.toThrow(
'Resource URL not available — server may not implement RFC 9728 Protected Resource Metadata'
);
});
it('stores and retrieves authorization server URL', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.authorizationServerUrl?.()).toBeUndefined();
provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL);
expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL);
});
it('stores and retrieves resource URL', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.resourceUrl?.()).toBeUndefined();
provider.saveResourceUrl?.(RESOURCE_SERVER_URL);
expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL);
});
it('has correct client metadata', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret',
clientName: 'custom-xaa-client'
});
const metadata = provider.clientMetadata;
expect(metadata.client_name).toBe('custom-xaa-client');
expect(metadata.redirect_uris).toEqual([]);
expect(metadata.grant_types).toEqual(['urn:ietf:params:oauth:grant-type:jwt-bearer']);
expect(metadata.token_endpoint_auth_method).toBe('client_secret_basic');
});
it('uses default client name when not provided', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.clientMetadata.client_name).toBe('cross-app-access-client');
});
it('returns undefined for redirectUrl (non-interactive flow)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(provider.redirectUrl).toBeUndefined();
});
it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(() => provider.redirectToAuthorization()).toThrow('redirectToAuthorization is not used for jwt-bearer flow');
});
it('throws error for codeVerifier (not used in jwt-bearer)', () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
expect(() => provider.codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow');
});
it('handles assertion callback errors gracefully', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => {
throw new Error('Failed to get ID token from IdP');
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await expect(
auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
})
).rejects.toThrow('Failed to get ID token from IdP');
});
it('allows assertion callback to return a promise', async () => {
const provider = new CrossAppAccessProvider({
assertion: ctx => {
return new Promise(resolve => {
setTimeout(() => resolve('async-jwt-grant'), 10);
});
},
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params.get('assertion')).toBe('async-jwt-grant');
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
});
it('includes scope in token request params when provided', async () => {
const provider = new CrossAppAccessProvider({
assertion: async () => 'jwt-grant',
clientId: 'client',
clientSecret: 'secret'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params.get('scope')).toBe('chat.read chat.write');
}
});
await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
scope: 'chat.read chat.write',
fetchFn: fetchMock
});
});
});

View file

@ -0,0 +1,428 @@
import type { FetchLike } from '@modelcontextprotocol/core';
import { describe, expect, it, vi } from 'vitest';
import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js';
describe('crossAppAccess', () => {
describe('requestJwtAuthorizationGrant', () => {
it('successfully exchanges ID token for JWT Authorization Grant', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
token_type: 'N_A',
expires_in: 300,
scope: 'chat.read chat.history'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
clientId: 'my-idp-client',
clientSecret: 'my-idp-secret',
scope: 'chat.read chat.history',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(result.expiresIn).toBe(300);
expect(result.scope).toBe('chat.read chat.history');
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://idp.example.com/token');
expect(init?.method).toBe('POST');
expect(init?.headers).toEqual({
'Content-Type': 'application/x-www-form-urlencoded'
});
const body = new URLSearchParams(init?.body as string);
expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange');
expect(body.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:id-jag');
expect(body.get('audience')).toBe('https://auth.chat.example/');
expect(body.get('resource')).toBe('https://mcp.chat.example/');
expect(body.get('subject_token')).toBe('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(body.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token');
expect(body.get('client_id')).toBe('my-idp-client');
expect(body.get('client_secret')).toBe('my-idp-secret');
expect(body.get('scope')).toBe('chat.read chat.history');
});
it('works without optional scope parameter', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string);
expect(body.get('scope')).toBeNull();
});
it('omits client_secret from body when not provided (public client)', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A'
})
} as Response);
await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'public-client',
fetchFn: mockFetch
});
const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string);
expect(body.get('client_id')).toBe('public-client');
// Must be absent — not empty string, not the literal "undefined"
expect(body.has('client_secret')).toBe(false);
});
it('throws error when issued_token_type is incorrect', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
access_token: 'token',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token exchange response');
});
it('accepts token_type other than N_A (issued_token_type is the real check)', async () => {
// RFC 6749 §5.1: token_type is case-insensitive; RFC 8693 §2.2.1: informational
// when the issued token isn't an access token. Real IdPs return 'n_a', 'Bearer', etc.
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'n_a'
})
} as Response);
const result = await requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
});
it('throws error when access_token is missing', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token exchange response');
});
it('handles OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
error: 'invalid_grant',
error_description: 'Audience validation failed'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed');
});
it('handles non-OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ message: 'Internal server error' })
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'https://idp.example.com/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Token exchange failed with status 500');
});
});
describe('discoverAndRequestJwtAuthGrant', () => {
it('discovers token endpoint and performs token exchange', async () => {
const mockFetch = vi.fn<FetchLike>();
// Mock discovery response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
issuer: 'https://idp.example.com',
authorization_endpoint: 'https://idp.example.com/authorize',
token_endpoint: 'https://idp.example.com/token',
jwks_uri: 'https://idp.example.com/jwks',
response_types_supported: ['code'],
grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange']
})
} as Response);
// Mock token exchange response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag-token',
token_type: 'N_A',
expires_in: 300
})
} as Response);
const result = await discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
});
expect(result.jwtAuthGrant).toBe('jag-token');
expect(result.expiresIn).toBe(300);
expect(mockFetch).toHaveBeenCalledTimes(2);
// First call is discovery
expect(String(mockFetch.mock.calls[0]![0])).toContain('.well-known/oauth-authorization-server');
// Second call is token exchange
expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token');
});
it('throws error when token endpoint is not discovered', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issuer: 'https://idp.example.com',
authorization_endpoint: 'https://idp.example.com/authorize'
// Missing token_endpoint and response_types_supported
})
} as Response);
await expect(
discoverAndRequestJwtAuthGrant({
idpUrl: 'https://idp.example.com',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(); // Zod validation error
});
});
describe('exchangeJwtAuthGrant', () => {
it('exchanges JAG for access token using client_secret_basic by default', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'chat.read chat.history'
})
} as Response);
const result = await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
fetchFn: mockFetch
});
expect(result.access_token).toBe('mcp-access-token');
expect(result.token_type).toBe('Bearer');
expect(result.expires_in).toBe(3600);
expect(result.scope).toBe('chat.read chat.history');
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://auth.chat.example/token');
expect(init?.method).toBe('POST');
// SEP-990 conformance: credentials in Authorization header, NOT in body
const headers = new Headers(init?.headers as Headers);
const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64');
expect(headers.get('Authorization')).toBe(`Basic ${expectedCredentials}`);
const body = new URLSearchParams(init?.body as string);
expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer');
expect(body.get('assertion')).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
expect(body.has('client_id')).toBe(false);
expect(body.has('client_secret')).toBe(false);
});
it('supports client_secret_post when explicitly requested', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer'
})
} as Response);
await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'my-mcp-client',
clientSecret: 'my-mcp-secret',
authMethod: 'client_secret_post',
fetchFn: mockFetch
});
const [, init] = mockFetch.mock.calls[0]!;
const headers = new Headers(init?.headers as Headers);
expect(headers.get('Authorization')).toBeNull();
const body = new URLSearchParams(init?.body as string);
expect(body.get('client_id')).toBe('my-mcp-client');
expect(body.get('client_secret')).toBe('my-mcp-secret');
});
it('supports authMethod none for public clients', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'mcp-access-token',
token_type: 'Bearer'
})
} as Response);
await exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'my-public-client',
authMethod: 'none',
fetchFn: mockFetch
});
const [, init] = mockFetch.mock.calls[0]!;
const headers = new Headers(init?.headers as Headers);
expect(headers.get('Authorization')).toBeNull();
const body = new URLSearchParams(init?.body as string);
expect(body.get('client_id')).toBe('my-public-client');
expect(body.has('client_secret')).toBe(false);
});
it('handles OAuth error responses', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({
error: 'invalid_grant',
error_description: 'JWT signature verification failed'
})
} as Response);
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'invalid-jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed');
});
it('validates token response with schema', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
// Missing required fields
token_type: 'Bearer'
})
} as Response);
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'https://auth.chat.example/token',
jwtAuthGrant: 'jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow('Invalid token response');
});
});
});