Skip to content

feat: support the RFC 8707 resource indicator in authorize() - #54

Merged
lakhansamani merged 1 commit into
mainfrom
feat/mcp-resource-indicator
Aug 11, 2026
Merged

feat: support the RFC 8707 resource indicator in authorize()#54
lakhansamani merged 1 commit into
mainfrom
feat/mcp-resource-indicator

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Adds the RFC 8707 resource indicator to authorize(), so a browser client can obtain a resource-bound token.

This is the hard blocker for using Authorizer's remote MCP server (authorizerdev/authorizer#757) from the browser: /mcp accepts only tokens whose aud is <authorizerURL>/mcp, and there was previously no way to ask for one. getToken() already forwarded resource; authorize() did not.

await authorizer.authorize({
  response_type: ResponseTypes.Code,
  resource: 'https://auth.example.com/mcp',
});

Both halves, or neither works

resource is sent on the /authorize request and on the code exchange. That is not belt-and-braces:

  • the /authorize call is what binds the audience to the authorization code
  • the exchange must echo the same value, because the token endpoint rejects a code exchange whose resource does not match the authorization request

Sending it in only one place yields either an unbound token or a rejected exchange.

Backward compatibility

Omitted entirely when the caller does not pass one, so every existing integration keeps the client as its audience. This matters: an empty resource is rejected by the server as an invalid target, so emitting the parameter unconditionally would break all of them. There is a test for exactly that.

Not sent on refresh. The server carries the binding across rotation itself, and supplying a stale or guessed value turns a working refresh into invalid_target.

Note for the server-side release

Server behaviour changed underneath this: a refresh request naming a different resource than the grant was bound to is now rejected with invalid_target rather than silently ignored. That was never a working configuration, but it did not error before.

Testing

Two tests, both verified to fail when the change is reverted. Full suite green: 11 suites, 103 tests.

The test fixtures took four passes to make honest, and each failure mode is worth knowing about because they all passed vacuously at first:

  • jsdom, not nodeauthorize() is browser-only and returns early via hasWindow(), so under the default node environment both tests asserted nothing.
  • WebCrypto and TextEncoder polyfilled — jsdom ships neither, and the PKCE code_challenge derivation needs both; without them the flow throws before building a URL.
  • window.fetch mocked, not cross-fetchgetFetcher() resolves to window.fetch whenever a window exists, so mocking cross-fetch left the real fetch in place and the token-request assertion read an empty call list.

Each is commented in the test file so the next person does not rediscover them.

getToken() already forwarded `resource`, but authorize() had no way to pass
one, so a browser client could not obtain a resource-bound token at all — and
Authorizer's own MCP endpoint accepts nothing else.

`resource` is now sent on BOTH halves of the code flow. Both are required: the
/authorize call is what binds the audience to the authorization code, and the
exchange must echo the same value because the token endpoint rejects a code
exchange whose resource does not match the authorization request. Sending it in
only one place yields either an unbound token or a rejected exchange.

Omitted entirely when the caller does not ask for one, so every existing
integration keeps the client as its audience — an empty `resource` would be
rejected as an invalid target.

Not sent on refresh: the server carries the binding across rotation itself, and
supplying a stale or guessed value turns a working refresh into invalid_target.

Tests mock window.fetch rather than cross-fetch (getFetcher resolves to
window.fetch whenever a window exists, so mocking cross-fetch would have left
the real fetch in place and asserted an empty call list), run under jsdom with
WebCrypto and TextEncoder polyfilled (authorize() is browser-only and derives
the PKCE challenge with crypto.subtle, so under the default node environment
both tests would have passed vacuously), and were verified to fail when the
change is reverted.

Full suite: 11 suites, 103 tests passing.
@lakhansamani
lakhansamani merged commit fd6a301 into main Aug 11, 2026
2 checks passed
@lakhansamani
lakhansamani deleted the feat/mcp-resource-indicator branch August 11, 2026 05:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant