Failed Conditions
Push — master ( de7238...543e2c )
by Florent
08:24
created

IdTokenBuilder::getSignatureKey()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 22
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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