Failed Conditions
Push — master ( 098fad...8b66f0 )
by Florent
18:51 queued 13:55
created

IdTokenBuilder::getHashSize()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 23
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 23
rs 9.0856
c 0
b 0
f 0
cc 2
eloc 17
nc 2
nop 0
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);
363
        $data = $this->updateClaimsWithAuthenticationTime($data, $this->userAccount);
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
    /**
382
     * @param array $claims
383
     *
384
     * @return array
385
     */
386
    private function updateClaimsWithJwtClaims(array $claims): array
387
    {
388
        if (null === $this->expiresAt) {
389
            $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...
390
        }
391
        $claims += [
392
            'iat' => time(),
393
            'nbf' => time(),
394
            'exp' => $this->expiresAt->getTimestamp(),
395
            'jti' => Base64Url::encode(random_bytes(16)),
396
            'iss' => $this->issuer,
397
        ];
398
399
        return $claims;
400
    }
401
402
    /**
403
     * @param array       $claims
404
     * @param UserAccount $userAccount
405
     *
406
     * @return array
407
     */
408
    private function updateClaimsWithAuthenticationTime(array $claims, UserAccount $userAccount): array
409
    {
410
        if (true === $this->withAuthenticationTime && null !== $userAccount->getLastLoginAt()) { //FIXME: check if the client has a require_auth_time parameter
411
            $claims['auth_time'] = $userAccount->getLastLoginAt();
412
        }
413
414
        return $claims;
415
    }
416
417
    /**
418
     * @param array $claims
419
     *
420
     * @return array
421
     */
422
    private function updateClaimsWithNonce(array $claims): array
423
    {
424
        if (null !== $this->nonce) {
425
            $claims['nonce'] = $this->nonce;
426
        }
427
428
        return $claims;
429
    }
430
431
    /**
432
     * @param array $claims
433
     *
434
     * @return array
435
     */
436
    private function updateClaimsAudience(array $claims): array
437
    {
438
        $claims['aud'] = [
439
            $this->client->getPublicId()->getValue(),
440
            $this->issuer,
441
        ];
442
        $claims['azp'] = $this->client->getPublicId()->getValue();
443
444
        return $claims;
445
    }
446
447
    /**
448
     * @param array       $claims
449
     * @param UserAccount $userAccount
450
     *
451
     * @return array
452
     */
453
    private function updateClaimsWithAmrAndAcrInfo(array $claims, UserAccount $userAccount): array
454
    {
455
        foreach (['amr' => 'amr', 'acr' => 'acr'] as $claim => $key) {
456
            if ($userAccount->has($claim)) {
457
                $claims[$key] = $userAccount->get($claim);
458
            }
459
        }
460
461
        return $claims;
462
    }
463
464
    /**
465
     * @param array $claims
466
     *
467
     * @return string
468
     */
469
    private function computeIdToken(array $claims): string
470
    {
471
        $signatureKey = $this->getSignatureKey($this->signatureAlgorithm);
472
        $header = $this->getHeaders($signatureKey, $this->signatureAlgorithm);
473
        $jsonConverter = new StandardConverter();
474
        $claims = $jsonConverter->encode($claims);
475
        $jws = $this->jwsBuilder
476
            ->create()
477
            ->withPayload($claims)
478
            ->addSignature($signatureKey, $header)
479
            ->build();
480
        $serializer = new JwsCompactSerializer($jsonConverter);
481
482
        return $serializer->serialize($jws, 0);
483
    }
484
485
    /**
486
     * @param Client $client
487
     * @param string $jwt
488
     *
489
     * @return string
490
     */
491
    private function tryToEncrypt(Client $client, string $jwt): string
492
    {
493
        $clientKeySet = $this->getClientKeySet($client);
494
        $keyEncryptionAlgorithm = $this->jweBuilder->getKeyEncryptionAlgorithmManager()->get($this->keyEncryptionAlgorithm);
495
        $encryptionKey = $clientKeySet->selectKey('enc', $keyEncryptionAlgorithm);
496
        if (null === $encryptionKey) {
497
            throw new \InvalidArgumentException('No encryption key available for the client.');
498
        }
499
        $header = [
500
            'typ' => 'JWT',
501
            'jti' => Base64Url::encode(random_bytes(16)),
502
            'alg' => $this->keyEncryptionAlgorithm,
503
            'enc' => $this->contentEncryptionAlgorithm,
504
        ];
505
        $jwe = $this->jweBuilder
506
            ->create()
507
            ->withPayload($jwt)
508
            ->withSharedProtectedHeader($header)
509
            ->addRecipient($encryptionKey)
510
            ->build();
511
        $jsonConverter = new StandardConverter();
512
        $serializer = new JweCompactSerializer($jsonConverter);
513
514
        return $serializer->serialize($jwe, 0);
515
    }
516
517
    /**
518
     * @param string $signatureAlgorithm
519
     *
520
     * @return JWK
521
     */
522
    private function getSignatureKey(string $signatureAlgorithm): JWK
523
    {
524
        $keys = $this->signatureKeys;
525
        if ($this->client->has('client_secret')) {
526
            $jwk = JWK::create([
527
                'kty' => 'oct',
528
                'use' => 'sig',
529
                'k' => Base64Url::encode($this->client->get('client_secret')),
530
            ]);
531
            $keys = $keys->with($jwk);
532
        }
533
        $signatureAlgorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
534
        if ('none' === $signatureAlgorithm->name()) {
535
            return JWK::create(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
536
        }
537
        $signatureKey = $keys->selectKey('sig', $signatureAlgorithm);
538
        if (null === $signatureKey) {
539
            throw new \InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
540
        }
541
542
        return $signatureKey;
543
    }
544
545
    /**
546
     * @param JWK    $signatureKey
547
     * @param string $signatureAlgorithm
548
     *
549
     * @return array
550
     */
551
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
552
    {
553
        $header = [
554
            'typ' => 'JWT',
555
            'alg' => $signatureAlgorithm,
556
        ];
557
        if ($signatureKey->has('kid')) {
558
            $header['kid'] = $signatureKey->get('kid');
559
        }
560
561
        return $header;
562
    }
563
564
    /**
565
     * @param array $claims
566
     *
567
     * @return array
568
     */
569
    private function updateClaimsWithTokenHash(array $claims): array
570
    {
571
        if ('none' === $this->signatureAlgorithm) {
572
            return $claims;
573
        }
574
        if (null !== $this->accessTokenId) {
575
            $claims['at_hash'] = $this->getHash($this->accessTokenId);
576
        }
577
        if (null !== $this->authorizationCodeId) {
578
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId);
579
        }
580
581
        return $claims;
582
    }
583
584
    /**
585
     * @param TokenId $tokenId
586
     *
587
     * @return string
588
     */
589
    private function getHash(TokenId $tokenId): string
590
    {
591
        return Base64Url::encode(mb_substr(hash($this->getHashMethod(), $tokenId->getValue(), true), 0, $this->getHashSize(), '8bit'));
592
    }
593
594
    /**
595
     * @throws \InvalidArgumentException
596
     *
597
     * @return string
598
     */
599
    private function getHashMethod(): string
600
    {
601
        $map = [
602
            'HS256' => 'sha256',
603
            'ES256' => 'sha256',
604
            'RS256' => 'sha256',
605
            'PS256' => 'sha256',
606
            'HS384' => 'sha384',
607
            'ES384' => 'sha384',
608
            'RS384' => 'sha384',
609
            'PS384' => 'sha384',
610
            'HS512' => 'sha512',
611
            'ES512' => 'sha512',
612
            'RS512' => 'sha512',
613
            'PS512' => 'sha512',
614
        ];
615
616
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
617
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
618
        }
619
620
        return $map[$this->signatureAlgorithm];
621
    }
622
623
    /**
624
     * @throws \InvalidArgumentException
625
     *
626
     * @return int
627
     */
628
    private function getHashSize(): int
629
    {
630
        $map = [
631
            'HS256' => 16,
632
            'ES256' => 16,
633
            'RS256' => 16,
634
            'PS256' => 16,
635
            'HS384' => 24,
636
            'ES384' => 24,
637
            'RS384' => 24,
638
            'PS384' => 24,
639
            'HS512' => 32,
640
            'ES512' => 32,
641
            'RS512' => 32,
642
            'PS512' => 32,
643
        ];
644
645
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
646
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
647
        }
648
649
        return $map[$this->signatureAlgorithm];
650
    }
651
652
    /**
653
     * @param Client $client
654
     *
655
     * @return JWKSet
656
     */
657
    private function getClientKeySet(Client $client): JWKSet
658
    {
659
        $keyset = JWKSet::createFromKeys([]);
660
        if ($client->has('jwks')) {
661
            $jwks = JWKSet::createFromJson($client->get('jwks'));
662
            foreach ($jwks as $jwk) {
663
                $keyset = $keyset->with($jwk);
664
            }
665
        }
666
        if ($client->has('client_secret')) {
667
            $jwk = JWK::create([
668
                'kty' => 'oct',
669
                'use' => 'enc',
670
                'k' => Base64Url::encode($client->get('client_secret')),
671
            ]);
672
            $keyset = $keyset->with($jwk);
673
        }
674
        if ($client->has('jwks_uri') && null !== $this->jkuFactory) {
675
            if ($client->has('jwks')) {
676
                $jwks = $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
677
                foreach ($jwks as $jwk) {
678
                    $keyset = $keyset->with($jwk);
679
                }
680
            }
681
        }
682
683
        if (empty($keyset)) {
684
            throw new \InvalidArgumentException('The client has no key or key set.');
685
        }
686
687
        return $keyset;
688
    }
689
}
690