From ae2a411873f0614cc4fe99f4c5870172da4af045 Mon Sep 17 00:00:00 2001 From: Jeff Derstadt Date: Wed, 6 Feb 2019 14:27:32 -0800 Subject: [PATCH] Ability to test OAuthPrompt and mock OAuth APIs --- libraries/botbuilder-core/src/index.ts | 1 + libraries/botbuilder-core/src/testAdapter.ts | 138 +++++++++++++++++- .../botbuilder-core/src/userTokenProvider.ts | 47 ++++++ .../botbuilder-core/tests/testAdapter.test.js | 110 ++++++++++++++ .../src/prompts/oauthPrompt.ts | 6 +- .../tests/oauthPrompt.test.js | 128 ++++++++++++++++ .../botbuilder/src/botFrameworkAdapter.ts | 13 +- 7 files changed, 431 insertions(+), 12 deletions(-) create mode 100644 libraries/botbuilder-core/src/userTokenProvider.ts create mode 100644 libraries/botbuilder-dialogs/tests/oauthPrompt.test.js diff --git a/libraries/botbuilder-core/src/index.ts b/libraries/botbuilder-core/src/index.ts index 5196144d1a..e1dc195db6 100644 --- a/libraries/botbuilder-core/src/index.ts +++ b/libraries/botbuilder-core/src/index.ts @@ -29,3 +29,4 @@ export * from './testAdapter'; export * from './transcriptLogger'; export * from './turnContext'; export * from './userState'; +export * from './userTokenProvider'; diff --git a/libraries/botbuilder-core/src/testAdapter.ts b/libraries/botbuilder-core/src/testAdapter.ts index 906e6726b6..9e8ce00ef3 100644 --- a/libraries/botbuilder-core/src/testAdapter.ts +++ b/libraries/botbuilder-core/src/testAdapter.ts @@ -7,9 +7,10 @@ */ // tslint:disable-next-line:no-require-imports import assert = require('assert'); -import { Activity, ActivityTypes, ConversationReference, ResourceResponse } from 'botframework-schema'; +import { Activity, ActivityTypes, ConversationReference, ResourceResponse, TokenResponse } from 'botframework-schema'; import { BotAdapter } from './botAdapter'; import { TurnContext } from './turnContext'; +import { IUserTokenProvider } from './userTokenProvider'; /** * Signature for a function that can be used to inspect individual activities returned by a bot @@ -41,7 +42,7 @@ export type TestActivityInspector = (activity: Partial, description: s * .then(() => done()); * ``` */ -export class TestAdapter extends BotAdapter { +export class TestAdapter extends BotAdapter implements IUserTokenProvider { /** * @private * INTERNAL: used to drive the promise chain forward when running tests. @@ -265,6 +266,120 @@ export class TestAdapter extends BotAdapter { new TestFlow(Promise.resolve(), this)); } + private _userTokens: UserToken[] = []; + private _magicCodes: TokenMagicCode[] = []; + + /** + * Adds a fake user token so it can later be retrieved. + * @param connectionName The connection name. + * @param channelId The channel id. + * @param userId The user id. + * @param token The token to store. + * @param magicCode (Optional) The optional magic code to associate with this token. + */ + public addUserToken(connectionName: string, channelId: string, userId: string, token: string, magicCode: string = undefined) { + const key: UserToken = new UserToken(); + key.ChannelId = channelId; + key.ConnectionName = connectionName; + key.UserId = userId; + key.Token = token; + + if (!magicCode) + { + this._userTokens.push(key); + } + else + { + const mc = new TokenMagicCode(); + mc.Key = key; + mc.MagicCode = magicCode; + this._magicCodes.push(mc); + } + } + + /** + * Retrieves the OAuth token for a user that is in a sign-in flow. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param magicCode (Optional) Optional user entered code to validate. + */ + public async getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise { + const key: UserToken = new UserToken(); + key.ChannelId = context.activity.channelId; + key.ConnectionName = connectionName; + key.UserId = context.activity.from.id; + + if (magicCode) { + var magicCodeRecord = this._magicCodes.filter(x => key.EqualsKey(x.Key)); + if (magicCodeRecord && magicCodeRecord.length > 0 && magicCodeRecord[0].MagicCode === magicCode) { + // move the token to long term dictionary + this.addUserToken(connectionName, key.ChannelId, key.UserId, magicCodeRecord[0].Key.Token); + + // remove from the magic code list + const idx = this._magicCodes.indexOf(magicCodeRecord[0]); + this._magicCodes = this._magicCodes.splice(idx, 1); + } + } + + var match = this._userTokens.filter(x => key.EqualsKey(x)); + + if (match && match.length > 0) + { + return { + connectionName: match[0].ConnectionName, + token: match[0].Token, + expiration: undefined + }; + } + else + { + // not found + return undefined; + } + } + + /** + * Signs the user out with the token server. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + public async signOutUser(context: TurnContext, connectionName: string): Promise { + var channelId = context.activity.channelId; + var userId = context.activity.from.id; + + var newRecords: UserToken[] = []; + for (var i = 0; i < this._userTokens.length; i++) { + var t = this._userTokens[i]; + if (t.ChannelId !== channelId || + t.UserId !== userId || + (connectionName && connectionName !== t.ConnectionName)) + { + newRecords.push(t); + } + } + this._userTokens = newRecords; + } + + /** + * Gets a signin link from the token server that can be sent as part of a SigninCard. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + public async getSignInLink(context: TurnContext, connectionName: string): Promise { + return `https://fake.com/oauthsignin/${connectionName}/${context.activity.channelId}/${context.activity.from.id}`; + } + + /** + * Signs the user out with the token server. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + public async getAadTokens(context: TurnContext, connectionName: string, resourceUrls: string[]): Promise<{ + [propertyName: string]: TokenResponse; + }> { + return undefined; + } + /** * Indicates if the activity is a reply from the bot (role == 'bot') * @@ -282,6 +397,25 @@ export class TestAdapter extends BotAdapter { } } +class UserToken { + public ConnectionName: string; + public UserId: string; + public ChannelId: string; + public Token: string; + + public EqualsKey(rhs: UserToken): boolean { + return rhs != null && + this.ConnectionName === rhs.ConnectionName && + this.UserId === rhs.UserId && + this.ChannelId === rhs.ChannelId; + } +} + +class TokenMagicCode { + public Key: UserToken; + public MagicCode: string; +} + /** * Support class for `TestAdapter` that allows for the simple construction of a sequence of tests. * diff --git a/libraries/botbuilder-core/src/userTokenProvider.ts b/libraries/botbuilder-core/src/userTokenProvider.ts new file mode 100644 index 0000000000..3367ffadfa --- /dev/null +++ b/libraries/botbuilder-core/src/userTokenProvider.ts @@ -0,0 +1,47 @@ +/** + * @module botbuilder + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { TurnContext } from './turnContext'; +import { TokenResponse } from 'botframework-schema'; + +/** + * Interface for User Token OAuth APIs for BotAdapters + */ +export interface IUserTokenProvider { + /** + * Retrieves the OAuth token for a user that is in a sign-in flow. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + * @param magicCode (Optional) Optional user entered code to validate. + */ + getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise; + + /** + * Signs the user out with the token server. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + signOutUser(context: TurnContext, connectionName: string): Promise; + + /** + * Gets a signin link from the token server that can be sent as part of a SigninCard. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + getSignInLink(context: TurnContext, connectionName: string): Promise; + + /** + * Signs the user out with the token server. + * @param context Context for the current turn of conversation with the user. + * @param connectionName Name of the auth connection to use. + */ + getAadTokens(context: TurnContext, connectionName: string, resourceUrls: string[]): Promise<{ + [propertyName: string]: TokenResponse; + }>; +} + diff --git a/libraries/botbuilder-core/tests/testAdapter.test.js b/libraries/botbuilder-core/tests/testAdapter.test.js index 5d6f0cc64d..3bd9a31f0d 100644 --- a/libraries/botbuilder-core/tests/testAdapter.test.js +++ b/libraries/botbuilder-core/tests/testAdapter.test.js @@ -348,4 +348,114 @@ describe(`TestAdapter`, function () { } throw new Error(`TestAdapter.testActivities() should not have succeeded without activities argument.`); }); + + it(`getUserToken returns null`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection').then(token => { + assert(!token); + done(); + }); + }); + adapter.send('hi'); + }); + + it(`getUserToken returns null with code`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection', '123456').then(token => { + assert(!token); + done(); + }); + }); + adapter.send('hi'); + }); + + it(`getUserToken returns token`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection').then(token => { + assert(token); + assert(token.token); + assert(token.connectionName); + done(); + }); + }); + adapter.addUserToken('myConnection', 'test', 'user', '123abc'); + adapter.send('hi'); + }); + + it(`getUserToken returns token with code`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection').then(token => { + assert(!token); + context.adapter.getUserToken(context, 'myConnection', '888777').then(token2 => { + assert(token2); + assert(token2.token); + assert(token2.connectionName); + context.adapter.getUserToken(context, 'myConnection').then(token3 => { + assert(token3); + assert(token3.token); + assert(token3.connectionName); + done(); + }); + }); + }); + }); + adapter.addUserToken('myConnection', 'test', 'user', '123abc', '888777'); + adapter.send('hi'); + }); + + it(`getSignInLink returns token with code`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getSignInLink(context, 'myConnection').then(link => { + assert(link); + done(); + }); + }); + adapter.send('hi'); + }); + + it(`signOutUser is noop`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.signOutUser(context, 'myConnection').then(x => { + done(); + }); + }); + adapter.send('hi'); + }); + + it(`signOutUser logs out user`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection').then(token => { + assert(token); + assert(token.token); + assert(token.connectionName); + context.adapter.signOutUser(context, 'myConnection').then(x => { + context.adapter.getUserToken(context, 'myConnection').then(token2 => { + assert(!token2); + done(); + }); + }); + }); + }); + adapter.addUserToken('myConnection', 'test', 'user', '123abc'); + adapter.send('hi'); + }); + + it(`signOutUser with no connectionName signs all out`, function (done) { + const adapter = new TestAdapter((context) => { + context.adapter.getUserToken(context, 'myConnection').then(token => { + assert(token); + assert(token.token); + assert(token.connectionName); + context.adapter.signOutUser(context, undefined).then(x => { + context.adapter.getUserToken(context, 'myConnection').then(token2 => { + assert(!token2); + done(); + }); + }); + }); + }); + adapter.addUserToken('myConnection', 'test', 'user', '123abc'); + adapter.addUserToken('myConnection2', 'test', 'user', 'def456'); + adapter.send('hi'); + }); }); diff --git a/libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts b/libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts index af434a51a9..d4a25bb596 100644 --- a/libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts +++ b/libraries/botbuilder-dialogs/src/prompts/oauthPrompt.ts @@ -6,7 +6,7 @@ * Licensed under the MIT License. */ import { Token } from '@microsoft/recognizers-text-date-time'; -import { Activity, ActivityTypes, Attachment, CardFactory, InputHints, MessageFactory, TokenResponse, TurnContext } from 'botbuilder-core'; +import { Activity, ActivityTypes, Attachment, CardFactory, InputHints, MessageFactory, TokenResponse, TurnContext, IUserTokenProvider } from 'botbuilder-core'; import { Dialog, DialogTurnResult } from '../dialog'; import { DialogContext } from '../dialogContext'; import { PromptOptions, PromptRecognizerResult, PromptValidator } from './prompt'; @@ -196,7 +196,7 @@ export class OAuthPrompt extends Dialog { } // Get the token and call validator - const adapter: any = context.adapter as any; // cast to BotFrameworkAdapter + const adapter: IUserTokenProvider = context.adapter as IUserTokenProvider; return await adapter.getUserToken(context, this.settings.connectionName, code); } @@ -223,7 +223,7 @@ export class OAuthPrompt extends Dialog { } // Sign out user - const adapter: any = context.adapter as any; // cast to BotFrameworkAdapter + const adapter: IUserTokenProvider = context.adapter as IUserTokenProvider; return adapter.signOutUser(context, this.settings.connectionName); } diff --git a/libraries/botbuilder-dialogs/tests/oauthPrompt.test.js b/libraries/botbuilder-dialogs/tests/oauthPrompt.test.js new file mode 100644 index 0000000000..55dee67603 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/oauthPrompt.test.js @@ -0,0 +1,128 @@ +const { ActivityTypes, ConversationState, MemoryStorage, TestAdapter, CardFactory } = require('botbuilder-core'); +const { OAuthPrompt, OAuthPromptSettings, DialogSet, DialogTurnStatus, ListStyle } = require('../'); +const assert = require('assert'); + +const beginMessage = { text: `begin`, type: 'message' }; +const answerMessage = { text: `yes`, type: 'message' }; +const invalidMessage = { text: `what?`, type: 'message' }; + +describe('OAuthPrompt', function () { + this.timeout(5000); + + it('should call OAuthPrompt', async function () { + var connectionName = "myConnection"; + var token = "abc123"; + + // Initialize TestAdapter. + const adapter = new TestAdapter(async (turnContext) => { + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { }); + } else if (results.status === DialogTurnStatus.complete) { + if (results.result.token) { + await turnContext.sendActivity(`Logged in.`); + } + else { + await turnContext.sendActivity(`Failed`); + } + } + await convoState.saveChanges(turnContext); + }); + + // Create new ConversationState with MemoryStorage and register the state as middleware. + const convoState = new ConversationState(new MemoryStorage()); + + // Create a DialogState property, DialogSet and AttachmentPrompt. + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new OAuthPrompt('prompt', { + connectionName, + title: 'Login', + timeout: 300000 + })); + + await adapter.send('Hello') + .assertReply(activity => { + assert(activity.attachments.length === 1); + assert(activity.attachments[0].contentType === CardFactory.contentTypes.oauthCard); + + // send a mock EventActivity back to the bot with the token + adapter.addUserToken(connectionName, activity.channelId, activity.recipient.id, token); + + var eventActivity = createReply(activity); + eventActivity.type = ActivityTypes.Event; + var from = eventActivity.from; + eventActivity.from = eventActivity.recipient; + eventActivity.recipient = from; + eventActivity.name = "tokens/response"; + eventActivity.value = { + connectionName, + token + }; + + adapter.send(eventActivity); + }) + .assertReply('Logged in.'); + }); + + it('should call OAuthPrompt with code', async function () { + var connectionName = "myConnection"; + var token = "abc123"; + var magicCode = "888999"; + + // Initialize TestAdapter. + const adapter = new TestAdapter(async (turnContext) => { + const dc = await dialogs.createContext(turnContext); + + const results = await dc.continueDialog(); + if (results.status === DialogTurnStatus.empty) { + await dc.prompt('prompt', { }); + } else if (results.status === DialogTurnStatus.complete) { + if (results.result.token) { + await turnContext.sendActivity(`Logged in.`); + } + else { + await turnContext.sendActivity(`Failed`); + } + } + await convoState.saveChanges(turnContext); + }); + + // Create new ConversationState with MemoryStorage and register the state as middleware. + const convoState = new ConversationState(new MemoryStorage()); + + // Create a DialogState property, DialogSet and AttachmentPrompt. + const dialogState = convoState.createProperty('dialogState'); + const dialogs = new DialogSet(dialogState); + dialogs.add(new OAuthPrompt('prompt', { + connectionName, + title: 'Login', + timeout: 300000 + })); + + await adapter.send('Hello') + .assertReply(activity => { + assert(activity.attachments.length === 1); + assert(activity.attachments[0].contentType === CardFactory.contentTypes.oauthCard); + + // send a mock EventActivity back to the bot with the token + adapter.addUserToken(connectionName, activity.channelId, activity.recipient.id, token, magicCode); + }) + .send(magicCode) + .assertReply('Logged in.'); + }); +}); + +function createReply(activity) { + return { + type: ActivityTypes.Message, + from: { id: activity.recipient.id, name: activity.recipient.name }, + recipient: { id: activity.from.id, name: activity.from.name }, + replyToId: activity.id, + serviceUrl: activity.serviceUrl, + channelId: activity.channelId, + conversation: { isGroup: activity.conversation.isGroup, id: activity.conversation.id, name: activity.conversation.name }, + }; +} diff --git a/libraries/botbuilder/src/botFrameworkAdapter.ts b/libraries/botbuilder/src/botFrameworkAdapter.ts index 91ad0d818d..f456672a65 100644 --- a/libraries/botbuilder/src/botFrameworkAdapter.ts +++ b/libraries/botbuilder/src/botFrameworkAdapter.ts @@ -6,11 +6,10 @@ * Licensed under the MIT License. */ -import { Activity, ActivityTypes, BotAdapter, ChannelAccount, ConversationAccount, ConversationParameters, ConversationReference, ConversationsResult, ResourceResponse, TurnContext } from 'botbuilder-core'; +import { Activity, ActivityTypes, BotAdapter, ChannelAccount, ConversationAccount, ConversationParameters, ConversationReference, ConversationsResult, IUserTokenProvider, ResourceResponse, TokenResponse, TurnContext } from 'botbuilder-core'; import { ChannelValidation, ConnectorClient, EmulatorApiClient, GovernmentConstants, JwtTokenValidation, MicrosoftAppCredentials, SimpleCredentialProvider, TokenApiClient, TokenApiModels } from 'botframework-connector'; import * as os from 'os'; - /** * Express or Restify Request object. */ @@ -105,7 +104,7 @@ const INVOKE_RESPONSE_KEY: symbol = Symbol('invokeResponse'); * }); * ``` */ -export class BotFrameworkAdapter extends BotAdapter { +export class BotFrameworkAdapter extends BotAdapter implements IUserTokenProvider { protected readonly credentials: MicrosoftAppCredentials; protected readonly credentialsProvider: SimpleCredentialProvider; protected readonly settings: BotFrameworkAdapterSettings; @@ -359,7 +358,7 @@ export class BotFrameworkAdapter extends BotAdapter { * @param connectionName Name of the auth connection to use. * @param magicCode (Optional) Optional user entered code to validate. */ - public async getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise { + public async getUserToken(context: TurnContext, connectionName: string, magicCode?: string): Promise { if (!context.activity.from || !context.activity.from.id) { throw new Error(`BotFrameworkAdapter.getUserToken(): missing from or from.id`); } @@ -372,7 +371,7 @@ export class BotFrameworkAdapter extends BotAdapter { if (!result || !result.token || result._response.status == 404) { return undefined; } else { - return result; + return result; } } @@ -418,7 +417,7 @@ export class BotFrameworkAdapter extends BotAdapter { * @param connectionName Name of the auth connection to use. */ public async getAadTokens(context: TurnContext, connectionName: string, resourceUrls: string[]): Promise<{ - [propertyName: string]: TokenApiModels.TokenResponse; + [propertyName: string]: TokenResponse; }> { if (!context.activity.from || !context.activity.from.id) { throw new Error(`BotFrameworkAdapter.getAadTokens(): missing from or from.id`); @@ -428,7 +427,7 @@ export class BotFrameworkAdapter extends BotAdapter { const url: string = this.oauthApiUrl(context); const client: TokenApiClient = this.createTokenApiClient(url); - return (await client.userToken.getAadTokens(userId, connectionName, { resourceUrls: resourceUrls }))._response.parsedBody; + return <{[propertyName: string]: TokenResponse; }>(await client.userToken.getAadTokens(userId, connectionName, { resourceUrls: resourceUrls }))._response.parsedBody; } /**