Passed
Pull Request — master (#1408)
by
unknown
49:03 queued 14:05
created

AuthCodeGrant::getClientRedirectUri()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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