Skip to content

Commit 689af0d

Browse files
committed
[Security] OAuth2 Introspection Endpoint (RFC7662)
In addition to the excellent work of @vincentchalamon #48272, this PR allows getting the data from the OAuth2 Introspection Endpoint. This endpoint is defined in the [RFC7662](https://datatracker.ietf.org/doc/html/rfc7662). It returns the following information that is used to retrieve the user: * If the access token is active * A set of claims that are similar to the OIDC one, including the `sub` or the `username`.
1 parent 1258445 commit 689af0d

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)