]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Oidc/OidcProviderSettings.php
Fixed failing test after drawio default url change
[bookstack] / app / Auth / Access / Oidc / OidcProviderSettings.php
1 <?php
2
3 namespace BookStack\Auth\Access\Oidc;
4
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;
10
11 /**
12  * OpenIdConnectProviderSettings
13  * Acts as a DTO for settings used within the oidc request and token handling.
14  * Performs auto-discovery upon request.
15  */
16 class OidcProviderSettings
17 {
18     /**
19      * @var string
20      */
21     public $issuer;
22
23     /**
24      * @var string
25      */
26     public $clientId;
27
28     /**
29      * @var string
30      */
31     public $clientSecret;
32
33     /**
34      * @var string
35      */
36     public $redirectUri;
37
38     /**
39      * @var string
40      */
41     public $authorizationEndpoint;
42
43     /**
44      * @var string
45      */
46     public $tokenEndpoint;
47
48     /**
49      * @var string[]|array[]
50      */
51     public $keys = [];
52
53     public function __construct(array $settings)
54     {
55         $this->applySettingsFromArray($settings);
56         $this->validateInitial();
57     }
58
59     /**
60      * Apply an array of settings to populate setting properties within this class.
61      */
62     protected function applySettingsFromArray(array $settingsArray)
63     {
64         foreach ($settingsArray as $key => $value) {
65             if (property_exists($this, $key)) {
66                 $this->$key = $value;
67             }
68         }
69     }
70
71     /**
72      * Validate any core, required properties have been set.
73      *
74      * @throws InvalidArgumentException
75      */
76     protected function validateInitial()
77     {
78         $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
79         foreach ($required as $prop) {
80             if (empty($this->$prop)) {
81                 throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
82             }
83         }
84
85         if (strpos($this->issuer, 'https://') !== 0) {
86             throw new InvalidArgumentException('Issuer value must start with https://');
87         }
88     }
89
90     /**
91      * Perform a full validation on these settings.
92      *
93      * @throws InvalidArgumentException
94      */
95     public function validate(): void
96     {
97         $this->validateInitial();
98         $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
99         foreach ($required as $prop) {
100             if (empty($this->$prop)) {
101                 throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
102             }
103         }
104     }
105
106     /**
107      * Discover and autoload settings from the configured issuer.
108      *
109      * @throws OidcIssuerDiscoveryException
110      */
111     public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
112     {
113         try {
114             $cacheKey = 'oidc-discovery::' . $this->issuer;
115             $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
116                 return $this->loadSettingsFromIssuerDiscovery($httpClient);
117             });
118             $this->applySettingsFromArray($discoveredSettings);
119         } catch (ClientExceptionInterface $exception) {
120             throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
121         }
122     }
123
124     /**
125      * @throws OidcIssuerDiscoveryException
126      * @throws ClientExceptionInterface
127      */
128     protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
129     {
130         $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
131         $request = new Request('GET', $issuerUrl);
132         $response = $httpClient->sendRequest($request);
133         $result = json_decode($response->getBody()->getContents(), true);
134
135         if (empty($result) || !is_array($result)) {
136             throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
137         }
138
139         if ($result['issuer'] !== $this->issuer) {
140             throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
141         }
142
143         $discoveredSettings = [];
144
145         if (!empty($result['authorization_endpoint'])) {
146             $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
147         }
148
149         if (!empty($result['token_endpoint'])) {
150             $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
151         }
152
153         if (!empty($result['jwks_uri'])) {
154             $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
155             $discoveredSettings['keys'] = $this->filterKeys($keys);
156         }
157
158         return $discoveredSettings;
159     }
160
161     /**
162      * Filter the given JWK keys down to just those we support.
163      */
164     protected function filterKeys(array $keys): array
165     {
166         return array_filter($keys, function (array $key) {
167             $alg = $key['alg'] ?? null;
168
169             return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
170         });
171     }
172
173     /**
174      * Return an array of jwks as PHP key=>value arrays.
175      *
176      * @throws ClientExceptionInterface
177      * @throws OidcIssuerDiscoveryException
178      */
179     protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
180     {
181         $request = new Request('GET', $uri);
182         $response = $httpClient->sendRequest($request);
183         $result = json_decode($response->getBody()->getContents(), true);
184
185         if (empty($result) || !is_array($result) || !isset($result['keys'])) {
186             throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
187         }
188
189         return $result['keys'];
190     }
191
192     /**
193      * Get the settings needed by an OAuth provider, as a key=>value array.
194      */
195     public function arrayForProvider(): array
196     {
197         $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
198         $settings = [];
199         foreach ($settingKeys as $setting) {
200             $settings[$setting] = $this->$setting;
201         }
202
203         return $settings;
204     }
205 }