AbstractGrant   F
last analyzed

Complexity

Total Complexity 70

Size/Duplication

Total Lines 517
Duplicated Lines 0 %

Test Coverage

Coverage 88.96%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 148
dl 0
loc 517
ccs 145
cts 163
cp 0.8896
rs 2.8
c 5
b 0
f 0
wmc 70

35 Methods

Rating   Name   Duplication   Size   Complexity  
A getIntervalVisibility() 0 3 1
A respondToDeviceAuthorizationRequest() 0 3 1
A getServerParameter() 0 3 2
A setScopeRepository() 0 3 1
A canRespondToAuthorizationRequest() 0 3 1
A revokeRefreshTokens() 0 3 1
A getClientEntityOrFail() 0 10 2
A setRefreshTokenRepository() 0 3 1
A setPrivateKey() 0 3 1
A canRespondToAccessTokenRequest() 0 7 2
A completeDeviceAuthorizationRequest() 0 3 1
A getQueryStringParameter() 0 3 2
A issueRefreshToken() 0 28 5
A getClientCredentials() 0 17 4
A setIncludeVerificationUriComplete() 0 3 1
A issueAuthCode() 0 37 6
A completeAuthorizationRequest() 0 3 1
A getRequestParameter() 0 5 1
A issueAccessToken() 0 27 4
A setAccessTokenRepository() 0 3 1
A getBasicAuthCredentials() 0 22 5
A setUserRepository() 0 3 1
A setRefreshTokenTTL() 0 3 1
A setIntervalVisibility() 0 3 1
A setClientRepository() 0 3 1
A setDefaultScope() 0 3 1
A getCookieParameter() 0 3 2
A validateClient() 0 23 4
A validateScopes() 0 21 5
A generateUniqueIdentifier() 0 14 4
A setAuthCodeRepository() 0 3 1
A validateRedirectUri() 0 10 2
A convertScopesQueryStringToArray() 0 4 1
A canRespondToDeviceAuthorizationRequest() 0 3 1
A validateAuthorizationRequest() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like AbstractGrant often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AbstractGrant, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * OAuth 2.0 Abstract grant.
5
 *
6
 * @author      Alex Bilbie <[email protected]>
7
 * @copyright   Copyright (c) Alex Bilbie
8
 * @license     http://mit-license.org/
9
 *
10
 * @link        https://github.com/thephpleague/oauth2-server
11
 */
12
13
declare(strict_types=1);
14
15
namespace League\OAuth2\Server\Grant;
16
17
use DateInterval;
18
use DateTimeImmutable;
19
use DomainException;
20
use Error;
21
use Exception;
22
use League\OAuth2\Server\CryptKeyInterface;
23
use League\OAuth2\Server\CryptTrait;
24
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
25
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
26
use League\OAuth2\Server\Entities\ClientEntityInterface;
27
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
28
use League\OAuth2\Server\Entities\ScopeEntityInterface;
29
use League\OAuth2\Server\EventEmitting\EmitterAwarePolyfill;
30
use League\OAuth2\Server\Exception\OAuthServerException;
31
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
32
use League\OAuth2\Server\RedirectUriValidators\RedirectUriValidator;
33
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
34
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
35
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
36
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
37
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
38
use League\OAuth2\Server\Repositories\UserRepositoryInterface;
39
use League\OAuth2\Server\RequestEvent;
40
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
41
use League\OAuth2\Server\ResponseTypes\DeviceCodeResponse;
42
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
43
use LogicException;
44
use Psr\Http\Message\ServerRequestInterface;
45
use TypeError;
46
47
use function array_filter;
48
use function array_key_exists;
49
use function base64_decode;
50
use function bin2hex;
51
use function explode;
52
use function is_null;
53
use function is_string;
54
use function random_bytes;
55
use function strpos;
56
use function substr;
57
use function trim;
58
59
/**
60
 * Abstract grant class.
61
 */
62
abstract class AbstractGrant implements GrantTypeInterface
63
{
64
    use EmitterAwarePolyfill;
65
    use CryptTrait;
66
67
    protected const SCOPE_DELIMITER_STRING = ' ';
68
69
    protected const MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS = 10;
70
71
    protected ClientRepositoryInterface $clientRepository;
72
73
    protected AccessTokenRepositoryInterface $accessTokenRepository;
74
75
    protected ScopeRepositoryInterface $scopeRepository;
76
77
    protected AuthCodeRepositoryInterface $authCodeRepository;
78
79
    protected RefreshTokenRepositoryInterface $refreshTokenRepository;
80
81
    protected UserRepositoryInterface $userRepository;
82
83
    protected DateInterval $refreshTokenTTL;
84
85
    protected CryptKeyInterface $privateKey;
86
87
    protected string $defaultScope;
88
89
    protected bool $revokeRefreshTokens = true;
90
91 96
    public function setClientRepository(ClientRepositoryInterface $clientRepository): void
92
    {
93 96
        $this->clientRepository = $clientRepository;
94
    }
95
96 52
    public function setAccessTokenRepository(AccessTokenRepositoryInterface $accessTokenRepository): void
97
    {
98 52
        $this->accessTokenRepository = $accessTokenRepository;
99
    }
100
101 64
    public function setScopeRepository(ScopeRepositoryInterface $scopeRepository): void
102
    {
103 64
        $this->scopeRepository = $scopeRepository;
104
    }
105
106 87
    public function setRefreshTokenRepository(RefreshTokenRepositoryInterface $refreshTokenRepository): void
107
    {
108 87
        $this->refreshTokenRepository = $refreshTokenRepository;
109
    }
110
111 50
    public function setAuthCodeRepository(AuthCodeRepositoryInterface $authCodeRepository): void
112
    {
113 50
        $this->authCodeRepository = $authCodeRepository;
114
    }
115
116 6
    public function setUserRepository(UserRepositoryInterface $userRepository): void
117
    {
118 6
        $this->userRepository = $userRepository;
119
    }
120
121
    /**
122
     * {@inheritdoc}
123
     */
124 2
    public function setRefreshTokenTTL(DateInterval $refreshTokenTTL): void
125
    {
126 2
        $this->refreshTokenTTL = $refreshTokenTTL;
127
    }
128
129
    /**
130
     * Set the private key
131
     */
132 45
    public function setPrivateKey(CryptKeyInterface $key): void
133
    {
134 45
        $this->privateKey = $key;
135
    }
136
137 35
    public function setDefaultScope(string $scope): void
138
    {
139 35
        $this->defaultScope = $scope;
140
    }
141
142 11
    public function revokeRefreshTokens(bool $revokeRefreshTokens): void
143
    {
144 11
        $this->revokeRefreshTokens = $revokeRefreshTokens;
145
    }
146
147
    /**
148
     * Validate the client.
149
     *
150
     * @throws OAuthServerException
151
     */
152 55
    protected function validateClient(ServerRequestInterface $request): ClientEntityInterface
153
    {
154 55
        [$clientId, $clientSecret] = $this->getClientCredentials($request);
155
156 52
        if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) {
157 11
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
158
159 11
            throw OAuthServerException::invalidClient($request);
160
        }
161 41
        $client = $this->getClientEntityOrFail($clientId, $request);
162
163
        // If a redirect URI is provided ensure it matches what is pre-registered
164 41
        $redirectUri = $this->getRequestParameter('redirect_uri', $request, null);
165
166 41
        if ($redirectUri !== null) {
167 15
            if (!is_string($redirectUri)) {
168
                throw OAuthServerException::invalidRequest('redirect_uri');
169
            }
170
171 15
            $this->validateRedirectUri($redirectUri, $client, $request);
172
        }
173
174 41
        return $client;
175
    }
176
177
    /**
178
     * Wrapper around ClientRepository::getClientEntity() that ensures we emit
179
     * an event and throw an exception if the repo doesn't return a client
180
     * entity.
181
     *
182
     * This is a bit of defensive coding because the interface contract
183
     * doesn't actually enforce non-null returns/exception-on-no-client so
184
     * getClientEntity might return null. By contrast, this method will
185
     * always either return a ClientEntityInterface or throw.
186
     */
187 75
    protected function getClientEntityOrFail(string $clientId, ServerRequestInterface $request): ClientEntityInterface
188
    {
189 75
        $client = $this->clientRepository->getClientEntity($clientId);
190
191 75
        if ($client instanceof ClientEntityInterface === false) {
192 4
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
193 4
            throw OAuthServerException::invalidClient($request);
194
        }
195
196 71
        return $client;
197
    }
198
199
    /**
200
     * Gets the client credentials from the request from the request body or
201
     * the Http Basic Authorization header
202
     *
203
     * @return string[]
204
     */
205 66
    protected function getClientCredentials(ServerRequestInterface $request): array
206
    {
207 66
        [$basicAuthUser, $basicAuthPassword] = $this->getBasicAuthCredentials($request);
208
209 66
        $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser);
210
211 66
        if (is_null($clientId)) {
212 3
            throw OAuthServerException::invalidRequest('client_id');
213
        }
214
215 63
        $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword);
216
217 63
        if ($clientSecret !== null && !is_string($clientSecret)) {
218 1
            throw OAuthServerException::invalidRequest('client_secret');
219
        }
220
221 62
        return [$clientId, $clientSecret];
222
    }
223
224
    /**
225
     * Validate redirectUri from the request. If a redirect URI is provided
226
     * ensure it matches what is pre-registered
227
     *
228
     * @throws OAuthServerException
229
     */
230 30
    protected function validateRedirectUri(
231
        string $redirectUri,
232
        ClientEntityInterface $client,
233
        ServerRequestInterface $request
234
    ): void {
235 30
        $validator = new RedirectUriValidator($client->getRedirectUri());
236
237 30
        if (!$validator->validateRedirectUri($redirectUri)) {
238 4
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
239 4
            throw OAuthServerException::invalidClient($request);
240
        }
241
    }
242
243
    /**
244
     * Validate scopes in the request.
245
     *
246
     * @param null|string|string[] $scopes
247
     *
248
     * @throws OAuthServerException
249
     *
250
     * @return ScopeEntityInterface[]
251
     */
252 53
    public function validateScopes(string|array|null $scopes, string $redirectUri = null): array
253
    {
254 53
        if ($scopes === null) {
0 ignored issues
show
introduced by
The condition $scopes === null is always false.
Loading history...
255
            $scopes = [];
256 53
        } elseif (is_string($scopes)) {
0 ignored issues
show
introduced by
The condition is_string($scopes) is always false.
Loading history...
257 35
            $scopes = $this->convertScopesQueryStringToArray($scopes);
258
        }
259
260 53
        $validScopes = [];
261
262 53
        foreach ($scopes as $scopeItem) {
263 53
            $scope = $this->scopeRepository->getScopeEntityByIdentifier($scopeItem);
264
265 53
            if ($scope instanceof ScopeEntityInterface === false) {
266 5
                throw OAuthServerException::invalidScope($scopeItem, $redirectUri);
267
            }
268
269 48
            $validScopes[] = $scope;
270
        }
271
272 48
        return $validScopes;
273
    }
274
275
    /**
276
     * Converts a scopes query string to an array to easily iterate for validation.
277
     *
278
     * @return string[]
279
     */
280 35
    private function convertScopesQueryStringToArray(string $scopes): array
281
    {
282 35
        return array_filter(explode(self::SCOPE_DELIMITER_STRING, trim($scopes)), function ($scope) {
283 35
            return $scope !== '';
284 35
        });
285
    }
286
287
    /**
288
     * Retrieve request parameter.
289
     */
290 73
    protected function getRequestParameter(string $parameter, ServerRequestInterface $request, mixed $default = null): mixed
291
    {
292 73
        $requestParameters = (array) $request->getParsedBody();
293
294 73
        return $requestParameters[$parameter] ?? $default;
295
    }
296
297
    /**
298
     * Retrieve HTTP Basic Auth credentials with the Authorization header
299
     * of a request. First index of the returned array is the username,
300
     * second is the password (so list() will work). If the header does
301
     * not exist, or is otherwise an invalid HTTP Basic header, return
302
     * [null, null].
303
     *
304
     * @return string[]|null[]
305
     */
306 71
    protected function getBasicAuthCredentials(ServerRequestInterface $request): array
307
    {
308 71
        if (!$request->hasHeader('Authorization')) {
309 62
            return [null, null];
310
        }
311
312 9
        $header = $request->getHeader('Authorization')[0];
313 9
        if (strpos($header, 'Basic ') !== 0) {
314 3
            return [null, null];
315
        }
316
317 6
        $decoded = base64_decode(substr($header, 6), true);
318
319 6
        if ($decoded === false) {
320 1
            return [null, null];
321
        }
322
323 5
        if (strpos($decoded, ':') === false) {
324 2
            return [null, null]; // HTTP Basic header without colon isn't valid
325
        }
326
327 3
        return explode(':', $decoded, 2);
328
    }
329
330
    /**
331
     * Retrieve query string parameter.
332
     */
333 22
    protected function getQueryStringParameter(string $parameter, ServerRequestInterface $request, mixed $default = null): ?string
334
    {
335 22
        return isset($request->getQueryParams()[$parameter]) ? $request->getQueryParams()[$parameter] : $default;
336
    }
337
338
    /**
339
     * Retrieve cookie parameter.
340
     */
341 1
    protected function getCookieParameter(string $parameter, ServerRequestInterface $request, mixed $default = null): ?string
342
    {
343 1
        return isset($request->getCookieParams()[$parameter]) ? $request->getCookieParams()[$parameter] : $default;
344
    }
345
346
    /**
347
     * Retrieve server parameter.
348
     */
349 28
    protected function getServerParameter(string $parameter, ServerRequestInterface $request, mixed $default = null): ?string
350
    {
351 28
        return isset($request->getServerParams()[$parameter]) ? $request->getServerParams()[$parameter] : $default;
352
    }
353
354
    /**
355
     * Issue an access token.
356
     *
357
     * @param ScopeEntityInterface[] $scopes
358
     *
359
     * @throws OAuthServerException
360
     * @throws UniqueTokenIdentifierConstraintViolationException
361
     */
362 26
    protected function issueAccessToken(
363
        DateInterval $accessTokenTTL,
364
        ClientEntityInterface $client,
365
        string|int|null $userIdentifier,
366
        array $scopes = []
367
    ): AccessTokenEntityInterface {
368 26
        $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;
369
370 26
        $accessToken = $this->accessTokenRepository->getNewToken($client, $scopes, $userIdentifier);
371 26
        $accessToken->setExpiryDateTime((new DateTimeImmutable())->add($accessTokenTTL));
372 26
        $accessToken->setPrivateKey($this->privateKey);
373
374 26
        while ($maxGenerationAttempts-- > 0) {
375 26
            $accessToken->setIdentifier($this->generateUniqueIdentifier());
376
            try {
377 26
                $this->accessTokenRepository->persistNewAccessToken($accessToken);
378
379 24
                return $accessToken;
380 3
            } catch (UniqueTokenIdentifierConstraintViolationException $e) {
381 2
                if ($maxGenerationAttempts === 0) {
382 1
                    throw $e;
383
                }
384
            }
385
        }
386
387
        // This should never be hit. It is here to work around a PHPStan false error
388
        return $accessToken;
389
    }
390
391
    /**
392
     * Issue an auth code.
393
     *
394
     * @param non-empty-string       $userIdentifier
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
395
     * @param ScopeEntityInterface[] $scopes
396
     *
397
     * @throws OAuthServerException
398
     * @throws UniqueTokenIdentifierConstraintViolationException
399
     */
400 7
    protected function issueAuthCode(
401
        DateInterval $authCodeTTL,
402
        ClientEntityInterface $client,
403
        string $userIdentifier,
404
        ?string $redirectUri,
405
        array $scopes = []
406
    ): AuthCodeEntityInterface {
407 7
        $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;
408
409 7
        $authCode = $this->authCodeRepository->getNewAuthCode();
410 7
        $authCode->setExpiryDateTime((new DateTimeImmutable())->add($authCodeTTL));
411 7
        $authCode->setClient($client);
412 7
        $authCode->setUserIdentifier($userIdentifier);
413
414 7
        if ($redirectUri !== null) {
415 1
            $authCode->setRedirectUri($redirectUri);
416
        }
417
418 7
        foreach ($scopes as $scope) {
419 1
            $authCode->addScope($scope);
420
        }
421
422 7
        while ($maxGenerationAttempts-- > 0) {
423 7
            $authCode->setIdentifier($this->generateUniqueIdentifier());
424
            try {
425 7
                $this->authCodeRepository->persistNewAuthCode($authCode);
426
427 5
                return $authCode;
428 3
            } catch (UniqueTokenIdentifierConstraintViolationException $e) {
429 2
                if ($maxGenerationAttempts === 0) {
430 1
                    throw $e;
431
                }
432
            }
433
        }
434
435
        // This should never be hit. It is here to work around a PHPStan false error
436
        return $authCode;
437
    }
438
439
    /**
440
     * @throws OAuthServerException
441
     * @throws UniqueTokenIdentifierConstraintViolationException
442
     */
443 19
    protected function issueRefreshToken(AccessTokenEntityInterface $accessToken): ?RefreshTokenEntityInterface
444
    {
445 19
        $refreshToken = $this->refreshTokenRepository->getNewRefreshToken();
446
447 19
        if ($refreshToken === null) {
448 5
            return null;
449
        }
450
451 14
        $refreshToken->setExpiryDateTime((new DateTimeImmutable())->add($this->refreshTokenTTL));
452 14
        $refreshToken->setAccessToken($accessToken);
453
454 14
        $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;
455
456 14
        while ($maxGenerationAttempts-- > 0) {
457 14
            $refreshToken->setIdentifier($this->generateUniqueIdentifier());
458
            try {
459 14
                $this->refreshTokenRepository->persistNewRefreshToken($refreshToken);
460
461 12
                return $refreshToken;
462 3
            } catch (UniqueTokenIdentifierConstraintViolationException $e) {
463 2
                if ($maxGenerationAttempts === 0) {
464 1
                    throw $e;
465
                }
466
            }
467
        }
468
469
        // This should never be hit. It is here to work around a PHPStan false error
470
        return $refreshToken;
471
    }
472
473
    /**
474
     * Generate a new unique identifier.
475
     *
476
     * @throws OAuthServerException
477
     */
478 39
    protected function generateUniqueIdentifier(int $length = 40): string
479
    {
480
        try {
481 39
            if ($length < 1) {
482
                throw new DomainException('Length must be a positive integer');
483
            }
484
485 39
            return bin2hex(random_bytes($length));
486
            // @codeCoverageIgnoreStart
487
        } catch (TypeError | Error $e) {
488
            throw OAuthServerException::serverError('An unexpected error has occurred', $e);
489
        } catch (Exception $e) {
490
            // If you get this message, the CSPRNG failed hard.
491
            throw OAuthServerException::serverError('Could not generate a random string', $e);
492
        }
493
        // @codeCoverageIgnoreEnd
494
    }
495
496
    /**
497
     * {@inheritdoc}
498
     */
499 5
    public function canRespondToAccessTokenRequest(ServerRequestInterface $request): bool
500
    {
501 5
        $requestParameters = (array) $request->getParsedBody();
502
503 5
        return (
504 5
            array_key_exists('grant_type', $requestParameters)
505 5
            && $requestParameters['grant_type'] === $this->getIdentifier()
506 5
        );
507
    }
508
509
    /**
510
     * {@inheritdoc}
511
     */
512 1
    public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool
513
    {
514 1
        return false;
515
    }
516
517
    /**
518
     * {@inheritdoc}
519
     */
520 1
    public function validateAuthorizationRequest(ServerRequestInterface $request): AuthorizationRequestInterface
521
    {
522 1
        throw new LogicException('This grant cannot validate an authorization request');
523
    }
524
525
    /**
526
     * {@inheritdoc}
527
     */
528 1
    public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface
529
    {
530 1
        throw new LogicException('This grant cannot complete an authorization request');
531
    }
532
533
    /**
534
     * {@inheritdoc}
535
     */
536
    public function canRespondToDeviceAuthorizationRequest(ServerRequestInterface $request): bool
537
    {
538
        return false;
539
    }
540
541
    /**
542
     * {@inheritdoc}
543
     */
544
    public function respondToDeviceAuthorizationRequest(ServerRequestInterface $request): DeviceCodeResponse
545
    {
546
        throw new LogicException('This grant cannot validate a device authorization request');
547
    }
548
549
    /**
550
     * {@inheritdoc}
551
     */
552
    public function completeDeviceAuthorizationRequest(string $deviceCode, string $userId, bool $userApproved): void
553
    {
554
        throw new LogicException('This grant cannot complete a device authorization request');
555
    }
556
557
    /**
558
     * {@inheritdoc}
559
     */
560
    public function setIntervalVisibility(bool $intervalVisibility): void
561
    {
562
        throw new LogicException('This grant does not support the interval parameter');
563
    }
564
565
    /**
566
     * {@inheritdoc}
567
     */
568
    public function getIntervalVisibility(): bool
569
    {
570
        return false;
571
    }
572
573
    /**
574
     * {@inheritdoc}
575
     */
576
    public function setIncludeVerificationUriComplete(bool $includeVerificationUriComplete): void
577
    {
578
        throw new LogicException('This grant does not support the verification_uri_complete parameter');
579
    }
580
}
581