Passed
Push — master ( dd22d8...f84357 )
by Andrew
23:07 queued 21:07
created

AuthCodeGrant::validateCodeChallenge()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7.6383

Importance

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