From 34d9b2fe03e4cf7b183037a2994554b780f78db8 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:25:30 -0600 Subject: [PATCH 1/2] Implement Authkit Sessions --- composer.json | 3 +- lib/CookieSession.php | 143 ++++++++++++++ lib/Resource/Session.php | 54 +++++ .../SessionAuthenticationFailureResponse.php | 42 ++++ .../SessionAuthenticationSuccessResponse.php | 102 ++++++++++ lib/Session/HaliteSessionEncryption.php | 119 +++++++++++ lib/Session/SessionEncryptionInterface.php | 34 ++++ lib/UserManagement.php | 167 ++++++++++++++++ tests/WorkOS/CookieSessionTest.php | 97 +++++++++ .../Session/HaliteSessionEncryptionTest.php | 175 +++++++++++++++++ tests/WorkOS/UserManagementTest.php | 184 ++++++++++++++++++ 11 files changed, 1119 insertions(+), 1 deletion(-) create mode 100644 lib/CookieSession.php create mode 100644 lib/Resource/Session.php create mode 100644 lib/Resource/SessionAuthenticationFailureResponse.php create mode 100644 lib/Resource/SessionAuthenticationSuccessResponse.php create mode 100644 lib/Session/HaliteSessionEncryption.php create mode 100644 lib/Session/SessionEncryptionInterface.php create mode 100644 tests/WorkOS/CookieSessionTest.php create mode 100644 tests/WorkOS/Session/HaliteSessionEncryptionTest.php diff --git a/composer.json b/composer.json index d1b07be..90ce35d 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ ], "require": { "php": ">=7.3.0", - "ext-curl": "*" + "ext-curl": "*", + "paragonie/halite": "^5.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.15|^3.6", diff --git a/lib/CookieSession.php b/lib/CookieSession.php new file mode 100644 index 0000000..55f9e05 --- /dev/null +++ b/lib/CookieSession.php @@ -0,0 +1,143 @@ +userManagement = $userManagement; + $this->sealedSession = $sealedSession; + $this->cookiePassword = $cookiePassword; + } + + /** + * Authenticates the sealed session and returns user information. + * + * @return SessionAuthenticationSuccessResponse|SessionAuthenticationFailureResponse + * @throws Exception\WorkOSException + */ + public function authenticate() + { + return $this->userManagement->authenticateWithSessionCookie( + $this->sealedSession, + $this->cookiePassword + ); + } + + /** + * Refreshes an expired session and optionally rotates the cookie password. + * + * @param array $options Options for session refresh + * - 'organizationId' (string|null): Organization to scope the session to + * - 'cookiePassword' (string|null): New password for cookie rotation + * + * @return array{SessionAuthenticationSuccessResponse|SessionAuthenticationFailureResponse, string|null} + * Returns [response, newSealedSession] + * @throws Exception\WorkOSException + */ + public function refresh(array $options = []) + { + $organizationId = $options['organizationId'] ?? null; + $newCookiePassword = $options['cookiePassword'] ?? $this->cookiePassword; + + // First authenticate to get the current session data + $authResult = $this->authenticate(); + + if (!$authResult->authenticated) { + return [$authResult, null]; + } + + // Use the refresh token to get new authentication tokens + try { + $refreshedAuth = $this->userManagement->authenticateWithRefreshToken( + $authResult->refreshToken, + $organizationId + ); + + // Create new sealed session with refreshed data + $newSealedSession = $this->userManagement->sealSession( + [ + 'access_token' => $refreshedAuth->accessToken, + 'refresh_token' => $refreshedAuth->refreshToken, + 'session_id' => $authResult->sessionId + ], + $newCookiePassword + ); + + // Build success response from refreshed auth + $successResponse = SessionAuthenticationSuccessResponse::constructFromResponse([ + 'authenticated' => true, + 'access_token' => $refreshedAuth->accessToken, + 'refresh_token' => $refreshedAuth->refreshToken, + 'session_id' => $authResult->sessionId, + 'user' => $refreshedAuth->user->raw, + 'organization_id' => $refreshedAuth->organizationId ?? $organizationId, + 'authentication_method' => $authResult->authenticationMethod + ]); + + return [$successResponse, $newSealedSession]; + } catch (\Exception $e) { + $failureResponse = new SessionAuthenticationFailureResponse( + SessionAuthenticationFailureResponse::REASON_INVALID_JWT + ); + return [$failureResponse, null]; + } + } + + /** + * Gets the logout URL for the current session. + * + * @param array $options + * - 'returnTo' (string|null): URL to redirect to after logout + * + * @return string Logout URL + * @throws Exception\UnexpectedValueException + */ + public function getLogoutUrl(array $options = []) + { + $authResult = $this->authenticate(); + + if (!$authResult->authenticated) { + throw new Exception\UnexpectedValueException( + "Cannot get logout URL for unauthenticated session" + ); + } + + $returnTo = $options['returnTo'] ?? null; + return $this->userManagement->getLogoutUrl($authResult->sessionId, $returnTo); + } +} diff --git a/lib/Resource/Session.php b/lib/Resource/Session.php new file mode 100644 index 0000000..9c21dd4 --- /dev/null +++ b/lib/Resource/Session.php @@ -0,0 +1,54 @@ + "id", + "user_id" => "userId", + "ip_address" => "ipAddress", + "user_agent" => "userAgent", + "organization_id" => "organizationId", + "authentication_method" => "authenticationMethod", + "status" => "status", + "expires_at" => "expiresAt", + "ended_at" => "endedAt", + "created_at" => "createdAt", + "updated_at" => "updatedAt", + "object" => "object" + ]; +} diff --git a/lib/Resource/SessionAuthenticationFailureResponse.php b/lib/Resource/SessionAuthenticationFailureResponse.php new file mode 100644 index 0000000..9901c6a --- /dev/null +++ b/lib/Resource/SessionAuthenticationFailureResponse.php @@ -0,0 +1,42 @@ + "authenticated", + "reason" => "reason" + ]; + + /** + * Construct a failure response with a specific reason. + * + * @param string $reason Reason for authentication failure + */ + public function __construct(string $reason) + { + $this->values = [ + "authenticated" => false, + "reason" => $reason + ]; + $this->raw = []; + } +} diff --git a/lib/Resource/SessionAuthenticationSuccessResponse.php b/lib/Resource/SessionAuthenticationSuccessResponse.php new file mode 100644 index 0000000..049e37e --- /dev/null +++ b/lib/Resource/SessionAuthenticationSuccessResponse.php @@ -0,0 +1,102 @@ + "authenticated", + "access_token" => "accessToken", + "refresh_token" => "refreshToken", + "session_id" => "sessionId", + "user" => "user", + "organization_id" => "organizationId", + "role" => "role", + "roles" => "roles", + "permissions" => "permissions", + "entitlements" => "entitlements", + "feature_flags" => "featureFlags", + "impersonator" => "impersonator", + "authentication_method" => "authenticationMethod" + ]; + + public static function constructFromResponse($response) + { + $instance = parent::constructFromResponse($response); + + // Always set authenticated to true for success responses + $instance->values["authenticated"] = true; + + // Construct User resource from user data + if (isset($response["user"])) { + $instance->values["user"] = User::constructFromResponse($response["user"]); + } + + // Construct Role if present + if (isset($response["role"])) { + $instance->values["role"] = new RoleResponse($response["role"]["slug"]); + } + + // Construct Roles array if present + if (isset($response["roles"])) { + $roles = []; + foreach ($response["roles"] as $role) { + $roles[] = new RoleResponse($role["slug"]); + } + $instance->values["roles"] = $roles; + } + + // Construct FeatureFlags array if present + if (isset($response["feature_flags"])) { + $featureFlags = []; + foreach ($response["feature_flags"] as $flag) { + $featureFlags[] = FeatureFlag::constructFromResponse($flag); + } + $instance->values["featureFlags"] = $featureFlags; + } + + // Construct Impersonator if present + if (isset($response["impersonator"])) { + $instance->values["impersonator"] = Impersonator::constructFromResponse( + $response["impersonator"] + ); + } + + return $instance; + } +} diff --git a/lib/Session/HaliteSessionEncryption.php b/lib/Session/HaliteSessionEncryption.php new file mode 100644 index 0000000..90d368f --- /dev/null +++ b/lib/Session/HaliteSessionEncryption.php @@ -0,0 +1,119 @@ + $data, + 'expires_at' => $expiresAt + ]; + + $key = $this->deriveKey($password); + $encrypted = SymmetricCrypto::encrypt( + new HiddenString(json_encode($payload)), + $key + ); + + return base64_encode($encrypted); + } catch (\Exception $e) { + throw new UnexpectedValueException( + "Failed to seal session: " . $e->getMessage() + ); + } + } + + /** + * Decrypts and unseals session data with TTL validation. + * + * @param string $sealed Sealed session string + * @param string $password Decryption password + * + * @return array Unsealed session data + * @throws \WorkOS\Exception\UnexpectedValueException + */ + public function unseal(string $sealed, string $password): array + { + try { + $key = $this->deriveKey($password); + $encrypted = base64_decode($sealed); + + $decryptedHiddenString = SymmetricCrypto::decrypt($encrypted, $key); + $decrypted = $decryptedHiddenString->getString(); + $payload = json_decode($decrypted, true); + + if (!isset($payload['expires_at']) || !isset($payload['data'])) { + throw new UnexpectedValueException("Invalid session payload"); + } + + if (time() > $payload['expires_at']) { + throw new UnexpectedValueException("Session has expired"); + } + + return $payload['data']; + } catch (UnexpectedValueException $e) { + // Re-throw our exceptions + throw $e; + } catch (\Exception $e) { + throw new UnexpectedValueException( + "Failed to unseal session: " . $e->getMessage() + ); + } + } + + /** + * Derives an encryption key from password using HKDF. + * + * @param string $password Password to derive key from + * + * @return EncryptionKey Encryption key for Halite + * @throws \WorkOS\Exception\UnexpectedValueException + */ + private function deriveKey(string $password): EncryptionKey + { + try { + // Use HKDF to derive a 32-byte key from the password + // This ensures the password is properly formatted for Halite + $keyMaterial = hash_hkdf('sha256', $password, 32); + + return new EncryptionKey(new HiddenString($keyMaterial)); + } catch (\Exception $e) { + throw new UnexpectedValueException( + "Failed to derive encryption key: " . $e->getMessage() + ); + } + } +} diff --git a/lib/Session/SessionEncryptionInterface.php b/lib/Session/SessionEncryptionInterface.php new file mode 100644 index 0000000..6c08a78 --- /dev/null +++ b/lib/Session/SessionEncryptionInterface.php @@ -0,0 +1,34 @@ + $options['limit'] ?? self::DEFAULT_PAGE_SIZE, + "before" => $options['before'] ?? null, + "after" => $options['after'] ?? null, + "order" => $options['order'] ?? null + ]; + + $response = Client::request( + Client::METHOD_GET, + $path, + null, + $params, + true + ); + + $sessions = []; + list($before, $after) = Util\Request::parsePaginationArgs($response); + + foreach ($response["data"] as $responseData) { + \array_push($sessions, Resource\Session::constructFromResponse($responseData)); + } + + return [$before, $after, $sessions]; + } + + /** + * Revoke a session. + * + * @param string $sessionId Session ID + * + * @return Resource\Session The revoked session + * @throws Exception\WorkOSException + */ + public function revokeSession(string $sessionId) + { + $path = "user_management/sessions/{$sessionId}/revoke"; + + $response = Client::request( + Client::METHOD_POST, + $path, + null, + null, + true + ); + + return Resource\Session::constructFromResponse($response); + } + + /** + * Creates a sealed session from session data. + * + * @param array $sessionData Session data containing access_token, refresh_token, session_id + * @param string $cookiePassword Password to encrypt the session + * @param int|null $ttl Time-to-live in seconds (null for default) + * + * @return string Sealed session string + * @throws Exception\WorkOSException + */ + public function sealSession(array $sessionData, string $cookiePassword, ?int $ttl = null) + { + $encryptor = new Session\HaliteSessionEncryption(); + return $encryptor->seal($sessionData, $cookiePassword, $ttl); + } + + /** + * Authenticate with a sealed session cookie. + * + * @param string $sealedSession Encrypted session cookie data + * @param string $cookiePassword Password to decrypt the session + * + * @return Resource\SessionAuthenticationSuccessResponse|Resource\SessionAuthenticationFailureResponse + * @throws Exception\WorkOSException + */ + public function authenticateWithSessionCookie( + string $sealedSession, + string $cookiePassword + ) { + if (empty($sealedSession)) { + return new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_NO_SESSION_COOKIE_PROVIDED + ); + } + + try { + $encryptor = new Session\HaliteSessionEncryption(); + $sessionData = $encryptor->unseal($sealedSession, $cookiePassword); + + if (!isset($sessionData['access_token']) || !isset($sessionData['refresh_token'])) { + return new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE + ); + } + + // Verify the JWT access token and get user info via API + $path = "user_management/sessions/authenticate"; + $params = [ + "access_token" => $sessionData['access_token'], + "refresh_token" => $sessionData['refresh_token'] + ]; + + $response = Client::request( + Client::METHOD_POST, + $path, + null, + $params, + true + ); + + return Resource\SessionAuthenticationSuccessResponse::constructFromResponse($response); + } catch (\Exception $e) { + return new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE + ); + } + } + + /** + * Load a sealed session and return a CookieSession instance. + * + * @param string $sealedSession Encrypted session cookie data + * @param string $cookiePassword Password to decrypt the session + * + * @return CookieSession + */ + public function loadSealedSession(string $sealedSession, string $cookiePassword) + { + return new CookieSession($this, $sealedSession, $cookiePassword); + } + + /** + * Extract and decrypt a session from HTTP cookies. + * + * @param string $cookiePassword Password to decrypt the session + * @param string $cookieName Name of the session cookie (default: 'wos-session') + * + * @return CookieSession|null + */ + public function getSessionFromCookie( + string $cookiePassword, + string $cookieName = 'wos-session' + ) { + if (!isset($_COOKIE[$cookieName])) { + return null; + } + + $sealedSession = $_COOKIE[$cookieName]; + return $this->loadSealedSession($sealedSession, $cookiePassword); + } } diff --git a/tests/WorkOS/CookieSessionTest.php b/tests/WorkOS/CookieSessionTest.php new file mode 100644 index 0000000..b1ab2e1 --- /dev/null +++ b/tests/WorkOS/CookieSessionTest.php @@ -0,0 +1,97 @@ +traitSetUp(); + $this->withApiKeyAndClientId(); + $this->userManagement = new UserManagement(); + + // Create a sealed session for testing + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + $this->sealedSession = $this->userManagement->sealSession( + $sessionData, + $this->cookiePassword + ); + } + + public function testConstructCookieSession() + { + $cookieSession = new CookieSession( + $this->userManagement, + $this->sealedSession, + $this->cookiePassword + ); + + $this->assertInstanceOf(CookieSession::class, $cookieSession); + } + + public function testAuthenticateFailsWithInvalidSession() + { + $cookieSession = new CookieSession( + $this->userManagement, + "invalid-sealed-session-data", + $this->cookiePassword + ); + + $result = $cookieSession->authenticate(); + + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertFalse($result->authenticated); + } + + public function testGetLogoutUrlThrowsExceptionForUnauthenticatedSession() + { + $cookieSession = new CookieSession( + $this->userManagement, + "invalid-sealed-session-data", + $this->cookiePassword + ); + + $this->expectException(Exception\UnexpectedValueException::class); + $this->expectExceptionMessage("Cannot get logout URL for unauthenticated session"); + + $cookieSession->getLogoutUrl(); + } + + public function testLoadSealedSessionReturnsValidCookieSession() + { + $cookieSession = $this->userManagement->loadSealedSession( + $this->sealedSession, + $this->cookiePassword + ); + + $this->assertInstanceOf(CookieSession::class, $cookieSession); + } +} diff --git a/tests/WorkOS/Session/HaliteSessionEncryptionTest.php b/tests/WorkOS/Session/HaliteSessionEncryptionTest.php new file mode 100644 index 0000000..540b9df --- /dev/null +++ b/tests/WorkOS/Session/HaliteSessionEncryptionTest.php @@ -0,0 +1,175 @@ +encryptor = new HaliteSessionEncryption(); + } + + public function testSealAndUnseal() + { + $data = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + + $sealed = $this->encryptor->seal($data, $this->password); + + $this->assertIsString($sealed); + $this->assertNotEmpty($sealed); + $this->assertGreaterThan(0, strlen($sealed)); + + $unsealed = $this->encryptor->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testSealedDataIsDifferentEachTime() + { + $data = ['test' => 'value']; + + $sealed1 = $this->encryptor->seal($data, $this->password); + $sealed2 = $this->encryptor->seal($data, $this->password); + + // Encrypted data should be different each time due to random nonce + $this->assertNotEquals($sealed1, $sealed2); + + // But both should decrypt to the same value + $unsealed1 = $this->encryptor->unseal($sealed1, $this->password); + $unsealed2 = $this->encryptor->unseal($sealed2, $this->password); + + $this->assertEquals($data, $unsealed1); + $this->assertEquals($data, $unsealed2); + } + + public function testUnsealWithWrongPasswordFails() + { + $data = ['test' => 'value']; + $sealed = $this->encryptor->seal($data, $this->password); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Failed to unseal session'); + + $this->encryptor->unseal($sealed, 'wrong-password-that-should-not-work'); + } + + public function testExpiredSessionFails() + { + $data = ['test' => 'value']; + $sealed = $this->encryptor->seal($data, $this->password, -1); // Already expired (TTL of -1 second) + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Session has expired'); + + $this->encryptor->unseal($sealed, $this->password); + } + + public function testCustomTTL() + { + $data = ['test' => 'value']; + $ttl = 3600; // 1 hour + + $sealed = $this->encryptor->seal($data, $this->password, $ttl); + $unsealed = $this->encryptor->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testLongTTL() + { + $data = ['test' => 'value']; + $ttl = 2592000; // 30 days (WorkOS session default) + + $sealed = $this->encryptor->seal($data, $this->password, $ttl); + $unsealed = $this->encryptor->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testComplexDataStructures() + { + $data = [ + 'access_token' => 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...', + 'refresh_token' => 'refresh_01H7X1M4TZJN5N4HG4XXMA1234', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234', + 'user' => [ + 'id' => 'user_123', + 'email' => 'test@example.com', + 'first_name' => 'Test', + 'last_name' => 'User' + ], + 'organization_id' => 'org_123', + 'roles' => ['admin', 'user'], + 'permissions' => ['read', 'write', 'delete'] + ]; + + $sealed = $this->encryptor->seal($data, $this->password); + $unsealed = $this->encryptor->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testInvalidBase64Fails() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Failed to unseal session'); + + $this->encryptor->unseal('not-valid-base64-!@#$%^&*()', $this->password); + } + + public function testCorruptedDataFails() + { + $data = ['test' => 'value']; + $sealed = $this->encryptor->seal($data, $this->password); + + // Corrupt the sealed data by modifying a character + $corrupted = substr($sealed, 0, -5) . 'XXXXX'; + + $this->expectException(UnexpectedValueException::class); + + $this->encryptor->unseal($corrupted, $this->password); + } + + public function testEmptyDataArray() + { + $data = []; + + $sealed = $this->encryptor->seal($data, $this->password); + $unsealed = $this->encryptor->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testDifferentPasswordsProduceDifferentResults() + { + $data = ['test' => 'value']; + $password1 = 'password-one-for-testing-encryption'; + $password2 = 'password-two-for-testing-encryption'; + + $sealed1 = $this->encryptor->seal($data, $password1); + $sealed2 = $this->encryptor->seal($data, $password2); + + $this->assertNotEquals($sealed1, $sealed2); + + // Each can only be unsealed with its own password + $unsealed1 = $this->encryptor->unseal($sealed1, $password1); + $this->assertEquals($data, $unsealed1); + + $unsealed2 = $this->encryptor->unseal($sealed2, $password2); + $this->assertEquals($data, $unsealed2); + + // Trying to unseal with the wrong password should fail + $this->expectException(UnexpectedValueException::class); + $this->encryptor->unseal($sealed1, $password2); + } +} diff --git a/tests/WorkOS/UserManagementTest.php b/tests/WorkOS/UserManagementTest.php index 3a71d17..6f76a69 100644 --- a/tests/WorkOS/UserManagementTest.php +++ b/tests/WorkOS/UserManagementTest.php @@ -2199,4 +2199,188 @@ private function enrollAuthChallengeFixture() "authenticationFactorId" => "auth_factor_01FXNWW32G7F3MG8MYK5D1HJJM" ]; } + + // Session Management Tests + + public function testListSessions() + { + $userId = "user_01H7X1M4TZJN5N4HG4XXMA1234"; + $path = "user_management/users/{$userId}/sessions"; + + $result = json_encode([ + "data" => [ + [ + "id" => "session_01H7X1M4TZJN5N4HG4XXMA1234", + "user_id" => $userId, + "ip_address" => "192.168.1.1", + "user_agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "organization_id" => "org_01H7X1M4TZJN5N4HG4XXMA9876", + "authentication_method" => "SSO", + "status" => "active", + "expires_at" => "2026-02-01T00:00:00.000Z", + "ended_at" => null, + "created_at" => "2026-01-01T00:00:00.000Z", + "updated_at" => "2026-01-01T00:00:00.000Z", + "object" => "session" + ], + [ + "id" => "session_01H7X1M4TZJN5N4HG4XXMA5678", + "user_id" => $userId, + "ip_address" => "192.168.1.2", + "user_agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "organization_id" => null, + "authentication_method" => "Password", + "status" => "active", + "expires_at" => "2026-02-01T00:00:00.000Z", + "ended_at" => null, + "created_at" => "2026-01-01T00:00:00.000Z", + "updated_at" => "2026-01-01T00:00:00.000Z", + "object" => "session" + ] + ], + "list_metadata" => ["before" => null, "after" => null] + ]); + + $this->mockRequest( + Client::METHOD_GET, + $path, + null, + ["limit" => 10, "before" => null, "after" => null, "order" => null], + true, + $result + ); + + list($before, $after, $sessions) = $this->userManagement->listSessions($userId); + + $this->assertCount(2, $sessions); + $this->assertInstanceOf(Resource\Session::class, $sessions[0]); + $this->assertEquals("session_01H7X1M4TZJN5N4HG4XXMA1234", $sessions[0]->id); + $this->assertEquals("active", $sessions[0]->status); + $this->assertEquals("192.168.1.1", $sessions[0]->ipAddress); + $this->assertEquals("SSO", $sessions[0]->authenticationMethod); + } + + public function testRevokeSession() + { + $sessionId = "session_01H7X1M4TZJN5N4HG4XXMA1234"; + $path = "user_management/sessions/{$sessionId}/revoke"; + + $result = json_encode([ + "id" => $sessionId, + "user_id" => "user_01H7X1M4TZJN5N4HG4XXMA1234", + "ip_address" => "192.168.1.1", + "user_agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "organization_id" => null, + "authentication_method" => "Password", + "status" => "inactive", + "expires_at" => "2026-02-01T00:00:00.000Z", + "ended_at" => "2026-01-05T12:00:00.000Z", + "created_at" => "2026-01-01T00:00:00.000Z", + "updated_at" => "2026-01-05T12:00:00.000Z", + "object" => "session" + ]); + + $this->mockRequest( + Client::METHOD_POST, + $path, + null, + null, + true, + $result + ); + + $session = $this->userManagement->revokeSession($sessionId); + + $this->assertInstanceOf(Resource\Session::class, $session); + $this->assertEquals($sessionId, $session->id); + $this->assertEquals("inactive", $session->status); + $this->assertNotNull($session->endedAt); + $this->assertEquals("2026-01-05T12:00:00.000Z", $session->endedAt); + } + + public function testSealSession() + { + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + $cookiePassword = 'test-password-for-encryption-with-minimum-length'; + + $sealed = $this->userManagement->sealSession($sessionData, $cookiePassword); + + $this->assertIsString($sealed); + $this->assertNotEmpty($sealed); + + // Verify we can unseal it + $encryptor = new Session\HaliteSessionEncryption(); + $unsealed = $encryptor->unseal($sealed, $cookiePassword); + $this->assertEquals($sessionData, $unsealed); + } + + public function testAuthenticateWithSessionCookieNoSessionProvided() + { + $result = $this->userManagement->authenticateWithSessionCookie("", "password"); + + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertFalse($result->authenticated); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_NO_SESSION_COOKIE_PROVIDED, + $result->reason + ); + } + + public function testLoadSealedSession() + { + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + $cookiePassword = 'test-password-for-encryption-with-minimum-length'; + + $sealed = $this->userManagement->sealSession($sessionData, $cookiePassword); + $cookieSession = $this->userManagement->loadSealedSession($sealed, $cookiePassword); + + $this->assertInstanceOf(CookieSession::class, $cookieSession); + } + + public function testGetSessionFromCookieWithNoCookie() + { + $cookiePassword = 'test-password-for-encryption-with-minimum-length'; + + // Ensure no cookie is set + if (isset($_COOKIE['wos-session'])) { + unset($_COOKIE['wos-session']); + } + + $result = $this->userManagement->getSessionFromCookie($cookiePassword); + + $this->assertNull($result); + } + + public function testGetSessionFromCookieWithCookie() + { + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + $cookiePassword = 'test-password-for-encryption-with-minimum-length'; + + $sealed = $this->userManagement->sealSession($sessionData, $cookiePassword); + + // Simulate cookie being set + $_COOKIE['wos-session'] = $sealed; + + $cookieSession = $this->userManagement->getSessionFromCookie($cookiePassword); + + $this->assertInstanceOf(CookieSession::class, $cookieSession); + + // Cleanup + unset($_COOKIE['wos-session']); + } } From 19d649b15e07890f0810273695c40c3968bc1de5 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:49:10 -0600 Subject: [PATCH 2/2] Downgrade Halite to v4 to support PHP v7 --- composer.json | 2 +- lib/CookieSession.php | 3 ++ tests/WorkOS/CookieSessionTest.php | 80 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 90ce35d..a3a630a 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": ">=7.3.0", "ext-curl": "*", - "paragonie/halite": "^5.0" + "paragonie/halite": "^4.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.15|^3.6", diff --git a/lib/CookieSession.php b/lib/CookieSession.php index 55f9e05..e233c9c 100644 --- a/lib/CookieSession.php +++ b/lib/CookieSession.php @@ -84,7 +84,10 @@ public function refresh(array $options = []) // Use the refresh token to get new authentication tokens try { $refreshedAuth = $this->userManagement->authenticateWithRefreshToken( + WorkOS::getClientId(), $authResult->refreshToken, + null, + null, $organizationId ); diff --git a/tests/WorkOS/CookieSessionTest.php b/tests/WorkOS/CookieSessionTest.php index b1ab2e1..a768588 100644 --- a/tests/WorkOS/CookieSessionTest.php +++ b/tests/WorkOS/CookieSessionTest.php @@ -94,4 +94,84 @@ public function testLoadSealedSessionReturnsValidCookieSession() $this->assertInstanceOf(CookieSession::class, $cookieSession); } + + public function testRefreshPassesCorrectParametersToAuthenticateWithRefreshToken() + { + // REGRESSION TEST: Verify that CookieSession.refresh() passes all 5 parameters + // to authenticateWithRefreshToken(), not just 2. The clientId parameter must be + // included from WorkOS::getClientId() (see CookieSession.php:86-92) + + $organizationId = "org_01H7X1M4TZJN5N4HG4XXMA1234"; + + // Create a mock UserManagement to verify method calls + $userManagementMock = $this->getMockBuilder(UserManagement::class) + ->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken', 'sealSession']) + ->getMock(); + + // Mock authenticateWithSessionCookie to return a successful authentication + $authResponseData = [ + 'authenticated' => true, + 'access_token' => 'test_access_token', + 'refresh_token' => 'test_refresh_token', + 'session_id' => 'session_123', + 'user' => [ + 'object' => 'user', + 'id' => 'user_123', + 'email' => 'test@test.com', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email_verified' => true, + 'created_at' => '2021-01-01T00:00:00.000Z', + 'updated_at' => '2021-01-01T00:00:00.000Z' + ] + ]; + $authResponse = Resource\SessionAuthenticationSuccessResponse::constructFromResponse($authResponseData); + $userManagementMock->method('authenticateWithSessionCookie') + ->willReturn($authResponse); + + // CRITICAL ASSERTION: Verify authenticateWithRefreshToken is called with exactly 5 parameters + $refreshResponseData = [ + 'access_token' => 'new_access_token', + 'refresh_token' => 'new_refresh_token', + 'user' => [ + 'object' => 'user', + 'id' => 'user_123', + 'email' => 'test@test.com', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email_verified' => true, + 'created_at' => '2021-01-01T00:00:00.000Z', + 'updated_at' => '2021-01-01T00:00:00.000Z' + ] + ]; + $refreshResponse = Resource\AuthenticationResponse::constructFromResponse($refreshResponseData); + + $userManagementMock->expects($this->once()) + ->method('authenticateWithRefreshToken') + ->with( + $this->identicalTo(WorkOS::getClientId()), // clientId from config + $this->identicalTo('test_refresh_token'), // refresh token + $this->identicalTo(null), // ipAddress + $this->identicalTo(null), // userAgent + $this->identicalTo($organizationId) // organizationId + ) + ->willReturn($refreshResponse); + + $userManagementMock->method('sealSession') + ->willReturn('new_sealed_session'); + + // Execute refresh with the mocked UserManagement + $cookieSession = new CookieSession( + $userManagementMock, + $this->sealedSession, + $this->cookiePassword + ); + + [$response, $newSealedSession] = $cookieSession->refresh([ + 'organizationId' => $organizationId + ]); + + // If we reach here without the mock throwing an exception, the test passes + $this->assertInstanceOf(Resource\SessionAuthenticationSuccessResponse::class, $response); + } }