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
|
|
|
{
|
2022-11-23 19:50:59 +08:00
|
|
|
public string $issuer;
|
|
|
|
public string $clientId;
|
|
|
|
public string $clientSecret;
|
|
|
|
public ?string $redirectUri;
|
|
|
|
public ?string $authorizationEndpoint;
|
|
|
|
public ?string $tokenEndpoint;
|
2023-12-07 00:41:50 +08:00
|
|
|
public ?string $endSessionEndpoint;
|
2021-10-13 06:00:52 +08:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @var string[]|array[]
|
|
|
|
*/
|
2022-11-23 19:50:59 +08:00
|
|
|
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)
|
|
|
|
{
|
|
|
|
foreach ($settingsArray as $key => $value) {
|
|
|
|
if (property_exists($this, $key)) {
|
|
|
|
$this->$key = $value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Validate any core, required properties have been set.
|
2021-10-16 23:01:59 +08:00
|
|
|
*
|
2021-10-13 06:00:52 +08:00
|
|
|
* @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");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-08 21:16:09 +08:00
|
|
|
if (!str_starts_with($this->issuer, 'https://')) {
|
2021-10-16 23:01:59 +08:00
|
|
|
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-16 23:01:59 +08:00
|
|
|
*
|
2021-10-13 06:00:52 +08:00
|
|
|
* @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.
|
2021-10-16 23:01:59 +08:00
|
|
|
*
|
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)
|
|
|
|
{
|
|
|
|
try {
|
|
|
|
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
2021-10-16 23:01:59 +08:00
|
|
|
$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) {
|
2021-10-16 23:01:59 +08:00
|
|
|
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'];
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!empty($result['jwks_uri'])) {
|
|
|
|
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
2021-10-14 20:37:55 +08:00
|
|
|
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
2021-10-13 06:00:52 +08:00
|
|
|
}
|
|
|
|
|
2023-12-07 00:41:50 +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
|
|
|
|
{
|
2021-10-16 23:01:59 +08:00
|
|
|
return array_filter($keys, function (array $key) {
|
2022-11-23 19:50:59 +08:00
|
|
|
$alg = $key['alg'] ?? 'RS256';
|
|
|
|
$use = $key['use'] ?? 'sig';
|
2022-01-31 00:44:19 +08:00
|
|
|
|
2022-11-23 19:50:59 +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-16 23:01:59 +08:00
|
|
|
*
|
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'])) {
|
2021-10-16 23:01:59 +08:00
|
|
|
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 arrayForProvider(): array
|
|
|
|
{
|
|
|
|
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
|
|
|
|
$settings = [];
|
|
|
|
foreach ($settingKeys as $setting) {
|
|
|
|
$settings[$setting] = $this->$setting;
|
|
|
|
}
|
2021-10-16 23:01:59 +08:00
|
|
|
|
2021-10-13 06:00:52 +08:00
|
|
|
return $settings;
|
|
|
|
}
|
2021-10-16 23:01:59 +08:00
|
|
|
}
|