From 1b0c713aaa8363472a3444d50482eeb2cd685604 Mon Sep 17 00:00:00 2001 From: Tyler Klose Date: Fri, 21 Aug 2026 16:32:19 -0400 Subject: [PATCH] feat(authorization): send the RFC 8707 `resource` parameter The runner is the MCP client in the `authorization` direction, and the spec requires clients to send `resource` in both the authorization request and the token request. It sent neither. Adds `--resource ` and a matching `resource` field on AuthorizationServerOptionsSchema, so `--file` supplies it too. The value must be an absolute URI with no fragment. The parameter is only sent when set, so existing runs are unchanged. No new check IDs: in this direction the runner is the client, so a check that it sent `resource` could never fail. The existing grant checks now reach a path they couldn't before. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.ts | 4 + .../authorization-code-grant.test.ts | 99 +++++++++++++++++++ .../authorization-code-grant.ts | 6 ++ .../authorization-server-metadata.test.ts | 33 +++++++ src/schemas.ts | 9 ++ 5 files changed, 151 insertions(+) diff --git a/src/index.ts b/src/index.ts index 4fdb5bcb..8eb1a0cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -763,6 +763,10 @@ program '--client-secret ', 'OAuth client secret (omit for public/PKCE-only clients)' ) + .option( + '--resource ', + 'Canonical URI of the MCP server the token is for (RFC 8707 resource parameter)' + ) .option( '-p, --port ', 'Port for the local OAuth callback server; register http://127.0.0.1:/callback as a redirect URI', diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index 6b51bf69..e2eaf06a 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -18,6 +18,8 @@ const SERVER_URL = 'https://example.com'; const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; const TOKEN_ENDPOINT = `${SERVER_URL}/token`; +const RESOURCE = 'https://mcp.example.com/mcp'; + const OPTIONS = { url: SERVER_URL, clientId: 'client', @@ -311,6 +313,103 @@ describe('AuthorizationCodeGrantScenario', () => { expect(check.errorMessage).toContain('Invalid Cache-Control'); }); + it('sends the resource parameter in both requests when it is set', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run( + { ...OPTIONS, resource: RESOURCE }, + DETAILS + ); + + expect(checks[0].status).toBe('SUCCESS'); + + const authorizationRequest = new URL( + (checks[0].details as any).authorizationRequest + ); + expect(authorizationRequest.searchParams.get('resource')).toBe(RESOURCE); + + const tokenBody = new URLSearchParams( + mockedRequest.mock.calls[0][1]?.body as string + ); + expect(tokenBody.get('resource')).toBe(RESOURCE); + }); + + it('omits the resource parameter when it is not set', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks[0].status).toBe('SUCCESS'); + + const authorizationRequest = new URL( + (checks[0].details as any).authorizationRequest + ); + expect(authorizationRequest.searchParams.has('resource')).toBe(false); + + const tokenBody = new URLSearchParams( + mockedRequest.mock.calls[0][1]?.body as string + ); + expect(tokenBody.has('resource')).toBe(false); + }); + + it('catches an authorization server that rejects the resource parameter', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + // An AS that 400s when `resource` is present. Before the runner sent the + // parameter this path could not be reached, so the fault was invisible. + mockedRequest.mockImplementation(async (_url, opts: any) => { + const rejected = new URLSearchParams(opts.body).has('resource'); + return { + statusCode: rejected ? 400 : 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store' + }, + body: { + json: async () => + rejected + ? { error: 'invalid_target' } + : { access_token: 'access-token', token_type: 'Bearer' } + } + } as any; + }); + + const withResource = await scenario.run( + { ...OPTIONS, resource: RESOURCE }, + DETAILS + ); + expect(withResource[0].status).toBe('FAILURE'); + expect(withResource[0].errorMessage).toContain('400'); + + const withoutResource = await scenario.run(OPTIONS, DETAILS); + expect(withoutResource[0].status).toBe('SUCCESS'); + }); + it('returns SKIPPED when client_secret_post and client_secret_basic are missing', async () => { const scenario = new AuthorizationCodeGrantScenario(); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 48bcdeed..13f3ba04 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -168,6 +168,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz code_challenge: this.codeChallenge, code_challenge_method: 'S256' }); + if (options.resource) { + params.set('resource', options.resource); + } return `${metadata.authorization_endpoint}?${params.toString()}`; } @@ -260,6 +263,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz redirect_uri: redirectUri, code_verifier: this.codeVerifier }); + if (options.resource) { + params.set('resource', options.resource); + } const headers: Record = { 'content-type': 'application/x-www-form-urlencoded' }; diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index a1a5d669..20ad4373 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -205,6 +205,39 @@ describe('AuthorizationServerOptionsSchema', () => { expect(result.data.scenario).toBeUndefined(); } }); + + it('accepts an absolute resource URI', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: 'https://mcp.example.com/mcp' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.resource).toBe('https://mcp.example.com/mcp'); + } + }); + + it('rejects a resource URI with a fragment', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: 'https://mcp.example.com/mcp#section' + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('fragment'); + } + }); + + it('rejects a resource that is not an absolute URI', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: '/mcp' + }); + expect(result.success).toBe(false); + }); }); describe('printAuthorizationServerResults', () => { diff --git a/src/schemas.ts b/src/schemas.ts index 040aa2e3..b8f67831 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -57,6 +57,15 @@ export const AuthorizationServerOptionsSchema = z.object({ .optional(), clientId: z.string().min(1, 'Client id cannot be empty').optional(), clientSecret: z.string().min(1, 'Client secret cannot be empty').optional(), + // RFC 8707 ยง2: the resource value is an absolute URI with no fragment. + resource: z + .string() + .refine((value) => URL.canParse(value), 'Resource must be an absolute URI') + .refine( + (value) => !URL.canParse(value) || !new URL(value).hash, + 'Resource must not include a fragment' + ) + .optional(), port: z .number() .int('Port must be an integer')