diff --git a/lib/classes/oauth2/discovery/auth_server_config_reader.php b/lib/classes/oauth2/discovery/auth_server_config_reader.php new file mode 100644 index 00000000000..ed7c58350ea --- /dev/null +++ b/lib/classes/oauth2/discovery/auth_server_config_reader.php @@ -0,0 +1,109 @@ +. + +namespace core\oauth2\discovery; + +use core\http_client; +use GuzzleHttp\Exception\ClientException; + +/** + * Simple reader class, allowing OAuth 2 Authorization Server Metadata to be read from an auth server's well-known. + * + * {@link https://www.rfc-editor.org/rfc/rfc8414} + * + * @package core + * @copyright 2023 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class auth_server_config_reader { + + /** @var \stdClass the config object read from the discovery document. */ + protected \stdClass $metadata; + + /** @var \moodle_url the base URL for the auth server which was last used during a read.*/ + protected \moodle_url $issuerurl; + + /** + * Constructor. + * + * @param http_client $httpclient an http client instance. + * @param string $wellknownsuffix the well-known suffix, defaulting to 'oauth-authorization-server'. + */ + public function __construct(protected http_client $httpclient, + protected string $wellknownsuffix = 'oauth-authorization-server') { + } + + /** + * Read the metadata from the remote host. + * + * @param \moodle_url $issuerurl the auth server issuer URL. + * @return \stdClass the configuration data object. + * @throws ClientException|\GuzzleHttp\Exception\GuzzleException if the http client experiences any problems. + */ + public function read_configuration(\moodle_url $issuerurl): \stdClass { + $this->issuerurl = $issuerurl; + $this->validate_uri(); + + $url = $this->get_configuration_url()->out(false); + $response = $this->httpclient->request('GET', $url); + $this->metadata = json_decode($response->getBody()); + return $this->metadata; + } + + /** + * Make sure the base URI is suitable for use in discovery. + * + * @return void + * @throws \moodle_exception if the URI fails validation. + */ + protected function validate_uri() { + if (!empty($this->issuerurl->get_query_string())) { + throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL cannot contain a query component.'); + } + if (strtolower($this->issuerurl->get_scheme()) !== 'https') { + throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL must use HTTPS scheme.'); + } + // This catches URL fragments. Since a query string is ruled out above, out_omit_querystring(false) returns only fragments. + if ($this->issuerurl->out_omit_querystring() != $this->issuerurl->out(false)) { + throw new \moodle_exception('Error: '.__METHOD__.': Auth server base URL must not contain fragments.'); + } + } + + /** + * Get the Auth server metadata URL. + * + * Per {@link https://www.rfc-editor.org/rfc/rfc8414#section-3}, if the issuer URL contains a path component, + * the well known suffix is added between the host and path components. + * + * @return \moodle_url the full URL to the auth server metadata. + */ + protected function get_configuration_url(): \moodle_url { + $path = $this->issuerurl->get_path(); + if (!empty($path) && $path !== '/') { + // Insert the well known suffix between the host and path components. + $port = $this->issuerurl->get_port() ? ':'.$this->issuerurl->get_port() : ''; + $uri = $this->issuerurl->get_scheme() . "://" . $this->issuerurl->get_host() . $port ."/". + ".well-known/" . $this->wellknownsuffix . $path; + } else { + // No path, just append the well known suffix. + $uri = $this->issuerurl->out(false); + $uri .= (substr($uri, -1) == '/' ? '' : '/'); + $uri .= ".well-known/$this->wellknownsuffix"; + } + + return new \moodle_url($uri); + } +} diff --git a/lib/tests/oauth2/discovery/auth_server_config_reader_test.php b/lib/tests/oauth2/discovery/auth_server_config_reader_test.php new file mode 100644 index 00000000000..fb5671b8870 --- /dev/null +++ b/lib/tests/oauth2/discovery/auth_server_config_reader_test.php @@ -0,0 +1,674 @@ +. + +namespace core\oauth2\discovery; + +use core\http_client; +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use Psr\Http\Message\ResponseInterface; + +/** + * Unit tests for {@see auth_server_config_reader}. + * + * @coversDefaultClass \core\oauth2\discovery\auth_server_config_reader + * @package core + * @copyright 2023 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class auth_server_config_reader_test extends \advanced_testcase { + + /** + * Test reading the config for an auth server. + * + * @covers ::read_configuration + * @dataProvider config_provider + * @param string $issuerurl the auth server issuer URL. + * @param ResponseInterface $httpresponse a stub HTTP response. + * @param null|string $altwellknownsuffix an alternate value for the well known suffix to use in the reader. + * @param array $expected test expectations. + * @return void + */ + public function test_read_configuration(string $issuerurl, ResponseInterface $httpresponse, ?string $altwellknownsuffix = null, + array $expected = []) { + + $mock = new MockHandler([$httpresponse]); + $handlerstack = HandlerStack::create($mock); + if (!empty($expected['request'])) { + // Request history tracking to allow asserting that request was sent as expected below (to the stub client). + $container = []; + $history = Middleware::history($container); + $handlerstack->push($history); + } + + $args = [ + new http_client(['handler' => $handlerstack]), + ]; + if (!is_null($altwellknownsuffix)) { + $args[] = $altwellknownsuffix; + } + + if (!empty($expected['exception'])) { + $this->expectException($expected['exception']); + } + $configreader = new auth_server_config_reader(...$args); + $config = $configreader->read_configuration(new \moodle_url($issuerurl)); + + if (!empty($expected['request'])) { + // Verify the request goes to the correct URL (i.e. the well known suffix is correctly positioned). + $this->assertEquals($expected['request']['url'], $container[0]['request']->getUri()); + } + + $this->assertEquals($expected['metadata'], (array) $config); + } + + /** + * Provider for testing read_configuration(). + * + * @return array test data. + */ + public function config_provider(): array { + return [ + 'Valid, good issuer URL, good config' => [ + 'issuer_url' => 'https://app.example.com', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com/.well-known/oauth-authorization-server' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Valid, issuer URL with path component confirming well known suffix placement' => [ + 'issuer_url' => 'https://app.example.com/some/path', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com/.well-known/oauth-authorization-server/some/path' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Valid, single trailing / path only' => [ + 'issuer_url' => 'https://app.example.com/', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com/.well-known/oauth-authorization-server' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Invalid, non HTTPS issuer URL' => [ + 'issuer_url' => 'http://app.example.com', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'exception' => \moodle_exception::class + ] + ], + 'Invalid, query string in issuer URL' => [ + 'issuer_url' => 'https://app.example.com?test=cat', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'exception' => \moodle_exception::class + ] + ], + 'Invalid, fragment in issuer URL' => [ + 'issuer_url' => 'https://app.example.com/#cat', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'exception' => \moodle_exception::class + ] + ], + 'Valid, port in issuer URL' => [ + 'issuer_url' => 'https://app.example.com:8080/some/path', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => null, + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com:8080/.well-known/oauth-authorization-server/some/path' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Valid, alternate well known suffix, no path' => [ + 'issuer_url' => 'https://app.example.com', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => 'openid-configuration', // An application using the openid well known, which is valid. + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com/.well-known/openid-configuration' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Valid, alternate well known suffix, with path' => [ + 'issuer_url' => 'https://app.example.com/some/path/', + 'http_response' => new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ]) + ), + 'well_known_suffix' => 'openid-configuration', // An application using the openid well known, which is valid. + 'expected' => [ + 'request' => [ + 'url' => 'https://app.example.com/.well-known/openid-configuration/some/path/' + ], + 'metadata' => [ + "issuer" => "https://app.example.com", + "authorization_endpoint" => "https://app.example.com/authorize", + "token_endpoint" => "https://app.example.com/token", + "token_endpoint_auth_methods_supported" => [ + "client_secret_basic", + "private_key_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported" => [ + "RS256", + "ES256" + ], + "userinfo_endpoint" => "https://app.example.com/userinfo", + "jwks_uri" => "https://app.example.com/jwks.json", + "registration_endpoint" => "https://app.example.com/register", + "scopes_supported" => [ + "openid", + "profile", + "email", + ], + "response_types_supported" => [ + "code", + "code token" + ], + "service_documentation" => "http://app.example.com/service_documentation.html", + "ui_locales_supported" => [ + "en-US", + "en-GB", + "fr-FR", + ] + ] + ] + ], + 'Invalid, bad response' => [ + 'issuer_url' => 'https://app.example.com', + 'http_response' => new Response(404), + 'well_known_suffix' => null, + 'expected' => [ + 'exception' => ClientException::class + ] + ] + ]; + } +}