Passed
Push — master ( f7a19c...7b6561 )
by Andrew
30:19 queued 28:28
created

AbstractGrant::parseParam()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5.025

Importance

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