1 | <?php |
||||
2 | |||||
3 | namespace Spinen\Halo\Api; |
||||
4 | |||||
5 | use GuzzleHttp\Client as Guzzle; |
||||
6 | use GuzzleHttp\Exception\GuzzleException; |
||||
7 | use Illuminate\Support\Str; |
||||
8 | use RuntimeException; |
||||
9 | use Spinen\Halo\Exceptions\ClientConfigurationException; |
||||
10 | use Spinen\Halo\Exceptions\TokenException; |
||||
11 | |||||
12 | /** |
||||
13 | * Class Client |
||||
14 | */ |
||||
15 | class Client |
||||
16 | { |
||||
17 | /** |
||||
18 | * Client constructor. |
||||
19 | */ |
||||
20 | 38 | public function __construct( |
|||
21 | protected array $configs, |
||||
22 | protected Guzzle $guzzle = new Guzzle, |
||||
23 | protected Token $token = new Token, |
||||
24 | protected bool $debug = false, |
||||
25 | ) { |
||||
26 | 38 | $this->setConfigs($configs); |
|||
27 | 34 | $this->setToken($token); |
|||
28 | } |
||||
29 | |||||
30 | /** |
||||
31 | * Shortcut to 'DELETE' request |
||||
32 | * |
||||
33 | * @throws GuzzleException |
||||
34 | * @throws TokenException |
||||
35 | */ |
||||
36 | 1 | public function delete(string $path): ?array |
|||
37 | { |
||||
38 | 1 | return $this->request($path, [], 'DELETE'); |
|||
39 | } |
||||
40 | |||||
41 | /** |
||||
42 | * Generate keys need for PKCE |
||||
43 | */ |
||||
44 | 3 | public function generateProofKey(int $length = 30): array |
|||
45 | { |
||||
46 | 3 | return $this->configs['oauth']['authorization_code']['pkce'] |
|||
47 | 2 | ? [ |
|||
48 | 2 | 'verifier' => $verifier = base64_encode(Str::random($length)), |
|||
49 | // Convert "+" to "-" & "/" to "_" & remove trailing "=" |
||||
50 | 2 | 'challenge' => rtrim( |
|||
51 | 2 | characters: '=', |
|||
52 | 2 | string: strtr( |
|||
53 | 2 | from: '+/', |
|||
54 | 2 | string: base64_encode(hash(algo: 'sha256', data: $verifier, binary: true)), |
|||
55 | 2 | to: '-_', |
|||
56 | 2 | ), |
|||
57 | 2 | ), |
|||
58 | 2 | ] |
|||
59 | 3 | : []; // TODO: Should this be ['challenge' => null, 'verifier' => null]? |
|||
60 | } |
||||
61 | |||||
62 | /** |
||||
63 | * Shortcut to 'GET' request |
||||
64 | * |
||||
65 | * @throws GuzzleException |
||||
66 | * @throws TokenException |
||||
67 | */ |
||||
68 | 1 | public function get(string $path): ?array |
|||
69 | { |
||||
70 | 1 | return $this->request($path, [], 'GET'); |
|||
71 | } |
||||
72 | |||||
73 | /** |
||||
74 | * Get, return, or refresh the token |
||||
75 | * |
||||
76 | * @throws GuzzleException |
||||
77 | * @throws RuntimeException |
||||
78 | */ |
||||
79 | 14 | public function getToken(): Token |
|||
80 | { |
||||
81 | 14 | return match (true) { |
|||
82 | 14 | $this->token->isValid() => $this->token, |
|||
83 | 14 | $this->token->needsRefreshing() => $this->token = $this->oauthRequestToken([ |
|||
84 | 14 | 'grant_type' => 'refresh_token', |
|||
85 | 14 | 'refresh_token' => $this->token->refresh_token, |
|||
86 | 14 | ]), |
|||
87 | 14 | default => $this->token = $this->oauthRequestTokenUsingClientCredentials(), |
|||
88 | 14 | }; |
|||
89 | } |
||||
90 | |||||
91 | /** |
||||
92 | * Request a token |
||||
93 | * |
||||
94 | * @throws ClientConfigurationException |
||||
95 | * @throws GuzzleException |
||||
96 | * @throws RuntimeException |
||||
97 | */ |
||||
98 | 3 | public function oauthRequestToken(array $params): Token |
|||
99 | { |
||||
100 | // Use existing grant_type to not let a refresh type override |
||||
101 | 3 | $grant_type = $this->token->grant_type ?? $params['grant_type']; |
|||
102 | |||||
103 | 3 | if (is_null($this->configs['oauth'][$grant_type]['id'] ?? null)) { |
|||
104 | 1 | throw new ClientConfigurationException('The "client_id" for "'.$grant_type.'" cannot be null'); |
|||
105 | } |
||||
106 | |||||
107 | try { |
||||
108 | 2 | $this->token = new Token(...['grant_type' => $grant_type] + json_decode( |
|||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||
109 | 2 | associative: true, |
|||
110 | 2 | json: $this->guzzle->request( |
|||
111 | 2 | method: 'POST', |
|||
112 | 2 | options: [ |
|||
113 | 2 | 'debug' => $this->debug, |
|||
114 | 2 | 'form_params' => [ |
|||
115 | 2 | 'client_id' => $this->configs['oauth'][$grant_type]['id'], |
|||
116 | 2 | ...$params, |
|||
117 | 2 | ], |
|||
118 | 2 | 'headers' => [ |
|||
119 | 2 | 'Content-Type' => 'application/x-www-form-urlencoded', |
|||
120 | 2 | ], |
|||
121 | 2 | ], |
|||
122 | 2 | uri: $this->uri( |
|||
123 | 2 | path: 'token?'.http_build_query( |
|||
124 | 2 | $this->configs['tenant'] ? ['tenant' => $this->configs['tenant']] : [], |
|||
125 | 2 | ), |
|||
126 | 2 | url: $this->configs['oauth']['authorization_server'] |
|||
127 | 2 | ), |
|||
128 | 2 | ) |
|||
129 | 2 | ->getBody() |
|||
130 | 2 | ->getContents(), |
|||
131 | 2 | )); |
|||
132 | |||||
133 | 2 | return $this->token; |
|||
134 | } catch (GuzzleException $e) { |
||||
135 | // TODO: Figure out what to do with this error |
||||
136 | // TODO: Consider returning [] for 401's? |
||||
137 | |||||
138 | throw $e; |
||||
139 | } |
||||
140 | } |
||||
141 | |||||
142 | /** |
||||
143 | * Convert OAuth code to scoped token for user |
||||
144 | * |
||||
145 | * @throws ClientConfigurationException |
||||
146 | * @throws GuzzleException |
||||
147 | * @throws RuntimeException |
||||
148 | */ |
||||
149 | 3 | public function oauthRequestTokenUsingAuthorizationCode(string $code, string $uri, ?string $verifier = null, ?string $scope = null): Token |
|||
150 | { |
||||
151 | 3 | if ($this->configs['oauth']['authorization_code']['pkce'] && is_null($verifier)) { |
|||
152 | 1 | throw new ClientConfigurationException('PKCE is enabled, but no code verifier was provided.'); |
|||
153 | } |
||||
154 | |||||
155 | 2 | return $this->oauthRequestToken([ |
|||
156 | 2 | 'code' => $code, |
|||
157 | 2 | ...$verifier ? ['code_verifier' => $verifier] : [], |
|||
158 | 2 | 'grant_type' => 'authorization_code', |
|||
159 | 2 | 'redirect_uri' => $uri, |
|||
160 | 2 | 'scope' => $scope ?? $this->token->scope, |
|||
161 | 2 | ]); |
|||
162 | } |
||||
163 | |||||
164 | /** |
||||
165 | * Request a scoped token via client credentials |
||||
166 | * |
||||
167 | * @throws ClientConfigurationException |
||||
168 | * @throws GuzzleException |
||||
169 | * @throws RuntimeException |
||||
170 | */ |
||||
171 | 3 | public function oauthRequestTokenUsingClientCredentials(?string $scope = null): Token |
|||
172 | { |
||||
173 | 3 | if (is_null($this->configs['oauth']['client_credentials']['secret'] ?? null)) { |
|||
174 | 2 | throw new ClientConfigurationException('The "client_secret" for "client_credentials" cannot be null'); |
|||
175 | } |
||||
176 | |||||
177 | 1 | return tap($this->oauthRequestToken([ |
|||
178 | 1 | 'client_secret' => $this->configs['oauth']['client_credentials']['secret'], |
|||
179 | 1 | 'grant_type' => 'client_credentials', |
|||
180 | 1 | 'scope' => $scope ?? $this->token->scope, |
|||
181 | 1 | // You cannot refresh a client credential token, so null it out |
|||
182 | 1 | ]), fn (Token $t): ?Token => $t->refresh_token = null); |
|||
183 | } |
||||
184 | |||||
185 | /** |
||||
186 | * Build the uri to redirect the user to start the OAuth process |
||||
187 | * |
||||
188 | * @throws ClientConfigurationException |
||||
189 | */ |
||||
190 | 4 | public function oauthUri(string $uri, ?string $challenge = null, ?string $scope = null): string |
|||
191 | { |
||||
192 | 4 | if (is_null($this->configs['oauth']['authorization_code']['id'] ?? null)) { |
|||
193 | 1 | throw new ClientConfigurationException('The "client_id" for "authorization_code" cannot be null'); |
|||
194 | } |
||||
195 | |||||
196 | 3 | if ($this->configs['oauth']['authorization_code']['pkce'] && is_null($challenge)) { |
|||
197 | 1 | throw new ClientConfigurationException('PKCE is enabled, but no code challenge was provided.'); |
|||
198 | } |
||||
199 | |||||
200 | 2 | return $this->uri( |
|||
201 | 2 | path: 'authorize?'.http_build_query( |
|||
202 | 2 | [ |
|||
203 | 2 | 'client_id' => $this->configs['oauth']['authorization_code']['id'], |
|||
204 | 2 | ...$challenge |
|||
205 | 1 | ? [ |
|||
206 | 1 | 'code_challenge_method' => 'S256', |
|||
207 | 1 | 'code_challenge' => $challenge, |
|||
208 | 1 | ] |
|||
209 | : [], |
||||
210 | 2 | 'redirect_uri' => $uri, |
|||
211 | 2 | 'response_type' => 'code', |
|||
212 | 2 | 'scope' => $scope ?? $this->token->scope, |
|||
213 | 2 | ...$this->configs['tenant'] ? ['tenant' => $this->configs['tenant']] : [], |
|||
214 | 2 | ]), |
|||
215 | 2 | url: $this->configs['oauth']['authorization_server'] |
|||
216 | 2 | ); |
|||
217 | } |
||||
218 | |||||
219 | /** |
||||
220 | * Shortcut to 'POST' request |
||||
221 | * |
||||
222 | * @throws GuzzleException |
||||
223 | * @throws TokenException |
||||
224 | */ |
||||
225 | 1 | public function post(string $path, array $data): ?array |
|||
226 | { |
||||
227 | 1 | return $this->request($path, $data, 'POST'); |
|||
228 | } |
||||
229 | |||||
230 | /** |
||||
231 | * Shortcut to 'PUT' request |
||||
232 | * |
||||
233 | * @throws GuzzleException |
||||
234 | * @throws TokenException |
||||
235 | */ |
||||
236 | 1 | public function put(string $path, array $data): ?array |
|||
237 | { |
||||
238 | 1 | return $this->request($path, $data, 'PUT'); |
|||
239 | } |
||||
240 | |||||
241 | /** |
||||
242 | * Make an API call to Halo |
||||
243 | * |
||||
244 | * @throws GuzzleException |
||||
245 | * @throws TokenException |
||||
246 | */ |
||||
247 | 11 | public function request(?string $path, ?array $data = [], ?string $method = 'GET'): ?array |
|||
248 | { |
||||
249 | try { |
||||
250 | 11 | return json_decode( |
|||
251 | 11 | associative: true, |
|||
252 | 11 | json: $this->guzzle->request( |
|||
253 | 11 | method: $method, |
|||
0 ignored issues
–
show
It seems like
$method can also be of type null ; however, parameter $method of GuzzleHttp\Client::request() does only seem to accept string , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||
254 | 11 | options: [ |
|||
255 | 11 | 'debug' => $this->debug, |
|||
256 | 11 | 'headers' => [ |
|||
257 | 11 | 'Authorization' => (string) $this->getToken(), |
|||
258 | 11 | 'Content-Type' => 'application/json', |
|||
259 | 11 | ], |
|||
260 | 11 | 'body' => empty($data) ? null : json_encode($data), |
|||
261 | 11 | ], |
|||
262 | 11 | uri: $this->uri($path), |
|||
263 | 11 | ) |
|||
264 | 11 | ->getBody() |
|||
265 | 11 | ->getContents(), |
|||
266 | 11 | ); |
|||
267 | 1 | } catch (GuzzleException $e) { |
|||
268 | // TODO: Figure out what to do with this error |
||||
269 | // TODO: Consider returning [] for 401's? |
||||
270 | |||||
271 | 1 | throw $e; |
|||
272 | } |
||||
273 | } |
||||
274 | |||||
275 | /** |
||||
276 | * Validate & set the configs |
||||
277 | * |
||||
278 | * @throws ClientConfigurationException |
||||
279 | */ |
||||
280 | 38 | protected function setConfigs(array $configs): self |
|||
281 | { |
||||
282 | // Replace empty strings with nulls in config values |
||||
283 | 38 | $this->configs = array_map(fn ($v) => $v === '' ? null : $v, $configs); |
|||
284 | |||||
285 | // Default to true if not set |
||||
286 | 38 | $this->configs['oauth']['authorization_code']['pkce'] ??= true; |
|||
287 | |||||
288 | 38 | if (is_null($this->configs['oauth']['authorization_server'] ?? null)) { |
|||
289 | 1 | throw new ClientConfigurationException('The "authorization_server" cannot be null'); |
|||
290 | } |
||||
291 | |||||
292 | 37 | if (! filter_var($this->configs['oauth']['authorization_server'], FILTER_VALIDATE_URL)) { |
|||
293 | 1 | throw new ClientConfigurationException( |
|||
294 | 1 | sprintf('A valid url must be provided for "authorization_server" [%s]', $this->configs['oauth']['authorization_server']) |
|||
295 | 1 | ); |
|||
296 | } |
||||
297 | |||||
298 | 36 | if (is_null($this->configs['resource_server'] ?? null)) { |
|||
299 | 1 | throw new ClientConfigurationException('The "resource_server" cannot be null'); |
|||
300 | } |
||||
301 | |||||
302 | 35 | if (! filter_var($this->configs['resource_server'], FILTER_VALIDATE_URL)) { |
|||
303 | 1 | throw new ClientConfigurationException( |
|||
304 | 1 | sprintf('A valid url must be provided for "resource_server" [%s]', $this->configs['resource_server']) |
|||
305 | 1 | ); |
|||
306 | } |
||||
307 | |||||
308 | 34 | return $this; |
|||
309 | } |
||||
310 | |||||
311 | /** |
||||
312 | * Set debug |
||||
313 | */ |
||||
314 | public function setDebug(bool $debug): self |
||||
315 | { |
||||
316 | $this->debug = $debug; |
||||
317 | |||||
318 | return $this; |
||||
319 | } |
||||
320 | |||||
321 | /** |
||||
322 | * Set the token & refresh if needed |
||||
323 | */ |
||||
324 | 34 | public function setToken(Token|string $token): self |
|||
325 | { |
||||
326 | 34 | $this->token = is_string($token) |
|||
327 | 12 | ? new Token(access_token: $token) |
|||
328 | 34 | : $token; |
|||
329 | |||||
330 | 34 | return $this; |
|||
331 | } |
||||
332 | |||||
333 | /** |
||||
334 | * URL to Halo |
||||
335 | * |
||||
336 | * If path is passed in, then append it to the end. By default, it will use the url |
||||
337 | * in the configs, but if a url is passed in as a second parameter then it is used. |
||||
338 | * If no url is found it will use the hard-coded v2 Halo API URL. |
||||
339 | */ |
||||
340 | 16 | public function uri(?string $path = null, ?string $url = null): string |
|||
341 | { |
||||
342 | 16 | if ($path && Str::startsWith($path, 'http')) { |
|||
343 | return $path; |
||||
344 | } |
||||
345 | |||||
346 | 16 | $path = ltrim($path ?? '/', '/'); |
|||
347 | |||||
348 | 16 | return rtrim($url ?? $this->configs['resource_server'], '/') |
|||
349 | 16 | .($path ? (Str::startsWith($path, '?') ? null : '/').$path : '/'); |
|||
350 | } |
||||
351 | |||||
352 | /** |
||||
353 | * Is the token valid & if provided a scope, is the token approved for the scope |
||||
354 | */ |
||||
355 | 5 | public function validToken(?string $scope = null): bool |
|||
356 | { |
||||
357 | 5 | return $this->token->isValid($scope); |
|||
358 | } |
||||
359 | } |
||||
360 |