diff --git a/__tests__/happy.replaceActivityFromId.js b/__tests__/happy.replaceActivityFromId.js new file mode 100644 index 000000000..cd1bb0e44 --- /dev/null +++ b/__tests__/happy.replaceActivityFromId.js @@ -0,0 +1,49 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import postActivity from './setup/postActivity'; +import waitForBotToRespond from './setup/waitForBotToRespond'; + +describe('Happy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('should receive the welcome message from bot', () => { + let directLine; + + describe('using REST', () => { + beforeEach(() => jest.setTimeout(timeouts.rest)); + + test('with secret', async () => { + directLine = await createDirectLine.forREST({ token: false }); + }); + }); + + describe('using Web Socket', () => { + beforeEach(() => jest.setTimeout(timeouts.webSocket)); + + test('with secret', async () => { + directLine = await createDirectLine.forWebSocket({ token: false }); + }); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + + directLine.setUserId('u_test'); + + await Promise.all([ + postActivity(directLine, { text: 'Hello, World!', type: 'message' }), + waitForBotToRespond(directLine, ({ from }) => from.id === 'u_test') + ]); + }); + }); +}); diff --git a/__tests__/happy.userIdOnStartConversation.js b/__tests__/happy.userIdOnStartConversation.js new file mode 100644 index 000000000..ba19e709d --- /dev/null +++ b/__tests__/happy.userIdOnStartConversation.js @@ -0,0 +1,45 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import waitForBotToRespond from './setup/waitForBotToRespond'; + +describe('Happy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('should receive the welcome message from bot', () => { + let directLine; + + describe('using REST', () => { + beforeEach(() => jest.setTimeout(timeouts.rest)); + + test('with secret', async () => { + directLine = await createDirectLine.forREST({ token: false }); + }); + }); + + describe('using Web Socket', () => { + beforeEach(() => jest.setTimeout(timeouts.webSocket)); + + test('with secret', async () => { + directLine = await createDirectLine.forWebSocket({ token: false }); + }); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + + directLine.setUserId('u_test'); + + await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome'); + }); + }); +}); diff --git a/__tests__/unhappy.setUserIdAfterConnect.js b/__tests__/unhappy.setUserIdAfterConnect.js new file mode 100644 index 000000000..b795e1b28 --- /dev/null +++ b/__tests__/unhappy.setUserIdAfterConnect.js @@ -0,0 +1,44 @@ +import 'dotenv/config'; + +import onErrorResumeNext from 'on-error-resume-next'; + +import { timeouts } from './constants.json'; +import * as createDirectLine from './setup/createDirectLine'; +import waitForConnected from './setup/waitForConnected'; + +describe('Unhappy path', () => { + let unsubscribes; + + beforeEach(() => unsubscribes = []); + afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); + + describe('should receive the welcome message from bot', () => { + let directLine; + + describe('using REST', () => { + beforeEach(() => jest.setTimeout(timeouts.rest)); + + test('with secret', async () => { + directLine = await createDirectLine.forREST({ token: false }); + }); + }); + + describe('using Web Socket', () => { + beforeEach(() => jest.setTimeout(timeouts.webSocket)); + + test('with secret', async () => { + directLine = await createDirectLine.forWebSocket({ token: false }); + }); + }); + + afterEach(async () => { + // If directLine object is undefined, that means the test is failing. + if (!directLine) { return; } + + unsubscribes.push(directLine.end.bind(directLine)); + unsubscribes.push(await waitForConnected(directLine)); + + expect(() => directLine.setUserId('e_test')).toThrowError('DirectLineJS: It is connected, we cannot set user id.'); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 8ad6d0e56..a54d4d0a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2158,6 +2158,15 @@ "integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==", "dev": true }, + "@types/jsonwebtoken": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz", + "integrity": "sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "12.7.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.4.tgz", @@ -7095,6 +7104,11 @@ } } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", diff --git a/package.json b/package.json index 536e47d9e..63a91413c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "botframework-streaming": "4.10.3", "core-js": "3.6.4", "cross-fetch": "3.0.4", + "jwt-decode": "3.1.2", "rxjs": "5.5.10", "url-search-params-polyfill": "8.0.0" }, @@ -42,6 +43,7 @@ "@babel/preset-env": "^7.6.0", "@babel/preset-typescript": "^7.6.0", "@types/jest": "^24.0.18", + "@types/jsonwebtoken": "^8.5.0", "@types/node": "^12.7.4", "@types/p-defer": "^2.0.0", "babel-jest": "^24.9.0", diff --git a/src/directLine.ts b/src/directLine.ts index 07f5d6ea1..7cc1b15fc 100644 --- a/src/directLine.ts +++ b/src/directLine.ts @@ -9,6 +9,7 @@ import { IScheduler } from 'rxjs/Scheduler'; import { Subscriber } from 'rxjs/Subscriber'; import { Subscription } from 'rxjs/Subscription'; import { async as AsyncScheduler } from 'rxjs/scheduler/async'; +import jwtDecode, { JwtPayload, InvalidTokenError } from 'jwt-decode'; import 'rxjs/add/operator/catch'; import 'rxjs/add/operator/combineLatest'; @@ -471,6 +472,7 @@ export class DirectLine implements IBotConnection { private retries: number; private localeOnStartConversation: string; + private userIdOnStartConversation: string; private pollingInterval: number = 1000; //ms @@ -630,6 +632,9 @@ export class DirectLine implements IBotConnection { const body = this.conversationId ? undefined : { + user: { + id: this.userIdOnStartConversation + }, locale: this.localeOnStartConversation }; return this.services.ajax({ @@ -749,6 +754,11 @@ export class DirectLine implements IBotConnection { } postActivity(activity: Activity) { + // If user id is set, check if it match activity.from.id and always override it in activity + if (this.userIdOnStartConversation && activity.from && activity.from.id !== this.userIdOnStartConversation) { + console.warn('DirectLineJS: Activity.from.id does not match with user id, ignoring activity.from.id'); + activity.from.id = this.userIdOnStartConversation; + } // Use postMessageWithAttachments for messages with attachments that are local files (e.g. an image to upload) // Technically we could use it for *all* activities, but postActivity is much lighter weight // So, since WebChat is partially a reference implementation of Direct Line, we implement both. @@ -1032,5 +1042,32 @@ export class DirectLine implements IBotConnection { return `${DIRECT_LINE_VERSION} (${clientAgent} ${process.env.npm_package_version})`; } + setUserId(userId: string) { + if (this.connectionStatus$.getValue() === ConnectionStatus.Online) { + throw new Error('DirectLineJS: It is connected, we cannot set user id.'); + } + + const userIdFromToken = this.parseToken(this.token); + if (userIdFromToken) { + return console.warn('DirectLineJS: user id is already set in token, will ignore this user id.'); + } + + if (/^dl_/u.test(userId)) { + return console.warn('DirectLineJS: user id prefixed with "dl_" is reserved and must be embedded into the Direct Line token to prevent forgery.'); + } + + this.userIdOnStartConversation = userId; + } + + private parseToken(token: string) { + try { + const { user } = jwtDecode(token) as { [key: string]: any; }; + return user; + } catch (e) { + if (e instanceof InvalidTokenError) { + return undefined; + } + } + } }