Add RFC 9728 protected resource metadata - #84
roborourke wants to merge 4 commits into
Conversation
RFC 9728 protected resource metadata uses the same `.well-known` path shape as RFC 8414, so the matching and the multisite site lookup are worth sharing rather than copying. `maybe_serve_document()` now loops a registry of documents instead of hardcoding one, and the RFC 8414 body moves into its own handler. `match_well_known_path()` takes the well-known path to match, defaulted so existing callers are unaffected. Two behaviour changes fall out of this: The site-relative form (`/blog/.well-known/…`) was an exact match, so it could only name a site. It is now a prefix match returning the rest of the path, which RFC 9728 needs to name a resource inside the site. The matcher now always returns a path measured from the domain root, whichever form the client used, so callers do not have to care which one arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Publishes `/.well-known/oauth-protected-resource`, naming the authorization server that protects a resource. RFC 8414 describes the server but never says which server guards a given API, so a client that hits a 401 has nowhere to start. This closes that loop: the document's `authorization_servers` is the same value as the RFC 8414 `issuer`. The path after the well-known segment is a resource path, not a site path as it is for RFC 8414, so it covers the site path plus the REST prefix plus a route. The site therefore has to be resolved first, by longest matching path, before the rest can be checked against that site's own REST base. The base is read from `rest_url()` rather than `rest_get_url_prefix()` so index permalinks and a filtered prefix work without special cases. Any path under the REST API is described, so a resource server mounted on its own route gets a correct document without registering anything. Paths outside the REST API are refused, apart from the site root, so this does not answer for arbitrary URLs on the domain. The advertised URL sits under the site rather than the domain root. Both forms are served and they are identical for a site at the root, but a site in a subdirectory does not own the domain root, so only the site-relative form is reachable there. Only fields the plugin can state honestly are included. `scopes_supported` is left out because the scope system is an unused stub, and `body` is left out of `bearer_methods_supported` because tokens are only read from the Authorization header and the query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Metadata is only half of RFC 9728. A client still has to be told where the document is, which section 5.1 does with a `resource_metadata` parameter on the `WWW-Authenticate` challenge. The challenge goes on any 401 from the REST API, not just this plugin's own failures. `rest_authorization_required_code()` returns 401 when logged out and 403 when logged in, so a 401 already means "anonymous request hit a protected route" for core and for every plugin that uses it. Keying off that covers the whole REST API with nothing to register. The plugin's own `oauth2/` routes are excluded. They are the authorization server, not a resource it protects, so pointing them at resource metadata would send clients in a circle. `rest_post_dispatch` is the hook because it is the only one that sees both dispatched responses and authentication errors as a response object, and it still runs before headers are sent. Invalid tokens now return 401 instead of 403. RFC 6750 section 3.1 requires it, and clients ignore a challenge on a 403, which would have left this inert. Client credentials being disabled stays 403: that is an authorization failure, so re-authenticating would not help and no challenge is sent. The error parameters are only sent when a token was supplied and rejected. RFC 6750 section 3 omits them when the client sent no credentials, since nothing has gone wrong yet. The header is added to the CORS expose list. Browsers hide it from JavaScript otherwise, which would silently stop browser clients from following the challenge at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| $sites = get_sites( | ||
| [ | ||
| 'domain' => get_site()->domain, | ||
| 'path__in' => $candidate_paths, | ||
| 'number' => 0, | ||
| ] | ||
| ); |
There was a problem hiding this comment.
Should never make an unbounded call. I would've thought if we already matched a site by the time we hit parse_request this additional logic and lookup wouldn't be needed...
There was a problem hiding this comment.
Fixed the unbounded call in 7199f77. path__in caps the result at one row per candidate, so number is now that count, and site meta cache priming is off since we never read it.
The lookup itself does need to stay. WordPress picks the site from the first path segment only (get_site_by_path( $domain, $path, 1 )), and .well-known is not a site, so an inserted-path request falls back to the root site:
| Request | Site resolved |
|---|---|
/.well-known/oauth-protected-resource/blog/wp-json |
root / |
/blog/.well-known/oauth-protected-resource/wp-json |
/blog/ |
So you are right that the site is already determined by parse_request, but for the form RFC 9728 asks clients to build it is the wrong site. That is the same reason #82 needed it.
Added two tests covering both rows so this is recorded in the suite rather than just here.
The `get_sites()` call passed `number => 0`, which reads as unbounded. `path__in` already caps the result at one row per candidate path, so the count is the real bound and saying so makes that obvious. Site meta is never read here, so its cache priming is skipped too. The lookup itself has to stay. WordPress resolves the site from the first path segment, so a request for `/.well-known/oauth-protected-resource/blog/wp-json` lands on the root site rather than the subsite it is asking about. Two tests record that, covering both forms, so the reason is in the suite rather than only in a review thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #28
Tells OAuth2 clients which authorization server protects this site's REST API, and points them at that answer when a request is refused.
Follows on from #82. That added RFC 8414, which describes the server but never says which server guards a given API. A client that gets a 401 from
/wp-json/...still has nowhere to start. This adds the other half.Two parts:
/.well-known/oauth-protected-resourcepublishes the resource metadata. Itsauthorization_serversis the same value as the RFC 8414issuer, so the two documents link up.WWW-Authenticate: Bearer resource_metadata="...", pointing at that document.Together those let a client go from a refused request to the authorize and token endpoints with nothing hardcoded.
All protected resources, by default
The challenge goes on any 401 from the REST API, not just this plugin's own failures.
rest_authorization_required_code()returns 401 when logged out and 403 when logged in, so a 401 already means an anonymous request hit a protected route. That covers core and every plugin that uses it, with nothing to register.Any path under the REST API gets a document too, so a resource server mounted on its own route works without registering anything. Paths outside the REST API are refused, apart from the site root.
The plugin's own
oauth2/routes are excluded. They are the authorization server, not a resource it protects.Behaviour changes:
Invalid tokens now return 401 instead of 403. RFC 6750 §3.1 requires it, and clients ignore a challenge on a 403, so without this the feature would be inert. No test asserted the old status. Anything downstream branching on 403 for a bad token will see 401.
WWW-Authenticateis added to the CORS expose list. Browsers hide it from JavaScript otherwise, so browser clients could not follow the challenge at all. This changes CORS for the whole REST API, not just OAuth2 routes.Client credentials being disabled stays 403 and gets no challenge. That is an authorization failure, so re-authenticating would not help.
Notes
This generalises #82's
.well-knownhandler into a small registry rather than copying the path matching. Two changes fall out of that: the site-relative form (/blog/.well-known/…) becomes a prefix match so it can name a resource inside a site, and the matcher now always returns a path measured from the domain root whichever form the client used.Tested on single site and multisite, including subdirectory sites: 182 tests pass, PHPCS clean.
🤖 Generated with Claude Code, with some edits by me