feat(authorization): send the RFC 8707 resource parameter (#466)
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 <uri>` 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) <noreply@anthropic.com>
This commit is contained in:
parent
7022d78607
commit
a983ba93c9
5 changed files with 151 additions and 0 deletions
|
|
@ -767,6 +767,10 @@ program
|
|||
'--client-secret <secret>',
|
||||
'OAuth client secret (omit for public/PKCE-only clients)'
|
||||
)
|
||||
.option(
|
||||
'--resource <uri>',
|
||||
'Canonical URI of the MCP server the token is for (RFC 8707 resource parameter)'
|
||||
)
|
||||
.option(
|
||||
'-p, --port <port>',
|
||||
'Port for the local OAuth callback server; register http://127.0.0.1:<port>/callback as a redirect URI',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'content-type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue