Failed Conditions
Push — master ( 7c3864...930f9b )
by Florent
14:15
created

IdTokenBuilder::getClientKeySet()   B

Complexity

Conditions 8
Paths 16

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 30
rs 8.1954
c 0
b 0
f 0
cc 8
nc 16
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\User\User;
32
use OAuth2Framework\Component\Core\UserAccount\UserAccount;
33
use OAuth2Framework\Component\OpenIdConnect\UserInfo\UserInfo;
34
35
class IdTokenBuilder
36
{
37
    private $issuer;
38
    private $client;
39
    private $user;
40
    private $userAccount;
41
    private $redirectUri;
42
    private $userinfo;
43
    private $signatureKeys;
44
    private $lifetime;
45
    private $scope = null;
46
    private $requestedClaims = [];
47
    private $claimsLocales = null;
48
    private $accessTokenId = null;
49
    private $authorizationCodeId = null;
50
    private $nonce = null;
51
    private $withAuthenticationTime = false;
52
    private $jwsBuilder = null;
53
    private $signatureAlgorithm = null;
54
    private $jweBuilder;
55
    private $keyEncryptionAlgorithm = null;
56
    private $contentEncryptionAlgorithm = null;
57
    private $expiresAt = null;
58
    private $jkuFactory = null;
59
    private $authorizationCodeRepository = null;
60
61
    public function __construct(string $issuer, UserInfo $userinfo, int $lifetime, Client $client, User $user, UserAccount $userAccount, string $redirectUri, ?JKUFactory $jkuFactory, ?AuthorizationCodeRepository $authorizationCodeRepository)
62
    {
63
        $this->issuer = $issuer;
64
        $this->userinfo = $userinfo;
65
        $this->lifetime = $lifetime;
66
        $this->client = $client;
67
        $this->user = $user;
68
        $this->userAccount = $userAccount;
69
        $this->redirectUri = $redirectUri;
70
        $this->jkuFactory = $jkuFactory;
71
        $this->authorizationCodeRepository = $authorizationCodeRepository;
72
    }
73
74
    public function setAccessToken(AccessToken $accessToken): void
75
    {
76
        $this->accessTokenId = $accessToken->getTokenId();
77
        $this->expiresAt = $accessToken->getExpiresAt();
78
        $this->scope = $accessToken->getParameter()->has('scope') ? $accessToken->getParameter()->get('scope') : null;
79
80
        if ($accessToken->getMetadata()->has('authorization_code_id') && null !== $this->authorizationCodeRepository) {
81
            $authorizationCodeId = new AuthorizationCodeId($accessToken->getMetadata()->get('authorization_code_id'));
82
            $authorizationCode = $this->authorizationCodeRepository->find($authorizationCodeId);
83
            if (null === $authorizationCode) {
84
                return;
85
            }
86
            $this->authorizationCodeId = $authorizationCodeId;
87
            $queryParams = $authorizationCode->getQueryParams();
88
            foreach (['nonce' => 'nonce', 'claims_locales' => 'claimsLocales'] as $k => $v) {
89
                if (\array_key_exists($k, $queryParams)) {
90
                    $this->$v = $queryParams[$k];
91
                }
92
            }
93
            $this->withAuthenticationTime = \array_key_exists('max_age', $authorizationCode->getQueryParams());
94
        }
95
    }
96
97
    public function withAccessTokenId(AccessTokenId $accessTokenId): void
98
    {
99
        $this->accessTokenId = $accessTokenId;
100
    }
101
102
    public function withAuthorizationCodeId(AuthorizationCodeId $authorizationCodeId): void
103
    {
104
        $this->authorizationCodeId = $authorizationCodeId;
105
    }
106
107
    public function withClaimsLocales(string $claimsLocales): void
108
    {
109
        $this->claimsLocales = $claimsLocales;
110
    }
111
112
    public function withAuthenticationTime(): void
113
    {
114
        $this->withAuthenticationTime = true;
115
    }
116
117
    public function withScope(string $scope): void
118
    {
119
        $this->scope = $scope;
120
    }
121
122
    public function withRequestedClaims(array $requestedClaims): void
123
    {
124
        $this->requestedClaims = $requestedClaims;
125
    }
126
127
    public function withNonce(string $nonce): void
128
    {
129
        $this->nonce = $nonce;
130
    }
131
132
    public function withExpirationAt(\DateTimeImmutable $expiresAt): void
133
    {
134
        $this->expiresAt = $expiresAt;
135
    }
136
137
    public function withoutAuthenticationTime(): void
138
    {
139
        $this->withAuthenticationTime = false;
140
    }
141
142
    public function withSignature(JWSBuilder $jwsBuilder, JWKSet $signatureKeys, string $signatureAlgorithm): void
143
    {
144
        if (!\in_array($signatureAlgorithm, $jwsBuilder->getSignatureAlgorithmManager()->list(), true)) {
145
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported signature algorithm "%s". Please use one of the following one: %s', $signatureAlgorithm, \implode(', ', $jwsBuilder->getSignatureAlgorithmManager()->list())));
146
        }
147
        if (0 === $signatureKeys->count()) {
148
            throw new \InvalidArgumentException('The signature key set must contain at least one key.');
149
        }
150
        $this->jwsBuilder = $jwsBuilder;
151
        $this->signatureKeys = $signatureKeys;
152
        $this->signatureAlgorithm = $signatureAlgorithm;
153
    }
154
155
    public function withEncryption(JWEBuilder $jweBuilder, string $keyEncryptionAlgorithm, string $contentEncryptionAlgorithm): void
156
    {
157
        if (!\in_array($keyEncryptionAlgorithm, $jweBuilder->getKeyEncryptionAlgorithmManager()->list(), true)) {
158
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported key encryption algorithm "%s". Please use one of the following one: %s', $keyEncryptionAlgorithm, \implode(', ', $jweBuilder->getKeyEncryptionAlgorithmManager()->list())));
159
        }
160
        if (!\in_array($contentEncryptionAlgorithm, $jweBuilder->getContentEncryptionAlgorithmManager()->list(), true)) {
161
            throw new \InvalidArgumentException(\Safe\sprintf('Unsupported content encryption algorithm "%s". Please use one of the following one: %s', $contentEncryptionAlgorithm, \implode(', ', $jweBuilder->getContentEncryptionAlgorithmManager()->list())));
162
        }
163
        $this->jweBuilder = $jweBuilder;
164
        $this->keyEncryptionAlgorithm = $keyEncryptionAlgorithm;
165
        $this->contentEncryptionAlgorithm = $contentEncryptionAlgorithm;
166
    }
167
168
    public function build(): string
169
    {
170
        if (null === $this->scope) {
171
            throw new \LogicException('It is mandatory to set the scope.');
172
        }
173
        $data = $this->userinfo->getUserinfo($this->client, $this->user, $this->userAccount, $this->redirectUri, $this->requestedClaims, $this->scope, $this->claimsLocales);
174
        //$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...
175
        $data = $this->updateClaimsWithAuthenticationTime($data, $this->user, $this->requestedClaims);
176
        $data = $this->updateClaimsWithNonce($data);
177
        if (null !== $this->signatureAlgorithm) {
178
            $data = $this->updateClaimsWithJwtClaims($data);
179
            $data = $this->updateClaimsWithTokenHash($data);
180
            $data = $this->updateClaimsAudience($data);
181
            $result = $this->computeIdToken($data);
182
        } else {
183
            $result = \Safe\json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
184
        }
185
186
        if (null !== $this->keyEncryptionAlgorithm && null !== $this->contentEncryptionAlgorithm) {
187
            $result = $this->tryToEncrypt($this->client, $result);
188
        }
189
190
        return $result;
191
    }
192
193
    private function updateClaimsWithJwtClaims(array $claims): array
194
    {
195
        if (null === $this->expiresAt) {
196
            $this->expiresAt = (new \DateTimeImmutable())->setTimestamp(\time() + $this->lifetime);
197
        }
198
        $claims += [
199
            'iat' => \time(),
200
            'nbf' => \time(),
201
            'exp' => $this->expiresAt->getTimestamp(),
202
            'jti' => Base64Url::encode(\random_bytes(16)),
203
            'iss' => $this->issuer,
204
        ];
205
206
        return $claims;
207
    }
208
209
    private function updateClaimsWithAuthenticationTime(array $claims, User $user, array $requestedClaims): array
210
    {
211
        if ((true === $this->withAuthenticationTime || \array_key_exists('auth_time', $requestedClaims)) && null !== $user->getLastLoginAt()) {
212
            $claims['auth_time'] = $user->getLastLoginAt();
213
        }
214
215
        return $claims;
216
    }
217
218
    private function updateClaimsWithNonce(array $claims): array
219
    {
220
        if (null !== $this->nonce) {
221
            $claims['nonce'] = $this->nonce;
222
        }
223
224
        return $claims;
225
    }
226
227
    private function updateClaimsAudience(array $claims): array
228
    {
229
        $claims['aud'] = [
230
            $this->client->getPublicId()->getValue(),
231
            $this->issuer,
232
        ];
233
        $claims['azp'] = $this->client->getPublicId()->getValue();
234
235
        return $claims;
236
    }
237
238
    private function computeIdToken(array $claims): string
239
    {
240
        $signatureKey = $this->getSignatureKey($this->signatureAlgorithm);
241
        $header = $this->getHeaders($signatureKey, $this->signatureAlgorithm);
242
        $jsonConverter = new StandardConverter();
243
        $claims = $jsonConverter->encode($claims);
244
        $jws = $this->jwsBuilder
245
            ->create()
246
            ->withPayload($claims)
247
            ->addSignature($signatureKey, $header)
248
            ->build();
249
        $serializer = new JwsCompactSerializer($jsonConverter);
250
251
        return $serializer->serialize($jws, 0);
252
    }
253
254
    private function tryToEncrypt(Client $client, string $jwt): string
255
    {
256
        $clientKeySet = $this->getClientKeySet($client);
257
        $keyEncryptionAlgorithm = $this->jweBuilder->getKeyEncryptionAlgorithmManager()->get($this->keyEncryptionAlgorithm);
258
        $encryptionKey = $clientKeySet->selectKey('enc', $keyEncryptionAlgorithm);
259
        if (null === $encryptionKey) {
260
            throw new \InvalidArgumentException('No encryption key available for the client.');
261
        }
262
        $header = [
263
            'typ' => 'JWT',
264
            'jti' => Base64Url::encode(\random_bytes(16)),
265
            'alg' => $this->keyEncryptionAlgorithm,
266
            'enc' => $this->contentEncryptionAlgorithm,
267
        ];
268
        $jwe = $this->jweBuilder
269
            ->create()
270
            ->withPayload($jwt)
271
            ->withSharedProtectedHeader($header)
272
            ->addRecipient($encryptionKey)
273
            ->build();
274
        $jsonConverter = new StandardConverter();
275
        $serializer = new JweCompactSerializer($jsonConverter);
276
277
        return $serializer->serialize($jwe, 0);
278
    }
279
280
    private function getSignatureKey(string $signatureAlgorithm): JWK
281
    {
282
        $keys = $this->signatureKeys;
283
        if ($this->client->has('client_secret')) {
284
            $jwk = JWK::create([
285
                'kty' => 'oct',
286
                'use' => 'sig',
287
                'k' => Base64Url::encode($this->client->get('client_secret')),
288
            ]);
289
            $keys = $keys->with($jwk);
290
        }
291
        $signatureAlgorithm = $this->jwsBuilder->getSignatureAlgorithmManager()->get($signatureAlgorithm);
292
        if ('none' === $signatureAlgorithm->name()) {
293
            return JWK::create(['kty' => 'none', 'alg' => 'none', 'use' => 'sig']);
294
        }
295
        $signatureKey = $keys->selectKey('sig', $signatureAlgorithm);
296
        if (null === $signatureKey) {
297
            throw new \InvalidArgumentException('Unable to find a key to sign the ID Token. Please verify the selected key set contains suitable keys.');
298
        }
299
300
        return $signatureKey;
301
    }
302
303
    private function getHeaders(JWK $signatureKey, string $signatureAlgorithm): array
304
    {
305
        $header = [
306
            'typ' => 'JWT',
307
            'alg' => $signatureAlgorithm,
308
        ];
309
        if ($signatureKey->has('kid')) {
310
            $header['kid'] = $signatureKey->get('kid');
311
        }
312
313
        return $header;
314
    }
315
316
    private function updateClaimsWithTokenHash(array $claims): array
317
    {
318
        if ('none' === $this->signatureAlgorithm) {
319
            return $claims;
320
        }
321
        if (null !== $this->accessTokenId) {
322
            $claims['at_hash'] = $this->getHash($this->accessTokenId);
323
        }
324
        if (null !== $this->authorizationCodeId) {
325
            $claims['c_hash'] = $this->getHash($this->authorizationCodeId);
326
        }
327
328
        return $claims;
329
    }
330
331
    private function getHash(TokenId $tokenId): string
332
    {
333
        return Base64Url::encode(\mb_substr(\hash($this->getHashMethod(), $tokenId->getValue(), true), 0, $this->getHashSize(), '8bit'));
334
    }
335
336
    private function getHashMethod(): string
337
    {
338
        $map = [
339
            'HS256' => 'sha256',
340
            'ES256' => 'sha256',
341
            'RS256' => 'sha256',
342
            'PS256' => 'sha256',
343
            'HS384' => 'sha384',
344
            'ES384' => 'sha384',
345
            'RS384' => 'sha384',
346
            'PS384' => 'sha384',
347
            'HS512' => 'sha512',
348
            'ES512' => 'sha512',
349
            'RS512' => 'sha512',
350
            'PS512' => 'sha512',
351
        ];
352
353
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
354
            throw new \InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
355
        }
356
357
        return $map[$this->signatureAlgorithm];
358
    }
359
360
    private function getHashSize(): int
361
    {
362
        $map = [
363
            'HS256' => 16,
364
            'ES256' => 16,
365
            'RS256' => 16,
366
            'PS256' => 16,
367
            'HS384' => 24,
368
            'ES384' => 24,
369
            'RS384' => 24,
370
            'PS384' => 24,
371
            'HS512' => 32,
372
            'ES512' => 32,
373
            'RS512' => 32,
374
            'PS512' => 32,
375
        ];
376
377
        if (!\array_key_exists($this->signatureAlgorithm, $map)) {
378
            throw new \InvalidArgumentException(\Safe\sprintf('Algorithm "%s" is not supported', $this->signatureAlgorithm));
379
        }
380
381
        return $map[$this->signatureAlgorithm];
382
    }
383
384
    private function getClientKeySet(Client $client): JWKSet
385
    {
386
        $keyset = JWKSet::createFromKeys([]);
387
        if ($client->has('jwks')) {
388
            $jwks = JWKSet::createFromJson($client->get('jwks'));
389
            foreach ($jwks as $jwk) {
390
                $keyset = $keyset->with($jwk);
391
            }
392
        }
393
        if ($client->has('client_secret')) {
394
            $jwk = JWK::create([
395
                'kty' => 'oct',
396
                'use' => 'enc',
397
                'k' => Base64Url::encode($client->get('client_secret')),
398
            ]);
399
            $keyset = $keyset->with($jwk);
400
        }
401
        if ($client->has('jwks_uri') && null !== $this->jkuFactory) {
402
            $jwksUri = $this->jkuFactory->loadFromUrl($client->get('jwks_uri'));
403
            foreach ($jwksUri as $jwk) {
404
                $keyset = $keyset->with($jwk);
405
            }
406
        }
407
408
        if (empty($keyset)) {
409
            throw new \InvalidArgumentException('The client has no key or key set.');
410
        }
411
412
        return $keyset;
413
    }
414
}
415