diff --git a/src/channel/ably/utils.ts b/src/channel/ably/utils.ts index 04d9316c..6a13efe9 100644 --- a/src/channel/ably/utils.ts +++ b/src/channel/ably/utils.ts @@ -12,11 +12,11 @@ export const parseJwt = (jwtToken: string): { header: any; payload: any } => { // Get Token Header const base64HeaderUrl = jwtToken.split('.')[0]; const base64Header = base64HeaderUrl.replace('-', '+').replace('_', '/'); - const header = JSON.parse(toText(base64Header)); + const header = JSON.parse(fromBase64UrlEncoded(base64Header)); // Get Token payload const base64Url = jwtToken.split('.')[1]; const base64 = base64Url.replace('-', '+').replace('_', '/'); - const payload = JSON.parse(toText(base64)); + const payload = JSON.parse(fromBase64UrlEncoded(base64)); return { header, payload }; }; @@ -33,11 +33,37 @@ export const toTokenDetails = (jwtToken: string): TokenDetails | any => { const isBrowser = typeof window === 'object'; -const toText = (base64: string) => { +/** + * Helper method to decode base64 url encoded string + * https://stackoverflow.com/a/78178053 + * @param base64 base64 url encoded string + * @returns decoded text string + */ +export const fromBase64UrlEncoded = (base64: string): string => { + const base64Encoded = base64.replace(/-/g, '+').replace(/_/g, '/'); + const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4)); + const base64WithPadding = base64Encoded + padding; + + if (isBrowser) { + return atob(base64WithPadding); + } + return Buffer.from(base64WithPadding, 'base64').toString(); +}; + +/** + * Helper method to encode text into base64 url encoded string + * https://stackoverflow.com/a/78178053 + * @param base64 text + * @returns base64 url encoded string + */ +export const toBase64UrlEncoded = (text: string): string => { + let encoded = '' if (isBrowser) { - return atob(base64); + encoded = btoa(text); + } else { + encoded = Buffer.from(text).toString('base64'); } - return Buffer.from(base64, 'base64').toString('binary'); + return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }; const isAbsoluteUrl = (url: string) => (url && url.indexOf('http://') === 0) || url.indexOf('https://') === 0; diff --git a/src/connector/ably-connector.ts b/src/connector/ably-connector.ts index f4a0a3c1..cebb15ec 100644 --- a/src/connector/ably-connector.ts +++ b/src/connector/ably-connector.ts @@ -2,6 +2,7 @@ import { Connector } from './connector'; import { AblyChannel, AblyPrivateChannel, AblyPresenceChannel, AblyAuth } from './../channel'; import { AblyRealtime, TokenDetails } from '../../typings/ably'; +import { toBase64UrlEncoded } from '../channel/ably/utils'; /** * This class creates a connector to Ably. @@ -118,9 +119,14 @@ export class AblyConnector extends Connector { /** * Get the socket ID for the connection. + * For ably, returns base64 url encoded json with keys {connectionKey, clientId} */ socketId(): string { - return this.ably.connection.key; + let socketIdObject = { + connectionKey : this.ably.connection.key, + clientId : this.ably.auth.clientId ?? null, + } + return toBase64UrlEncoded(JSON.stringify(socketIdObject)); } /** diff --git a/src/echo.ts b/src/echo.ts index 5e63590e..d646fa32 100644 --- a/src/echo.ts +++ b/src/echo.ts @@ -117,6 +117,7 @@ export default class Echo { /** * Get the Socket ID for the connection. + * For ably, returns base64 url encoded json with keys {connectionKey, clientId} */ socketId(): string { return this.connector.socketId(); diff --git a/tests/ably/ably-user-login.test.ts b/tests/ably/ably-user-login.test.ts index 2e986028..77ec69bf 100644 --- a/tests/ably/ably-user-login.test.ts +++ b/tests/ably/ably-user-login.test.ts @@ -39,7 +39,7 @@ describe('AblyUserLogin', () => { }); }); - test('user logs in without previous (guest) channels', async () => { + test('user logs in without previous (public) channels', async () => { let connectionStates : Array= [] // Initial clientId is null expect(mockAuthServer.clientId).toBeNull(); @@ -70,7 +70,7 @@ describe('AblyUserLogin', () => { expect(echo.connector.ablyAuth.existingToken().clientId).toBe('sacOO7@github.com'); }); - test('user logs in with previous (guest) channels', async () => { + test('user logs in with previous (public) channels', async () => { let connectionStates : Array= [] let publicChannelStates : Array= [] diff --git a/tests/ably/ably-user-rest-publishing.test.ts b/tests/ably/ably-user-rest-publishing.test.ts new file mode 100644 index 00000000..6975ea3e --- /dev/null +++ b/tests/ably/ably-user-rest-publishing.test.ts @@ -0,0 +1,167 @@ +import { setup, tearDown } from './setup/sandbox'; +import Echo from '../../src/echo'; +import { MockAuthServer } from './setup/mock-auth-server'; +import { AblyChannel, AblyPrivateChannel } from '../../src/channel'; +import * as Ably from 'ably'; +import waitForExpect from 'wait-for-expect'; +import { fromBase64UrlEncoded } from '../../src/channel/ably/utils'; + +jest.setTimeout(30000); +describe('AblyUserRestPublishing', () => { + let testApp: any; + let mockAuthServer: MockAuthServer; + let echoInstances: Array; + + beforeAll(async () => { + global.Ably = Ably; + testApp = await setup(); + mockAuthServer = new MockAuthServer(testApp.keys[0].keyStr); + }); + + afterAll(async () => { + return await tearDown(testApp); + }); + + beforeEach(() => { + echoInstances = []; + }) + + afterEach((done) => { + let promises: Array> = [] + for (const echo of echoInstances) { + echo.disconnect(); + const promise = new Promise(res => { + echo.connector.ably.connection.once('closed', () => { + res(true); + }); + }) + promises.push(promise); + } + Promise.all(promises).then(_ => { + done(); + }) + }); + + async function getGuestUserChannel(channelName: string) { + mockAuthServer.clientId = null; + const guestUser = new Echo({ + broadcaster: 'ably', + useTls: true, + environment: 'sandbox', + requestTokenFn: mockAuthServer.getSignedToken + }); + echoInstances.push(guestUser); + const publicChannel = guestUser.channel(channelName) as AblyChannel; + await new Promise((resolve) => publicChannel.subscribed(resolve)); + expect(guestUser.connector.ably.auth.clientId).toBeFalsy(); + expect(guestUser.connector.ablyAuth.existingToken().clientId).toBeNull(); + return publicChannel; + } + + async function getLoggedInUserChannel(channelName: string) { + mockAuthServer.clientId = 'sacOO7@github.com'; + const loggedInUser = new Echo({ + broadcaster: 'ably', + useTls: true, + environment: 'sandbox', + requestTokenFn: mockAuthServer.getSignedToken + }); + echoInstances.push(loggedInUser); + const privateChannel = loggedInUser.private(channelName) as AblyPrivateChannel; + await new Promise((resolve) => privateChannel.subscribed(resolve)); + expect(loggedInUser.connector.ably.auth.clientId).toBe("sacOO7@github.com"); + expect(loggedInUser.connector.ablyAuth.existingToken().clientId).toBe("sacOO7@github.com") + mockAuthServer.clientId = null; + return privateChannel; + } + + test('Guest user return socketId as base64 encoded connectionkey and null clientId', async () => { + await getGuestUserChannel("dummyChannel"); + const guestUser = echoInstances[0]; + const socketIdObj = JSON.parse(fromBase64UrlEncoded(guestUser.socketId())); + + const expectedConnectionKey = guestUser.connector.ably.connection.key; + + expect(socketIdObj.connectionKey).toBe(expectedConnectionKey); + expect(socketIdObj.connectionKey).toBeTruthy(); + + expect(socketIdObj.clientId).toBeNull(); + }); + + test('Guest user publishes message via rest API', async () => { + let messagesReceived: Array = [] + let channelName = "testChannel"; + + const publicChannel1 = await getGuestUserChannel(channelName); + publicChannel1.listenToAll((eventName, data) => { + messagesReceived.push(eventName); + }); + + const publicChannel2 = await getGuestUserChannel(channelName); + publicChannel2.listenToAll((eventName, data) => { + messagesReceived.push(eventName); + }) + + // Publish message to all clients + await mockAuthServer.broadcast(`public:${channelName}`, "testEvent", "mydata") + await waitForExpect(() => { + expect(messagesReceived.length).toBe(2); + expect(messagesReceived.filter(m => m == ".testEvent").length).toBe(2) + }); + + // Publish message to other client + messagesReceived = [] + const firstClientSocketId = echoInstances[0].socketId(); + await mockAuthServer.broadcastToOthers(firstClientSocketId, + { channelName: `public:${channelName}`, eventName: "toOthers", payload: "data" }) + await waitForExpect(() => { + expect(messagesReceived.length).toBe(1); + expect(messagesReceived.filter(m => m == ".toOthers").length).toBe(1); + }); + }); + + test('Logged in user return socketId as base64 encoded connectionkey and clientId', async () => { + await getLoggedInUserChannel("dummyChannel"); + const loggedInUser = echoInstances[0]; + const socketIdObj = JSON.parse(fromBase64UrlEncoded(loggedInUser.socketId())); + + const expectedConnectionKey = loggedInUser.connector.ably.connection.key; + + expect(socketIdObj.connectionKey).toBe(expectedConnectionKey); + expect(socketIdObj.connectionKey).toBeTruthy(); + + expect(socketIdObj.clientId).toBe("sacOO7@github.com"); + }); + + test('Logged in user publishes message via rest API', async () => { + let messagesReceived: Array = [] + let channelName = "testChannel"; + + const privateChannel1 = await getLoggedInUserChannel(channelName); + privateChannel1.listenToAll((eventName, data) => { + messagesReceived.push(eventName); + }); + + const privateChannel2 = await getLoggedInUserChannel(channelName); + privateChannel2.listenToAll((eventName, data) => { + messagesReceived.push(eventName); + }) + + // Publish message to all clients + await mockAuthServer.broadcast(`private:${channelName}`, "testEvent", "mydata") + await waitForExpect(() => { + expect(messagesReceived.length).toBe(2); + expect(messagesReceived.filter(m => m == ".testEvent").length).toBe(2); + }); + + // Publish message to other client + messagesReceived = [] + const firstClientSocketId = echoInstances[0].socketId(); + await mockAuthServer.broadcastToOthers(firstClientSocketId, + { channelName: `private:${channelName}`, eventName: "toOthers", payload: "data" }); + await waitForExpect(() => { + expect(messagesReceived.length).toBe(1); + expect(messagesReceived.filter(m => m == ".toOthers").length).toBe(1); + }); + }); +}); diff --git a/tests/ably/setup/mock-auth-server.ts b/tests/ably/setup/mock-auth-server.ts index 8d244a6d..940a8589 100644 --- a/tests/ably/setup/mock-auth-server.ts +++ b/tests/ably/setup/mock-auth-server.ts @@ -1,19 +1,25 @@ -import { isNullOrUndefinedOrEmpty, parseJwt } from '../../../src/channel/ably/utils'; +import { isNullOrUndefinedOrEmpty, parseJwt, fromBase64UrlEncoded } from '../../../src/channel/ably/utils'; import * as Ably from 'ably/promises'; import * as jwt from 'jsonwebtoken'; type channels = Array; +/** + * MockAuthServer mimicks {@link https://github.com/ably/laravel-broadcaster/blob/main/src/AblyBroadcaster.php AblyBroadcaster.php}. + * Aim is to keep implementation and behaviour in sync with AblyBroadcaster.php, so that it can be tested + * without running actual PHP server. + */ export class MockAuthServer { keyName: string; keySecret: string; ablyClient: Ably.Rest; clientId: string | null = 'sacOO7@github.com'; - userInfo = { id: 'sacOO7@github.com', name: 'sacOO7' }; shortLived: channels; banned: channels; + userInfo = { id: 'sacOO7@github.com', name: 'sacOO7' }; // Used for presence + constructor(apiKey: string, environment = 'sandbox') { const keys = apiKey.split(':'); this.keyName = keys[0]; @@ -21,8 +27,23 @@ export class MockAuthServer { this.ablyClient = new Ably.Rest({ key: apiKey, environment }); } + /** + * Broadcast to all clients subscribed to given channel. + */ broadcast = async (channelName: string, eventName: string, message: string) => { - await this.ablyClient.channels.get(channelName).publish(eventName, message); + await this.broadcastToOthers("", {channelName, eventName, payload: message}); + }; + + /** + * Broadcast on behalf of a given realtime client. + */ + broadcastToOthers = async (socketId: string, {channelName, eventName, payload}) => { + let protoMsg = {name: eventName, data: payload}; + if (!isNullOrUndefinedOrEmpty(socketId)) { + const socketIdObj = JSON.parse(fromBase64UrlEncoded(socketId)); + protoMsg = {...protoMsg, ...socketIdObj} + } + await this.ablyClient.channels.get(channelName).publish(protoMsg); }; tokenInvalidOrExpired = (serverTime, token) => { diff --git a/tests/ably/setup/sandbox.ts b/tests/ably/setup/sandbox.ts index a6884d9a..de9be9a3 100644 --- a/tests/ably/setup/sandbox.ts +++ b/tests/ably/setup/sandbox.ts @@ -1,4 +1,4 @@ -import { httpDeleteAsync, httpPostAsync, toBase64 } from './utils'; +import { httpDeleteAsync, httpPostAsync, toBase64UrlEncoded } from './utils'; let sandboxUrl = 'https://sandbox-rest.ably.io/apps'; @@ -16,7 +16,7 @@ const creatNewApp = async () => { const deleteApp = async (app) => { let authKey = app.keys[0].keyStr; - const headers = { Authorization: 'Basic ' + toBase64(authKey) }; + const headers = { Authorization: 'Basic ' + toBase64UrlEncoded(authKey) }; return await httpDeleteAsync(`${sandboxUrl}/${app.appId}`, headers); }; diff --git a/tests/ably/setup/utils.ts b/tests/ably/setup/utils.ts index 9213fde1..9929bc0f 100644 --- a/tests/ably/setup/utils.ts +++ b/tests/ably/setup/utils.ts @@ -1,4 +1,5 @@ -import { httpRequestAsync } from '../../../src/channel/ably/utils'; +import { httpRequestAsync} from '../../../src/channel/ably/utils'; +export { toBase64UrlEncoded } from '../../../src/channel/ably/utils'; const safeAssert = (assertions: Function, done: Function, finalAssertion = false) => { try { @@ -17,8 +18,6 @@ export const execute = (fn: Function, times: number) => { } }; -export const toBase64 = (text: string) => Buffer.from(text, 'binary').toString('base64'); - export const httpPostAsync = async (url: string, postData: any) => { postData = JSON.stringify(postData); let postOptions = { diff --git a/tests/ably/utils.test.ts b/tests/ably/utils.test.ts index 5afb9d5b..658f384e 100644 --- a/tests/ably/utils.test.ts +++ b/tests/ably/utils.test.ts @@ -1,32 +1,67 @@ -import { parseJwt, toTokenDetails } from '../../src/channel/ably/utils'; +import { fromBase64UrlEncoded, parseJwt, toBase64UrlEncoded, toTokenDetails } from '../../src/channel/ably/utils'; describe('Utils', () => { - test('should parse JWT properly', () => { - const token = + describe('JWT handling', () => { + test('should parse JWT properly', () => { + const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImFiY2QifQ.eyJpYXQiOjE2NTQ2MzQyMTIsImV4cCI6MTY1NDYzNzgxMiwieC1hYmx5LWNsaWVudElkIjoidXNlcjEyMyIsIngtYWJseS1jYXBhYmlsaXR5Ijoie1wicHVibGljOipcIjpbXCJzdWJzY3JpYmVcIixcImhpc3RvcnlcIixcImNoYW5uZWwtbWV0YWRhdGFcIl19In0.GenM5EyUeJvgAGBD_EG-89FueNKWtyRZyi61s9G2Bs4'; - const expectedHeader = { - alg: 'HS256', - kid: 'abcd', - typ: 'JWT', - }; - const expectedPayload = { - iat: 1654634212, - exp: 1654637812, - 'x-ably-clientId': 'user123', - 'x-ably-capability': '{"public:*":["subscribe","history","channel-metadata"]}', - }; + const expectedHeader = { + alg: 'HS256', + kid: 'abcd', + typ: 'JWT', + }; + const expectedPayload = { + iat: 1654634212, + exp: 1654637812, + 'x-ably-clientId': 'user123', + 'x-ably-capability': '{"public:*":["subscribe","history","channel-metadata"]}', + }; - expect(parseJwt(token).header).toStrictEqual(expectedHeader); - expect(parseJwt(token).payload).toStrictEqual(expectedPayload); + expect(parseJwt(token).header).toStrictEqual(expectedHeader); + expect(parseJwt(token).payload).toStrictEqual(expectedPayload); + }); + + test('should convert to tokenDetails', () => { + const token = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImFiY2QifQ.eyJpYXQiOjE2NTQ2MzQyMTIsImV4cCI6MTY1NDYzNzgxMiwieC1hYmx5LWNsaWVudElkIjoidXNlcjEyMyIsIngtYWJseS1jYXBhYmlsaXR5Ijoie1wicHVibGljOipcIjpbXCJzdWJzY3JpYmVcIixcImhpc3RvcnlcIixcImNoYW5uZWwtbWV0YWRhdGFcIl19In0.GenM5EyUeJvgAGBD_EG-89FueNKWtyRZyi61s9G2Bs4'; + const tokenDetails = toTokenDetails(token); + expect(tokenDetails.clientId).toBe('user123'); + expect(tokenDetails.expires).toBe(1654637812000); + expect(tokenDetails.issued).toBe(1654634212000); + expect(tokenDetails.token).toBe(token); + }); + + test('should throw error for invalid JWT', () => { + const invalidToken = 'invalid.token.string'; + expect(() => parseJwt(invalidToken)).toThrow('Unexpected token'); + }); }); - test('should convert to tokenDetails', () => { - const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6ImFiY2QifQ.eyJpYXQiOjE2NTQ2MzQyMTIsImV4cCI6MTY1NDYzNzgxMiwieC1hYmx5LWNsaWVudElkIjoidXNlcjEyMyIsIngtYWJseS1jYXBhYmlsaXR5Ijoie1wicHVibGljOipcIjpbXCJzdWJzY3JpYmVcIixcImhpc3RvcnlcIixcImNoYW5uZWwtbWV0YWRhdGFcIl19In0.GenM5EyUeJvgAGBD_EG-89FueNKWtyRZyi61s9G2Bs4'; - const tokenDetails = toTokenDetails(token); - expect(tokenDetails.clientId).toBe('user123'); - expect(tokenDetails.expires).toBe(1654637812000); - expect(tokenDetails.issued).toBe(1654634212000); - expect(tokenDetails.token).toBe(token); + describe('Base64 URL encoding/decoding', () => { + test('should encode text into Base64UrlEncoded string', () => { + const normalText = "laravel-echo codebase is of best quality, period!" + const encodedText = toBase64UrlEncoded(normalText); + expect(encodedText).toBe('bGFyYXZlbC1lY2hvIGNvZGViYXNlIGlzIG9mIGJlc3QgcXVhbGl0eSwgcGVyaW9kIQ') + + // edge cases + expect(toBase64UrlEncoded('')).toBe(''); + expect(toBase64UrlEncoded('Hello, 世界! 🌍')).toBe('SGVsbG8sIOS4lueVjCEg8J-MjQ'); + expect(toBase64UrlEncoded('Hello+World/123')).toBe('SGVsbG8rV29ybGQvMTIz'); + expect(toBase64UrlEncoded('a')).toBe('YQ'); // Would be 'YQ==' in standard Base64 + expect(toBase64UrlEncoded('\x8EÇdwïìvÇ')).toBe('wo7Dh2R3w6_DrHbDhw'); + }); + + test('should decode Base64UrlEncoded string into text', () => { + const normalText = "bGFyYXZlbC1lY2hvIGNvZGViYXNlIGlzIG9mIGJlc3QgcXVhbGl0eSwgcGVyaW9kIQ" + const encodedText = fromBase64UrlEncoded(normalText); + expect(encodedText).toBe('laravel-echo codebase is of best quality, period!') + + // edge cases + expect(fromBase64UrlEncoded('')).toBe(''); + expect(fromBase64UrlEncoded('SGVsbG8sIOS4lueVjCEg8J-MjQ')).toBe('Hello, 世界! 🌍'); + expect(fromBase64UrlEncoded('SGVsbG8rV29ybGQvMTIz')).toBe('Hello+World/123'); + expect(fromBase64UrlEncoded('YQ')).toBe('a'); // No padding in Base64Url + expect(fromBase64UrlEncoded('wo7Dh2R3w6_DrHbDhw')).toBe('\x8EÇdwïìvÇ'); + }); }); });