From 78aafc3462b934abab65ea8c7012c284bb5ef01f Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:25:42 -0600 Subject: [PATCH 1/8] chore: add Halite encryption dependency Add paragonie/halite ^4.0 for PHP 7.3+ compatible symmetric encryption. This library provides libsodium-based encryption for session data. --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d1b07be..a3a630a 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ ], "require": { "php": ">=7.3.0", - "ext-curl": "*" + "ext-curl": "*", + "paragonie/halite": "^4.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.15|^3.6", From 133b8ef2c1ab4c7c9d9c813f72dcc006913d70e6 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:25:53 -0600 Subject: [PATCH 2/8] feat: add Session resource model Add Session resource class for representing user sessions with properties: id, userId, ipAddress, userAgent, organizationId, authenticationMethod, status, expiresAt, endedAt, createdAt, updatedAt. --- lib/Resource/Session.php | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 lib/Resource/Session.php 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" + ]; +} From ed7f007c8370a0dd2d11495751265969371053a1 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:04 -0600 Subject: [PATCH 3/8] feat: add session authentication response resources Add SessionAuthenticationFailureResponse with failure reason constants: - NO_SESSION_COOKIE_PROVIDED - INVALID_SESSION_COOKIE - ENCRYPTION_ERROR - HTTP_ERROR Add SessionAuthenticationSuccessResponse with full session data including user, tokens, organization, roles, permissions, entitlements, and impersonator. --- .../SessionAuthenticationFailureResponse.php | 43 ++++++++ .../SessionAuthenticationSuccessResponse.php | 102 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 lib/Resource/SessionAuthenticationFailureResponse.php create mode 100644 lib/Resource/SessionAuthenticationSuccessResponse.php diff --git a/lib/Resource/SessionAuthenticationFailureResponse.php b/lib/Resource/SessionAuthenticationFailureResponse.php new file mode 100644 index 0000000..b00aefb --- /dev/null +++ b/lib/Resource/SessionAuthenticationFailureResponse.php @@ -0,0 +1,43 @@ + "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; + } +} From 5638685f96d0ac6cbf907fd077be10757c8d48d5 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:16 -0600 Subject: [PATCH 4/8] feat: add session encryption interface and Halite implementation Add SessionEncryptionInterface defining seal/unseal contract for session encryption providers. Add HaliteSessionEncryption implementing libsodium-based symmetric encryption with HKDF key derivation and TTL validation. Default TTL is 30 days to match WorkOS session lifetime. Include comprehensive tests for encryption, TTL expiration, wrong password handling, and data integrity. --- lib/Session/HaliteSessionEncryption.php | 119 ++++++++++++ lib/Session/SessionEncryptionInterface.php | 34 ++++ .../Session/HaliteSessionEncryptionTest.php | 175 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 lib/Session/HaliteSessionEncryption.php create mode 100644 lib/Session/SessionEncryptionInterface.php create mode 100644 tests/WorkOS/Session/HaliteSessionEncryptionTest.php 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 @@ +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); + } +} From ffb53181d7cd5a0b618c706981a52b829fdf5055 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:30 -0600 Subject: [PATCH 5/8] feat: add signing-only session handler Add SigningOnlySessionHandler as an alternative to full encryption. Uses HMAC-SHA256 for integrity verification without encrypting the payload. Suitable for TLS-only environments where encryption overhead is undesirable. Includes version field for future compatibility, TTL validation, and constant-time signature comparison to prevent timing attacks. --- lib/Session/SigningOnlySessionHandler.php | 99 +++++++ .../Session/SigningOnlySessionHandlerTest.php | 260 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 lib/Session/SigningOnlySessionHandler.php create mode 100644 tests/WorkOS/Session/SigningOnlySessionHandlerTest.php diff --git a/lib/Session/SigningOnlySessionHandler.php b/lib/Session/SigningOnlySessionHandler.php new file mode 100644 index 0000000..77ef469 --- /dev/null +++ b/lib/Session/SigningOnlySessionHandler.php @@ -0,0 +1,99 @@ + self::VERSION, + 'd' => $data, + 'e' => $expiry, + ]; + + $payloadJson = json_encode($payload); + $signature = hash_hmac(self::ALGORITHM, $payloadJson, $password, true); + + $sealed = [ + 'p' => base64_encode($payloadJson), + 's' => base64_encode($signature), + ]; + + return base64_encode(json_encode($sealed)); + } + + /** + * Unseal session data by verifying HMAC signature. + * + * @param string $sealed Signed session string + * @param string $password HMAC key + * @return array Unsealed session data + * @throws UnexpectedValueException If signature invalid or expired + */ + public function unseal(string $sealed, string $password): array + { + $decoded = json_decode(base64_decode($sealed), true); + if (!$decoded || !isset($decoded['p']) || !isset($decoded['s'])) { + throw new UnexpectedValueException('Invalid signed session format'); + } + + $payloadJson = base64_decode($decoded['p']); + $providedSignature = base64_decode($decoded['s']); + $expectedSignature = hash_hmac(self::ALGORITHM, $payloadJson, $password, true); + + // Constant-time comparison to prevent timing attacks + if (!hash_equals($expectedSignature, $providedSignature)) { + throw new UnexpectedValueException('Invalid session signature'); + } + + $payload = json_decode($payloadJson, true); + if (!$payload || !isset($payload['v']) || !isset($payload['d']) || !isset($payload['e'])) { + throw new UnexpectedValueException('Invalid payload structure'); + } + + // Version check for future compatibility + if ($payload['v'] !== self::VERSION) { + throw new UnexpectedValueException('Unsupported session version'); + } + + // TTL check + if ($payload['e'] < time()) { + throw new UnexpectedValueException('Session expired'); + } + + return $payload['d']; + } +} diff --git a/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php new file mode 100644 index 0000000..282fb31 --- /dev/null +++ b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php @@ -0,0 +1,260 @@ +handler = new SigningOnlySessionHandler(); + } + + public function testSealAndUnseal() + { + $data = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + + $sealed = $this->handler->seal($data, $this->password); + + $this->assertIsString($sealed); + $this->assertNotEmpty($sealed); + + $unsealed = $this->handler->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testSealedDataIsReadable() + { + $data = ['test' => 'value']; + + $sealed = $this->handler->seal($data, $this->password); + + // Signing-only data should be decodable (not encrypted) + $decoded = json_decode(base64_decode($sealed), true); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('p', $decoded); // payload + $this->assertArrayHasKey('s', $decoded); // signature + + // Payload should be readable + $payloadJson = base64_decode($decoded['p']); + $payload = json_decode($payloadJson, true); + $this->assertIsArray($payload); + $this->assertArrayHasKey('d', $payload); // data + $this->assertEquals($data, $payload['d']); + } + + public function testUnsealWithWrongPasswordFails() + { + $data = ['test' => 'value']; + $sealed = $this->handler->seal($data, $this->password); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid session signature'); + + $this->handler->unseal($sealed, 'wrong-password'); + } + + public function testExpiredSessionFails() + { + $data = ['test' => 'value']; + $sealed = $this->handler->seal($data, $this->password, -1); // Already expired + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Session expired'); + + $this->handler->unseal($sealed, $this->password); + } + + public function testCustomTTL() + { + $data = ['test' => 'value']; + $ttl = 3600; // 1 hour + + $sealed = $this->handler->seal($data, $this->password, $ttl); + $unsealed = $this->handler->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testTamperedDataFails() + { + $data = ['test' => 'value']; + $sealed = $this->handler->seal($data, $this->password); + + // Decode, modify, re-encode (without updating signature) + $decoded = json_decode(base64_decode($sealed), true); + $payloadJson = base64_decode($decoded['p']); + $payload = json_decode($payloadJson, true); + $payload['d'] = ['test' => 'tampered']; // Modify the data + $decoded['p'] = base64_encode(json_encode($payload)); + $tampered = base64_encode(json_encode($decoded)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid session signature'); + + $this->handler->unseal($tampered, $this->password); + } + + public function testInvalidFormatFails() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid signed session format'); + + $this->handler->unseal('not-valid-base64-data', $this->password); + } + + public function testMissingPayloadFieldFails() + { + $invalid = base64_encode(json_encode(['s' => 'signature-only'])); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid signed session format'); + + $this->handler->unseal($invalid, $this->password); + } + + public function testMissingSignatureFieldFails() + { + $invalid = base64_encode(json_encode(['p' => 'payload-only'])); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid signed session format'); + + $this->handler->unseal($invalid, $this->password); + } + + public function testInvalidPayloadStructureFails() + { + // Create valid signature but with invalid payload structure + $payload = ['invalid' => 'structure']; // Missing v, d, e fields + $payloadJson = json_encode($payload); + $signature = hash_hmac('sha256', $payloadJson, $this->password, true); + + $sealed = base64_encode(json_encode([ + 'p' => base64_encode($payloadJson), + 's' => base64_encode($signature), + ])); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid payload structure'); + + $this->handler->unseal($sealed, $this->password); + } + + public function testVersionCheckFails() + { + // Create valid signature but with wrong version + $payload = [ + 'v' => 999, // Unsupported version + 'd' => ['test' => 'value'], + 'e' => time() + 3600, + ]; + $payloadJson = json_encode($payload); + $signature = hash_hmac('sha256', $payloadJson, $this->password, true); + + $sealed = base64_encode(json_encode([ + 'p' => base64_encode($payloadJson), + 's' => base64_encode($signature), + ])); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported session version'); + + $this->handler->unseal($sealed, $this->password); + } + + 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->handler->seal($data, $this->password); + $unsealed = $this->handler->unseal($sealed, $this->password); + + $this->assertEquals($data, $unsealed); + } + + public function testDifferentPasswordsProduceDifferentSignatures() + { + $data = ['test' => 'value']; + $password1 = 'password-one-for-signing'; + $password2 = 'password-two-for-signing'; + + $sealed1 = $this->handler->seal($data, $password1); + $sealed2 = $this->handler->seal($data, $password2); + + // Signatures should be different + $this->assertNotEquals($sealed1, $sealed2); + + // Each can only be unsealed with its own password + $unsealed1 = $this->handler->unseal($sealed1, $password1); + $this->assertEquals($data, $unsealed1); + + $unsealed2 = $this->handler->unseal($sealed2, $password2); + $this->assertEquals($data, $unsealed2); + } + + public function testSignatureIsConstantTimeCompared() + { + // This test verifies hash_equals is used (timing attack prevention) + // We can't directly test timing, but we ensure the code path exists + $data = ['test' => 'value']; + $sealed = $this->handler->seal($data, $this->password); + + // Valid unseal should work + $unsealed = $this->handler->unseal($sealed, $this->password); + $this->assertEquals($data, $unsealed); + } + + public function testImplementsSessionEncryptionInterface() + { + $this->assertInstanceOf(SessionEncryptionInterface::class, $this->handler); + } + + public function testCanBeUsedWithUserManagement() + { + // SigningOnlySessionHandler can be injected into UserManagement + $userManagement = new \WorkOS\UserManagement($this->handler); + + $data = [ + 'access_token' => 'test_access_token', + 'refresh_token' => 'test_refresh_token', + ]; + + // Seal directly (as authkit-php would do) + $sealed = $this->handler->seal($data, $this->password); + + // UserManagement should be able to unseal it via authenticateWithSessionCookie + // (will get HTTP error since no API, but that's past the encryption layer) + $result = $userManagement->authenticateWithSessionCookie($sealed, $this->password); + + // Should succeed past encryption (get HTTP error, not encryption error) + $this->assertInstanceOf(\WorkOS\Resource\SessionAuthenticationFailureResponse::class, $result); + $this->assertEquals( + \WorkOS\Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR, + $result->reason + ); + } +} From 05ac08c7932f5514c281745b05aea4af279d8280 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:40 -0600 Subject: [PATCH 6/8] feat: add session management to UserManagement Add session management methods: - listSessions: List sessions for a user with pagination - revokeSession: Revoke a specific session - authenticateWithSessionCookie: Validate sealed session cookies - loadSealedSession: Create CookieSession from sealed data - getSessionFromCookie: Extract session from HTTP cookies Support injectable SessionEncryptionInterface for custom encryption providers, defaulting to HaliteSessionEncryption. --- lib/UserManagement.php | 194 ++++++++++++++++++++ tests/WorkOS/UserManagementTest.php | 266 ++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) diff --git a/lib/UserManagement.php b/lib/UserManagement.php index a27a22f..4258f7a 100644 --- a/lib/UserManagement.php +++ b/lib/UserManagement.php @@ -16,6 +16,43 @@ class UserManagement public const AUTHORIZATION_PROVIDER_GOOGLE_OAUTH = "GoogleOAuth"; public const AUTHORIZATION_PROVIDER_MICROSOFT_OAUTH = "MicrosoftOAuth"; + /** + * @var Session\SessionEncryptionInterface|null + */ + private $sessionEncryptor = null; + + /** + * @param Session\SessionEncryptionInterface|null $encryptor Optional encryption provider + */ + public function __construct(?Session\SessionEncryptionInterface $encryptor = null) + { + $this->sessionEncryptor = $encryptor; + } + + /** + * Set the session encryptor. + * + * @param Session\SessionEncryptionInterface $encryptor + * @return void + */ + public function setSessionEncryptor(Session\SessionEncryptionInterface $encryptor): void + { + $this->sessionEncryptor = $encryptor; + } + + /** + * Get the session encryptor, defaulting to Halite. + * + * @return Session\SessionEncryptionInterface + */ + private function getSessionEncryptor(): Session\SessionEncryptionInterface + { + if ($this->sessionEncryptor === null) { + $this->sessionEncryptor = new Session\HaliteSessionEncryption(); + } + return $this->sessionEncryptor; + } + /** * Create User. * @@ -1328,4 +1365,161 @@ public function getLogoutUrl(string $sessionId, ?string $return_to = null) return Client::generateUrl("user_management/sessions/logout", $params); } + + /** + * List sessions for a user. + * + * @param string $userId User ID + * @param array $options Additional options + * - 'limit' (int): Maximum number of records to return (default: 10) + * - 'before' (string|null): Session ID to look before + * - 'after' (string|null): Session ID to look after + * - 'order' (string|null): The order in which to paginate records + * + * @return array{?string, ?string, Resource\Session[]} + * An array containing before/after cursors and array of Session instances + * @throws Exception\WorkOSException + */ + public function listSessions(string $userId, array $options = []) + { + $path = "user_management/users/{$userId}/sessions"; + + $params = [ + "limit" => $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); + } + + /** + * 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 + ); + } + + // Tight try/catch for unsealing only + try { + $sessionData = $this->getSessionEncryptor()->unseal($sealedSession, $cookiePassword); + } catch (\Exception $e) { + return new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_ENCRYPTION_ERROR + ); + } + + if (!isset($sessionData['access_token']) || !isset($sessionData['refresh_token'])) { + return new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE + ); + } + + // Separate try/catch for HTTP request + try { + $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_HTTP_ERROR + ); + } + } + + /** + * 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/UserManagementTest.php b/tests/WorkOS/UserManagementTest.php index 3a71d17..82d666b 100644 --- a/tests/WorkOS/UserManagementTest.php +++ b/tests/WorkOS/UserManagementTest.php @@ -2199,4 +2199,270 @@ 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 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'; + + // Use encryptor directly (sealing is authkit-php's responsibility) + $encryptor = new Session\HaliteSessionEncryption(); + $sealed = $encryptor->seal($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'; + + // Use encryptor directly (sealing is authkit-php's responsibility) + $encryptor = new Session\HaliteSessionEncryption(); + $sealed = $encryptor->seal($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']); + } + + public function testConstructorWithCustomEncryptor() + { + $mockEncryptor = $this->createMock(Session\SessionEncryptionInterface::class); + $mockEncryptor->method('unseal') + ->willReturn(['access_token' => 'test', 'refresh_token' => 'test']); + + $userManagement = new UserManagement($mockEncryptor); + + // The custom encryptor should be used for authentication + // Mock will succeed on unseal, but API call will fail - we just verify no encryption error + $result = $userManagement->authenticateWithSessionCookie('any_sealed_data', 'password'); + + // Should get past encryption (HTTP error expected, not encryption error) + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR, + $result->reason + ); + } + + public function testSetSessionEncryptor() + { + $mockEncryptor = $this->createMock(Session\SessionEncryptionInterface::class); + $mockEncryptor->method('unseal') + ->willReturn(['access_token' => 'test', 'refresh_token' => 'test']); + + $userManagement = new UserManagement(); + $userManagement->setSessionEncryptor($mockEncryptor); + + // The custom encryptor should be used for authentication + $result = $userManagement->authenticateWithSessionCookie('any_sealed_data', 'password'); + + // Should get past encryption (HTTP error expected, not encryption error) + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR, + $result->reason + ); + } + + public function testAuthenticateWithSessionCookieEncryptionError() + { + $mockEncryptor = $this->createMock(Session\SessionEncryptionInterface::class); + $mockEncryptor->method('unseal') + ->willThrowException(new \Exception('Decryption failed')); + + $userManagement = new UserManagement($mockEncryptor); + $result = $userManagement->authenticateWithSessionCookie('invalid_sealed_data', 'password'); + + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertFalse($result->authenticated); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_ENCRYPTION_ERROR, + $result->reason + ); + } + + public function testAuthenticateWithSessionCookieHttpError() + { + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890' + ]; + $cookiePassword = 'test-password-for-encryption-with-minimum-length'; + + // Use encryptor directly (sealing is authkit-php's responsibility) + $encryptor = new Session\HaliteSessionEncryption(); + $sealed = $encryptor->seal($sessionData, $cookiePassword); + + // Set up mock to throw exception on API call + Client::setRequestClient($this->requestClientMock); + $this->requestClientMock + ->expects($this->atLeastOnce()) + ->method('request') + ->willThrowException(new \Exception('HTTP request failed')); + + $result = $this->userManagement->authenticateWithSessionCookie($sealed, $cookiePassword); + + $this->assertInstanceOf( + Resource\SessionAuthenticationFailureResponse::class, + $result + ); + $this->assertFalse($result->authenticated); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR, + $result->reason + ); + } } From 36e02798612741b5140d3dd109edbac19ca32187 Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:26:51 -0600 Subject: [PATCH 7/8] feat: add CookieSession class Add CookieSession for high-level session management: - authenticate: Validate session and return user data - refresh: Refresh expired tokens and return raw tokens for re-sealing - getLogoutUrl: Generate logout URL for the session Designed to match workos-node CookieSession behavior. Sealing tokens is the responsibility of the calling code (e.g., authkit-php). --- lib/CookieSession.php | 149 +++++++++++++++++ tests/WorkOS/CookieSessionTest.php | 251 +++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 lib/CookieSession.php create mode 100644 tests/WorkOS/CookieSessionTest.php diff --git a/lib/CookieSession.php b/lib/CookieSession.php new file mode 100644 index 0000000..6893818 --- /dev/null +++ b/lib/CookieSession.php @@ -0,0 +1,149 @@ +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 returns new tokens. + * + * Note: This method returns raw tokens. The calling code (e.g., authkit-php) + * is responsible for sealing the tokens into a new session cookie. + * + * @param array $options Options for session refresh + * - 'organizationId' (string|null): Organization to scope the session to + * + * @return array{SessionAuthenticationSuccessResponse|SessionAuthenticationFailureResponse, array|null} + * Returns [response, newTokens] where newTokens contains: + * - 'access_token': The new access token + * - 'refresh_token': The new refresh token + * - 'session_id': The session ID + * Returns [failureResponse, null] on error. + * @throws Exception\WorkOSException + */ + public function refresh(array $options = []) + { + $organizationId = $options['organizationId'] ?? null; + + // First authenticate to get the current session data + $authResult = $this->authenticate(); + + if (!$authResult->authenticated) { + return [$authResult, null]; + } + + // Tight try/catch for refresh token API call + try { + $refreshedAuth = $this->userManagement->authenticateWithRefreshToken( + WorkOS::getClientId(), + $authResult->refreshToken, + null, + null, + $organizationId + ); + } catch (\Exception $e) { + $failureResponse = new SessionAuthenticationFailureResponse( + SessionAuthenticationFailureResponse::REASON_HTTP_ERROR + ); + return [$failureResponse, null]; + } + + // Build success response + $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 raw tokens for the caller to seal + $newTokens = [ + 'access_token' => $refreshedAuth->accessToken, + 'refresh_token' => $refreshedAuth->refreshToken, + 'session_id' => $authResult->sessionId + ]; + + return [$successResponse, $newTokens]; + } + + /** + * 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/tests/WorkOS/CookieSessionTest.php b/tests/WorkOS/CookieSessionTest.php new file mode 100644 index 0000000..af13ac3 --- /dev/null +++ b/tests/WorkOS/CookieSessionTest.php @@ -0,0 +1,251 @@ +traitSetUp(); + $this->withApiKeyAndClientId(); + $this->userManagement = new UserManagement(); + + // Create a sealed session for testing using encryptor directly + // (sealing is authkit-php's responsibility, not SDK's) + $sessionData = [ + 'access_token' => 'test_access_token_12345', + 'refresh_token' => 'test_refresh_token_67890', + 'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234' + ]; + $encryptor = new Session\HaliteSessionEncryption(); + $this->sealedSession = $encryptor->seal($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); + } + + public function testRefreshReturnsRawTokensOnSuccess() + { + $organizationId = "org_01H7X1M4TZJN5N4HG4XXMA1234"; + + // Create a mock UserManagement to verify method calls + $userManagementMock = $this->getMockBuilder(UserManagement::class) + ->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken']) + ->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); + + // Setup refresh to succeed + $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); + + // Execute refresh with the mocked UserManagement + $cookieSession = new CookieSession( + $userManagementMock, + $this->sealedSession, + $this->cookiePassword + ); + + [$response, $tokens] = $cookieSession->refresh([ + 'organizationId' => $organizationId + ]); + + // Verify response is successful + $this->assertInstanceOf(Resource\SessionAuthenticationSuccessResponse::class, $response); + $this->assertTrue($response->authenticated); + + // Verify tokens are returned as raw array (not sealed) + $this->assertIsArray($tokens); + $this->assertArrayHasKey('access_token', $tokens); + $this->assertArrayHasKey('refresh_token', $tokens); + $this->assertArrayHasKey('session_id', $tokens); + $this->assertEquals('new_access_token', $tokens['access_token']); + $this->assertEquals('new_refresh_token', $tokens['refresh_token']); + $this->assertEquals('session_123', $tokens['session_id']); + } + + public function testRefreshReturnsNullTokensOnAuthFailure() + { + $userManagementMock = $this->getMockBuilder(UserManagement::class) + ->onlyMethods(['authenticateWithSessionCookie']) + ->getMock(); + + $failResponse = new Resource\SessionAuthenticationFailureResponse( + Resource\SessionAuthenticationFailureResponse::REASON_INVALID_SESSION_COOKIE + ); + + $userManagementMock->method('authenticateWithSessionCookie') + ->willReturn($failResponse); + + $cookieSession = new CookieSession( + $userManagementMock, + 'invalid-session', + $this->cookiePassword + ); + + [$response, $tokens] = $cookieSession->refresh(); + + $this->assertFalse($response->authenticated); + $this->assertNull($tokens); + } + + public function testRefreshReturnsHttpErrorOnApiFailure() + { + $userManagementMock = $this->getMockBuilder(UserManagement::class) + ->onlyMethods(['authenticateWithSessionCookie', 'authenticateWithRefreshToken']) + ->getMock(); + + // Mock successful initial auth + $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); + + // Mock refresh to throw exception + $userManagementMock->method('authenticateWithRefreshToken') + ->willThrowException(new \Exception('HTTP request failed')); + + $cookieSession = new CookieSession( + $userManagementMock, + $this->sealedSession, + $this->cookiePassword + ); + + [$response, $tokens] = $cookieSession->refresh(); + + $this->assertFalse($response->authenticated); + $this->assertEquals( + Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR, + $response->reason + ); + $this->assertNull($tokens); + } +} From 0666e7822f5254677faadbc2181baa73a87475ed Mon Sep 17 00:00:00 2001 From: Birdcar <434063+birdcar@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:45:01 -0600 Subject: [PATCH 8/8] fix: tighten exception handling to catch only HTTP errors Change catch blocks from \Exception to Exception\BaseRequestException to only catch HTTP-related errors from Client::request(). This prevents accidentally swallowing unrelated errors like TypeErrors or configuration issues that should bubble up for debugging. Addresses review feedback on PR #315. --- lib/CookieSession.php | 2 +- lib/UserManagement.php | 2 +- tests/WorkOS/CookieSessionTest.php | 5 +++-- .../Session/SigningOnlySessionHandlerTest.php | 17 +++++++++++++++++ tests/WorkOS/UserManagementTest.php | 19 +++++++++++++++++-- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/CookieSession.php b/lib/CookieSession.php index 6893818..4714c94 100644 --- a/lib/CookieSession.php +++ b/lib/CookieSession.php @@ -96,7 +96,7 @@ public function refresh(array $options = []) null, $organizationId ); - } catch (\Exception $e) { + } catch (Exception\BaseRequestException $e) { $failureResponse = new SessionAuthenticationFailureResponse( SessionAuthenticationFailureResponse::REASON_HTTP_ERROR ); diff --git a/lib/UserManagement.php b/lib/UserManagement.php index 4258f7a..b0bc7a7 100644 --- a/lib/UserManagement.php +++ b/lib/UserManagement.php @@ -1483,7 +1483,7 @@ public function authenticateWithSessionCookie( ); return Resource\SessionAuthenticationSuccessResponse::constructFromResponse($response); - } catch (\Exception $e) { + } catch (Exception\BaseRequestException $e) { return new Resource\SessionAuthenticationFailureResponse( Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR ); diff --git a/tests/WorkOS/CookieSessionTest.php b/tests/WorkOS/CookieSessionTest.php index af13ac3..1398c3c 100644 --- a/tests/WorkOS/CookieSessionTest.php +++ b/tests/WorkOS/CookieSessionTest.php @@ -229,9 +229,10 @@ public function testRefreshReturnsHttpErrorOnApiFailure() $userManagementMock->method('authenticateWithSessionCookie') ->willReturn($authResponse); - // Mock refresh to throw exception + // Mock refresh to throw HTTP exception + $response = new Resource\Response('{"error": "server_error"}', [], 500); $userManagementMock->method('authenticateWithRefreshToken') - ->willThrowException(new \Exception('HTTP request failed')); + ->willThrowException(new Exception\ServerException($response)); $cookieSession = new CookieSession( $userManagementMock, diff --git a/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php index 282fb31..0c377a6 100644 --- a/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php +++ b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php @@ -4,15 +4,21 @@ use PHPUnit\Framework\TestCase; use WorkOS\Exception\UnexpectedValueException; +use WorkOS\Client; +use WorkOS\Resource\Response; +use WorkOS\Exception\ServerException; +use WorkOS\WorkOS; class SigningOnlySessionHandlerTest extends TestCase { private $handler; private $password = "test-password-for-hmac-signing"; + private $requestClientMock; protected function setUp(): void { $this->handler = new SigningOnlySessionHandler(); + $this->requestClientMock = $this->createMock(\WorkOS\RequestClient\RequestClientInterface::class); } public function testSealAndUnseal() @@ -235,6 +241,17 @@ public function testImplementsSessionEncryptionInterface() public function testCanBeUsedWithUserManagement() { + // Set up required configuration + WorkOS::setApiKey('sk_test_12345'); + + // Set up mock to throw HTTP exception on API call + $response = new Response('{"error": "server_error"}', [], 500); + Client::setRequestClient($this->requestClientMock); + $this->requestClientMock + ->expects($this->atLeastOnce()) + ->method('request') + ->willThrowException(new ServerException($response)); + // SigningOnlySessionHandler can be injected into UserManagement $userManagement = new \WorkOS\UserManagement($this->handler); diff --git a/tests/WorkOS/UserManagementTest.php b/tests/WorkOS/UserManagementTest.php index 82d666b..269b897 100644 --- a/tests/WorkOS/UserManagementTest.php +++ b/tests/WorkOS/UserManagementTest.php @@ -2374,6 +2374,13 @@ public function testConstructorWithCustomEncryptor() $mockEncryptor->method('unseal') ->willReturn(['access_token' => 'test', 'refresh_token' => 'test']); + // Create fresh HTTP client mock to throw exception + $httpMock = $this->createMock(\WorkOS\RequestClient\RequestClientInterface::class); + $response = new Resource\Response('{"error": "server_error"}', [], 500); + $httpMock->method('request') + ->willThrowException(new Exception\ServerException($response)); + Client::setRequestClient($httpMock); + $userManagement = new UserManagement($mockEncryptor); // The custom encryptor should be used for authentication @@ -2397,6 +2404,13 @@ public function testSetSessionEncryptor() $mockEncryptor->method('unseal') ->willReturn(['access_token' => 'test', 'refresh_token' => 'test']); + // Create fresh HTTP client mock to throw exception + $httpMock = $this->createMock(\WorkOS\RequestClient\RequestClientInterface::class); + $response = new Resource\Response('{"error": "server_error"}', [], 500); + $httpMock->method('request') + ->willThrowException(new Exception\ServerException($response)); + Client::setRequestClient($httpMock); + $userManagement = new UserManagement(); $userManagement->setSessionEncryptor($mockEncryptor); @@ -2446,12 +2460,13 @@ public function testAuthenticateWithSessionCookieHttpError() $encryptor = new Session\HaliteSessionEncryption(); $sealed = $encryptor->seal($sessionData, $cookiePassword); - // Set up mock to throw exception on API call + // Set up mock to throw HTTP exception on API call + $response = new Resource\Response('{"error": "server_error"}', [], 500); Client::setRequestClient($this->requestClientMock); $this->requestClientMock ->expects($this->atLeastOnce()) ->method('request') - ->willThrowException(new \Exception('HTTP request failed')); + ->willThrowException(new Exception\ServerException($response)); $result = $this->userManagement->authenticateWithSessionCookie($sealed, $cookiePassword);