Failed Conditions
Push — master ( 65a339...cf9b90 )
by Florent
03:13
created

IdTokenBuilder   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 463
Duplicated Lines 0 %

Importance

Changes 7
Bugs 1 Features 0
Metric Value
eloc 209
c 7
b 1
f 0
dl 0
loc 463
rs 3.36
wmc 63

27 Methods

Rating   Name   Duplication   Size   Complexity  
A withExpirationAt() 0 3 1
A withNonce() 0 3 1
A withClaimsLocales() 0 3 1
A updateClaimsWithNonce() 0 7 2
A withScope() 0 3 1
A withEncryption() 0 11 3
A withoutAuthenticationTime() 0 3 1
A withRequestedClaims() 0 3 1
A build() 0 23 5
A withSignature() 0 11 3
A updateClaimsWithJwtClaims() 0 14 2
A withAuthenticationTime() 0 3 1
A withAuthorizationCodeId() 0 3 1
A updateClaimsAudience() 0 9 1
A withAccessTokenId() 0 3 1
A __construct() 0 10 1
B setAccessToken() 0 20 7
A updateClaimsWithAuthenticationTime() 0 7 4
A computeIdToken() 0 14 1
A getHash() 0 3 1
A getHashMethod() 0 22 2
A getHeaders() 0 11 2
B getClientKeySet() 0 29 8
A getSignatureKey() 0 21 4
A updateClaimsWithTokenHash() 0 13 4
A getHashSize() 0 22 2
A tryToEncrypt() 0 24 2

How to fix   Complexity   

Complex Class

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

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * The MIT License (MIT)
7
 *
8
 * Copyright (c) 2014-2019 Spomky-Labs
9
 *
10
 * This software may be modified and distributed under the terms
11
 * of the MIT license.  See the LICENSE file for details.
12
 */
13
14
namespace OAuth2Framework\Component\OpenIdConnect;
15
16
use Base64Url\Base64Url;
17
use InvalidArgumentException;
18
use Jose\Component\Core\JWK;
19
use Jose\Component\Core\JWKSet;
20
use Jose\Component\Core\Util\JsonConverter;
21
use Jose\Component\Encryption\JWEBuilder;
22
use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer;
23
use Jose\Component\KeyManagement\JKUFactory;
24
use Jose\Component\Signature\JWSBuilder;
25
use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer;
26
use OAuth2Framework\Component\AuthorizationCodeGrant\AuthorizationCodeId;
27
use OAuth2Framework\Component\AuthorizationCodeGrant\AuthorizationCodeRepository;
28
use OAuth2Framework\Component\Core\AccessToken\AccessToken;
29
use OAuth2Framework\Component\Core\AccessToken\AccessTokenId;
30
use OAuth2Framework\Component\Core\Client\Client;
31
use OAuth2Framework\Component\Core\UserAccount\UserAccount;
32
use OAuth2Framework\Component\OpenIdConnect\UserInfo\UserInfo;
33
34
class IdTokenBuilder
35
{
36
    /**
37
     * @var string
38
     */
39
    private $issuer;
40
41
    /**
42
     * @var Client
43
     */
44
    private $client;
45
46
    /**
47
     * @var UserAccount
48
     */
49
    private $userAccount;
50
51
    /**
52
     * @var string
53
     */
54
    private $redirectUri;
55
56
    /**
57
     * @var UserInfo
58
     */
59
    private $userinfo;
60
61
    /**
62
     * @var JWKSet
63
     */
64
    private $signatureKeys;
65
66
    /**
67
     * @var int
68
     */
69
    private $lifetime;
70
71
    /**
72
     * @var string
73
     */
74
    private $scope;
75
76
    /**
77
     * @var array
78
     */
79
    private $requestedClaims = [];
80
81
    /**
82
     * @var string
83
     */
84
    private $claimsLocales;
85
86
    /**
87
     * @var null|AccessTokenId
88
     */
89
    private $accessTokenId;
90
91
    /**
92
     * @var null|AuthorizationCodeId
93
     */
94
    private $authorizationCodeId;
95
96
    /**
97
     * @var string
98
     */
99
    private $nonce;
100
101
    /**
102
     * @var bool
103
     */
104
    private $withAuthenticationTime = false;
105
106
    /**
107
     * @var JWSBuilder
108
     */
109
    private $jwsBuilder;
110
111
    /**
112
     * @var string
113
     */
114
    private $signatureAlgorithm;
115
116
    /**
117
     * @var JWEBuilder
118
     */
119
    private $jweBuilder;
120
121
    /**
122
     * @var string
123
     */
124
    private $keyEncryptionAlgorithm;
125
126
    /**
127
     * @var string
128
     */
129
    private $contentEncryptionAlgorithm;
130
131
    /**
132
     * @var \DateTimeImmutable
133
     */
134
    private $expiresAt;
135
136
    /**
137
     * @var null|JKUFactory
138
     */
139
    private $jkuFactory;
140
141
    /**
142
     * @var null|AuthorizationCodeRepository
143
     */
144
    private $authorizationCodeRepository;
145
146
    public function __construct(string $issuer, UserInfo $userinfo, int $lifetime, Client $client, UserAccount $userAccount, string $redirectUri, ?JKUFactory $jkuFactory, ?AuthorizationCodeRepository $authorizationCodeRepository)
147
    {
148
        $this->issuer = $issuer;
149
        $this->userinfo = $userinfo;
150
        $this->lifetime = $lifetime;
151
        $this->client = $client;
152
        $this->userAccount = $userAccount;
153
        $this->redirectUri = $redirectUri;
154
        $this->jkuFactory = $jkuFactory;
155
        $this->authorizationCodeRepository = $authorizationCodeRepository;
156
    }
157
158
    public function setAccessToken(AccessToken $accessToken): void
159
    {
160
        $this->accessTokenId = $accessToken->getId();
161
        $this->expiresAt = $accessToken->getExpiresAt();
162
        $this->scope = $accessToken->getParameter()->has('scope') ? $accessToken->getParameter()->get('scope') : null;
163
164
        if ($accessToken->getMetadata()->has('authorization_code_id') && null !== $this->authorizationCodeRepository) {
165
            $authorizationCodeId = new AuthorizationCodeId($accessToken->getMetadata()->get('authorization_code_id'));
0 ignored issues
show
Bug introduced by
It seems like $accessToken->getMetadat...authorization_code_id') can also be of type null; however, parameter $value of OAuth2Framework\Componen...onCodeId::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

165
            $authorizationCodeId = new AuthorizationCodeId(/** @scrutinizer ignore-type */ $accessToken->getMetadata()->get('authorization_code_id'));
Loading history...
166
            $authorizationCode = $this->authorizationCodeRepository->find($authorizationCodeId);
167
            if (null === $authorizationCode) {
168
                return;
169
            }
170
            $this->authorizationCodeId = $authorizationCodeId;
171
            $queryParams = $authorizationCode->getQueryParameters();
172
            foreach (['nonce' => 'nonce', 'claims_locales' => 'claimsLocales'] as $k => $v) {
173
                if (\array_key_exists($k, $queryParams)) {
174
                    $this->{$v} = $queryParams[$k];
175
                }
176
            }
177
            $this->withAuthenticationTime = \array_key_exists('max_age', $authorizationCode->getQueryParameters());
178
        }
179
    }
180
181
    public function withAccessTokenId(AccessTokenId $accessTokenId): void
182
    {
183
        $this->accessTokenId = $accessTokenId;
184
    }
185
186
    public function withAuthorizationCodeId(AuthorizationCodeId $authorizationCodeId): void
187
    {
188
        $this->authorizationCodeId = $authorizationCodeId;
189
    }
190
191
    public function withClaimsLocales(string $claimsLocales): void
192
    {
193
        $this->claimsLocales = $claimsLocales;
194
    }
195
196
    public function withAuthenticationTime(): void
197
    {
198
        $this->withAuthenticationTime = true;
199
    }
200
201
    public function withScope(string $scope): void
202
    {
203
        $this->scope = $scope;
204
    }
205
206
    public function withRequestedClaims(array $requestedClaims): void
207
    {
208
        $this->requestedClaims = $requestedClaims;
209
    }
210
211
    public function withNonce(string $nonce): void
212
    {
213
        $this->nonce = $nonce;
214
    }
215
216
    public function withExpirationAt(\DateTimeImmutable $expiresAt): void
217
    {
218
        $this->expiresAt = $expiresAt;
219
    }
220
221
    public function withoutAuthenticationTime(): void
222
    {
223
        $this->withAuthenticationTime = false;
224
    }
225
226
    public function withSignature(JWSBuilder $jwsBuilder, JWKSet $signatureKeys, string $signatureAlgorithm): void
227
    {
228
        if (!\in_array($signatureAlgorithm, $jwsBuilder->getSignatureAlgorithmManager()->list(), true)) {
229
            throw new InvalidArgumentException(\Safe\sprintf('Unsupported signature algorithm "%s". Please use one of the following one: %s', $signatureAlgorithm, implode(', ', $jwsBuilder->getSignatureAlgorithmManager()->list())));
230
        }
231
        if (0 === $signatureKeys->count()) {
232
            throw new InvalidArgumentException('The signature key set must contain at least one key.');
233
        }
234
        $this->jwsBuilder = $jwsBuilder;
235
        $this->signatureKeys = $signatureKeys;
236
        $this->signatureAlgorithm = $signatureAlgorithm;
237
    }
238
239
    public function withEncryption(JWEBuilder $jweBuilder, string $keyEncryptionAlgorithm, string $contentEncryptionAlgorithm): void
240
    {
241
        if (!\in_array($keyEncryptionAlgorithm, $jweBuilder->getKeyEncryptionAlgorithmManager()->list(), true)) {
242
            throw new InvalidArgumentException(\Safe\sprintf('Unsupported key encryption algorithm "%s". Please use one of the following one: %s', $keyEncryptionAlgorithm, implode(', ', $jweBuilder->getKeyEncryptionAlgorithmManager()->list())));
243
        }
244
        if (!\in_array($contentEncryptionAlgorithm, $jweBuilder->getContentEncryptionAlgorithmManager()->list(), true)) {
245
            throw new InvalidArgumentException(\Safe\sprintf('Unsupported content encryption algorithm "%s". Please use one of the following one: %s', $contentEncryptionAlgorithm, implode(', ', $jweBuilder->getContentEncryptionAlgorithmManager()->list())));
246
        }
247
        $this->jweBuilder = $jweBuilder;
248
        $this->keyEncryptionAlgorithm = $keyEncryptionAlgorithm;
249
        $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
250
    }
251
252
    public function build(): string
253
    {
254
        if (null === $this->scope) {
255
            throw new \LogicException('It is mandatory to set the scope.');
256
        }
257
        $data = $this->userinfo->getUserinfo($this->client, $this->userAccount, $this->redirectUri, $this->requestedClaims, $this->scope, $this->claimsLocales);
258
        //$data = $this->updateClaimsWithAmrAndAcrInfo($data, $this->userAccount);
259
        $data = $this->updateClaimsWithAuthenticationTime($data, $this->requestedClaims);
260
        $data = $this->updateClaimsWithNonce($data);
261
        if (null !== $this->signatureAlgorithm) {
262
            $data = $this->updateClaimsWithJwtClaims($data);
263
            $data = $this->updateClaimsWithTokenHash($data);
264
            $data = $this->updateClaimsAudience($data);
265
            $result = $this->computeIdToken($data);
266
        } else {
267
            $result = \Safe\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
268
        }
269
270
        if (null !== $this->keyEncryptionAlgorithm && null !== $this->contentEncryptionAlgorithm) {
271
            $result = $this->tryToEncrypt($this->client, $result);
272
        }
273
274
        return $result;
275
    }
276
277
    private function updateClaimsWithJwtClaims(array $claims): array
278
    {
279
        if (null === $this->expiresAt) {
280
            $this->expiresAt = (new \DateTimeImmutable())->setTimestamp(time() + $this->lifetime);
0 ignored issues
show
Documentation Bug introduced by
It seems like new DateTimeImmutable()-...me() + $this->lifetime) can also be of type false. However, the property $expiresAt is declared as type DateTimeImmutable. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
281
        }
282
        $claims += [
283
            'iat' => time(),
284
            'nbf' => time(),
285
            'exp' => $this->expiresAt->getTimestamp(),
286
            'jti' => Base64Url::encode(random_bytes(16)),
287
            'iss' => $this->issuer,
288
        ];
289
290
        return $claims;
291
    }
292
293
    private function updateClaimsWithAuthenticationTime(array $claims, array $requestedClaims): array
294
    {
295
        if ((true === $this->withAuthenticationTime || \array_key_exists('auth_time', $requestedClaims)) && null !== $this->userAccount->getLastLoginAt()) {
296
            $claims['auth_time'] = $this->userAccount->getLastLoginAt();
297
        }
298
299
        return $claims;
300
    }
301
302
    private function updateClaimsWithNonce(array $claims): array
303
    {
304
        if (null !== $this->nonce) {
305
            $claims['nonce'] = $this->nonce;
306
        }
307
308
        return $claims;
309
    }
310
311
    private function updateClaimsAudience(array $claims): array
312
    {
313
        $claims['aud'] = [
314
            $this->client->getPublicId()->getValue(),
315
            $this->issuer,
316
        ];
317
        $claims['azp'] = $this->client->getPublicId()->getValue();
318
319
        return $claims;
320
    }
321
322
    private function computeIdToken(array $claims): string
323
    {
324
        $signatureKey = $this->getSignatureKey($this->signatureAlgorithm);
325
        $header = $this->getHeaders($signatureKey, $this->signatureAlgorithm);
326
        $claimsAsArray = JsonConverter::encode($claims);
327
        $jws = $this->jwsBuilder
328
            ->create()
329
            ->withPayload($claimsAsArray)
330
            ->addSignature($signatureKey, $header)
331
            ->build()
332
        ;
333
        $serializer = new JwsCompactSerializer();
334
335
        return $serializer->serialize($jws, 0);
336
    }
337
338
    private function tryToEncrypt(Client $client, string $jwt): string
339
    {
340
        $clientKeySet = $this->getClientKeySet($client);
341
        $keyEncryptionAlgorithm = $this->jweBuilder->getKeyEncryptionAlgorithmManager()->get($this->keyEncryptionAlgorithm);
342
        $encryptionKey = $clientKeySet->selectKey('enc', $keyEncryptionAlgorithm);
343
        if (null === $encryptionKey) {
344
            throw new InvalidArgumentException('No encryption key available for the client.');
345
        }
346
        $header = [
347
            'typ' => 'JWT',
348
            'jti' => Base64Url::encode(random_bytes(16)),
349
            'alg' => $this->keyEncryptionAlgorithm,
350
            'enc' => $this->contentEncryptionAlgorithm,
351
        ];
352
        $jwe = $this->jweBuilder
353
            ->create()
354
            ->withPayload($jwt)
355
            ->withSharedProtectedHeader($header)
356
            ->addRecipient($encryptionKey)
357
            ->build()
358
        ;
359
        $serializer = new JweCompactSerializer();
360
361
        return $serializer->serialize($jwe, 0);
362
    }
363
364
    private function getSignatureKey(string $signatureAlgorithm): JWK
365
    {
366
        $keys = $this->signatureKeys;
367
        if ($this->client->has('client_secret')) {
368
            $jwk = new JWK([
369
                'kty' => 'oct',
370
                'use' => 'sig',
371
                'k' => Base64Url::encode($this->client->get('client_secret')),
0 ignored issues
show
Bug introduced by
It seems like $this->client->get('client_secret') can also be of type null; however, parameter $data of Base64Url\Base64Url::encode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

371
                'k' => Base64Url::encode(/** @scrutinizer ignore-type */ $this->client->get('client_secret')),
Loading history...
372
            ]);
373
            $keys = $keys->with($jwk);
374
        }
375
        $algorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
376
        if ('none' === $algorithm->name()) {
377
            return new JWK(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
378
        }
379
        $signatureKey = $keys->selectKey('sig', $algorithm);
380
        if (null === $signatureKey) {
381
            throw new InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
382
        }
383
384
        return $signatureKey;
385
    }
386
387
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
388
    {
389
        $header = [
390
            'typ' => 'JWT',
391
            'alg' => $signatureAlgorithm,
392
        ];
393
        if ($signatureKey->has('kid')) {
394
            $header['kid'] = $signatureKey->get('kid');
395
        }
396
397
        return $header;
398
    }
399
400
    private function updateClaimsWithTokenHash(array $claims): array
401
    {
402
        if ('none' === $this->signatureAlgorithm) {
403
            return $claims;
404
        }
405
        if (null !== $this->accessTokenId) {
406
            $claims['at_hash'] = $this->getHash($this->accessTokenId->getValue());
407
        }
408
        if (null !== $this->authorizationCodeId) {
409
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId->getValue());
410
        }
411
412
        return $claims;
413
    }
414
415
    private function getHash(string $tokenId): string
416
    {
417
        return Base64Url::encode(mb_substr(hash($this->getHashMethod(), $tokenId, true), 0, $this->getHashSize(), '8bit'));
418
    }
419
420
    private function getHashMethod(): string
421
    {
422
        $map = [
423
            'HS256' => 'sha256',
424
            'ES256' => 'sha256',
425
            'RS256' => 'sha256',
426
            'PS256' => 'sha256',
427
            'HS384' => 'sha384',
428
            'ES384' => 'sha384',
429
            'RS384' => 'sha384',
430
            'PS384' => 'sha384',
431
            'HS512' => 'sha512',
432
            'ES512' => 'sha512',
433
            'RS512' => 'sha512',
434
            'PS512' => 'sha512',
435
        ];
436
437
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
438
            throw new InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
439
        }
440
441
        return $map[$this->signatureAlgorithm];
442
    }
443
444
    private function getHashSize(): int
445
    {
446
        $map = [
447
            'HS256' => 16,
448
            'ES256' => 16,
449
            'RS256' => 16,
450
            'PS256' => 16,
451
            'HS384' => 24,
452
            'ES384' => 24,
453
            'RS384' => 24,
454
            'PS384' => 24,
455
            'HS512' => 32,
456
            'ES512' => 32,
457
            'RS512' => 32,
458
            'PS512' => 32,
459
        ];
460
461
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
462
            throw new InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
463
        }
464
465
        return $map[$this->signatureAlgorithm];
466
    }
467
468
    private function getClientKeySet(Client $client): JWKSet
469
    {
470
        $keyset = new JWKSet([]);
471
        if ($client->has('jwks')) {
472
            $jwks = JWKSet::createFromJson($client->get('jwks'));
0 ignored issues
show
Bug introduced by
It seems like $client->get('jwks') can also be of type null; however, parameter $json of Jose\Component\Core\JWKSet::createFromJson() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

472
            $jwks = JWKSet::createFromJson(/** @scrutinizer ignore-type */ $client->get('jwks'));
Loading history...
473
            foreach ($jwks as $jwk) {
474
                $keyset = $keyset->with($jwk);
475
            }
476
        }
477
        if ($client->has('client_secret')) {
478
            $jwk = new JWK([
479
                'kty' => 'oct',
480
                'use' => 'enc',
481
                'k' => Base64Url::encode($client->get('client_secret')),
0 ignored issues
show
Bug introduced by
It seems like $client->get('client_secret') can also be of type null; however, parameter $data of Base64Url\Base64Url::encode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

481
                'k' => Base64Url::encode(/** @scrutinizer ignore-type */ $client->get('client_secret')),
Loading history...
482
            ]);
483
            $keyset = $keyset->with($jwk);
484
        }
485
        if ($client->has('jwks_uri') && null !== $this->jkuFactory) {
486
            $jwksUri = $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
0 ignored issues
show
Bug introduced by
It seems like $client->get('jwks_uri') can also be of type null; however, parameter $url of Jose\Component\KeyManage...UFactory::loadFromUrl() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

486
            $jwksUri = $this->jkuFactory->loadFromUrl(/** @scrutinizer ignore-type */ $client->get('jwks_uri'));
Loading history...
487
            foreach ($jwksUri as $jwk) {
488
                $keyset = $keyset->with($jwk);
489
            }
490
        }
491
492
        if (0 === $keyset->count()) {
493
            throw new InvalidArgumentException('The client has no key or key set.');
494
        }
495
496
        return $keyset;
497
    }
498
}
499