Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 38 additions & 37 deletions src/SDK/Token/Extractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>
*/
public function getClaims(string $sessionToken): array
{
$parts = $this->parseToken($sessionToken);
return $parts['payload'] ?? [];
return $this->validateJWT($sessionToken);
Comment thread
omercnet marked this conversation as resolved.
}

/**
* 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) {
Expand Down Expand Up @@ -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');
Expand Down
100 changes: 100 additions & 0 deletions src/tests/DescopeSDKTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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), '+/', '-_'), '=');
}
}