diff --git a/.gitignore b/.gitignore index 4a9b0e3..04b6984 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,12 @@ ## https://docs.npmjs.com/misc/faq#should-i-check-my-node_modules-folder-into-git node_modules -# Composer-generated lock file +# Composer-generated lock file and dependencies composer.lock +vendor + +# PHPUnit cache +.phpunit.result.cache # Book build output _book diff --git a/inc/namespace.php b/inc/namespace.php index ea681f1..6222e57 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -19,6 +19,7 @@ function bootstrap() { add_filter( 'rest_authentication_errors', __NAMESPACE__ . '\\Authentication\\maybe_report_errors' ); add_filter( 'rest_index', __NAMESPACE__ . '\\register_in_index' ); add_action( 'rest_api_init', __NAMESPACE__ . '\\Endpoints\\register' ); + add_action( 'parse_request', __NAMESPACE__ . '\\Well_Known\\maybe_serve_document' ); // Internal default hooks. add_filter( 'oauth2.grant_types', __NAMESPACE__ . '\\register_grant_types', 0 ); diff --git a/inc/well-known/namespace.php b/inc/well-known/namespace.php new file mode 100644 index 0000000..53eec08 --- /dev/null +++ b/inc/well-known/namespace.php @@ -0,0 +1,188 @@ + get_site()->domain, + 'path' => $site_path, + 'number' => 1, + 'fields' => 'ids', + ] + ); + + if ( empty( $sites ) ) { + return null; + } + + return (int) $sites[0]; +} + +/** + * Gets the metadata document describing a site on the network. + * + * @param int $site_id Site to describe. + * @return array RFC 8414 metadata document. + */ +function get_metadata_for_site( $site_id ) { + if ( ! is_multisite() || get_current_blog_id() === $site_id ) { + return get_authorization_server_metadata(); + } + + switch_to_blog( $site_id ); + $metadata = get_authorization_server_metadata(); + restore_current_blog(); + + return $metadata; +} + +/** + * Builds the RFC 8414 authorization server metadata document. + * + * @return array Metadata describing the current site. + */ +function get_authorization_server_metadata() { + $metadata = [ + 'issuer' => home_url(), + 'authorization_endpoint' => OAuth2\get_authorization_url(), + 'token_endpoint' => OAuth2\get_token_url(), + 'grant_types_supported' => get_grant_types_supported(), + 'response_types_supported' => get_response_types_supported(), + 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_post', 'client_secret_basic' ], + ]; + + /** + * Filter the OAuth2 authorization server metadata for a site. + * + * @param array $metadata RFC 8414 metadata document. + */ + return apply_filters( 'oauth2.well_known_authorization_server_metadata', $metadata ); +} + +/** + * Gets the grant types the token endpoint accepts. + * + * Combines the registered authorization grant type handlers with + * `client_credentials`, which the token endpoint supports directly rather + * than through the `oauth2.grant_types` filter. + * + * @return string[] Grant type identifiers. + */ +function get_grant_types_supported() { + $grant_types = array_keys( OAuth2\get_grant_types() ); + $grant_types[] = 'client_credentials'; + + return array_values( array_unique( $grant_types ) ); +} + +/** + * Gets the response types advertised by the registered grant type handlers. + * + * @return string[] Response type codes, e.g. `code`, `token`. + */ +function get_response_types_supported() { + $response_types = []; + + foreach ( OAuth2\get_grant_types() as $handler ) { + $response_types[] = $handler->get_response_type_code(); + } + + return array_values( array_unique( $response_types ) ); +} + +/** + * Sends a JSON discovery document and exits. + * + * @param array $document Data to encode as the response body. + */ +function send_json_document( $document ) { + header( 'Content-Type: application/json' ); + header( 'Access-Control-Allow-Origin: *' ); + echo wp_json_encode( $document ); + exit; +} diff --git a/plugin.php b/plugin.php index 9b8ae34..0d85d37 100644 --- a/plugin.php +++ b/plugin.php @@ -39,6 +39,7 @@ require __DIR__ . '/inc/endpoints/namespace.php'; require __DIR__ . '/inc/endpoints/class-authorization.php'; require __DIR__ . '/inc/endpoints/class-token.php'; +require __DIR__ . '/inc/well-known/namespace.php'; require __DIR__ . '/inc/tokens/namespace.php'; require __DIR__ . '/inc/tokens/class-token.php'; require __DIR__ . '/inc/tokens/class-access-token.php'; diff --git a/tests/test-well-known.php b/tests/test-well-known.php new file mode 100644 index 0000000..ecae459 --- /dev/null +++ b/tests/test-well-known.php @@ -0,0 +1,165 @@ +markTestSkipped( 'Requires a multisite install.' ); + } + } + + // ------------------------------------------------------------------------- + // match_well_known_path + // ------------------------------------------------------------------------- + + public function test_match_well_known_path_matches_root_site() { + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server' ) ); + } + + public function test_match_well_known_path_tolerates_trailing_slash() { + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server/' ) ); + } + + public function test_match_well_known_path_ignores_query_string() { + $this->assertEquals( '/', match_well_known_path( '/.well-known/oauth-authorization-server?foo=bar' ) ); + } + + public function test_match_well_known_path_returns_null_for_unrelated_path() { + $this->assertNull( match_well_known_path( '/some-other-path' ) ); + } + + /** + * RFC 8414 puts the well-known path in front of the site's own path. + */ + public function test_match_well_known_path_reads_site_path_suffix() { + $this->assertEquals( '/blog/', match_well_known_path( '/.well-known/oauth-authorization-server/blog' ) ); + } + + public function test_match_well_known_path_reads_nested_site_path_suffix() { + $this->assertEquals( '/blog/sub/', match_well_known_path( '/.well-known/oauth-authorization-server/blog/sub' ) ); + } + + public function test_match_well_known_path_matches_a_subsites_own_path() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + switch_to_blog( $site_id ); + $matched = match_well_known_path( '/blog/.well-known/oauth-authorization-server' ); + restore_current_blog(); + + $this->assertEquals( '/blog/', $matched ); + } + + // ------------------------------------------------------------------------- + // get_site_id_by_path + // ------------------------------------------------------------------------- + + public function test_get_site_id_by_path_finds_the_current_site() { + $this->assertEquals( get_current_blog_id(), get_site_id_by_path( '/' ) ); + } + + public function test_get_site_id_by_path_returns_null_for_unknown_path() { + $this->assertNull( get_site_id_by_path( '/no-such-site/' ) ); + } + + public function test_get_site_id_by_path_finds_a_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $this->assertEquals( $site_id, get_site_id_by_path( '/blog/' ) ); + } + + // ------------------------------------------------------------------------- + // get_authorization_server_metadata + // ------------------------------------------------------------------------- + + public function test_metadata_issuer_is_the_site_url() { + $metadata = get_authorization_server_metadata(); + $this->assertEquals( home_url(), $metadata['issuer'] ); + } + + public function test_metadata_advertises_the_token_endpoint() { + $metadata = get_authorization_server_metadata(); + $this->assertStringContainsString( 'oauth2/access_token', $metadata['token_endpoint'] ); + } + + public function test_metadata_is_filterable() { + add_filter( 'oauth2.well_known_authorization_server_metadata', function ( $metadata ) { + $metadata['service_documentation'] = 'https://example.org/docs'; + return $metadata; + } ); + + $metadata = get_authorization_server_metadata(); + + $this->assertEquals( 'https://example.org/docs', $metadata['service_documentation'] ); + } + + // ------------------------------------------------------------------------- + // get_metadata_for_site + // ------------------------------------------------------------------------- + + public function test_get_metadata_for_site_describes_the_requested_subsite() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + + $metadata = get_metadata_for_site( $site_id ); + + $this->assertEquals( get_home_url( $site_id ), $metadata['issuer'] ); + $this->assertNotEquals( home_url(), $metadata['issuer'] ); + $this->assertStringContainsString( '/blog/', $metadata['token_endpoint'] ); + } + + public function test_get_metadata_for_site_restores_the_current_site() { + $this->require_multisite(); + + $site_id = $this->factory->blog->create( [ 'path' => '/blog/' ] ); + $original = get_current_blog_id(); + + get_metadata_for_site( $site_id ); + + $this->assertEquals( $original, get_current_blog_id() ); + } + + // ------------------------------------------------------------------------- + // get_grant_types_supported / get_response_types_supported + // ------------------------------------------------------------------------- + + public function test_get_grant_types_supported_includes_authorization_code() { + $this->assertContains( 'authorization_code', get_grant_types_supported() ); + } + + public function test_get_grant_types_supported_includes_client_credentials() { + $this->assertContains( 'client_credentials', get_grant_types_supported() ); + } + + public function test_get_response_types_supported_includes_code_and_token() { + $response_types = get_response_types_supported(); + $this->assertContains( 'code', $response_types ); + $this->assertContains( 'token', $response_types ); + } +}