Passed
Pull Request — master (#1470)
by
unknown
33:34
created

AuthCodeGrant::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 8
nc 2
nop 3
dl 0
loc 16
ccs 8
cts 8
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * @author      Alex Bilbie <[email protected]>
5
 * @copyright   Copyright (c) Alex Bilbie
6
 * @license     http://mit-license.org/
7
 *
8
 * @link        https://github.com/thephpleague/oauth2-server
9
 */
10
11
declare(strict_types=1);
12
13
namespace League\OAuth2\Server\Grant;
14
15
use DateInterval;
16
use DateTimeImmutable;
17
use Exception;
18
use InvalidArgumentException;
19
use League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface;
20
use League\OAuth2\Server\CodeChallengeVerifiers\PlainVerifier;
21
use League\OAuth2\Server\CodeChallengeVerifiers\S256Verifier;
22
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
23
use League\OAuth2\Server\Entities\ClientEntityInterface;
24
use League\OAuth2\Server\Entities\UserEntityInterface;
25
use League\OAuth2\Server\Exception\OAuthServerException;
26
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
27
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
28
use League\OAuth2\Server\RequestAccessTokenEvent;
29
use League\OAuth2\Server\RequestEvent;
30
use League\OAuth2\Server\RequestRefreshTokenEvent;
31
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
32
use League\OAuth2\Server\ResponseTypes\RedirectResponse;
33
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
34
use LogicException;
35
use Psr\Http\Message\ServerRequestInterface;
36
37
use function array_key_exists;
38
use function array_keys;
39
use function array_map;
40
use function count;
41
use function hash_algos;
42
use function implode;
43
use function in_array;
44
use function is_array;
45
use function json_decode;
46
use function json_encode;
47
use function preg_match;
48
use function sprintf;
49
use function time;
50
51
class AuthCodeGrant extends AbstractAuthorizeGrant
52
{
53
    private bool $requireCodeChallengeForPublicClients = true;
54
55
    /**
56
     * @var CodeChallengeVerifierInterface[]
57
     */
58
    private array $codeChallengeVerifiers = [];
59
60
    /**
61
     * @throws Exception
62
     */
63
    public function __construct(
64 53
        AuthCodeRepositoryInterface $authCodeRepository,
65
        RefreshTokenRepositoryInterface $refreshTokenRepository,
66
        private DateInterval $authCodeTTL
67
    ) {
68
        $this->setAuthCodeRepository($authCodeRepository);
69 53
        $this->setRefreshTokenRepository($refreshTokenRepository);
70 53
        $this->refreshTokenTTL = new DateInterval('P1M');
71 53
72
        if (in_array('sha256', hash_algos(), true)) {
73 53
            $s256Verifier = new S256Verifier();
74 53
            $this->codeChallengeVerifiers[$s256Verifier->getMethod()] = $s256Verifier;
75 53
        }
76
77
        $plainVerifier = new PlainVerifier();
78 53
        $this->codeChallengeVerifiers[$plainVerifier->getMethod()] = $plainVerifier;
79 53
    }
80
81
    /**
82
     * Disable the requirement for a code challenge for public clients.
83
     */
84
    public function disableRequireCodeChallengeForPublicClients(): void
85
    {
86
        $this->requireCodeChallengeForPublicClients = false;
87
    }
88
89
    /**
90
     * Respond to an access token request.
91
     *
92
     * @throws OAuthServerException
93
     */
94
    public function respondToAccessTokenRequest(
95 27
        ServerRequestInterface $request,
96
        ResponseTypeInterface $responseType,
97
        DateInterval $accessTokenTTL
98
    ): ResponseTypeInterface {
99
        list($clientId) = $this->getClientCredentials($request);
100 27
101
        $client = $this->getClientEntityOrFail($clientId, $request);
102 27
103
        // Only validate the client if it is confidential
104
        if ($client->isConfidential()) {
105 27
            $this->validateClient($request);
106 18
        }
107
108
        $code = $this->getRequestParameter('code', $request);
109 27
110
        if ($code === null) {
111 26
            throw OAuthServerException::invalidRequest('code');
112 1
        }
113
114
        if ($this->canUseCrypt()) {
115
            try {
116 25
                $authCodePayload = json_decode($this->decrypt($code));
117
118 23
                $ace = $this->authCodeRepository->getNewAuthCode();
119
120 16
                if ($ace == null) {
121 16
                    // Probably should throw an exception here instead
122 16
                    return $responseType;
123 16
                }
124 16
125 16
                if (isset($authCodePayload->auth_code_id)) {
126 16
                    $ace->setIdentifier($authCodePayload->auth_code_id);
127 9
                }
128 1
129 8
                if (isset($authCodePayload->client_id)) {
130 1
                    $ace->setClient($this->getClientEntityOrFail($authCodePayload->client_id, $request));
131
                }
132
133 16
                if (isset($authCodePayload->user_id)) {
134
                    $ace->setUserIdentifier((string)$authCodePayload->user_id);
135
                }
136 16
137 1
                if (isset($authCodePayload->code_challenge)) {
138 1
                    $ace->setCodeChallenge($authCodePayload->code_challenge);
139 1
                }
140 1
141
                if (isset($authCodePayload->code_challenge_method)) {
142
                    $ace->setCodeChallengeMethod($authCodePayload->code_challenge_method);
143 15
                }
144 7
145
                if (isset($authCodePayload->redirect_uri)) {
146
                    $ace->setRedirectUri($authCodePayload->redirect_uri);
147
                }
148 10
149 10
                if (isset($authCodePayload->expire_time)) {
150 10
                    $expire = new DateTimeImmutable();
151
                    $expire = $expire->setTimestamp($authCodePayload->expire_time);
152
153 10
                    $ace->setExpiryDateTime($expire);
154
                }
155 8
156 7
                if (isset($authCodePayload->scopes)) {
157 7
                    $scopes = $this->validateScopes($authCodePayload->scopes);
158
159
                    $ace->setScopes($scopes);
160
                }
161 8
162
            } catch (InvalidArgumentException $e) {
163 8
                throw OAuthServerException::invalidGrant('Cannot validate the provided authorization code');
164
            } catch (LogicException $e) {
165
                throw OAuthServerException::invalidRequest('code', 'Issue decrypting the authorization code', $e);
166 7
            }
167
        } else {
168 7
            // Get the Auth Code Payload from Repository
169 1
            $ace = $this->authCodeRepository->getAuthCodeEntity($code);
170
171
            if (empty($ace)) {
172
                throw OAuthServerException::invalidRequest('code', 'Cannot find authorization code');
173
            }
174 6
        }
175 3
176 3
        $this->validateAuthorizationCode($ace, $client, $request);
177 3
178 3
        $scopes = $this->scopeRepository->finalizeScopes(
179
            $ace->getScopes(),
180
            $this->getIdentifier(),
181 3
            $client,
182 3
            $ace->getUserIdentifier(),
183 3
            $ace->getIdentifier()
184
        );
185 3
186 1
        $codeVerifier = $this->getRequestParameter('code_verifier', $request);
187
188
        // If a code challenge isn't present but a code verifier is, reject the request to block PKCE downgrade attack
189
        if ($ace->getCodeChallenge() === null && $codeVerifier !== null) {
190
            throw OAuthServerException::invalidRequest(
191
                'code_challenge',
192
                'code_verifier received when no code_challenge is present'
193
            );
194
        }
195
196
        if ($ace->getCodeChallenge() !== null) {
197
            $this->validateCodeChallenge($ace, $codeVerifier);
198
        }
199
200
        // Issue and persist new access token
201
        $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $ace->getUserIdentifier(), $scopes);
202 23
        $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken));
203
        $responseType->setAccessToken($accessToken);
204
205
        // Issue and persist new refresh token if given
206
        $refreshToken = $this->issueRefreshToken($accessToken);
207 23
208 1
        if ($refreshToken !== null) {
209
            $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken));
210
            $responseType->setRefreshToken($refreshToken);
211 22
        }
212 1
213
        // Revoke used auth code
214
        $this->authCodeRepository->revokeAuthCode($ace->getIdentifier());
215 21
216 1
        return $responseType;
217
    }
218
219 20
    private function validateCodeChallenge(AuthCodeEntityInterface $authCodeEntity, ?string $codeVerifier): void
220 1
    {
221
        if ($codeVerifier === null) {
222
            throw OAuthServerException::invalidRequest('code_verifier');
223
        }
224
225 19
        // Validate code_verifier according to RFC-7636
226 19
        // @see: https://tools.ietf.org/html/rfc7636#section-4.1
227 1
        if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $codeVerifier) !== 1) {
228
            throw OAuthServerException::invalidRequest(
229
                'code_verifier',
230
                'Code Verifier must follow the specifications of RFC-7636.'
231 18
            );
232 2
        }
233
234
235
        if (isset($this->codeChallengeVerifiers[$authCodeEntity->getCodeChallengeMethod()])) {
236
            $codeChallengeVerifier = $this->codeChallengeVerifiers[$authCodeEntity->getCodeChallengeMethod()];
237
238
            if ($authCodeEntity->getCodeChallenge() === null || $codeChallengeVerifier->verifyCodeChallenge($codeVerifier, $authCodeEntity->getCodeChallenge()) === false) {
239 33
                throw OAuthServerException::invalidGrant('Failed to verify `code_verifier`.');
240
            }
241 33
        } else {
242
            throw OAuthServerException::serverError(
243
                sprintf(
244
                    'Unsupported code challenge method `%s`',
245
                    $authCodeEntity->getCodeChallengeMethod()
246
                )
247 2
            );
248
        }
249 2
    }
250 2
251 2
    /**
252 2
     * Validate the authorization code.
253 2
     */
254
    private function validateAuthorizationCode(
255
        AuthCodeEntityInterface $authCodeEntity,
256
        ClientEntityInterface $client,
257
        ServerRequestInterface $request
258
    ): void {
259 16
        try {
260
            if (empty($authCodeEntity->getIdentifier())) {
261 16
                // Make sure its not empty
262 16
                throw OAuthServerException::invalidRequest('code', 'Authorization code malformed');
263 16
            }
264 16
        } catch (\Throwable $th) {
265 16
            // $identifier must not be accessed before initialization
266
            throw OAuthServerException::invalidRequest('code', 'Authorization code malformed');
267 16
        }
268 1
269
        if (time() > $authCodeEntity->getExpiryDateTime()->getTimestamp()) {
270
            throw OAuthServerException::invalidGrant('Authorization code has expired');
271 15
        }
272
273 14
        if ($this->authCodeRepository->isAuthCodeRevoked($authCodeEntity->getIdentifier()) === true) {
274
            throw OAuthServerException::invalidGrant('Authorization code has been revoked');
275 14
        }
276 12
277
        if ($authCodeEntity->getClient()->getIdentifier() !== $client->getIdentifier()) {
278 2
            throw OAuthServerException::invalidRequest('code', 'Authorization code was not issued to this client');
279 2
        }
280
281
        // The redirect URI is required in this request if it was specified
282
        // in the authorization request
283
        $redirectUri = $this->getRequestParameter('redirect_uri', $request);
284
        if ($authCodeEntity->getRedirectUri() !== null && $redirectUri === null) {
285
            throw OAuthServerException::invalidRequest('redirect_uri');
286 12
        }
287
288 12
        // If a redirect URI has been provided ensure it matches the stored redirect URI
289 12
        if ($redirectUri !== null && $authCodeEntity->getRedirectUri() !== $redirectUri) {
290 12
            throw OAuthServerException::invalidRequest('redirect_uri', 'Invalid redirect URI');
291 12
        }
292 12
    }
293 12
294 12
    /**
295
     * Return the grant identifier that can be used in matching up requests.
296 7
     */
297 7
    public function getIdentifier(): string
298 7
    {
299 7
        return 'authorization_code';
300
    }
301 7
302 1
    /**
303
     * {@inheritdoc}
304
     */
305 7
    public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool
306
    {
307 7
        return (
308
            array_key_exists('response_type', $request->getQueryParams())
309 7
            && $request->getQueryParams()['response_type'] === 'code'
310 2
            && isset($request->getQueryParams()['client_id'])
311
        );
312 2
    }
313
314
    /**
315
     * {@inheritdoc}
316
     */
317
    public function validateAuthorizationRequest(ServerRequestInterface $request): AuthorizationRequestInterface
318
    {
319 2
        $clientId = $this->getQueryStringParameter(
320 1
            'client_id',
321 1
            $request,
322 1
            $this->getServerParameter('PHP_AUTH_USER', $request)
323 1
        );
324 1
325 1
        if ($clientId === null) {
326 1
            throw OAuthServerException::invalidRequest('client_id');
327 1
        }
328 1
329
        $client = $this->getClientEntityOrFail($clientId, $request);
330
331
        $redirectUri = $this->getQueryStringParameter('redirect_uri', $request);
332
333 1
        if ($redirectUri !== null) {
334
            $this->validateRedirectUri($redirectUri, $client, $request);
335
        } elseif (
336
            $client->getRedirectUri() === '' ||
337
            (is_array($client->getRedirectUri()) && count($client->getRedirectUri()) !== 1)
338
        ) {
339
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
340 1
341 1
            throw OAuthServerException::invalidClient($request);
342 5
        }
343 1
344
        $stateParameter = $this->getQueryStringParameter('state', $request);
345
346 5
        $scopes = $this->validateScopes(
347
            $this->getQueryStringParameter('scope', $request, $this->defaultScope),
348
            $this->makeRedirectUri(
349
                $redirectUri ?? $this->getClientRedirectUri($client),
350
                $stateParameter !== null ? ['state' => $stateParameter] : []
351
            )
352 8
        );
353
354 8
        $authorizationRequest = $this->createAuthorizationRequest();
355 1
        $authorizationRequest->setGrantTypeId($this->getIdentifier());
356
        $authorizationRequest->setClient($client);
357
        $authorizationRequest->setRedirectUri($redirectUri);
358 7
359 7
        if ($stateParameter !== null) {
360
            $authorizationRequest->setState($stateParameter);
361
        }
362 7
363 6
        $authorizationRequest->setScopes($scopes);
364 6
365 6
        $codeChallenge = $this->getQueryStringParameter('code_challenge', $request);
366 6
367 6
        if ($codeChallenge !== null) {
368 6
            $codeChallengeMethod = $this->getQueryStringParameter('code_challenge_method', $request, 'plain');
369 6
370
            if ($codeChallengeMethod === null) {
371 4
                throw OAuthServerException::invalidRequest(
372 4
                    'code_challenge_method',
373 4
                    'Code challenge method must be provided when `code_challenge` is set.'
374 4
                );
375 4
            }
376 4
377 4
            if (array_key_exists($codeChallengeMethod, $this->codeChallengeVerifiers) === false) {
378 4
                throw OAuthServerException::invalidRequest(
379 4
                    'code_challenge_method',
380 4
                    'Code challenge method must be one of ' . implode(', ', array_map(
381
                        function ($method) {
382 4
                            return '`' . $method . '`';
383
                        },
384 4
                        array_keys($this->codeChallengeVerifiers)
385
                    ))
386
                );
387
            }
388 4
389 4
            // Validate code_challenge according to RFC-7636
390 4
            // @see: https://tools.ietf.org/html/rfc7636#section-4.2
391 4
            if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $codeChallenge) !== 1) {
392 4
                throw OAuthServerException::invalidRequest(
393 4
                    'code_challenge',
394 4
                    'Code challenge must follow the specifications of RFC-7636.'
395 4
                );
396 4
            }
397 4
398
            $authorizationRequest->setCodeChallenge($codeChallenge);
399 4
            $authorizationRequest->setCodeChallengeMethod($codeChallengeMethod);
400
        } elseif ($this->requireCodeChallengeForPublicClients && !$client->isConfidential()) {
401
            throw OAuthServerException::invalidRequest('code_challenge', 'Code challenge must be provided for public clients');
402
        }
403 1
404 1
        return $authorizationRequest;
405 1
    }
406 1
407 1
    /**
408 1
     * {@inheritdoc}
409 1
     */
410 1
    public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface
411 1
    {
412
        if ($authorizationRequest->getUser() instanceof UserEntityInterface === false) {
413
            throw new LogicException('An instance of UserEntityInterface should be set on the AuthorizationRequest');
414
        }
415
416
        $finalRedirectUri = $authorizationRequest->getRedirectUri()
417
                          ?? $this->getClientRedirectUri($authorizationRequest->getClient());
418
419
        // The user approved the client, redirect them back with an auth code
420
        if ($authorizationRequest->isAuthorizationApproved() === true) {
421
            $authCode = $this->issueAuthCode(
422
                $this->authCodeTTL,
423
                $authorizationRequest->getClient(),
424
                $authorizationRequest->getUser()->getIdentifier(),
425
                $authorizationRequest->getRedirectUri(),
426
                $authorizationRequest->getScopes(),
427
                $authorizationRequest->getCodeChallenge(),
428
                $authorizationRequest->getCodeChallengeMethod()
429
            );
430
431
            $code = $authCode->getIdentifier();
432
433
            if ($this->canUseCrypt()) {
434
                $payload = [
435
                    'client_id'             => $authCode->getClient()->getIdentifier(),
436
                    'redirect_uri'          => $authCode->getRedirectUri(),
437
                    'auth_code_id'          => $authCode->getIdentifier(),
438
                    'scopes'                => $authCode->getScopes(),
439
                    'user_id'               => $authCode->getUserIdentifier(),
440
                    'expire_time'           => (new DateTimeImmutable())->add($this->authCodeTTL)->getTimestamp(),
441
                    'code_challenge'        => $authorizationRequest->getCodeChallenge(),
442
                    'code_challenge_method' => $authorizationRequest->getCodeChallengeMethod(),
443
                ];
444
445
                $jsonPayload = json_encode($payload);
446
447
                if ($jsonPayload === false) {
448
                    throw new LogicException('An error was encountered when JSON encoding the authorization request response');
449
                }
450
451
                $code = $this->encrypt($jsonPayload);
452
            }
453
454
            $response = new RedirectResponse();
455
            $response->setRedirectUri(
456
                $this->makeRedirectUri(
457
                    $finalRedirectUri,
458
                    [
459
                        'code'  => $code,
460
                        'state' => $authorizationRequest->getState(),
461
                    ]
462
                )
463
            );
464
465
            return $response;
466
        }
467
468
        // The user denied the client, redirect them back with an error
469
        throw OAuthServerException::accessDenied(
470
            'The user denied the request',
471
            $this->makeRedirectUri(
472
                $finalRedirectUri,
473
                [
474
                    'state' => $authorizationRequest->getState(),
475
                ]
476
            )
477
        );
478
    }
479
}
480