feat: add conformance test for authorization server metadata (#170)

This commit is contained in:
Michito-Okai 2026-04-01 21:42:23 +09:00 • committed by GitHub
commit 050d9cf93a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 479 additions and 10 deletions

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { AuthorizationServerMetadataEndpointScenario } from './authorization-server-metadata.js';
import { request } from 'undici';
vi.mock('undici', () => ({
request: vi.fn()
}));
const mockedRequest = vi.mocked(request);
describe('AuthorizationServerMetadataEndpointScenario (SUCCESS only)', () => {
it('returns SUCCESS for valid authorization server metadata', async () => {
const scenario = new AuthorizationServerMetadataEndpointScenario();
const serverUrl = 'https://example.com';
mockedRequest.mockResolvedValue({
statusCode: 200,
headers: {
'content-type': 'application/json'
},
body: {
json: async () => ({
issuer: 'https://example.com',
authorization_endpoint: 'https://example.com/auth',
token_endpoint: 'https://example.com/token',
response_types_supported: ['code']
})
}
} as any);
const checks = await scenario.run(serverUrl);
expect(checks).toHaveLength(1);
const check = checks[0];
expect(check.status).toBe('SUCCESS');
expect(check.errorMessage).toBeUndefined();
expect(check.details).toBeDefined();
expect(check.details!.contentType).toContain('application/json');
expect((check.details!.body as any).issuer).toBe('https://example.com');
expect((check.details!.body as any).authorization_endpoint).toBe(
'https://example.com/auth'
);
expect((check.details!.body as any).token_endpoint).toBe(
'https://example.com/token'
);
expect((check.details!.body as any).response_types_supported).toEqual([
'code'
]);
});
});

View file

@ -0,0 +1,155 @@
/**
* Authorization server metadata endpoint test scenarios for MCP authorization servers
*/
import {
ClientScenarioForAuthorizationServer,
ConformanceCheck,
SpecVersion
} from '../../types';
import { request } from 'undici';
type Status = 'SUCCESS' | 'FAILURE';
export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer {
name = 'authorization-server-metadata-endpoint';
specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25'];
description = `Test authorization server metadata endpoint.
**Authorization Server Implementation Requirements:**
**Endpoint**: \`authorization server metadata\`
**Requirements**:
- HTTP response status code MUST be 200 OK
- Content-Type header MUST be application/json
- Return a JSON response including issuer, authorization_endpoint, token_endpoint and response_types_supported
- The issuer value MUST match the URI obtained by removing the well-known URI string from the authorization server metadata URI.`;
async run(serverUrl: string): Promise<ConformanceCheck[]> {
let status: Status = 'SUCCESS';
let errorMessage: string | undefined;
let details: any;
let response: any | null = null;
try {
const wellKnownUrls = this.createWellKnownUrl(serverUrl);
for (const url of wellKnownUrls) {
try {
const checkResponse = await request(url, { method: 'GET' });
if (checkResponse.statusCode === 200) {
response = checkResponse;
break;
}
} catch {
// Ignore the error and proceed to the next loop.
}
}
if (!response) {
throw new Error(
'All authorization server metadata endpoints returned invalid status code.'
);
}
this.validateContentType(response.headers['content-type']);
const body = await this.parseJson(response);
const errors: string[] = [];
this.validateMetadataBody(body, serverUrl, errors);
if (errors.length > 0) {
status = 'FAILURE';
errorMessage = errors.join(', ');
}
details = {
contentType: response.headers['content-type'],
body
};
} catch (error) {
status = 'FAILURE';
errorMessage = error instanceof Error ? error.message : String(error);
}
return [
{
id: 'authorization-server-metadata',
name: 'AuthorizationServerMetadata',
description: 'Valid authorization server metadata response',
status,
timestamp: new Date().toISOString(),
errorMessage,
specReferences: [
{
id: 'Authorization-Server-Metadata',
url: 'https://datatracker.ietf.org/doc/html/rfc8414'
}
],
...(details ? { details } : {})
}
];
}
private createWellKnownUrl(serverUrl: string): string[] {
const base = new URL(serverUrl);
const origin = base.origin;
const path = base.pathname.replace(/\/$/, '');
const urls = new Set<string>();
urls.add(`${origin}/.well-known/oauth-authorization-server${path}`);
urls.add(`${origin}/.well-known/openid-configuration${path}`);
urls.add(`${origin}${path}/.well-known/openid-configuration`);
return Array.from(urls);
}
private validateContentType(contentType?: string | string[]): void {
const valid =
typeof contentType === 'string' &&
contentType.toLowerCase().includes('application/json');
if (!valid) {
throw new Error(`Invalid Content-Type: ${contentType ?? '(missing)'}`);
}
}
private async parseJson(response: any): Promise<Record<string, any>> {
const body = await response.body.json();
if (typeof body !== 'object' || body === null) {
throw new Error('Response body is not an object');
}
return body;
}
private validateMetadataBody(
body: Record<string, any>,
serverUrl: string,
errors: string[]
): void {
this.assertString(
body.authorization_endpoint,
'authorization_endpoint',
errors
);
this.assertString(body.token_endpoint, 'token_endpoint', errors);
if (
!Array.isArray(body.response_types_supported) ||
!body.response_types_supported.includes('code')
) {
errors.push(
'Response body does not include valid "response_types_supported" claim'
);
}
if (body.issuer !== serverUrl) {
errors.push(`Invalid issuer: ${body.issuer ?? '(missing)'}`);
}
}
private assertString(value: unknown, name: string, errors: string[]): void {
if (typeof value !== 'string' || value.length === 0) {
errors.push(`Response body does not include valid "${name}" claim`);
}
}
}

View file

@ -1,4 +1,9 @@
import { Scenario, ClientScenario, SpecVersion } from '../types';
import {
Scenario,
ClientScenario,
ClientScenarioForAuthorizationServer,
SpecVersion
} from '../types';
import { InitializeScenario } from './client/initialize';
import { ToolsCallScenario } from './client/tools_call';
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults';
@ -60,6 +65,7 @@ import {
extensionScenariosList
} from './client/auth/index';
import { listMetadataScenarios } from './client/auth/discovery-metadata';
import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata';
// Pending client scenarios (not yet fully tested/implemented)
const pendingClientScenariosList: ClientScenario[] = [
@ -142,6 +148,23 @@ export const clientScenarios = new Map<string, ClientScenario>(
allClientScenariosList.map((scenario) => [scenario.name, scenario])
);
// All client scenarios for authorization server
const allClientScenariosListForAuthorizationServer: ClientScenario[] = [
// Authorization server scenarios
new AuthorizationServerMetadataEndpointScenario()
];
// Client scenarios map for authorization server - built from list
export const clientScenariosForAuthorizationServer = new Map<
string,
ClientScenario
>(
allClientScenariosListForAuthorizationServer.map((scenario) => [
scenario.name,
scenario
])
);
// All client test scenarios (core + backcompat + extensions)
const scenariosList: Scenario[] = [
new InitializeScenario(),
@ -180,6 +203,12 @@ export function getClientScenario(name: string): ClientScenario | undefined {
return clientScenarios.get(name);
}
export function getClientScenarioForAuthorizationServer(
name: string
): ClientScenarioForAuthorizationServer | undefined {
return clientScenariosForAuthorizationServer.get(name);
}
export function listScenarios(): string[] {
return Array.from(scenarios.keys());
}
@ -212,6 +241,10 @@ export function listBackcompatScenarios(): string[] {
return backcompatScenariosList.map((scenario) => scenario.name);
}
export function listClientScenariosForAuthorizationServer(): string[] {
return Array.from(clientScenariosForAuthorizationServer.keys());
}
export function listDraftScenarios(): string[] {
return draftScenariosList.map((scenario) => scenario.name);
}
@ -248,11 +281,21 @@ export function listClientScenariosForSpec(version: SpecVersion): string[] {
.map((s) => s.name);
}
export function listClientScenariosForAuthorizationServerForSpec(
version: SpecVersion
): string[] {
return allClientScenariosListForAuthorizationServer
.filter((s) => s.specVersions.includes(version))
.map((s) => s.name);
}
export function getScenarioSpecVersions(
name: string
): SpecVersion[] | undefined {
return (
scenarios.get(name)?.specVersions ?? clientScenarios.get(name)?.specVersions
scenarios.get(name)?.specVersions ??
clientScenarios.get(name)?.specVersions ??
clientScenariosForAuthorizationServer.get(name)?.specVersions
);
}