From 163691e3dd845f12c37ce9994da00062bd1a0c83 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 14 Apr 2026 15:07:36 -0400 Subject: [PATCH 1/3] fix: build redirect endpoint URLs locally instead of making HTTP requests getAuthorizationUrl() and getLogoutUrl() on SSO and UserManagement were making real HTTP requests to redirect endpoints, receiving HTML instead of JSON, and throwing a TypeError via fromArray(null). These methods now construct the URL client-side and return a string, matching the Python and .NET SDKs. Also fixes decodeResponse() to throw ApiException on non-JSON success bodies instead of silently returning null, and updates the V5 migration guide to reflect the correct behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .oagen-manifest.json | 2 +- docs/V5_MIGRATION_GUIDE.md | 16 +++++--------- lib/HttpClient.php | 32 +++++++++++++++++++++++++++- lib/Service/SSO.php | 25 ++++++---------------- lib/Service/UserManagement.php | 24 ++++++--------------- tests/Service/SSOTest.php | 19 ++++++----------- tests/Service/UserManagementTest.php | 18 +++++++--------- 7 files changed, 64 insertions(+), 72 deletions(-) diff --git a/.oagen-manifest.json b/.oagen-manifest.json index 0af858ee..d19d30e5 100644 --- a/.oagen-manifest.json +++ b/.oagen-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "language": "php", - "generatedAt": "2026-04-14T16:33:11.276Z", + "generatedAt": "2026-04-14T19:04:38.043Z", "files": [ "lib/Resource/ActionAuthenticationDenied.php", "lib/Resource/ActionAuthenticationDeniedData.php", diff --git a/docs/V5_MIGRATION_GUIDE.md b/docs/V5_MIGRATION_GUIDE.md index 38a68321..575f7ae0 100644 --- a/docs/V5_MIGRATION_GUIDE.md +++ b/docs/V5_MIGRATION_GUIDE.md @@ -320,14 +320,12 @@ After: $sso = $workos->sso(); ``` -#### `getAuthorizationUrl()` no longer builds a URL locally +#### `getAuthorizationUrl()` still builds a URL locally but the API changed In v4, `SSO::getAuthorizationUrl(...)` returned a string and implicitly used `WorkOS::getClientId()`. -In v5 it: +In v5 it still returns a string, but: -- makes an HTTP request -- returns `WorkOS\Resource\SSOAuthorizeUrlResponse` - requires an instantiated client with `clientId` - requires `redirectUri` @@ -344,13 +342,11 @@ $url = $sso->getAuthorizationUrl( After: ```php -$response = $workos->sso()->getAuthorizationUrl( +$url = $workos->sso()->getAuthorizationUrl( redirectUri: 'https://example.com/callback', domain: 'example.com', state: json_encode(['return_to' => '/dashboard']), ); - -$url = $response->url; ``` `state` is now a string parameter. If you used array state in v4, encode it yourself. @@ -501,14 +497,12 @@ These methods existed in v4 but should be treated as removed in v5: #### Auth and logout URL helpers changed behavior -`userManagement()->getAuthorizationUrl()` and `userManagement()->getLogoutUrl()` no longer just build a local string. +`userManagement()->getAuthorizationUrl()` and `userManagement()->getLogoutUrl()` still build URLs locally and return strings. Notable differences: -- they make API calls - `getAuthorizationUrl()` now requires `redirectUri` and an instantiated client with `clientId` - `state` is now a string, not an array that the SDK JSON-encodes for you -- `getLogoutUrl()` now returns response data instead of a locally composed URL string Before: @@ -523,7 +517,7 @@ $url = $userManagement->getAuthorizationUrl( After: ```php -$response = $workos->userManagement()->getAuthorizationUrl( +$url = $workos->userManagement()->getAuthorizationUrl( redirectUri: 'https://example.com/callback', state: json_encode(['return_to' => '/dashboard']), provider: \WorkOS\Resource\UserManagementAuthenticationProvider::Authkit, diff --git a/lib/HttpClient.php b/lib/HttpClient.php index dda3208d..caa5ed19 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -77,6 +77,24 @@ public function requireClientId(): string return $this->clientId; } + /** + * Build a fully-qualified URL without making an HTTP request. + * + * Used for redirect endpoints (e.g., SSO authorize, logout) where the + * caller needs a URL to redirect the user's browser to. + * + * @param array $query + */ + public function buildUrl(string $path, array $query = [], ?RequestOptions $options = null): string + { + $url = $this->resolveUrl($path, $options); + if (count($query) > 0) { + $url .= '?' . http_build_query($query); + } + + return $url; + } + public function request( string $method, string $path, @@ -244,7 +262,19 @@ private function decodeResponse(ResponseInterface $response): ?array } $decoded = json_decode($contents, true); - return is_array($decoded) ? $decoded : null; + if (!is_array($decoded)) { + $statusCode = $response->getStatusCode(); + $requestId = $response->getHeaderLine('X-Request-ID') ?: $response->getHeaderLine('x-request-id') ?: null; + $preview = mb_substr($contents, 0, 200); + + throw new Exception\ApiException( + sprintf('Expected JSON response but received non-JSON body (HTTP %d): %s', $statusCode, $preview), + $statusCode, + $requestId, + ); + } + + return $decoded; } private function mapApiException(ResponseInterface $response, ?\Throwable $previous = null): ApiException diff --git a/lib/Service/SSO.php b/lib/Service/SSO.php index fd502431..370efdfb 100644 --- a/lib/Service/SSO.php +++ b/lib/Service/SSO.php @@ -8,7 +8,6 @@ use WorkOS\Resource\Connection; use WorkOS\Resource\Profile; -use WorkOS\Resource\SSOAuthorizeUrlResponse; use WorkOS\Resource\SSOLogoutAuthorizeResponse; use WorkOS\Resource\SSOTokenResponse; @@ -119,7 +118,7 @@ public function deleteConnection( * @param string|null $domainHint Can be used to pre-fill the domain field when initiating authentication with Microsoft OAuth or with a Google SAML connection type. * @param string|null $loginHint Can be used to pre-fill the username/email address field of the IdP sign-in page for the user, if you know their username ahead of time. Currently supported for OAuth, OpenID Connect, Okta, and Entra ID connections. * @param string|null $nonce A random string generated by the client that is used to mitigate replay attacks. - * @return \WorkOS\Resource\SSOAuthorizeUrlResponse + * @return string */ public function getAuthorizationUrl( string $redirectUri, @@ -134,7 +133,7 @@ public function getAuthorizationUrl( ?string $loginHint = null, ?string $nonce = null, ?\WorkOS\RequestOptions $options = null, - ): \WorkOS\Resource\SSOAuthorizeUrlResponse { + ): string { $query = array_filter([ 'provider_scopes' => $providerScopes, 'provider_query_params' => $providerQueryParams, @@ -150,13 +149,7 @@ public function getAuthorizationUrl( 'response_type' => 'code', ], fn ($v) => $v !== null); $query['client_id'] = $this->client->requireClientId(); - $response = $this->client->request( - method: 'GET', - path: 'sso/authorize', - query: $query, - options: $options, - ); - return SSOAuthorizeUrlResponse::fromArray($response); + return $this->client->buildUrl('sso/authorize', $query, $options); } /** @@ -166,22 +159,16 @@ public function getAuthorizationUrl( * * Before redirecting to this endpoint, you need to generate a short-lived logout token using the [Logout Authorize](https://workos.com/docs/reference/sso/logout/authorize) endpoint. * @param string $token The logout token returned from the [Logout Authorize](https://workos.com/docs/reference/sso/logout/authorize) endpoint. - * @return mixed + * @return string */ public function getLogoutUrl( string $token, ?\WorkOS\RequestOptions $options = null, - ): mixed { + ): string { $query = [ 'token' => $token, ]; - $response = $this->client->request( - method: 'GET', - path: 'sso/logout', - query: $query, - options: $options, - ); - return $response; + return $this->client->buildUrl('sso/logout', $query, $options); } /** diff --git a/lib/Service/UserManagement.php b/lib/Service/UserManagement.php index 131ed43a..be6e7cbc 100644 --- a/lib/Service/UserManagement.php +++ b/lib/Service/UserManagement.php @@ -363,7 +363,7 @@ public function authenticateWithDeviceCode( * @param string|null $state An opaque value used to maintain state between the request and the callback. * @param string|null $organizationId The ID of the organization to authenticate the user against. * @param string $redirectUri The callback URI where the authorization code will be sent after authentication. - * @return mixed + * @return string */ public function getAuthorizationUrl( string $redirectUri, @@ -381,7 +381,7 @@ public function getAuthorizationUrl( ?string $state = null, ?string $organizationId = null, ?\WorkOS\RequestOptions $options = null, - ): mixed { + ): string { $query = array_filter([ 'code_challenge_method' => $codeChallengeMethod, 'code_challenge' => $codeChallenge, @@ -400,13 +400,7 @@ public function getAuthorizationUrl( 'response_type' => 'code', ], fn ($v) => $v !== null); $query['client_id'] = $this->client->requireClientId(); - $response = $this->client->request( - method: 'GET', - path: 'user_management/authorize', - query: $query, - options: $options, - ); - return $response; + return $this->client->buildUrl('user_management/authorize', $query, $options); } /** @@ -438,24 +432,18 @@ public function createDevice( * Logout a user from the current [session](https://workos.com/docs/reference/authkit/session). * @param string $sessionId The ID of the session to revoke. This can be extracted from the `sid` claim of the access token. * @param string|null $returnTo The URL to redirect the user to after session revocation. - * @return mixed + * @return string */ public function getLogoutUrl( string $sessionId, ?string $returnTo = null, ?\WorkOS\RequestOptions $options = null, - ): mixed { + ): string { $query = array_filter([ 'session_id' => $sessionId, 'return_to' => $returnTo, ], fn ($v) => $v !== null); - $response = $this->client->request( - method: 'GET', - path: 'user_management/sessions/logout', - query: $query, - options: $options, - ); - return $response; + return $this->client->buildUrl('user_management/sessions/logout', $query, $options); } /** diff --git a/tests/Service/SSOTest.php b/tests/Service/SSOTest.php index 312a8d5f..a52cde10 100644 --- a/tests/Service/SSOTest.php +++ b/tests/Service/SSOTest.php @@ -58,23 +58,18 @@ public function testDeleteConnection(): void public function testGetAuthorizationUrl(): void { - $fixture = $this->loadFixture('sso_authorize_url_response'); - $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]); + $client = $this->createMockClient([]); $result = $client->sso()->getAuthorizationUrl(redirectUri: 'test_value'); - $this->assertInstanceOf(\WorkOS\Resource\SSOAuthorizeUrlResponse::class, $result); - $this->assertIsArray($result->toArray()); - $request = $this->getLastRequest(); - $this->assertSame('GET', $request->getMethod()); - $this->assertStringEndsWith('sso/authorize', $request->getUri()->getPath()); + $this->assertIsString($result); + $this->assertStringContainsString('sso/authorize', $result); } public function testGetLogoutUrl(): void { - $client = $this->createMockClient([['status' => 200, 'body' => []]]); - $client->sso()->getLogoutUrl(token: 'test_value'); - $request = $this->getLastRequest(); - $this->assertSame('GET', $request->getMethod()); - $this->assertStringEndsWith('sso/logout', $request->getUri()->getPath()); + $client = $this->createMockClient([]); + $result = $client->sso()->getLogoutUrl(token: 'test_value'); + $this->assertIsString($result); + $this->assertStringContainsString('sso/logout', $result); } public function testAuthorizeLogout(): void diff --git a/tests/Service/UserManagementTest.php b/tests/Service/UserManagementTest.php index 5ded8658..90903ca5 100644 --- a/tests/Service/UserManagementTest.php +++ b/tests/Service/UserManagementTest.php @@ -27,11 +27,10 @@ public function testGetJwks(): void public function testGetAuthorizationUrl(): void { - $client = $this->createMockClient([['status' => 200, 'body' => []]]); - $client->userManagement()->getAuthorizationUrl(redirectUri: 'test_value'); - $request = $this->getLastRequest(); - $this->assertSame('GET', $request->getMethod()); - $this->assertStringEndsWith('user_management/authorize', $request->getUri()->getPath()); + $client = $this->createMockClient([]); + $result = $client->userManagement()->getAuthorizationUrl(redirectUri: 'test_value'); + $this->assertIsString($result); + $this->assertStringContainsString('user_management/authorize', $result); } public function testCreateDevice(): void @@ -51,11 +50,10 @@ public function testCreateDevice(): void public function testGetLogoutUrl(): void { - $client = $this->createMockClient([['status' => 200, 'body' => []]]); - $client->userManagement()->getLogoutUrl(sessionId: 'test_value'); - $request = $this->getLastRequest(); - $this->assertSame('GET', $request->getMethod()); - $this->assertStringEndsWith('user_management/sessions/logout', $request->getUri()->getPath()); + $client = $this->createMockClient([]); + $result = $client->userManagement()->getLogoutUrl(sessionId: 'test_value'); + $this->assertIsString($result); + $this->assertStringContainsString('user_management/sessions/logout', $result); } public function testRevokeSession(): void From ec14b4262a0518e2dbab1dbb7831fecbab36192f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 14 Apr 2026 15:37:34 -0400 Subject: [PATCH 2/3] harden with testing --- lib/HttpClient.php | 5 +- tests/HttpClientTest.php | 85 ++++++++++++++++++++++++++++ tests/Service/SSOTest.php | 16 +++++- tests/Service/UserManagementTest.php | 21 ++++++- 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 tests/HttpClientTest.php diff --git a/lib/HttpClient.php b/lib/HttpClient.php index caa5ed19..ab7bb721 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -88,8 +88,9 @@ public function requireClientId(): string public function buildUrl(string $path, array $query = [], ?RequestOptions $options = null): string { $url = $this->resolveUrl($path, $options); - if (count($query) > 0) { - $url .= '?' . http_build_query($query); + $queryString = http_build_query($query); + if ($queryString !== '') { + $url .= '?' . $queryString; } return $url; diff --git a/tests/HttpClientTest.php b/tests/HttpClientTest.php new file mode 100644 index 00000000..fcc31534 --- /dev/null +++ b/tests/HttpClientTest.php @@ -0,0 +1,85 @@ +Redirect'; + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'text/html'], $html), + ]); + $handler = HandlerStack::create($mock); + + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + handler: $handler, + ); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Expected JSON response but received non-JSON body'); + + $client->request('GET', '/test'); + } + + public function testBuildUrlOmitsQuestionMarkForEmptyQuery(): void + { + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + ); + + $url = $client->buildUrl('sso/authorize', []); + $this->assertSame('https://api.workos.com/sso/authorize', $url); + } + + public function testBuildUrlOmitsQuestionMarkForEmptyArrayValues(): void + { + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + ); + + // http_build_query returns '' for arrays containing only empty arrays + $url = $client->buildUrl('sso/authorize', ['scopes' => []]); + $this->assertStringNotContainsString('?', $url); + } + + public function testBuildUrlAppendsQueryString(): void + { + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + ); + + $url = $client->buildUrl('sso/authorize', ['client_id' => 'abc', 'response_type' => 'code']); + $this->assertStringContainsString('?', $url); + parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query); + $this->assertSame('abc', $query['client_id']); + $this->assertSame('code', $query['response_type']); + } +} diff --git a/tests/Service/SSOTest.php b/tests/Service/SSOTest.php index a52cde10..fc80c4e4 100644 --- a/tests/Service/SSOTest.php +++ b/tests/Service/SSOTest.php @@ -59,9 +59,21 @@ public function testDeleteConnection(): void public function testGetAuthorizationUrl(): void { $client = $this->createMockClient([]); - $result = $client->sso()->getAuthorizationUrl(redirectUri: 'test_value'); + $result = $client->sso()->getAuthorizationUrl(providerScopes: [], providerQueryParams: [], domain: 'test_value', provider: \WorkOS\Resource\SSOProvider::AppleOAuth, redirectUri: 'test_value', state: 'test_value', connection: 'test_value', organization: 'test_value', domainHint: 'test_value', loginHint: 'test_value', nonce: 'test_value'); $this->assertIsString($result); $this->assertStringContainsString('sso/authorize', $result); + parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query); + $this->assertSame('test_value', $query['domain']); + $this->assertSame('AppleOAuth', $query['provider']); + $this->assertSame('test_value', $query['redirect_uri']); + $this->assertSame('test_value', $query['state']); + $this->assertSame('test_value', $query['connection']); + $this->assertSame('test_value', $query['organization']); + $this->assertSame('test_value', $query['domain_hint']); + $this->assertSame('test_value', $query['login_hint']); + $this->assertSame('test_value', $query['nonce']); + $this->assertSame('code', $query['response_type']); + $this->assertArrayHasKey('client_id', $query); } public function testGetLogoutUrl(): void @@ -70,6 +82,8 @@ public function testGetLogoutUrl(): void $result = $client->sso()->getLogoutUrl(token: 'test_value'); $this->assertIsString($result); $this->assertStringContainsString('sso/logout', $result); + parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query); + $this->assertSame('test_value', $query['token']); } public function testAuthorizeLogout(): void diff --git a/tests/Service/UserManagementTest.php b/tests/Service/UserManagementTest.php index 90903ca5..9f8d9781 100644 --- a/tests/Service/UserManagementTest.php +++ b/tests/Service/UserManagementTest.php @@ -28,9 +28,23 @@ public function testGetJwks(): void public function testGetAuthorizationUrl(): void { $client = $this->createMockClient([]); - $result = $client->userManagement()->getAuthorizationUrl(redirectUri: 'test_value'); + $result = $client->userManagement()->getAuthorizationUrl(codeChallengeMethod: 'test_value', codeChallenge: 'test_value', domainHint: 'test_value', connectionId: 'test_value', providerQueryParams: [], providerScopes: [], invitationToken: 'test_value', screenHint: \WorkOS\Resource\UserManagementAuthenticationScreenHint::SignUp, loginHint: 'test_value', provider: \WorkOS\Resource\UserManagementAuthenticationProvider::Authkit, prompt: 'test_value', state: 'test_value', organizationId: 'test_value', redirectUri: 'test_value'); $this->assertIsString($result); $this->assertStringContainsString('user_management/authorize', $result); + parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query); + $this->assertSame('test_value', $query['code_challenge']); + $this->assertSame('test_value', $query['domain_hint']); + $this->assertSame('test_value', $query['connection_id']); + $this->assertSame('test_value', $query['invitation_token']); + $this->assertSame('sign-up', $query['screen_hint']); + $this->assertSame('test_value', $query['login_hint']); + $this->assertSame('authkit', $query['provider']); + $this->assertSame('test_value', $query['prompt']); + $this->assertSame('test_value', $query['state']); + $this->assertSame('test_value', $query['organization_id']); + $this->assertSame('test_value', $query['redirect_uri']); + $this->assertSame('code', $query['response_type']); + $this->assertArrayHasKey('client_id', $query); } public function testCreateDevice(): void @@ -51,9 +65,12 @@ public function testCreateDevice(): void public function testGetLogoutUrl(): void { $client = $this->createMockClient([]); - $result = $client->userManagement()->getLogoutUrl(sessionId: 'test_value'); + $result = $client->userManagement()->getLogoutUrl(sessionId: 'test_value', returnTo: 'test_value'); $this->assertIsString($result); $this->assertStringContainsString('user_management/sessions/logout', $result); + parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query); + $this->assertSame('test_value', $query['session_id']); + $this->assertSame('test_value', $query['return_to']); } public function testRevokeSession(): void From 2e56c2ea68fdef547183dced6b916d6ef915e87e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 14 Apr 2026 15:45:29 -0400 Subject: [PATCH 3/3] cleanup --- lib/HttpClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index ab7bb721..32ce2291 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -265,7 +265,7 @@ private function decodeResponse(ResponseInterface $response): ?array $decoded = json_decode($contents, true); if (!is_array($decoded)) { $statusCode = $response->getStatusCode(); - $requestId = $response->getHeaderLine('X-Request-ID') ?: $response->getHeaderLine('x-request-id') ?: null; + $requestId = $response->getHeaderLine('X-Request-ID') ?: null; $preview = mb_substr($contents, 0, 200); throw new Exception\ApiException( @@ -281,7 +281,7 @@ private function decodeResponse(ResponseInterface $response): ?array private function mapApiException(ResponseInterface $response, ?\Throwable $previous = null): ApiException { $statusCode = $response->getStatusCode(); - $requestId = $response->getHeaderLine('X-Request-ID') ?: $response->getHeaderLine('x-request-id') ?: null; + $requestId = $response->getHeaderLine('X-Request-ID') ?: null; $body = $this->decodeErrorBody($response); return match ($statusCode) {