Client::validToken()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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
array('grant_type' => $g...tBody()->getContents()) is expanded, but the parameter $access_token of Spinen\Halo\Api\Token::__construct() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

108
            $this->token = new Token(/** @scrutinizer ignore-type */ ...['grant_type' => $grant_type] + json_decode(
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
Bug introduced by
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 ignore-type  annotation

253
                    /** @scrutinizer ignore-type */ method: $method,
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