diff --git a/phpunit.xml b/phpunit.xml
index 886c4380..0e890f1a 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -7,6 +7,8 @@
src/tests/SDKConfigCacheTest.php
src/tests/APIExceptionMappingTest.php
src/tests/APIRetryTest.php
+ src/tests/StaticStateIsolationTest.php
+ src/tests/EndpointsTest.php
\ No newline at end of file
diff --git a/src/SDK/API.php b/src/SDK/API.php
index f1b1b94a..a1b476ed 100644
--- a/src/SDK/API.php
+++ b/src/SDK/API.php
@@ -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 */
@@ -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();
@@ -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;
@@ -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 {
@@ -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) {
@@ -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) {
@@ -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.
*
diff --git a/src/SDK/Configuration/SDKConfig.php b/src/SDK/Configuration/SDKConfig.php
index 8c439165..7898ed9c 100644
--- a/src/SDK/Configuration/SDKConfig.php
+++ b/src/SDK/Configuration/SDKConfig.php
@@ -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;
@@ -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()
]);
diff --git a/src/SDK/DescopeSDK.php b/src/SDK/DescopeSDK.php
index ba384101..85a7836e 100644
--- a/src/SDK/DescopeSDK.php
+++ b/src/SDK/DescopeSDK.php
@@ -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);
diff --git a/src/SDK/EndpointsV1.php b/src/SDK/EndpointsV1.php
index e90bf4b1..5ca71c4e 100644
--- a/src/SDK/EndpointsV1.php
+++ b/src/SDK/EndpointsV1.php
@@ -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.
*
diff --git a/src/SDK/Token/Extractor.php b/src/SDK/Token/Extractor.php
index 4374f7d6..396bc4b2 100644
--- a/src/SDK/Token/Extractor.php
+++ b/src/SDK/Token/Extractor.php
@@ -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) {
@@ -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.
*/
diff --git a/src/tests/StaticStateIsolationTest.php b/src/tests/StaticStateIsolationTest.php
new file mode 100644
index 00000000..e92469da
--- /dev/null
+++ b/src/tests/StaticStateIsolationTest.php
@@ -0,0 +1,81 @@
+ */
+ 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);
+ }
+}