Default to S256 code challenge if not specified in authorization server metadata (#992)
This commit is contained in:
parent
55309642d9
commit
59cb50463a
2 changed files with 62 additions and 58 deletions
|
|
@ -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,
|
||||
|
|
@ -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',
|
||||
|
|
@ -986,9 +973,9 @@ 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'
|
||||
});
|
||||
|
|
@ -996,9 +983,9 @@ describe('OAuth Authorization', () => {
|
|||
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'
|
||||
};
|
||||
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue