DeviceCodeGrant::validateDeviceCode()   A
last analyzed

Complexity

Conditions 6
Paths 6

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 6.4689

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 15
c 1
b 0
f 0
nc 6
nop 2
dl 0
loc 31
ccs 13
cts 17
cp 0.7647
crap 6.4689
rs 9.2222
1
<?php
2
3
/**
4
 * OAuth 2.0 Device Code grant.
5
 *
6
 * @author      Andrew Millington <[email protected]>
7
 * @copyright   Copyright (c) Alex Bilbie
8
 * @license     http://mit-license.org/
9
 *
10
 * @link        https://github.com/thephpleague/oauth2-server
11
 */
12
13
declare(strict_types=1);
14
15
namespace League\OAuth2\Server\Grant;
16
17
use DateInterval;
18
use DateTimeImmutable;
19
use Error;
20
use Exception;
21
use League\OAuth2\Server\Entities\ClientEntityInterface;
22
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
23
use League\OAuth2\Server\Entities\ScopeEntityInterface;
24
use League\OAuth2\Server\Exception\OAuthServerException;
25
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
26
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
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\ResponseTypes\DeviceCodeResponse;
32
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
33
use Psr\Http\Message\ServerRequestInterface;
34
use TypeError;
35
36
use function is_null;
37
use function random_int;
38
use function strlen;
39
use function time;
40
41
/**
42
 * Device Code grant class.
43
 */
44
class DeviceCodeGrant extends AbstractGrant
45
{
46
    protected DeviceCodeRepositoryInterface $deviceCodeRepository;
47
    private bool $includeVerificationUriComplete = false;
48
    private bool $intervalVisibility = false;
49
    private string $verificationUri;
50
51 18
    public function __construct(
52
        DeviceCodeRepositoryInterface $deviceCodeRepository,
53
        RefreshTokenRepositoryInterface $refreshTokenRepository,
54
        private DateInterval $deviceCodeTTL,
55
        string $verificationUri,
56
        private readonly int $retryInterval = 5
57
    ) {
58 18
        $this->setDeviceCodeRepository($deviceCodeRepository);
59 18
        $this->setRefreshTokenRepository($refreshTokenRepository);
60
61 18
        $this->refreshTokenTTL = new DateInterval('P1M');
62
63 18
        $this->setVerificationUri($verificationUri);
64
    }
65
66
    /**
67
     * {@inheritdoc}
68
     */
69 2
    public function canRespondToDeviceAuthorizationRequest(ServerRequestInterface $request): bool
70
    {
71 2
        return true;
72
    }
73
74
    /**
75
     * {@inheritdoc}
76
     */
77 8
    public function respondToDeviceAuthorizationRequest(ServerRequestInterface $request): DeviceCodeResponse
78
    {
79 8
        $clientId = $this->getRequestParameter(
80 8
            'client_id',
81 8
            $request,
82 8
            $this->getServerParameter('PHP_AUTH_USER', $request)
83 8
        );
84
85 8
        if ($clientId === null) {
86 2
            throw OAuthServerException::invalidRequest('client_id');
87
        }
88
89 6
        $client = $this->getClientEntityOrFail($clientId, $request);
90
91 5
        $scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope));
92
93 5
        $deviceCodeEntity = $this->issueDeviceCode(
94 5
            $this->deviceCodeTTL,
95 5
            $client,
96 5
            $this->verificationUri,
97 5
            $scopes
98 5
        );
99
100 5
        $response = new DeviceCodeResponse();
101
102 5
        if ($this->includeVerificationUriComplete === true) {
103 1
            $response->includeVerificationUriComplete();
104
        }
105
106 5
        if ($this->intervalVisibility === true) {
107 2
            $response->includeInterval();
108
        }
109
110 5
        $response->setDeviceCodeEntity($deviceCodeEntity);
111
112 5
        return $response;
113
    }
114
115
    /**
116
     * {@inheritdoc}
117
     */
118 3
    public function completeDeviceAuthorizationRequest(string $deviceCode, string $userId, bool $userApproved): void
119
    {
120 3
        $deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByDeviceCode($deviceCode);
121
122 3
        if ($deviceCode instanceof DeviceCodeEntityInterface === false) {
123
            throw OAuthServerException::invalidRequest('device_code', 'Device code does not exist');
124
        }
125
126 3
        if ($userId === '') {
127
            throw OAuthServerException::invalidRequest('user_id', 'User ID is required');
128
        }
129
130 3
        $deviceCode->setUserIdentifier($userId);
131 3
        $deviceCode->setUserApproved($userApproved);
132
133 3
        $this->deviceCodeRepository->persistDeviceCode($deviceCode);
134
    }
135
136
    /**
137
     * {@inheritdoc}
138
     */
139 7
    public function respondToAccessTokenRequest(
140
        ServerRequestInterface $request,
141
        ResponseTypeInterface $responseType,
142
        DateInterval $accessTokenTTL
143
    ): ResponseTypeInterface {
144
        // Validate request
145 7
        $client = $this->validateClient($request);
146 6
        $deviceCodeEntity = $this->validateDeviceCode($request, $client);
147
148
        // If device code has no user associated, respond with pending or slow down
149 4
        if (is_null($deviceCodeEntity->getUserIdentifier())) {
150 2
            $shouldSlowDown = $this->deviceCodePolledTooSoon($deviceCodeEntity->getLastPolledAt());
151
152 2
            $deviceCodeEntity->setLastPolledAt(new DateTimeImmutable());
153 2
            $this->deviceCodeRepository->persistDeviceCode($deviceCodeEntity);
154
155 2
            if ($shouldSlowDown) {
156 1
                throw OAuthServerException::slowDown();
157
            }
158
159 1
            throw OAuthServerException::authorizationPending();
160
        }
161
162 2
        if ($deviceCodeEntity->getUserApproved() === false) {
163 1
            throw OAuthServerException::accessDenied();
164
        }
165
166
        // Finalize the requested scopes
167 1
        $finalizedScopes = $this->scopeRepository->finalizeScopes($deviceCodeEntity->getScopes(), $this->getIdentifier(), $client, $deviceCodeEntity->getUserIdentifier());
168
169
        // Issue and persist new access token
170 1
        $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $deviceCodeEntity->getUserIdentifier(), $finalizedScopes);
171 1
        $this->getEmitter()->emit(new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken));
172 1
        $responseType->setAccessToken($accessToken);
173
174
        // Issue and persist new refresh token if given
175 1
        $refreshToken = $this->issueRefreshToken($accessToken);
176
177 1
        if ($refreshToken !== null) {
178 1
            $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken));
179 1
            $responseType->setRefreshToken($refreshToken);
180
        }
181
182 1
        $this->deviceCodeRepository->revokeDeviceCode($deviceCodeEntity->getIdentifier());
183
184 1
        return $responseType;
185
    }
186
187
    /**
188
     * @throws OAuthServerException
189
     */
190 6
    protected function validateDeviceCode(ServerRequestInterface $request, ClientEntityInterface $client): DeviceCodeEntityInterface
191
    {
192 6
        $deviceCode = $this->getRequestParameter('device_code', $request);
193
194 6
        if (is_null($deviceCode)) {
195 1
            throw OAuthServerException::invalidRequest('device_code');
196
        }
197
198 5
        $deviceCodeEntity = $this->deviceCodeRepository->getDeviceCodeEntityByDeviceCode(
199 5
            $deviceCode
200 5
        );
201
202 5
        if ($deviceCodeEntity instanceof DeviceCodeEntityInterface === false) {
203
            $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
204
205
            throw OAuthServerException::invalidGrant();
206
        }
207
208 5
        if (time() > $deviceCodeEntity->getExpiryDateTime()->getTimestamp()) {
209 1
            throw OAuthServerException::expiredToken('device_code');
210
        }
211
212 4
        if ($this->deviceCodeRepository->isDeviceCodeRevoked($deviceCode) === true) {
213
            throw OAuthServerException::invalidRequest('device_code', 'Device code has been revoked');
214
        }
215
216 4
        if ($deviceCodeEntity->getClient()->getIdentifier() !== $client->getIdentifier()) {
217
            throw OAuthServerException::invalidRequest('device_code', 'Device code was not issued to this client');
218
        }
219
220 4
        return $deviceCodeEntity;
221
    }
222
223 2
    private function deviceCodePolledTooSoon(?DateTimeImmutable $lastPoll): bool
224
    {
225 2
        return $lastPoll !== null && $lastPoll->getTimestamp() + $this->retryInterval > time();
226
    }
227
228
    /**
229
     * Set the verification uri
230
     */
231 18
    public function setVerificationUri(string $verificationUri): void
232
    {
233 18
        $this->verificationUri = $verificationUri;
234
    }
235
236
    /**
237
     * {@inheritdoc}
238
     */
239 12
    public function getIdentifier(): string
240
    {
241 12
        return 'urn:ietf:params:oauth:grant-type:device_code';
242
    }
243
244 18
    private function setDeviceCodeRepository(DeviceCodeRepositoryInterface $deviceCodeRepository): void
245
    {
246 18
        $this->deviceCodeRepository = $deviceCodeRepository;
247
    }
248
249
    /**
250
     * Issue a device code.
251
     *
252
     * @param ScopeEntityInterface[] $scopes
253
     *
254
     * @throws OAuthServerException
255
     * @throws UniqueTokenIdentifierConstraintViolationException
256
     */
257 5
    protected function issueDeviceCode(
258
        DateInterval $deviceCodeTTL,
259
        ClientEntityInterface $client,
260
        string $verificationUri,
261
        array $scopes = [],
262
    ): DeviceCodeEntityInterface {
263 5
        $maxGenerationAttempts = self::MAX_RANDOM_TOKEN_GENERATION_ATTEMPTS;
264
265 5
        $deviceCode = $this->deviceCodeRepository->getNewDeviceCode();
266 5
        $deviceCode->setExpiryDateTime((new DateTimeImmutable())->add($deviceCodeTTL));
267 5
        $deviceCode->setClient($client);
268 5
        $deviceCode->setVerificationUri($verificationUri);
269 5
        $deviceCode->setInterval($this->retryInterval);
270
271 5
        foreach ($scopes as $scope) {
272 5
            $deviceCode->addScope($scope);
273
        }
274
275 5
        while ($maxGenerationAttempts-- > 0) {
276 5
            $deviceCode->setIdentifier($this->generateUniqueIdentifier());
277 5
            $deviceCode->setUserCode($this->generateUserCode());
278
279
            try {
280 5
                $this->deviceCodeRepository->persistDeviceCode($deviceCode);
281
282 5
                return $deviceCode;
283
            } catch (UniqueTokenIdentifierConstraintViolationException $e) {
284
                if ($maxGenerationAttempts === 0) {
285
                    throw $e;
286
                }
287
            }
288
        }
289
290
        // This should never be hit. It is here to work around a PHPStan false error
291
        return $deviceCode;
292
    }
293
294
    /**
295
     * Generate a new user code.
296
     *
297
     * @throws OAuthServerException
298
     */
299 5
    protected function generateUserCode(int $length = 8): string
300
    {
301
        try {
302 5
            $userCode = '';
303 5
            $userCodeCharacters = 'BCDFGHJKLMNPQRSTVWXZ';
304
305 5
            while (strlen($userCode) < $length) {
306 5
                $userCode .= $userCodeCharacters[random_int(0, 19)];
307
            }
308
309 5
            return $userCode;
310
            // @codeCoverageIgnoreStart
311
        } catch (TypeError | Error $e) {
312
            throw OAuthServerException::serverError('An unexpected error has occurred', $e);
313
        } catch (Exception $e) {
314
            // If you get this message, the CSPRNG failed hard.
315
            throw OAuthServerException::serverError('Could not generate a random string', $e);
316
        }
317
        // @codeCoverageIgnoreEnd
318
    }
319
320 2
    public function setIntervalVisibility(bool $intervalVisibility): void
321
    {
322 2
        $this->intervalVisibility = $intervalVisibility;
323
    }
324
325
    public function getIntervalVisibility(): bool
326
    {
327
        return $this->intervalVisibility;
328
    }
329
330 1
    public function setIncludeVerificationUriComplete(bool $includeVerificationUriComplete): void
331
    {
332 1
        $this->includeVerificationUriComplete = $includeVerificationUriComplete;
333
    }
334
}
335