Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oagen-manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 5 additions & 11 deletions docs/V5_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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.
Expand Down Expand Up @@ -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:

Expand All @@ -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,
Expand Down
35 changes: 33 additions & 2 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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,
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 6 additions & 19 deletions lib/Service/SSO.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

use WorkOS\Resource\Connection;
use WorkOS\Resource\Profile;
use WorkOS\Resource\SSOAuthorizeUrlResponse;
use WorkOS\Resource\SSOLogoutAuthorizeResponse;
use WorkOS\Resource\SSOTokenResponse;

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand Down
24 changes: 6 additions & 18 deletions lib/Service/UserManagement.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down
85 changes: 85 additions & 0 deletions tests/HttpClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);
// @oagen-ignore-file

namespace Tests;

use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use WorkOS\Exception\ApiException;
use WorkOS\HttpClient;

class HttpClientTest extends TestCase
{
public function testDecodeResponseThrowsOnNonJsonBody(): void
{
$html = '<html><body>Redirect</body></html>';
$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']);
}
}
35 changes: 22 additions & 13 deletions tests/Service/SSOTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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
Expand Down
Loading
Loading