spinen /
halo-php-client
| 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
Loading history...
|
|||||
| 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
Loading history...
|
|||||
| 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 |