From 6cc03e073f40adab326ed72be93563c40b00f518 Mon Sep 17 00:00:00 2001 From: Omer <639682+omercnet@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:05:27 +0000 Subject: [PATCH 1/2] fix(sdk): verify JWT claims before returning them --- README.md | 2 +- src/SDK/Token/Extractor.php | 71 +++++++++++++++++------------------- src/tests/DescopeSDKTest.php | 53 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 65caf7af..6ffc057b 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ print_r($response); --- -6. `DescopeSDK->getClaims($sessionToken)` - will return all of the claims from the JWT in an array format. +6. `DescopeSDK->getClaims($sessionToken)` - will validate the JWT signature and return all of the verified claims in an array format. 7. `DescopeSDK->getUserDetails($refreshToken)` - will return all of the user information (email, phone, verification status, etc.) using a provided refresh token. ### User Management Functions diff --git a/src/SDK/Token/Extractor.php b/src/SDK/Token/Extractor.php index 396bc4b2..e14760c6 100644 --- a/src/SDK/Token/Extractor.php +++ b/src/SDK/Token/Extractor.php @@ -23,22 +23,21 @@ public function __construct($config) } /** - * Return an array representing the Token's claims. + * Return an array representing the validated Token's claims. * * @return array */ public function getClaims(string $sessionToken): array { - $parts = $this->parseToken($sessionToken); - return $parts['payload'] ?? []; + return $this->validateJWT($sessionToken); } /** - * Parse and validate the JWT token. + * Parse the JWT token structure. * * @throws TokenException if validation fails. */ - public function parseToken(string $sessionToken): array + private function parseToken(string $sessionToken): array { $parts = explode('.', $sessionToken); if (count($parts) !== 3) { @@ -70,48 +69,46 @@ public function parseToken(string $sessionToken): array */ public function validateJWT(string $sessionToken): array { + $jwt = $this->parseToken($sessionToken); + + if (!isset($jwt['header']['kid'])) { + throw new TokenException('Missing key ID in JWT header'); + } + $useRefreshedKey = false; do { - try { - $jwkSet = $this->config->getJWKSets($useRefreshedKey); - $jwt = $this->parseToken($sessionToken); - - if (!isset($jwt['header']['kid'])) { - throw new TokenException('Missing key ID in JWT header'); - } + $jwkSet = $this->config->getJWKSets($useRefreshedKey); - $matchingKey = null; - foreach ($jwkSet['keys'] as $key) { - if ($key['kid'] === $jwt['header']['kid']) { - $matchingKey = $key; - break; - } + $matchingKey = null; + foreach ($jwkSet['keys'] as $key) { + if ($key['kid'] === $jwt['header']['kid']) { + $matchingKey = $key; + break; } + } - if (!$matchingKey) { - throw new TokenException('No matching key found in JWKS'); + if (!$matchingKey) { + if ($useRefreshedKey) { + throw new TokenException('JWT validation failed after retry: No matching key found in JWKS'); } + $useRefreshedKey = true; + continue; + } - $publicKeyPEM = $this->convertJWKToPEM($matchingKey); - $signatureValid = $this->verifySignature( - $jwt['raw']['header'] . '.' . $jwt['raw']['payload'], - $jwt['signature'], - $publicKeyPEM - ); + $publicKeyPEM = $this->convertJWKToPEM($matchingKey); + $signatureValid = $this->verifySignature( + $jwt['raw']['header'] . '.' . $jwt['raw']['payload'], + $jwt['signature'], + $publicKeyPEM + ); - if (!$signatureValid) { - throw new TokenException('Invalid signature'); - } + if (!$signatureValid) { + throw new TokenException('Invalid signature'); + } - $this->assertIssuerMatchesProject($jwt['payload']); + $this->assertIssuerMatchesProject($jwt['payload']); - return $jwt['payload']; - } catch (TokenException $e) { - if ($useRefreshedKey) { - throw new TokenException('JWT validation failed after retry: ' . $e->getMessage()); - } - $useRefreshedKey = true; - } + return $jwt['payload']; } while ($useRefreshedKey); throw new TokenException('JWT validation failed'); diff --git a/src/tests/DescopeSDKTest.php b/src/tests/DescopeSDKTest.php index aa5b290a..61eec0bd 100644 --- a/src/tests/DescopeSDKTest.php +++ b/src/tests/DescopeSDKTest.php @@ -8,9 +8,13 @@ use Descope\SDK\Auth\Password; use Descope\SDK\Auth\SSO; use Descope\SDK\Management\Management; +use Descope\SDK\Cache\CacheInterface; +use Descope\SDK\Configuration\SDKConfig; +use Descope\SDK\Exception\TokenException; use Descope\SDK\Exception\ValidationException; use Descope\SDK\EndpointsV1; use Descope\SDK\EndpointsV2; +use Descope\SDK\Token\Extractor; use Descope\SDK\Management\MgmtV1; final class DescopeSDKTest extends TestCase @@ -40,6 +44,43 @@ public function testVerifyThrowsExceptionWithoutToken() $this->sdk->verify(null); } + public function testGetClaimsRejectsForgedTokenClaims() + { + $cache = new class implements CacheInterface { + public function get(string $key) + { + return [ + 'keys' => [[ + 'kid' => 'legit-key', + 'kty' => 'RSA', + 'n' => 'sXchf9VHkhPcxP3YXyUbKBo1hTvA2gBC2fD31cstjYb9dyG_rMXVNth5f-vY95bkXICnpPxPId2MnCpbne-Yj1FcGj8JM_2v3ERds43Le2psCIfDOtkKw_S01qK2JfUCpyXibSZ9OmUekR74y15I4z6w_sROF1Et1YYAfR8s', + 'e' => 'AQAB' + ]] + ]; + } + + public function set(string $key, $value, int $ttl = 3600): bool + { + return true; + } + + public function delete(string $key): bool + { + return true; + } + }; + + $extractor = new Extractor(new SDKConfig(['projectId' => 'test_project_id'], $cache)); + $token = $this->jwt( + ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => 'legit-key'], + ['sub' => 'attacker', 'roles' => ['admin'], 'exp' => time() + 3600], + 'not_a_real_rsa_signature' + ); + + $this->expectException(TokenException::class); + $extractor->getClaims($token); + } + public function testRefreshSessionThrowsExceptionWithoutToken() { $this->expectException(ValidationException::class); @@ -132,4 +173,16 @@ public function testNullableParameterTypesForMultipleMethods() } } } + + private function jwt(array $header, array $payload, string $signature): string + { + return $this->base64UrlEncode(json_encode($header)) . '.' . + $this->base64UrlEncode(json_encode($payload)) . '.' . + $this->base64UrlEncode($signature); + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } } From d75f37e484d03b39c6f97c6c0a7f72304c81c6bd Mon Sep 17 00:00:00 2001 From: Omer <639682+omercnet@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:22:02 +0000 Subject: [PATCH 2/2] fix(sdk): reject expired getClaims tokens --- src/SDK/Token/Extractor.php | 4 ++ src/tests/DescopeSDKTest.php | 99 ++++++++++++++++++++++++++---------- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/SDK/Token/Extractor.php b/src/SDK/Token/Extractor.php index e14760c6..95ad0dc6 100644 --- a/src/SDK/Token/Extractor.php +++ b/src/SDK/Token/Extractor.php @@ -106,6 +106,10 @@ public function validateJWT(string $sessionToken): array throw new TokenException('Invalid signature'); } + if (isset($jwt['payload']['exp']) && time() > $jwt['payload']['exp']) { + throw new TokenException('Token has expired'); + } + $this->assertIssuerMatchesProject($jwt['payload']); return $jwt['payload']; diff --git a/src/tests/DescopeSDKTest.php b/src/tests/DescopeSDKTest.php index 61eec0bd..5b5cd962 100644 --- a/src/tests/DescopeSDKTest.php +++ b/src/tests/DescopeSDKTest.php @@ -46,34 +46,10 @@ public function testVerifyThrowsExceptionWithoutToken() public function testGetClaimsRejectsForgedTokenClaims() { - $cache = new class implements CacheInterface { - public function get(string $key) - { - return [ - 'keys' => [[ - 'kid' => 'legit-key', - 'kty' => 'RSA', - 'n' => 'sXchf9VHkhPcxP3YXyUbKBo1hTvA2gBC2fD31cstjYb9dyG_rMXVNth5f-vY95bkXICnpPxPId2MnCpbne-Yj1FcGj8JM_2v3ERds43Le2psCIfDOtkKw_S01qK2JfUCpyXibSZ9OmUekR74y15I4z6w_sROF1Et1YYAfR8s', - 'e' => 'AQAB' - ]] - ]; - } - - public function set(string $key, $value, int $ttl = 3600): bool - { - return true; - } - - public function delete(string $key): bool - { - return true; - } - }; - - $extractor = new Extractor(new SDKConfig(['projectId' => 'test_project_id'], $cache)); + $extractor = $this->extractorWithTestKey($this->privateKey()); $token = $this->jwt( ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => 'legit-key'], - ['sub' => 'attacker', 'roles' => ['admin'], 'exp' => time() + 3600], + ['iss' => 'test_project_id', 'sub' => 'attacker', 'roles' => ['admin'], 'exp' => time() + 3600], 'not_a_real_rsa_signature' ); @@ -81,6 +57,20 @@ public function delete(string $key): bool $extractor->getClaims($token); } + public function testGetClaimsRejectsExpiredTokenClaims() + { + $privateKey = $this->privateKey(); + $extractor = $this->extractorWithTestKey($privateKey); + $token = $this->signedJwt( + ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => 'legit-key'], + ['iss' => 'test_project_id', 'sub' => 'user', 'roles' => ['admin'], 'exp' => time() - 1], + $privateKey + ); + + $this->expectException(TokenException::class); + $extractor->getClaims($token); + } + public function testRefreshSessionThrowsExceptionWithoutToken() { $this->expectException(ValidationException::class); @@ -181,6 +171,63 @@ private function jwt(array $header, array $payload, string $signature): string $this->base64UrlEncode($signature); } + private function signedJwt(array $header, array $payload, $privateKey): string + { + $signedData = $this->base64UrlEncode(json_encode($header)) . '.' . + $this->base64UrlEncode(json_encode($payload)); + openssl_sign($signedData, $signature, $privateKey, OPENSSL_ALGO_SHA256); + return $signedData . '.' . $this->base64UrlEncode($signature); + } + + private function extractorWithTestKey($privateKey): Extractor + { + $key = $this->jwkFromPrivateKey($privateKey); + $cache = new class($key) implements CacheInterface { + private $key; + + public function __construct(array $key) + { + $this->key = $key; + } + + public function get(string $key) + { + return ['keys' => [$this->key]]; + } + + public function set(string $key, $value, int $ttl = 3600): bool + { + return true; + } + + public function delete(string $key): bool + { + return true; + } + }; + + return new Extractor(new SDKConfig(['projectId' => 'test_project_id'], $cache)); + } + + private function privateKey() + { + return openssl_pkey_new([ + 'private_key_bits' => 1024, + 'private_key_type' => OPENSSL_KEYTYPE_RSA + ]); + } + + private function jwkFromPrivateKey($privateKey): array + { + $details = openssl_pkey_get_details($privateKey); + return [ + 'kid' => 'legit-key', + 'kty' => 'RSA', + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']) + ]; + } + private function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');