cloudflare / cloudflare/workers-oauth-provider
resourceMatches uses strict string equality, rejecting RFC 3986-equivalent resource URIs (breaks MCP Server Portal refresh)
- Dominant language
- TypeScript
- Stars
- 1.9k
- Forks
- 134
- PR merge metrics
- No merged PRs in 30d
Description
**Version:** 0.8.3
## Summary
`resourceMatches` (dist/oauth-provider.js:2640) compares the token request's
`resource` parameter against the granted one with strict string equality
(unless `resourceMatchOriginOnly` is set):
```js
function resourceMatches(requested, granted, originOnly) {
if (!originOnly) return requested === granted;
...
}
```
`https://host` and `https://host/` are equivalent URIs under RFC 3986 §6.2.3
(an empty path and `/` identify the same resource), but they fail this
comparison, producing:
```
invalid_target - "Requested resource was not included in the authorization request"
```
## This is not theoretical: Cloudflare's own MCP Server Portal triggers it
The portal (Zero Trust > AI controls), acting as an OAuth client toward an AS
built on this library, sends the `resource` value verbatim from the upstream's
RFC 9728 metadata at authorization, but URL-normalized (with a trailing slash)
at refresh. Captured live against our AS, and reproduced on 100% of refreshes
across multiple portal client generations:
```
authorize GET /authorize?...&resource=https%3A%2F%2Fmcp-ga.vervology.app <- no slash
refresh POST /oauth/token grant_type=refresh_token
resource = "https://mcp-ga.vervology.app/" <- slash added
-> 400 invalid_target
```
Result: every portal-connected upstream on this library loses its credential
at the first access-token expiry (about an hour). The portal marks the server
`error: Authorization failed: invalid_target` and stops serving it. For
contrast, claude.ai's MCP client sends the identical un-normalized string on
both legs and never trips this.
(The portal's inconsistency between the two legs is arguably its own bug, and
is being reported separately through Zero Trust channels, but the library's
comparison is stricter than RFC 3986 equivalence with or without that.)
## Suggested fix
Normalize both sides per RFC 3986 §6.2 (at minimum `new URL(x).href`
canonicalization, which resolves the empty-path vs `/` equivalence) before
comparison, in the default path as well as `originOnly`.
## Workaround (verified in production)
Strip the `resource` parameter from `refresh_token` grant requests in a
wrapper before the provider sees them. RFC 8707 §2.2 makes it optional, and
the library then correctly falls back to the grant's recorded resource for
the audience. Running live on two ASes; refresh survival verified across
multiple token lifetimes.
Jono
Contributor guide
Research direction
Start at resourceMatches in dist/oauth-provider.js around line 2640 and read how requested and granted resource URIs are compared in both normal and originOnly paths. Use the RFC 3986 equivalence examples in the issue to define completion: an empty path and '/' should match, while genuinely different resources should still be rejected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100