diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index 46fdd21..2db99d9 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -67,7 +67,9 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { throw new Error('No auth code in redirect URL'); } } else { - throw new Error('No redirect location received'); + throw new Error( + `No redirect location received, from '${authorizationUrl.toString()}'` + ); } } catch (error) { console.error('Failed to fetch authorization URL:', error); diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index fbdda08..f2f78ae 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -14,7 +14,6 @@ export const handle401 = async ( serverUrl: string | URL ): Promise => { const resourceMetadataUrl = extractResourceMetadataUrl(response); - let result = await auth(provider, { serverUrl, resourceMetadataUrl, diff --git a/package-lock.json b/package-lock.json index 899e0f1..334302d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.1.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", "lefthook": "^2.0.2", "zod": "^3.25.76" }, "bin": { - "conformance": "dist/index.mjs" + "conformance": "dist/index.js" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -838,11 +838,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", - "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", + "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", + "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -857,8 +859,38 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -2157,6 +2189,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -2169,6 +2202,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3034,6 +3106,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -3043,6 +3116,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3521,6 +3610,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -4129,6 +4219,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4241,6 +4332,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4942,6 +5042,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 7925234..cedad3c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dist" ], "bin": { - "conformance": "dist/index.mjs" + "conformance": "dist/index.js" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -43,7 +43,7 @@ "vitest": "^4.0.5" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", "lefthook": "^2.0.2", diff --git a/src/scenarios/client/auth/basic-dcr.test.ts b/src/scenarios/client/auth/basic-dcr.test.ts deleted file mode 100644 index 6470d15..0000000 --- a/src/scenarios/client/auth/basic-dcr.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - runClientAgainstScenario, - SpawnedClientRunner -} from './test_helpers/testClient.js'; -import path from 'path'; - -describe('PRM Path-Based Discovery', () => { - test('client discovers PRM at path-based location before root', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-dcr'); - }); - - test('bad client requests root PRM location', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test-broken1.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-dcr', [ - // There will be other failures, but this is the one that matters - 'prm-priority-order' - ]); - }); -}); diff --git a/src/scenarios/client/auth/basic-dcr.ts b/src/scenarios/client/auth/basic-dcr.ts index 77524cc..902fbdb 100644 --- a/src/scenarios/client/auth/basic-dcr.ts +++ b/src/scenarios/client/auth/basic-dcr.ts @@ -4,27 +4,26 @@ import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; import { Request, Response } from 'express'; +import { SpecReferences } from './spec-references.js'; export class AuthBasicDCRScenario implements Scenario { name = 'auth/basic-dcr'; description = 'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required'; - private authServer = new ServerLifecycle(() => this.authBaseUrl); - private server = new ServerLifecycle(() => this.baseUrl); + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - private baseUrl: string = ''; - private authBaseUrl: string = ''; async start(): Promise { this.checks = []; - const authApp = createAuthServer(this.checks, () => this.authBaseUrl); - this.authBaseUrl = await this.authServer.start(authApp); + const authApp = createAuthServer(this.checks, this.authServer.getUrl); + await this.authServer.start(authApp); const app = createServer( this.checks, - () => this.baseUrl, - () => this.authBaseUrl + this.server.getUrl, + this.authServer.getUrl ); // For this scenario, reject PRM requests at root location since we have the path-based PRM. @@ -39,10 +38,8 @@ export class AuthBasicDCRScenario implements Scenario { status: 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ - { - id: 'mcp-authorization-prm', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements' - } + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY ], details: { url: req.url, @@ -58,9 +55,9 @@ export class AuthBasicDCRScenario implements Scenario { } ); - this.baseUrl = await this.server.start(app); + await this.server.start(app); - return { serverUrl: `${this.baseUrl}/mcp` }; + return { serverUrl: `${this.server.getUrl()}/mcp` }; } async stop() { diff --git a/src/scenarios/client/auth/basic-metadata-var1.test.ts b/src/scenarios/client/auth/basic-metadata-var1.test.ts deleted file mode 100644 index ee5bac4..0000000 --- a/src/scenarios/client/auth/basic-metadata-var1.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - runClientAgainstScenario, - SpawnedClientRunner -} from './test_helpers/testClient.js'; -import path from 'path'; - -describe('OAuth Metadata at OpenID Configuration Path', () => { - test('client discovers OAuth metadata at OpenID configuration path', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-metadata-var1'); - }); -}); diff --git a/src/scenarios/client/auth/basic-metadata-var1.ts b/src/scenarios/client/auth/basic-metadata-var1.ts deleted file mode 100644 index e8ae638..0000000 --- a/src/scenarios/client/auth/basic-metadata-var1.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls } from '../../../types.js'; -import { createAuthServer } from './helpers/createAuthServer.js'; -import { createServer } from './helpers/createServer.js'; -import { ServerLifecycle } from './helpers/serverLifecycle.js'; - -export class AuthBasicMetadataVar1Scenario implements Scenario { - // TODO: name should match what we put in the scenario map - name = 'auth/basic-metadata-var1'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(() => this.authBaseUrl); - private server = new ServerLifecycle(() => this.baseUrl); - private checks: ConformanceCheck[] = []; - private baseUrl: string = ''; - private authBaseUrl: string = ''; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, () => this.authBaseUrl, { - metadataPath: '/.well-known/openid-configuration', - isOpenIdConfiguration: true - }); - this.authBaseUrl = await this.authServer.start(authApp); - - const app = createServer( - this.checks, - () => this.baseUrl, - () => this.authBaseUrl, - { - // TODO: this will put this path in the WWW-Authenticate header - // but RFC 9728 states that in that case, the resource in the PRM - // must match the URL used to make the request to the resource server. - // We'll need to establish an opinion on whether that means the - // URL for the metadata fetch, or the URL for the MCP endpoint, - // or more generally what are the valid scenarios / combos. - prmPath: '/.well-known/oauth-protected-resource' - } - ); - this.baseUrl = await this.server.start(app); - - return { serverUrl: `${this.baseUrl}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/basic-metadata.ts b/src/scenarios/client/auth/basic-metadata.ts new file mode 100644 index 0000000..828f756 --- /dev/null +++ b/src/scenarios/client/auth/basic-metadata.ts @@ -0,0 +1,210 @@ +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import { SpecReferences } from './spec-references.js'; + +export class AuthBasicMetadataVar1Scenario implements Scenario { + name = 'auth/basic-metadata-var1'; + description = + 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/.well-known/openid-configuration', + isOpenIdConfiguration: true + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} + +export class AuthBasicMetadataVar2Scenario implements Scenario { + name = 'auth/basic-metadata-var2'; + description = + 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/tenant1/.well-known/openid-configuration', + isOpenIdConfiguration: true, + routePrefix: '/tenant1' + }); + + authApp.get('/.well-known/oauth-authorization-server', (req, res) => { + this.checks.push({ + id: 'authorization-server-metadata-wrong-path', + name: 'AuthorizationServerMetadataWrongPath', + description: + 'Client requested authorization server at the root path when the AS URL has a path-based location', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY + ], + details: { + url: req.url + } + }); + res.status(404).send('Not Found'); + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + () => `${this.authServer.getUrl()}/tenant1`, + { + prmPath: '/.well-known/oauth-protected-resource' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} + +export class AuthBasicMetadataVar3Scenario implements Scenario { + name = 'auth/basic-metadata-var3'; + description = + 'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/tenant1/.well-known/openid-configuration', + isOpenIdConfiguration: true, + routePrefix: '/tenant1' + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + () => { + return `${this.authServer.getUrl()}/tenant1`; + }, + { + // This is a custom path, so unable to get via probing, it's only available + // via following the `resource_metadata_url` in the WWW-Authenticate header. + // The resource must match the original request URL per RFC 9728. + prmPath: '/custom/metadata/location.json' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 4b0128d..67a88e4 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,10 +1,13 @@ import express, { Request, Response } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; import { createRequestLogger } from '../../../request-logger.js'; +import { SpecReferences } from '../spec-references.js'; export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; + loggingEnabled?: boolean; + routePrefix?: string; } export function createAuthServer( @@ -14,18 +17,29 @@ export function createAuthServer( ): express.Application { const { metadataPath = '/.well-known/oauth-authorization-server', - isOpenIdConfiguration = false + isOpenIdConfiguration = false, + loggingEnabled = true, + routePrefix = '' } = options; + + const authRoutes = { + authorization_endpoint: `${routePrefix}/authorize`, + token_endpoint: `${routePrefix}/token`, + registration_endpoint: `${routePrefix}/register` + }; + const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); - app.use( - createRequestLogger(checks, { - incomingId: 'incoming-auth-request', - outgoingId: 'outgoing-auth-response' - }) - ); + if (loggingEnabled) { + app.use( + createRequestLogger(checks, { + incomingId: 'incoming-auth-request', + outgoingId: 'outgoing-auth-response' + }) + ); + } app.get(metadataPath, (req: Request, res: Response) => { checks.push({ @@ -35,10 +49,8 @@ export function createAuthServer( status: 'SUCCESS', timestamp: new Date().toISOString(), specReferences: [ - { - id: 'RFC-8414', - url: 'https://tools.ietf.org/html/rfc8414' - } + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY ], details: { url: req.url, @@ -48,9 +60,9 @@ export function createAuthServer( const metadata: any = { issuer: getAuthBaseUrl(), - authorization_endpoint: `${getAuthBaseUrl()}/authorize`, - token_endpoint: `${getAuthBaseUrl()}/token`, - registration_endpoint: `${getAuthBaseUrl()}/register`, + authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, + token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, + registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], @@ -67,19 +79,14 @@ export function createAuthServer( res.json(metadata); }); - app.get('/authorize', (req: Request, res: Response) => { + app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => { checks.push({ id: 'authorization-request', name: 'AuthorizationRequest', description: 'Client made authorization request', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-6749-4.1.1', - url: 'https://tools.ietf.org/html/rfc6749#section-4.1.1' - } - ], + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT], details: { response_type: req.query.response_type, client_id: req.query.client_id, @@ -101,19 +108,14 @@ export function createAuthServer( res.redirect(redirectUrl.toString()); }); - app.post('/token', (req: Request, res: Response) => { + app.post(authRoutes.token_endpoint, (req: Request, res: Response) => { checks.push({ id: 'token-request', name: 'TokenRequest', description: 'Client requested access token', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-6749-4.1.3', - url: 'https://tools.ietf.org/html/rfc6749#section-4.1.3' - } - ], + specReferences: [SpecReferences.OAUTH_2_1_TOKEN], details: { endpoint: '/token', grantType: req.body.grant_type @@ -127,19 +129,14 @@ export function createAuthServer( }); }); - app.post('/register', (req: Request, res: Response) => { + app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => { checks.push({ id: 'client-registration', name: 'ClientRegistration', description: 'Client registered with authorization server', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-7591-2', - url: 'https://tools.ietf.org/html/rfc7591#section-2' - } - ], + specReferences: [SpecReferences.MCP_DCR], details: { endpoint: '/register', clientName: req.body.client_name diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index a73e38d..35417fb 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -6,9 +6,10 @@ import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; import { createRequestLogger } from '../../../request-logger.js'; import { MockTokenVerifier } from './mockTokenVerifier.js'; +import { SpecReferences } from '../spec-references.js'; export interface ServerOptions { - prmPath?: string; + prmPath?: string | null; } export function createServer( @@ -47,30 +48,37 @@ export function createServer( }) ); - app.get(prmPath, (req: Request, res: Response) => { - checks.push({ - id: 'prm-pathbased-requested', - name: 'PRMPathBasedRequested', - description: 'Client requested PRM metadata at path-based location', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-9728-3', - url: 'https://tools.ietf.org/html/rfc9728#section-3' + if (prmPath !== null) { + app.get(prmPath, (req: Request, res: Response) => { + checks.push({ + id: 'prm-pathbased-requested', + name: 'PRMPathBasedRequested', + description: 'Client requested PRM metadata at path-based location', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY + ], + details: { + url: req.url, + path: req.path } - ], - details: { - url: req.url, - path: req.path - } - }); + }); - res.json({ - resource: getBaseUrl(), - authorization_servers: [getAuthServerUrl()] + // Resource is usually $baseUrl/mcp, but if PRM is at the root, + // the resource identifier is the root. + const resource = + prmPath === '/.well-known/oauth-protected-resource' + ? getBaseUrl() + : `${getBaseUrl()}/mcp`; + + res.json({ + resource, + authorization_servers: [getAuthServerUrl()] + }); }); - }); + } app.post('/mcp', async (req: Request, res: Response, next: NextFunction) => { // Apply bearer token auth per-request in order to delay setting PRM URL @@ -79,7 +87,9 @@ export function createServer( const authMiddleware = requireBearerAuth({ verifier: new MockTokenVerifier(checks), requiredScopes: [], - resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + ...(prmPath !== null && { + resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + }) }); authMiddleware(req, res, async (err?: any) => { diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 251d620..a636ec3 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -1,6 +1,7 @@ import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import type { ConformanceCheck } from '../../../../types.js'; +import { SpecReferences } from '../spec-references.js'; export class MockTokenVerifier implements OAuthTokenVerifier { constructor(private checks: ConformanceCheck[]) {} @@ -13,12 +14,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier { description: 'Client provided valid bearer token', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Authorization', - url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization' - } - ], + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], details: { token: token.substring(0, 10) + '...' } @@ -37,12 +33,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier { description: 'Client provided invalid bearer token', status: 'FAILURE', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Authorization', - url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization' - } - ], + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], details: { message: 'Token verification failed', token: token ? token.substring(0, 10) + '...' : 'missing' diff --git a/src/scenarios/client/auth/helpers/serverLifecycle.ts b/src/scenarios/client/auth/helpers/serverLifecycle.ts index 74ca986..7fb3886 100644 --- a/src/scenarios/client/auth/helpers/serverLifecycle.ts +++ b/src/scenarios/client/auth/helpers/serverLifecycle.ts @@ -5,7 +5,10 @@ export class ServerLifecycle { private httpServer: any = null; private baseUrl: string = ''; - constructor(private getBaseUrl: () => string) {} + // Arrow function to avoid needing lots of .bind(this) + getUrl = (): string => { + return this.baseUrl; + }; async start(app: express.Application): Promise { this.app = app; @@ -25,8 +28,4 @@ export class ServerLifecycle { } this.app = null; } - - getUrl(): string { - return this.baseUrl; - } } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts new file mode 100644 index 0000000..87c99d5 --- /dev/null +++ b/src/scenarios/client/auth/index.test.ts @@ -0,0 +1,37 @@ +import { authScenariosList } from './index.js'; +import { + runClientAgainstScenario, + SpawnedClientRunner +} from './test_helpers/testClient.js'; +import path from 'path'; + +describe('Client Auth Scenarios', () => { + const clientPath = path.join( + process.cwd(), + 'examples/clients/typescript/auth-test.ts' + ); + + // Generate individual test for each auth scenario + for (const scenario of authScenariosList) { + test(`${scenario.name} passes`, async () => { + const runner = new SpawnedClientRunner(clientPath); + await runClientAgainstScenario(runner, scenario.name); + }); + } +}); + +describe('Negative tests', () => { + test('bad client requests root PRM location', async () => { + const clientPath = path.join( + process.cwd(), + 'examples/clients/typescript/auth-test-broken1.ts' + ); + const runner = new SpawnedClientRunner(clientPath); + await runClientAgainstScenario(runner, 'auth/basic-dcr', [ + // There will be other failures, but this is the one that matters + 'prm-priority-order' + ]); + }); + + // TODO: Add more negative tests here +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts new file mode 100644 index 0000000..04b64b0 --- /dev/null +++ b/src/scenarios/client/auth/index.ts @@ -0,0 +1,20 @@ +import { Scenario } from '../../../types'; +import { AuthBasicDCRScenario } from './basic-dcr.js'; +import { + AuthBasicMetadataVar1Scenario, + AuthBasicMetadataVar2Scenario, + AuthBasicMetadataVar3Scenario +} from './basic-metadata.js'; +import { + Auth20250326OAuthMetadataBackcompatScenario, + Auth20250326OEndpointFallbackScenario +} from './march-spec-backcompat.js'; + +export const authScenariosList: Scenario[] = [ + new AuthBasicDCRScenario(), + new AuthBasicMetadataVar1Scenario(), + new AuthBasicMetadataVar2Scenario(), + new AuthBasicMetadataVar3Scenario(), + new Auth20250326OAuthMetadataBackcompatScenario(), + new Auth20250326OEndpointFallbackScenario() +]; diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts new file mode 100644 index 0000000..0999bdf --- /dev/null +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -0,0 +1,192 @@ +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import express, { Request, Response } from 'express'; +import { SpecReferences } from './spec-references.js'; + +export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { + name = 'auth/2025-03-26-oauth-metadata-backcompat'; + description = + 'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location'; + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + // Legacy server, so we create the auth server endpoints on the + // same URL as the main server (rather than separating AS / RS). + const authApp = createAuthServer(this.checks, this.server.getUrl, { + // Disable logging since the main server will already have logging enabled + loggingEnabled: false, + // Add a prefix to auth endpoints to avoid being caught by auth fallbacks + routePrefix: '/oauth' + }); + const app = createServer( + this.checks, + this.server.getUrl, + this.server.getUrl, + // Explicitly set to null to indicate no PRM available + { prmPath: null } + ); + app.use(authApp); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_DISCOVERY] + }); + } + } + + return this.checks; + } +} + +export class Auth20250326OEndpointFallbackScenario implements Scenario { + name = 'auth/2025-03-26-oauth-endpoint-fallback'; + description = + 'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)'; + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const app = createServer( + this.checks, + this.server.getUrl, + this.server.getUrl, + { prmPath: null } + ); + + // needed for /token endpoint + app.use(express.urlencoded({ extended: true })); + + app.get('/authorize', (req: Request, res: Response) => { + this.checks.push({ + id: 'authorization-request', + name: 'AuthorizationRequest', + description: 'Client made authorization request to fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + response_type: req.query.response_type, + client_id: req.query.client_id, + redirect_uri: req.query.redirect_uri, + state: req.query.state, + code_challenge: req.query.code_challenge ? 'present' : 'missing', + code_challenge_method: req.query.code_challenge_method + } + }); + + const redirectUri = req.query.redirect_uri as string; + const state = req.query.state as string; + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set('code', 'test-auth-code'); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + res.redirect(redirectUrl.toString()); + }); + + app.post('/token', (req: Request, res: Response) => { + this.checks.push({ + id: 'token-request', + name: 'TokenRequest', + description: 'Client requested access token from fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + endpoint: '/token', + grantType: req.body.grant_type + } + }); + + res.json({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + }); + + app.post('/register', (req: Request, res: Response) => { + this.checks.push({ + id: 'client-registration', + name: 'ClientRegistration', + description: + 'Client registered with authorization server at fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + endpoint: '/register', + clientName: req.body.client_name + } + }); + + res.status(201).json({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_name: req.body.client_name || 'test-client', + redirect_uris: req.body.redirect_uris || [] + }); + }); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts new file mode 100644 index 0000000..e81e140 --- /dev/null +++ b/src/scenarios/client/auth/spec-references.ts @@ -0,0 +1,44 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + RFC_PRM_DISCOVERY: { + id: 'RFC-9728', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1' + }, + RFC_AUTH_SERVER_METADATA_REQUEST: { + id: 'RFC-8414-metadata-request', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1' + }, + LEGACY_2025_03_26_AUTH_DISCOVERY: { + id: 'MCP-2025-03-26-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery' + }, + LEGACY_2025_03_26_AUTH_URL_FALLBACK: { + id: 'MCP-2025-03-26-Authorization-metadata-url-fallback', + url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery' + }, + MCP_PRM_DISCOVERY: { + id: 'MCP-2025-06-18-PRM-discovery', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements' + }, + MCP_AUTH_DISCOVERY: { + id: 'MCP-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery' + }, + MCP_DCR: { + id: 'MCP-Dynamic-client-registration', + url: 'https://modelcontextprotocol.io/specification/draft/basic/client#dynamic-client-registration' + }, + OAUTH_2_1_AUTHORIZATION_ENDPOINT: { + id: 'OAUTH-2.1-authorization-endpoint', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-authorization-endpoint' + }, + OAUTH_2_1_TOKEN: { + id: 'OAUTH-2.1-token-request', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request' + }, + MCP_ACCESS_TOKEN_USAGE: { + id: 'MCP-Access-token-usage', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage' + } +}; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 13f4aa1..5d50dff 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,8 +1,6 @@ import { Scenario, ClientScenario } from '../types.js'; import { InitializeScenario } from './client/initialize.js'; import { ToolsCallScenario } from './client/tools_call.js'; -import { AuthBasicDCRScenario } from './client/auth/basic-dcr.js'; -import { AuthBasicMetadataVar1Scenario } from './client/auth/basic-metadata-var1.js'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js'; // Import all new server test scenarios @@ -47,6 +45,8 @@ import { PromptsGetWithImageScenario } from './server/prompts.js'; +import { authScenariosList } from './client/auth/index.js'; + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1330) @@ -115,9 +115,8 @@ export const clientScenarios = new Map( const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), - new AuthBasicDCRScenario(), - new AuthBasicMetadataVar1Scenario(), - new ElicitationClientDefaultsScenario() + new ElicitationClientDefaultsScenario(), + ...authScenariosList ]; // Scenarios map - built from list diff --git a/src/scenarios/request-logger.ts b/src/scenarios/request-logger.ts index 742db4e..6f63837 100644 --- a/src/scenarios/request-logger.ts +++ b/src/scenarios/request-logger.ts @@ -16,7 +16,8 @@ export function createRequestLogger( let requestDescription = `Received ${req.method} request for ${req.path}`; const requestDetails: any = { method: req.method, - path: req.path + path: req.path, + body: req.body }; // Add query parameters to details if they exist diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index b410bb2..c66e425 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -4,6 +4,10 @@ import { ClientScenario, ConformanceCheck } from '../../types.js'; import { connectToServer } from './client-helper.js'; +import { + TextResourceContents, + BlobResourceContents +} from '@modelcontextprotocol/sdk/types.js'; export class ResourcesListScenario implements ClientScenario { name = 'resources-list'; @@ -122,7 +126,7 @@ Implement resource \`test://static-text\` that returns: errors.push('contents is not an array'); if (result.contents.length === 0) errors.push('contents array is empty'); - const content = result.contents[0]; + const content = result.contents[0] as TextResourceContents | undefined; if (content) { if (!content.uri) errors.push('Content missing uri'); if (!content.mimeType) errors.push('Content missing mimeType'); @@ -206,7 +210,7 @@ Implement resource \`test://static-binary\` that returns: if (!result.contents) errors.push('Missing contents array'); if (result.contents.length === 0) errors.push('contents array is empty'); - const content = result.contents[0]; + const content = result.contents[0] as BlobResourceContents | undefined; if (content) { if (!content.uri) errors.push('Content missing uri'); if (!content.mimeType) errors.push('Content missing mimeType'); @@ -297,11 +301,15 @@ Returns (for \`uri: "test://template/123/data"\`): const content = result.contents[0]; if (content) { if (!content.uri) errors.push('Content missing uri'); - if (!content.text && !content.blob) - errors.push('Content missing text or blob'); - - // Check if parameter was substituted - const text = content.text || (content.blob ? '[binary]' : ''); + const hasText = 'text' in content; + const hasBlob = 'blob' in content; + if (!hasText && !hasBlob) errors.push('Content missing text or blob'); + + const text = hasText + ? (content as TextResourceContents).text + : hasBlob + ? '[binary]' + : ''; if (typeof text === 'string' && !text.includes('123')) { errors.push('Parameter substitution not reflected in content'); } @@ -322,7 +330,11 @@ Returns (for \`uri: "test://template/123/data"\`): ], details: { uri: content?.uri, - content: content?.text || content?.blob + content: content + ? 'text' in content + ? (content as TextResourceContents).text + : (content as BlobResourceContents).blob + : undefined } });