From 7e1483c198b30e2f4128d8952ffec3bc6466c480 Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Tue, 11 Aug 2026 14:59:01 +0300 Subject: [PATCH 1/2] feat: support an auth management key for disabled auth methods --- .env.example | 3 +- README.md | 26 +++ phpunit.xml | 1 + src/SDK/API.php | 18 +- src/SDK/DescopeSDK.php | 3 +- src/tests/APIAuthManagementKeyTest.php | 229 +++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 src/tests/APIAuthManagementKeyTest.php diff --git a/.env.example b/.env.example index 412aec13..b60c68df 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DESCOPE_PROJECT_ID="YOUR_PROJECT_ID" -DESCOPE_MANAGEMENT_KEY="YOUR_MANAGEMENT_KEY" \ No newline at end of file +DESCOPE_MANAGEMENT_KEY="YOUR_MANAGEMENT_KEY" +DESCOPE_AUTH_MANAGEMENT_KEY="YOUR_AUTH_MANAGEMENT_KEY" diff --git a/README.md b/README.md index 04034b38..0430e906 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ You'll need to set up a `.env` file in the root directory with your Descope Proj ``` DESCOPE_PROJECT_ID= DESCOPE_MANAGEMENT_KEY= +# Optional, only needed for authentication methods with disabled public access +DESCOPE_AUTH_MANAGEMENT_KEY= ``` ## Using the SDK @@ -38,11 +40,31 @@ use Descope\SDK\DescopeSDK; $descopeSDK = new DescopeSDK([ 'projectId' => $_ENV['DESCOPE_PROJECT_ID'], 'managementKey' => $_ENV['DESCOPE_MANAGEMENT_KEY'], // Optional, only used for Management functions + 'authManagementKey' => $_ENV['DESCOPE_AUTH_MANAGEMENT_KEY'], // Optional, only needed for authentication methods with disabled public access 'debug' => false, // Optional, enables verbose error logging (default: false) 'requestTimeout' => 60, // Optional, HTTP request timeout in seconds (default: 60) ]); ``` +### Auth Management Key + +Authentication methods whose public access has been disabled can still be used by providing an +auth management key. When set, it is sent along with every authentication request. + +Create one in the [Descope Console](https://app.descope.com/settings/company/managementkeys) with +either the `Authentication` or `Full Access` scope on the project or company. + +```php +$descopeSDK = new DescopeSDK([ + 'projectId' => $_ENV['DESCOPE_PROJECT_ID'], + 'authManagementKey' => $_ENV['DESCOPE_AUTH_MANAGEMENT_KEY'], +]); +``` + +**Note**: the auth management key can, and probably should, be a different management key than the +one provided as `managementKey` for management API usage. The auth management key is never sent on +management requests, and the management key is never sent on authentication requests. + ### HTTP Timeouts Every HTTP call the SDK makes is bounded so a slow or unresponsive network peer @@ -322,6 +344,10 @@ print_r($response); ### User Management Functions +All management functions require a `managementKey`. That key is used only for management functions - +to reach authentication methods whose public access has been disabled, use the +[Auth Management Key](#auth-management-key) instead. + Each of these functions have code examples on how to use them. > Some of these values may be incorrect for your environment, they exist purely as an example for your own implementation. diff --git a/phpunit.xml b/phpunit.xml index 59b32e5b..d681de64 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,7 @@ src/tests/SDKConfigCacheTest.php src/tests/APIExceptionMappingTest.php src/tests/APIRetryTest.php + src/tests/APIAuthManagementKeyTest.php src/tests/APIHttpTimeoutTest.php src/tests/StaticStateIsolationTest.php src/tests/EndpointsTest.php diff --git a/src/SDK/API.php b/src/SDK/API.php index a7f469cf..d2c9dfff 100644 --- a/src/SDK/API.php +++ b/src/SDK/API.php @@ -27,6 +27,7 @@ class API private $httpClient; private $projectId; private $managementKey; + private $authManagementKey; private $baseUrl; private $debug; @@ -43,6 +44,9 @@ class API * @param float|null $requestTimeout Overall request timeout in seconds. Defaults to 60. * @param ClientInterface|null $httpClient Optional pre-configured Guzzle client. When supplied its own * transport options (including timeouts) are respected as-is. + * @param string|null $authManagementKey Management key sent with every authentication request so that + * methods whose public access has been disabled can still be used. + * Never sent on management requests. */ public function __construct( string $projectId, @@ -50,7 +54,8 @@ public function __construct( ?bool $debug = null, ?string $baseUrl = null, ?float $requestTimeout = null, - ?ClientInterface $httpClient = null + ?ClientInterface $httpClient = null, + ?string $authManagementKey = null ) { $clientOptions = [ 'timeout' => $requestTimeout ?? self::DEFAULT_REQUEST_TIMEOUT_SECONDS, @@ -77,6 +82,7 @@ public function __construct( $this->projectId = $projectId; $this->managementKey = $managementKey ?? ''; + $this->authManagementKey = $authManagementKey ?? ''; $this->baseUrl = EndpointsV1::resolveBaseUrl($projectId, $baseUrl); // Set debug flag from parameter, environment variable, or default to false @@ -457,11 +463,15 @@ private function getAuthToken(bool $useManagementKey, ?string $refreshToken = nu return $this->projectId . ':' . $this->managementKey; } - if ($refreshToken) { - return $this->projectId . ':' . $refreshToken; + $parts = [$this->projectId]; + if (!empty($refreshToken)) { + $parts[] = $refreshToken; + } + if (!$useManagementKey && !empty($this->authManagementKey)) { + $parts[] = $this->authManagementKey; } - return $this->projectId; + return implode(':', $parts); } /** diff --git a/src/SDK/DescopeSDK.php b/src/SDK/DescopeSDK.php index f4266649..a5a3987c 100644 --- a/src/SDK/DescopeSDK.php +++ b/src/SDK/DescopeSDK.php @@ -72,7 +72,8 @@ public function __construct(array $config) $debug, $config['baseUrl'] ?? null, $requestTimeout, - $httpClient + $httpClient, + $config['authManagementKey'] ?? '' ); // If OPTIONAL management key was provided in $config if (!empty($config['managementKey'])) { diff --git a/src/tests/APIAuthManagementKeyTest.php b/src/tests/APIAuthManagementKeyTest.php new file mode 100644 index 00000000..d067c1ee --- /dev/null +++ b/src/tests/APIAuthManagementKeyTest.php @@ -0,0 +1,229 @@ +> */ + private $requests = []; + + protected function setUp(): void + { + $this->requests = []; + EndpointsV1::setBaseUrlFromString('https://api.descope.com'); + MgmtV1::setBaseUrlFromString('https://api.descope.com'); + } + + public function testAuthRequestWithoutAuthManagementKey(): void + { + $api = $this->api(null, null); + $api->doPost(EndpointsV1::$SIGN_IN_PASSWORD_PATH, [], false); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID); + } + + public function testAuthRequestWithAuthManagementKey(): void + { + $api = $this->api(null, self::AUTH_MANAGEMENT_KEY); + $api->doPost(EndpointsV1::$SIGN_IN_PASSWORD_PATH, [], false); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID . ':' . self::AUTH_MANAGEMENT_KEY); + } + + public function testAuthRequestWithRefreshTokenAndAuthManagementKey(): void + { + $api = $this->api(null, self::AUTH_MANAGEMENT_KEY); + $api->doPost(EndpointsV1::$REFRESH_TOKEN_PATH, [], false, self::REFRESH_TOKEN); + + $this->assertAuthorization( + 'Bearer ' . self::PROJECT_ID . ':' . self::REFRESH_TOKEN . ':' . self::AUTH_MANAGEMENT_KEY + ); + } + + public function testAuthGetRequestWithRefreshTokenAndAuthManagementKey(): void + { + $api = $this->api(null, self::AUTH_MANAGEMENT_KEY); + $api->doGet(EndpointsV1::$ME_PATH, false, self::REFRESH_TOKEN); + + $this->assertAuthorization( + 'Bearer ' . self::PROJECT_ID . ':' . self::REFRESH_TOKEN . ':' . self::AUTH_MANAGEMENT_KEY + ); + } + + public function testAuthRequestWithRefreshTokenOnly(): void + { + $api = $this->api(null, null); + $api->doPost(EndpointsV1::$REFRESH_TOKEN_PATH, [], false, self::REFRESH_TOKEN); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID . ':' . self::REFRESH_TOKEN); + } + + /** + * An access key is presented in the same position a refresh token is, so it composes with the + * auth management key the same way. Matches the Java and Python SDKs. + */ + public function testAccessKeyExchangeCarriesTheAuthManagementKey(): void + { + $sdk = $this->sdk(self::AUTH_MANAGEMENT_KEY); + $sdk->exchangeAccessKey(self::ACCESS_KEY); + + $this->assertAuthorization( + 'Bearer ' . self::PROJECT_ID . ':' . self::ACCESS_KEY . ':' . self::AUTH_MANAGEMENT_KEY + ); + } + + public function testAccessKeyExchangeWithoutAuthManagementKey(): void + { + $sdk = $this->sdk(null); + $sdk->exchangeAccessKey(self::ACCESS_KEY); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID . ':' . self::ACCESS_KEY); + } + + public function testManagementRequestSendsOnlyTheManagementKey(): void + { + $api = $this->api(self::MANAGEMENT_KEY, self::AUTH_MANAGEMENT_KEY); + $api->doPost(MgmtV1::$USER_LOAD_PATH, [], true); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID . ':' . self::MANAGEMENT_KEY); + } + + public function testManagementDeleteSendsOnlyTheManagementKey(): void + { + $api = $this->api(self::MANAGEMENT_KEY, self::AUTH_MANAGEMENT_KEY); + $api->doDelete(MgmtV1::$USER_DELETE_PATH); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID . ':' . self::MANAGEMENT_KEY); + } + + /** + * A management call made without a management key falls through to the shared bearer + * assembly, and must still not pick up the auth management key. + */ + public function testManagementRequestWithoutManagementKeyDoesNotSendAuthManagementKey(): void + { + $api = $this->api(null, self::AUTH_MANAGEMENT_KEY); + $api->doPost(MgmtV1::$USER_LOAD_PATH, [], true); + + $this->assertAuthorization('Bearer ' . self::PROJECT_ID); + } + + public function testAuthManagementKeyIsWiredFromSdkConfig(): void + { + $sdk = new DescopeSDK([ + 'projectId' => self::PROJECT_ID, + 'authManagementKey' => self::AUTH_MANAGEMENT_KEY, + ]); + + $sdkReflection = new ReflectionClass($sdk); + $apiProperty = $sdkReflection->getProperty('api'); + $apiProperty->setAccessible(true); + $api = $apiProperty->getValue($sdk); + + $apiReflection = new ReflectionClass($api); + $authManagementKeyProperty = $apiReflection->getProperty('authManagementKey'); + $authManagementKeyProperty->setAccessible(true); + + $this->assertSame(self::AUTH_MANAGEMENT_KEY, $authManagementKeyProperty->getValue($api)); + } + + public function testAuthManagementKeyDefaultsToEmptyWhenNotConfigured(): void + { + $sdk = new DescopeSDK(['projectId' => self::PROJECT_ID]); + + $sdkReflection = new ReflectionClass($sdk); + $apiProperty = $sdkReflection->getProperty('api'); + $apiProperty->setAccessible(true); + $api = $apiProperty->getValue($sdk); + + $apiReflection = new ReflectionClass($api); + $authManagementKeyProperty = $apiReflection->getProperty('authManagementKey'); + $authManagementKeyProperty->setAccessible(true); + + $this->assertSame('', $authManagementKeyProperty->getValue($api)); + } + + /** + * Builds an API whose HTTP client records the requests it is handed. + */ + private function api(?string $managementKey, ?string $authManagementKey): API + { + $api = new API( + self::PROJECT_ID, + $managementKey, + false, + 'https://api.descope.com', + null, + null, + $authManagementKey + ); + + $this->captureRequestsOn($api); + + return $api; + } + + /** + * Builds a full SDK whose API records the requests it is handed, so tests can go through the + * public entry points rather than calling the API directly. + */ + private function sdk(?string $authManagementKey): DescopeSDK + { + $config = ['projectId' => self::PROJECT_ID]; + if ($authManagementKey !== null) { + $config['authManagementKey'] = $authManagementKey; + } + + $sdk = new DescopeSDK($config); + + $sdkReflection = new ReflectionClass($sdk); + $apiProperty = $sdkReflection->getProperty('api'); + $apiProperty->setAccessible(true); + $this->captureRequestsOn($apiProperty->getValue($sdk)); + + return $sdk; + } + + /** + * Swaps in an HTTP client that answers with a stub response and records every request. + */ + private function captureRequestsOn(API $api): void + { + $stack = HandlerStack::create(new MockHandler([new Response(200, [], json_encode(['ok' => true]))])); + $stack->push(Middleware::history($this->requests)); + + $reflection = new ReflectionClass(API::class); + $httpClientProp = $reflection->getProperty('httpClient'); + $httpClientProp->setAccessible(true); + $httpClientProp->setValue($api, new Client(['handler' => $stack])); + } + + private function assertAuthorization(string $expected): void + { + $this->assertCount(1, $this->requests); + $this->assertSame($expected, $this->requests[0]['request']->getHeaderLine('Authorization')); + } +} From 9c19ec2d6fd6a5a9c66df57ac5402a4306bba8ae Mon Sep 17 00:00:00 2001 From: Itai Hanski Date: Tue, 11 Aug 2026 17:07:24 +0300 Subject: [PATCH 2/2] fix: respect the management flag when a token is presented --- src/SDK/API.php | 45 +++++++++----------------- src/tests/APIAuthManagementKeyTest.php | 27 ++++++++++++++++ 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/SDK/API.php b/src/SDK/API.php index d2c9dfff..f00bfe3a 100644 --- a/src/SDK/API.php +++ b/src/SDK/API.php @@ -134,13 +134,7 @@ private function transformEmptyArraysToObjects($data) */ public function doPost(string $uri, array $body, ?bool $useManagementKey = false, ?string $refreshToken = null): array { - $authToken = ""; - - if ($refreshToken) { - $authToken = $this->getAuthToken(false, $refreshToken); - } else { - $authToken = $this->getAuthToken($useManagementKey, ''); - } + $authToken = $this->getAuthToken($useManagementKey, $refreshToken); $uri = $this->resolveRequestUrl($uri); @@ -185,13 +179,7 @@ public function doPost(string $uri, array $body, ?bool $useManagementKey = false */ public function doPatch(string $uri, array $body, ?bool $useManagementKey = false, ?string $refreshToken = null): array { - $authToken = ""; - - if ($refreshToken) { - $authToken = $this->getAuthToken(false, $refreshToken); - } else { - $authToken = $this->getAuthToken($useManagementKey, ''); - } + $authToken = $this->getAuthToken($useManagementKey, $refreshToken); $uri = $this->resolveRequestUrl($uri); @@ -235,13 +223,7 @@ public function doPatch(string $uri, array $body, ?bool $useManagementKey = fals */ public function doGet(string $uri, bool $useManagementKey, ?string $refreshToken = null): array { - $authToken = ""; - - if ($refreshToken) { - $authToken = $this->getAuthToken(false, $refreshToken); - } else { - $authToken = $this->getAuthToken($useManagementKey); - } + $authToken = $this->getAuthToken($useManagementKey, $refreshToken); $uri = $this->resolveRequestUrl($uri); @@ -452,23 +434,26 @@ private function getHeaders(string $authToken): array } /** - * Constructs the auth token based on whether the management key is used. + * Constructs the auth token: the project ID, then the token the caller presented if there is + * one, then the key for the kind of request being made - the management key for management + * requests, the auth management key for authentication requests. The two keys are never sent + * together. * - * @param bool $useManagementKey Whether to use the management key for authentication. + * @param bool|null $useManagementKey Whether this is a management request. + * @param string|null $refreshToken Refresh token or access key presented by the caller. * @return string The constructed auth token. */ - private function getAuthToken(bool $useManagementKey, ?string $refreshToken = null): string + private function getAuthToken(?bool $useManagementKey, ?string $refreshToken = null): string { - if ($useManagementKey && !empty($this->managementKey)) { - return $this->projectId . ':' . $this->managementKey; - } - $parts = [$this->projectId]; + if (!empty($refreshToken)) { $parts[] = $refreshToken; } - if (!$useManagementKey && !empty($this->authManagementKey)) { - $parts[] = $this->authManagementKey; + + $key = $useManagementKey ? $this->managementKey : $this->authManagementKey; + if (!empty($key)) { + $parts[] = $key; } return implode(':', $parts); diff --git a/src/tests/APIAuthManagementKeyTest.php b/src/tests/APIAuthManagementKeyTest.php index d067c1ee..7dbdc27c 100644 --- a/src/tests/APIAuthManagementKeyTest.php +++ b/src/tests/APIAuthManagementKeyTest.php @@ -131,6 +131,33 @@ public function testManagementRequestWithoutManagementKeyDoesNotSendAuthManageme $this->assertAuthorization('Bearer ' . self::PROJECT_ID); } + /** + * No SDK call site presents a token on a management request, but the API is public, so pin + * that such a request keys off the management key and not the auth management key. + */ + public function testManagementRequestWithATokenSendsOnlyTheManagementKey(): void + { + $api = $this->api(self::MANAGEMENT_KEY, self::AUTH_MANAGEMENT_KEY); + $api->doPost(MgmtV1::$USER_LOAD_PATH, [], true, self::REFRESH_TOKEN); + + $this->assertAuthorization( + 'Bearer ' . self::PROJECT_ID . ':' . self::REFRESH_TOKEN . ':' . self::MANAGEMENT_KEY + ); + } + + /** + * A null management flag means "not a management request", so it must not be treated as one. + */ + public function testNullManagementFlagIsTreatedAsAnAuthenticationRequest(): void + { + $api = $this->api(self::MANAGEMENT_KEY, self::AUTH_MANAGEMENT_KEY); + $api->doPost(EndpointsV1::$SIGN_IN_PASSWORD_PATH, [], null, self::REFRESH_TOKEN); + + $this->assertAuthorization( + 'Bearer ' . self::PROJECT_ID . ':' . self::REFRESH_TOKEN . ':' . self::AUTH_MANAGEMENT_KEY + ); + } + public function testAuthManagementKeyIsWiredFromSdkConfig(): void { $sdk = new DescopeSDK([