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 ( bacbb8...b76473 )
by François
02:05
created

OAuthClient::getAuthorizeUri()   B

Complexity

Conditions 2
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 24
rs 8.9713
c 0
b 0
f 0
cc 2
eloc 16
nc 1
nop 2
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 TokenStorageInterface */
42
    private $tokenStorage;
43
44
    /** @var \fkooman\OAuth\Client\Http\HttpClientInterface */
45
    private $httpClient;
46
47
    /** @var SessionInterface */
48
    private $session;
49
50
    /** @var RandomInterface */
51
    private $random;
52
53
    /** @var \Psr\Log\LoggerInterface */
54
    private $logger;
55
56
    /** @var \DateTime */
57
    private $dateTime;
58
59
    /** @var array */
60
    private $providerList = [];
61
62
    /** @var string */
63
    private $providerId = null;
64
65
    /** @var string|null */
66
    private $userId = null;
67
68
    /**
69
     * @param TokenStorageInterface    $tokenStorage
70
     * @param Http\HttpClientInterface $httpClient
71
     */
72
    public function __construct(TokenStorageInterface $tokenStorage, HttpClientInterface $httpClient)
73
    {
74
        $this->tokenStorage = $tokenStorage;
75
        $this->httpClient = $httpClient;
76
77
        $this->session = new Session();
78
        $this->random = new Random();
79
        $this->logger = new NullLogger();
80
        $this->dateTime = new DateTime();
81
    }
82
83
    /**
84
     * @param string   $providerId
85
     * @param Provider $provider
86
     */
87
    public function addProvider($providerId, Provider $provider)
88
    {
89
        $this->providerList[$providerId] = $provider;
90
        // the first provider we add becomes the active provider, can be
91
        // overridden by the "setProviderId" method
92
        if (1 === count($this->providerList)) {
93
            $this->providerId = $providerId;
94
        }
95
    }
96
97
    /**
98
     * @param string $providerId
99
     */
100
    public function setProviderId($providerId)
101
    {
102
        if (!array_key_exists($providerId, $this->providerList)) {
103
            throw new OAuthException(sprintf('provider with providerId "%s" does not exist', $this->providerId));
104
        }
105
    }
106
107
    /**
108
     * @param SessionInterface $session
109
     */
110
    public function setSession(SessionInterface $session)
111
    {
112
        $this->session = $session;
113
    }
114
115
    /**
116
     * @param RandomInterface $random
117
     */
118
    public function setRandom(RandomInterface $random)
119
    {
120
        $this->random = $random;
121
    }
122
123
    /**
124
     * @param LoggerInterface $logger
125
     */
126
    public function setLogger(LoggerInterface $logger)
127
    {
128
        $this->logger = $logger;
129
    }
130
131
    /**
132
     * @param DateTime $dateTime
133
     */
134
    public function setDateTime(DateTime $dateTime)
135
    {
136
        $this->dateTime = $dateTime;
137
    }
138
139
    /**
140
     * @param string $userId
141
     */
142
    public function setUserId($userId)
143
    {
144
        $this->userId = $userId;
145
    }
146
147
    /**
148
     * Perform a GET request.
149
     *
150
     * @param string|null $requestScope   the required scope to perform the
151
     *                                    request, use `null` if you want to
152
     *                                    request a resource without token
153
     * @param string      $requestUri
154
     * @param array       $requestHeaders
155
     *
156
     * @return Http\Response|false
157
     */
158
    public function get($requestScope, $requestUri, array $requestHeaders = [])
159
    {
160
        return $this->send($requestScope, Request::get($requestUri, $requestHeaders));
161
    }
162
163
    /**
164
     * Perform a POST request.
165
     *
166
     * @param string|null $requestScope
167
     * @param string      $requestUri
168
     * @param array       $postBody
169
     * @param array       $requestHeaders
170
     *
171
     * @return Http\Response|false
172
     */
173
    public function post($requestScope, $requestUri, array $postBody, array $requestHeaders = [])
174
    {
175
        return $this->send($requestScope, Request::post($requestUri, $postBody, $requestHeaders));
176
    }
177
178
    /**
179
     * Perform a HTTP request.
180
     *
181
     * @param string|null  $requestScope
182
     * @param Http\Request $request
183
     *
184
     * @return Response|false
185
     */
186
    public function send($requestScope, Request $request)
187
    {
188
        // if the requestScope is null, the request is sent without an OAuth
189
        // token, useful to use the same API to request resources that are not
190
        // protected
191
        if (is_null($requestScope)) {
192
            return $this->httpClient->send($request);
193
        }
194
195
        if (is_null($this->userId)) {
196
            throw new OAuthException('userId not set');
197
        }
198
199
        // make sure we have an access token
200 View Code Duplication
        if (false === $accessToken = $this->tokenStorage->getAccessToken($this->userId, $this->providerId, $requestScope)) {
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...
201
            $this->logger->info(sprintf('no access_token available for user "%s" with scope "%s"', $this->userId, $requestScope));
202
203
            return false;
204
        }
205
206
        if ($requestScope !== $accessToken->getScope()) {
207
            throw new OAuthException('access_token does not have the required scope');
208
        }
209
210
        $refreshedToken = false;
211
        if ($accessToken->isExpired($this->dateTime)) {
212
            $this->logger->info(sprintf('access_token for user "%s" with scope "%s" expired', $this->userId, $requestScope));
213
            // access_token is expired, try to refresh it
214 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...
215
                $this->logger->info(sprintf('no refresh_token available in this access_token for user "%s" with scope "%s", deleting it', $this->userId, $requestScope));
216
                // we do not have a refresh_token, delete this access token, it
217
                // is useless now...
218
                $this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken);
219
220
                return false;
221
            }
222
223
            $this->logger->info(sprintf('using refresh_token to obtain new access_token for user "%s" with scope "%s"', $this->userId, $requestScope));
224
225
            try {
226
                // delete the old one, and use it to try to get a new one
227
                $this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken);
228
                $accessToken = $this->refreshAccessToken($accessToken);
229
            } catch (OAuthServerException $e) {
230
                $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()));
231
232
                return false;
233
            }
234
            $this->logger->info(sprintf('got a new access_token using the refresh_token for user "%s" with scope "%s"', $this->userId, $requestScope));
235
            $refreshedToken = true;
236
        }
237
238
        // add Authorization header to the request headers
239
        $request->setHeader('Authorization', sprintf('Bearer %s', $accessToken->getToken()));
240
241
        $response = $this->httpClient->send($request);
242 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...
243
            $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));
244
            // this indicates an invalid access_token
245
            $this->tokenStorage->deleteAccessToken($this->userId, $this->providerId, $accessToken);
246
247
            return false;
248
        }
249
250
        $this->logger->info(sprintf('access_token for use "%s" with scope "%s" successfully used', $this->userId, $requestScope));
251
252
        if ($refreshedToken) {
253
            $this->logger->info(sprintf('storing refreshed access_token for user "%s" with scope "%s" as it was successfully used', $this->userId, $requestScope));
254
            // if we refreshed the token, and it was successful, i.e. not a 401,
255
            // update the stored AccessToken
256
            $this->tokenStorage->setAccessToken($this->userId, $this->providerId, $accessToken);
257
        }
258
259
        return $response;
260
    }
261
262
    /**
263
     * Obtain an authorization request URL to start the authorization process
264
     * at the OAuth provider.
265
     *
266
     * @param string $scope       the space separated scope tokens
267
     * @param string $redirectUri the URL to redirect back to after coming back
268
     *                            from the OAuth provider (callback URL)
269
     *
270
     * @return string the authorization request URL
271
     *
272
     * @see https://tools.ietf.org/html/rfc6749#section-3.3
273
     * @see https://tools.ietf.org/html/rfc6749#section-3.1.2
274
     */
275
    public function getAuthorizeUri($scope, $redirectUri)
276
    {
277
        $queryParams = http_build_query(
278
            [
279
                'client_id' => $this->getActiveProvider()->getId(),
280
                'redirect_uri' => $redirectUri,
281
                'scope' => $scope,
282
                'state' => $this->random->get(16),
283
                'response_type' => 'code',
284
            ],
285
            '&'
286
        );
287
288
        $authorizeUri = sprintf(
289
            '%s%s%s',
290
            $this->getActiveProvider()->getAuthorizationEndpoint(),
291
            false === strpos($this->getActiveProvider()->getAuthorizationEndpoint(), '?') ? '?' : '&',
292
            $queryParams
293
        );
294
        $this->session->set('_oauth2_session_provider_id', $this->providerId);
295
        $this->session->set('_oauth2_session_authorize_uri', $authorizeUri);
296
297
        return $authorizeUri;
298
    }
299
300
    /**
301
     * @param string $responseCode  the code passed to the "code"
302
     *                              query parameter on the callback URL
303
     * @param string $responseState the state passed to the "state"
304
     *                              query parameter on the callback URL
305
     */
306
    public function handleCallback($responseCode, $responseState)
307
    {
308
        if (is_null($this->userId)) {
309
            throw new OAuthException('userId not set');
310
        }
311
312
        // set the providerId from session, Provider *MUST* already be
313
        // registered at this time...
314
        $this->setProviderId($this->session->get('_oauth2_session_provider_id'));
315
316
        $requestParameters = self::parseRequestUri(
317
            $this->session->get('_oauth2_session_authorize_uri')
318
        );
319
320
        // delete the session, we don't want it to be used multiple times...
321
        $this->session->del('_oauth2_session_authorize_uri');
322
        $this->session->del('_oauth2_session_provider_id');
323
324
        if ($responseState !== $requestParameters['state']) {
325
            // the OAuth state from the initial request MUST be the same as the
326
            // state used by the response
327
            throw new OAuthException('invalid OAuth state');
328
        }
329
330
        if ($requestParameters['client_id'] !== $this->getActiveProvider()->getId()) {
331
            // the client_id used for the initial request differs from the
332
            // currently configured Provider, the client_id MUST be identical
333
            throw new OAuthException('unexpected client identifier');
334
        }
335
336
        // prepare access_token request
337
        $tokenRequestData = [
338
            'client_id' => $this->getActiveProvider()->getId(),
339
            'grant_type' => 'authorization_code',
340
            'code' => $responseCode,
341
            'redirect_uri' => $requestParameters['redirect_uri'],
342
        ];
343
344
        $responseData = $this->validateTokenResponse(
345
            $this->httpClient->send(
346
                Request::post(
347
                    $this->getActiveProvider()->getTokenEndpoint(),
348
                    $tokenRequestData,
349
                    [
350
                        'Authorization' => sprintf(
351
                            'Basic %s',
352
                            Base64::encode(
353
                                sprintf('%s:%s', $this->getActiveProvider()->getId(), $this->getActiveProvider()->getSecret())
354
                            )
355
                        ),
356
                    ]
357
                )
358
            ),
359
            $requestParameters['scope']
360
        );
361
362
        $this->tokenStorage->setAccessToken(
363
            $this->userId,
364
            $this->providerId,
365
            new AccessToken(
366
                $responseData['access_token'],
367
                $responseData['token_type'],
368
                $responseData['scope'],
369
                array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : null,
370
                $responseData['expires_at']
371
            )
372
        );
373
    }
374
375
    /**
376
     * @param AccessToken $accessToken
377
     *
378
     * @return AccessToken
379
     */
380
    private function refreshAccessToken(AccessToken $accessToken)
381
    {
382
        // prepare access_token request
383
        $tokenRequestData = [
384
            'grant_type' => 'refresh_token',
385
            'refresh_token' => $accessToken->getRefreshToken(),
386
            'scope' => $accessToken->getScope(),
387
        ];
388
389
        $responseData = $this->validateTokenResponse(
390
            $this->httpClient->send(
391
                Request::post(
392
                    $this->getActiveProvider()->getTokenEndpoint(),
393
                    $tokenRequestData,
394
                    [
395
                        'Authorization' => sprintf(
396
                            'Basic %s',
397
                            Base64::encode(
398
                                sprintf('%s:%s', $this->getActiveProvider()->getId(), $this->getActiveProvider()->getSecret())
399
                            )
400
                        ),
401
                    ]
402
                )
403
            ),
404
            $accessToken->getScope()
405
        );
406
407
        return new AccessToken(
408
            $responseData['access_token'],
409
            $responseData['token_type'],
410
            $responseData['scope'],
411
            // if a new refresh_token was provided use that, if not reuse the old one
412
            array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : $accessToken->getRefreshToken(),
413
            $responseData['expires_at']
414
        );
415
    }
416
417
    /**
418
     * @param string $requestUri
419
     *
420
     * @return array
421
     */
422
    private static function parseRequestUri($requestUri)
423
    {
424
        if (!is_string($requestUri)) {
425
            throw new InvalidArgumentException('"requestUri" MUST be string');
426
        }
427
428
        if (false === $qPos = strpos($requestUri, '?')) {
429
            throw new OAuthException('"requestUri" not valid, no query string');
430
        }
431
        parse_str(substr($requestUri, $qPos + 1), $requestParameters);
432
433
        $requiredParameters = [
434
            'client_id',
435
            'redirect_uri',
436
            'scope',
437
            'state',
438
            'response_type',
439
        ];
440
441
        // all of the above parameters were part of the requestUri, make sure
442
        // they are still there...
443 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...
444
            if (!array_key_exists($requiredParameter, $requestParameters)) {
445
                throw new OAuthException(
446
                    sprintf(
447
                        'request URI not valid, missing required query parameter "%s"',
448
                        $requiredParameter
449
                    )
450
                );
451
            }
452
        }
453
454
        return $requestParameters;
455
    }
456
457
    /**
458
     * @param Http\Response $response
459
     * @param string        $requestScope
460
     *
461
     * @return array
462
     */
463
    private function validateTokenResponse(Response $response, $requestScope)
464
    {
465
        $tokenResponse = $response->json();
466
        // XXX what if not array?
467
468
        // check if an error occurred
469
        if (array_key_exists('error', $tokenResponse)) {
470
            if (array_key_exists('error_description', $tokenResponse)) {
471
                throw new OAuthServerException(sprintf('%s: %s', $tokenResponse['error'], $tokenResponse['error_description']));
472
            }
473
474
            throw new OAuthServerException($tokenResponse['error']);
475
        }
476
477
        $requiredParameters = [
478
            'access_token',
479
            'token_type',
480
        ];
481
482 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...
483
            if (!array_key_exists($requiredParameter, $tokenResponse)) {
484
                throw new OAuthException(
485
                    sprintf(
486
                        'token response not valid, missing required parameter "%s"',
487
                        $requiredParameter
488
                    )
489
                );
490
            }
491
        }
492
493
        if (!array_key_exists('scope', $tokenResponse)) {
494
            // if the token endpoint does not return a 'scope' value, the
495
            // specification says the requested scope was granted
496
            $tokenResponse['scope'] = $requestScope;
497
        }
498
499
        $tokenResponse['expires_at'] = $this->calculateExpiresAt($tokenResponse);
500
501
        return $tokenResponse;
502
    }
503
504
    /**
505
     * @param array $tokenResponse
506
     *
507
     * @return \DateTime
508
     */
509
    private function calculateExpiresAt(array $tokenResponse)
510
    {
511
        $dateTime = clone $this->dateTime;
512
        if (array_key_exists('expires_in', $tokenResponse)) {
513
            return date_add($dateTime, new DateInterval(sprintf('PT%dS', $tokenResponse['expires_in'])));
514
        }
515
516
        // if the 'expires_in' field is not available, we default to 1 year
517
        return date_add($dateTime, new DateInterval('P1Y'));
518
    }
519
520
    /**
521
     * Get the active OAuth provider.
522
     *
523
     * @return Provider
524
     */
525
    private function getActiveProvider()
526
    {
527
        return $this->providerList[$this->providerId];
528
    }
529
}
530