Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-4977] Fix/broadcast to others #50

Merged
merged 9 commits into from
Sep 25, 2024
31 changes: 19 additions & 12 deletions src/AblyBroadcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
coderabbitai[bot] marked this conversation as resolved.
Show resolved Hide resolved
foreach ($this->formatChannels($channels) as $channel) {
$this->ably->channels->get($channel)->publish(
$this->buildAblyMessage($event, $payload)
$this->buildAblyMessage($event, $payload, $socketIdObject)
);
}
} catch (AblyException $e) {
Expand Down Expand Up @@ -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;
coderabbitai[bot] marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
56 changes: 49 additions & 7 deletions src/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

namespace Ably\LaravelBroadcaster;

use Ably\Exceptions\AblyException;

class Utils
{
// JWT related PHP utility functions
/**
* @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];
}
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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;
}
}
70 changes: 43 additions & 27 deletions tests/AblyBroadcasterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}

Expand Down
79 changes: 79 additions & 0 deletions tests/UtilsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace Ably\LaravelBroadcaster\Tests;

use Ably\Exceptions\AblyException;
use Ably\LaravelBroadcaster\Utils;

class UtilsTest extends TestCase
{
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);
}

/**
* @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)));
}
}
Loading