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