Passed
Pull Request — master (#1409)
by
unknown
34:27
created

AuthCodeGrant   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 381
Duplicated Lines 0 %

Test Coverage

Coverage 90.36%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 179
dl 0
loc 381
ccs 178
cts 197
cp 0.9036
rs 6.96
c 3
b 0
f 0
wmc 53

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 2
A disableRequireCodeChallengeForPublicClients() 0 3 1
B validateCodeChallenge() 0 27 7
A completeAuthorizationRequest() 0 57 4
B validateAuthorizationCode() 0 33 9
A canRespondToAuthorizationRequest() 0 6 3
B respondToAccessTokenRequest() 0 75 10
A getClientRedirectUri() 0 5 2
C validateAuthorizationRequest() 0 89 14
A getIdentifier() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like AuthCodeGrant 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 AuthCodeGrant, and based on these observations, apply Extract Interface, too.

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 League\OAuth2\Server\CodeChallengeVerifiers\CodeChallengeVerifierInterface;
19
use League\OAuth2\Server\CodeChallengeVerifiers\PlainVerifier;
20
use League\OAuth2\Server\CodeChallengeVerifiers\S256Verifier;
21
use League\OAuth2\Server\Entities\ClientEntityInterface;
22
use League\OAuth2\Server\Entities\UserEntityInterface;
23
use League\OAuth2\Server\Exception\OAuthServerException;
24
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
25
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
26
use League\OAuth2\Server\RequestAccessTokenEvent;
27
use League\OAuth2\Server\RequestEvent;
28
use League\OAuth2\Server\RequestRefreshTokenEvent;
29
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
30
use League\OAuth2\Server\ResponseTypes\RedirectResponse;
31
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
32
use LogicException;
33
use Psr\Http\Message\ServerRequestInterface;
34
use stdClass;
35
36
use function array_key_exists;
37
use function array_keys;
38
use function array_map;
39
use function count;
40
use function hash_algos;
41
use function implode;
42
use function in_array;
43
use function is_array;
44
use function json_decode;
45
use function json_encode;
46
use function preg_match;
47
use function property_exists;
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 49
    public function __construct(
64
        AuthCodeRepositoryInterface $authCodeRepository,
65
        RefreshTokenRepositoryInterface $refreshTokenRepository,
66
        private DateInterval $authCodeTTL
67
    ) {
68 49
        $this->setAuthCodeRepository($authCodeRepository);
69 49
        $this->setRefreshTokenRepository($refreshTokenRepository);
70 49
        $this->refreshTokenTTL = new DateInterval('P1M');
71
72 49
        if (in_array('sha256', hash_algos(), true)) {
73 49
            $s256Verifier = new S256Verifier();
74 49
            $this->codeChallengeVerifiers[$s256Verifier->getMethod()] = $s256Verifier;
75
        }
76
77 49
        $plainVerifier = new PlainVerifier();
78 49
        $this->codeChallengeVerifiers[$plainVerifier->getMethod()] = $plainVerifier;
79
    }
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 24
    public function respondToAccessTokenRequest(
95
        ServerRequestInterface $request,
96
        ResponseTypeInterface $responseType,
97
        DateInterval $accessTokenTTL
98
    ): ResponseTypeInterface {
99 24
        list($clientId) = $this->getClientCredentials($request);
100
101 24
        $client = $this->getClientEntityOrFail($clientId, $request);
102
103
        // Only validate the client if it is confidential
104 24
        if ($client->isConfidential()) {
105 15
            $this->validateClient($request);
106
        }
107
108 24
        $encryptedAuthCode = $this->getRequestParameter('code', $request);
109
110 23
        if ($encryptedAuthCode === null) {
111 1
            throw OAuthServerException::invalidRequest('code');
112
        }
113
114
        try {
115 22
            $authCodePayload = json_decode($this->decrypt($encryptedAuthCode));
116
117 21
            $this->validateAuthorizationCode($authCodePayload, $client, $request);
118
119 15
            $scopes = $this->scopeRepository->finalizeScopes(
120 15
                $this->validateScopes($authCodePayload->scopes),
121 15
                $this->getIdentifier(),
122 15
                $client,
123 15
                $authCodePayload->user_id,
124 15
                $authCodePayload->auth_code_id
125 15
            );
126 7
        } catch (LogicException $e) {
127 1
            throw OAuthServerException::invalidRequest('code', 'Cannot decrypt the authorization code', $e);
128
        }
129
130 15
        $codeVerifier = $this->getRequestParameter('code_verifier', $request);
131
132
        // If a code challenge isn't present but a code verifier is, reject the request to block PKCE downgrade attack
133 15
        if (!isset($authCodePayload->code_challenge) && $codeVerifier !== null) {
134 1
            throw OAuthServerException::invalidRequest(
135 1
                'code_challenge',
136 1
                'code_verifier received when no code_challenge is present'
137 1
            );
138
        }
139
140 14
        if (isset($authCodePayload->code_challenge)) {
141 7
            $this->validateCodeChallenge($authCodePayload, $codeVerifier);
142
        }
143
144
        if ($this->authCodeRepository->lockAuthCode($authCodePayload->auth_code_id)) {
145 9
            try {
146 9
                // Issue and persist new access token
147 9
                $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $authCodePayload->user_id, $scopes);
148
                $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken));
149
                $responseType->setAccessToken($accessToken);
150 9
151
                // Issue and persist new refresh token if given
152 7
                $refreshToken = $this->issueRefreshToken($accessToken);
153 6
154 6
                if ($refreshToken !== null) {
155
                    $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken));
156
                    $responseType->setRefreshToken($refreshToken);
157
                }
158 7
159
                return $responseType;
160 7
            } catch (Exception $e) {
161
                $this->authCodeRepository->unlockAuthCode($authCodePayload->auth_code_id);
162
                throw OAuthServerException::serverError(
163 7
                    'access_token',
164
                    $e
165 7
                );
166 1
            } finally {
167
                // Revoke used auth code
168
                $this->authCodeRepository->revokeAuthCode($authCodePayload->auth_code_id);
169
            }
170
        }
0 ignored issues
show
Bug Best Practice introduced by
The function implicitly returns null when the if condition on line 144 is false. This is incompatible with the type-hinted return League\OAuth2\Server\Res...s\ResponseTypeInterface. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
171 6
    }
172 3
173 3
    private function validateCodeChallenge(object $authCodePayload, ?string $codeVerifier): void
174 3
    {
175 3
        if ($codeVerifier === null) {
176
            throw OAuthServerException::invalidRequest('code_verifier');
177
        }
178 3
179 3
        // Validate code_verifier according to RFC-7636
180 3
        // @see: https://tools.ietf.org/html/rfc7636#section-4.1
181
        if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $codeVerifier) !== 1) {
182 3
            throw OAuthServerException::invalidRequest(
183 3
                'code_verifier',
184
                'Code Verifier must follow the specifications of RFC-7636.'
185
            );
186
        }
187
188
        if (property_exists($authCodePayload, 'code_challenge_method')) {
189
            if (isset($this->codeChallengeVerifiers[$authCodePayload->code_challenge_method])) {
190
                $codeChallengeVerifier = $this->codeChallengeVerifiers[$authCodePayload->code_challenge_method];
191
192
                if (!isset($authCodePayload->code_challenge) || $codeChallengeVerifier->verifyCodeChallenge($codeVerifier, $authCodePayload->code_challenge) === false) {
193
                    throw OAuthServerException::invalidGrant('Failed to verify `code_verifier`.');
194
                }
195
            } else {
196
                throw OAuthServerException::serverError(
197
                    sprintf(
198
                        'Unsupported code challenge method `%s`',
199 21
                        $authCodePayload->code_challenge_method
200
                    )
201
                );
202
            }
203
        }
204 21
    }
205 1
206
    /**
207
     * Validate the authorization code.
208 20
     */
209 1
    private function validateAuthorizationCode(
210
        stdClass $authCodePayload,
211
        ClientEntityInterface $client,
212 19
        ServerRequestInterface $request
213 1
    ): void {
214
        if (!property_exists($authCodePayload, 'auth_code_id')) {
215
            throw OAuthServerException::invalidRequest('code', 'Authorization code malformed');
216 18
        }
217 1
218
        if (time() > $authCodePayload->expire_time) {
219
            throw OAuthServerException::invalidRequest('code', 'Authorization code has expired');
220
        }
221 17
222 17
        if ($this->authCodeRepository->isAuthCodeRevoked($authCodePayload->auth_code_id) === true) {
223 1
            throw OAuthServerException::invalidGrant('Authorization code has been revoked');
224
        }
225
226 16
        if ($this->authCodeRepository->isAuthCodeLocked($authCodePayload->auth_code_id) === true) {
227 1
            throw OAuthServerException::invalidGrant('Authorization code has been locked while an access code beeing issued');
228
        }
229
230
        if ($authCodePayload->client_id !== $client->getIdentifier()) {
231
            throw OAuthServerException::invalidRequest('code', 'Authorization code was not issued to this client');
232
        }
233
234 30
        // The redirect URI is required in this request
235
        $redirectUri = $this->getRequestParameter('redirect_uri', $request);
236 30
        if ($authCodePayload->redirect_uri !== '' && $redirectUri === null) {
237
            throw OAuthServerException::invalidRequest('redirect_uri');
238
        }
239
240
        if ($authCodePayload->redirect_uri !== $redirectUri) {
241
            throw OAuthServerException::invalidRequest('redirect_uri', 'Invalid redirect URI');
242 2
        }
243
    }
244 2
245 2
    /**
246 2
     * Return the grant identifier that can be used in matching up requests.
247 2
     */
248 2
    public function getIdentifier(): string
249
    {
250
        return 'authorization_code';
251
    }
252
253
    /**
254 15
     * {@inheritdoc}
255
     */
256 15
    public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool
257 15
    {
258 15
        return (
259 15
            array_key_exists('response_type', $request->getQueryParams())
260 15
            && $request->getQueryParams()['response_type'] === 'code'
261
            && isset($request->getQueryParams()['client_id'])
262 15
        );
263 1
    }
264
265
    /**
266 14
     * {@inheritdoc}
267
     */
268 13
    public function validateAuthorizationRequest(ServerRequestInterface $request): AuthorizationRequestInterface
269
    {
270 13
        $clientId = $this->getQueryStringParameter(
271 11
            'client_id',
272
            $request,
273 2
            $this->getServerParameter('PHP_AUTH_USER', $request)
274 2
        );
275
276
        if ($clientId === null) {
277
            throw OAuthServerException::invalidRequest('client_id');
278
        }
279
280
        $client = $this->getClientEntityOrFail($clientId, $request);
281 11
282 2
        $redirectUri = $this->getQueryStringParameter('redirect_uri', $request);
283 9
284
        if ($redirectUri !== null) {
285 11
            $this->validateRedirectUri($redirectUri, $client, $request);
286 11
        } elseif (
287 11
            $client->getRedirectUri() === '' ||
288 11
            (is_array($client->getRedirectUri()) && count($client->getRedirectUri()) !== 1)
289
        ) {
290 7
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
291
292 7
            throw OAuthServerException::invalidClient($request);
293 7
        }
294 7
295 7
        $defaultClientRedirectUri = is_array($client->getRedirectUri())
296
            ? $client->getRedirectUri()[0]
297 7
            : $client->getRedirectUri();
298 1
299
        $scopes = $this->validateScopes(
300
            $this->getQueryStringParameter('scope', $request, $this->defaultScope),
301 7
            $redirectUri ?? $defaultClientRedirectUri
302
        );
303 7
304
        $stateParameter = $this->getQueryStringParameter('state', $request);
305 7
306 2
        $authorizationRequest = $this->createAuthorizationRequest();
307
        $authorizationRequest->setGrantTypeId($this->getIdentifier());
308 2
        $authorizationRequest->setClient($client);
309
        $authorizationRequest->setRedirectUri($redirectUri);
310
311
        if ($stateParameter !== null) {
312
            $authorizationRequest->setState($stateParameter);
313
        }
314
315 2
        $authorizationRequest->setScopes($scopes);
316 1
317 1
        $codeChallenge = $this->getQueryStringParameter('code_challenge', $request);
318 1
319 1
        if ($codeChallenge !== null) {
320 1
            $codeChallengeMethod = $this->getQueryStringParameter('code_challenge_method', $request, 'plain');
321 1
322 1
            if ($codeChallengeMethod === null) {
323 1
                throw OAuthServerException::invalidRequest(
324 1
                    'code_challenge_method',
325
                    'Code challenge method must be provided when `code_challenge` is set.'
326
                );
327
            }
328
329 1
            if (array_key_exists($codeChallengeMethod, $this->codeChallengeVerifiers) === false) {
330
                throw OAuthServerException::invalidRequest(
331
                    'code_challenge_method',
332
                    'Code challenge method must be one of ' . implode(', ', array_map(
333
                        function ($method) {
334
                            return '`' . $method . '`';
335
                        },
336 1
                        array_keys($this->codeChallengeVerifiers)
337 1
                    ))
338 5
                );
339 1
            }
340
341
            // Validate code_challenge according to RFC-7636
342 5
            // @see: https://tools.ietf.org/html/rfc7636#section-4.2
343
            if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $codeChallenge) !== 1) {
344
                throw OAuthServerException::invalidRequest(
345
                    'code_challenge',
346
                    'Code challenge must follow the specifications of RFC-7636.'
347
                );
348 8
            }
349
350 8
            $authorizationRequest->setCodeChallenge($codeChallenge);
351 1
            $authorizationRequest->setCodeChallengeMethod($codeChallengeMethod);
352
        } elseif ($this->requireCodeChallengeForPublicClients && !$client->isConfidential()) {
353
            throw OAuthServerException::invalidRequest('code_challenge', 'Code challenge must be provided for public clients');
354 7
        }
355 7
356
        return $authorizationRequest;
357
    }
358 7
359 6
    /**
360 6
     * {@inheritdoc}
361 6
     */
362 6
    public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface
363 6
    {
364 6
        if ($authorizationRequest->getUser() instanceof UserEntityInterface === false) {
365 6
            throw new LogicException('An instance of UserEntityInterface should be set on the AuthorizationRequest');
366
        }
367 4
368 4
        $finalRedirectUri = $authorizationRequest->getRedirectUri()
369 4
                          ?? $this->getClientRedirectUri($authorizationRequest);
370 4
371 4
        // The user approved the client, redirect them back with an auth code
372 4
        if ($authorizationRequest->isAuthorizationApproved() === true) {
373 4
            $authCode = $this->issueAuthCode(
374 4
                $this->authCodeTTL,
375 4
                $authorizationRequest->getClient(),
376 4
                $authorizationRequest->getUser()->getIdentifier(),
377
                $authorizationRequest->getRedirectUri(),
378 4
                $authorizationRequest->getScopes()
379
            );
380 4
381
            $payload = [
382
                'client_id'             => $authCode->getClient()->getIdentifier(),
383
                'redirect_uri'          => $authCode->getRedirectUri(),
384 4
                'auth_code_id'          => $authCode->getIdentifier(),
385 4
                'scopes'                => $authCode->getScopes(),
386 4
                'user_id'               => $authCode->getUserIdentifier(),
387 4
                'expire_time'           => (new DateTimeImmutable())->add($this->authCodeTTL)->getTimestamp(),
388 4
                'code_challenge'        => $authorizationRequest->getCodeChallenge(),
389 4
                'code_challenge_method' => $authorizationRequest->getCodeChallengeMethod(),
390 4
            ];
391 4
392 4
            $jsonPayload = json_encode($payload);
393 4
394
            if ($jsonPayload === false) {
395 4
                throw new LogicException('An error was encountered when JSON encoding the authorization request response');
396
            }
397
398
            $response = new RedirectResponse();
399 1
            $response->setRedirectUri(
400 1
                $this->makeRedirectUri(
401 1
                    $finalRedirectUri,
402 1
                    [
403 1
                        'code'  => $this->encrypt($jsonPayload),
404 1
                        'state' => $authorizationRequest->getState(),
405 1
                    ]
406 1
                )
407 1
            );
408
409
            return $response;
410
        }
411
412
        // The user denied the client, redirect them back with an error
413 7
        throw OAuthServerException::accessDenied(
414
            'The user denied the request',
415 7
            $this->makeRedirectUri(
416 1
                $finalRedirectUri,
417 7
                [
418
                    'state' => $authorizationRequest->getState(),
419
                ]
420
            )
421
        );
422
    }
423
424
    /**
425
     * Get the client redirect URI if not set in the request.
426
     */
427
    private function getClientRedirectUri(AuthorizationRequestInterface $authorizationRequest): string
428
    {
429
        return is_array($authorizationRequest->getClient()->getRedirectUri())
430
                ? $authorizationRequest->getClient()->getRedirectUri()[0]
431
                : $authorizationRequest->getClient()->getRedirectUri();
432
    }
433
}
434