Skip to content

Commit 4971150

Browse files
committed
Refactor session architecture to match workos-node scope
Removes authkit-level abstractions that belong in a separate authkit-php library. The SDK now matches workos-node's session handling scope. Removed (moved to future authkit-php): - AuthService orchestration layer - AuthKitCore sealing coordination - DefaultSessionStorage and SessionStorageInterface - sealSession() method (sealing is authkit's responsibility) - REASON_INVALID_JWT constant (unused) Simplified: - UserManagement: keeps injectable encryption, removes sealing - CookieSession: only accepts UserManagement, refresh() returns raw tokens instead of sealed session string Breaking changes: - CookieSession no longer accepts AuthService - CookieSession.refresh() returns [response, tokens] not [response, sealed] - UserManagement.sealSession() removed - UserManagement.getAuthKitCore() removed This addresses PR feedback: - Injectable encryption via SessionEncryptionInterface - Separate try/catch blocks for encryption vs HTTP errors - Pluggable session backing without prescriptive sealing
1 parent 318e543 commit 4971150

5 files changed

Lines changed: 286 additions & 99 deletions

File tree

lib/CookieSession.php

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* Class CookieSession
1010
*
1111
* Handles encrypted session cookies for user authentication and session management.
12+
* Matches workos-node CookieSession behavior - unsealing and validating sessions.
1213
*/
1314
class CookieSession
1415
{
@@ -59,20 +60,25 @@ public function authenticate()
5960
}
6061

6162
/**
62-
* Refreshes an expired session and optionally rotates the cookie password.
63+
* Refreshes an expired session and returns new tokens.
64+
*
65+
* Note: This method returns raw tokens. The calling code (e.g., authkit-php)
66+
* is responsible for sealing the tokens into a new session cookie.
6367
*
6468
* @param array $options Options for session refresh
6569
* - 'organizationId' (string|null): Organization to scope the session to
66-
* - 'cookiePassword' (string|null): New password for cookie rotation
6770
*
68-
* @return array{SessionAuthenticationSuccessResponse|SessionAuthenticationFailureResponse, string|null}
69-
* Returns [response, newSealedSession]
71+
* @return array{SessionAuthenticationSuccessResponse|SessionAuthenticationFailureResponse, array|null}
72+
* Returns [response, newTokens] where newTokens contains:
73+
* - 'access_token': The new access token
74+
* - 'refresh_token': The new refresh token
75+
* - 'session_id': The session ID
76+
* Returns [failureResponse, null] on error.
7077
* @throws Exception\WorkOSException
7178
*/
7279
public function refresh(array $options = [])
7380
{
7481
$organizationId = $options['organizationId'] ?? null;
75-
$newCookiePassword = $options['cookiePassword'] ?? $this->cookiePassword;
7682

7783
// First authenticate to get the current session data
7884
$authResult = $this->authenticate();
@@ -81,7 +87,7 @@ public function refresh(array $options = [])
8187
return [$authResult, null];
8288
}
8389

84-
// Use the refresh token to get new authentication tokens
90+
// Tight try/catch for refresh token API call
8591
try {
8692
$refreshedAuth = $this->userManagement->authenticateWithRefreshToken(
8793
WorkOS::getClientId(),
@@ -90,35 +96,32 @@ public function refresh(array $options = [])
9096
null,
9197
$organizationId
9298
);
93-
94-
// Create new sealed session with refreshed data
95-
$newSealedSession = $this->userManagement->sealSession(
96-
[
97-
'access_token' => $refreshedAuth->accessToken,
98-
'refresh_token' => $refreshedAuth->refreshToken,
99-
'session_id' => $authResult->sessionId
100-
],
101-
$newCookiePassword
102-
);
103-
104-
// Build success response from refreshed auth
105-
$successResponse = SessionAuthenticationSuccessResponse::constructFromResponse([
106-
'authenticated' => true,
107-
'access_token' => $refreshedAuth->accessToken,
108-
'refresh_token' => $refreshedAuth->refreshToken,
109-
'session_id' => $authResult->sessionId,
110-
'user' => $refreshedAuth->user->raw,
111-
'organization_id' => $refreshedAuth->organizationId ?? $organizationId,
112-
'authentication_method' => $authResult->authenticationMethod
113-
]);
114-
115-
return [$successResponse, $newSealedSession];
11699
} catch (\Exception $e) {
117100
$failureResponse = new SessionAuthenticationFailureResponse(
118-
SessionAuthenticationFailureResponse::REASON_INVALID_JWT
101+
SessionAuthenticationFailureResponse::REASON_HTTP_ERROR
119102
);
120103
return [$failureResponse, null];
121104
}
105+
106+
// Build success response
107+
$successResponse = SessionAuthenticationSuccessResponse::constructFromResponse([
108+
'authenticated' => true,
109+
'access_token' => $refreshedAuth->accessToken,
110+
'refresh_token' => $refreshedAuth->refreshToken,
111+
'session_id' => $authResult->sessionId,
112+
'user' => $refreshedAuth->user->raw,
113+
'organization_id' => $refreshedAuth->organizationId ?? $organizationId,
114+
'authentication_method' => $authResult->authenticationMethod
115+
]);
116+
117+
// Return raw tokens for the caller to seal
118+
$newTokens = [
119+
'access_token' => $refreshedAuth->accessToken,
120+
'refresh_token' => $refreshedAuth->refreshToken,
121+
'session_id' => $authResult->sessionId
122+
];
123+
124+
return [$successResponse, $newTokens];
122125
}
123126

124127
/**

lib/Resource/SessionAuthenticationFailureResponse.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class SessionAuthenticationFailureResponse extends BaseWorkOSResource
1414
{
1515
public const REASON_NO_SESSION_COOKIE_PROVIDED = "NO_SESSION_COOKIE_PROVIDED";
1616
public const REASON_INVALID_SESSION_COOKIE = "INVALID_SESSION_COOKIE";
17-
public const REASON_INVALID_JWT = "INVALID_JWT";
17+
public const REASON_ENCRYPTION_ERROR = "ENCRYPTION_ERROR";
18+
public const REASON_HTTP_ERROR = "HTTP_ERROR";
1819

1920
public const RESOURCE_ATTRIBUTES = [
2021
"authenticated",

lib/UserManagement.php

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,43 @@ class UserManagement
1616
public const AUTHORIZATION_PROVIDER_GOOGLE_OAUTH = "GoogleOAuth";
1717
public const AUTHORIZATION_PROVIDER_MICROSOFT_OAUTH = "MicrosoftOAuth";
1818

19+
/**
20+
* @var Session\SessionEncryptionInterface|null
21+
*/
22+
private $sessionEncryptor = null;
23+
24+
/**
25+
* @param Session\SessionEncryptionInterface|null $encryptor Optional encryption provider
26+
*/
27+
public function __construct(?Session\SessionEncryptionInterface $encryptor = null)
28+
{
29+
$this->sessionEncryptor = $encryptor;
30+
}
31+
32+
/**
33+
* Set the session encryptor.
34+
*
35+
* @param Session\SessionEncryptionInterface $encryptor
36+
* @return void
37+
*/
38+
public function setSessionEncryptor(Session\SessionEncryptionInterface $encryptor): void
39+
{
40+
$this->sessionEncryptor = $encryptor;
41+
}
42+
43+
/**
44+
* Get the session encryptor, defaulting to Halite.
45+
*
46+
* @return Session\SessionEncryptionInterface
47+
*/
48+
private function getSessionEncryptor(): Session\SessionEncryptionInterface
49+
{
50+
if ($this->sessionEncryptor === null) {
51+
$this->sessionEncryptor = new Session\HaliteSessionEncryption();
52+
}
53+
return $this->sessionEncryptor;
54+
}
55+
1956
/**
2057
* Create User.
2158
*
@@ -1395,22 +1432,6 @@ public function revokeSession(string $sessionId)
13951432
return Resource\Session::constructFromResponse($response);
13961433
}
13971434

1398-
/**
1399-
* Creates a sealed session from session data.
1400-
*
1401-
* @param array $sessionData Session data containing access_token, refresh_token, session_id
1402-
* @param string $cookiePassword Password to encrypt the session
1403-
* @param int|null $ttl Time-to-live in seconds (null for default)
1404-
*
1405-
* @return string Sealed session string
1406-
* @throws Exception\WorkOSException
1407-
*/
1408-
public function sealSession(array $sessionData, string $cookiePassword, ?int $ttl = null)
1409-
{
1410-
$encryptor = new Session\HaliteSessionEncryption();
1411-
return $encryptor->seal($sessionData, $cookiePassword, $ttl);
1412-
}
1413-
14141435
/**
14151436
* Authenticate with a sealed session cookie.
14161437
*
@@ -1430,17 +1451,23 @@ public function authenticateWithSessionCookie(
14301451
);
14311452
}
14321453

1454+
// Tight try/catch for unsealing only
14331455
try {
1434-
$encryptor = new Session\HaliteSessionEncryption();
1435-
$sessionData = $encryptor->unseal($sealedSession, $cookiePassword);
1456+
$sessionData = $this->getSessionEncryptor()->unseal($sealedSession, $cookiePassword);
1457+
} catch (\Exception $e) {
1458+
return new Resource\SessionAuthenticationFailureResponse(
1459+
Resource\SessionAuthenticationFailureResponse::REASON_ENCRYPTION_ERROR
1460+
);
1461+
}
14361462

1437-
if (!isset($sessionData['access_token']) || !isset($sessionData['refresh_token'])) {
1438-
return new Resource\SessionAuthenticationFailureResponse(
1439-
Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE
1440-
);
1441-
}
1463+
if (!isset($sessionData['access_token']) || !isset($sessionData['refresh_token'])) {
1464+
return new Resource\SessionAuthenticationFailureResponse(
1465+
Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE
1466+
);
1467+
}
14421468

1443-
// Verify the JWT access token and get user info via API
1469+
// Separate try/catch for HTTP request
1470+
try {
14441471
$path = "user_management/sessions/authenticate";
14451472
$params = [
14461473
"access_token" => $sessionData['access_token'],
@@ -1458,7 +1485,7 @@ public function authenticateWithSessionCookie(
14581485
return Resource\SessionAuthenticationSuccessResponse::constructFromResponse($response);
14591486
} catch (\Exception $e) {
14601487
return new Resource\SessionAuthenticationFailureResponse(
1461-
Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE
1488+
Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR
14621489
);
14631490
}
14641491
}

tests/WorkOS/CookieSessionTest.php

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,15 @@ protected function setUp(): void
3131
$this->withApiKeyAndClientId();
3232
$this->userManagement = new UserManagement();
3333

34-
// Create a sealed session for testing
34+
// Create a sealed session for testing using encryptor directly
35+
// (sealing is authkit-php's responsibility, not SDK's)
3536
$sessionData = [
3637
'access_token' => 'test_access_token_12345',
3738
'refresh_token' => 'test_refresh_token_67890',
3839
'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234'
3940
];
40-
$this->sealedSession = $this->userManagement->sealSession(
41-
$sessionData,
42-
$this->cookiePassword
43-
);
41+
$encryptor = new Session\HaliteSessionEncryption();
42+
$this->sealedSession = $encryptor->seal($sessionData, $this->cookiePassword);
4443
}
4544

4645
public function testConstructCookieSession()
@@ -95,17 +94,13 @@ public function testLoadSealedSessionReturnsValidCookieSession()
9594
$this->assertInstanceOf(CookieSession::class, $cookieSession);
9695
}
9796

98-
public function testRefreshPassesCorrectParametersToAuthenticateWithRefreshToken()
97+
public function testRefreshReturnsRawTokensOnSuccess()
9998
{
100-
// REGRESSION TEST: Verify that CookieSession.refresh() passes all 5 parameters
101-
// to authenticateWithRefreshToken(), not just 2. The clientId parameter must be
102-
// included from WorkOS::getClientId() (see CookieSession.php:86-92)
103-
10499
$organizationId = "org_01H7X1M4TZJN5N4HG4XXMA1234";
105100

106101
// Create a mock UserManagement to verify method calls
107102
$userManagementMock = $this->getMockBuilder(UserManagement::class)
108-
->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken', 'sealSession'])
103+
->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken'])
109104
->getMock();
110105

111106
// Mock authenticateWithSessionCookie to return a successful authentication
@@ -129,7 +124,7 @@ public function testRefreshPassesCorrectParametersToAuthenticateWithRefreshToken
129124
$userManagementMock->method('authenticateWithSessionCookie')
130125
->willReturn($authResponse);
131126

132-
// CRITICAL ASSERTION: Verify authenticateWithRefreshToken is called with exactly 5 parameters
127+
// Setup refresh to succeed
133128
$refreshResponseData = [
134129
'access_token' => 'new_access_token',
135130
'refresh_token' => 'new_refresh_token',
@@ -150,28 +145,107 @@ public function testRefreshPassesCorrectParametersToAuthenticateWithRefreshToken
150145
->method('authenticateWithRefreshToken')
151146
->with(
152147
$this->identicalTo(WorkOS::getClientId()), // clientId from config
153-
$this->identicalTo('test_refresh_token'), // refresh token
154-
$this->identicalTo(null), // ipAddress
155-
$this->identicalTo(null), // userAgent
156-
$this->identicalTo($organizationId) // organizationId
148+
$this->identicalTo('test_refresh_token'), // refresh token
149+
$this->identicalTo(null), // ipAddress
150+
$this->identicalTo(null), // userAgent
151+
$this->identicalTo($organizationId) // organizationId
157152
)
158153
->willReturn($refreshResponse);
159154

160-
$userManagementMock->method('sealSession')
161-
->willReturn('new_sealed_session');
162-
163155
// Execute refresh with the mocked UserManagement
164156
$cookieSession = new CookieSession(
165157
$userManagementMock,
166158
$this->sealedSession,
167159
$this->cookiePassword
168160
);
169161

170-
[$response, $newSealedSession] = $cookieSession->refresh([
162+
[$response, $tokens] = $cookieSession->refresh([
171163
'organizationId' => $organizationId
172164
]);
173165

174-
// If we reach here without the mock throwing an exception, the test passes
166+
// Verify response is successful
175167
$this->assertInstanceOf(Resource\SessionAuthenticationSuccessResponse::class, $response);
168+
$this->assertTrue($response->authenticated);
169+
170+
// Verify tokens are returned as raw array (not sealed)
171+
$this->assertIsArray($tokens);
172+
$this->assertArrayHasKey('access_token', $tokens);
173+
$this->assertArrayHasKey('refresh_token', $tokens);
174+
$this->assertArrayHasKey('session_id', $tokens);
175+
$this->assertEquals('new_access_token', $tokens['access_token']);
176+
$this->assertEquals('new_refresh_token', $tokens['refresh_token']);
177+
$this->assertEquals('session_123', $tokens['session_id']);
178+
}
179+
180+
public function testRefreshReturnsNullTokensOnAuthFailure()
181+
{
182+
$userManagementMock = $this->getMockBuilder(UserManagement::class)
183+
->onlyMethods(['authenticateWithSessionCookie'])
184+
->getMock();
185+
186+
$failResponse = new Resource\SessionAuthenticationFailureResponse(
187+
Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE
188+
);
189+
190+
$userManagementMock->method('authenticateWithSessionCookie')
191+
->willReturn($failResponse);
192+
193+
$cookieSession = new CookieSession(
194+
$userManagementMock,
195+
'invalid-session',
196+
$this->cookiePassword
197+
);
198+
199+
[$response, $tokens] = $cookieSession->refresh();
200+
201+
$this->assertFalse($response->authenticated);
202+
$this->assertNull($tokens);
203+
}
204+
205+
public function testRefreshReturnsHttpErrorOnApiFailure()
206+
{
207+
$userManagementMock = $this->getMockBuilder(UserManagement::class)
208+
->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken'])
209+
->getMock();
210+
211+
// Mock successful initial auth
212+
$authResponseData = [
213+
'authenticated' => true,
214+
'access_token' => 'test_access_token',
215+
'refresh_token' => 'test_refresh_token',
216+
'session_id' => 'session_123',
217+
'user' => [
218+
'object' => 'user',
219+
'id' => 'user_123',
220+
'email' => 'test@test.com',
221+
'first_name' => 'Test',
222+
'last_name' => 'User',
223+
'email_verified' => true,
224+
'created_at' => '2021-01-01T00:00:00.000Z',
225+
'updated_at' => '2021-01-01T00:00:00.000Z'
226+
]
227+
];
228+
$authResponse = Resource\SessionAuthenticationSuccessResponse::constructFromResponse($authResponseData);
229+
$userManagementMock->method('authenticateWithSessionCookie')
230+
->willReturn($authResponse);
231+
232+
// Mock refresh to throw exception
233+
$userManagementMock->method('authenticateWithRefreshToken')
234+
->willThrowException(new \Exception('HTTP request failed'));
235+
236+
$cookieSession = new CookieSession(
237+
$userManagementMock,
238+
$this->sealedSession,
239+
$this->cookiePassword
240+
);
241+
242+
[$response, $tokens] = $cookieSession->refresh();
243+
244+
$this->assertFalse($response->authenticated);
245+
$this->assertEquals(
246+
Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR,
247+
$response->reason
248+
);
249+
$this->assertNull($tokens);
176250
}
177251
}

0 commit comments

Comments
 (0)