Add RFC 9728 protected resource metadata - #18
Closed
roborourke wants to merge 17 commits into
Closed
roborourke wants to merge 17 commits into
roborourke wants to merge 17 commits into
Conversation
Upgrade WPCS to 3.4.1 and PHPCS to 3.13
Add filter for alternative authorization header
The constraint is only used by consuming projects that install this plugin via Composer; the plugin itself has no code dependency on the installer's API. Widening to ^1 || ^2 keeps installs working as installers v1 is deprecated and hosting ecosystems move to v2. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Allow composer/installers v2
RFC 6749 section 2.3.1 makes Basic auth the recommended way for a client to authenticate at the token endpoint, and the client_credentials grant already parses it. The authorization_code grant only read client_id from the body, so MCP clients authenticating this way failed with a missing parameter error.
RFC 6749 section 2.3.1 has the client form-encode the id and secret before they go in the header, so a secret containing a space or a plus only matches once the server decodes it.
…code-grant Accept HTTP Basic client credentials on the authorization_code grant
…own-endpoints Add RFC 8414 OAuth2 authorization server metadata endpoint
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>
roborourke
force-pushed
the
roborourke/Implement-RFC-9728
branch
from
September 17, 2026 14:04
fee1fbb to
f94c5d7
Compare
Collaborator
Author
|
Superseded by WP-API#84, which targets upstream main now that RFC 8414 has landed there. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Tells OAuth2 clients which authorization server protects this site's REST API, and points them at that answer when a request is refused.
RFC 8414 (#17) 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 worth a look
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. Deliberate, not an oversight.
Notes
This targets #17 and needs it to merge first. It generalises that branch's
.well-knownhandler into a small registry rather than copying the path matching.#17 said this belonged in another plugin, probably
hm-rest-ability. That call has been reversed: it lives next to the RFC 8414 document it pairs with.The endpoint needs the server to send unknown paths to WordPress. Pretty permalinks arrange that on Apache; nginx setups usually do it anyway. A site without a REST path can only describe its own root.
scopes_supportedis left out because the scope system is an unused stub.bodyis left out ofbearer_methods_supportedbecause tokens are only read from the Authorization header and the query string.Tested on single site and multisite, including subdirectory sites: 167 tests pass, PHPCS is clean.
🤖 Generated with Claude Code