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 ( a73f5d...82832a )
by François
01:58
created

OAuthClient::setDateTime()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
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 RandomInterface */
51
    private $random;
52
53
    /** @var \Psr\Log\LoggerInterface */
54
    private $logger;
55
56
    /** @var \DateTime */
57
    private $dateTime;
58
59
    /** @var string|null */
60
    private $userId = null;
61
62
    /**
63
     * @param Provider                 $provider
64
     * @param TokenStorageInterface    $tokenStorage
65
     * @param Http\HttpClientInterface $httpClient
66
     */
67
    public function __construct(Provider $provider, TokenStorageInterface $tokenStorage, HttpClientInterface $httpClient)
68
    {
69
        $this->provider = $provider;
70
        $this->tokenStorage = $tokenStorage;
71
        $this->httpClient = $httpClient;
72
73
        $this->random = new Random();
74
        $this->logger = new NullLogger();
75
        $this->dateTime = new DateTime();
76
    }
77
78
    /**
79
     * @param RandomInterface $random
80
     */
81
    public function setRandom(RandomInterface $random)
82
    {
83
        $this->random = $random;
84
    }
85
86
    /**
87
     * @param LoggerInterface $logger
88
     */
89
    public function setLogger(LoggerInterface $logger)
90
    {
91
        $this->logger = $logger;
92
    }
93
94
    /**
95
     * @param DateTime $dateTime
96
     */
97
    public function setDateTime(DateTime $dateTime)
98
    {
99
        $this->dateTime = $dateTime;
100
    }
101
102
    /**
103
     * @param string $userId
104
     */
105
    public function setUserId($userId)
106
    {
107
        $this->userId = $userId;
108
    }
109
110
    /**
111
     * @param string $requestScope
112
     * @param string $requestUri
113
     * @param array  $requestHeaders
114
     *
115
     * @return Http\Response|false
116
     */
117
    public function get($requestScope, $requestUri, array $requestHeaders = [])
118
    {
119
        return $this->send($requestScope, Request::get($requestUri, $requestHeaders));
120
    }
121
122
    /**
123
     * @param string $requestScope
124
     * @param string $requestUri
125
     * @param array  $postBody
126
     * @param array  $requestHeaders
127
     *
128
     * @return Http\Response|false
129
     */
130
    public function post($requestScope, $requestUri, array $postBody, array $requestHeaders = [])
131
    {
132
        return $this->send($requestScope, Request::post($requestUri, $postBody, $requestHeaders));
133
    }
134
135
    /**
136
     * @param string       $requestScope
137
     * @param Http\Request $request
138
     *
139
     * @return Response|false
140
     */
141
    public function send($requestScope, Request $request)
0 ignored issues
show
Unused Code introduced by
The parameter $requestScope is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
142
    {
143
        if (is_null($this->userId)) {
144
            throw new OAuthException('userId not set');
145
        }
146
147
        // make sure we have an access token
148
        if (false === $accessToken = $this->tokenStorage->getAccessToken($this->userId)) {
149
            $this->logger->info('no access_token available');
150
151
            return false;
152
        }
153
154
        $refreshedToken = false;
155
        if ($accessToken->isExpired($this->dateTime)) {
156
            $this->logger->info('access_token expired');
157
            // access_token is expired, try to refresh it
158
            if (is_null($accessToken->getRefreshToken())) {
159
                $this->logger->info('no refresh_token available, delete access_token');
160
                // we do not have a refresh_token, delete this access token, it
161
                // is useless now...
162
                $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
163
164
                return false;
165
            }
166
167
            $this->logger->info('attempting to refresh access_token');
168
            // deal with possibly revoked authorization! XXX
169
            try {
170
                $accessToken = $this->refreshAccessToken($accessToken);
171
            } catch (OAuthServerException $e) {
172
                $this->logger->info(sprintf('unable to use refresh_token %s', $e->getMessage()));
173
174
                // delete the access_token, the refresh_token could not be used
175
                $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
176
177
                return false;
178
            }
179
180
            // maybe delete old accesstoken here? XXX
181
            $this->logger->info('access_token refreshed');
182
            $refreshedToken = true;
183
        }
184
185
        // add Authorization header to the request headers
186
        $request->setHeader('Authorization', sprintf('Bearer %s', $accessToken->getToken()));
187
188
        $response = $this->httpClient->send($request);
189
        if (401 === $response->getStatusCode()) {
190
            $this->logger->info('access_token appears to be invalid, delete access_token');
191
            // this indicates an invalid access_token
192
            $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
193
194
            return false;
195
        }
196
197
        $this->logger->info('access_token was valid, call succeeded');
198
199
        if ($refreshedToken) {
200
            $this->logger->info('access_token was refreshed and it worked, so store it now for future use');
201
            // if we refreshed the token, and it was successful, i.e. not a 401,
202
            // update the stored AccessToken
203
            $this->tokenStorage->setAccessToken($this->userId, $accessToken);
204
        }
205
206
        return $response;
207
    }
208
209
    /**
210
     * Obtain an authorization request URL to start the authorization process
211
     * at the OAuth provider.
212
     *
213
     * @param string $scope       the space separated scope tokens
214
     * @param string $redirectUri the URL to redirect back to after coming back
215
     *                            from the OAuth provider (callback URL)
216
     *
217
     * @return string the authorization request URL
218
     *
219
     * @see https://tools.ietf.org/html/rfc6749#section-3.3
220
     * @see https://tools.ietf.org/html/rfc6749#section-3.1.2
221
     */
222
    public function getAuthorizeUri($scope, $redirectUri)
223
    {
224
        $queryParams = http_build_query(
225
            [
226
                'client_id' => $this->provider->getId(),
227
                'redirect_uri' => $redirectUri,
228
                'scope' => $scope,
229
                'state' => $this->random->get(16),
230
                'response_type' => 'code',
231
            ],
232
            '&'
233
        );
234
235
        return sprintf(
236
            '%s%s%s',
237
            $this->provider->getAuthorizationEndpoint(),
238
            false === strpos($this->provider->getAuthorizationEndpoint(), '?') ? '?' : '&',
239
            $queryParams
240
        );
241
    }
242
243
    /**
244
     * Obtain the access token from the OAuth provider after returning from the
245
     * OAuth provider on the redirectUri (callback URL).
246
     *
247
     * @param string $requestUri    the original authorization
248
     *                              request URL as obtained by getAuthorzationRequestUri
249
     * @param string $responseCode  the code passed to the 'code'
250
     *                              query parameter on the callback URL
251
     * @param string $responseState the state passed to the 'state'
252
     *                              query parameter on the callback URL
253
     */
254
    public function handleCallback($requestUri, $responseCode, $responseState)
255
    {
256
        if (is_null($this->userId)) {
257
            throw new OAuthException('userId not set');
258
        }
259
260
        // the requestUri parameter is provided by the caller of this call, and
261
        // does NOT contain external input so does not need to be validated
262
        $requestParameters = self::parseRequestUri($requestUri);
263
        if ($responseState !== $requestParameters['state']) {
264
            // the OAuth state from the initial request MUST be the same as the
265
            // state used by the response
266
            throw new OAuthException('invalid OAuth state');
267
        }
268
269
        if ($requestParameters['client_id'] !== $this->provider->getId()) {
270
            // the client_id used for the initial request differs from the
271
            // currently configured Provider, the client_id MUST be identical
272
            throw new OAuthException('unexpected client identifier');
273
        }
274
275
        // prepare access_token request
276
        $tokenRequestData = [
277
            'client_id' => $this->provider->getId(),
278
            'grant_type' => 'authorization_code',
279
            'code' => $responseCode,
280
            'redirect_uri' => $requestParameters['redirect_uri'],
281
        ];
282
283
        $responseData = $this->validateTokenResponse(
284
            $this->httpClient->send(
285
                Request::post(
286
                    $this->provider->getTokenEndpoint(),
287
                    $tokenRequestData,
288
                    [
289
                        'Authorization' => sprintf(
290
                            'Basic %s',
291
                            Base64::encode(
292
                                sprintf('%s:%s', $this->provider->getId(), $this->provider->getSecret())
293
                            )
294
                        ),
295
                    ]
296
                )
297
            ),
298
            $requestParameters['scope']
299
        );
300
301
        $this->tokenStorage->setAccessToken(
302
            $this->userId,
303
            new AccessToken(
304
                $responseData['access_token'],
305
                $responseData['token_type'],
306
                $responseData['scope'],
307
                array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : null,
308
                $responseData['expires_at']
309
            )
310
        );
311
    }
312
313
    /**
314
     * @param AccessToken $accessToken
315
     *
316
     * @return AccessToken
317
     */
318
    private function refreshAccessToken(AccessToken $accessToken)
319
    {
320
        // prepare access_token request
321
        $tokenRequestData = [
322
            'grant_type' => 'refresh_token',
323
            'refresh_token' => $accessToken->getRefreshToken(),
324
            'scope' => $accessToken->getScope(),
325
        ];
326
327
        $responseData = $this->validateTokenResponse(
328
            $this->httpClient->send(
329
                Request::post(
330
                    $this->provider->getTokenEndpoint(),
331
                    $tokenRequestData,
332
                    [
333
                        'Authorization' => sprintf(
334
                            'Basic %s',
335
                            Base64::encode(
336
                                sprintf('%s:%s', $this->provider->getId(), $this->provider->getSecret())
337
                            )
338
                        ),
339
                    ]
340
                )
341
            ),
342
            $accessToken->getScope()
343
        );
344
345
        return new AccessToken(
346
            $responseData['access_token'],
347
            $responseData['token_type'],
348
            $responseData['scope'],
349
            // if a new refresh_token was provided use that, if not reuse the old one
350
            array_key_exists('refresh_token', $responseData) ? $responseData['refresh_token'] : $accessToken->getRefreshToken(),
351
            $responseData['expires_at']
352
        );
353
    }
354
355
    /**
356
     * @param string $requestUri
357
     *
358
     * @return array
359
     */
360
    private static function parseRequestUri($requestUri)
361
    {
362
        if (!is_string($requestUri)) {
363
            throw new InvalidArgumentException('"requestUri" MUST be string');
364
        }
365
366
        if (false === strpos($requestUri, '?')) {
367
            throw new OAuthException('"requestUri" not valid, no query string');
368
        }
369
370
        parse_str(explode('?', $requestUri)[1], $requestParameters);
371
372
        $requiredParameters = [
373
            'client_id',
374
            'redirect_uri',
375
            'scope',
376
            'state',
377
            'response_type',
378
        ];
379
380
        // all of the above parameters were part of the requestUri, make sure
381
        // they are still there...
382 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...
383
            if (!array_key_exists($requiredParameter, $requestParameters)) {
384
                throw new OAuthException(
385
                    sprintf(
386
                        'request URI not valid, missing required query parameter "%s"',
387
                        $requiredParameter
388
                    )
389
                );
390
            }
391
        }
392
393
        return $requestParameters;
394
    }
395
396
    /**
397
     * @param Http\Response $response
398
     * @param string        $requestScope
399
     *
400
     * @return array
401
     */
402
    private function validateTokenResponse(Response $response, $requestScope)
403
    {
404
        $tokenResponse = $response->json();
405
        // XXX what if not array?
406
407
        // check if an error occurred
408
        if (array_key_exists('error', $tokenResponse)) {
409
            if (array_key_exists('error_description', $tokenResponse)) {
410
                throw new OAuthServerException(sprintf('%s: %s', $tokenResponse['error'], $tokenResponse['error_description']));
411
            }
412
413
            throw new OAuthServerException($tokenResponse['error']);
414
        }
415
416
        $requiredParameters = [
417
            'access_token',
418
            'token_type',
419
        ];
420
421 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...
422
            if (!array_key_exists($requiredParameter, $tokenResponse)) {
423
                throw new OAuthException(
424
                    sprintf(
425
                        'token response not valid, missing required parameter "%s"',
426
                        $requiredParameter
427
                    )
428
                );
429
            }
430
        }
431
432
        if (!array_key_exists('scope', $tokenResponse)) {
433
            // if the token endpoint does not return a 'scope' value, the
434
            // specification says the requested scope was granted
435
            $tokenResponse['scope'] = $requestScope;
436
        }
437
438
        $tokenResponse['expires_at'] = $this->calculateExpiresAt($tokenResponse);
439
440
        return $tokenResponse;
441
    }
442
443
    /**
444
     * @param array $tokenResponse
445
     *
446
     * @return \DateTime
447
     */
448
    private function calculateExpiresAt(array $tokenResponse)
449
    {
450
        $dateTime = clone $this->dateTime;
451
        if (array_key_exists('expires_in', $tokenResponse)) {
452
            return date_add($dateTime, new DateInterval(sprintf('PT%dS', $tokenResponse['expires_in'])));
453
        }
454
455
        // if the 'expires_in' field is not available, we default to 1 year
456
        return date_add($dateTime, new DateInterval('P1Y'));
457
    }
458
}
459