Skip to content

Add RFC 9728 protected resource metadata - #84

Open
roborourke wants to merge 4 commits into
WP-API:mainfrom
humanmade:roborourke/Implement-RFC-9728
Open

roborourke wants to merge 4 commits into
WP-API:mainfrom
humanmade:roborourke/Implement-RFC-9728

Conversation

@roborourke

@roborourke roborourke commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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-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:

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.

Notes

This generalises #82's .well-known handler 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

roborourke and others added 3 commits September 17, 2026 15:00
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>
Comment on lines +114 to +120
$sites = get_sites(
[
'domain' => get_site()->domain,
'path__in' => $candidate_paths,
'number' => 0,
]
);

@roborourke roborourke Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
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.

Send WWW-Authenticate header

1 participant