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