Skip to content

Commit 8465b97

Browse files
committed
fix: revoke WorkOS session server-side on logout
The logout flow was redirecting the browser to WorkOS's session logout endpoint, which relied on WorkOS dashboard settings for the redirect destination. WordPress lost control of where the user ended up. Switch to server-side session revocation via POST to /user_management/sessions/{id}/revoke so WordPress and the LogoutRedirect filter retain full control of the logout redirect URL.
1 parent a775680 commit 8465b97

4 files changed

Lines changed: 213 additions & 48 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ tests/
214214
├── EntitlementGateTest.php # Entitlement gate tests
215215
├── EventLoggerTest.php # Activity log event logger tests
216216
├── LoginBypassTest.php # Login bypass tests
217+
├── LoginLogoutTest.php # Login logout session revocation tests
217218
├── LoginSessionTest.php # Login session tests
218219
├── LoginTokensTest.php # Login tokens tests
219220
├── LogoutRedirectTest.php # Logout redirect tests
@@ -236,34 +237,14 @@ tests/
236237

237238
### Writing Tests
238239

240+
Use the global `/slic` skill for comprehensive guidance on test structure, environment setup tiers, HTTP mocking patterns, assertions, factories, and advanced patterns. Always invoke `/slic` before writing or modifying tests.
241+
239242
- **Base class:** Extend `lucatume\WPBrowser\TestCase\WPTestCase`
240243
- **Namespace:** `WorkOS\Tests\Wpunit`
241244
- **Pattern:** AAA (Arrange, Act, Assert)
242245
- **HTTP mocking:** Use the `pre_http_request` filter to intercept outbound HTTP calls
243246
- **WordPress factories:** Use `static::factory()` to create test posts, users, etc.
244247

245-
Example:
246-
247-
```php
248-
namespace WorkOS\Tests\Wpunit;
249-
250-
use lucatume\WPBrowser\TestCase\WPTestCase;
251-
252-
class ExampleTest extends WPTestCase {
253-
254-
public function test_something(): void {
255-
// Arrange
256-
$user_id = static::factory()->user->create();
257-
258-
// Act
259-
$result = some_function( $user_id );
260-
261-
// Assert
262-
$this->assertTrue( $result );
263-
}
264-
}
265-
```
266-
267248
### Existing Config Files
268249

269250
| File | Purpose |

src/WorkOS/Api/Client.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,21 @@ public function list_events( array $params = [] ) {
356356
return $this->get( '/events', $params );
357357
}
358358

359+
// -------------------------------------------------------------------------
360+
// Sessions
361+
// -------------------------------------------------------------------------
362+
363+
/**
364+
* Revoke a WorkOS session server-side.
365+
*
366+
* @param string $session_id WorkOS session ID.
367+
*
368+
* @return array|\WP_Error
369+
*/
370+
public function revoke_session( string $session_id ) {
371+
return $this->post( "/user_management/sessions/{$session_id}/revoke" );
372+
}
373+
359374
// -------------------------------------------------------------------------
360375
// Webhook Verification
361376
// -------------------------------------------------------------------------

src/WorkOS/Auth/Login.php

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -321,19 +321,11 @@ public function handle_logout( int $user_id ): void {
321321
delete_user_meta( $user_id, '_workos_refresh_token' );
322322
delete_user_meta( $user_id, '_workos_session_id' );
323323

324-
// Extract session ID from JWT and redirect to WorkOS logout.
324+
// Revoke the WorkOS session server-side so WordPress retains full
325+
// control of the logout redirect (no browser detour to WorkOS).
325326
$session_id = $access_token ? self::extract_session_id( $access_token ) : '';
326327
if ( $session_id ) {
327-
// Use logout_redirect filter at priority 20 (after LogoutRedirect at 10)
328-
// to ensure WorkOS session revocation always wins when a session exists.
329-
// Capture the role-based redirect URL from $redirect_to and pass it as return_to.
330-
add_filter(
331-
'logout_redirect',
332-
function ( $redirect_to ) use ( $session_id ) {
333-
return self::get_workos_logout_url( $session_id, $redirect_to );
334-
},
335-
20
336-
);
328+
workos()->api()->revoke_session( $session_id );
337329
}
338330
}
339331

@@ -370,21 +362,6 @@ private static function extract_session_id( string $token ): string {
370362
return $payload['sid'] ?? '';
371363
}
372364

373-
/**
374-
* Build the WorkOS session logout URL.
375-
*
376-
* @param string $session_id WorkOS session ID.
377-
* @param string $return_to Optional URL to return to after WorkOS logout.
378-
*
379-
* @return string Full logout URL.
380-
*/
381-
private static function get_workos_logout_url( string $session_id, string $return_to = '' ): string {
382-
$params = [ 'session_id' => $session_id ];
383-
if ( $return_to ) {
384-
$params['return_to'] = $return_to;
385-
}
386-
return \WorkOS\Api\Client::get_base_url() . '/user_management/sessions/logout?' . http_build_query( $params );
387-
}
388365

389366
/**
390367
* Store WorkOS tokens in usermeta (encrypted if possible).

tests/wpunit/LoginLogoutTest.php

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
/**
3+
* Tests for Login::handle_logout().
4+
*
5+
* @package WorkOS\Tests\Wpunit
6+
*/
7+
8+
namespace WorkOS\Tests\Wpunit;
9+
10+
use lucatume\WPBrowser\TestCase\WPTestCase;
11+
use WorkOS\Auth\Login;
12+
13+
/**
14+
* Logout session cleanup and server-side revocation tests.
15+
*/
16+
class LoginLogoutTest extends WPTestCase {
17+
18+
/**
19+
* Login instance under test.
20+
*
21+
* @var Login
22+
*/
23+
private Login $login;
24+
25+
/**
26+
* Captured HTTP requests.
27+
*
28+
* @var array
29+
*/
30+
private array $captured_requests = [];
31+
32+
/**
33+
* Set up each test.
34+
*/
35+
public function setUp(): void {
36+
parent::setUp();
37+
38+
\WorkOS\Config::set_active_environment( 'production' );
39+
update_option( 'workos_production', [
40+
'api_key' => 'sk_test_fake',
41+
'client_id' => 'client_fake',
42+
'environment_id' => 'environment_test',
43+
] );
44+
\WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
45+
46+
add_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10, 3 );
47+
48+
remove_all_actions( 'user_register' );
49+
50+
// Remove constructor hooks so tests only exercise handle_logout directly.
51+
remove_all_actions( 'login_init' );
52+
remove_all_filters( 'authenticate' );
53+
remove_all_actions( 'wp_logout' );
54+
55+
$this->login = new Login();
56+
}
57+
58+
/**
59+
* Tear down each test.
60+
*/
61+
public function tearDown(): void {
62+
remove_filter( 'pre_http_request', [ $this, 'intercept_http' ], 10 );
63+
delete_option( 'workos_production' );
64+
\WorkOS\Config::set_active_environment( 'staging' );
65+
\WorkOS\App::container()->get( \WorkOS\Options\Production::class )->reset();
66+
67+
$this->captured_requests = [];
68+
69+
parent::tearDown();
70+
}
71+
72+
/**
73+
* Intercept outbound HTTP requests.
74+
*
75+
* @param false|array $preempt Response override.
76+
* @param array $args Request args.
77+
* @param string $url Request URL.
78+
*
79+
* @return array Fake response.
80+
*/
81+
public function intercept_http( $preempt, array $args, string $url ): array {
82+
$this->captured_requests[] = [
83+
'url' => $url,
84+
'method' => $args['method'] ?? 'GET',
85+
'body' => $args['body'] ?? '',
86+
];
87+
88+
return [
89+
'response' => [ 'code' => 200, 'message' => 'OK' ],
90+
'body' => '{}',
91+
];
92+
}
93+
94+
/**
95+
* Build a fake JWT with a given payload.
96+
*
97+
* No real signature — extract_session_id only decodes the payload.
98+
*
99+
* @param array $payload JWT payload claims.
100+
*
101+
* @return string Fake JWT string.
102+
*/
103+
private function build_fake_jwt( array $payload ): string {
104+
$header = rtrim( strtr( base64_encode( '{"alg":"RS256","typ":"JWT"}' ), '+/', '-_' ), '=' );
105+
$body = rtrim( strtr( base64_encode( wp_json_encode( $payload ) ), '+/', '-_' ), '=' );
106+
$sig = 'fake_signature';
107+
108+
return "{$header}.{$body}.{$sig}";
109+
}
110+
111+
/**
112+
* Create a user with a stored WorkOS access token.
113+
*
114+
* @param string $session_id Session ID to embed in the JWT.
115+
*
116+
* @return int WordPress user ID.
117+
*/
118+
private function create_user_with_token( string $session_id ): int {
119+
$user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
120+
$token = $this->build_fake_jwt( [ 'sid' => $session_id, 'sub' => 'user_test' ] );
121+
122+
update_user_meta( $user_id, '_workos_access_token', $token );
123+
update_user_meta( $user_id, '_workos_refresh_token', 'refresh_fake' );
124+
update_user_meta( $user_id, '_workos_session_id', $session_id );
125+
126+
return $user_id;
127+
}
128+
129+
/**
130+
* Test tokens are deleted from usermeta on logout.
131+
*/
132+
public function test_clears_tokens_on_logout(): void {
133+
$user_id = $this->create_user_with_token( 'session_abc' );
134+
135+
$this->login->handle_logout( $user_id );
136+
137+
$this->assertEmpty( get_user_meta( $user_id, '_workos_access_token', true ) );
138+
$this->assertEmpty( get_user_meta( $user_id, '_workos_refresh_token', true ) );
139+
$this->assertEmpty( get_user_meta( $user_id, '_workos_session_id', true ) );
140+
}
141+
142+
/**
143+
* Test server-side session revocation is called with the correct session ID.
144+
*/
145+
public function test_revokes_session_server_side(): void {
146+
$user_id = $this->create_user_with_token( 'session_xyz' );
147+
148+
$this->login->handle_logout( $user_id );
149+
150+
$this->assertCount( 1, $this->captured_requests );
151+
$this->assertStringContainsString(
152+
'/user_management/sessions/session_xyz/revoke',
153+
$this->captured_requests[0]['url']
154+
);
155+
$this->assertSame( 'POST', $this->captured_requests[0]['method'] );
156+
}
157+
158+
/**
159+
* Test no API call is made when user has no access token.
160+
*/
161+
public function test_skips_revocation_when_no_access_token(): void {
162+
$user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
163+
164+
$this->login->handle_logout( $user_id );
165+
166+
$this->assertCount( 0, $this->captured_requests );
167+
}
168+
169+
/**
170+
* Test no API call is made when JWT has no sid claim.
171+
*/
172+
public function test_skips_revocation_when_token_has_no_sid(): void {
173+
$user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
174+
$token = $this->build_fake_jwt( [ 'sub' => 'user_test' ] );
175+
update_user_meta( $user_id, '_workos_access_token', $token );
176+
177+
$this->login->handle_logout( $user_id );
178+
179+
$this->assertCount( 0, $this->captured_requests );
180+
}
181+
182+
/**
183+
* Test no logout_redirect filter is registered (redirect stays with WordPress).
184+
*/
185+
public function test_does_not_register_logout_redirect_filter(): void {
186+
$user_id = $this->create_user_with_token( 'session_abc' );
187+
188+
$this->login->handle_logout( $user_id );
189+
190+
$this->assertFalse( has_filter( 'logout_redirect', [ $this->login, 'wrap_logout_redirect' ] ) );
191+
}
192+
}

0 commit comments

Comments
 (0)