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", diff --git a/lib/CookieSession.php b/lib/CookieSession.php new file mode 100644 index 0000000..4714c94 --- /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\BaseRequestException $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/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..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; + } +} 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 @@ + 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/lib/UserManagement.php b/lib/UserManagement.php index a27a22f..b0bc7a7 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\BaseRequestException $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/CookieSessionTest.php b/tests/WorkOS/CookieSessionTest.php new file mode 100644 index 0000000..1398c3c --- /dev/null +++ b/tests/WorkOS/CookieSessionTest.php @@ -0,0 +1,252 @@ +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 HTTP exception + $response = new Resource\Response('{"error": "server_error"}', [], 500); + $userManagementMock->method('authenticateWithRefreshToken') + ->willThrowException(new Exception\ServerException($response)); + + $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); + } +} 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/Session/SigningOnlySessionHandlerTest.php b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php new file mode 100644 index 0000000..0c377a6 --- /dev/null +++ b/tests/WorkOS/Session/SigningOnlySessionHandlerTest.php @@ -0,0 +1,277 @@ +handler = new SigningOnlySessionHandler(); + $this->requestClientMock = $this->createMock(\WorkOS\RequestClient\RequestClientInterface::class); + } + + 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() + { + // 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); + + $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 + ); + } +} diff --git a/tests/WorkOS/UserManagementTest.php b/tests/WorkOS/UserManagementTest.php index 3a71d17..269b897 100644 --- a/tests/WorkOS/UserManagementTest.php +++ b/tests/WorkOS/UserManagementTest.php @@ -2199,4 +2199,285 @@ 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']); + + // 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 + // 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']); + + // 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); + + // 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 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\ServerException($response)); + + $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 + ); + } }