Skip to content

Commit eae3949

Browse files
workos-sdk-automation[bot]gjtorikianclaude
authored
fix: build redirect endpoint URLs locally instead of making HTTP requests (#358)
Co-authored-by: Garen J. Torikian <gjtorikian@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abccc87 commit eae3949

8 files changed

Lines changed: 183 additions & 74 deletions

File tree

.oagen-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": 1,
33
"language": "php",
4-
"generatedAt": "2026-04-14T16:33:11.276Z",
4+
"generatedAt": "2026-04-14T19:04:38.043Z",
55
"files": [
66
"lib/Resource/ActionAuthenticationDenied.php",
77
"lib/Resource/ActionAuthenticationDeniedData.php",

docs/V5_MIGRATION_GUIDE.md

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,14 +320,12 @@ After:
320320
$sso = $workos->sso();
321321
```
322322

323-
#### `getAuthorizationUrl()` no longer builds a URL locally
323+
#### `getAuthorizationUrl()` still builds a URL locally but the API changed
324324

325325
In v4, `SSO::getAuthorizationUrl(...)` returned a string and implicitly used `WorkOS::getClientId()`.
326326

327-
In v5 it:
327+
In v5 it still returns a string, but:
328328

329-
- makes an HTTP request
330-
- returns `WorkOS\Resource\SSOAuthorizeUrlResponse`
331329
- requires an instantiated client with `clientId`
332330
- requires `redirectUri`
333331

@@ -344,13 +342,11 @@ $url = $sso->getAuthorizationUrl(
344342
After:
345343

346344
```php
347-
$response = $workos->sso()->getAuthorizationUrl(
345+
$url = $workos->sso()->getAuthorizationUrl(
348346
redirectUri: 'https://example.com/callback',
349347
domain: 'example.com',
350348
state: json_encode(['return_to' => '/dashboard']),
351349
);
352-
353-
$url = $response->url;
354350
```
355351

356352
`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:
501497

502498
#### Auth and logout URL helpers changed behavior
503499

504-
`userManagement()->getAuthorizationUrl()` and `userManagement()->getLogoutUrl()` no longer just build a local string.
500+
`userManagement()->getAuthorizationUrl()` and `userManagement()->getLogoutUrl()` still build URLs locally and return strings.
505501

506502
Notable differences:
507503

508-
- they make API calls
509504
- `getAuthorizationUrl()` now requires `redirectUri` and an instantiated client with `clientId`
510505
- `state` is now a string, not an array that the SDK JSON-encodes for you
511-
- `getLogoutUrl()` now returns response data instead of a locally composed URL string
512506

513507
Before:
514508

@@ -523,7 +517,7 @@ $url = $userManagement->getAuthorizationUrl(
523517
After:
524518

525519
```php
526-
$response = $workos->userManagement()->getAuthorizationUrl(
520+
$url = $workos->userManagement()->getAuthorizationUrl(
527521
redirectUri: 'https://example.com/callback',
528522
state: json_encode(['return_to' => '/dashboard']),
529523
provider: \WorkOS\Resource\UserManagementAuthenticationProvider::Authkit,

lib/HttpClient.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ public function requireClientId(): string
7777
return $this->clientId;
7878
}
7979

80+
/**
81+
* Build a fully-qualified URL without making an HTTP request.
82+
*
83+
* Used for redirect endpoints (e.g., SSO authorize, logout) where the
84+
* caller needs a URL to redirect the user's browser to.
85+
*
86+
* @param array<string, mixed> $query
87+
*/
88+
public function buildUrl(string $path, array $query = [], ?RequestOptions $options = null): string
89+
{
90+
$url = $this->resolveUrl($path, $options);
91+
$queryString = http_build_query($query);
92+
if ($queryString !== '') {
93+
$url .= '?' . $queryString;
94+
}
95+
96+
return $url;
97+
}
98+
8099
public function request(
81100
string $method,
82101
string $path,
@@ -244,13 +263,25 @@ private function decodeResponse(ResponseInterface $response): ?array
244263
}
245264

246265
$decoded = json_decode($contents, true);
247-
return is_array($decoded) ? $decoded : null;
266+
if (!is_array($decoded)) {
267+
$statusCode = $response->getStatusCode();
268+
$requestId = $response->getHeaderLine('X-Request-ID') ?: null;
269+
$preview = mb_substr($contents, 0, 200);
270+
271+
throw new Exception\ApiException(
272+
sprintf('Expected JSON response but received non-JSON body (HTTP %d): %s', $statusCode, $preview),
273+
$statusCode,
274+
$requestId,
275+
);
276+
}
277+
278+
return $decoded;
248279
}
249280

250281
private function mapApiException(ResponseInterface $response, ?\Throwable $previous = null): ApiException
251282
{
252283
$statusCode = $response->getStatusCode();
253-
$requestId = $response->getHeaderLine('X-Request-ID') ?: $response->getHeaderLine('x-request-id') ?: null;
284+
$requestId = $response->getHeaderLine('X-Request-ID') ?: null;
254285
$body = $this->decodeErrorBody($response);
255286

256287
return match ($statusCode) {

lib/Service/SSO.php

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
use WorkOS\Resource\Connection;
1010
use WorkOS\Resource\Profile;
11-
use WorkOS\Resource\SSOAuthorizeUrlResponse;
1211
use WorkOS\Resource\SSOLogoutAuthorizeResponse;
1312
use WorkOS\Resource\SSOTokenResponse;
1413

@@ -119,7 +118,7 @@ public function deleteConnection(
119118
* @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.
120119
* @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.
121120
* @param string|null $nonce A random string generated by the client that is used to mitigate replay attacks.
122-
* @return \WorkOS\Resource\SSOAuthorizeUrlResponse
121+
* @return string
123122
*/
124123
public function getAuthorizationUrl(
125124
string $redirectUri,
@@ -134,7 +133,7 @@ public function getAuthorizationUrl(
134133
?string $loginHint = null,
135134
?string $nonce = null,
136135
?\WorkOS\RequestOptions $options = null,
137-
): \WorkOS\Resource\SSOAuthorizeUrlResponse {
136+
): string {
138137
$query = array_filter([
139138
'provider_scopes' => $providerScopes,
140139
'provider_query_params' => $providerQueryParams,
@@ -150,13 +149,7 @@ public function getAuthorizationUrl(
150149
'response_type' => 'code',
151150
], fn ($v) => $v !== null);
152151
$query['client_id'] = $this->client->requireClientId();
153-
$response = $this->client->request(
154-
method: 'GET',
155-
path: 'sso/authorize',
156-
query: $query,
157-
options: $options,
158-
);
159-
return SSOAuthorizeUrlResponse::fromArray($response);
152+
return $this->client->buildUrl('sso/authorize', $query, $options);
160153
}
161154

162155
/**
@@ -166,22 +159,16 @@ public function getAuthorizationUrl(
166159
*
167160
* 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.
168161
* @param string $token The logout token returned from the [Logout Authorize](https://workos.com/docs/reference/sso/logout/authorize) endpoint.
169-
* @return mixed
162+
* @return string
170163
*/
171164
public function getLogoutUrl(
172165
string $token,
173166
?\WorkOS\RequestOptions $options = null,
174-
): mixed {
167+
): string {
175168
$query = [
176169
'token' => $token,
177170
];
178-
$response = $this->client->request(
179-
method: 'GET',
180-
path: 'sso/logout',
181-
query: $query,
182-
options: $options,
183-
);
184-
return $response;
171+
return $this->client->buildUrl('sso/logout', $query, $options);
185172
}
186173

187174
/**

lib/Service/UserManagement.php

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ public function authenticateWithDeviceCode(
363363
* @param string|null $state An opaque value used to maintain state between the request and the callback.
364364
* @param string|null $organizationId The ID of the organization to authenticate the user against.
365365
* @param string $redirectUri The callback URI where the authorization code will be sent after authentication.
366-
* @return mixed
366+
* @return string
367367
*/
368368
public function getAuthorizationUrl(
369369
string $redirectUri,
@@ -381,7 +381,7 @@ public function getAuthorizationUrl(
381381
?string $state = null,
382382
?string $organizationId = null,
383383
?\WorkOS\RequestOptions $options = null,
384-
): mixed {
384+
): string {
385385
$query = array_filter([
386386
'code_challenge_method' => $codeChallengeMethod,
387387
'code_challenge' => $codeChallenge,
@@ -400,13 +400,7 @@ public function getAuthorizationUrl(
400400
'response_type' => 'code',
401401
], fn ($v) => $v !== null);
402402
$query['client_id'] = $this->client->requireClientId();
403-
$response = $this->client->request(
404-
method: 'GET',
405-
path: 'user_management/authorize',
406-
query: $query,
407-
options: $options,
408-
);
409-
return $response;
403+
return $this->client->buildUrl('user_management/authorize', $query, $options);
410404
}
411405

412406
/**
@@ -438,24 +432,18 @@ public function createDevice(
438432
* Logout a user from the current [session](https://workos.com/docs/reference/authkit/session).
439433
* @param string $sessionId The ID of the session to revoke. This can be extracted from the `sid` claim of the access token.
440434
* @param string|null $returnTo The URL to redirect the user to after session revocation.
441-
* @return mixed
435+
* @return string
442436
*/
443437
public function getLogoutUrl(
444438
string $sessionId,
445439
?string $returnTo = null,
446440
?\WorkOS\RequestOptions $options = null,
447-
): mixed {
441+
): string {
448442
$query = array_filter([
449443
'session_id' => $sessionId,
450444
'return_to' => $returnTo,
451445
], fn ($v) => $v !== null);
452-
$response = $this->client->request(
453-
method: 'GET',
454-
path: 'user_management/sessions/logout',
455-
query: $query,
456-
options: $options,
457-
);
458-
return $response;
446+
return $this->client->buildUrl('user_management/sessions/logout', $query, $options);
459447
}
460448

461449
/**

tests/HttpClientTest.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
// @oagen-ignore-file
5+
6+
namespace Tests;
7+
8+
use GuzzleHttp\Handler\MockHandler;
9+
use GuzzleHttp\HandlerStack;
10+
use GuzzleHttp\Psr7\Response;
11+
use PHPUnit\Framework\TestCase;
12+
use WorkOS\Exception\ApiException;
13+
use WorkOS\HttpClient;
14+
15+
class HttpClientTest extends TestCase
16+
{
17+
public function testDecodeResponseThrowsOnNonJsonBody(): void
18+
{
19+
$html = '<html><body>Redirect</body></html>';
20+
$mock = new MockHandler([
21+
new Response(200, ['Content-Type' => 'text/html'], $html),
22+
]);
23+
$handler = HandlerStack::create($mock);
24+
25+
$client = new HttpClient(
26+
apiKey: 'test_key',
27+
clientId: null,
28+
baseUrl: 'https://api.workos.com',
29+
timeout: 10,
30+
maxRetries: 0,
31+
handler: $handler,
32+
);
33+
34+
$this->expectException(ApiException::class);
35+
$this->expectExceptionMessage('Expected JSON response but received non-JSON body');
36+
37+
$client->request('GET', '/test');
38+
}
39+
40+
public function testBuildUrlOmitsQuestionMarkForEmptyQuery(): void
41+
{
42+
$client = new HttpClient(
43+
apiKey: 'test_key',
44+
clientId: null,
45+
baseUrl: 'https://api.workos.com',
46+
timeout: 10,
47+
maxRetries: 0,
48+
);
49+
50+
$url = $client->buildUrl('sso/authorize', []);
51+
$this->assertSame('https://api.workos.com/sso/authorize', $url);
52+
}
53+
54+
public function testBuildUrlOmitsQuestionMarkForEmptyArrayValues(): void
55+
{
56+
$client = new HttpClient(
57+
apiKey: 'test_key',
58+
clientId: null,
59+
baseUrl: 'https://api.workos.com',
60+
timeout: 10,
61+
maxRetries: 0,
62+
);
63+
64+
// http_build_query returns '' for arrays containing only empty arrays
65+
$url = $client->buildUrl('sso/authorize', ['scopes' => []]);
66+
$this->assertStringNotContainsString('?', $url);
67+
}
68+
69+
public function testBuildUrlAppendsQueryString(): void
70+
{
71+
$client = new HttpClient(
72+
apiKey: 'test_key',
73+
clientId: null,
74+
baseUrl: 'https://api.workos.com',
75+
timeout: 10,
76+
maxRetries: 0,
77+
);
78+
79+
$url = $client->buildUrl('sso/authorize', ['client_id' => 'abc', 'response_type' => 'code']);
80+
$this->assertStringContainsString('?', $url);
81+
parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
82+
$this->assertSame('abc', $query['client_id']);
83+
$this->assertSame('code', $query['response_type']);
84+
}
85+
}

tests/Service/SSOTest.php

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,23 +58,32 @@ public function testDeleteConnection(): void
5858

5959
public function testGetAuthorizationUrl(): void
6060
{
61-
$fixture = $this->loadFixture('sso_authorize_url_response');
62-
$client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);
63-
$result = $client->sso()->getAuthorizationUrl(redirectUri: 'test_value');
64-
$this->assertInstanceOf(\WorkOS\Resource\SSOAuthorizeUrlResponse::class, $result);
65-
$this->assertIsArray($result->toArray());
66-
$request = $this->getLastRequest();
67-
$this->assertSame('GET', $request->getMethod());
68-
$this->assertStringEndsWith('sso/authorize', $request->getUri()->getPath());
61+
$client = $this->createMockClient([]);
62+
$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');
63+
$this->assertIsString($result);
64+
$this->assertStringContainsString('sso/authorize', $result);
65+
parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);
66+
$this->assertSame('test_value', $query['domain']);
67+
$this->assertSame('AppleOAuth', $query['provider']);
68+
$this->assertSame('test_value', $query['redirect_uri']);
69+
$this->assertSame('test_value', $query['state']);
70+
$this->assertSame('test_value', $query['connection']);
71+
$this->assertSame('test_value', $query['organization']);
72+
$this->assertSame('test_value', $query['domain_hint']);
73+
$this->assertSame('test_value', $query['login_hint']);
74+
$this->assertSame('test_value', $query['nonce']);
75+
$this->assertSame('code', $query['response_type']);
76+
$this->assertArrayHasKey('client_id', $query);
6977
}
7078

7179
public function testGetLogoutUrl(): void
7280
{
73-
$client = $this->createMockClient([['status' => 200, 'body' => []]]);
74-
$client->sso()->getLogoutUrl(token: 'test_value');
75-
$request = $this->getLastRequest();
76-
$this->assertSame('GET', $request->getMethod());
77-
$this->assertStringEndsWith('sso/logout', $request->getUri()->getPath());
81+
$client = $this->createMockClient([]);
82+
$result = $client->sso()->getLogoutUrl(token: 'test_value');
83+
$this->assertIsString($result);
84+
$this->assertStringContainsString('sso/logout', $result);
85+
parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);
86+
$this->assertSame('test_value', $query['token']);
7887
}
7988

8089
public function testAuthorizeLogout(): void

0 commit comments

Comments
 (0)