Skip to content

Commit 306fd3b

Browse files
committed
feature #57721 [Security][SecurityBundle] Add encryption support to OIDC tokens (Spomky)
This PR was merged into the 7.3 branch. Discussion ---------- [Security][SecurityBundle] Add encryption support to OIDC tokens | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #50441 | License | MIT The changes add encryption support to OpenID Connect (OIDC) tokens in the Symfony Security Bundle. This is useful in making the application more secure. They also ensure the tokens are correctly decrypted and validated before use. Additionally, tests have been expanded to cover these new scenarios. ```yaml security: firewalls: main: pattern: ^/ access_token: token_handler: oidc: ... encryption: enabled: true algorithms: [...] keyset: '{"keys": [{...}]}' ``` Commits ------- 04c53b4bae0 [Security] OAuth2 Introspection Endpoint (RFC7662)
2 parents 9bac2f7 + 689af0d commit 306fd3b

File tree

2 files changed

+114
-35
lines changed

2 files changed

+114
-35
lines changed

AccessToken/Oidc/OidcTokenHandler.php

Lines changed: 109 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@
1717
use Jose\Component\Core\AlgorithmManager;
1818
use Jose\Component\Core\JWK;
1919
use Jose\Component\Core\JWKSet;
20+
use Jose\Component\Encryption\JWEDecrypter;
21+
use Jose\Component\Encryption\JWETokenSupport;
22+
use Jose\Component\Encryption\Serializer\CompactSerializer as JweCompactSerializer;
23+
use Jose\Component\Encryption\Serializer\JWESerializerManager;
2024
use Jose\Component\Signature\JWSTokenSupport;
2125
use Jose\Component\Signature\JWSVerifier;
22-
use Jose\Component\Signature\Serializer\CompactSerializer;
26+
use Jose\Component\Signature\Serializer\CompactSerializer as JwsCompactSerializer;
2327
use Jose\Component\Signature\Serializer\JWSSerializerManager;
2428
use Psr\Clock\ClockInterface;
2529
use Psr\Log\LoggerInterface;
@@ -37,10 +41,13 @@
3741
final class OidcTokenHandler implements AccessTokenHandlerInterface
3842
{
3943
use OidcTrait;
44+
private ?JWKSet $decryptionKeyset = null;
45+
private ?AlgorithmManager $decryptionAlgorithms = null;
46+
private bool $enforceEncryption = false;
4047

4148
public function __construct(
4249
private Algorithm|AlgorithmManager $signatureAlgorithm,
43-
private JWK|JWKSet $jwkset,
50+
private JWK|JWKSet $signatureKeyset,
4451
private string $audience,
4552
private array $issuers,
4653
private string $claim = 'sub',
@@ -51,50 +58,29 @@ public function __construct(
5158
trigger_deprecation('symfony/security-http', '7.1', 'First argument must be instance of %s, %s given.', AlgorithmManager::class, Algorithm::class);
5259
$this->signatureAlgorithm = new AlgorithmManager([$signatureAlgorithm]);
5360
}
54-
if ($jwkset instanceof JWK) {
61+
if ($signatureKeyset instanceof JWK) {
5562
trigger_deprecation('symfony/security-http', '7.1', 'Second argument must be instance of %s, %s given.', JWKSet::class, JWK::class);
56-
$this->jwkset = new JWKSet([$jwkset]);
63+
$this->signatureKeyset = new JWKSet([$signatureKeyset]);
5764
}
5865
}
5966

67+
public function enabledJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $decryptionAlgorithms, bool $enforceEncryption): void
68+
{
69+
$this->decryptionKeyset = $decryptionKeyset;
70+
$this->decryptionAlgorithms = $decryptionAlgorithms;
71+
$this->enforceEncryption = $enforceEncryption;
72+
}
73+
6074
public function getUserBadgeFrom(string $accessToken): UserBadge
6175
{
6276
if (!class_exists(JWSVerifier::class) || !class_exists(Checker\HeaderCheckerManager::class)) {
6377
throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".');
6478
}
6579

6680
try {
67-
// Decode the token
68-
$jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
69-
$serializerManager = new JWSSerializerManager([new CompactSerializer()]);
70-
$jws = $serializerManager->unserialize($accessToken);
71-
$claims = json_decode($jws->getPayload(), true);
72-
73-
// Verify the signature
74-
if (!$jwsVerifier->verifyWithKeySet($jws, $this->jwkset, 0)) {
75-
throw new InvalidSignatureException();
76-
}
77-
78-
// Verify the headers
79-
$headerCheckerManager = new Checker\HeaderCheckerManager([
80-
new Checker\AlgorithmChecker($this->signatureAlgorithm->list()),
81-
], [
82-
new JWSTokenSupport(),
83-
]);
84-
// if this check fails, an InvalidHeaderException is thrown
85-
$headerCheckerManager->check($jws, 0);
86-
87-
// Verify the claims
88-
$checkers = [
89-
new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
90-
new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
91-
new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: false),
92-
new Checker\AudienceChecker($this->audience),
93-
new Checker\IssuerChecker($this->issuers),
94-
];
95-
$claimCheckerManager = new ClaimCheckerManager($checkers);
96-
// if this check fails, an InvalidClaimException is thrown
97-
$claimCheckerManager->check($claims);
81+
$accessToken = $this->decryptIfNeeded($accessToken);
82+
$claims = $this->loadAndVerifyJws($accessToken);
83+
$this->verifyClaims($claims);
9884

9985
if (empty($claims[$this->claim])) {
10086
throw new MissingClaimException(\sprintf('"%s" claim not found.', $this->claim));
@@ -111,4 +97,92 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
11197
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
11298
}
11399
}
100+
101+
private function loadAndVerifyJws(string $accessToken): array
102+
{
103+
// Decode the token
104+
$jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
105+
$serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]);
106+
$jws = $serializerManager->unserialize($accessToken);
107+
108+
// Verify the signature
109+
if (!$jwsVerifier->verifyWithKeySet($jws, $this->signatureKeyset, 0)) {
110+
throw new InvalidSignatureException();
111+
}
112+
113+
$headerCheckerManager = new Checker\HeaderCheckerManager([
114+
new Checker\AlgorithmChecker($this->signatureAlgorithm->list()),
115+
], [
116+
new JWSTokenSupport(),
117+
]);
118+
// if this check fails, an InvalidHeaderException is thrown
119+
$headerCheckerManager->check($jws, 0);
120+
121+
return json_decode($jws->getPayload(), true);
122+
}
123+
124+
private function verifyClaims(array $claims): array
125+
{
126+
// Verify the claims
127+
$checkers = [
128+
new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
129+
new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
130+
new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
131+
new Checker\AudienceChecker($this->audience),
132+
new Checker\IssuerChecker($this->issuers),
133+
];
134+
$claimCheckerManager = new ClaimCheckerManager($checkers);
135+
136+
// if this check fails, an InvalidClaimException is thrown
137+
return $claimCheckerManager->check($claims);
138+
}
139+
140+
private function decryptIfNeeded(string $accessToken): string
141+
{
142+
if (null === $this->decryptionKeyset || null === $this->decryptionAlgorithms) {
143+
$this->logger?->debug('The encrypted tokens (JWE) are not supported. Skipping.');
144+
145+
return $accessToken;
146+
}
147+
148+
$jweHeaderChecker = new Checker\HeaderCheckerManager(
149+
[
150+
new Checker\AlgorithmChecker($this->decryptionAlgorithms->list()),
151+
new Checker\CallableChecker('enc', fn ($value) => \in_array($value, $this->decryptionAlgorithms->list())),
152+
new Checker\CallableChecker('cty', fn ($value) => 'JWT' === $value),
153+
new Checker\IssuedAtChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
154+
new Checker\NotBeforeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
155+
new Checker\ExpirationTimeChecker(clock: $this->clock, allowedTimeDrift: 0, protectedHeaderOnly: true),
156+
],
157+
[new JWETokenSupport()]
158+
);
159+
$jweDecrypter = new JWEDecrypter($this->decryptionAlgorithms, null);
160+
$serializerManager = new JWESerializerManager([new JweCompactSerializer()]);
161+
try {
162+
$jwe = $serializerManager->unserialize($accessToken);
163+
$jweHeaderChecker->check($jwe, 0);
164+
$result = $jweDecrypter->decryptUsingKeySet($jwe, $this->decryptionKeyset, 0);
165+
if (false === $result) {
166+
throw new \RuntimeException('The JWE could not be decrypted.');
167+
}
168+
169+
$payload = $jwe->getPayload();
170+
if (null === $payload) {
171+
throw new \RuntimeException('The JWE payload is empty.');
172+
}
173+
174+
return $payload;
175+
} catch (\InvalidArgumentException|\RuntimeException $e) {
176+
if ($this->enforceEncryption) {
177+
$this->logger?->error('An error occurred while decrypting the token.', [
178+
'error' => $e->getMessage(),
179+
'trace' => $e->getTraceAsString(),
180+
]);
181+
throw new BadCredentialsException('Encrypted token is required.', 0, $e);
182+
}
183+
$this->logger?->debug('The token decryption failed. Skipping as not mandatory.');
184+
185+
return $accessToken;
186+
}
187+
}
114188
}

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add encryption support to `OidcTokenHandler` (JWE)
8+
49
7.2
510
---
611

0 commit comments

Comments
 (0)