Failed Conditions
Push — master ( c6baf0...a3629e )
by Florent
16:19
created

IdTokenBuilder::computeIdToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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