From ad76c4c6f1aa1d97eefc796debbc78a9da568a01 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 13:11:26 +0100 Subject: [PATCH 1/4] Generalise the well-known handler to serve several documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- inc/well-known/namespace.php | 78 +++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php index 53eec08..dec0e5c 100644 --- a/inc/well-known/namespace.php +++ b/inc/well-known/namespace.php @@ -11,18 +11,62 @@ const AUTHORIZATION_SERVER_DOCUMENT = 'oauth-authorization-server'; const AUTHORIZATION_SERVER_PATH = '/.well-known/' . AUTHORIZATION_SERVER_DOCUMENT; +const PROTECTED_RESOURCE_DOCUMENT = 'oauth-protected-resource'; +const PROTECTED_RESOURCE_PATH = '/.well-known/' . PROTECTED_RESOURCE_DOCUMENT; + +/** + * Gets the discovery documents this plugin serves. + * + * Keys are well-known document names, values are handlers that receive the + * matched request path and either send a document or return. + * + * @return callable[] Map of document name to handler. + */ +function get_documents() { + $documents = [ + AUTHORIZATION_SERVER_DOCUMENT => __NAMESPACE__ . '\\serve_authorization_server_document', + PROTECTED_RESOURCE_DOCUMENT => __NAMESPACE__ . '\\serve_protected_resource_document', + ]; + + /** + * Filter the well-known discovery documents this plugin serves. + * + * @param callable[] $documents Map of document name to handler. + */ + return apply_filters( 'oauth2.well_known_documents', $documents ); +} /** * Intercepts `.well-known/` requests before WordPress tries to match a * post/page, and serves the matching discovery document. + * + * The request only gets this far if the server sends unknown paths to + * WordPress. Pretty permalinks arrange that on Apache; nginx setups usually + * do it whatever the permalink setting is. */ function maybe_serve_document() { - $site_path = match_well_known_path( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + + foreach ( get_documents() as $document => $handler ) { + $matched = match_well_known_path( $request_uri, '/.well-known/' . $document ); + + if ( null === $matched ) { + continue; + } - if ( null === $site_path ) { + // Handlers exit once they have sent a document. Returning lets + // WordPress carry on and 404 the request. + $handler( $matched ); return; } +} +/** + * Serves the RFC 8414 authorization server metadata document. + * + * @param string $site_path Path of the site being asked about, with a trailing slash. + */ +function serve_authorization_server_document( $site_path ) { $site_id = get_site_id_by_path( $site_path ); if ( null === $site_id ) { @@ -33,10 +77,10 @@ function maybe_serve_document() { } /** - * Works out which site, if any, a request URI is asking for metadata about. + * Works out which path, if any, a request URI is asking for metadata about. * - * RFC 8414 puts the well-known path in front of the site's own path, so a - * site at `https://example.com/blog` publishes its metadata at + * The well-known path goes in front of the path being described, so a site at + * `https://example.com/blog` publishes its metadata at * `https://example.com/.well-known/oauth-authorization-server/blog`. On a * subdirectory network that request lands on the root site, which then has * to answer for the subsite. @@ -45,23 +89,35 @@ function maybe_serve_document() { * clients ask for, and it is the only one a site in a subdirectory can * answer without owning the domain root. * + * The returned path is always measured from the domain root, whichever form + * was used, so callers get the same answer either way. What that path means + * depends on the document: RFC 8414 describes a site, RFC 9728 a resource. + * * Tolerates a trailing slash: some hosts redirect extensionless GET paths to * their trailing-slash form before WordPress runs, and clients following * that redirect must still get the document. * - * @param string $request_uri Raw request URI, as in `$_SERVER['REQUEST_URI']`. - * @return string|null Path of the site being asked about, or null if this isn't a metadata request. + * @param string $request_uri Raw request URI, as in `$_SERVER['REQUEST_URI']`. + * @param string $well_known_path Well-known path to match, e.g. `/.well-known/oauth-authorization-server`. + * @return string|null Path being asked about, with a trailing slash, or null if this isn't a metadata request. */ -function match_well_known_path( $request_uri ) { +function match_well_known_path( $request_uri, $well_known_path = AUTHORIZATION_SERVER_PATH ) { $path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) ); $current_path = get_current_site_path(); + $site_prefix = untrailingslashit( $current_path ) . $well_known_path; - if ( untrailingslashit( $current_path ) . AUTHORIZATION_SERVER_PATH === $path ) { + // The site's own path, e.g. `/blog/.well-known/oauth-protected-resource`. + if ( $site_prefix === $path ) { return $current_path; } - if ( strpos( $path, AUTHORIZATION_SERVER_PATH . '/' ) === 0 ) { - return trailingslashit( substr( $path, strlen( AUTHORIZATION_SERVER_PATH ) ) ); + if ( strpos( $path, $site_prefix . '/' ) === 0 ) { + return trailingslashit( untrailingslashit( $current_path ) . substr( $path, strlen( $site_prefix ) ) ); + } + + // The domain root, e.g. `/.well-known/oauth-protected-resource/blog`. + if ( strpos( $path, $well_known_path . '/' ) === 0 ) { + return trailingslashit( substr( $path, strlen( $well_known_path ) ) ); } return null; From 6ec81dea7167c398690061a348bdda9b900a6c7a Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 13:11:37 +0100 Subject: [PATCH 2/4] Add RFC 9728 protected resource metadata 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 --- inc/well-known/protected-resource.php | 249 +++++++++++++++++ plugin.php | 1 + tests/test-protected-resource.php | 384 ++++++++++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 inc/well-known/protected-resource.php create mode 100644 tests/test-protected-resource.php diff --git a/inc/well-known/protected-resource.php b/inc/well-known/protected-resource.php new file mode 100644 index 0000000..d5d06e6 --- /dev/null +++ b/inc/well-known/protected-resource.php @@ -0,0 +1,249 @@ + $site['id'], + 'site_path' => $site['path'], + 'sub_path' => untrailingslashit( $sub_path ), + ]; +} + +/** + * Lists a path and each of its parents, longest first. + * + * @param string $path Path to walk up from. + * @return string[] Paths, each with a trailing slash, ending with `/`. + */ +function get_ancestor_paths( $path ) { + $paths = []; + $segments = array_values( array_filter( explode( '/', trim( $path, '/' ) ), 'strlen' ) ); + $segments = array_slice( $segments, 0, MAX_PATH_SEGMENTS ); + + while ( ! empty( $segments ) ) { + $paths[] = '/' . implode( '/', $segments ) . '/'; + array_pop( $segments ); + } + + $paths[] = '/'; + + return $paths; +} + +/** + * Finds the site serving the longest of the given paths. + * + * @param string[] $candidate_paths Paths to match, each with a trailing slash. + * @return array|null Site ID and path, or null if no site serves any of them. + */ +function resolve_site_by_path_prefix( array $candidate_paths ) { + if ( ! is_multisite() ) { + $current_path = get_current_site_path(); + + if ( ! in_array( $current_path, $candidate_paths, true ) ) { + return null; + } + + return [ + 'id' => get_current_blog_id(), + 'path' => $current_path, + ]; + } + + $sites = get_sites( + [ + 'domain' => get_site()->domain, + 'path__in' => $candidate_paths, + 'number' => 0, + ] + ); + + if ( empty( $sites ) ) { + return null; + } + + usort( + $sites, + function ( $a, $b ) { + return strlen( $b->path ) <=> strlen( $a->path ); + } + ); + + return [ + 'id' => (int) $sites[0]->blog_id, + 'path' => $sites[0]->path, + ]; +} + +/** + * Gets where a site's REST API sits within that site. + * + * A site without pretty permalinks has no REST path at all, so only its root + * can be described as a resource. + * + * @param int $site_id Site to look up. + * @return string Site-relative REST path, e.g. `/wp-json`, or an empty string when the site has no REST path. + */ +function get_rest_base_sub_path( $site_id ) { + $switched = false; + + if ( is_multisite() && get_current_blog_id() !== (int) $site_id ) { + switch_to_blog( $site_id ); + $switched = true; + } + + $rest_url = rest_url( '/' ); + $home_path = untrailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); + + if ( $switched ) { + restore_current_blog(); + } + + // Plain permalinks address routes with a query argument, e.g. + // `?rest_route=/`, leaving no path to describe a resource with. + if ( ! empty( wp_parse_url( $rest_url, PHP_URL_QUERY ) ) ) { + return ''; + } + + $rest_path = untrailingslashit( (string) wp_parse_url( $rest_url, PHP_URL_PATH ) ); + + return substr( $rest_path, strlen( $home_path ) ); +} + +/** + * Gets the metadata document describing a resource on a site. + * + * @param int $site_id Site serving the resource. + * @param string $sub_path Site-relative path of the resource. + * @return array RFC 9728 metadata document. + */ +function get_protected_resource_metadata_for_site( $site_id, $sub_path = '' ) { + if ( ! is_multisite() || get_current_blog_id() === (int) $site_id ) { + return get_protected_resource_metadata( $sub_path ); + } + + switch_to_blog( $site_id ); + $metadata = get_protected_resource_metadata( $sub_path ); + restore_current_blog(); + + return $metadata; +} + +/** + * Builds the RFC 9728 protected resource metadata document. + * + * @param string $sub_path Site-relative path of the resource, e.g. `/wp-json`. + * @return array Metadata describing the resource. + */ +function get_protected_resource_metadata( $sub_path = '' ) { + $metadata = [ + 'resource' => untrailingslashit( home_url( $sub_path ) ), + 'authorization_servers' => [ home_url() ], + 'bearer_methods_supported' => get_bearer_methods_supported(), + ]; + + $name = get_bloginfo( 'name', 'display' ); + + if ( ! empty( $name ) ) { + $metadata['resource_name'] = $name; + } + + /** + * Filter the OAuth2 protected resource metadata for a resource. + * + * @param array $metadata RFC 9728 metadata document. + * @param string $sub_path Site-relative path of the resource being described. + */ + return apply_filters( 'oauth2.well_known_protected_resource_metadata', $metadata, $sub_path ); +} + +/** + * Gets the ways a client can present an access token. + * + * Tokens are read from the `Authorization` header and the `access_token` + * query argument. Form bodies are not checked, so `body` is not advertised. + * + * @return string[] Bearer token methods, as defined by RFC 6750. + */ +function get_bearer_methods_supported() { + return [ 'header', 'query' ]; +} + +/** + * Builds the URL a resource's metadata document is published at. + * + * The URL sits under the site rather than the domain root, so it is reachable + * whether or not WordPress owns the domain root. Both forms are served, and + * they are the same URL for a site at the domain root. + * + * @param string|null $sub_path Site-relative resource path, or null for the site's REST API root. + * @return string Metadata document URL. + */ +function get_protected_resource_metadata_url( $sub_path = null ) { + if ( null === $sub_path ) { + $sub_path = get_rest_base_sub_path( get_current_blog_id() ); + } + + return untrailingslashit( home_url( PROTECTED_RESOURCE_PATH . $sub_path ) ); +} diff --git a/plugin.php b/plugin.php index 0d85d37..cf4a155 100644 --- a/plugin.php +++ b/plugin.php @@ -40,6 +40,7 @@ require __DIR__ . '/inc/endpoints/class-authorization.php'; require __DIR__ . '/inc/endpoints/class-token.php'; require __DIR__ . '/inc/well-known/namespace.php'; +require __DIR__ . '/inc/well-known/protected-resource.php'; require __DIR__ . '/inc/tokens/namespace.php'; require __DIR__ . '/inc/tokens/class-token.php'; require __DIR__ . '/inc/tokens/class-access-token.php'; diff --git a/tests/test-protected-resource.php b/tests/test-protected-resource.php new file mode 100644 index 0000000..268a2dd --- /dev/null +++ b/tests/test-protected-resource.php @@ -0,0 +1,384 @@ +set_permalink_structure( '/%postname%/' ); + } + + /** + * Skip a test that can only run on a network. + */ + protected function require_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires a multisite install.' ); + } + } + + /** + * Give a site the pretty permalinks the REST API path form needs. + * + * @param int $site_id Site to update. + */ + protected function set_pretty_permalinks( $site_id ) { + switch_to_blog( $site_id ); + update_option( 'permalink_structure', '/%postname%/' ); + restore_current_blog(); + } + + // ------------------------------------------------------------------------- + // match_well_known_path + // ------------------------------------------------------------------------- + + public function test_match_well_known_path_matches_the_protected_resource_document() { + $this->assertEquals( + '/wp-json/', + match_well_known_path( '/.well-known/oauth-protected-resource/wp-json', PROTECTED_RESOURCE_PATH ) + ); + } + + public function test_match_well_known_path_matches_the_bare_protected_resource_path() { + $this->assertEquals( + '/', + match_well_known_path( '/.well-known/oauth-protected-resource', PROTECTED_RESOURCE_PATH ) + ); + } + + public function test_match_well_known_path_ignores_a_different_document() { + $this->assertNull( + match_well_known_path( '/.well-known/oauth-authorization-server', PROTECTED_RESOURCE_PATH ) + ); + } + + public function test_match_well_known_path_defaults_to_the_authorization_server_document() { + $this->assertNull( match_well_known_path( '/.well-known/oauth-protected-resource' ) ); + } + + // ------------------------------------------------------------------------- + // get_ancestor_paths + // ------------------------------------------------------------------------- + + public function test_get_ancestor_paths_lists_parents_longest_first() { + $this->assertEquals( + [ '/blog/wp-json/mcp/', '/blog/wp-json/', '/blog/', '/' ], + get_ancestor_paths( '/blog/wp-json/mcp' ) + ); + } + + public function test_get_ancestor_paths_of_the_root_is_just_the_root() { + $this->assertEquals( [ '/' ], get_ancestor_paths( '/' ) ); + } + + // ------------------------------------------------------------------------- + // get_rest_base_sub_path + // ------------------------------------------------------------------------- + + public function test_get_rest_base_sub_path_is_the_rest_prefix() { + $this->assertEquals( '/wp-json', get_rest_base_sub_path( get_current_blog_id() ) ); + } + + public function test_get_rest_base_sub_path_honours_a_filtered_prefix() { + add_filter( + 'rest_url_prefix', + function () { + return 'api'; + } + ); + + $this->assertEquals( '/api', get_rest_base_sub_path( get_current_blog_id() ) ); + } + + public function test_get_rest_base_sub_path_is_empty_without_pretty_permalinks() { + $this->set_permalink_structure( '' ); + + $this->assertEquals( '', get_rest_base_sub_path( get_current_blog_id() ) ); + } + + // ------------------------------------------------------------------------- + // split_resource_path + // ------------------------------------------------------------------------- + + public function test_split_resource_path_matches_the_rest_root() { + $resource = split_resource_path( '/wp-json/' ); + + $this->assertEquals( get_current_blog_id(), $resource['site_id'] ); + $this->assertEquals( '/wp-json', $resource['sub_path'] ); + } + + public function test_split_resource_path_matches_a_route_beneath_the_rest_root() { + $resource = split_resource_path( '/wp-json/wp/v2/posts/' ); + + $this->assertEquals( '/wp-json/wp/v2/posts', $resource['sub_path'] ); + } + + public function test_split_resource_path_matches_the_site_root() { + $resource = split_resource_path( '/' ); + + $this->assertEquals( get_current_blog_id(), $resource['site_id'] ); + $this->assertEquals( '', $resource['sub_path'] ); + } + + public function test_split_resource_path_rejects_a_path_outside_the_rest_api() { + $this->assertNull( split_resource_path( '/not-wp-json/' ) ); + } + + public function test_split_resource_path_honours_a_filtered_rest_prefix() { + add_filter( + 'rest_url_prefix', + function () { + return 'api'; + } + ); + + $resource = split_resource_path( '/api/mcp/' ); + + $this->assertEquals( '/api/mcp', $resource['sub_path'] ); + } + + public function test_split_resource_path_without_pretty_permalinks_serves_only_the_site_root() { + $this->set_permalink_structure( '' ); + + $this->assertNull( split_resource_path( '/wp-json/' ) ); + $this->assertEquals( '', split_resource_path( '/' )['sub_path'] ); + } + + public function test_split_resource_path_prefers_the_longest_site_path() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $this->set_pretty_permalinks( $site_id ); + + $resource = split_resource_path( '/blog/wp-json/mcp/' ); + + $this->assertEquals( $site_id, $resource['site_id'] ); + $this->assertEquals( '/blog/', $resource['site_path'] ); + $this->assertEquals( '/wp-json/mcp', $resource['sub_path'] ); + } + + public function test_split_resource_path_falls_back_to_the_root_site() { + $this->require_multisite(); + + $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $resource = split_resource_path( '/wp-json/' ); + + $this->assertEquals( get_current_blog_id(), $resource['site_id'] ); + $this->assertEquals( '/wp-json', $resource['sub_path'] ); + } + + public function test_split_resource_path_rejects_an_unknown_site() { + $this->require_multisite(); + + $this->assertNull( split_resource_path( '/no-such-site/wp-json/' ) ); + } + + // ------------------------------------------------------------------------- + // get_protected_resource_metadata + // ------------------------------------------------------------------------- + + public function test_metadata_resource_is_the_requested_identifier() { + $metadata = get_protected_resource_metadata( '/wp-json/mcp' ); + + $this->assertEquals( home_url( '/wp-json/mcp' ), $metadata['resource'] ); + } + + public function test_metadata_resource_has_no_trailing_slash() { + $metadata = get_protected_resource_metadata( '/wp-json/' ); + + $this->assertEquals( home_url( '/wp-json' ), $metadata['resource'] ); + } + + public function test_metadata_resource_for_the_site_root_is_the_home_url() { + $metadata = get_protected_resource_metadata( '' ); + + $this->assertEquals( untrailingslashit( home_url() ), $metadata['resource'] ); + } + + /** + * The RFC 8414 document's issuer is where a client goes next, so the two + * have to name the same authorization server. + */ + public function test_metadata_authorization_server_matches_the_8414_issuer() { + $metadata = get_protected_resource_metadata( '/wp-json' ); + + $this->assertEquals( + [ get_authorization_server_metadata()['issuer'] ], + $metadata['authorization_servers'] + ); + } + + public function test_metadata_advertises_header_and_query_bearer_methods() { + $methods = get_protected_resource_metadata( '/wp-json' )['bearer_methods_supported']; + + $this->assertContains( 'header', $methods ); + $this->assertContains( 'query', $methods ); + } + + /** + * Tokens are never read from a form body, so advertising it would be wrong. + */ + public function test_metadata_does_not_advertise_the_body_bearer_method() { + $this->assertNotContains( + 'body', + get_protected_resource_metadata( '/wp-json' )['bearer_methods_supported'] + ); + } + + public function test_metadata_omits_scopes_supported() { + $this->assertArrayNotHasKey( 'scopes_supported', get_protected_resource_metadata( '/wp-json' ) ); + } + + public function test_metadata_includes_the_site_name() { + $this->assertEquals( + get_bloginfo( 'name', 'display' ), + get_protected_resource_metadata( '/wp-json' )['resource_name'] + ); + } + + public function test_metadata_is_filterable() { + add_filter( + 'oauth2.well_known_protected_resource_metadata', + function ( $metadata ) { + $metadata['scopes_supported'] = [ 'read' ]; + return $metadata; + } + ); + + $this->assertEquals( [ 'read' ], get_protected_resource_metadata( '/wp-json' )['scopes_supported'] ); + } + + public function test_metadata_filter_receives_the_resource_path() { + $seen = null; + + add_filter( + 'oauth2.well_known_protected_resource_metadata', + function ( $metadata, $sub_path ) use ( &$seen ) { + $seen = $sub_path; + return $metadata; + }, + 10, + 2 + ); + + get_protected_resource_metadata( '/wp-json/mcp' ); + + $this->assertEquals( '/wp-json/mcp', $seen ); + } + + // ------------------------------------------------------------------------- + // get_protected_resource_metadata_for_site + // ------------------------------------------------------------------------- + + public function test_get_protected_resource_metadata_for_site_describes_the_requested_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $this->set_pretty_permalinks( $site_id ); + + $metadata = get_protected_resource_metadata_for_site( $site_id, '/wp-json' ); + + $this->assertEquals( get_home_url( $site_id, '/wp-json' ), $metadata['resource'] ); + $this->assertNotEquals( home_url( '/wp-json' ), $metadata['resource'] ); + $this->assertEquals( [ get_home_url( $site_id ) ], $metadata['authorization_servers'] ); + } + + public function test_get_protected_resource_metadata_for_site_restores_the_current_site() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $original = get_current_blog_id(); + + get_protected_resource_metadata_for_site( $site_id, '/wp-json' ); + + $this->assertEquals( $original, get_current_blog_id() ); + } + + // ------------------------------------------------------------------------- + // get_protected_resource_metadata_url + // ------------------------------------------------------------------------- + + public function test_metadata_url_puts_the_well_known_path_before_the_resource_path() { + $this->assertEquals( + home_url( '/.well-known/oauth-protected-resource/wp-json' ), + get_protected_resource_metadata_url( '/wp-json' ) + ); + } + + public function test_metadata_url_defaults_to_the_rest_root() { + $this->assertEquals( + home_url( '/.well-known/oauth-protected-resource/wp-json' ), + get_protected_resource_metadata_url() + ); + } + + public function test_metadata_url_for_the_site_root_has_no_resource_path() { + $this->assertEquals( + home_url( '/.well-known/oauth-protected-resource' ), + get_protected_resource_metadata_url( '' ) + ); + } + + /** + * A site in a subdirectory doesn't own the domain root, so the URL it + * advertises has to sit under the site itself to be reachable. + */ + public function test_metadata_url_sits_under_the_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $this->set_pretty_permalinks( $site_id ); + + switch_to_blog( $site_id ); + $url = get_protected_resource_metadata_url(); + $matched = match_well_known_path( wp_parse_url( $url, PHP_URL_PATH ), PROTECTED_RESOURCE_PATH ); + restore_current_blog(); + + $this->assertStringStartsWith( get_home_url( $site_id, '/.well-known/' ), $url ); + $this->assertEquals( '/blog/wp-json/', $matched ); + } + + /** + * The URL a client is told to fetch has to be one the matcher accepts. + */ + public function test_metadata_url_is_servable() { + $url = get_protected_resource_metadata_url(); + $path = wp_parse_url( $url, PHP_URL_PATH ); + + $matched = match_well_known_path( $path, PROTECTED_RESOURCE_PATH ); + $resource = split_resource_path( $matched ); + + $this->assertEquals( '/wp-json', $resource['sub_path'] ); + $this->assertEquals( + home_url( '/wp-json' ), + get_protected_resource_metadata_for_site( $resource['site_id'], $resource['sub_path'] )['resource'] + ); + } +} From f94c5d7fdfc1a0997f6e5e7321e7f5e0a135f59f Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 13:11:49 +0100 Subject: [PATCH 3/4] Send a WWW-Authenticate challenge on unauthorized REST responses 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 --- inc/authentication/namespace.php | 93 +++++++++++- inc/namespace.php | 2 + tests/test-www-authenticate.php | 244 +++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 tests/test-www-authenticate.php diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index 46ccd74..d52420a 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -8,8 +8,10 @@ namespace WP\OAuth2\Authentication; use WP_Error; +use WP_REST_Response; use WP_User; use WP\OAuth2\Tokens; +use WP\OAuth2\Well_Known; /** * Get a request header by name, case-insensitively. @@ -209,8 +211,97 @@ function create_invalid_token_error( $token ) { 'oauth2.authentication.attempt_authentication.invalid_token', __( 'Supplied token is invalid.', 'oauth2' ), [ - 'status' => \WP_Http::FORBIDDEN, + 'status' => \WP_Http::UNAUTHORIZED, 'token' => $token, ] ); } + +/** + * Adds a `WWW-Authenticate` challenge to unauthorized REST API responses. + * + * Attached to the rest_post_dispatch filter. WordPress answers an anonymous + * request to a protected route with a 401, whoever registered that route, so + * this covers the whole REST API rather than this plugin's own endpoints. + * + * @param WP_REST_Response $response Response about to be sent. + * @param mixed $server REST server instance. + * @param mixed $request Request being answered. + * + * @return WP_REST_Response Response, with a challenge when one applies. + */ +function add_www_authenticate_header( $response, $server = null, $request = null ) { + if ( ! $response instanceof WP_REST_Response || \WP_Http::UNAUTHORIZED !== $response->get_status() ) { + return $response; + } + + // This plugin's own endpoints are the authorization server, not a + // resource it protects. + if ( $request && strpos( '/' . ltrim( (string) $request->get_route(), '/' ), '/oauth2/' ) === 0 ) { + return $response; + } + + $headers = $response->get_headers(); + + if ( isset( $headers['WWW-Authenticate'] ) ) { + return $response; + } + + $response->header( 'WWW-Authenticate', build_authenticate_challenge() ); + + return $response; +} + +/** + * Builds the `WWW-Authenticate` challenge sent with unauthorized responses. + * + * The error parameters are only included when a token was supplied and + * rejected. RFC 6750 section 3 leaves them out when the client sent no + * credentials at all, since there is nothing yet to report as wrong. + * + * @return string Challenge header value. + */ +function build_authenticate_challenge() { + global $oauth2_error; + + $params = []; + + if ( is_wp_error( $oauth2_error ) && strpos( $oauth2_error->get_error_code(), 'oauth2.authentication.' ) === 0 ) { + $params['error'] = 'invalid_token'; + $params['error_description'] = $oauth2_error->get_error_message(); + } + + $params['resource_metadata'] = Well_Known\get_protected_resource_metadata_url(); + + $parts = []; + + foreach ( $params as $key => $value ) { + $parts[] = sprintf( '%s="%s"', $key, addcslashes( (string) $value, '"\\' ) ); + } + + $challenge = 'Bearer ' . implode( ', ', $parts ); + + /** + * Filter the WWW-Authenticate challenge sent with unauthorized REST API responses. + * + * @param string $challenge Challenge header value. + * @param array $params Challenge parameters used to build it. + */ + return apply_filters( 'oauth2.www_authenticate_challenge', $challenge, $params ); +} + +/** + * Lets browsers read the `WWW-Authenticate` challenge on cross-origin requests. + * + * Without this the header is hidden from JavaScript, so a browser client + * cannot follow the challenge to the metadata document. + * + * @param string[] $headers Headers exposed to CORS requests. + * + * @return string[] Headers, including the challenge. + */ +function expose_authenticate_header( $headers ) { + $headers[] = 'WWW-Authenticate'; + + return $headers; +} diff --git a/inc/namespace.php b/inc/namespace.php index 6222e57..cacc818 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -17,6 +17,8 @@ function bootstrap() { // REST API integration. add_filter( 'rest_authentication_errors', __NAMESPACE__ . '\\Authentication\\maybe_report_errors' ); + add_filter( 'rest_post_dispatch', __NAMESPACE__ . '\\Authentication\\add_www_authenticate_header', 10, 3 ); + add_filter( 'rest_exposed_cors_headers', __NAMESPACE__ . '\\Authentication\\expose_authenticate_header' ); add_filter( 'rest_index', __NAMESPACE__ . '\\register_in_index' ); add_action( 'rest_api_init', __NAMESPACE__ . '\\Endpoints\\register' ); add_action( 'parse_request', __NAMESPACE__ . '\\Well_Known\\maybe_serve_document' ); diff --git a/tests/test-www-authenticate.php b/tests/test-www-authenticate.php new file mode 100644 index 0000000..1a7c318 --- /dev/null +++ b/tests/test-www-authenticate.php @@ -0,0 +1,244 @@ +set_permalink_structure( '/%postname%/' ); + + global $wp_rest_server; + $this->server = new WP_REST_Server(); + $wp_rest_server = $this->server; + do_action( 'rest_api_init', $this->server ); + } + + public function tear_down() { + global $wp_rest_server, $oauth2_error; + $wp_rest_server = null; + $oauth2_error = null; + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + + parent::tear_down(); + } + + /** + * Dispatch a request the way serve_request() would, so the challenge + * filter runs. WP_REST_Server::dispatch() does not apply it on its own. + * + * @param WP_REST_Request $request Request to dispatch. + * + * @return WP_REST_Response Filtered response. + */ + protected function dispatch( WP_REST_Request $request ) { + return apply_filters( 'rest_post_dispatch', $this->server->dispatch( $request ), $this->server, $request ); + } + + /** + * Get the challenge from a response, or null when there isn't one. + * + * @param WP_REST_Response $response Response to read. + * + * @return string|null Challenge header value. + */ + protected function get_challenge( WP_REST_Response $response ) { + $headers = $response->get_headers(); + + return $headers['WWW-Authenticate'] ?? null; + } + + // ------------------------------------------------------------------------- + // Which responses get a challenge + // ------------------------------------------------------------------------- + + /** + * Core answers an anonymous request to a protected route with a 401, so + * routes this plugin knows nothing about are covered too. + */ + public function test_challenge_is_added_to_a_core_unauthorized_response() { + $response = $this->dispatch( new WP_REST_Request( 'GET', '/wp/v2/settings' ) ); + + $this->assertEquals( 401, $response->get_status() ); + $this->assertStringStartsWith( 'Bearer ', $this->get_challenge( $response ) ); + } + + public function test_challenge_is_not_added_to_a_successful_response() { + $response = $this->dispatch( new WP_REST_Request( 'GET', '/' ) ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertNull( $this->get_challenge( $response ) ); + } + + /** + * A logged-in user without the capability gets a 403, which is an + * authorization failure. Re-authenticating would not help. + */ + public function test_challenge_is_not_added_to_a_forbidden_response() { + wp_set_current_user( $this->factory->user->create( [ 'role' => 'subscriber' ] ) ); + + $response = $this->dispatch( new WP_REST_Request( 'GET', '/wp/v2/settings' ) ); + + $this->assertEquals( 403, $response->get_status() ); + $this->assertNull( $this->get_challenge( $response ) ); + } + + /** + * The token endpoint is the authorization server, not a resource it + * protects, so it must not point clients back at resource metadata. + */ + public function test_challenge_is_not_added_to_the_token_endpoint() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->set_param( 'client_id', 'nonexistent' ); + $request->set_param( 'client_secret', 'wrong' ); + + $response = $this->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + $this->assertNull( $this->get_challenge( $response ) ); + } + + public function test_an_existing_challenge_is_not_overwritten() { + $response = new WP_REST_Response( null, 401 ); + $response->header( 'WWW-Authenticate', 'Basic realm="example"' ); + + $filtered = add_www_authenticate_header( $response, $this->server, new WP_REST_Request( 'GET', '/wp/v2/settings' ) ); + + $this->assertEquals( 'Basic realm="example"', $this->get_challenge( $filtered ) ); + } + + /** + * Embedded responses run the filter again, so it has to be safe to repeat. + */ + public function test_adding_the_challenge_twice_leaves_one_header() { + $request = new WP_REST_Request( 'GET', '/wp/v2/settings' ); + $response = new WP_REST_Response( null, 401 ); + + add_www_authenticate_header( $response, $this->server, $request ); + add_www_authenticate_header( $response, $this->server, $request ); + + $this->assertIsString( $this->get_challenge( $response ) ); + } + + // ------------------------------------------------------------------------- + // Challenge contents + // ------------------------------------------------------------------------- + + public function test_challenge_points_at_the_resource_metadata_document() { + $this->assertStringContainsString( + sprintf( 'resource_metadata="%s"', get_protected_resource_metadata_url() ), + build_authenticate_challenge() + ); + } + + /** + * RFC 6750 section 3 leaves the error out when the client sent nothing to + * be wrong about. + */ + public function test_challenge_is_bare_when_no_credentials_were_supplied() { + $this->assertStringNotContainsString( 'error=', build_authenticate_challenge() ); + } + + public function test_challenge_reports_a_rejected_token() { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer invalidtokenxyz'; + attempt_authentication(); + + $challenge = build_authenticate_challenge(); + + $this->assertStringContainsString( 'error="invalid_token"', $challenge ); + $this->assertStringContainsString( 'error_description="Supplied token is invalid."', $challenge ); + } + + public function test_challenge_is_bare_for_a_valid_token() { + $client = $this->create_client(); + $token = Access_Token::create( $client, $this->factory->user->create_and_get() ); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token->get_key(); + attempt_authentication(); + + $this->assertStringNotContainsString( 'error=', build_authenticate_challenge() ); + } + + public function test_challenge_is_filterable() { + add_filter( + 'oauth2.www_authenticate_challenge', + function () { + return 'Bearer realm="custom"'; + } + ); + + $this->assertEquals( 'Bearer realm="custom"', build_authenticate_challenge() ); + } + + // ------------------------------------------------------------------------- + // Invalid token status + // ------------------------------------------------------------------------- + + /** + * RFC 6750 section 3.1 requires 401 for an invalid token, and a challenge + * on a 403 would be ignored by clients. + */ + public function test_an_invalid_token_is_unauthorized_not_forbidden() { + global $oauth2_error; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer invalidtokenxyz'; + + attempt_authentication(); + + $this->assertEquals( 401, $oauth2_error->get_error_data()['status'] ); + } + + // ------------------------------------------------------------------------- + // CORS + // ------------------------------------------------------------------------- + + public function test_challenge_header_is_exposed_to_cors_requests() { + $this->assertContains( 'WWW-Authenticate', expose_authenticate_header( [ 'Link' ] ) ); + } + + // ------------------------------------------------------------------------- + // Multisite + // ------------------------------------------------------------------------- + + public function test_challenge_names_the_subsite_it_was_sent_from() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires a multisite install.' ); + } + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + switch_to_blog( $site_id ); + update_option( 'permalink_structure', '/%postname%/' ); + $challenge = build_authenticate_challenge(); + restore_current_blog(); + + $this->assertStringContainsString( '/blog/', $challenge ); + } +} From 7199f77f2f2a7321931cc37e3ab1da258c8abbc6 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 15:24:56 +0100 Subject: [PATCH 4/4] Bound the site lookup and pin why it is needed 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 --- inc/well-known/protected-resource.php | 7 ++++--- tests/test-protected-resource.php | 30 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/inc/well-known/protected-resource.php b/inc/well-known/protected-resource.php index d5d06e6..e24e613 100644 --- a/inc/well-known/protected-resource.php +++ b/inc/well-known/protected-resource.php @@ -113,9 +113,10 @@ function resolve_site_by_path_prefix( array $candidate_paths ) { $sites = get_sites( [ - 'domain' => get_site()->domain, - 'path__in' => $candidate_paths, - 'number' => 0, + 'domain' => get_site()->domain, + 'path__in' => $candidate_paths, + 'number' => count( $candidate_paths ), + 'update_site_meta_cache' => false, ] ); diff --git a/tests/test-protected-resource.php b/tests/test-protected-resource.php index 268a2dd..94896dc 100644 --- a/tests/test-protected-resource.php +++ b/tests/test-protected-resource.php @@ -194,6 +194,36 @@ public function test_split_resource_path_falls_back_to_the_root_site() { $this->assertEquals( '/wp-json', $resource['sub_path'] ); } + /** + * WordPress resolves the site from the first path segment, so a request in + * the form RFC 9728 asks for lands on the root site, not the site it is + * asking about. That is why the site is looked up again here. + */ + public function test_core_resolves_the_root_site_for_the_inserted_path_form() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $landed = get_site_by_path( get_site()->domain, '/.well-known/oauth-protected-resource/blog/wp-json', 1 ); + + $this->assertEquals( get_current_blog_id(), (int) $landed->blog_id ); + $this->assertNotEquals( $site_id, (int) $landed->blog_id ); + } + + /** + * A subsite asked directly does resolve itself, so no lookup is needed + * for that form. + */ + public function test_core_resolves_the_subsite_for_its_own_path_form() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $landed = get_site_by_path( get_site()->domain, '/blog/.well-known/oauth-protected-resource/wp-json', 1 ); + + $this->assertEquals( $site_id, (int) $landed->blog_id ); + } + public function test_split_resource_path_rejects_an_unknown_site() { $this->require_multisite();