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); + } +}