BookStack/app/Access/Oidc/OidcProviderSettings.php

201 lines
6.5 KiB
PHP
Raw Normal View History

2021-10-13 06:00:52 +08:00
<?php
2023-05-18 00:56:55 +08:00
namespace BookStack\Access\Oidc;
2021-10-13 06:00:52 +08:00
use GuzzleHttp\Psr7\Request;
use Illuminate\Contracts\Cache\Repository;
use InvalidArgumentException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
/**
* OpenIdConnectProviderSettings
* Acts as a DTO for settings used within the oidc request and token handling.
* Performs auto-discovery upon request.
*/
2021-10-13 06:04:28 +08:00
class OidcProviderSettings
2021-10-13 06:00:52 +08:00
{
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
Oidc: Properly query the UserInfo Endpoint BooksStack's OIDC Client requests the 'profile' and 'email' scope values in order to have access to the 'name', 'email', and other claims. It looks for these claims in the ID Token that is returned along with the Access Token. However, the OIDC-core specification section 5.4 [1] only requires that the Provider include those claims in the ID Token *if* an Access Token is not also issued. If an Access Token is issued, the Provider can leave out those claims from the ID Token, and the Client is supposed to obtain them by submitting the Access Token to the UserInfo Endpoint. So I suppose it's just good luck that the OIDC Providers that BookStack has been tested with just so happen to also stick those claims in the ID Token even though they don't have to. But others (in particular: https://login.infomaniak.com) don't do so, and require fetching the UserInfo Endpoint.) A workaround is currently possible by having the user write a theme with a ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo Endpoint. This workaround isn't great, for a few reasons: 1. Asking the user to implement core parts of the OIDC protocol is silly. 2. The user either needs to re-fetch the .well-known/openid-configuration file to discover the endpoint (adding yet another round-trip to each login) or hard-code the endpoint, which is fragile. 3. The hook doesn't receive the HTTP client configuration. So, have BookStack's OidcService fetch the UserInfo Endpoint and inject those claims into the ID Token, if a UserInfo Endpoint is defined. Two points about this: - Injecting them into the ID Token's claims is the most obvious approach given the current code structure; though I'm not sure it is the best approach, perhaps it should instead fetch the user info in processAuthorizationResponse() and pass that as an argument to processAccessTokenCallback() which would then need a bit of restructuring. But this made sense because it's also how the ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works. - OIDC *requires* that a UserInfo Endpoint exists, so why bother with that "if a UserInfo Endpoint is defined" bit? Simply out of an abundance of caution that there's an existing BookStack user that is relying on it not fetching the UserInfo Endpoint in order to work with a non-compliant OIDC Provider. [1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-16 01:58:20 +08:00
public ?string $userinfoEndpoint;
2021-10-13 06:00:52 +08:00
/**
* @var string[]|array[]
*/
public ?array $keys = [];
2021-10-13 06:00:52 +08:00
public function __construct(array $settings)
{
$this->applySettingsFromArray($settings);
$this->validateInitial();
}
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray): void
2021-10-13 06:00:52 +08:00
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
/**
* Validate any core, required properties have been set.
*
2021-10-13 06:00:52 +08:00
* @throws InvalidArgumentException
*/
protected function validateInitial(): void
2021-10-13 06:00:52 +08:00
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://');
2021-10-13 06:00:52 +08:00
}
}
/**
* Perform a full validation on these settings.
*
2021-10-13 06:00:52 +08:00
* @throws InvalidArgumentException
*/
public function validate(): void
{
$this->validateInitial();
2021-10-13 06:00:52 +08:00
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
foreach ($endpointProperties as $prop) {
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
}
}
2021-10-13 06:00:52 +08:00
}
/**
* Discover and autoload settings from the configured issuer.
*
2021-10-13 06:04:28 +08:00
* @throws OidcIssuerDiscoveryException
2021-10-13 06:00:52 +08:00
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
2021-10-13 06:00:52 +08:00
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
2021-10-13 06:00:52 +08:00
return $this->loadSettingsFromIssuerDiscovery($httpClient);
});
$this->applySettingsFromArray($discoveredSettings);
} catch (ClientExceptionInterface $exception) {
2021-10-13 06:04:28 +08:00
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
2021-10-13 06:00:52 +08:00
}
}
/**
2021-10-13 06:04:28 +08:00
* @throws OidcIssuerDiscoveryException
2021-10-13 06:00:52 +08:00
* @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)) {
2021-10-13 06:04:28 +08:00
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
2021-10-13 06:00:52 +08:00
}
if ($result['issuer'] !== $this->issuer) {
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
2021-10-13 06:00:52 +08:00
}
$discoveredSettings = [];
if (!empty($result['authorization_endpoint'])) {
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
}
if (!empty($result['token_endpoint'])) {
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
Oidc: Properly query the UserInfo Endpoint BooksStack's OIDC Client requests the 'profile' and 'email' scope values in order to have access to the 'name', 'email', and other claims. It looks for these claims in the ID Token that is returned along with the Access Token. However, the OIDC-core specification section 5.4 [1] only requires that the Provider include those claims in the ID Token *if* an Access Token is not also issued. If an Access Token is issued, the Provider can leave out those claims from the ID Token, and the Client is supposed to obtain them by submitting the Access Token to the UserInfo Endpoint. So I suppose it's just good luck that the OIDC Providers that BookStack has been tested with just so happen to also stick those claims in the ID Token even though they don't have to. But others (in particular: https://login.infomaniak.com) don't do so, and require fetching the UserInfo Endpoint.) A workaround is currently possible by having the user write a theme with a ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo Endpoint. This workaround isn't great, for a few reasons: 1. Asking the user to implement core parts of the OIDC protocol is silly. 2. The user either needs to re-fetch the .well-known/openid-configuration file to discover the endpoint (adding yet another round-trip to each login) or hard-code the endpoint, which is fragile. 3. The hook doesn't receive the HTTP client configuration. So, have BookStack's OidcService fetch the UserInfo Endpoint and inject those claims into the ID Token, if a UserInfo Endpoint is defined. Two points about this: - Injecting them into the ID Token's claims is the most obvious approach given the current code structure; though I'm not sure it is the best approach, perhaps it should instead fetch the user info in processAuthorizationResponse() and pass that as an argument to processAccessTokenCallback() which would then need a bit of restructuring. But this made sense because it's also how the ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works. - OIDC *requires* that a UserInfo Endpoint exists, so why bother with that "if a UserInfo Endpoint is defined" bit? Simply out of an abundance of caution that there's an existing BookStack user that is relying on it not fetching the UserInfo Endpoint in order to work with a non-compliant OIDC Provider. [1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-16 01:58:20 +08:00
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
2021-10-13 06:00:52 +08:00
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
2021-10-13 06:00:52 +08:00
}
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
2021-10-13 06:00:52 +08:00
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) {
$alg = $key['alg'] ?? 'RS256';
$use = $key['use'] ?? 'sig';
2022-01-31 00:44:19 +08:00
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
2021-10-13 06:00:52 +08:00
});
}
/**
* Return an array of jwks as PHP key=>value arrays.
*
2021-10-13 06:00:52 +08:00
* @throws ClientExceptionInterface
2021-10-13 06:04:28 +08:00
* @throws OidcIssuerDiscoveryException
2021-10-13 06:00:52 +08:00
*/
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 OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
2021-10-13 06:00:52 +08:00
}
return $result['keys'];
}
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForOAuthProvider(): array
2021-10-13 06:00:52 +08:00
{
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
2021-10-13 06:00:52 +08:00
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;
}
2021-10-13 06:00:52 +08:00
return $settings;
}
}