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 ( 411235...d56106 )
by François
02:29
created

OAuthClient::setUserId()   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 DateTime;
28
use fkooman\OAuth\Client\Exception\OAuthException;
29
use fkooman\OAuth\Client\Exception\OAuthServerException;
30
use fkooman\OAuth\Client\Http\HttpClientInterface;
31
use fkooman\OAuth\Client\Http\Request;
32
use fkooman\OAuth\Client\Http\Response;
33
use ParagonIE\ConstantTime\Base64;
34
use ParagonIE\ConstantTime\Base64UrlSafe;
35
36
class OAuthClient
37
{
38
    /** @var TokenStorageInterface */
39
    private $tokenStorage;
40
41
    /** @var \fkooman\OAuth\Client\Http\HttpClientInterface */
42
    private $httpClient;
43
44
    /** @var SessionInterface */
45
    private $session;
46
47
    /** @var RandomInterface */
48
    private $random;
49
50
    /** @var \DateTime */
51
    private $dateTime;
52
53
    /** @var Provider */
54
    private $provider = null;
55
56
    /** @var string */
57
    private $userId = null;
58
59
    /**
60
     * @param TokenStorageInterface    $tokenStorage
61
     * @param Http\HttpClientInterface $httpClient
62
     */
63
    public function __construct(TokenStorageInterface $tokenStorage, HttpClientInterface $httpClient)
64
    {
65
        $this->tokenStorage = $tokenStorage;
66
        $this->httpClient = $httpClient;
67
68
        $this->session = new Session();
69
        $this->random = new Random();
70
        $this->dateTime = new DateTime();
71
    }
72
73
    /**
74
     * @param Provider $provider
75
     */
76
    public function setProvider(Provider $provider)
77
    {
78
        $this->provider = $provider;
79
    }
80
81
    /**
82
     * @param SessionInterface $session
83
     */
84
    public function setSession(SessionInterface $session)
85
    {
86
        $this->session = $session;
87
    }
88
89
    /**
90
     * @param RandomInterface $random
91
     */
92
    public function setRandom(RandomInterface $random)
93
    {
94
        $this->random = $random;
95
    }
96
97
    /**
98
     * @param DateTime $dateTime
99
     */
100
    public function setDateTime(DateTime $dateTime)
101
    {
102
        $this->dateTime = $dateTime;
103
    }
104
105
    /**
106
     * @param string $userId
107
     */
108
    public function setUserId($userId)
109
    {
110
        $this->userId = $userId;
111
    }
112
113
    /**
114
     * Perform a GET request, convenience wrapper for ::send().
115
     *
116
     * @param string $requestScope
117
     * @param string $requestUri
118
     * @param array  $requestHeaders
119
     *
120
     * @return Http\Response|false
121
     */
122
    public function get($requestScope, $requestUri, array $requestHeaders = [])
123
    {
124
        return $this->send($requestScope, Request::get($requestUri, $requestHeaders));
125
    }
126
127
    /**
128
     * Perform a POST request, convenience wrapper for ::send().
129
     *
130
     * @param string $requestScope
131
     * @param string $requestUri
132
     * @param array  $postBody
133
     * @param array  $requestHeaders
134
     *
135
     * @return Http\Response|false
136
     */
137
    public function post($requestScope, $requestUri, array $postBody, array $requestHeaders = [])
138
    {
139
        return $this->send($requestScope, Request::post($requestUri, $postBody, $requestHeaders));
140
    }
141
142
    /**
143
     * Perform a HTTP request.
144
     *
145
     * @param string       $requestScope
146
     * @param Http\Request $request
147
     *
148
     * @return Response|false
149
     */
150
    public function send($requestScope, Request $request)
151
    {
152
        if (is_null($this->userId)) {
153
            throw new OAuthException('userId not set');
154
        }
155
156
        if (false === $accessToken = $this->getAccessToken($requestScope)) {
157
            return false;
158
        }
159
160
        if ($accessToken->isExpired($this->dateTime)) {
161
            // access_token is expired, try to refresh it
162
            if (is_null($accessToken->getRefreshToken())) {
163
                // we do not have a refresh_token, delete this access token, it
164
                // is useless now...
165
                $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
166
167
                return false;
168
            }
169
170
            // try to refresh the AccessToken
171
            if (false === $accessToken = $this->refreshAccessToken($accessToken)) {
172
                // didn't work
173
                return false;
174
            }
175
        }
176
177
        // add Authorization header to the request headers
178
        $request->setHeader('Authorization', sprintf('Bearer %s', $accessToken->getToken()));
179
180
        $response = $this->httpClient->send($request);
181
        if (401 === $response->getStatusCode()) {
182
            // the access_token was not accepted, but isn't expired, we assume
183
            // the user revoked it, also no need to try with refresh_token
184
            $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
185
186
            return false;
187
        }
188
189
        return $response;
190
    }
191
192
    /**
193
     * Obtain an authorization request URL to start the authorization process
194
     * at the OAuth provider.
195
     *
196
     * @param string $scope       the space separated scope tokens
197
     * @param string $redirectUri the URL registered at the OAuth provider, to
198
     *                            be redirected back to
199
     *
200
     * @return string the authorization request URL
201
     *
202
     * @see https://tools.ietf.org/html/rfc6749#section-3.3
203
     * @see https://tools.ietf.org/html/rfc6749#section-3.1.2
204
     */
205
    public function getAuthorizeUri($scope, $redirectUri)
206
    {
207
        if (is_null($this->userId)) {
208
            throw new OAuthException('userId not set');
209
        }
210
211
        $codeVerifier = $this->generateCodeVerifier();
212
213
        $queryParameters = [
214
            'client_id' => $this->provider->getClientId(),
215
            'redirect_uri' => $redirectUri,
216
            'scope' => $scope,
217
            'state' => $this->random->get(16),
218
            'response_type' => 'code',
219
            'code_challenge_method' => 'S256',
220
            'code_challenge' => self::hashCodeVerifier($codeVerifier),
221
        ];
222
223
        $authorizeUri = sprintf(
224
            '%s%s%s',
225
            $this->provider->getAuthorizationEndpoint(),
226
            false === strpos($this->provider->getAuthorizationEndpoint(), '?') ? '?' : '&',
227
            http_build_query($queryParameters, '&')
228
        );
229
        $this->session->set(
230
            '_oauth2_session',
231
            array_merge(
232
                $queryParameters,
233
                [
234
                    'code_verifier' => $codeVerifier,
235
                    'user_id' => $this->userId,
236
                    'provider_id' => $this->provider->getProviderId(),
237
                ]
238
            )
239
        );
240
241
        return $authorizeUri;
242
    }
243
244
    /**
245
     * @param string $responseCode  the code passed to the "code"
246
     *                              query parameter on the callback URL
247
     * @param string $responseState the state passed to the "state"
248
     *                              query parameter on the callback URL
249
     */
250
    public function handleCallback($responseCode, $responseState)
251
    {
252
        if (is_null($this->userId)) {
253
            throw new OAuthException('userId not set');
254
        }
255
256
        $sessionData = $this->session->get('_oauth2_session');
257
258
        // delete the session, we don't want it to be used multiple times...
259
        $this->session->del('_oauth2_session');
260
261
        if (!hash_equals($sessionData['state'], $responseState)) {
262
            // the OAuth state from the initial request MUST be the same as the
263
            // state used by the response
264
            throw new OAuthException('invalid session (state)');
265
        }
266
267
        // session providerId MUST match current set Provider
268
        if ($sessionData['provider_id'] !== $this->provider->getProviderId()) {
269
            throw new OAuthException('invalid session (provider_id)');
270
        }
271
272
        // session userId MUST match current set userId
273
        if ($sessionData['user_id'] !== $this->userId) {
274
            throw new OAuthException('invalid session (user_id)');
275
        }
276
277
        // prepare access_token request
278
        $tokenRequestData = [
279
            'client_id' => $this->provider->getClientId(),
280
            'grant_type' => 'authorization_code',
281
            'code' => $responseCode,
282
            'redirect_uri' => $sessionData['redirect_uri'],
283
            'code_verifier' => $sessionData['code_verifier'],
284
        ];
285
286
        $requestHeaders = [];
287
        // if we have a secret registered for the client, use it
288 View Code Duplication
        if (!is_null($this->provider->getSecret())) {
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...
289
            $requestHeaders = [
290
                'Authorization' => sprintf(
291
                    'Basic %s',
292
                    Base64::encode(
293
                        sprintf('%s:%s', $this->provider->getClientId(), $this->provider->getSecret())
294
                    )
295
                ),
296
            ];
297
        }
298
299
        $response = $this->httpClient->send(
300
            Request::post(
301
                $this->provider->getTokenEndpoint(),
302
                $tokenRequestData,
303
                $requestHeaders
304
            )
305
        );
306
307
        if (400 === $response->getStatusCode()) {
308
            // check for "invalid_grant"
309
            $responseData = $response->json();
310 View Code Duplication
            if (!array_key_exists('error', $responseData) || 'invalid_grant' !== $responseData['error']) {
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...
311
                // not an "invalid_grant", we can't deal with this here...
312
                throw new OAuthServerException($response);
313
            }
314
315
            throw new OAuthException('authorization_code was not accepted by the server');
316
        }
317
318
        if (!$response->isOkay()) {
319
            // if there is any other error, we can't deal with this here...
320
            throw new OAuthServerException($response);
321
        }
322
323
        $this->tokenStorage->storeAccessToken(
324
            $this->userId,
325
            AccessToken::fromCodeResponse(
326
                $this->provider,
327
                $this->dateTime,
328
                $response->json(),
329
                // in case server does not return a scope, we know it granted
330
                // our requested scope
331
                $sessionData['scope']
332
            )
333
        );
334
    }
335
336
    /**
337
     * @param AccessToken $accessToken
338
     *
339
     * @return AccessToken|false
340
     */
341
    private function refreshAccessToken(AccessToken $accessToken)
342
    {
343
        // prepare access_token request
344
        $tokenRequestData = [
345
            'grant_type' => 'refresh_token',
346
            'refresh_token' => $accessToken->getRefreshToken(),
347
            'scope' => $accessToken->getScope(),
348
        ];
349
350
        $requestHeaders = [];
351
        // if we have a secret registered for the client, use it
352 View Code Duplication
        if (!is_null($this->provider->getSecret())) {
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...
353
            $requestHeaders = [
354
                'Authorization' => sprintf(
355
                    'Basic %s',
356
                    Base64::encode(
357
                        sprintf('%s:%s', $this->provider->getClientId(), $this->provider->getSecret())
358
                    )
359
                ),
360
            ];
361
        }
362
363
        $response = $this->httpClient->send(
364
            Request::post(
365
                $this->provider->getTokenEndpoint(),
366
                $tokenRequestData,
367
                $requestHeaders
368
            )
369
        );
370
371
        if (400 === $response->getStatusCode()) {
372
            // check for "invalid_grant"
373
            $responseData = $response->json();
374 View Code Duplication
            if (!array_key_exists('error', $responseData) || 'invalid_grant' !== $responseData['error']) {
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...
375
                // not an "invalid_grant", we can't deal with this here...
376
                throw new OAuthServerException($response);
377
            }
378
379
            // delete the access_token, we assume the user revoked it
380
            $this->tokenStorage->deleteAccessToken($this->userId, $accessToken);
381
382
            return false;
383
        }
384
385
        if (!$response->isOkay()) {
386
            // if there is any other error, we can't deal with this here...
387
            throw new OAuthServerException($response);
388
        }
389
390
        $accessToken = AccessToken::fromRefreshResponse(
391
            $this->provider,
392
            $this->dateTime,
393
            $response->json(),
394
            // provide the old AccessToken to borrow some fields if the server
395
            // does not provide them on "refresh"
396
            $accessToken
397
        );
398
399
        // store the refreshed AccessToken
400
        $this->tokenStorage->storeAccessToken($this->userId, $accessToken);
401
402
        return $accessToken;
403
    }
404
405
    /**
406
     * Find an AccessToken in the list that matches this scope, bound to
407
     * providerId and userId.
408
     *
409
     * @param string $scope
410
     *
411
     * @return AccessToken|false
412
     */
413
    private function getAccessToken($scope)
414
    {
415
        $accessTokenList = $this->tokenStorage->getAccessTokenList($this->userId);
416
        foreach ($accessTokenList as $accessToken) {
417
            if ($this->provider->getProviderId() !== $accessToken->getProviderId()) {
418
                continue;
419
            }
420
            if ($scope !== $accessToken->getScope()) {
421
                continue;
422
            }
423
424
            return $accessToken;
425
        }
426
427
        return false;
428
    }
429
430
    /**
431
     * @param string $codeVerifier
432
     *
433
     * @return string
434
     */
435
    private static function hashCodeVerifier($codeVerifier)
436
    {
437
        return rtrim(
438
            Base64UrlSafe::encode(
439
                hash(
440
                    'sha256',
441
                    $codeVerifier,
442
                    true
443
                )
444
            ),
445
            '='
446
        );
447
    }
448
449
    /**
450
     * @return string
451
     */
452
    private function generateCodeVerifier()
453
    {
454
        return rtrim(
455
            Base64UrlSafe::encode(
456
                $this->random->get(32, true)
457
            ),
458
            '='
459
        );
460
    }
461
}
462