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