Skip to content

Add RFC 9728 protected resource metadata - #18

Closed
roborourke wants to merge 17 commits into
roborourke/oauth2-well-known-endpointsfrom
roborourke/Implement-RFC-9728
Closed

roborourke wants to merge 17 commits into
roborourke/oauth2-well-known-endpointsfrom
roborourke/Implement-RFC-9728

Conversation

@roborourke

Copy link
Copy Markdown
Collaborator

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-resource publishes the resource metadata. Its authorization_servers is the same value as the RFC 8414 issuer, so the two documents link up.
  • A 401 from the REST API now carries 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-Authenticate is 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-known handler 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_supported is left out because the scope system is an unused stub. body is left out of bearer_methods_supported because 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

abhishek-kaushik and others added 17 commits April 22, 2026 15:08
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>
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
roborourke force-pushed the roborourke/Implement-RFC-9728 branch from fee1fbb to f94c5d7 Compare September 17, 2026 14:04
@roborourke

Copy link
Copy Markdown
Collaborator Author

Superseded by WP-API#84, which targets upstream main now that RFC 8414 has landed there.

@roborourke roborourke closed this Sep 17, 2026
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.

4 participants