feat(sep-2468): cover the remaining untested traceability rows (#304)
Add two client scenarios for the SEP-2468 requirements that had no conformance coverage: - auth/metadata-issuer-mismatch: the AS metadata document declares an issuer that differs from the issuer identifier used to construct the well-known URL; the client must not use the metadata (sep-2468-client-validate-metadata-issuer). - auth/iss-normalized: the authorization redirect carries an iss value that only matches the recorded issuer after URL normalization; the client must compare with simple string comparison and reject (sep-2468-client-no-normalization). The two authorization-server-side rows (AS SHOULD include iss, AS MUST advertise authorization_response_iss_parameter_supported) are excluded: observing iss in an authorization response requires driving an Authorization Code Grant against the AS under test, which the authorization-server suite does not implement yet (#208).
This commit is contained in:
parent
909ea0b5fd
commit
f12baed382
3 changed files with 518 additions and 13 deletions
243
examples/clients/typescript/auth-test-iss-normalize.ts
Normal file
243
examples/clients/typescript/auth-test-iss-normalize.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Misbehaving client that normalizes URLs before comparing the iss parameter.
|
||||
*
|
||||
* Per RFC 9207 / SEP-2468, iss comparison must be a simple string comparison
|
||||
* with no URL normalization. This client instead compares
|
||||
* `new URL(received).href` against `new URL(expected).href`, so a
|
||||
* normalization-equivalent variant of the issuer (e.g. a trailing slash on an
|
||||
* empty path) is incorrectly accepted and the client proceeds to the token
|
||||
* endpoint. It also does not validate the AS metadata issuer against the
|
||||
* issuer identifier used to construct the well-known URL.
|
||||
*
|
||||
* Used as a negative fixture for the auth/iss-normalized scenario.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
import { logger } from './helpers/logger';
|
||||
|
||||
interface OAuthTokens {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function computeS256Challenge(codeVerifier: string): string {
|
||||
const hash = createHash('sha256').update(codeVerifier).digest();
|
||||
return hash
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the two URLs are equal after URL normalization. This is
|
||||
* the incorrect comparison this fixture deliberately performs.
|
||||
*/
|
||||
function urlsMatchAfterNormalization(a: string, b: string): boolean {
|
||||
return new URL(a).href === new URL(b).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth flow that incorrectly normalizes URLs before comparing iss.
|
||||
*/
|
||||
async function oauthFlowWithIssNormalization(
|
||||
_serverUrl: string | URL,
|
||||
resourceMetadataUrl: string | URL,
|
||||
fetchFn: FetchLike
|
||||
): Promise<OAuthTokens> {
|
||||
// 1. Fetch Protected Resource Metadata
|
||||
const prmResponse = await fetchFn(resourceMetadataUrl);
|
||||
if (!prmResponse.ok) {
|
||||
throw new Error(`Failed to fetch PRM: ${prmResponse.status}`);
|
||||
}
|
||||
const prm = await prmResponse.json();
|
||||
const authServerUrl = prm.authorization_servers?.[0];
|
||||
if (!authServerUrl) {
|
||||
throw new Error('No authorization server in PRM');
|
||||
}
|
||||
|
||||
// 2. Fetch Authorization Server Metadata
|
||||
const asMetadataUrl = new URL(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
authServerUrl
|
||||
);
|
||||
const asResponse = await fetchFn(asMetadataUrl.toString());
|
||||
if (!asResponse.ok) {
|
||||
throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`);
|
||||
}
|
||||
const asMetadata = await asResponse.json();
|
||||
|
||||
// NOTE: deliberately no RFC 8414 §3.3 metadata-issuer validation here.
|
||||
|
||||
const expectedIssuer: string = asMetadata.issuer;
|
||||
const issParameterSupported: boolean =
|
||||
asMetadata.authorization_response_iss_parameter_supported === true;
|
||||
|
||||
// 3. Register client (DCR)
|
||||
const dcrResponse = await fetchFn(asMetadata.registration_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: 'test-auth-client-iss-normalize',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
application_type: 'native'
|
||||
})
|
||||
});
|
||||
if (!dcrResponse.ok) {
|
||||
throw new Error(`DCR failed: ${dcrResponse.status}`);
|
||||
}
|
||||
const clientInfo = await dcrResponse.json();
|
||||
|
||||
// 4. Build authorization URL with PKCE
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const authUrl = new URL(asMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientInfo.client_id);
|
||||
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
|
||||
authUrl.searchParams.set('state', 'test-state');
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
// 5. Fetch authorization endpoint (simulates redirect)
|
||||
const authResponse = await fetchFn(authUrl.toString(), {
|
||||
redirect: 'manual'
|
||||
});
|
||||
const location = authResponse.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('No redirect from authorization endpoint');
|
||||
}
|
||||
const redirectUrl = new URL(location);
|
||||
const authCode = redirectUrl.searchParams.get('code');
|
||||
if (!authCode) {
|
||||
throw new Error('No auth code in redirect');
|
||||
}
|
||||
|
||||
// 6. Validate iss parameter — INCORRECTLY normalizing both sides first
|
||||
const issInRedirect = redirectUrl.searchParams.get('iss');
|
||||
|
||||
if (issParameterSupported) {
|
||||
// Server advertised support: iss must be present, but this client
|
||||
// accepts any normalization-equivalent value.
|
||||
if (!issInRedirect) {
|
||||
throw new Error(
|
||||
'Server advertised authorization_response_iss_parameter_supported but iss is absent from redirect'
|
||||
);
|
||||
}
|
||||
if (!urlsMatchAfterNormalization(issInRedirect, expectedIssuer)) {
|
||||
throw new Error(
|
||||
`iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'`
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
issInRedirect &&
|
||||
!urlsMatchAfterNormalization(issInRedirect, expectedIssuer)
|
||||
) {
|
||||
throw new Error(
|
||||
`iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'`
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Exchange code for token with PKCE code_verifier
|
||||
const tokenResponse = await fetchFn(asMetadata.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode,
|
||||
redirect_uri: 'http://localhost:3000/callback',
|
||||
client_id: clientInfo.client_id,
|
||||
code_verifier: codeVerifier
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text();
|
||||
throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`);
|
||||
}
|
||||
|
||||
return tokenResponse.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fetch wrapper that uses OAuth with normalized iss comparison.
|
||||
*/
|
||||
function withOAuthIssNormalization(baseUrl: string | URL): Middleware {
|
||||
let tokens: OAuthTokens | undefined;
|
||||
|
||||
return (next: FetchLike) => {
|
||||
return async (
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (tokens) {
|
||||
headers.set('Authorization', `Bearer ${tokens.access_token}`);
|
||||
}
|
||||
return next(input, { ...init, headers });
|
||||
};
|
||||
|
||||
let response = await makeRequest();
|
||||
|
||||
if (response.status === 401) {
|
||||
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
|
||||
if (!resourceMetadataUrl) {
|
||||
throw new Error('No resource_metadata in WWW-Authenticate');
|
||||
}
|
||||
tokens = await oauthFlowWithIssNormalization(
|
||||
baseUrl,
|
||||
resourceMetadataUrl,
|
||||
next
|
||||
);
|
||||
response = await makeRequest();
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-auth-client-iss-normalize', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
const oauthFetch = withOAuthIssNormalization(new URL(serverUrl))(fetch);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: oauthFetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-iss-normalize <server-url>');
|
||||
|
|
@ -3,12 +3,19 @@
|
|||
/**
|
||||
* Well-behaved client that validates the iss parameter in authorization responses.
|
||||
*
|
||||
* Per RFC 8414 §3.3:
|
||||
* - The issuer in the retrieved AS metadata document MUST be identical to the
|
||||
* issuer identifier used to construct the well-known URL; otherwise the
|
||||
* metadata MUST NOT be used.
|
||||
*
|
||||
* Per RFC 9207:
|
||||
* - If the AS advertises authorization_response_iss_parameter_supported: true,
|
||||
* the client MUST require iss in the redirect and MUST validate it against
|
||||
* the AS metadata issuer.
|
||||
* - If the AS does NOT advertise support, the client MUST reject any redirect
|
||||
* that unexpectedly contains an iss parameter.
|
||||
* - If the AS does NOT advertise support but the redirect contains iss anyway,
|
||||
* the client MUST still compare it to the recorded issuer and reject on
|
||||
* mismatch (SEP-2468 validation table row 3).
|
||||
* - Comparison is simple string comparison — no URL normalization.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
|
@ -75,6 +82,15 @@ async function oauthFlowWithIssValidation(
|
|||
}
|
||||
const asMetadata = await asResponse.json();
|
||||
|
||||
// RFC 8414 §3.3: the issuer in the metadata document must be identical
|
||||
// (simple string comparison) to the issuer identifier used to construct
|
||||
// the well-known URL. On mismatch the metadata must not be used.
|
||||
if (asMetadata.issuer !== authServerUrl) {
|
||||
throw new Error(
|
||||
`AS metadata issuer mismatch: expected '${authServerUrl}', got '${asMetadata.issuer}'`
|
||||
);
|
||||
}
|
||||
|
||||
const expectedIssuer: string = asMetadata.issuer;
|
||||
const issParameterSupported: boolean =
|
||||
asMetadata.authorization_response_iss_parameter_supported === true;
|
||||
|
|
|
|||
|
|
@ -351,7 +351,9 @@ registerScenarios(
|
|||
[
|
||||
'auth/iss-supported-missing',
|
||||
'auth/iss-wrong-issuer',
|
||||
'auth/iss-unexpected'
|
||||
'auth/iss-unexpected',
|
||||
'auth/iss-normalized',
|
||||
'auth/metadata-issuer-mismatch'
|
||||
],
|
||||
issValidationClient
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue