From e3502d0cd4bf633b3c6be4860206e911ca6addec Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Wed, 22 Apr 2026 15:08:51 +0200 Subject: [PATCH 01/10] Add custom Auth header --- inc/authentication/namespace.php | 58 +++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index aa6aa8e..63c0617 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -46,17 +46,57 @@ function get_authorization_header() { * @return string|null Token on success, null on failure. */ function get_provided_token() { - $header = get_authorization_header(); - if ( $header ) { - return get_token_from_bearer_header( $header ); - } + // Prefer the standard Authorization header. Only if it is missing or + // does not contain a bearer token (e.g. a proxy has injected Basic + // auth), fall back to the non-standard X-Authorization header. + $header = get_authorization_header(); + if ( $header ) { + $token = get_token_from_bearer_header( $header ); + if ( $token ) { + return $token; + } + } + + $alt_header = get_custom_authorization_header(); + if ( $alt_header ) { + $token = get_token_from_bearer_header( $alt_header ); + if ( $token ) { + return $token; + } + } + + $token = get_token_from_request(); + if ( $token ) { + return $token; + } + + return null; +} + +/** + * Get the X-Authorization header. + * + * Used when the standard Authorization header is consumed by a proxy + * layer (e.g. Imperva HTTP Basic Auth). + * + * @return string|null Header value if set, null otherwise. + */ +function get_custom_authorization_header() { + if ( ! empty( $_SERVER['HTTP_X_AUTHORIZATION'] ) ) { + return wp_unslash( $_SERVER['HTTP_X_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } - $token = get_token_from_request(); - if ( $token ) { - return $token; - } + if ( function_exists( 'getallheaders' ) ) { + $headers = getallheaders(); - return null; + foreach ( $headers as $key => $value ) { + if ( strtolower( $key ) === 'x-authorization' ) { + return $value; + } + } + } + + return null; } /** From 08deba69f164f47963cd4d868605d06081bac90a Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Tue, 12 May 2026 15:03:26 +0530 Subject: [PATCH 02/10] Use filter for alternative Authorization header --- inc/authentication/namespace.php | 41 +++++++++----------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index 63c0617..02e7d63 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -46,9 +46,6 @@ function get_authorization_header() { * @return string|null Token on success, null on failure. */ function get_provided_token() { - // Prefer the standard Authorization header. Only if it is missing or - // does not contain a bearer token (e.g. a proxy has injected Basic - // auth), fall back to the non-standard X-Authorization header. $header = get_authorization_header(); if ( $header ) { $token = get_token_from_bearer_header( $header ); @@ -57,7 +54,17 @@ function get_provided_token() { } } - $alt_header = get_custom_authorization_header(); + /** + * Provide an alternative authorization header value. + * + * Use this filter when the standard Authorization header is consumed by a + * proxy or server layer (e.g. Imperva HTTP Basic Auth). Return the raw + * header value (e.g. "Bearer ") to have it parsed as a bearer token. + * Return null to skip the fallback entirely. + * + * @param string|null $header Raw header value, or null to skip. + */ + $alt_header = apply_filters( 'oauth2.authentication.alternative_authorization_header', null ); if ( $alt_header ) { $token = get_token_from_bearer_header( $alt_header ); if ( $token ) { @@ -73,32 +80,6 @@ function get_provided_token() { return null; } -/** - * Get the X-Authorization header. - * - * Used when the standard Authorization header is consumed by a proxy - * layer (e.g. Imperva HTTP Basic Auth). - * - * @return string|null Header value if set, null otherwise. - */ -function get_custom_authorization_header() { - if ( ! empty( $_SERVER['HTTP_X_AUTHORIZATION'] ) ) { - return wp_unslash( $_SERVER['HTTP_X_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - } - - if ( function_exists( 'getallheaders' ) ) { - $headers = getallheaders(); - - foreach ( $headers as $key => $value ) { - if ( strtolower( $key ) === 'x-authorization' ) { - return $value; - } - } - } - - return null; -} - /** * Extracts the token from the given authorization header. * From 04215519654c42f3cfe87f934991ffdd032aa2e4 Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Tue, 2 Jun 2026 16:14:35 +0530 Subject: [PATCH 03/10] Allow custom authorization header name --- inc/authentication/namespace.php | 42 +++++++++++++------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index 02e7d63..46ccd74 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -12,26 +12,27 @@ use WP\OAuth2\Tokens; /** - * Get the authorization header + * Get a request header by name, case-insensitively. * * On certain systems and configurations, the Authorization header will be * stripped out by the server or PHP. Typically this is then used to * generate `PHP_AUTH_USER`/`PHP_AUTH_PASS` but not passed on. We use * `getallheaders` here to try and grab it out instead. * - * @return string|null Authorization header if set, null otherwise + * @param string $name Header name. Default 'authorization'. + * + * @return string|null Header value if set, null otherwise. */ -function get_authorization_header() { - if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { - return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized +function get_authorization_header( $name = 'authorization' ) { + $server_key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); + if ( ! empty( $_SERVER[ $server_key ] ) ) { + return wp_unslash( $_SERVER[ $server_key ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized } if ( function_exists( 'getallheaders' ) ) { $headers = getallheaders(); - - // Check for the authorization header case-insensitively foreach ( $headers as $key => $value ) { - if ( strtolower( $key ) === 'authorization' ) { + if ( strtolower( $key ) === strtolower( $name ) ) { return $value; } } @@ -46,27 +47,18 @@ function get_authorization_header() { * @return string|null Token on success, null on failure. */ function get_provided_token() { - $header = get_authorization_header(); - if ( $header ) { - $token = get_token_from_bearer_header( $header ); - if ( $token ) { - return $token; - } - } - /** - * Provide an alternative authorization header value. + * Filter the authorization header name used to extract the bearer token. * - * Use this filter when the standard Authorization header is consumed by a - * proxy or server layer (e.g. Imperva HTTP Basic Auth). Return the raw - * header value (e.g. "Bearer ") to have it parsed as a bearer token. - * Return null to skip the fallback entirely. + * Override when the standard Authorization header is consumed by a proxy + * (e.g. Imperva HTTP Basic Auth) and the token is forwarded under a + * different name such as X-Authorization. * - * @param string|null $header Raw header value, or null to skip. + * @param string $name Header name. Default 'authorization'. */ - $alt_header = apply_filters( 'oauth2.authentication.alternative_authorization_header', null ); - if ( $alt_header ) { - $token = get_token_from_bearer_header( $alt_header ); + $header = get_authorization_header( apply_filters( 'oauth2.authentication.authorization_header', 'authorization' ) ); + if ( $header ) { + $token = get_token_from_bearer_header( $header ); if ( $token ) { return $token; } From fe7d5ff8d71d70313e7b949ee4cce5094ea65599 Mon Sep 17 00:00:00 2001 From: Abhishek Kaushik Date: Tue, 2 Jun 2026 16:36:13 +0530 Subject: [PATCH 04/10] Add tests for authorization header handling --- tests/test-authentication.php | 142 ++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/test-authentication.php b/tests/test-authentication.php index 8d185e3..cde4135 100644 --- a/tests/test-authentication.php +++ b/tests/test-authentication.php @@ -14,6 +14,7 @@ use WP_User; use function WP\OAuth2\Authentication\attempt_authentication; +use function WP\OAuth2\Authentication\get_authorization_header; use function WP\OAuth2\Authentication\get_token_from_bearer_header; use function WP\OAuth2\Authentication\maybe_report_errors; @@ -118,6 +119,147 @@ public function test_attempt_authentication_no_op_when_no_token() { $this->assertNull( $result ); } + // ------------------------------------------------------------------------- + // get_authorization_header + // ------------------------------------------------------------------------- + + public function test_get_authorization_header_reads_default_authorization_header() { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer testtoken'; + + $result = get_authorization_header(); + + $this->assertEquals( 'Bearer testtoken', $result ); + } + + public function test_get_authorization_header_reads_custom_header_name() { + $_SERVER['HTTP_X_CUSTOM_AUTH'] = 'Bearer customtoken'; + + $result = get_authorization_header( 'x-custom-auth' ); + + unset( $_SERVER['HTTP_X_CUSTOM_AUTH'] ); + $this->assertEquals( 'Bearer customtoken', $result ); + } + + public function test_get_authorization_header_returns_null_when_header_absent() { + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + + $result = get_authorization_header(); + + $this->assertNull( $result ); + } + + public function test_get_authorization_header_returns_null_for_absent_custom_header() { + unset( $_SERVER['HTTP_X_MISSING'] ); + + $result = get_authorization_header( 'x-missing' ); + + $this->assertNull( $result ); + } + + public function test_get_authorization_header_converts_hyphen_to_underscore_in_server_key() { + $_SERVER['HTTP_X_MY_TOKEN'] = 'Bearer hyphentest'; + + $result = get_authorization_header( 'x-my-token' ); + + unset( $_SERVER['HTTP_X_MY_TOKEN'] ); + $this->assertEquals( 'Bearer hyphentest', $result ); + } + + // ------------------------------------------------------------------------- + // oauth2.authentication.authorization_header filter + // ------------------------------------------------------------------------- + + public function test_authorization_header_filter_default_reads_authorization_header() { + $token = Access_Token::create( $this->client, $this->user ); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token->get_key(); + $result = attempt_authentication(); + + $this->assertEquals( $this->user->ID, $result ); + } + + public function test_custom_authorization_header_filter_authenticates_token() { + $token = Access_Token::create( $this->client, $this->user ); + + add_filter( 'oauth2.authentication.authorization_header', static function () { + return 'x-my-auth'; + } ); + $_SERVER['HTTP_X_MY_AUTH'] = 'Bearer ' . $token->get_key(); + + $result = attempt_authentication(); + + remove_all_filters( 'oauth2.authentication.authorization_header' ); + unset( $_SERVER['HTTP_X_MY_AUTH'] ); + + $this->assertEquals( $this->user->ID, $result ); + } + + public function test_custom_header_filter_with_hyphenated_name() { + $token = Access_Token::create( $this->client, $this->user ); + + add_filter( 'oauth2.authentication.authorization_header', static function () { + return 'x-forwarded-auth'; + } ); + $_SERVER['HTTP_X_FORWARDED_AUTH'] = 'Bearer ' . $token->get_key(); + + $result = attempt_authentication(); + + remove_all_filters( 'oauth2.authentication.authorization_header' ); + unset( $_SERVER['HTTP_X_FORWARDED_AUTH'] ); + + $this->assertEquals( $this->user->ID, $result ); + } + + public function test_custom_header_absent_does_not_authenticate() { + add_filter( 'oauth2.authentication.authorization_header', static function () { + return 'x-my-auth'; + } ); + unset( $_SERVER['HTTP_X_MY_AUTH'] ); + + $result = attempt_authentication(); + + remove_all_filters( 'oauth2.authentication.authorization_header' ); + + $this->assertNull( $result ); + } + + public function test_custom_header_with_invalid_token_sets_error() { + global $oauth2_error; + + add_filter( 'oauth2.authentication.authorization_header', static function () { + return 'x-my-auth'; + } ); + $_SERVER['HTTP_X_MY_AUTH'] = 'Bearer invalidtoken123'; + + attempt_authentication(); + + remove_all_filters( 'oauth2.authentication.authorization_header' ); + unset( $_SERVER['HTTP_X_MY_AUTH'] ); + + $this->assertWPError( $oauth2_error ); + $this->assertEquals( + 'oauth2.authentication.attempt_authentication.invalid_token', + $oauth2_error->get_error_code() + ); + } + + public function test_filter_does_not_affect_other_requests_after_removal() { + $token = Access_Token::create( $this->client, $this->user ); + + // Add and immediately remove the filter. + $cb = static function () { + return 'x-my-auth'; + }; + add_filter( 'oauth2.authentication.authorization_header', $cb ); + remove_filter( 'oauth2.authentication.authorization_header', $cb ); + + // Standard Authorization header should still work. + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token->get_key(); + $result = attempt_authentication(); + + $this->assertEquals( $this->user->ID, $result ); + } + // ------------------------------------------------------------------------- // maybe_report_errors // ------------------------------------------------------------------------- From d251eb8cb853c7b4c58e30f896af7b42386379ea Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Wed, 2 Sep 2026 11:21:07 +0100 Subject: [PATCH 05/10] Allow composer/installers v2 The constraint is only used by consuming projects that install this plugin via Composer; the plugin itself has no code dependency on the installer's API. Widening to ^1 || ^2 keeps installs working as installers v1 is deprecated and hosting ecosystems move to v2. Co-authored-by: CommandCodeBot --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index afec5a4..b0d06cb 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ } ], "require": { - "composer/installers": "~1.0", + "composer/installers": "^1 || ^2", "php": ">=7.4" }, "require-dev": { From 7e1df6f56655f69072136f62949e42e1521c6320 Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Mon, 7 Sep 2026 17:56:06 +0100 Subject: [PATCH 06/10] Accept HTTP Basic client credentials on the authorization_code grant RFC 6749 section 2.3.1 makes Basic auth the recommended way for a client to authenticate at the token endpoint, and the client_credentials grant already parses it. The authorization_code grant only read client_id from the body, so MCP clients authenticating this way failed with a missing parameter error. --- inc/endpoints/class-token.php | 79 ++++++++++++++++++++++++----------- tests/test-token-endpoint.php | 46 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 24 deletions(-) diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php index 46368f4..49f6b7f 100644 --- a/inc/endpoints/class-token.php +++ b/inc/endpoints/class-token.php @@ -72,6 +72,21 @@ public function exchange_token( WP_REST_Request $request ) { return $this->handle_client_credentials( $request ); } + // RFC 6749 section 2.3.1: a client may authenticate with HTTP Basic + // instead of body parameters. Body parameters take precedence. + if ( $request->get_param( 'client_id' ) === null || $request->get_param( 'client_id' ) === '' ) { + $basic = $this->get_basic_auth_credentials( $request ); + if ( is_wp_error( $basic ) ) { + return $basic; + } + if ( null !== $basic ) { + $request->set_param( 'client_id', $basic[0] ); + if ( $request->get_param( 'client_secret' ) === null || $request->get_param( 'client_secret' ) === '' ) { + $request->set_param( 'client_secret', $basic[1] ); + } + } + } + // The authorization_code grant requires `client_id` and `code`. // These are declared optional at the schema level so they don't // apply to client_credentials, so validate presence here. The error @@ -201,30 +216,9 @@ private function extract_client_credentials( WP_REST_Request $request ) { } // Fall back to Basic authentication from Authorization header - $auth_header = $request->get_header( 'authorization' ); - - if ( ! empty( $auth_header ) && stripos( $auth_header, 'Basic ' ) === 0 ) { - $encoded = substr( $auth_header, 6 ); - $decoded = base64_decode( $encoded, true ); - - if ( false === $decoded ) { - return new WP_Error( - 'oauth2.endpoints.token.invalid_request', - __( 'Invalid Authorization header.', 'oauth2' ), - [ 'status' => WP_Http::BAD_REQUEST ] - ); - } - - $parts = explode( ':', $decoded, 2 ); - if ( count( $parts ) !== 2 ) { - return new WP_Error( - 'oauth2.endpoints.token.invalid_request', - __( 'Invalid Authorization header format.', 'oauth2' ), - [ 'status' => WP_Http::BAD_REQUEST ] - ); - } - - return [ trim( $parts[0] ), trim( $parts[1] ) ]; + $basic = $this->get_basic_auth_credentials( $request ); + if ( null !== $basic ) { + return $basic; } return new WP_Error( @@ -233,4 +227,41 @@ private function extract_client_credentials( WP_REST_Request $request ) { [ 'status' => WP_Http::BAD_REQUEST ] ); } + + /** + * Read client credentials from an HTTP Basic Authorization header. + * + * @param WP_REST_Request $request Request object. + * @return array|WP_Error|null Array with client_id and client_secret, error if the + * header is malformed, or null if there is no Basic header. + */ + private function get_basic_auth_credentials( WP_REST_Request $request ) { + $auth_header = $request->get_header( 'authorization' ); + + if ( empty( $auth_header ) || stripos( $auth_header, 'Basic ' ) !== 0 ) { + return null; + } + + $encoded = substr( $auth_header, 6 ); + $decoded = base64_decode( $encoded, true ); + + if ( false === $decoded ) { + return new WP_Error( + 'oauth2.endpoints.token.invalid_request', + __( 'Invalid Authorization header.', 'oauth2' ), + [ 'status' => WP_Http::BAD_REQUEST ] + ); + } + + $parts = explode( ':', $decoded, 2 ); + if ( count( $parts ) !== 2 ) { + return new WP_Error( + 'oauth2.endpoints.token.invalid_request', + __( 'Invalid Authorization header format.', 'oauth2' ), + [ 'status' => WP_Http::BAD_REQUEST ] + ); + } + + return [ trim( $parts[0] ), trim( $parts[1] ) ]; + } } diff --git a/tests/test-token-endpoint.php b/tests/test-token-endpoint.php index e9fe11e..2e4e224 100644 --- a/tests/test-token-endpoint.php +++ b/tests/test-token-endpoint.php @@ -133,6 +133,52 @@ public function test_exchange_token_valid() { $this->assertEquals( 'bearer', $data['token_type'] ); } + public function test_exchange_token_client_id_via_basic_auth_header() { + $user = $this->factory->user->create_and_get(); + $code = Authorization_Code::create( $this->client, $user ); + $encoded = base64_encode( $this->client->get_id() . ':' . $this->client->get_secret() ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'code', $code->get_code() ); + $request->add_header( 'Authorization', 'Basic ' . $encoded ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + } + + public function test_exchange_token_body_client_id_takes_precedence_over_basic_auth_header() { + $user = $this->factory->user->create_and_get(); + $code = Authorization_Code::create( $this->client, $user ); + $encoded = base64_encode( 'nonexistent-client:any-secret' ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'code', $code->get_code() ); + $request->add_header( 'Authorization', 'Basic ' . $encoded ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + public function test_exchange_token_invalid_basic_auth_header() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'code', 'somecode' ); + $request->add_header( 'Authorization', 'Basic not-valid-base64!!!' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'oauth2.endpoints.token.invalid_request', $data['code'] ); + } + public function test_exchange_token_deletes_code_after_use() { $user = $this->factory->user->create_and_get(); $code = Authorization_Code::create( $this->client, $user ); From f79421d6c20354e67926f9d12e6ddef1a46d089c Mon Sep 17 00:00:00 2001 From: Tom J Nowell Date: Wed, 9 Sep 2026 15:10:25 +0100 Subject: [PATCH 07/10] Form-decode Basic auth client credentials RFC 6749 section 2.3.1 has the client form-encode the id and secret before they go in the header, so a secret containing a space or a plus only matches once the server decodes it. --- inc/endpoints/class-token.php | 4 +++- tests/test-token-endpoint.php | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php index 49f6b7f..b853c75 100644 --- a/inc/endpoints/class-token.php +++ b/inc/endpoints/class-token.php @@ -262,6 +262,8 @@ private function get_basic_auth_credentials( WP_REST_Request $request ) { ); } - return [ trim( $parts[0] ), trim( $parts[1] ) ]; + // RFC 6749 section 2.3.1: both values are form-encoded before they go + // into the header, so decode them on the way back out. + return [ urldecode( trim( $parts[0] ) ), urldecode( trim( $parts[1] ) ) ]; } } diff --git a/tests/test-token-endpoint.php b/tests/test-token-endpoint.php index 2e4e224..d7c7180 100644 --- a/tests/test-token-endpoint.php +++ b/tests/test-token-endpoint.php @@ -231,6 +231,26 @@ public function test_client_credentials_via_basic_auth_header() { $this->assertArrayHasKey( 'access_token', $data ); } + public function test_client_credentials_basic_auth_header_is_form_decoded() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $secret = 'a b+c'; + update_post_meta( $client->get_post_id(), Client::CLIENT_SECRET_KEY, $secret ); + + // Per RFC 6749 section 2.3.1 the client form-encodes both values, so a + // space arrives as "+" and a literal "+" as "%2B". + $encoded = base64_encode( urlencode( $client->get_id() ) . ':' . urlencode( $secret ) ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->add_header( 'Authorization', 'Basic ' . $encoded ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + } + public function test_client_credentials_wrong_secret() { $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); From ad76c4c6f1aa1d97eefc796debbc78a9da568a01 Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Thu, 17 Sep 2026 13:11:26 +0100 Subject: [PATCH 08/10] 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 09/10] 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 10/10] 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 ); + } +}