Failed Conditions
Push — master ( b0e939...893034 )
by Florent
19:07
created

IdTokenBuilder::withNonce()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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