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