]> BookStack Code Mirror - bookstack/blob - app/Access/Oidc/OidcProviderSettings.php
Comments: Added HTML filter on load, tinymce elem filtering
[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 $redirectUri;
22     public ?string $authorizationEndpoint;
23     public ?string $tokenEndpoint;
24     public ?string $endSessionEndpoint;
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)
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()
55     {
56         $required = ['clientId', 'clientSecret', 'redirectUri', '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         $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
77         foreach ($required as $prop) {
78             if (empty($this->$prop)) {
79                 throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
80             }
81         }
82     }
83
84     /**
85      * Discover and autoload settings from the configured issuer.
86      *
87      * @throws OidcIssuerDiscoveryException
88      */
89     public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
90     {
91         try {
92             $cacheKey = 'oidc-discovery::' . $this->issuer;
93             $discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
94                 return $this->loadSettingsFromIssuerDiscovery($httpClient);
95             });
96             $this->applySettingsFromArray($discoveredSettings);
97         } catch (ClientExceptionInterface $exception) {
98             throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
99         }
100     }
101
102     /**
103      * @throws OidcIssuerDiscoveryException
104      * @throws ClientExceptionInterface
105      */
106     protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
107     {
108         $issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
109         $request = new Request('GET', $issuerUrl);
110         $response = $httpClient->sendRequest($request);
111         $result = json_decode($response->getBody()->getContents(), true);
112
113         if (empty($result) || !is_array($result)) {
114             throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
115         }
116
117         if ($result['issuer'] !== $this->issuer) {
118             throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
119         }
120
121         $discoveredSettings = [];
122
123         if (!empty($result['authorization_endpoint'])) {
124             $discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
125         }
126
127         if (!empty($result['token_endpoint'])) {
128             $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
129         }
130
131         if (!empty($result['jwks_uri'])) {
132             $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
133             $discoveredSettings['keys'] = $this->filterKeys($keys);
134         }
135
136         if (!empty($result['end_session_endpoint'])) {
137             $discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
138         }
139
140         return $discoveredSettings;
141     }
142
143     /**
144      * Filter the given JWK keys down to just those we support.
145      */
146     protected function filterKeys(array $keys): array
147     {
148         return array_filter($keys, function (array $key) {
149             $alg = $key['alg'] ?? 'RS256';
150             $use = $key['use'] ?? 'sig';
151
152             return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
153         });
154     }
155
156     /**
157      * Return an array of jwks as PHP key=>value arrays.
158      *
159      * @throws ClientExceptionInterface
160      * @throws OidcIssuerDiscoveryException
161      */
162     protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
163     {
164         $request = new Request('GET', $uri);
165         $response = $httpClient->sendRequest($request);
166         $result = json_decode($response->getBody()->getContents(), true);
167
168         if (empty($result) || !is_array($result) || !isset($result['keys'])) {
169             throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
170         }
171
172         return $result['keys'];
173     }
174
175     /**
176      * Get the settings needed by an OAuth provider, as a key=>value array.
177      */
178     public function arrayForProvider(): array
179     {
180         $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
181         $settings = [];
182         foreach ($settingKeys as $setting) {
183             $settings[$setting] = $this->$setting;
184         }
185
186         return $settings;
187     }
188 }