diff --git a/composer.json b/composer.json index c6c9661..c94b27d 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": { diff --git a/inc/authentication/namespace.php b/inc/authentication/namespace.php index 0078792..d52420a 100644 --- a/inc/authentication/namespace.php +++ b/inc/authentication/namespace.php @@ -8,30 +8,33 @@ namespace WP\OAuth2\Authentication; use WP_Error; +use WP_REST_Response; use WP_User; use WP\OAuth2\Tokens; +use WP\OAuth2\Well_Known; /** - * 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,9 +49,21 @@ function get_authorization_header() { * @return string|null Token on success, null on failure. */ function get_provided_token() { - $header = get_authorization_header(); + /** + * Filter the authorization header name used to extract the bearer token. + * + * 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 $name Header name. Default 'authorization'. + */ + $header = get_authorization_header( apply_filters( 'oauth2.authentication.authorization_header', 'authorization' ) ); if ( $header ) { - return get_token_from_bearer_header( $header ); + $token = get_token_from_bearer_header( $header ); + if ( $token ) { + return $token; + } } $token = get_token_from_request(); @@ -196,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/endpoints/class-token.php b/inc/endpoints/class-token.php index 46368f4..b853c75 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,43 @@ 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 ] + ); + } + + // 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/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/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; 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-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 // ------------------------------------------------------------------------- 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'] + ); + } +} diff --git a/tests/test-token-endpoint.php b/tests/test-token-endpoint.php index e9fe11e..d7c7180 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 ); @@ -185,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 ] ); 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 ); + } +}