Failed Conditions
Push — master ( e962b4...04ef90 )
by Florent
06:44
created

IdTokenBuilder::withRequestedClaims()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 4
nc 1
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
        $signatureAlgorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
536
        if ('none' === $signatureAlgorithm->name()) {
537
            return JWK::create(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
538
        }
539
        $signatureKey = $this->signatureKeys->selectKey('sig', $signatureAlgorithm);
540
        if (null === $signatureKey) {
541
            throw new \InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
542
        }
543
544
        return $signatureKey;
545
    }
546
547
    /**
548
     * @param JWK    $signatureKey
549
     * @param string $signatureAlgorithm
550
     *
551
     * @return array
552
     */
553
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
554
    {
555
        $header = [
556
            'typ' => 'JWT',
557
            'alg' => $signatureAlgorithm,
558
        ];
559
        if ($signatureKey->has('kid')) {
560
            $header['kid'] = $signatureKey->get('kid');
561
        }
562
563
        return $header;
564
    }
565
566
    /**
567
     * @param array $claims
568
     *
569
     * @return array
570
     */
571
    private function updateClaimsWithTokenHash(array $claims): array
572
    {
573
        if ('none' === $this->signatureAlgorithm) {
574
            return $claims;
575
        }
576
        if (null !== $this->accessTokenId) {
577
            $claims['at_hash'] = $this->getHash($this->accessTokenId);
578
        }
579
        if (null !== $this->authorizationCodeId) {
580
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId);
581
        }
582
583
        return $claims;
584
    }
585
586
    /**
587
     * @param TokenId $tokenId
588
     *
589
     * @return string
590
     */
591
    private function getHash(TokenId $tokenId): string
592
    {
593
        return Base64Url::encode(mb_substr(hash($this->getHashMethod(), $tokenId->getValue(), true), 0, $this->getHashSize(), '8bit'));
594
    }
595
596
    /**
597
     * @throws \InvalidArgumentException
598
     *
599
     * @return string
600
     */
601
    private function getHashMethod(): string
602
    {
603
        $map = [
604
            'HS256' => 'sha256',
605
            'ES256' => 'sha256',
606
            'RS256' => 'sha256',
607
            'PS256' => 'sha256',
608
            'HS384' => 'sha384',
609
            'ES384' => 'sha384',
610
            'RS384' => 'sha384',
611
            'PS384' => 'sha384',
612
            'HS512' => 'sha512',
613
            'ES512' => 'sha512',
614
            'RS512' => 'sha512',
615
            'PS512' => 'sha512',
616
        ];
617
618
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
619
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
620
        }
621
622
        return $map[$this->signatureAlgorithm];
623
    }
624
625
    /**
626
     * @throws \InvalidArgumentException
627
     *
628
     * @return int
629
     */
630
    private function getHashSize(): int
631
    {
632
        $map = [
633
            'HS256' => 16,
634
            'ES256' => 16,
635
            'RS256' => 16,
636
            'PS256' => 16,
637
            'HS384' => 24,
638
            'ES384' => 24,
639
            'RS384' => 24,
640
            'PS384' => 24,
641
            'HS512' => 32,
642
            'ES512' => 32,
643
            'RS512' => 32,
644
            'PS512' => 32,
645
        ];
646
647
        if (!array_key_exists($this->signatureAlgorithm, $map)) {
648
            throw new \InvalidArgumentException(sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
649
        }
650
651
        return $map[$this->signatureAlgorithm];
652
    }
653
654
    /**
655
     * @param Client $client
656
     *
657
     * @return JWKSet
658
     */
659
    private function getClientKeySet(Client $client): JWKSet
660
    {
661
        switch (true) {
662
            case $client->has('jwks') && 'private_key_jwt' === $client->getTokenEndpointAuthenticationMethod():
663
                return JWKSet::createFromJson($client->get('jwks'));
664
            case $client->has('client_secret') && 'client_secret_jwt' === $client->getTokenEndpointAuthenticationMethod():
665
                $jwk = JWK::create([
666
                    'kty' => 'oct',
667
                    'use' => 'sig',
668
                    'k' => Base64Url::encode($client->get('client_secret')),
669
                ]);
670
671
                return JWKSet::createFromKeys([$jwk]);
672
            case $client->has('jwks_uri') && 'private_key_jwt' === $client->getTokenEndpointAuthenticationMethod() && null !== $this->jkuFactory:
673
                return $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
674
            default:
675
                throw new \InvalidArgumentException('The client has no key or key set.');
676
        }
677
    }
678
}
679