AuthCodeGrant   B
last analyzed

Complexity

Total Complexity 50

Size/Duplication

Total Lines 367
Duplicated Lines 0 %

Test Coverage

Coverage 90.36%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 169
dl 0
loc 367
ccs 178
cts 197
cp 0.9036
rs 8.4
c 3
b 0
f 0
wmc 50

10 Methods

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