Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 49 additions & 39 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,30 +786,6 @@ describe('OAuth Authorization', () => {
expect(calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server');
});

it('throws error when OIDC provider does not support S256 PKCE', async () => {
// OAuth discovery fails
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404
});

// OpenID Connect discovery succeeds but without S256 support
const invalidOpenIdMetadata = {
...validOpenIdMetadata,
code_challenge_methods_supported: ['plain'] // Missing S256
};

mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => invalidOpenIdMetadata
});

await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow(
'does not support S256 code challenge method required by MCP specification'
);
});

it('continues on 4xx errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down Expand Up @@ -913,6 +889,17 @@ describe('OAuth Authorization', () => {
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',
Expand Down Expand Up @@ -986,19 +973,19 @@ describe('OAuth Authorization', () => {
expect(authorizationUrl.searchParams.get('prompt')).toBe('consent');
});

it('uses metadata authorization_endpoint when provided', async () => {
it.each([validMetadata, validOpenIdMetadata])('uses metadata authorization_endpoint when provided', async baseMetadata => {
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
metadata: validMetadata,
metadata: baseMetadata,
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback'
});

expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/auth\?/);
});

it('validates response type support', async () => {
it.each([validMetadata, validOpenIdMetadata])('validates response type support', async baseMetadata => {
const metadata = {
...validMetadata,
...baseMetadata,
response_types_supported: ['token'] // Does not support 'code'
};

Expand All @@ -1011,21 +998,44 @@ describe('OAuth Authorization', () => {
).rejects.toThrow(/does not support response type/);
});

it('validates PKCE support', async () => {
const metadata = {
...validMetadata,
response_types_supported: ['code'],
code_challenge_methods_supported: ['plain'] // Does not support 'S256'
};
// 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
};

await expect(
startAuthorization('https://auth.example.com', {
const { authorizationUrl } = await startAuthorization('https://auth.example.com', {
metadata,
clientInformation: validClientInfo,
redirectUrl: 'http://localhost:3000/callback'
})
).rejects.toThrow(/does not support code challenge method/);
});
});

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', () => {
Expand Down
32 changes: 13 additions & 19 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ export class UnauthorizedError extends Error {

type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';

const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code';
const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256';

/**
* Determines the best client authentication method to use based on server support and client configuration.
*
Expand Down Expand Up @@ -766,16 +769,7 @@ export async function discoverAuthorizationServerMetadata(
if (type === 'oauth') {
return OAuthMetadataSchema.parse(await response.json());
} else {
const metadata = OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());

// MCP spec requires OIDC providers to support S256 PKCE
if (!metadata.code_challenge_methods_supported?.includes('S256')) {
throw new Error(
`Incompatible OIDC provider at ${endpointUrl}: does not support S256 code challenge method required by MCP specification`
);
}

return metadata;
return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());
}
}

Expand Down Expand Up @@ -803,19 +797,19 @@ export async function startAuthorization(
resource?: URL;
}
): Promise<{ authorizationUrl: URL; codeVerifier: string }> {
const responseType = 'code';
const codeChallengeMethod = 'S256';

let authorizationUrl: URL;
if (metadata) {
authorizationUrl = new URL(metadata.authorization_endpoint);

if (!metadata.response_types_supported.includes(responseType)) {
throw new Error(`Incompatible auth server: does not support response type ${responseType}`);
if (!metadata.response_types_supported.includes(AUTHORIZATION_CODE_RESPONSE_TYPE)) {
throw new Error(`Incompatible auth server: does not support response type ${AUTHORIZATION_CODE_RESPONSE_TYPE}`);
}

if (!metadata.code_challenge_methods_supported || !metadata.code_challenge_methods_supported.includes(codeChallengeMethod)) {
throw new Error(`Incompatible auth server: does not support code challenge method ${codeChallengeMethod}`);
if (
metadata.code_challenge_methods_supported &&
!metadata.code_challenge_methods_supported.includes(AUTHORIZATION_CODE_CHALLENGE_METHOD)
) {
throw new Error(`Incompatible auth server: does not support code challenge method ${AUTHORIZATION_CODE_CHALLENGE_METHOD}`);
}
} else {
authorizationUrl = new URL('/authorize', authorizationServerUrl);
Expand All @@ -826,10 +820,10 @@ export async function startAuthorization(
const codeVerifier = challenge.code_verifier;
const codeChallenge = challenge.code_challenge;

authorizationUrl.searchParams.set('response_type', responseType);
authorizationUrl.searchParams.set('response_type', AUTHORIZATION_CODE_RESPONSE_TYPE);
authorizationUrl.searchParams.set('client_id', clientInformation.client_id);
authorizationUrl.searchParams.set('code_challenge', codeChallenge);
authorizationUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
authorizationUrl.searchParams.set('code_challenge_method', AUTHORIZATION_CODE_CHALLENGE_METHOD);
authorizationUrl.searchParams.set('redirect_uri', String(redirectUrl));

if (state) {
Expand Down