GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Completed
Push — master ( dde2c2...cd0e25 )
by François
02:43
created

OAuthClient::calculateExpiresAt()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 1
1
<?php
2
3
/**
4
 * Copyright (c) 2016, 2017 François Kooman <[email protected]>.
5
 *
6
 * Permission is hereby granted, free of charge, to any person obtaining a copy
7
 * of this software and associated documentation files (the "Software"), to deal
8
 * in the Software without restriction, including without limitation the rights
9
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
 * copies of the Software, and to permit persons to whom the Software is
11
 * furnished to do so, subject to the following conditions:
12
 *
13
 * The above copyright notice and this permission notice shall be included in all
14
 * copies or substantial portions of the Software.
15
 *
16
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
 * SOFTWARE.
23
 */
24
25
namespace fkooman\OAuth\Client;
26
27
use DateInterval;
28
use DateTime;
29
use fkooman\OAuth\Client\Exception\OAuthException;
30
use fkooman\OAuth\Client\Exception\OAuthServerException;
31
use fkooman\OAuth\Client\Http\HttpClientInterface;
32
use fkooman\OAuth\Client\Http\Request;
33
use fkooman\OAuth\Client\Http\Response;
34
use InvalidArgumentException;
35
use ParagonIE\ConstantTime\Base64;
36
use Psr\Log\LoggerInterface;
37
use Psr\Log\NullLogger;
38
39
class OAuthClient
40
{
41
    /** @var Provider */
42
    private $provider;
43
44
    /** @var TokenStorageInterface */
45
    private $tokenStorage;
46
47
    /** @var \fkooman\OAuth\Client\Http\HttpClientInterface */
48
    private $httpClient;
49
50
    /** @var SessionInterface */
51
    private $session;
52
53
    /** @var RandomInterface */
54
    private $random;
55
56
    /** @var \Psr\Log\LoggerInterface */
57
    private $logger;
58
59
    /** @var \DateTime */
60
    private $dateTime;
61
62
    /** @var string|null */
63
    private $userId = null;
64
65
    /**
66
     * @param Provider                 $provider
67
     * @param TokenStorageInterface    $tokenStorage
68
     * @param Http\HttpClientInterface $httpClient
69
     */
70
    public function __construct(Provider $provider, TokenStorageInterface $tokenStorage, HttpClientInterface $httpClient)
71
    {
72
        $this->provider = $provider;
73
        $this->tokenStorage = $tokenStorage;
74
        $this->httpClient = $httpClient;
75
76
        $this->session = new Session();
77
        $this->random = new Random();
78
        $this->logger = new NullLogger();
79
        $this->dateTime = new DateTime();
80
    }
81
82
    /**
83
     * @param SessionInterface $session
84
     */
85
    public function setSession(SessionInterface $session)
86
    {
87
        $this->session = $session;
88
    }
89
90
    /**
91
     * @param RandomInterface $random
92
     */
93
    public function setRandom(RandomInterface $random)
94
    {
95
        $this->random = $random;
96
    }
97
98
    /**
99
     * @param LoggerInterface $logger
100
     */
101
    public function setLogger(LoggerInterface $logger)
102
    {
103
        $this->logger = $logger;
104
    }
105
106
    /**
107
     * @param DateTime $dateTime
108
     */
109
    public function setDateTime(DateTime $dateTime)
110
    {
111
        $this->dateTime = $dateTime;
112
    }
113
114
    /**
115
     * @param string $userId
116
     */
117
    public function setUserId($userId)
118
    {
119
        $this->userId = $userId;
120
    }
121
122
    /**
123
     * Perform a GET request.
124
     *
125
     * @param string|null $requestScope   the required scope to perform the
126
     *                                    request, use `null` if you want to
127
     *                                    request a resource without token
128
     * @param string      $requestUri
129
     * @param array       $requestHeaders
130
     *
131
     * @return Http\Response|false
132
     */
133
    public function get($requestScope, $requestUri, array $requestHeaders = [])
134
    {
135
        return $this->send($requestScope, Request::get($requestUri, $requestHeaders));
136
    }
137
138
    /**
139
     * Perform a POST request.
140
     *
141
     * @param string|null $requestScope
142
     * @param string      $requestUri
143
     * @param array       $postBody
144
     * @param array       $requestHeaders
145
     *
146
     * @return Http\Response|false
147
     */
148
    public function post($requestScope, $requestUri, array $postBody, array $requestHeaders = [])
149
    {
150
        return $this->send($requestScope, Request::post($requestUri, $postBody, $requestHeaders));
151
    }
152
153
    /**
154
     * Perform a HTTP request.
155
     *
156
     * @param string|null  $requestScope
157
     * @param Http\Request $request
158
     *
159
     * @return Response|false
160
     */
161
    public function send($requestScope, Request $request)
162
    {
163
        // if the requestScope is null, the request is sent without an OAuth
164
        // token, useful to use the same API to request resources that are not
165
        // protected
166
        if (is_null($requestScope)) {
167
            return $this->httpClient->send($request);
168
        }
169
170
        if (is_null($this->userId)) {
171
            throw new OAuthException('userId not set');
172
        }
173
174
        // make sure we have an access token
175
        if (false === $accessToken = $this->tokenStorage->getAccessToken($this->userId, $requestScope)) {
176
            $this->logger->info(sprintf('no access_token available for user "%s" with scope "%s"', $this->userId, $requestScope));
177
178
            return false;
179
        }
180
181
        if ($requestScope !== $accessToken->getScope()) {
182
            throw new OAuthException('access_token does not have the required scope');
183
        }
184
185
        $refreshedToken = false;
186
        if ($accessToken->isExpired($this->dateTime)) {
187
            $this->logger->info(sprintf('access_token for user "%s" with scope "%s" expired', $this->userId, $requestScope));
188
            // access_token is expired, try to refresh it
189 View Code Duplication
            if (is_null($accessToken->getRefreshToken())) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
190
                $this->logger->info(sprintf('no refresh_token available in this access_token for user "%s" with scope "%s", deleting it', $this->userId, $requestScope));
191
                // we do not have a refresh_token, delete this access token, it
192
                // is useless now...
193
                $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
194
195
                return false;
196
            }
197
198
            $this->logger->info(sprintf('using refresh_token to obtain new access_token for user "%s" with scope "%s"', $this->userId, $requestScope));
199
200
            try {
201
                // delete the old one, and use it to try to get a new one
202
                $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
203
                $accessToken = $this->refreshAccessToken($accessToken);
204
            } catch (OAuthServerException $e) {
205
                $this->logger->info(sprintf('deleting access_token as refresh_token for user "%s" with scope "%s" was not accepted by the authorization server: "%s"', $this->userId, $requestScope, $e->getMessage()));
206
207
                return false;
208
            }
209
            $this->logger->info(sprintf('got a new access_token using the refresh_token for user "%s" with scope "%s"', $this->userId, $requestScope));
210
            $refreshedToken = true;
211
        }
212
213
        // add Authorization header to the request headers
214
        $request->setHeader('Authorization', sprintf('Bearer %s', $accessToken->getToken()));
215
216
        $response = $this->httpClient->send($request);
217 View Code Duplication
        if (401 === $response->getStatusCode()) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
218
            $this->logger->info(sprintf('deleting access_token for user "%s" with scope "%s" that was supposed to work, but did not, possibly revoked by user', $this->userId, $requestScope));
219
            // this indicates an invalid access_token
220
            $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
221
222
            return false;
223
        }
224
225
        $this->logger->info(sprintf('access_token for use "%s" with scope "%s" successfully used', $this->userId, $requestScope));
226
227
        if ($refreshedToken) {
228
            $this->logger->info(sprintf('storing refreshed access_token for user "%s" with scope "%s" as it was successfully used', $this->userId, $requestScope));
229
            // if we refreshed the token, and it was successful, i.e. not a 401,
230
            // update the stored AccessToken
231
            $this->tokenStorage->setAccessToken($this->userId, $accessToken);
232
        }
233
234
        return $response;
235
    }
236
237
    /**
238
     * Obtain an authorization request URL to start the authorization process
239
     * at the OAuth provider.
240
     *
241
     * @param string $scope       the space separated scope tokens
242
     * @param string $redirectUri the URL to redirect back to after coming back
243
     *                            from the OAuth provider (callback URL)
244
     *
245
     * @return string the authorization request URL
246
     *
247
     * @see https://tools.ietf.org/html/rfc6749#section-3.3
248
     * @see https://tools.ietf.org/html/rfc6749#section-3.1.2
249
     */
250
    public function getAuthorizeUri($scope, $redirectUri)
251
    {
252
        $queryParams = http_build_query(
253
            [
254
                'client_id' => $this->provider->getId(),
255
                'redirect_uri' => $redirectUri,
256
                'scope' => $scope,
257
                'state' => $this->random->get(16),
258
                'response_type' => 'code',
259
            ],
260
            '&'
261
        );
262
263
        $authorizeUri = sprintf(
264
            '%s%s%s',
265
            $this->provider->getAuthorizationEndpoint(),
266
            false === strpos($this->provider->getAuthorizationEndpoint(), '?') ? '?' : '&',
267
            $queryParams
268
        );
269
        $this->session->set('_oauth2_session', $authorizeUri);
270
271
        return $authorizeUri;
272
    }
273
274
    /**
275
     * @param string $responseCode  the code passed to the "code"
276
     *                              query parameter on the callback URL
277
     * @param string $responseState the state passed to the "state"
278
     *                              query parameter on the callback URL
279
     */
280
    public function handleCallback($responseCode, $responseState)
281
    {
282
        if (is_null($this->userId)) {
283
            throw new OAuthException('userId not set');
284
        }
285
286
        $requestParameters = self::parseRequestUri(
287
            $this->session->get('_oauth2_session')
288
        );
289
        if ($responseState !== $requestParameters['state']) {
290
            // the OAuth state from the initial request MUST be the same as the
291
            // state used by the response
292
            throw new OAuthException('invalid OAuth state');
293
        }
294
295
        if ($requestParameters['client_id'] !== $this->provider->getId()) {
296
            // the client_id used for the initial request differs from the
297
            // currently configured Provider, the client_id MUST be identical
298
            throw new OAuthException('unexpected client identifier');
299
        }
300
301
        // prepare access_token request
302
        $tokenRequestData = [
303
            'client_id' => $this->provider->getId(),
304
            'grant_type' => 'authorization_code',
305
            'code' => $responseCode,
306
            'redirect_uri' => $requestParameters['redirect_uri'],
307
        ];
308
309
        $responseData = $this->validateTokenResponse(
310
            $this->httpClient->send(
311
                Request::post(
312
                    $this->provider->getTokenEndpoint(),
313
                    $tokenRequestData,
314
                    [
315
                        'Authorization' => sprintf(
316
                            'Basic %s',
317
                            Base64::encode(
318
                                sprintf('%s:%s', $this->provider->getId(), $this->provider->getSecret())
319
                            )
320
                        ),
321
                    ]
322
                )
323
            ),
324
            $requestParameters['scope']
325
        );
326
327
        // delete the session
328
        $this->session->del('_oauth2_session');
329
330
        $this->tokenStorage->setAccessToken(
331
            $this->userId,
332
            new AccessToken(
333
                $responseData['access_token'],
334
                $responseData['token_type'],
335
                $responseData['scope'],
336
                array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : null,
337
                $responseData['expires_at']
338
            )
339
        );
340
    }
341
342
    /**
343
     * @param AccessToken $accessToken
344
     *
345
     * @return AccessToken
346
     */
347
    private function refreshAccessToken(AccessToken $accessToken)
348
    {
349
        // prepare access_token request
350
        $tokenRequestData = [
351
            'grant_type' => 'refresh_token',
352
            'refresh_token' => $accessToken->getRefreshToken(),
353
            'scope' => $accessToken->getScope(),
354
        ];
355
356
        $responseData = $this->validateTokenResponse(
357
            $this->httpClient->send(
358
                Request::post(
359
                    $this->provider->getTokenEndpoint(),
360
                    $tokenRequestData,
361
                    [
362
                        'Authorization' => sprintf(
363
                            'Basic %s',
364
                            Base64::encode(
365
                                sprintf('%s:%s', $this->provider->getId(), $this->provider->getSecret())
366
                            )
367
                        ),
368
                    ]
369
                )
370
            ),
371
            $accessToken->getScope()
372
        );
373
374
        return new AccessToken(
375
            $responseData['access_token'],
376
            $responseData['token_type'],
377
            $responseData['scope'],
378
            // if a new refresh_token was provided use that, if not reuse the old one
379
            array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : $accessToken->getRefreshToken(),
380
            $responseData['expires_at']
381
        );
382
    }
383
384
    /**
385
     * @param string $requestUri
386
     *
387
     * @return array
388
     */
389
    private static function parseRequestUri($requestUri)
390
    {
391
        if (!is_string($requestUri)) {
392
            throw new InvalidArgumentException('"requestUri" MUST be string');
393
        }
394
395
        if (false === $qPos = strpos($requestUri, '?')) {
396
            throw new OAuthException('"requestUri" not valid, no query string');
397
        }
398
        parse_str(substr($requestUri, $qPos + 1), $requestParameters);
399
400
        $requiredParameters = [
401
            'client_id',
402
            'redirect_uri',
403
            'scope',
404
            'state',
405
            'response_type',
406
        ];
407
408
        // all of the above parameters were part of the requestUri, make sure
409
        // they are still there...
410 View Code Duplication
        foreach ($requiredParameters as $requiredParameter) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
411
            if (!array_key_exists($requiredParameter, $requestParameters)) {
412
                throw new OAuthException(
413
                    sprintf(
414
                        'request URI not valid, missing required query parameter "%s"',
415
                        $requiredParameter
416
                    )
417
                );
418
            }
419
        }
420
421
        return $requestParameters;
422
    }
423
424
    /**
425
     * @param Http\Response $response
426
     * @param string        $requestScope
427
     *
428
     * @return array
429
     */
430
    private function validateTokenResponse(Response $response, $requestScope)
431
    {
432
        $tokenResponse = $response->json();
433
        // XXX what if not array?
434
435
        // check if an error occurred
436
        if (array_key_exists('error', $tokenResponse)) {
437
            if (array_key_exists('error_description', $tokenResponse)) {
438
                throw new OAuthServerException(sprintf('%s: %s', $tokenResponse['error'], $tokenResponse['error_description']));
439
            }
440
441
            throw new OAuthServerException($tokenResponse['error']);
442
        }
443
444
        $requiredParameters = [
445
            'access_token',
446
            'token_type',
447
        ];
448
449 View Code Duplication
        foreach ($requiredParameters as $requiredParameter) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
450
            if (!array_key_exists($requiredParameter, $tokenResponse)) {
451
                throw new OAuthException(
452
                    sprintf(
453
                        'token response not valid, missing required parameter "%s"',
454
                        $requiredParameter
455
                    )
456
                );
457
            }
458
        }
459
460
        if (!array_key_exists('scope', $tokenResponse)) {
461
            // if the token endpoint does not return a 'scope' value, the
462
            // specification says the requested scope was granted
463
            $tokenResponse['scope'] = $requestScope;
464
        }
465
466
        $tokenResponse['expires_at'] = $this->calculateExpiresAt($tokenResponse);
467
468
        return $tokenResponse;
469
    }
470
471
    /**
472
     * @param array $tokenResponse
473
     *
474
     * @return \DateTime
475
     */
476
    private function calculateExpiresAt(array $tokenResponse)
477
    {
478
        $dateTime = clone $this->dateTime;
479
        if (array_key_exists('expires_in', $tokenResponse)) {
480
            return date_add($dateTime, new DateInterval(sprintf('PT%dS', $tokenResponse['expires_in'])));
481
        }
482
483
        // if the 'expires_in' field is not available, we default to 1 year
484
        return date_add($dateTime, new DateInterval('P1Y'));
485
    }
486
}
487