diff --git a/README.md b/README.md index 65caf7a..6ffc057 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 396bc4b..95ad0dc 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,50 @@ 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 - ); - - if (!$signatureValid) { - throw new TokenException('Invalid signature'); - } + $publicKeyPEM = $this->convertJWKToPEM($matchingKey); + $signatureValid = $this->verifySignature( + $jwt['raw']['header'] . '.' . $jwt['raw']['payload'], + $jwt['signature'], + $publicKeyPEM + ); - $this->assertIssuerMatchesProject($jwt['payload']); + if (!$signatureValid) { + throw new TokenException('Invalid signature'); + } - return $jwt['payload']; - } catch (TokenException $e) { - if ($useRefreshedKey) { - throw new TokenException('JWT validation failed after retry: ' . $e->getMessage()); - } - $useRefreshedKey = true; + if (isset($jwt['payload']['exp']) && time() > $jwt['payload']['exp']) { + throw new TokenException('Token has expired'); } + + $this->assertIssuerMatchesProject($jwt['payload']); + + return $jwt['payload']; } while ($useRefreshedKey); throw new TokenException('JWT validation failed'); diff --git a/src/tests/DescopeSDKTest.php b/src/tests/DescopeSDKTest.php index aa5b290..5b5cd96 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,33 @@ public function testVerifyThrowsExceptionWithoutToken() $this->sdk->verify(null); } + public function testGetClaimsRejectsForgedTokenClaims() + { + $extractor = $this->extractorWithTestKey($this->privateKey()); + $token = $this->jwt( + ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => 'legit-key'], + ['iss' => 'test_project_id', 'sub' => 'attacker', 'roles' => ['admin'], 'exp' => time() + 3600], + 'not_a_real_rsa_signature' + ); + + $this->expectException(TokenException::class); + $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); @@ -132,4 +163,73 @@ 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 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), '+/', '-_'), '='); + } }