Failed Conditions
Push — master ( 9eeb29...881d26 )
by Florent
16:44
created

IdTokenBuilder   F

Complexity

Total Complexity 63

Size/Duplication

Total Lines 380
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 17

Importance

Changes 0
Metric Value
wmc 63
lcom 1
cbo 17
dl 0
loc 380
rs 3.36
c 0
b 0
f 0

27 Methods

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

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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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-2018 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 Jose\Component\Core\Converter\StandardConverter;
18
use Jose\Component\Core\JWK;
19
use Jose\Component\Core\JWKSet;
20
use Jose\Component\Encryption\JWEBuilder;
21
use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer;
22
use Jose\Component\KeyManagement\JKUFactory;
23
use Jose\Component\Signature\JWSBuilder;
24
use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer;
25
use OAuth2Framework\Component\AuthorizationCodeGrant\AuthorizationCodeId;
26
use OAuth2Framework\Component\AuthorizationCodeGrant\AuthorizationCodeRepository;
27
use OAuth2Framework\Component\Core\AccessToken\AccessToken;
28
use OAuth2Framework\Component\Core\AccessToken\AccessTokenId;
29
use OAuth2Framework\Component\Core\Client\Client;
30
use OAuth2Framework\Component\Core\Token\TokenId;
31
use OAuth2Framework\Component\Core\UserAccount\UserAccount;
32
use OAuth2Framework\Component\OpenIdConnect\UserInfo\UserInfo;
33
34
class IdTokenBuilder
35
{
36
    private $issuer;
37
    private $client;
38
    private $user;
39
    private $userAccount;
40
    private $redirectUri;
41
    private $userinfo;
42
    private $signatureKeys;
43
    private $lifetime;
44
    private $scope = null;
45
    private $requestedClaims = [];
46
    private $claimsLocales = null;
47
    private $accessTokenId = null;
48
    private $authorizationCodeId = null;
49
    private $nonce = null;
50
    private $withAuthenticationTime = false;
51
    private $jwsBuilder = null;
52
    private $signatureAlgorithm = null;
53
    private $jweBuilder;
54
    private $keyEncryptionAlgorithm = null;
55
    private $contentEncryptionAlgorithm = null;
56
    private $expiresAt = null;
57
    private $jkuFactory = null;
58
    private $authorizationCodeRepository = null;
59
60
    public function __construct(string $issuer, UserInfo $userinfo, int $lifetime, Client $client, UserAccount $userAccount, string $redirectUri, ?JKUFactory $jkuFactory, ?AuthorizationCodeRepository $authorizationCodeRepository)
61
    {
62
        $this->issuer = $issuer;
63
        $this->userinfo = $userinfo;
64
        $this->lifetime = $lifetime;
65
        $this->client = $client;
66
        $this->user = $user;
0 ignored issues
show
Bug introduced by
The variable $user does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
67
        $this->userAccount = $userAccount;
68
        $this->redirectUri = $redirectUri;
69
        $this->jkuFactory = $jkuFactory;
70
        $this->authorizationCodeRepository = $authorizationCodeRepository;
71
    }
72
73
    public function setAccessToken(AccessToken $accessToken): void
74
    {
75
        $this->accessTokenId = $accessToken->getTokenId();
76
        $this->expiresAt = $accessToken->getExpiresAt();
77
        $this->scope = $accessToken->getParameter()->has('scope') ? $accessToken->getParameter()->get('scope') : null;
78
79
        if ($accessToken->getMetadata()->has('authorization_code_id') && null !== $this->authorizationCodeRepository) {
80
            $authorizationCodeId = new AuthorizationCodeId($accessToken->getMetadata()->get('authorization_code_id'));
81
            $authorizationCode = $this->authorizationCodeRepository->find($authorizationCodeId);
82
            if (null === $authorizationCode) {
83
                return;
84
            }
85
            $this->authorizationCodeId = $authorizationCodeId;
86
            $queryParams = $authorizationCode->getQueryParams();
87
            foreach (['nonce' => 'nonce', 'claims_locales' => 'claimsLocales'] as $k => $v) {
88
                if (\array_key_exists($k, $queryParams)) {
89
                    $this->$v = $queryParams[$k];
90
                }
91
            }
92
            $this->withAuthenticationTime = \array_key_exists('max_age', $authorizationCode->getQueryParams());
93
        }
94
    }
95
96
    public function withAccessTokenId(AccessTokenId $accessTokenId): void
97
    {
98
        $this->accessTokenId = $accessTokenId;
99
    }
100
101
    public function withAuthorizationCodeId(AuthorizationCodeId $authorizationCodeId): void
102
    {
103
        $this->authorizationCodeId = $authorizationCodeId;
104
    }
105
106
    public function withClaimsLocales(string $claimsLocales): void
107
    {
108
        $this->claimsLocales = $claimsLocales;
109
    }
110
111
    public function withAuthenticationTime(): void
112
    {
113
        $this->withAuthenticationTime = true;
114
    }
115
116
    public function withScope(string $scope): void
117
    {
118
        $this->scope = $scope;
119
    }
120
121
    public function withRequestedClaims(array $requestedClaims): void
122
    {
123
        $this->requestedClaims = $requestedClaims;
124
    }
125
126
    public function withNonce(string $nonce): void
127
    {
128
        $this->nonce = $nonce;
129
    }
130
131
    public function withExpirationAt(\DateTimeImmutable $expiresAt): void
132
    {
133
        $this->expiresAt = $expiresAt;
134
    }
135
136
    public function withoutAuthenticationTime(): void
137
    {
138
        $this->withAuthenticationTime = false;
139
    }
140
141
    public function withSignature(JWSBuilder $jwsBuilder, JWKSet $signatureKeys, string $signatureAlgorithm): void
142
    {
143
        if (!\in_array($signatureAlgorithm, $jwsBuilder->getSignatureAlgorithmManager()->list(), true)) {
144
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported signature algorithm "%s". Please use one of the following one: %s', $signatureAlgorithm, \implode(', ', $jwsBuilder->getSignatureAlgorithmManager()->list())));
145
        }
146
        if (0 === $signatureKeys->count()) {
147
            throw new \InvalidArgumentException('The signature key set must contain at least one key.');
148
        }
149
        $this->jwsBuilder = $jwsBuilder;
150
        $this->signatureKeys = $signatureKeys;
151
        $this->signatureAlgorithm = $signatureAlgorithm;
152
    }
153
154
    public function withEncryption(JWEBuilder $jweBuilder, string $keyEncryptionAlgorithm, string $contentEncryptionAlgorithm): void
155
    {
156
        if (!\in_array($keyEncryptionAlgorithm, $jweBuilder->getKeyEncryptionAlgorithmManager()->list(), true)) {
157
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported key encryption algorithm "%s". Please use one of the following one: %s', $keyEncryptionAlgorithm, \implode(', ', $jweBuilder->getKeyEncryptionAlgorithmManager()->list())));
158
        }
159
        if (!\in_array($contentEncryptionAlgorithm, $jweBuilder->getContentEncryptionAlgorithmManager()->list(), true)) {
160
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported content encryption algorithm "%s". Please use one of the following one: %s', $contentEncryptionAlgorithm, \implode(', ', $jweBuilder->getContentEncryptionAlgorithmManager()->list())));
161
        }
162
        $this->jweBuilder = $jweBuilder;
163
        $this->keyEncryptionAlgorithm = $keyEncryptionAlgorithm;
164
        $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
165
    }
166
167
    public function build(): string
168
    {
169
        if (null === $this->scope) {
170
            throw new \LogicException('It is mandatory to set the scope.');
171
        }
172
        $data = $this->userinfo->getUserinfo($this->client, $this->user, $this->userAccount, $this->redirectUri, $this->requestedClaims, $this->scope, $this->claimsLocales);
0 ignored issues
show
Documentation introduced by
$this->userAccount is of type object<OAuth2Framework\C...serAccount\UserAccount>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Documentation introduced by
$this->redirectUri is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Unused Code introduced by
The call to UserInfo::getUserinfo() has too many arguments starting with $this->claimsLocales.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
173
        //$data = $this->updateClaimsWithAmrAndAcrInfo($data, $this->userAccount);
0 ignored issues
show
Unused Code Comprehensibility introduced by
63% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
174
        $data = $this->updateClaimsWithAuthenticationTime($data, $this->user, $this->requestedClaims);
0 ignored issues
show
Unused Code introduced by
The call to IdTokenBuilder::updateCl...ithAuthenticationTime() has too many arguments starting with $this->requestedClaims.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
175
        $data = $this->updateClaimsWithNonce($data);
176
        if (null !== $this->signatureAlgorithm) {
177
            $data = $this->updateClaimsWithJwtClaims($data);
178
            $data = $this->updateClaimsWithTokenHash($data);
179
            $data = $this->updateClaimsAudience($data);
180
            $result = $this->computeIdToken($data);
181
        } else {
182
            $result = \Safe\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
183
        }
184
185
        if (null !== $this->keyEncryptionAlgorithm && null !== $this->contentEncryptionAlgorithm) {
186
            $result = $this->tryToEncrypt($this->client, $result);
187
        }
188
189
        return $result;
190
    }
191
192
    private function updateClaimsWithJwtClaims(array $claims): array
193
    {
194
        if (null === $this->expiresAt) {
195
            $this->expiresAt = (new \DateTimeImmutable())->setTimestamp(\time() + $this->lifetime);
196
        }
197
        $claims += [
198
            'iat' => \time(),
199
            'nbf' => \time(),
200
            'exp' => $this->expiresAt->getTimestamp(),
201
            'jti' => Base64Url::encode(\random_bytes(16)),
202
            'iss' => $this->issuer,
203
        ];
204
205
        return $claims;
206
    }
207
208
    private function updateClaimsWithAuthenticationTime(array $claims, array $requestedClaims): array
209
    {
210
        if ((true === $this->withAuthenticationTime || \array_key_exists('auth_time', $requestedClaims)) && null !== $this->userAccount->getLastLoginAt()) {
211
            $claims['auth_time'] = $this->userAccount->getLastLoginAt();
212
        }
213
214
        return $claims;
215
    }
216
217
    private function updateClaimsWithNonce(array $claims): array
218
    {
219
        if (null !== $this->nonce) {
220
            $claims['nonce'] = $this->nonce;
221
        }
222
223
        return $claims;
224
    }
225
226
    private function updateClaimsAudience(array $claims): array
227
    {
228
        $claims['aud'] = [
229
            $this->client->getPublicId()->getValue(),
230
            $this->issuer,
231
        ];
232
        $claims['azp'] = $this->client->getPublicId()->getValue();
233
234
        return $claims;
235
    }
236
237
    private function computeIdToken(array $claims): string
238
    {
239
        $signatureKey = $this->getSignatureKey($this->signatureAlgorithm);
240
        $header = $this->getHeaders($signatureKey, $this->signatureAlgorithm);
241
        $jsonConverter = new StandardConverter();
242
        $claims = $jsonConverter->encode($claims);
243
        $jws = $this->jwsBuilder
244
            ->create()
245
            ->withPayload($claims)
246
            ->addSignature($signatureKey, $header)
247
            ->build();
248
        $serializer = new JwsCompactSerializer($jsonConverter);
249
250
        return $serializer->serialize($jws, 0);
251
    }
252
253
    private function tryToEncrypt(Client $client, string $jwt): string
254
    {
255
        $clientKeySet = $this->getClientKeySet($client);
256
        $keyEncryptionAlgorithm = $this->jweBuilder->getKeyEncryptionAlgorithmManager()->get($this->keyEncryptionAlgorithm);
257
        $encryptionKey = $clientKeySet->selectKey('enc', $keyEncryptionAlgorithm);
258
        if (null === $encryptionKey) {
259
            throw new \InvalidArgumentException('No encryption key available for the client.');
260
        }
261
        $header = [
262
            'typ' => 'JWT',
263
            'jti' => Base64Url::encode(\random_bytes(16)),
264
            'alg' => $this->keyEncryptionAlgorithm,
265
            'enc' => $this->contentEncryptionAlgorithm,
266
        ];
267
        $jwe = $this->jweBuilder
268
            ->create()
269
            ->withPayload($jwt)
270
            ->withSharedProtectedHeader($header)
271
            ->addRecipient($encryptionKey)
272
            ->build();
273
        $jsonConverter = new StandardConverter();
274
        $serializer = new JweCompactSerializer($jsonConverter);
275
276
        return $serializer->serialize($jwe, 0);
277
    }
278
279
    private function getSignatureKey(string $signatureAlgorithm): JWK
280
    {
281
        $keys = $this->signatureKeys;
282
        if ($this->client->has('client_secret')) {
283
            $jwk = JWK::create([
284
                'kty' => 'oct',
285
                'use' => 'sig',
286
                'k' => Base64Url::encode($this->client->get('client_secret')),
287
            ]);
288
            $keys = $keys->with($jwk);
289
        }
290
        $signatureAlgorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
291
        if ('none' === $signatureAlgorithm->name()) {
292
            return JWK::create(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
293
        }
294
        $signatureKey = $keys->selectKey('sig', $signatureAlgorithm);
295
        if (null === $signatureKey) {
296
            throw new \InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
297
        }
298
299
        return $signatureKey;
300
    }
301
302
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
303
    {
304
        $header = [
305
            'typ' => 'JWT',
306
            'alg' => $signatureAlgorithm,
307
        ];
308
        if ($signatureKey->has('kid')) {
309
            $header['kid'] = $signatureKey->get('kid');
310
        }
311
312
        return $header;
313
    }
314
315
    private function updateClaimsWithTokenHash(array $claims): array
316
    {
317
        if ('none' === $this->signatureAlgorithm) {
318
            return $claims;
319
        }
320
        if (null !== $this->accessTokenId) {
321
            $claims['at_hash'] = $this->getHash($this->accessTokenId);
322
        }
323
        if (null !== $this->authorizationCodeId) {
324
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId);
325
        }
326
327
        return $claims;
328
    }
329
330
    private function getHash(TokenId $tokenId): string
331
    {
332
        return Base64Url::encode(\mb_substr(\hash($this->getHashMethod(), $tokenId->getValue(), true), 0, $this->getHashSize(), '8bit'));
333
    }
334
335
    private function getHashMethod(): string
336
    {
337
        $map = [
338
            'HS256' => 'sha256',
339
            'ES256' => 'sha256',
340
            'RS256' => 'sha256',
341
            'PS256' => 'sha256',
342
            'HS384' => 'sha384',
343
            'ES384' => 'sha384',
344
            'RS384' => 'sha384',
345
            'PS384' => 'sha384',
346
            'HS512' => 'sha512',
347
            'ES512' => 'sha512',
348
            'RS512' => 'sha512',
349
            'PS512' => 'sha512',
350
        ];
351
352
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
353
            throw new \InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
354
        }
355
356
        return $map[$this->signatureAlgorithm];
357
    }
358
359
    private function getHashSize(): int
360
    {
361
        $map = [
362
            'HS256' => 16,
363
            'ES256' => 16,
364
            'RS256' => 16,
365
            'PS256' => 16,
366
            'HS384' => 24,
367
            'ES384' => 24,
368
            'RS384' => 24,
369
            'PS384' => 24,
370
            'HS512' => 32,
371
            'ES512' => 32,
372
            'RS512' => 32,
373
            'PS512' => 32,
374
        ];
375
376
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
377
            throw new \InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
378
        }
379
380
        return $map[$this->signatureAlgorithm];
381
    }
382
383
    private function getClientKeySet(Client $client): JWKSet
384
    {
385
        $keyset = JWKSet::createFromKeys([]);
386
        if ($client->has('jwks')) {
387
            $jwks = JWKSet::createFromJson($client->get('jwks'));
388
            foreach ($jwks as $jwk) {
389
                $keyset = $keyset->with($jwk);
390
            }
391
        }
392
        if ($client->has('client_secret')) {
393
            $jwk = JWK::create([
394
                'kty' => 'oct',
395
                'use' => 'enc',
396
                'k' => Base64Url::encode($client->get('client_secret')),
397
            ]);
398
            $keyset = $keyset->with($jwk);
399
        }
400
        if ($client->has('jwks_uri') && null !== $this->jkuFactory) {
401
            $jwksUri = $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
402
            foreach ($jwksUri as $jwk) {
403
                $keyset = $keyset->with($jwk);
404
            }
405
        }
406
407
        if (empty($keyset)) {
408
            throw new \InvalidArgumentException('The client has no key or key set.');
409
        }
410
411
        return $keyset;
412
    }
413
}
414