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: 2 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<file>src/tests/SDKConfigCacheTest.php</file>
<file>src/tests/APIExceptionMappingTest.php</file>
<file>src/tests/APIRetryTest.php</file>
<file>src/tests/StaticStateIsolationTest.php</file>
<file>src/tests/EndpointsTest.php</file>
</testsuite>
</testsuites>
</phpunit>
45 changes: 43 additions & 2 deletions src/SDK/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class API
private $httpClient;
private $projectId;
private $managementKey;
private $baseUrl;
private $debug;

/** @var int[] Delays between retries in microseconds: 100ms, 5s, 5s */
Expand All @@ -31,8 +32,9 @@ class API
* @param string $projectId
* @param string|null $managementKey Management key for authentication.
* @param bool|null $debug Enable debug/verbose logging. If null, checks DESCOPE_DEBUG env var.
* @param string|null $baseUrl Optional explicit base URL override (cluster/region).
*/
public function __construct(string $projectId, ?string $managementKey, ?bool $debug = null)
public function __construct(string $projectId, ?string $managementKey, ?bool $debug = null, ?string $baseUrl = null)
{
$this->httpClient = new Client();

Expand All @@ -53,7 +55,8 @@ public function __construct(string $projectId, ?string $managementKey, ?bool $de

$this->projectId = $projectId;
$this->managementKey = $managementKey ?? '';

$this->baseUrl = EndpointsV1::resolveBaseUrl($projectId, $baseUrl);

// Set debug flag from parameter, environment variable, or default to false
if ($debug !== null) {
$this->debug = $debug;
Expand Down Expand Up @@ -111,6 +114,8 @@ public function doPost(string $uri, array $body, ?bool $useManagementKey = false
$authToken = $this->getAuthToken($useManagementKey, '');
}

$this->assertCredentialHost($uri, $authToken);

$body = $this->transformEmptyArraysToObjects($body);
$jsonBody = empty($body) ? '{}' : json_encode($body);
try {
Expand Down Expand Up @@ -159,6 +164,8 @@ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken
$authToken = $this->getAuthToken($useManagementKey);
}

$this->assertCredentialHost($uri, $authToken);

try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $headers) {
Expand Down Expand Up @@ -197,6 +204,8 @@ public function doDelete(string $uri): array
{
$authToken = $this->getAuthToken(true);

$this->assertCredentialHost($uri, $authToken);

try {
$headers = $this->getHeaders($authToken);
$response = $this->executeWithRetry(function () use ($uri, $headers) {
Expand Down Expand Up @@ -306,6 +315,38 @@ private function createExceptionFromRequestException(RequestException $e): Desco
return new AuthException($statusCode, $errorType, $errorMessage, [], $e);
}

/**
* Refuses to send bearer credentials (management key or refresh token) to any host
* other than the instance's configured base URL. The bare project ID is public, so
* requests carrying only the project ID as the auth token are not host-restricted.
*
* @param string $uri Target request URI.
* @param string $authToken Auth token that will be sent as a bearer credential.
* @return void
* @throws AuthException If a credential would be sent to an unexpected host.
*/
private function assertCredentialHost(string $uri, string $authToken): void
{
if ($authToken === $this->projectId) {
return;
}

$target = parse_url($uri);
$base = parse_url($this->baseUrl);

$sameHost = isset($target['host'], $base['host'])
&& strcasecmp($target['host'], $base['host']) === 0
&& ($target['scheme'] ?? '') === ($base['scheme'] ?? '');

if (!$sameHost) {
throw new AuthException(
400,
'ERROR_TYPE_INVALID_ARGUMENT',
'Refusing to send credentials to unexpected host: ' . ($target['host'] ?? 'unknown')
);
}
}

/**
* Generates headers for the HTTP request.
*
Expand Down
3 changes: 1 addition & 2 deletions src/SDK/Configuration/SDKConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use Descope\SDK\EndpointsV1;
use Descope\SDK\EndpointsV2;
use Descope\SDK\API;
use Descope\SDK\Cache\CacheInterface;
use Descope\SDK\Cache\APCuCache;
Expand Down Expand Up @@ -78,7 +77,7 @@ public function getJWKSets(bool $forceRefresh = false): array
private function fetchJWKSets(): array
{
try {
$url = EndpointsV2::getPublicKeyPath() . '/' . $this->projectId;
$url = EndpointsV1::resolveBaseUrl($this->projectId, $this->baseUrl) . '/v2/keys/' . $this->projectId;
$response = $this->client->request('GET', $url, [
'headers' => $this->getSDKHeaders()
]);
Expand Down
2 changes: 1 addition & 1 deletion src/SDK/DescopeSDK.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function __construct(array $config)

// Determine debug flag from config or environment variable
$debug = $config['debug'] ?? null;
$this->api = new API($config['projectId'], $config['managementKey'] ?? '', $debug);
$this->api = new API($config['projectId'], $config['managementKey'] ?? '', $debug, $config['baseUrl'] ?? null);
// If OPTIONAL management key was provided in $config
if (!empty($config['managementKey'])) {
$this->management = new Management($this->api);
Expand Down
22 changes: 22 additions & 0 deletions src/SDK/EndpointsV1.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,28 @@ public static function extractRegionFromProjectId(string $projectId): ?string
return null;
}

/**
* Resolves the base URL for a given project without mutating shared static state.
* Uses the explicit override when provided, otherwise derives it from the project's region.
*
* @param string $projectId The project ID for the Descope project.
* @param string|null $baseUrl Optional explicit base URL override.
* @return string The resolved base URL.
*/
public static function resolveBaseUrl(string $projectId, ?string $baseUrl = null): string
{
if ($baseUrl !== null && $baseUrl !== '') {
return $baseUrl;
}

$region = self::extractRegionFromProjectId($projectId);
$urlPrefix = DEFAULT_URL_PREFIX;
if ($region) {
$urlPrefix .= ".$region";
}
return "$urlPrefix." . DEFAULT_DOMAIN;
}

/**
* Updates the API endpoint paths to reflect the currently set base URL.
*
Expand Down
27 changes: 27 additions & 0 deletions src/SDK/Token/Extractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ public function validateJWT(string $sessionToken): array
throw new TokenException('Invalid signature');
}

$this->assertIssuerMatchesProject($jwt['payload']);

return $jwt['payload'];
} catch (TokenException $e) {
if ($useRefreshedKey) {
Expand All @@ -115,6 +117,31 @@ public function validateJWT(string $sessionToken): array
throw new TokenException('JWT validation failed');
}

/**
* Ensures the token issuer resolves to the project the SDK is configured for.
* Descope issuers are either the bare project ID or a URL whose last path
* segment is the project ID (see API::adjustProperties).
*
* @throws TokenException if the issuer does not match the configured project ID.
*/
private function assertIssuerMatchesProject(array $payload): void
{
$projectId = $this->config->projectId;
if (empty($projectId)) {
return;
}

$issuer = $payload['iss'] ?? '';
if ($issuer === '') {
throw new TokenException('Token is missing issuer claim');
}

$issuerParts = explode('/', $issuer);
if (end($issuerParts) !== $projectId) {
throw new TokenException('Token issuer does not match the configured project ID');
}
}

/**
* Verify JWT signature.
*/
Expand Down
81 changes: 81 additions & 0 deletions src/tests/StaticStateIsolationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace Descope\Tests;

use Descope\SDK\API;
use Descope\SDK\Configuration\SDKConfig;
use Descope\SDK\Exception\AuthException;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

final class StaticStateIsolationTest extends TestCase
{
/** @var array<int,\Psr\Http\Message\RequestInterface> */
private function clientCapturingRequests(array &$container, string $body): Client
{
$mock = new MockHandler([new Response(200, [], $body), new Response(200, [], $body)]);
$stack = HandlerStack::create($mock);
$stack->push(Middleware::history($container));
return new Client(['handler' => $stack]);
}

public function testJWKSHostIsPerInstanceAndNotHijackedByLaterInstance(): void
{
$jwks = json_encode(['keys' => [['kid' => 'k1']]]);

$containerA = [];
$configA = new SDKConfig([
'projectId' => 'projectA',
'baseUrl' => 'https://api.descope.com',
]);
$configA->client = $this->clientCapturingRequests($containerA, $jwks);

// A later instance points at an attacker-controlled host.
$configB = new SDKConfig([
'projectId' => 'projectB',
'baseUrl' => 'https://attacker.example.com',
]);
$containerB = [];
$configB->client = $this->clientCapturingRequests($containerB, $jwks);

// A fetches its JWKS AFTER B was constructed.
$configA->getJWKSets(true);

$this->assertCount(1, $containerA);
$requestedUri = (string) $containerA[0]['request']->getUri();
$this->assertSame('https://api.descope.com/v2/keys/projectA', $requestedUri);
$this->assertStringNotContainsString('attacker.example.com', $requestedUri);
}

public function testCredentialedRequestToForeignHostIsRefused(): void
{
$api = new API('project', 'mgmt-key', false, 'https://api.descope.com');

$this->expectException(AuthException::class);
$this->expectExceptionMessage('Refusing to send credentials to unexpected host');

// A hijacked management path would resolve to a different host.
$api->doDelete('https://attacker.example.com/v1/mgmt/user/delete');
}

public function testProjectIdOnlyRequestToForeignHostIsAllowed(): void
{
// The bare project ID is public, so requests carrying only it are not host-restricted.
$api = new API('project', null, false, 'https://api.descope.com');

$reflection = new ReflectionClass(API::class);
$mock = new MockHandler([new Response(200, [], json_encode(['ok' => true]))]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$prop = $reflection->getProperty('httpClient');
$prop->setAccessible(true);
$prop->setValue($api, $client);

$result = $api->doGet('https://example.com/test', false);
$this->assertSame(['ok' => true], $result);
}
}