From 314622645d8c510fac22c50f9db7972459839a29 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 16 Sep 2026 16:47:19 +0100 Subject: [PATCH 1/6] Add RFC 8414/9728 OAuth2 discovery endpoints Adds two OAuth2 discovery endpoints so clients can find this plugin's endpoints and capabilities without hardcoding them: - /.well-known/oauth-authorization-server (RFC 8414) exposes the plugin's authorization and token endpoints, and the grant types it supports. - /.well-known/oauth-protected-resource (RFC 9728) advertises protected-resource metadata for this site. Any 401 REST response now also gets a `WWW-Authenticate: Bearer resource_metadata="..."` header, not scoped to a particular route, so any bearer-protected endpoint benefits rather than just one integration. This was ported and generalized from hm-rest-ability, where it didn't belong (that plugin's copy is being removed in humanmade/hm-rest-ability#42). One HM-specific behaviour was deliberately dropped in the port: the original defaulted its login-wall exemption to Human Made's own Require Login plugin. This plugin is meant to stay generic and potentially go upstream, so that default doesn't belong here. The exemption mechanism itself is kept as a filter, `oauth2.well_known_login_wall_exemptions`, but now defaults to empty. `code_challenge_methods_supported` (PKCE, RFC 8414) is intentionally left out for now, since PKCE support isn't merged into this repo's main yet (tracked in a separate open PR). Add it once that lands. Co-Authored-By: Claude Sonnet 5 --- inc/namespace.php | 1 + inc/well-known/namespace.php | 200 +++++++++++++++++++++++++++++++++++ plugin.php | 1 + tests/test-well-known.php | 171 ++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+) create mode 100644 inc/well-known/namespace.php create mode 100644 tests/test-well-known.php diff --git a/inc/namespace.php b/inc/namespace.php index ea681f1..27537fd 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -19,6 +19,7 @@ function bootstrap() { add_filter( 'rest_authentication_errors', __NAMESPACE__ . '\\Authentication\\maybe_report_errors' ); add_filter( 'rest_index', __NAMESPACE__ . '\\register_in_index' ); add_action( 'rest_api_init', __NAMESPACE__ . '\\Endpoints\\register' ); + Well_Known\bootstrap(); // Internal default hooks. add_filter( 'oauth2.grant_types', __NAMESPACE__ . '\\register_grant_types', 0 ); diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php new file mode 100644 index 0000000..4e870d4 --- /dev/null +++ b/inc/well-known/namespace.php @@ -0,0 +1,200 @@ + home_url(), + 'authorization_endpoint' => OAuth2\get_authorization_url(), + 'token_endpoint' => OAuth2\get_token_url(), + 'grant_types_supported' => get_grant_types_supported(), + 'response_types_supported' => get_response_types_supported(), + 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_post', 'client_secret_basic' ], + ]; + + /** + * Filter the OAuth2 authorization server metadata returned at + * `/.well-known/oauth-authorization-server`. + * + * @param array $metadata RFC 8414 metadata document. + */ + $metadata = apply_filters( 'oauth2.well_known_authorization_server_metadata', $metadata ); + + send_json_document( $metadata ); +} + +/** + * Gets the grant types the token endpoint accepts. + * + * Combines the registered authorization grant type handlers with + * `client_credentials`, which the token endpoint supports directly rather + * than through the `oauth2.grant_types` filter. + * + * @return string[] Grant type identifiers. + */ +function get_grant_types_supported() { + $grant_types = array_keys( OAuth2\get_grant_types() ); + $grant_types[] = 'client_credentials'; + + return array_values( array_unique( $grant_types ) ); +} + +/** + * Gets the response types advertised by the registered grant type handlers. + * + * @return string[] Response type codes, e.g. `code`, `token`. + */ +function get_response_types_supported() { + $response_types = []; + + foreach ( OAuth2\get_grant_types() as $handler ) { + $response_types[] = $handler->get_response_type_code(); + } + + return array_values( array_unique( $response_types ) ); +} + +/** + * Outputs the RFC 9728 protected resource metadata document and exits. + */ +function serve_protected_resource_metadata() { + $metadata = [ + 'resource' => home_url(), + 'authorization_servers' => [ home_url() ], + ]; + + /** + * Filter the OAuth2 protected resource metadata returned at + * `/.well-known/oauth-protected-resource`. + * + * @param array $metadata RFC 9728 metadata document. + */ + $metadata = apply_filters( 'oauth2.well_known_protected_resource_metadata', $metadata ); + + send_json_document( $metadata ); +} + +/** + * Sends a JSON discovery document and exits. + * + * @param array $document Data to encode as the response body. + */ +function send_json_document( $document ) { + header( 'Content-Type: application/json' ); + header( 'Access-Control-Allow-Origin: *' ); + echo wp_json_encode( $document ); + exit; +} + +/** + * Adds a WWW-Authenticate header to 401 REST responses, so OAuth2 clients + * can discover the protected resource metadata document after a failed or + * missing bearer token challenge. + * + * Applies to any REST response, not just this plugin's own endpoints, since + * a 401 on a bearer-protected route is exactly the case this document exists + * to resolve. + * + * @param WP_REST_Response $response Result to send to the client. + * @return WP_REST_Response + */ +function add_www_authenticate_header( WP_REST_Response $response ) { + if ( $response->get_status() !== 401 ) { + return $response; + } + + $resource_metadata_url = home_url( '/.well-known/oauth-protected-resource' ); + $response->header( 'WWW-Authenticate', "Bearer resource_metadata=\"{$resource_metadata_url}\"" ); + + return $response; +} diff --git a/plugin.php b/plugin.php index 9b8ae34..0d85d37 100644 --- a/plugin.php +++ b/plugin.php @@ -39,6 +39,7 @@ require __DIR__ . '/inc/endpoints/namespace.php'; require __DIR__ . '/inc/endpoints/class-authorization.php'; require __DIR__ . '/inc/endpoints/class-token.php'; +require __DIR__ . '/inc/well-known/namespace.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-well-known.php b/tests/test-well-known.php new file mode 100644 index 0000000..209e716 --- /dev/null +++ b/tests/test-well-known.php @@ -0,0 +1,171 @@ +server = $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $this->server ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + unset( $_SERVER['REQUEST_URI'] ); + parent::tear_down(); + } + + // ------------------------------------------------------------------------- + // match_well_known_path + // ------------------------------------------------------------------------- + + public function test_match_well_known_path_matches_authorization_server() { + $this->assertEquals( + 'oauth-authorization-server', + match_well_known_path( '/.well-known/oauth-authorization-server' ) + ); + } + + public function test_match_well_known_path_matches_protected_resource() { + $this->assertEquals( + 'oauth-protected-resource', + match_well_known_path( '/.well-known/oauth-protected-resource' ) + ); + } + + public function test_match_well_known_path_tolerates_trailing_slash() { + $this->assertEquals( + 'oauth-authorization-server', + match_well_known_path( '/.well-known/oauth-authorization-server/' ) + ); + } + + public function test_match_well_known_path_ignores_query_string() { + $this->assertEquals( + 'oauth-authorization-server', + match_well_known_path( '/.well-known/oauth-authorization-server?foo=bar' ) + ); + } + + public function test_match_well_known_path_returns_null_for_unrelated_path() { + $this->assertNull( match_well_known_path( '/some-other-path' ) ); + } + + // ------------------------------------------------------------------------- + // get_grant_types_supported / get_response_types_supported + // ------------------------------------------------------------------------- + + public function test_get_grant_types_supported_includes_authorization_code() { + $this->assertContains( 'authorization_code', get_grant_types_supported() ); + } + + public function test_get_grant_types_supported_includes_client_credentials() { + $this->assertContains( 'client_credentials', get_grant_types_supported() ); + } + + public function test_get_response_types_supported_includes_code_and_token() { + $response_types = get_response_types_supported(); + $this->assertContains( 'code', $response_types ); + $this->assertContains( 'token', $response_types ); + } + + // ------------------------------------------------------------------------- + // maybe_exempt_login_wall + // ------------------------------------------------------------------------- + + public function test_maybe_exempt_login_wall_noops_outside_well_known() { + $_SERVER['REQUEST_URI'] = '/some-other-path'; + + $called = false; + add_filter( 'oauth2.well_known_login_wall_exemptions', function ( $exemptions ) use ( &$called ) { + $called = true; + return $exemptions; + } ); + + maybe_exempt_login_wall(); + + $this->assertFalse( $called ); + } + + public function test_maybe_exempt_login_wall_removes_configured_action() { + $_SERVER['REQUEST_URI'] = '/.well-known/oauth-authorization-server'; + + $ran = false; + $callback = function () use ( &$ran ) { + $ran = true; + }; + add_action( 'oauth2_tests_login_wall', $callback, 999 ); + + add_filter( 'oauth2.well_known_login_wall_exemptions', function ( $exemptions ) use ( $callback ) { + $exemptions[] = [ 'oauth2_tests_login_wall', $callback, 999 ]; + return $exemptions; + } ); + + maybe_exempt_login_wall(); + + do_action( 'oauth2_tests_login_wall' ); + $this->assertFalse( $ran ); + } + + // ------------------------------------------------------------------------- + // add_www_authenticate_header + // ------------------------------------------------------------------------- + + public function test_add_www_authenticate_header_adds_header_on_401() { + $response = new WP_REST_Response( [], 401 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + + $response = add_www_authenticate_header( $response, $this->server, $request ); + + $this->assertStringContainsString( + '/.well-known/oauth-protected-resource', + $response->get_headers()['WWW-Authenticate'] + ); + } + + public function test_add_www_authenticate_header_applies_regardless_of_route() { + $response = new WP_REST_Response( [], 401 ); + $request = new WP_REST_Request( 'GET', '/some/unrelated/route' ); + + $response = add_www_authenticate_header( $response, $this->server, $request ); + + $this->assertArrayHasKey( 'WWW-Authenticate', $response->get_headers() ); + } + + public function test_add_www_authenticate_header_ignores_non_401() { + $response = new WP_REST_Response( [], 403 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + + $response = add_www_authenticate_header( $response, $this->server, $request ); + + $this->assertArrayNotHasKey( 'WWW-Authenticate', $response->get_headers() ); + } +} From 223414ff076d1401eac9b507368ae47381c77a30 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 16 Sep 2026 16:47:26 +0100 Subject: [PATCH 2/6] Ignore vendor/ and PHPUnit's result cache Composer dependencies and PHPUnit's cache file were getting installed locally but weren't ignored, risking an accidental commit. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4a9b0e3..04b6984 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,12 @@ ## https://docs.npmjs.com/misc/faq#should-i-check-my-node_modules-folder-into-git node_modules -# Composer-generated lock file +# Composer-generated lock file and dependencies composer.lock +vendor + +# PHPUnit cache +.phpunit.result.cache # Book build output _book From 8fb8395e5d9631d63fc45051fe7e736bdd367611 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 16 Sep 2026 16:51:35 +0100 Subject: [PATCH 3/6] Narrow well-known endpoints to RFC 8414 only Drop the RFC 9728 protected-resource metadata document and the WWW-Authenticate header that pointed to it. Both describe protecting a resource server, not this plugin's own OAuth2 authorization and token endpoints, so they don't belong here. That behaviour may return in a different plugin instead, most likely hm-rest-ability, but where it lives is still to be decided. RFC 8414 stays: the authorization-server metadata document at /.well-known/oauth-authorization-server only describes endpoints this plugin itself implements. Co-Authored-By: Claude Sonnet 5 --- inc/well-known/namespace.php | 55 +----------------------------------- tests/test-well-known.php | 44 ----------------------------- 2 files changed, 1 insertion(+), 98 deletions(-) diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php index 4e870d4..67c9a97 100644 --- a/inc/well-known/namespace.php +++ b/inc/well-known/namespace.php @@ -8,7 +8,6 @@ namespace WP\OAuth2\Well_Known; use WP\OAuth2; -use WP_REST_Response; /** * Register well-known discovery hooks. @@ -16,7 +15,6 @@ function bootstrap() { add_action( 'init', __NAMESPACE__ . '\\maybe_exempt_login_wall', 998 ); add_action( 'parse_request', __NAMESPACE__ . '\\maybe_serve_document' ); - add_filter( 'rest_post_dispatch', __NAMESPACE__ . '\\add_www_authenticate_header' ); } /** @@ -59,10 +57,6 @@ function maybe_serve_document() { if ( 'oauth-authorization-server' === $document ) { serve_authorization_server_metadata(); } - - if ( 'oauth-protected-resource' === $document ) { - serve_protected_resource_metadata(); - } } /** @@ -73,7 +67,7 @@ function maybe_serve_document() { * that redirect must still get the document. * * @param string $request_uri Raw request URI, as in `$_SERVER['REQUEST_URI']`. - * @return string|null `oauth-authorization-server`, `oauth-protected-resource`, or null. + * @return string|null `oauth-authorization-server`, or null. */ function match_well_known_path( $request_uri ) { $path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) ); @@ -82,10 +76,6 @@ function match_well_known_path( $request_uri ) { return 'oauth-authorization-server'; } - if ( '/.well-known/oauth-protected-resource' === $path ) { - return 'oauth-protected-resource'; - } - return null; } @@ -144,26 +134,6 @@ function get_response_types_supported() { return array_values( array_unique( $response_types ) ); } -/** - * Outputs the RFC 9728 protected resource metadata document and exits. - */ -function serve_protected_resource_metadata() { - $metadata = [ - 'resource' => home_url(), - 'authorization_servers' => [ home_url() ], - ]; - - /** - * Filter the OAuth2 protected resource metadata returned at - * `/.well-known/oauth-protected-resource`. - * - * @param array $metadata RFC 9728 metadata document. - */ - $metadata = apply_filters( 'oauth2.well_known_protected_resource_metadata', $metadata ); - - send_json_document( $metadata ); -} - /** * Sends a JSON discovery document and exits. * @@ -175,26 +145,3 @@ function send_json_document( $document ) { echo wp_json_encode( $document ); exit; } - -/** - * Adds a WWW-Authenticate header to 401 REST responses, so OAuth2 clients - * can discover the protected resource metadata document after a failed or - * missing bearer token challenge. - * - * Applies to any REST response, not just this plugin's own endpoints, since - * a 401 on a bearer-protected route is exactly the case this document exists - * to resolve. - * - * @param WP_REST_Response $response Result to send to the client. - * @return WP_REST_Response - */ -function add_www_authenticate_header( WP_REST_Response $response ) { - if ( $response->get_status() !== 401 ) { - return $response; - } - - $resource_metadata_url = home_url( '/.well-known/oauth-protected-resource' ); - $response->header( 'WWW-Authenticate', "Bearer resource_metadata=\"{$resource_metadata_url}\"" ); - - return $response; -} diff --git a/tests/test-well-known.php b/tests/test-well-known.php index 209e716..ff7e77c 100644 --- a/tests/test-well-known.php +++ b/tests/test-well-known.php @@ -9,11 +9,8 @@ require_once __DIR__ . '/class-test-case.php'; -use WP_REST_Request; -use WP_REST_Response; use WP_REST_Server; -use function WP\OAuth2\Well_Known\add_www_authenticate_header; use function WP\OAuth2\Well_Known\get_grant_types_supported; use function WP\OAuth2\Well_Known\get_response_types_supported; use function WP\OAuth2\Well_Known\match_well_known_path; @@ -54,13 +51,6 @@ public function test_match_well_known_path_matches_authorization_server() { ); } - public function test_match_well_known_path_matches_protected_resource() { - $this->assertEquals( - 'oauth-protected-resource', - match_well_known_path( '/.well-known/oauth-protected-resource' ) - ); - } - public function test_match_well_known_path_tolerates_trailing_slash() { $this->assertEquals( 'oauth-authorization-server', @@ -134,38 +124,4 @@ public function test_maybe_exempt_login_wall_removes_configured_action() { do_action( 'oauth2_tests_login_wall' ); $this->assertFalse( $ran ); } - - // ------------------------------------------------------------------------- - // add_www_authenticate_header - // ------------------------------------------------------------------------- - - public function test_add_www_authenticate_header_adds_header_on_401() { - $response = new WP_REST_Response( [], 401 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); - - $response = add_www_authenticate_header( $response, $this->server, $request ); - - $this->assertStringContainsString( - '/.well-known/oauth-protected-resource', - $response->get_headers()['WWW-Authenticate'] - ); - } - - public function test_add_www_authenticate_header_applies_regardless_of_route() { - $response = new WP_REST_Response( [], 401 ); - $request = new WP_REST_Request( 'GET', '/some/unrelated/route' ); - - $response = add_www_authenticate_header( $response, $this->server, $request ); - - $this->assertArrayHasKey( 'WWW-Authenticate', $response->get_headers() ); - } - - public function test_add_www_authenticate_header_ignores_non_401() { - $response = new WP_REST_Response( [], 403 ); - $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); - - $response = add_www_authenticate_header( $response, $this->server, $request ); - - $this->assertArrayNotHasKey( 'WWW-Authenticate', $response->get_headers() ); - } } From 499011a4cfe7460fddf0f88d2957ddd9453abf38 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 16 Sep 2026 17:38:31 +0100 Subject: [PATCH 4/6] Drop the well-known login-wall exemption filter The oauth2.well_known_login_wall_exemptions filter let sites remove arbitrary init hooks via remove_action() before serving .well-known/ documents. That's a fragile pattern: it depends on knowing the exact hook, callback, and priority another plugin used, and breaks silently if any of those change. It also solved a problem nobody has hit yet. Drop it until there's a real need, and design for that need directly rather than guessing at a generic mechanism. With that hook gone, Well_Known only registers one action (parse_request), so its own bootstrap() wrapper added nothing. Bind that hook directly in inc/namespace.php's bootstrap(), matching how Endpoints\register is already bound there. Co-Authored-By: Claude Sonnet 5 --- inc/namespace.php | 2 +- inc/well-known/namespace.php | 38 ----------------------- tests/test-well-known.php | 60 ------------------------------------ 3 files changed, 1 insertion(+), 99 deletions(-) diff --git a/inc/namespace.php b/inc/namespace.php index 27537fd..6222e57 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -19,7 +19,7 @@ function bootstrap() { add_filter( 'rest_authentication_errors', __NAMESPACE__ . '\\Authentication\\maybe_report_errors' ); add_filter( 'rest_index', __NAMESPACE__ . '\\register_in_index' ); add_action( 'rest_api_init', __NAMESPACE__ . '\\Endpoints\\register' ); - Well_Known\bootstrap(); + add_action( 'parse_request', __NAMESPACE__ . '\\Well_Known\\maybe_serve_document' ); // Internal default hooks. add_filter( 'oauth2.grant_types', __NAMESPACE__ . '\\register_grant_types', 0 ); diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php index 67c9a97..725359a 100644 --- a/inc/well-known/namespace.php +++ b/inc/well-known/namespace.php @@ -9,44 +9,6 @@ use WP\OAuth2; -/** - * Register well-known discovery hooks. - */ -function bootstrap() { - add_action( 'init', __NAMESPACE__ . '\\maybe_exempt_login_wall', 998 ); - add_action( 'parse_request', __NAMESPACE__ . '\\maybe_serve_document' ); -} - -/** - * Removes any configured login-wall callbacks from `.well-known/` requests, - * so unauthenticated OAuth2 clients can reach the discovery documents. - * - * No-ops on sites that don't run one of the filtered plugins. - */ -function maybe_exempt_login_wall() { - if ( strpos( $_SERVER['REQUEST_URI'] ?? '', '/.well-known/' ) !== 0 ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput - return; - } - - /** - * Filter the login-wall callbacks to remove for `.well-known/` requests. - * - * Sites that gate the whole site behind a login wall (via a plugin - * hooked to e.g. `init`) can use this to let unauthenticated OAuth2 - * clients still reach the discovery documents. Empty by default. - * - * Each entry is a [ hook, function_to_remove, priority ] tuple passed to - * remove_action(). - * - * @param array $exemptions Array of remove_action() argument tuples. - */ - $exemptions = apply_filters( 'oauth2.well_known_login_wall_exemptions', [] ); - - foreach ( $exemptions as list( $hook, $function_to_remove, $priority ) ) { - remove_action( $hook, $function_to_remove, $priority ); - } -} - /** * Intercepts `.well-known/` requests before WordPress tries to match a * post/page, and serves the matching discovery document. diff --git a/tests/test-well-known.php b/tests/test-well-known.php index ff7e77c..74a835f 100644 --- a/tests/test-well-known.php +++ b/tests/test-well-known.php @@ -9,37 +9,15 @@ require_once __DIR__ . '/class-test-case.php'; -use WP_REST_Server; - use function WP\OAuth2\Well_Known\get_grant_types_supported; use function WP\OAuth2\Well_Known\get_response_types_supported; use function WP\OAuth2\Well_Known\match_well_known_path; -use function WP\OAuth2\Well_Known\maybe_exempt_login_wall; /** * Test cases for the well-known discovery document functions. */ class Test_Well_Known extends Test_Case { - /** - * @var WP_REST_Server - */ - protected $server; - - public function set_up() { - parent::set_up(); - global $wp_rest_server; - $this->server = $wp_rest_server = new WP_REST_Server(); - do_action( 'rest_api_init', $this->server ); - } - - public function tear_down() { - global $wp_rest_server; - $wp_rest_server = null; - unset( $_SERVER['REQUEST_URI'] ); - parent::tear_down(); - } - // ------------------------------------------------------------------------- // match_well_known_path // ------------------------------------------------------------------------- @@ -86,42 +64,4 @@ public function test_get_response_types_supported_includes_code_and_token() { $this->assertContains( 'code', $response_types ); $this->assertContains( 'token', $response_types ); } - - // ------------------------------------------------------------------------- - // maybe_exempt_login_wall - // ------------------------------------------------------------------------- - - public function test_maybe_exempt_login_wall_noops_outside_well_known() { - $_SERVER['REQUEST_URI'] = '/some-other-path'; - - $called = false; - add_filter( 'oauth2.well_known_login_wall_exemptions', function ( $exemptions ) use ( &$called ) { - $called = true; - return $exemptions; - } ); - - maybe_exempt_login_wall(); - - $this->assertFalse( $called ); - } - - public function test_maybe_exempt_login_wall_removes_configured_action() { - $_SERVER['REQUEST_URI'] = '/.well-known/oauth-authorization-server'; - - $ran = false; - $callback = function () use ( &$ran ) { - $ran = true; - }; - add_action( 'oauth2_tests_login_wall', $callback, 999 ); - - add_filter( 'oauth2.well_known_login_wall_exemptions', function ( $exemptions ) use ( $callback ) { - $exemptions[] = [ 'oauth2_tests_login_wall', $callback, 999 ]; - return $exemptions; - } ); - - maybe_exempt_login_wall(); - - do_action( 'oauth2_tests_login_wall' ); - $this->assertFalse( $ran ); - } } From 801a3b9cd573fd9ddf5ed7fe8954974dfde0b169 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 16 Sep 2026 19:32:21 +0100 Subject: [PATCH 5/6] Use constants for the well-known document name and path The identifier 'oauth-authorization-server' appeared three times as a literal string: as the returned document name, inside the /.well-known/ path, and in the equality check in maybe_serve_document(). Replace these with two constants, AUTHORIZATION_SERVER_DOCUMENT and AUTHORIZATION_SERVER_PATH, with the path derived from the document constant so they can't drift apart. tests/test-well-known.php deliberately keeps asserting the literal strings rather than the constants, so a typo in either constant still fails the tests instead of passing silently. Addresses review feedback from joehoyle on upstream PR WP-API/OAuth2#82. Co-Authored-By: Claude Sonnet 5 --- inc/well-known/namespace.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php index 725359a..54f2523 100644 --- a/inc/well-known/namespace.php +++ b/inc/well-known/namespace.php @@ -9,6 +9,9 @@ use WP\OAuth2; +const AUTHORIZATION_SERVER_DOCUMENT = 'oauth-authorization-server'; +const AUTHORIZATION_SERVER_PATH = '/.well-known/' . AUTHORIZATION_SERVER_DOCUMENT; + /** * Intercepts `.well-known/` requests before WordPress tries to match a * post/page, and serves the matching discovery document. @@ -16,7 +19,7 @@ function maybe_serve_document() { $document = match_well_known_path( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput - if ( 'oauth-authorization-server' === $document ) { + if ( AUTHORIZATION_SERVER_DOCUMENT === $document ) { serve_authorization_server_metadata(); } } @@ -34,8 +37,8 @@ function maybe_serve_document() { function match_well_known_path( $request_uri ) { $path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) ); - if ( '/.well-known/oauth-authorization-server' === $path ) { - return 'oauth-authorization-server'; + if ( AUTHORIZATION_SERVER_PATH === $path ) { + return AUTHORIZATION_SERVER_DOCUMENT; } return null; From 17fe69e5803a02c4f0d674ed9f7652be5faa4f46 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 10:57:12 +0100 Subject: [PATCH 6/6] Serve well-known metadata for subdirectory sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old path match only worked for a site at the domain root: it compared the request path against the exact string /.well-known/oauth-authorization-server. RFC 8414 §3 instead inserts the well-known path before the issuer's own path, so a site at https://example.com/blog is discovered at https://example.com/.well-known/oauth-authorization-server/blog. On a subdirectory multisite network that request always lands on the root site, which must then answer on the subsite's behalf rather than returning its own metadata. match_well_known_path() now recognises both this RFC form and the site's own path (/blog/.well-known/oauth-authorization-server, the OpenID Connect-style form), which is also the only form a subdirectory install can serve for itself. get_site_id_by_path() resolves a path to a site via an exact get_sites() lookup and returns null for unknown paths, so an unrecognised path 404s instead of silently falling back to the root site's metadata. get_metadata_for_site() switch_to_blog()s to the resolved site to build the document, so a subsite's issuer/authorization/token endpoints are its own, then restores the current blog. serve_authorization_server_metadata() was split into a pure get_authorization_server_metadata() (returns the array, still filterable via oauth2.well_known_authorization_server_metadata) and the existing send_json_document(), making the document contents unit-testable. Tests extended with multisite-only cases (skipped on single site via a require_multisite() helper) covering subsite resolution, the subsite's own issuer, and that the current blog is restored. Addresses review feedback from @joehoyle on WP-API/OAuth2#82 asking whether the RFC 8414 §3.1 issuer-path insertion form would resolve correctly for a site in a subdirectory. Co-Authored-By: Claude Sonnet 5 --- inc/well-known/namespace.php | 106 +++++++++++++++++++++++++----- tests/test-well-known.php | 124 +++++++++++++++++++++++++++++++---- 2 files changed, 202 insertions(+), 28 deletions(-) diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php index 54f2523..53eec08 100644 --- a/inc/well-known/namespace.php +++ b/inc/well-known/namespace.php @@ -17,37 +17,116 @@ * post/page, and serves the matching discovery document. */ function maybe_serve_document() { - $document = match_well_known_path( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $site_path = match_well_known_path( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput - if ( AUTHORIZATION_SERVER_DOCUMENT === $document ) { - serve_authorization_server_metadata(); + if ( null === $site_path ) { + return; } + + $site_id = get_site_id_by_path( $site_path ); + + if ( null === $site_id ) { + return; + } + + send_json_document( get_metadata_for_site( $site_id ) ); } /** - * Works out which discovery document, if any, a request URI is asking for. + * Works out which site, 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 + * `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. + * + * The site's own path is matched too: that is the form OpenID Connect + * clients ask for, and it is the only one a site in a subdirectory can + * answer without owning the domain root. * * 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 `oauth-authorization-server`, or null. + * @return string|null Path of the site being asked about, or null if this isn't a metadata request. */ function match_well_known_path( $request_uri ) { - $path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) ); + $path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) ); + $current_path = get_current_site_path(); + + if ( untrailingslashit( $current_path ) . AUTHORIZATION_SERVER_PATH === $path ) { + return $current_path; + } - if ( AUTHORIZATION_SERVER_PATH === $path ) { - return AUTHORIZATION_SERVER_DOCUMENT; + if ( strpos( $path, AUTHORIZATION_SERVER_PATH . '/' ) === 0 ) { + return trailingslashit( substr( $path, strlen( AUTHORIZATION_SERVER_PATH ) ) ); } return null; } /** - * Outputs the RFC 8414 authorization server metadata document and exits. + * Gets the path the current site is served from, e.g. `/` or `/blog/`. + * + * @return string Site path, with a trailing slash. + */ +function get_current_site_path() { + return (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ); +} + +/** + * Finds the site served from a given path. + * + * @param string $site_path Site path, with a trailing slash. + * @return int|null Site ID, or null if no site is served from that path. + */ +function get_site_id_by_path( $site_path ) { + if ( ! is_multisite() ) { + return get_current_site_path() === $site_path ? get_current_blog_id() : null; + } + + $sites = get_sites( + [ + 'domain' => get_site()->domain, + 'path' => $site_path, + 'number' => 1, + 'fields' => 'ids', + ] + ); + + if ( empty( $sites ) ) { + return null; + } + + return (int) $sites[0]; +} + +/** + * Gets the metadata document describing a site on the network. + * + * @param int $site_id Site to describe. + * @return array RFC 8414 metadata document. + */ +function get_metadata_for_site( $site_id ) { + if ( ! is_multisite() || get_current_blog_id() === $site_id ) { + return get_authorization_server_metadata(); + } + + switch_to_blog( $site_id ); + $metadata = get_authorization_server_metadata(); + restore_current_blog(); + + return $metadata; +} + +/** + * Builds the RFC 8414 authorization server metadata document. + * + * @return array Metadata describing the current site. */ -function serve_authorization_server_metadata() { +function get_authorization_server_metadata() { $metadata = [ 'issuer' => home_url(), 'authorization_endpoint' => OAuth2\get_authorization_url(), @@ -58,14 +137,11 @@ function serve_authorization_server_metadata() { ]; /** - * Filter the OAuth2 authorization server metadata returned at - * `/.well-known/oauth-authorization-server`. + * Filter the OAuth2 authorization server metadata for a site. * * @param array $metadata RFC 8414 metadata document. */ - $metadata = apply_filters( 'oauth2.well_known_authorization_server_metadata', $metadata ); - - send_json_document( $metadata ); + return apply_filters( 'oauth2.well_known_authorization_server_metadata', $metadata ); } /** diff --git a/tests/test-well-known.php b/tests/test-well-known.php index 74a835f..ecae459 100644 --- a/tests/test-well-known.php +++ b/tests/test-well-known.php @@ -9,8 +9,11 @@ require_once __DIR__ . '/class-test-case.php'; +use function WP\OAuth2\Well_Known\get_authorization_server_metadata; use function WP\OAuth2\Well_Known\get_grant_types_supported; +use function WP\OAuth2\Well_Known\get_metadata_for_site; use function WP\OAuth2\Well_Known\get_response_types_supported; +use function WP\OAuth2\Well_Known\get_site_id_by_path; use function WP\OAuth2\Well_Known\match_well_known_path; /** @@ -18,35 +21,130 @@ */ class Test_Well_Known extends Test_Case { + /** + * Skip a test that can only run on a network. + */ + protected function require_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires a multisite install.' ); + } + } + // ------------------------------------------------------------------------- // match_well_known_path // ------------------------------------------------------------------------- - public function test_match_well_known_path_matches_authorization_server() { - $this->assertEquals( - 'oauth-authorization-server', - match_well_known_path( '/.well-known/oauth-authorization-server' ) - ); + public function test_match_well_known_path_matches_root_site() { + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server' ) ); } public function test_match_well_known_path_tolerates_trailing_slash() { - $this->assertEquals( - 'oauth-authorization-server', - match_well_known_path( '/.well-known/oauth-authorization-server/' ) - ); + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server/' ) ); } public function test_match_well_known_path_ignores_query_string() { - $this->assertEquals( - 'oauth-authorization-server', - match_well_known_path( '/.well-known/oauth-authorization-server?foo=bar' ) - ); + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server?foo=bar' ) ); } public function test_match_well_known_path_returns_null_for_unrelated_path() { $this->assertNull( match_well_known_path( '/some-other-path' ) ); } + /** + * RFC 8414 puts the well-known path in front of the site's own path. + */ + public function test_match_well_known_path_reads_site_path_suffix() { + $this->assertEquals( '/blog/', match_well_known_path( '/.well-known/oauth-authorization-server/blog' ) ); + } + + public function test_match_well_known_path_reads_nested_site_path_suffix() { + $this->assertEquals( '/blog/sub/', match_well_known_path( '/.well-known/oauth-authorization-server/blog/sub' ) ); + } + + public function test_match_well_known_path_matches_a_subsites_own_path() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + switch_to_blog( $site_id ); + $matched = match_well_known_path( '/blog/.well-known/oauth-authorization-server' ); + restore_current_blog(); + + $this->assertEquals( '/blog/', $matched ); + } + + // ------------------------------------------------------------------------- + // get_site_id_by_path + // ------------------------------------------------------------------------- + + public function test_get_site_id_by_path_finds_the_current_site() { + $this->assertEquals( get_current_blog_id(), get_site_id_by_path( '/' ) ); + } + + public function test_get_site_id_by_path_returns_null_for_unknown_path() { + $this->assertNull( get_site_id_by_path( '/no-such-site/' ) ); + } + + public function test_get_site_id_by_path_finds_a_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $this->assertEquals( $site_id, get_site_id_by_path( '/blog/' ) ); + } + + // ------------------------------------------------------------------------- + // get_authorization_server_metadata + // ------------------------------------------------------------------------- + + public function test_metadata_issuer_is_the_site_url() { + $metadata = get_authorization_server_metadata(); + $this->assertEquals( home_url(), $metadata['issuer'] ); + } + + public function test_metadata_advertises_the_token_endpoint() { + $metadata = get_authorization_server_metadata(); + $this->assertStringContainsString( 'oauth2/access_token', $metadata['token_endpoint'] ); + } + + public function test_metadata_is_filterable() { + add_filter( 'oauth2.well_known_authorization_server_metadata', function ( $metadata ) { + $metadata['service_documentation'] = 'https://example.org/docs'; + return $metadata; + } ); + + $metadata = get_authorization_server_metadata(); + + $this->assertEquals( 'https://example.org/docs', $metadata['service_documentation'] ); + } + + // ------------------------------------------------------------------------- + // get_metadata_for_site + // ------------------------------------------------------------------------- + + public function test_get_metadata_for_site_describes_the_requested_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $metadata = get_metadata_for_site( $site_id ); + + $this->assertEquals( get_home_url( $site_id ), $metadata['issuer'] ); + $this->assertNotEquals( home_url(), $metadata['issuer'] ); + $this->assertStringContainsString( '/blog/', $metadata['token_endpoint'] ); + } + + public function test_get_metadata_for_site_restores_the_current_site() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $original = get_current_blog_id(); + + get_metadata_for_site( $site_id ); + + $this->assertEquals( $original, get_current_blog_id() ); + } + // ------------------------------------------------------------------------- // get_grant_types_supported / get_response_types_supported // -------------------------------------------------------------------------