3 namespace BookStack\Access\Oidc;
5 use GuzzleHttp\Psr7\Request;
6 use Illuminate\Contracts\Cache\Repository;
7 use InvalidArgumentException;
8 use Psr\Http\Client\ClientExceptionInterface;
9 use Psr\Http\Client\ClientInterface;
12 * OpenIdConnectProviderSettings
13 * Acts as a DTO for settings used within the oidc request and token handling.
14 * Performs auto-discovery upon request.
16 class OidcProviderSettings
18 public string $issuer;
19 public string $clientId;
20 public string $clientSecret;
21 public ?string $authorizationEndpoint;
22 public ?string $tokenEndpoint;
23 public ?string $endSessionEndpoint;
24 public ?string $userinfoEndpoint;
27 * @var string[]|array[]
29 public ?array $keys = [];
31 public function __construct(array $settings)
33 $this->applySettingsFromArray($settings);
34 $this->validateInitial();
38 * Apply an array of settings to populate setting properties within this class.
40 protected function applySettingsFromArray(array $settingsArray): void
42 foreach ($settingsArray as $key => $value) {
43 if (property_exists($this, $key)) {
50 * Validate any core, required properties have been set.
52 * @throws InvalidArgumentException
54 protected function validateInitial(): void
56 $required = ['clientId', 'clientSecret', 'issuer'];
57 foreach ($required as $prop) {
58 if (empty($this->$prop)) {
59 throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
63 if (!str_starts_with($this->issuer, 'https://')) {
64 throw new InvalidArgumentException('Issuer value must start with https://');
69 * Perform a full validation on these settings.
71 * @throws InvalidArgumentException
73 public function validate(): void
75 $this->validateInitial();
77 $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
78 foreach ($required as $prop) {
79 if (empty($this->$prop)) {
80 throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
84 $endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
85 foreach ($endpointProperties as $prop) {
86 if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
87 throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
93 * Discover and autoload settings from the configured issuer.
95 * @throws OidcIssuerDiscoveryException
97 public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
100 $cacheKey = 'oidc-discovery::' . $this->issuer;
101 $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
102 return $this->loadSettingsFromIssuerDiscovery($httpClient);
104 $this->applySettingsFromArray($discoveredSettings);
105 } catch (ClientExceptionInterface $exception) {
106 throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
111 * @throws OidcIssuerDiscoveryException
112 * @throws ClientExceptionInterface
114 protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
116 $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
117 $request = new Request('GET', $issuerUrl);
118 $response = $httpClient->sendRequest($request);
119 $result = json_decode($response->getBody()->getContents(), true);
121 if (empty($result) || !is_array($result)) {
122 throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
125 if ($result['issuer'] !== $this->issuer) {
126 throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
129 $discoveredSettings = [];
131 if (!empty($result['authorization_endpoint'])) {
132 $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
135 if (!empty($result['token_endpoint'])) {
136 $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
139 if (!empty($result['userinfo_endpoint'])) {
140 $discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
143 if (!empty($result['jwks_uri'])) {
144 $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
145 $discoveredSettings['keys'] = $this->filterKeys($keys);
148 if (!empty($result['end_session_endpoint'])) {
149 $discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
152 return $discoveredSettings;
156 * Filter the given JWK keys down to just those we support.
158 protected function filterKeys(array $keys): array
160 return array_filter($keys, function (array $key) {
161 $alg = $key['alg'] ?? 'RS256';
162 $use = $key['use'] ?? 'sig';
164 return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
169 * Return an array of jwks as PHP key=>value arrays.
171 * @throws ClientExceptionInterface
172 * @throws OidcIssuerDiscoveryException
174 protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
176 $request = new Request('GET', $uri);
177 $response = $httpClient->sendRequest($request);
178 $result = json_decode($response->getBody()->getContents(), true);
180 if (empty($result) || !is_array($result) || !isset($result['keys'])) {
181 throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
184 return $result['keys'];
188 * Get the settings needed by an OAuth provider, as a key=>value array.
190 public function arrayForOAuthProvider(): array
192 $settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
194 foreach ($settingKeys as $setting) {
195 $settings[$setting] = $this->$setting;