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..32ce2291 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -77,6 +77,25 @@ 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); + $queryString = http_build_query($query); + if ($queryString !== '') { + $url .= '?' . $queryString; + } + + return $url; + } + public function request( string $method, string $path, @@ -244,13 +263,25 @@ 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') ?: 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 { $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) { 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/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 312a8d5f..fc80c4e4 100644 --- a/tests/Service/SSOTest.php +++ b/tests/Service/SSOTest.php @@ -58,23 +58,32 @@ public function testDeleteConnection(): void public function testGetAuthorizationUrl(): void { - $fixture = $this->loadFixture('sso_authorize_url_response'); - $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]); - $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()); + $client = $this->createMockClient([]); + $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 { - $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); + 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 5ded8658..9f8d9781 100644 --- a/tests/Service/UserManagementTest.php +++ b/tests/Service/UserManagementTest.php @@ -27,11 +27,24 @@ 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(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,11 +64,13 @@ 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', 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