diff --git a/src/AblyBroadcaster.php b/src/AblyBroadcaster.php index 0f5a151..2afcad1 100644 --- a/src/AblyBroadcaster.php +++ b/src/AblyBroadcaster.php @@ -168,19 +168,22 @@ public function validAuthenticationResponse($request, $result) /** * Broadcast the given event. * - * @param array $channels - * @param string $event - * @param array $payload + * @param array $channels + * @param string $event + * @param array $payload * @return void * * @throws \Illuminate\Broadcasting\BroadcastException + * @throws \Exception */ public function broadcast($channels, $event, $payload = []) { + $socketId = Arr::pull($payload, 'socket'); try { + $socketIdObject = Utils::decodeSocketId($socketId); foreach ($this->formatChannels($channels) as $channel) { $this->ably->channels->get($channel)->publish( - $this->buildAblyMessage($event, $payload) + $this->buildAblyMessage($event, $payload, $socketIdObject) ); } } catch (AblyException $e) { @@ -310,19 +313,23 @@ public function formatChannels($channels) /** * Build an Ably message object for broadcasting. * - * @param string $event - * @param array $payload - * @return \Ably\Models\Message + * @param string $event + * @param array $payload + * @param object $socketIdObject + * @return AblyMessage */ - protected function buildAblyMessage($event, $payload = []) + protected function buildAblyMessage($event, $payload = [], $socketIdObject = null) { - $socket = Arr::pull($payload, 'socket'); - - return tap(new AblyMessage, function ($message) use ($event, $payload, $socket) { + $message = tap(new AblyMessage, function ($message) use ($event, $payload) { $message->name = $event; $message->data = $payload; - $message->connectionKey = $socket; }); + + if ($socketIdObject) { + $message->connectionKey = $socketIdObject->connectionKey; + $message->clientId = $socketIdObject->clientId; + } + return $message; } /** diff --git a/src/Utils.php b/src/Utils.php index 0729b19..b3c8c9f 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -2,6 +2,8 @@ namespace Ably\LaravelBroadcaster; +use Ably\Exceptions\AblyException; + class Utils { // JWT related PHP utility functions @@ -9,11 +11,11 @@ class Utils * @param string $jwt * @return array */ - public static function parseJwt($jwt) + public static function parseJwt($jwt): array { $tokenParts = explode('.', $jwt); - $header = json_decode(base64_decode($tokenParts[0]), true); - $payload = json_decode(base64_decode($tokenParts[1]), true); + $header = json_decode(self::base64urlDecode($tokenParts[0]), true); + $payload = json_decode(self::base64urlDecode($tokenParts[1]), true); return ['header' => $header, 'payload' => $payload]; } @@ -23,7 +25,7 @@ public static function parseJwt($jwt) * @param array $payload * @return string */ - public static function generateJwt($headers, $payload, $key) + public static function generateJwt($headers, $payload, $key): string { $encodedHeaders = self::base64urlEncode(json_encode($headers)); $encodedPayload = self::base64urlEncode(json_encode($payload)); @@ -39,7 +41,7 @@ public static function generateJwt($headers, $payload, $key) * @param mixed $timeFn * @return bool */ - public static function isJwtValid($jwt, $timeFn, $key) + public static function isJwtValid($jwt, $timeFn, $key): bool { // split the jwt $tokenParts = explode('.', $jwt); @@ -48,7 +50,7 @@ public static function isJwtValid($jwt, $timeFn, $key) $tokenSignature = $tokenParts[2]; // check the expiration time - note this will cause an error if there is no 'exp' claim in the jwt - $expiration = json_decode(base64_decode($payload))->exp; + $expiration = json_decode(self::base64urlDecode($payload))->exp; $isTokenExpired = $expiration <= $timeFn(); // build a signature based on the header and payload using the secret @@ -58,8 +60,48 @@ public static function isJwtValid($jwt, $timeFn, $key) return $isSignatureValid && ! $isTokenExpired; } - public static function base64urlEncode($str) + /** + * https://www.php.net/manual/en/function.base64-encode.php#127544 + */ + public static function base64urlEncode($str): string { return rtrim(strtr(base64_encode($str), '+/', '-_'), '='); } + + /** + * https://www.php.net/manual/en/function.base64-encode.php#127544 + */ + public static function base64urlDecode($data): string + { + return base64_decode(strtr($data, '-_', '+/'), true); + } + + const SOCKET_ID_ERROR = "please make sure to send base64 url encoded json with " + ."'connectionKey' and 'clientId' as keys. 'clientId' is null if connection is not identified"; + + /** + * @return object + * @throws AblyException + */ + public static function decodeSocketId($socketId): ?object + { + $socketIdObject = null; + if ($socketId) { + $socketIdJsonString = self::base64urlDecode($socketId); + if (!$socketIdJsonString) { + throw new AblyException("Base64 decoding failed, ".self::SOCKET_ID_ERROR); + } + $socketIdObject = json_decode($socketIdJsonString); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new AblyException("JSON decoding failed: " . json_last_error_msg() . ", " . self::SOCKET_ID_ERROR); + } + if (!isset($socketIdObject->connectionKey)) { + throw new AblyException("ConnectionKey is not set, ".self::SOCKET_ID_ERROR); + } + if (!property_exists($socketIdObject, 'clientId')) { + throw new AblyException("ClientId is missing, ".self::SOCKET_ID_ERROR); + } + } + return $socketIdObject; + } } diff --git a/tests/AblyBroadcasterTest.php b/tests/AblyBroadcasterTest.php index 6d05097..b06d403 100644 --- a/tests/AblyBroadcasterTest.php +++ b/tests/AblyBroadcasterTest.php @@ -123,27 +123,6 @@ public function testAuthThrowAccessDeniedHttpExceptionWithPresenceChannelWhenReq ); } - public function testGenerateAndValidateToken() - { - $headers = ['alg' => 'HS256', 'typ' => 'JWT']; - $payload = ['sub' => '1234567890', 'name' => 'John Doe', 'admin' => true, 'exp' => (time() + 60)]; - $jwtToken = Utils::generateJwt($headers, $payload, 'efgh'); - - $parsedJwt = Utils::parseJwt($jwtToken); - self::assertEquals('HS256', $parsedJwt['header']['alg']); - self::assertEquals('JWT', $parsedJwt['header']['typ']); - - self::assertEquals('1234567890', $parsedJwt['payload']['sub']); - self::assertEquals('John Doe', $parsedJwt['payload']['name']); - self::assertEquals(true, $parsedJwt['payload']['admin']); - - $timeFn = function () { - return time(); - }; - $jwtIsValid = Utils::isJwtValid($jwtToken, $timeFn, 'efgh'); - self::assertTrue($jwtIsValid); - } - public function testShouldGetSignedToken() { $token = $this->broadcaster->getSignedToken(null, null, 'user123', $this->guardedChannelCapability); @@ -341,25 +320,62 @@ public function testLaravelAblyAgentHeader() $this->assertcontains( 'Ably-Agent: '.$expectedLaravelHeader, $ably->http->lastHeaders, 'Expected Laravel broadcaster header in HTTP request '.json_encode($ably->http->lastHeaders)); } - public function testPayloadShouldNotIncludeSocketKey() + public function testPublishPayloadShouldNotIncludeSocketKey() { - $broadcaster = m::mock(AblyBroadcasterExposed::class, [$this->ably, []])->makePartial(); + $ably = (new AblyFactory())->make([ + 'key' => 'abcd:efgh', + 'httpClass' => 'Ably\LaravelBroadcaster\Tests\HttpMock', + ]); + $broadcaster = m::mock(AblyBroadcasterExposed::class, [$ably, []])->makePartial(); + $socketIdObject = new \stdClass(); + $socketIdObject->connectionKey = 'foo'; + $socketIdObject->clientId = 'sacOO7'; $payload = [ 'foo' => 'bar', - 'socket' => null + 'socket' => Utils::base64urlEncode(json_encode($socketIdObject)) ]; + $broadcaster->broadcast(["channel1", "channel2"], 'testEvent', $payload); + + self::assertCount(2, $broadcaster->payloads); + foreach ($broadcaster->payloads as $payload) { + self::assertArrayNotHasKey('socket', $payload); + } + } + + public function testBuildMessageBasedOnSocketIdObject() + { + $broadcaster = m::mock(AblyBroadcasterExposed::class, [$this->ably, []])->makePartial(); + $payload = [ + 'foo' => 'bar', + 'chat' => 'hello there' + ]; $message = $broadcaster->buildAblyMessage('testEvent', $payload); - self::assertArrayNotHasKey('socket', $message->data); + self::assertEquals('testEvent', $message->name); + self::assertEquals($payload, $message->data); + self::assertNull($message->connectionKey); + self::assertNull($message->clientId); + + $socketIdObject = new \stdClass(); + $socketIdObject->connectionKey = 'foo'; + $socketIdObject->clientId = 'sacOO7'; + + $message = $broadcaster->buildAblyMessage('testEvent', $payload, $socketIdObject); + self::assertEquals('testEvent', $message->name); + self::assertEquals($payload, $message->data); + self::assertEquals('foo', $message->connectionKey); + self::assertEquals('sacOO7', $message->clientId); } } class AblyBroadcasterExposed extends AblyBroadcaster { - public function buildAblyMessage($event, $payload = []) + public $payloads = []; + public function buildAblyMessage($event, $payload = [], $socketIdObject = null) { - return parent::buildAblyMessage($event, $payload); + $this->payloads[] = $payload; + return parent::buildAblyMessage($event, $payload, $socketIdObject); } } diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php new file mode 100644 index 0000000..27f5ae4 --- /dev/null +++ b/tests/UtilsTest.php @@ -0,0 +1,79 @@ + 'HS256', 'typ' => 'JWT']; + $payload = ['sub' => '1234567890', 'name' => 'John Doe', 'admin' => true, 'exp' => (time() + 60)]; + $jwtToken = Utils::generateJwt($headers, $payload, 'efgh'); + + $parsedJwt = Utils::parseJwt($jwtToken); + self::assertEquals('HS256', $parsedJwt['header']['alg']); + self::assertEquals('JWT', $parsedJwt['header']['typ']); + + self::assertEquals('1234567890', $parsedJwt['payload']['sub']); + self::assertEquals('John Doe', $parsedJwt['payload']['name']); + self::assertEquals(true, $parsedJwt['payload']['admin']); + + $timeFn = function () { + return time(); + }; + $jwtIsValid = Utils::isJwtValid($jwtToken, $timeFn, 'efgh'); + self::assertTrue($jwtIsValid); + } + + /** + * @throws AblyException + */ + public function testDecodeSocketId() { + $socketIdObject = Utils::decodeSocketId(null); + self::assertNull($socketIdObject); + + $originalSocketIdObj = new \stdClass(); + $originalSocketIdObj->connectionKey = 'key'; + $originalSocketIdObj->clientId = null; + $socketIdObject = Utils::decodeSocketId(Utils::base64urlEncode(json_encode($originalSocketIdObj))); + self::assertEquals('key', $socketIdObject->connectionKey); + self::assertNull($socketIdObject->clientId); + + $originalSocketIdObj = new \stdClass(); + $originalSocketIdObj->connectionKey = 'key'; + $originalSocketIdObj->clientId = 'id'; + $socketIdObject = Utils::decodeSocketId(Utils::base64urlEncode(json_encode($originalSocketIdObj))); + self::assertEquals('key', $socketIdObject->connectionKey); + self::assertEquals('id', $socketIdObject->clientId); + } + + public function testExceptionOnDecodingInvalidSocketId() + { + self::expectException(AblyException::class); + self::expectExceptionMessage("Base64 decoding failed, ".Utils::SOCKET_ID_ERROR); + Utils::decodeSocketId("invalid_socket_id"); + } + + public function testExceptionOnMissingClientIdInSocketId() + { + $socketIdObject = new \stdClass(); + $socketIdObject->connectionKey = 'key'; + + self::expectException(AblyException::class); + self::expectExceptionMessage("ClientId is missing, ".Utils::SOCKET_ID_ERROR); + Utils::decodeSocketId(Utils::base64urlEncode(json_encode($socketIdObject))); + } + + public function testExceptionOnMissingConnectionKeyInSocketId() + { + $socketIdObject = new \stdClass(); + $socketIdObject->clientId = 'id'; + + self::expectException(AblyException::class); + self::expectExceptionMessage("ConnectionKey is not set, ".Utils::SOCKET_ID_ERROR); + Utils::decodeSocketId(Utils::base64urlEncode(json_encode($socketIdObject))); + } +}