GClient::getRefreshToken()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\Google;
5
6
use Psr\Log\LoggerInterface;
7
use Psr\Log\NullLogger;
8
9
/**
10
 * Class to connect to the google API using OAuth2 authentication.
11
 *
12
 * This class only usese cURL.
13
 *
14
 * Best practice is to use the OAuth client JSON configuration file,
15
 * which can be downloaded from Google Cloud Console, to set all project
16
 * and customer specific information (IDs, secrets, URIs).
17
 *
18
 * Create a client configuration at https://console.cloud.google.com
19
 *
20
 * @link https://console.cloud.google.com
21
 *
22
 * @author Stefanius <[email protected]>
23
 * @copyright MIT License - see the LICENSE file for details
24
 */
25
class GClient
26
{
27
    /** GET request */
28
    public const GET = 0;
29
    /** POST request */
30
    public const POST = 1;
31
    /** PUT request */
32
    public const PUT = 2;
33
    /** PATCH request */
34
    public const PATCH = 3;
35
    /** DELETE request */
36
    public const DELETE = 4;
37
38
    /** Default endpoint for the google OAuth2 authentication   */
39
    protected const DEF_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth';
40
    /** Default endpoint to request access/refresh tokens   */
41
    protected const DEF_TOKEN_URI = 'https://oauth2.googleapis.com/token';
42
43
    /** @var string client-ID of the google cloud project     */
44
    protected string $strProjectID = '';
45
    /** @var string client-ID within the google cloud project     */
46
    protected string $strClientID = '';
47
    /** @var string Endpoint for the google OAuth2 authentication     */
48
    protected string $strAuthURI = '';
49
    /** @var string Endpoint to request access/refresh tokens     */
50
    protected string $strTokenURI = '';
51
    /** @var string client secret for authentication     */
52
    protected string $strClientSecret = '';
53
    /** @var string redirect URI configured for the client     */
54
    protected string $strRedirectURI = '';
55
    /** @var array<string>  requested scope     */
56
    protected array $aScope = [];
57
    /** @var array<mixed>   received access token     */
58
    protected array $aAccessToken = [];
59
    /** @var string   received refresh token     */
60
    protected string $strRefreshToken = '';
61
62
    /** @var int response code of the latest HTTP request     */
63
    protected int $iLastResponseCode = 0;
64
    /** @var string error description if the latest HTTP request has failed     */
65
    protected string $strLastError = '';
66
    /** @var string status if the latest HTTP request has failed     */
67
    protected string $strLastStatus = '';
68
69
    /** @var LoggerInterface    loger     */
70
    protected LoggerInterface $oLogger;
71
72
    /**
73
     * @param LoggerInterface $oLogger
74
     */
75
    public function __construct(LoggerInterface $oLogger = null)
76
    {
77
        // ensure we have a valid logger instance
78
        $this->oLogger = $oLogger ?? new NullLogger();
79
        $this->strAuthURI = self::DEF_AUTH_URI;
80
        $this->strTokenURI = self::DEF_TOKEN_URI;
81
    }
82
83
    /**
84
     * Set the OAuth2 client configuration from the google API console.
85
     * The method tries to extract
86
     * - $strClientID
87
     * - $strProjectID
88
     * - $strAuthURI
89
     * - $strTokenURI
90
     * - $strClientSecret
91
     * - $strRedirectURI
92
     * from the JSON config file.
93
     * @param string $strClientSecrets    filename
94
     */
95
    public function setOAuthClient(string $strClientSecrets) : void
96
    {
97
        if (file_exists($strClientSecrets)) {
98
            $strOAuthClient = file_get_contents($strClientSecrets);
99
            $aOAuthClient = json_decode($strOAuthClient, true);
100
            if ($aOAuthClient !== null && (isset($aOAuthClient['web']) || isset($aOAuthClient['installed']))) {
101
                $aData = $aOAuthClient['web'] ?? $aOAuthClient['installed'];
102
                $this->strClientID = $aData['client_id'] ?? '';
103
                $this->strProjectID = $aData['project_id'] ?? '';
104
                $this->strAuthURI = $aData['auth_uri'] ?? '';
105
                $this->strTokenURI = $aData['token_uri'] ?? '';
106
                $this->strClientSecret = $aData['client_secret'] ?? '';
107
                if (isset($aData['redirect_uris']) and is_array($aData['redirect_uris'])) {
108
                    $this->strRedirectURI = $aData['redirect_uris'][0];
109
                }
110
            } else {
111
                throw new MissingClientInformationException('No valid client informations from google API console available.');
112
            }
113
        } else {
114
            throw new MissingClientInformationException('Client secrets file [' . $strClientSecrets . '] not found!');
115
        }
116
    }
117
118
    /**
119
     * Build the URL to call the google OAuth2.
120
     * Description for the $strLoginHint param from google docs: <br/>
121
     * > When your application knows which user it is trying to authenticate, it may provide
122
     * > this parameter as a hint to the Authentication Server. Passing this hint will either
123
     * > pre-fill the email box on the sign-in form or select the proper multi-login session,
124
     * > thereby simplifying the login flow.
125
     * @param string $strLoginHint  an **existing** google account to preselect in the login form.
126
     * @throws MissingPropertyException
127
     * @return string
128
     */
129
    public function buildAuthURL(string $strLoginHint = '') : string
130
    {
131
        $this->checkProperty($this->strAuthURI, 'auth_uri');
132
        $this->checkProperty($this->strClientID, 'client_id');
133
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
134
        if (count($this->aScope) < 1) {
135
            throw new MissingPropertyException('The scope must be specified before call this method!');
136
        }
137
        $aLoginParams = [
138
            'response_type' => 'code',
139
            'access_type' => 'offline',
140
            'redirect_uri' => $this->strRedirectURI,
141
            'client_id' => $this->strClientID,
142
            'scope' => implode(' ', $this->aScope),
143
            'prompt' => 'consent',
144
            'include_granted_scopes' => 'true',
145
        ];
146
        if (!empty($strLoginHint)) {
147
            $aLoginParams['login_hint'] = $strLoginHint;
148
        }
149
        $this->oLogger->info('GClient: succesfully build auth URL');
150
        return $this->strAuthURI . '?' . http_build_query($aLoginParams);
151
    }
152
153
    /**
154
     * Send request to get access and refresh token from a passed auth code.
155
     * @param string $strAuthCode   the code passed from accounts.google.com
156
     * @throws MissingPropertyException
157
     * @return bool
158
     */
159
    public function fetchTokens(string $strAuthCode) : bool
160
    {
161
        $this->checkProperty($strAuthCode, 'auth code');
162
        $this->checkProperty($this->strClientID, 'client_id');
163
        $this->checkProperty($this->strClientSecret, 'client_secret');
164
        $this->checkProperty($this->strTokenURI, 'token_uri');
165
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
166
167
        $aData = [
168
            'grant_type' => 'authorization_code',
169
            'code' => $strAuthCode,
170
            'redirect_uri' => $this->strRedirectURI,
171
            'client_id' => $this->strClientID,
172
            'client_secret' => $this->strClientSecret,
173
        ];
174
175
        $aHeader = array(
176
            'Host' => $this->strTokenURI,
177
            'Cache-Control' => 'no-store',
178
            'Content-Type' => 'application/x-www-form-urlencoded',
179
        );
180
181
        // since the request only provides an 'expires in' value, we have to keep
182
        // track of the timestamp, we sent the request
183
        $timeRequest = time();
184
185
        $data = http_build_query($aData);
186
        if (($strResponse = $this->fetchJsonResponse($this->strTokenURI, self::POST, $aHeader, $data)) !== false) {
187
            // the body contains the access- and refresh token
188
            $this->aAccessToken = json_decode($strResponse, true);
189
            $this->aAccessToken['created'] = $timeRequest;
190
            $this->strRefreshToken = $this->aAccessToken['refresh_token'] ?? '';
191
            unset($this->aAccessToken['refresh_token']);
192
            $this->oLogger->info('GClient: succesfully fetched access token from auth code');
193
        } else {
194
            $this->aAccessToken = [];
195
            $this->strRefreshToken = '';
196
            $this->oLogger->error(
197
                'GClient: error fetching access token from auth code', [
198
                    'responsecode' => $this->iLastResponseCode,
199
                    'authorization_code' => $strAuthCode,
200
                ]
201
            );
202
        }
203
        return count($this->aAccessToken) > 0;
204
    }
205
206
    /**
207
     * Refresh expired access token.
208
     * @param string $strRefreshToken
209
     * @throws MissingPropertyException
210
     * @return array<mixed> new access token
211
     */
212
    public function refreshAccessToken(string $strRefreshToken) : array
213
    {
214
        $this->checkProperty($strRefreshToken, 'refresh_token');
215
        $this->checkProperty($this->strClientID, 'client_id');
216
        $this->checkProperty($this->strClientSecret, 'client_secret');
217
        $this->checkProperty($this->strTokenURI, 'token_uri');
218
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
219
220
        $aData = [
221
            'grant_type' => 'refresh_token',
222
            'refresh_token' => $strRefreshToken,
223
            'client_id' => $this->strClientID,
224
            'client_secret' => $this->strClientSecret,
225
        ];
226
227
        $aHeader = array(
228
            'Host' => $this->strTokenURI,
229
            'Cache-Control' => 'no-store',
230
            'Content-Type' => 'application/x-www-form-urlencoded',
231
        );
232
233
        // since the request only provides an 'expires in' value, we have to keep
234
        // track of the timestamp, we sent the request
235
        $timeRequest = time();
236
237
        $data = http_build_query($aData);
238
        if (($strResponse = $this->fetchJsonResponse($this->strTokenURI, self::POST, $aHeader, $data)) !== false) {
239
            // the body contains the access- and refresh token
240
            $this->aAccessToken = json_decode($strResponse, true);
241
            $this->aAccessToken['created'] = $timeRequest;
242
            $this->oLogger->info('GClient: succesfully refreshed access token');
243
        } else {
244
            $this->aAccessToken = [];
245
            $this->strRefreshToken = '';
246
            $this->oLogger->error(
247
                'GClient: error refreshing access token', [
248
                    'responsecode' => $this->iLastResponseCode,
249
                    'refresh_token' => $strRefreshToken,
250
                ]
251
            );
252
        }
253
        return $this->aAccessToken;
254
    }
255
256
    /**
257
     * The current set access token.
258
     * @return array<mixed>
259
     */
260
    public function getAccessToken() : array
261
    {
262
        return $this->aAccessToken;
263
    }
264
265
    /**
266
     * Set a saved access token.
267
     * @param string|array<mixed> $token   accesstoken as string (JSON) or array
268
     */
269
    public function setAccessToken($token) : void
270
    {
271
        if (is_array($token)) {
272
            $this->aAccessToken = $token;
273
        } else {
274
            $aToken = json_decode($token, true);
275
            if (is_array($aToken)) {
276
                $this->aAccessToken = $aToken;
277
            } else {
278
                $this->aAccessToken = [];
279
            }
280
        }
281
    }
282
283
    /**
284
     * Check, if the actual set access token has expired.
285
     * It is recommended to set an offset to give time for the execution of
286
     * the next request.
287
     * @param int $iOffset additional offset until 'real' expiration
288
     * @return bool
289
     */
290
    public function isAccessTokenExpired(int $iOffset = 20) : bool
291
    {
292
        $bExpired = true;
293
        if (isset($this->aAccessToken['expires_in']) && isset($this->aAccessToken['created'])) {
294
            $bExpired = time() > $this->aAccessToken['created'] + $this->aAccessToken['expires_in'] + $iOffset;
295
        }
296
        return $bExpired;
297
    }
298
299
    /**
300
     * Response code of the last API request.
301
     * @return int
302
     */
303
    public function getLastResponseCode() : int
304
    {
305
        return $this->iLastResponseCode;
306
    }
307
308
    /**
309
     * Error text if the last API request has failed.
310
     * @return string
311
     */
312
    public function getLastError() : string
313
    {
314
        return $this->strLastError;
315
    }
316
317
    /**
318
     * Status if the last API request has failed.
319
     * @return string
320
     */
321
    public function getLastStatus() : string
322
    {
323
        return $this->strLastStatus;
324
    }
325
326
    /**
327
     * Get the OAuth HTTP header for API requests.
328
     * @return string
329
     */
330
    public function getAuthHeader() : string
331
    {
332
        $strAuth = '';
0 ignored issues
show
Unused Code introduced by
The assignment to $strAuth is dead and can be removed.
Loading history...
333
        if (isset($this->aAccessToken['token_type']) && isset($this->aAccessToken['access_token'])) {
334
            $strAuth = 'Authorization: ' . $this->aAccessToken['token_type'] . ' ' . $this->aAccessToken['access_token'];
335
        } else {
336
            throw new MissingPropertyException('No auth access set!');
337
        }
338
        return $strAuth;
339
    }
340
341
    /**
342
     * Getter for the current refresh token.
343
     * @return string
344
     */
345
    public function getRefreshToken() : string
346
    {
347
        return $this->strRefreshToken;
348
    }
349
350
    /**
351
     * Getter for the current client ID.
352
     * @return string
353
     */
354
    public function getClientID() : string
355
    {
356
        return $this->strClientID;
357
    }
358
359
    /**
360
     * Getter for the current project ID.
361
     * @return string
362
     */
363
    public function getProjectID() : string
364
    {
365
        return $this->strProjectID;
366
    }
367
368
    /**
369
     * Getter for the current auth URI.
370
     * @return string
371
     */
372
    public function getAuthURI() : string
373
    {
374
        return $this->strAuthURI;
375
    }
376
377
    /**
378
     * Getter for the current token URI.
379
     * @return string
380
     */
381
    public function getTokenURI() : string
382
    {
383
        return $this->strTokenURI;
384
    }
385
386
    /**
387
     * Getter for the current client secret.
388
     * @return string
389
     */
390
    public function getClientSecret() : string
391
    {
392
        return $this->strClientSecret;
393
    }
394
395
    /**
396
     * Getter for the current redirect URI.
397
     * @return string
398
     */
399
    public function getRedirectURI() : string
400
    {
401
        return $this->strRedirectURI;
402
    }
403
404
    /**
405
     * Set the current client ID.
406
     * @param string $strClientID
407
     */
408
    public function setClientID(string $strClientID) : void
409
    {
410
        $this->strClientID = $strClientID;
411
    }
412
413
    /**
414
     * Set the current project ID.
415
     * @param string $strProjectID
416
     */
417
    public function setProjectID(string $strProjectID) : void
418
    {
419
        $this->strProjectID = $strProjectID;
420
    }
421
422
    /**
423
     * Set the current auth URI.
424
     * @param string $strAuthURI
425
     */
426
    public function setAuthURI(string $strAuthURI) : void
427
    {
428
        $this->strAuthURI = $strAuthURI;
429
    }
430
431
    /**
432
     * Set the current token URI.
433
     * @param string $strTokenURI
434
     */
435
    public function setTokenURI(string $strTokenURI) : void
436
    {
437
        $this->strTokenURI = $strTokenURI;
438
    }
439
440
    /**
441
     * Set the current client secret.
442
     * @param string $strClientSecret
443
     */
444
    public function setClientSecret(string $strClientSecret) : void
445
    {
446
        $this->strClientSecret = $strClientSecret;
447
    }
448
449
    /**
450
     * Set the current redirect URI.
451
     * @param string $strRedirectURI
452
     */
453
    public function setRedirectURI(string $strRedirectURI) : void
454
    {
455
        $this->strRedirectURI = $strRedirectURI;
456
    }
457
458
    /**
459
     * Add scope that is neede for following API requests.
460
     * @param string|array<string> $scope
461
     */
462
    public function addScope($scope) : void
463
    {
464
        if (is_array($scope)) {
465
            $this->aScope = array_merge($this->aScope, $scope);
466
        } else if (!in_array($scope, $this->aScope)) {
467
            $this->aScope[] = $scope;
468
        }
469
    }
470
471
    /**
472
     * Set a logger instance.
473
     * @param \Psr\Log\LoggerInterface $oLogger
474
     */
475
    public function setLogger(LoggerInterface $oLogger) : void
476
    {
477
        $this->oLogger = $oLogger;
478
    }
479
480
    /**
481
     * Cleint request to the API.
482
     * @param string $strURI
483
     * @param int $iMethod
484
     * @param array<string> $aHeader
485
     * @param string $data
486
     * @return string|false
487
     */
488
    public function fetchJsonResponse(string $strURI, int $iMethod, array $aHeader = [], string $data = '')
489
    {
490
        $result = false;
491
492
        $curl = curl_init();
493
494
        curl_setopt($curl, CURLOPT_URL, $strURI);
495
        switch ($iMethod) {
496
            case self::POST:
497
                curl_setopt($curl, CURLOPT_POST, true);
498
                break;
499
            case self::PUT:
500
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT');
501
                break;
502
            case self::PATCH:
503
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PATCH');
504
                break;
505
            case self::DELETE:
506
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
507
                break;
508
        }
509
        if (!empty($data)) {
510
            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
511
        }
512
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
513
        curl_setopt($curl, CURLOPT_USERAGENT, 'PHP cURL Http Request');
514
        curl_setopt($curl, CURLOPT_HTTPHEADER, $aHeader);
515
        curl_setopt($curl, CURLOPT_HEADER, true);
516
517
        $strResponse = curl_exec($curl);
518
519
        $this->iLastResponseCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
520
        $iHeaderSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
521
522
        curl_close($curl);
523
524
        if ($this->iLastResponseCode == 200) {
525
            $this->strLastError = '';
526
            $this->strLastStatus = '';
527
            $result = is_string($strResponse) ? substr($strResponse, $iHeaderSize) : '';
528
        } else {
529
            $strError = is_string($strResponse) ? substr($strResponse, $iHeaderSize) : '';
530
            if (strlen($strError) > 0) {
531
                $aError = json_decode($strError, true);
532
                if (isset($aError['error'])) {
533
                    $this->strLastError = $aError['error']['message'] ?? '';
534
                    $this->strLastStatus = $aError['error']['status'] ?? '';
535
                    $this->oLogger->error('GClient: ' . $this->strLastError);
536
                }
537
            }
538
        }
539
        return $result;
540
    }
541
542
    /**
543
     * Set information about the last error occured.
544
     * If any error can be detected before an API request is made, use this
545
     * method to set an reproduceable errormessage.
546
     * @param int $iResponseCode
547
     * @param string $strError
548
     * @param string $strStatus
549
     */
550
    public function setError(int $iResponseCode, string $strError, string $strStatus) : void
551
    {
552
        $this->iLastResponseCode = $iResponseCode;
553
        $this->strLastError = $strError;
554
        $this->strLastStatus = $strStatus;
555
        $this->oLogger->error('GClient: ' . $this->strLastError);
556
    }
557
558
    /**
559
     * Parse the header of an HTTP response.
560
     * @param string $strHeader
561
     * @return array<string,string>
562
     */
563
    public function parseHttpHeader(string $strHeader) : array
564
    {
565
        $aHeader = [];
566
        $strHeader = trim($strHeader);
567
        $aLine = explode("\n", $strHeader);
568
        $aHeader['status'] = $aLine[0];
569
        array_shift($aLine);
570
571
        foreach ($aLine as $strLine){
572
            // only consider the first colon, since other colons can also appear in
573
            // the header value - the rest of such a value would be lost
574
            // (eg "Location: https: // www ...." - "// www ...." would be gone !)
575
            $aValue = explode(":", $strLine, 2);
576
577
            // header names are NOT case sensitive, so make all lowercase!
578
            $strName = strtolower(trim($aValue[0]));
579
            if (count($aValue) == 2) {
580
                $aHeader[$strName] = trim($aValue[1]);
581
            } else {
582
                $aHeader[$strName] = true;
583
            }
584
        }
585
        return $aHeader;
586
    }
587
588
    /**
589
     * Check, if given property is set and throw exception, if not so.
590
     * @param string $strValue
591
     * @param string $strName
592
     * @throws MissingPropertyException
593
     */
594
    private function checkProperty(string $strValue, string $strName) : void
595
    {
596
        if (empty($strValue)) {
597
            throw new MissingPropertyException($strName . ' must be set before call this method!');
598
        }
599
    }
600
}