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 ( edbb76...20e434 )
by François
02:13
created

OAuthClient::parseRequestUri()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 34
Code Lines 19

Duplication

Lines 10
Ratio 29.41 %

Importance

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