diff --git a/.env.example.complete b/.env.example.complete index e92eb5099..418875165 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -245,6 +245,7 @@ OIDC_DISPLAY_NAME_CLAIMS=name OIDC_CLIENT_ID=null OIDC_CLIENT_SECRET=null OIDC_ISSUER=null +OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null diff --git a/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php new file mode 100644 index 000000000..26dfca1fe --- /dev/null +++ b/app/Auth/Access/OpenIdConnect/IssuerDiscoveryException.php @@ -0,0 +1,8 @@ +applySettingsFromArray($settings); + $this->validateInitial(); + } + + /** + * Apply an array of settings to populate setting properties within this class. + */ + protected function applySettingsFromArray(array $settingsArray) + { + foreach ($settingsArray as $key => $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } + } + } + + /** + * Validate any core, required properties have been set. + * @throws InvalidArgumentException + */ + protected function validateInitial() + { + $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + + if (strpos($this->issuer, 'https://') !== 0) { + throw new InvalidArgumentException("Issuer value must start with https://"); + } + } + + /** + * Perform a full validation on these settings. + * @throws InvalidArgumentException + */ + public function validate(): void + { + $this->validateInitial(); + $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; + foreach ($required as $prop) { + if (empty($this->$prop)) { + throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); + } + } + } + + /** + * Discover and autoload settings from the configured issuer. + * @throws IssuerDiscoveryException + */ + public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) + { + try { + $cacheKey = 'oidc-discovery::' . $this->issuer; + $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function() use ($httpClient) { + return $this->loadSettingsFromIssuerDiscovery($httpClient); + }); + $this->applySettingsFromArray($discoveredSettings); + } catch (ClientExceptionInterface $exception) { + throw new IssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); + } + } + + /** + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface + */ + protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array + { + $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; + $request = new Request('GET', $issuerUrl); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result)) { + throw new IssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); + } + + if ($result['issuer'] !== $this->issuer) { + throw new IssuerDiscoveryException("Unexpected issuer value found on discovery response"); + } + + $discoveredSettings = []; + + if (!empty($result['authorization_endpoint'])) { + $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; + } + + if (!empty($result['token_endpoint'])) { + $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; + } + + if (!empty($result['jwks_uri'])) { + $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); + $discoveredSettings['keys'] = array_filter($keys); + } + + return $discoveredSettings; + } + + /** + * Filter the given JWK keys down to just those we support. + */ + protected function filterKeys(array $keys): array + { + return array_filter($keys, function(array $key) { + return $key['key'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256'; + }); + } + + /** + * Return an array of jwks as PHP key=>value arrays. + * @throws ClientExceptionInterface + * @throws IssuerDiscoveryException + */ + protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array + { + $request = new Request('GET', $uri); + $response = $httpClient->sendRequest($request); + $result = json_decode($response->getBody()->getContents(), true); + + if (empty($result) || !is_array($result) || !isset($result['keys'])) { + throw new IssuerDiscoveryException("Error reading keys from issuer jwks_uri"); + } + + return $result['keys']; + } + + /** + * Get the settings needed by an OAuth provider, as a key=>value array. + */ + public function arrayForProvider(): array + { + $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settings = []; + foreach ($settingKeys as $setting) { + $settings[$setting] = $this->$setting; + } + return $settings; + } +} \ No newline at end of file diff --git a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php index 7471a5007..57c9d1238 100644 --- a/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php +++ b/app/Auth/Access/OpenIdConnect/OpenIdConnectService.php @@ -8,6 +8,9 @@ use BookStack\Exceptions\OpenIdConnectException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\UserRegistrationException; use Exception; +use GuzzleHttp\Client; +use Illuminate\Support\Facades\Cache; +use Psr\Http\Client\ClientExceptionInterface; use function auth; use function config; use function trans; @@ -39,7 +42,8 @@ class OpenIdConnectService */ public function login(): array { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); return [ 'url' => $provider->getAuthorizationUrl(), 'state' => $provider->getState(), @@ -52,34 +56,57 @@ class OpenIdConnectService * the authorization server. * Returns null if not authenticated. * @throws Exception + * @throws ClientExceptionInterface */ public function processAuthorizeResponse(?string $authorizationCode): ?User { - $provider = $this->getProvider(); + $settings = $this->getProviderSettings(); + $provider = $this->getProvider($settings); // Try to exchange authorization code for access token $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); - return $this->processAccessTokenCallback($accessToken); + return $this->processAccessTokenCallback($accessToken, $settings); } /** - * Load the underlying OpenID Connect Provider. + * @throws IssuerDiscoveryException + * @throws ClientExceptionInterface */ - protected function getProvider(): OpenIdConnectOAuthProvider + protected function getProviderSettings(): OpenIdConnectProviderSettings { - // Setup settings - $settings = [ + $settings = new OpenIdConnectProviderSettings([ + 'issuer' => $this->config['issuer'], 'clientId' => $this->config['client_id'], 'clientSecret' => $this->config['client_secret'], 'redirectUri' => url('/oidc/redirect'), 'authorizationEndpoint' => $this->config['authorization_endpoint'], 'tokenEndpoint' => $this->config['token_endpoint'], - ]; + ]); - return new OpenIdConnectOAuthProvider($settings); + // Use keys if configured + if (!empty($this->config['jwt_public_key'])) { + $settings->keys = [$this->config['jwt_public_key']]; + } + + // Run discovery + if ($this->config['discover'] ?? false) { + $settings->discoverFromIssuer(new Client(['timeout' => 3]), Cache::store(null), 15); + } + + $settings->validate(); + + return $settings; + } + + /** + * Load the underlying OpenID Connect Provider. + */ + protected function getProvider(OpenIdConnectProviderSettings $settings): OpenIdConnectOAuthProvider + { + return new OpenIdConnectOAuthProvider($settings->arrayForProvider()); } /** @@ -126,13 +153,13 @@ class OpenIdConnectService * @throws UserRegistrationException * @throws StoppedAuthenticationException */ - protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken): User + protected function processAccessTokenCallback(OpenIdConnectAccessToken $accessToken, OpenIdConnectProviderSettings $settings): User { $idTokenText = $accessToken->getIdToken(); $idToken = new OpenIdConnectIdToken( $idTokenText, - $this->config['issuer'], - [$this->config['jwt_public_key']] + $settings->issuer, + $settings->keys, ); if ($this->config['dump_user_details']) { @@ -140,7 +167,7 @@ class OpenIdConnectService } try { - $idToken->validate($this->config['client_id']); + $idToken->validate($settings->clientId); } catch (InvalidTokenException $exception) { throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}"); } diff --git a/app/Config/oidc.php b/app/Config/oidc.php index 43e8678ad..1b50d9d66 100644 --- a/app/Config/oidc.php +++ b/app/Config/oidc.php @@ -17,9 +17,14 @@ return [ // OAuth2/OpenId client secret, as configured in your Authorization server. 'client_secret' => env('OIDC_CLIENT_SECRET', null), - // The issuer of the identity token (id_token) this will be compared with what is returned in the token. + // The issuer of the identity token (id_token) this will be compared with + // what is returned in the token. 'issuer' => env('OIDC_ISSUER', null), + // Auto-discover the relevant endpoints and keys from the issuer. + // Fetched details are cached for 15 minutes. + 'discover' => env('OIDC_ISSUER_DISCOVER', false), + // Public key that's used to verify the JWT token with. // Can be the key value itself or a local 'file://public.key' reference. 'jwt_public_key' => env('OIDC_PUBLIC_KEY', null), diff --git a/composer.json b/composer.json index 066e67c4f..dc281e5a8 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "league/commonmark": "^1.5", "league/flysystem-aws-s3-v3": "^1.0.29", "league/html-to-markdown": "^5.0.0", + "league/oauth2-client": "^2.6", "nunomaduro/collision": "^3.1", "onelogin/php-saml": "^4.0", "phpseclib/phpseclib": "~3.0", diff --git a/composer.lock b/composer.lock index d64d8d640..89e408eb9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ef6a8bb7bc6e99c70eeabc7695fc56eb", + "content-hash": "b82cfdfe8bb32847ba2188804858d5fd", "packages": [ { "name": "aws/aws-crt-php", @@ -2536,6 +2536,76 @@ }, "time": "2021-08-15T23:05:49+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "badb01e62383430706433191b82506b6df24ad98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98", + "reference": "badb01e62383430706433191b82506b6df24ad98", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "paragonie/random_compat": "^1 || ^2 || ^9.99", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0" + }, + "time": "2020-10-28T02:03:40+00:00" + }, { "name": "monolog/monolog", "version": "2.3.5",