Failed Conditions
Push — master ( 1312dd...bb342a )
by Florent
04:40
created

IdTokenBuilder::tryToEncrypt()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 25
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 8.8571
c 0
b 0
f 0
cc 2
eloc 20
nc 2
nop 2
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\KeyManagement\JKUFactory;
22
use Jose\Component\Signature\JWSBuilder;
23
use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer;
24
use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer;
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
0 ignored issues
show
Coding Style introduced by
Since you have declared the constructor as private, maybe you should also declare the class as final.
Loading history...
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|null
73
     */
74
    private $scope = null;
75
76
    /**
77
     * @var array
78
     */
79
    private $requestedClaims = [];
80
81
    /**
82
     * @var string|null
83
     */
84
    private $claimsLocales = null;
85
86
    /**
87
     * @var TokenId|null
88
     */
89
    private $accessTokenId = null;
90
91
    /**
92
     * @var TokenId|null
93
     */
94
    private $authorizationCodeId = null;
95
96
    /**
97
     * @var string|null
98
     */
99
    private $nonce = null;
100
101
    /**
102
     * @var bool
103
     */
104
    private $withAuthenticationTime = false;
105
106
    /**
107
     * @var JWSBuilder|null
108
     */
109
    private $jwsBuilder = null;
110
111
    /**
112
     * @var string|null
113
     */
114
    private $signatureAlgorithm = null;
115
116
    /**
117
     * @var JWEBuilder|null
118
     */
119
    private $jweBuilder;
120
121
    /**
122
     * @var string|null
123
     */
124
    private $keyEncryptionAlgorithm = null;
125
126
    /**
127
     * @var string|null
128
     */
129
    private $contentEncryptionAlgorithm = null;
130
131
    /**
132
     * @var \DateTimeImmutable|null
133
     */
134
    private $expiresAt = null;
135
136
    /**
137
     * @var null|JKUFactory
138
     */
139
    private $jkuFactory = null;
140
141
    /**
142
     * @var null|AuthorizationCodeRepository
143
     */
144
    private $authorizationCodeRepository = null;
145
146
    private 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 static function create(string $issuer, UserInfo $userinfo, int $lifetime, Client $client, UserAccount $userAccount, string $redirectUri, ?JKUFactory $jkuFactory, ?AuthorizationCodeRepository $authorizationCodeRepository): self
159
    {
160
        return new self($issuer, $userinfo, $lifetime, $client, $userAccount, $redirectUri, $jkuFactory, $authorizationCodeRepository);
161
    }
162
163
    /**
164
     * @param AccessToken $accessToken
165
     *
166
     * @return IdTokenBuilder
167
     */
168
    public function withAccessToken(AccessToken $accessToken): self
169
    {
170
        $clone = clone $this;
171
        $clone->accessTokenId = $accessToken->getTokenId();
172
        $clone->expiresAt = $accessToken->getExpiresAt();
173
        $clone->scope = $accessToken->hasParameter('scope') ? $accessToken->getParameter('scope') : null;
174
175
        if ($accessToken->hasMetadata('authorization_code_id') && null !== $this->authorizationCodeRepository) {
176
            $authorizationCodeId = AuthorizationCodeId::create($accessToken->getMetadata('authorization_code_id'));
177
            $authorizationCode = $this->authorizationCodeRepository->find($authorizationCodeId);
178
            if (null === $authorizationCode) {
179
                return $clone;
180
            }
181
            $clone->authorizationCodeId = $authorizationCodeId;
182
            $queryParams = $authorizationCode->getQueryParams();
183
            foreach (['nonce' => 'nonce', 'claims_locales' => 'claimsLocales'] as $k => $v) {
184
                if (array_key_exists($k, $queryParams)) {
185
                    $clone->$v = $queryParams[$k];
186
                }
187
            }
188
            $clone->withAuthenticationTime = array_key_exists('max_age', $authorizationCode->getQueryParams());
189
        }
190
191
        return $clone;
192
    }
193
194
    /**
195
     * @param AccessTokenId $accessTokenId
196
     *
197
     * @return IdTokenBuilder
198
     */
199
    public function withAccessTokenId(AccessTokenId $accessTokenId): self
200
    {
201
        $clone = clone $this;
202
        $clone->accessTokenId = $accessTokenId;
203
204
        return $clone;
205
    }
206
207
    /**
208
     * @param AuthorizationCodeId $authorizationCodeId
209
     *
210
     * @return IdTokenBuilder
211
     */
212
    public function withAuthorizationCodeId(AuthorizationCodeId $authorizationCodeId): self
213
    {
214
        $clone = clone $this;
215
        $clone->authorizationCodeId = $authorizationCodeId;
216
217
        return $clone;
218
    }
219
220
    /**
221
     * @param string $claimsLocales
222
     *
223
     * @return IdTokenBuilder
224
     */
225
    public function withClaimsLocales(string $claimsLocales): self
226
    {
227
        $clone = clone $this;
228
        $clone->claimsLocales = $claimsLocales;
229
230
        return $clone;
231
    }
232
233
    /**
234
     * @return IdTokenBuilder
235
     */
236
    public function withAuthenticationTime(): self
237
    {
238
        $clone = clone $this;
239
        $clone->withAuthenticationTime = true;
240
241
        return $clone;
242
    }
243
244
    /**
245
     * @param string $scope
246
     *
247
     * @return IdTokenBuilder
248
     */
249
    public function withScope(string $scope): self
250
    {
251
        $clone = clone $this;
252
        $clone->scope = $scope;
253
254
        return $clone;
255
    }
256
257
    /**
258
     * @param array $requestedClaims
259
     *
260
     * @return IdTokenBuilder
261
     */
262
    public function withRequestedClaims(array $requestedClaims): self
263
    {
264
        $clone = clone $this;
265
        $clone->requestedClaims = $requestedClaims;
266
267
        return $clone;
268
    }
269
270
    /**
271
     * @param string $nonce
272
     *
273
     * @return IdTokenBuilder
274
     */
275
    public function withNonce(string $nonce): self
276
    {
277
        $clone = clone $this;
278
        $clone->nonce = $nonce;
279
280
        return $clone;
281
    }
282
283
    /**
284
     * @param \DateTimeImmutable $expiresAt
285
     *
286
     * @return IdTokenBuilder
287
     */
288
    public function withExpirationAt(\DateTimeImmutable $expiresAt): self
289
    {
290
        $clone = clone $this;
291
        $clone->expiresAt = $expiresAt;
292
293
        return $clone;
294
    }
295
296
    /**
297
     * @return IdTokenBuilder
298
     */
299
    public function withoutAuthenticationTime(): self
300
    {
301
        $clone = clone $this;
302
        $clone->withAuthenticationTime = false;
303
304
        return $clone;
305
    }
306
307
    /**
308
     * @param JWSBuilder $jwsBuilder
309
     * @param JWKSet     $signatureKeys
310
     * @param string     $signatureAlgorithm
311
     *
312
     * @return IdTokenBuilder
313
     */
314
    public function withSignature(JWSBuilder $jwsBuilder, JWKSet $signatureKeys, string $signatureAlgorithm): self
315
    {
316
        if (!in_array($signatureAlgorithm, $jwsBuilder->getSignatureAlgorithmManager()->list())) {
317
            throw new \InvalidArgumentException(sprintf('Unsupported signature algorithm "%s". Please use one of the following one: %s', $signatureAlgorithm, implode(', ', $jwsBuilder->getSignatureAlgorithmManager()->list())));
318
        }
319
        if (0 === $signatureKeys->count()) {
320
            throw new \InvalidArgumentException('The signature key set must contain at least one key.');
321
        }
322
        $clone = clone $this;
323
        $clone->jwsBuilder = $jwsBuilder;
324
        $clone->signatureKeys = $signatureKeys;
325
        $clone->signatureAlgorithm = $signatureAlgorithm;
326
327
        return $clone;
328
    }
329
330
    /**
331
     * @param JWEBuilder $jweBuilder
332
     * @param string     $keyEncryptionAlgorithm
333
     * @param string     $contentEncryptionAlgorithm
334
     *
335
     * @return IdTokenBuilder
336
     */
337
    public function withEncryption(JWEBuilder $jweBuilder, string $keyEncryptionAlgorithm, string $contentEncryptionAlgorithm): self
338
    {
339
        if (!in_array($keyEncryptionAlgorithm, $jweBuilder->getKeyEncryptionAlgorithmManager()->list())) {
340
            throw new \InvalidArgumentException(sprintf('Unsupported key encryption algorithm "%s". Please use one of the following one: %s', $keyEncryptionAlgorithm, implode(', ', $jweBuilder->getKeyEncryptionAlgorithmManager()->list())));
341
        }
342
        if (!in_array($contentEncryptionAlgorithm, $jweBuilder->getContentEncryptionAlgorithmManager()->list())) {
343
            throw new \InvalidArgumentException(sprintf('Unsupported content encryption algorithm "%s". Please use one of the following one: %s', $contentEncryptionAlgorithm, implode(', ', $jweBuilder->getContentEncryptionAlgorithmManager()->list())));
344
        }
345
        $clone = clone $this;
346
        $clone->jweBuilder = $jweBuilder;
347
        $clone->keyEncryptionAlgorithm = $keyEncryptionAlgorithm;
348
        $clone->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
349
350
        return $clone;
351
    }
352
353
    /**
354
     * @return string
355
     */
356
    public function build(): string
357
    {
358
        if (null === $this->scope) {
359
            throw new \LogicException('It is mandatory to set the scope.');
360
        }
361
        $data = $this->userinfo->getUserinfo($this->client, $this->userAccount, $this->redirectUri, $this->requestedClaims, $this->scope, $this->claimsLocales);
362
        //$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...
363
        //$data = $this->updateClaimsWithAuthenticationTime($data, $this->userAccount, $this->requestedClaims);
0 ignored issues
show
Unused Code Comprehensibility introduced by
62% 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...
364
        $data = $this->updateClaimsWithNonce($data);
365
        if (null !== $this->signatureAlgorithm) {
366
            $data = $this->updateClaimsWithJwtClaims($data);
367
            $data = $this->updateClaimsWithTokenHash($data);
368
            $data = $this->updateClaimsAudience($data);
369
            $result = $this->computeIdToken($data);
370
        } else {
371
            $result = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
372
        }
373
374
        if (null !== $this->keyEncryptionAlgorithm && null !== $this->contentEncryptionAlgorithm) {
375
            $result = $this->tryToEncrypt($this->client, $result);
376
        }
377
378
        return $result;
379
    }
380
381
    private function updateClaimsWithJwtClaims(array $claims): array
382
    {
383
        if (null === $this->expiresAt) {
384
            $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 object<DateTimeImmutable>|null. 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...
385
        }
386
        $claims += [
387
            'iat' => time(),
388
            'nbf' => time(),
389
            'exp' => $this->expiresAt->getTimestamp(),
390
            'jti' => Base64Url::encode(random_bytes(16)),
391
            'iss' => $this->issuer,
392
        ];
393
394
        return $claims;
395
    }
396
397
    /**
398
     * @param array       $claims
399
     * @param UserAccount $userAccount
400
     *
401
     * @return array
402
     */
403
    private function updateClaimsWithAuthenticationTime(array $claims, UserAccount $userAccount, array $requestedClaims): array
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
404
    {
405
        if ((true === $this->withAuthenticationTime || array_key_exists('auth_time', $requestedClaims)) && null !== $userAccount->getLastLoginAt()) {
406
            $claims['auth_time'] = $userAccount->getLastLoginAt();
407
        }
408
409
        return $claims;
410
    }
411
412
    private function updateClaimsWithNonce(array $claims): array
413
    {
414
        if (null !== $this->nonce) {
415
            $claims['nonce'] = $this->nonce;
416
        }
417
418
        return $claims;
419
    }
420
421
    private function updateClaimsAudience(array $claims): array
422
    {
423
        $claims['aud'] = [
424
            $this->client->getPublicId()->getValue(),
425
            $this->issuer,
426
        ];
427
        $claims['azp'] = $this->client->getPublicId()->getValue();
428
429
        return $claims;
430
    }
431
432
    private function computeIdToken(array $claims): string
433
    {
434
        $signatureKey = $this->getSignatureKey($this->signatureAlgorithm);
435
        $header = $this->getHeaders($signatureKey, $this->signatureAlgorithm);
436
        $jsonConverter = new StandardConverter();
437
        $claims = $jsonConverter->encode($claims);
438
        $jws = $this->jwsBuilder
439
            ->create()
440
            ->withPayload($claims)
441
            ->addSignature($signatureKey, $header)
442
            ->build();
443
        $serializer = new JwsCompactSerializer($jsonConverter);
444
445
        return $serializer->serialize($jws, 0);
446
    }
447
448
    private function tryToEncrypt(Client $client, string $jwt): string
449
    {
450
        $clientKeySet = $this->getClientKeySet($client);
451
        $keyEncryptionAlgorithm = $this->jweBuilder->getKeyEncryptionAlgorithmManager()->get($this->keyEncryptionAlgorithm);
452
        $encryptionKey = $clientKeySet->selectKey('enc', $keyEncryptionAlgorithm);
453
        if (null === $encryptionKey) {
454
            throw new \InvalidArgumentException('No encryption key available for the client.');
455
        }
456
        $header = [
457
            'typ' => 'JWT',
458
            'jti' => Base64Url::encode(random_bytes(16)),
459
            'alg' => $this->keyEncryptionAlgorithm,
460
            'enc' => $this->contentEncryptionAlgorithm,
461
        ];
462
        $jwe = $this->jweBuilder
463
            ->create()
464
            ->withPayload($jwt)
465
            ->withSharedProtectedHeader($header)
466
            ->addRecipient($encryptionKey)
467
            ->build();
468
        $jsonConverter = new StandardConverter();
469
        $serializer = new JweCompactSerializer($jsonConverter);
470
471
        return $serializer->serialize($jwe, 0);
472
    }
473
474
    /**
475
     * @param string $signatureAlgorithm
476
     *
477
     * @return JWK
478
     */
479
    private function getSignatureKey(string $signatureAlgorithm): JWK
480
    {
481
        $keys = $this->signatureKeys;
482
        if ($this->client->has('client_secret')) {
483
            $jwk = JWK::create([
484
                'kty' => 'oct',
485
                'use' => 'sig',
486
                'k' => Base64Url::encode($this->client->get('client_secret')),
487
            ]);
488
            $keys = $keys->with($jwk);
489
        }
490
        $signatureAlgorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
491
        if ('none' === $signatureAlgorithm->name()) {
492
            return JWK::create(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
493
        }
494
        $signatureKey = $keys->selectKey('sig', $signatureAlgorithm);
495
        if (null === $signatureKey) {
496
            throw new \InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
497
        }
498
499
        return $signatureKey;
500
    }
501
502
    /**
503
     * @param JWK    $signatureKey
504
     * @param string $signatureAlgorithm
505
     *
506
     * @return array
507
     */
508
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
509
    {
510
        $header = [
511
            'typ' => 'JWT',
512
            'alg' => $signatureAlgorithm,
513
        ];
514
        if ($signatureKey->has('kid')) {
515
            $header['kid'] = $signatureKey->get('kid');
516
        }
517
518
        return $header;
519
    }
520
521
    /**
522
     * @param array $claims
523
     *
524
     * @return array
525
     */
526
    private function updateClaimsWithTokenHash(array $claims): array
527
    {
528
        if ('none' === $this->signatureAlgorithm) {
529
            return $claims;
530
        }
531
        if (null !== $this->accessTokenId) {
532
            $claims['at_hash'] = $this->getHash($this->accessTokenId);
533
        }
534
        if (null !== $this->authorizationCodeId) {
535
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId);
536
        }
537
538
        return $claims;
539
    }
540
541
    /**
542
     * @param TokenId $tokenId
543
     *
544
     * @return string
545
     */
546
    private function getHash(TokenId $tokenId): string
547
    {
548
        return Base64Url::encode(mb_substr(hash($this->getHashMethod(), $tokenId->getValue(), true), 0, $this->getHashSize(), '8bit'));
549
    }
550
551
    /**
552
     * @throws \InvalidArgumentException
553
     *
554
     * @return string
555
     */
556
    private function getHashMethod(): string
557
    {
558
        $map = [
559
            'HS256' => 'sha256',
560
            'ES256' => 'sha256',
561
            'RS256' => 'sha256',
562
            'PS256' => 'sha256',
563
            'HS384' => 'sha384',
564
            'ES384' => 'sha384',
565
            'RS384' => 'sha384',
566
            'PS384' => 'sha384',
567
            'HS512' => 'sha512',
568
            'ES512' => 'sha512',
569
            'RS512' => 'sha512',
570
            'PS512' => 'sha512',
571
        ];
572
573
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
574
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
575
        }
576
577
        return $map[$this->signatureAlgorithm];
578
    }
579
580
    /**
581
     * @throws \InvalidArgumentException
582
     *
583
     * @return int
584
     */
585
    private function getHashSize(): int
586
    {
587
        $map = [
588
            'HS256' => 16,
589
            'ES256' => 16,
590
            'RS256' => 16,
591
            'PS256' => 16,
592
            'HS384' => 24,
593
            'ES384' => 24,
594
            'RS384' => 24,
595
            'PS384' => 24,
596
            'HS512' => 32,
597
            'ES512' => 32,
598
            'RS512' => 32,
599
            'PS512' => 32,
600
        ];
601
602
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
603
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
604
        }
605
606
        return $map[$this->signatureAlgorithm];
607
    }
608
609
    /**
610
     * @param Client $client
611
     *
612
     * @return JWKSet
613
     */
614
    private function getClientKeySet(Client $client): JWKSet
615
    {
616
        $keyset = JWKSet::createFromKeys([]);
617
        if ($client->has('jwks')) {
618
            $jwks = JWKSet::createFromJson($client->get('jwks'));
619
            foreach ($jwks as $jwk) {
620
                $keyset = $keyset->with($jwk);
621
            }
622
        }
623
        if ($client->has('client_secret')) {
624
            $jwk = JWK::create([
625
                'kty' => 'oct',
626
                'use' => 'enc',
627
                'k' => Base64Url::encode($client->get('client_secret')),
628
            ]);
629
            $keyset = $keyset->with($jwk);
630
        }
631
        if ($client->has('jwks_uri') && null !== $this->jkuFactory) {
632
            $jwks_uri = $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
633
            foreach ($jwks_uri as $jwk) {
634
                $keyset = $keyset->with($jwk);
635
            }
636
        }
637
638
        if (empty($keyset)) {
639
            throw new \InvalidArgumentException('The client has no key or key set.');
640
        }
641
642
        return $keyset;
643
    }
644
}
645