UNPKG

botbuilder-dialogs

Version:

A dialog stack based conversation manager for Microsoft BotBuilder.

499 lines 23.7 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthPrompt = void 0; /** * @module botbuilder-dialogs */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ const botbuilder_core_1 = require("botbuilder-core"); const UserTokenAccess = require("./userTokenAccess"); const botframework_connector_1 = require("botframework-connector"); const dialog_1 = require("../dialog"); /** * Response body returned for a token exchange invoke activity. */ class TokenExchangeInvokeResponse { constructor(id, connectionName, failureDetail) { this.id = id; this.connectionName = connectionName; this.failureDetail = failureDetail; } } /** * Creates a new prompt that asks the user to sign in using the Bot Frameworks Single Sign On (SSO) * service. * * @remarks * The prompt will attempt to retrieve the users current token and if the user isn't signed in, it * will send them an `OAuthCard` containing a button they can press to signin. Depending on the * channel, the user will be sent through one of two possible signin flows: * * - The automatic signin flow where once the user signs in and the SSO service will forward the bot * the users access token using either an `event` or `invoke` activity. * - The "magic code" flow where where once the user signs in they will be prompted by the SSO * service to send the bot a six digit code confirming their identity. This code will be sent as a * standard `message` activity. * * Both flows are automatically supported by the `OAuthPrompt` and the only thing you need to be * careful of is that you don't block the `event` and `invoke` activities that the prompt might * be waiting on. * * > [!NOTE] * > You should avoid persisting the access token with your bots other state. The Bot Frameworks * > SSO service will securely store the token on your behalf. If you store it in your bots state * > it could expire or be revoked in between turns. * > * > When calling the prompt from within a waterfall step you should use the token within the step * > following the prompt and then let the token go out of scope at the end of your function. * * #### Prompt Usage * * When used with your bots `DialogSet` you can simply add a new instance of the prompt as a named * dialog using `DialogSet.add()`. You can then start the prompt from a waterfall step using either * `DialogContext.beginDialog()` or `DialogContext.prompt()`. The user will be prompted to signin as * needed and their access token will be passed as an argument to the callers next waterfall step: * * ```JavaScript * const { ConversationState, MemoryStorage, OAuthLoginTimeoutMsValue } = require('botbuilder'); * const { DialogSet, OAuthPrompt, WaterfallDialog } = require('botbuilder-dialogs'); * * const convoState = new ConversationState(new MemoryStorage()); * const dialogState = convoState.createProperty('dialogState'); * const dialogs = new DialogSet(dialogState); * * dialogs.add(new OAuthPrompt('loginPrompt', { * connectionName: 'GitConnection', * title: 'Login To GitHub', * timeout: OAuthLoginTimeoutMsValue // User has 15 minutes to login * })); * * dialogs.add(new WaterfallDialog('taskNeedingLogin', [ * async (step) => { * return await step.beginDialog('loginPrompt'); * }, * async (step) => { * const token = step.result; * if (token) { * * // ... continue with task needing access token ... * * } else { * await step.context.sendActivity(`Sorry... We couldn't log you in. Try again later.`); * return await step.endDialog(); * } * } * ])); * ``` */ class OAuthPrompt extends dialog_1.Dialog { /** * Creates a new OAuthPrompt instance. * * @param dialogId Unique ID of the dialog within its parent `DialogSet` or `ComponentDialog`. * @param settings Settings used to configure the prompt. * @param validator (Optional) validator that will be called each time the user responds to the prompt. */ constructor(dialogId, settings, validator) { super(dialogId); this.settings = settings; this.validator = validator; this.PersistedCaller = 'botbuilder-dialogs.caller'; } /** * Called when a prompt dialog is pushed onto the dialog stack and is being activated. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current * turn of the conversation. * @param options Optional. [PromptOptions](xref:botbuilder-dialogs.PromptOptions), * additional information to pass to the prompt being started. * @returns A `Promise` representing the asynchronous operation. * @remarks * If the task is successful, the result indicates whether the prompt is still * active after the turn has been processed by the prompt. */ beginDialog(dc, options) { return __awaiter(this, void 0, void 0, function* () { // Ensure prompts have input hint set const o = Object.assign({}, options); if (o.prompt && typeof o.prompt === 'object' && typeof o.prompt.inputHint !== 'string') { o.prompt.inputHint = botbuilder_core_1.InputHints.AcceptingInput; } if (o.retryPrompt && typeof o.retryPrompt === 'object' && typeof o.retryPrompt.inputHint !== 'string') { o.retryPrompt.inputHint = botbuilder_core_1.InputHints.AcceptingInput; } // Initialize prompt state const timeout = typeof this.settings.timeout === 'number' ? this.settings.timeout : 900000; const state = dc.activeDialog.state; state.state = {}; state.options = o; state.expires = new Date().getTime() + timeout; state[this.PersistedCaller] = OAuthPrompt.createCallerInfo(dc.context); // Attempt to get the users token const output = yield UserTokenAccess.getUserToken(dc.context, this.settings, undefined); if (output) { // Return token return yield dc.endDialog(output); } // Prompt user to login yield OAuthPrompt.sendOAuthCard(this.settings, dc.context, state.options.prompt); return dialog_1.Dialog.EndOfTurn; }); } /** * Called when a prompt dialog is the active dialog and the user replied with a new activity. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn * of the conversation. * @returns A `Promise` representing the asynchronous operation. * @remarks * If the task is successful, the result indicates whether the dialog is still * active after the turn has been processed by the dialog. * The prompt generally continues to receive the user's replies until it accepts the * user's reply as valid input for the prompt. */ continueDialog(dc) { return __awaiter(this, void 0, void 0, function* () { // Check for timeout const state = dc.activeDialog.state; const isMessage = dc.context.activity.type === botbuilder_core_1.ActivityTypes.Message; const isTimeoutActivityType = isMessage || OAuthPrompt.isTokenResponseEvent(dc.context) || OAuthPrompt.isTeamsVerificationInvoke(dc.context) || OAuthPrompt.isTokenExchangeRequestInvoke(dc.context); // If the incoming Activity is a message, or an Activity Type normally handled by OAuthPrompt, // check to see if this OAuthPrompt Expiration has elapsed, and end the dialog if so. const hasTimedOut = isTimeoutActivityType && new Date().getTime() > state.expires; if (hasTimedOut) { return yield dc.endDialog(undefined); } else { // Recognize token const recognized = yield this.recognizeToken(dc); if (state.state['attemptCount'] === undefined) { state.state['attemptCount'] = 0; } // Validate the return value let isValid = false; if (this.validator) { isValid = yield this.validator({ context: dc.context, recognized: recognized, state: state.state, options: state.options, attemptCount: ++state.state['attemptCount'], }); } else if (recognized.succeeded) { isValid = true; } // Return recognized value or re-prompt if (isValid) { return yield dc.endDialog(recognized.value); } if (isMessage && this.settings.endOnInvalidMessage) { return yield dc.endDialog(undefined); } // Send retry prompt if (!dc.context.responded && isMessage && state.options.retryPrompt) { yield dc.context.sendActivity(state.options.retryPrompt); } return dialog_1.Dialog.EndOfTurn; } }); } /** * Attempts to retrieve the stored token for the current user. * * @param context Context reference the user that's being looked up. * @param code (Optional) login code received from the user. * @returns The token response. */ getUserToken(context, code) { return __awaiter(this, void 0, void 0, function* () { return UserTokenAccess.getUserToken(context, this.settings, code); }); } /** * Signs the user out of the service. * * @remarks * This example shows creating an instance of the prompt and then signing out the user. * * ```JavaScript * const prompt = new OAuthPrompt({ * connectionName: 'GitConnection', * title: 'Login To GitHub' * }); * await prompt.signOutUser(context); * ``` * @param context Context referencing the user that's being signed out. * @returns A promise representing the asynchronous operation. */ signOutUser(context) { return __awaiter(this, void 0, void 0, function* () { return UserTokenAccess.signOutUser(context, this.settings); }); } /** * Sends an OAuth card. * * @param {OAuthPromptSettings} settings OAuth settings. * @param {TurnContext} turnContext Turn context. * @param {string | Partial<Activity>} prompt Message activity. */ static sendOAuthCard(settings, turnContext, prompt) { return __awaiter(this, void 0, void 0, function* () { // Initialize outgoing message const msg = typeof prompt === 'object' ? Object.assign({}, prompt) : botbuilder_core_1.MessageFactory.text(prompt, undefined, botbuilder_core_1.InputHints.AcceptingInput); if (!Array.isArray(msg.attachments)) { msg.attachments = []; } // Append appropriate card if missing if (!this.isOAuthCardSupported(turnContext)) { if (!msg.attachments.some((a) => a.contentType === botbuilder_core_1.CardFactory.contentTypes.signinCard)) { const signInResource = yield UserTokenAccess.getSignInResource(turnContext, settings); msg.attachments.push(botbuilder_core_1.CardFactory.signinCard(settings.title, signInResource.signInLink, settings.text)); } } else if (!msg.attachments.some((a) => a.contentType === botbuilder_core_1.CardFactory.contentTypes.oauthCard)) { let cardActionType = botbuilder_core_1.ActionTypes.Signin; const signInResource = yield UserTokenAccess.getSignInResource(turnContext, settings); let link = signInResource.signInLink; const identity = turnContext.turnState.get(turnContext.adapter.BotIdentityKey); // use the SignInLink when // in speech channel or // bot is a skill or // an extra OAuthAppCredentials is being passed in if (OAuthPrompt.isFromStreamingConnection(turnContext.activity) || (identity && botframework_connector_1.SkillValidation.isSkillClaim(identity.claims)) || settings.oAuthAppCredentials) { if (turnContext.activity.channelId === botbuilder_core_1.Channels.Emulator) { cardActionType = botbuilder_core_1.ActionTypes.OpenUrl; } } else if (settings.showSignInLink === false || (!settings.showSignInLink && !this.channelRequiresSignInLink(turnContext.activity.channelId))) { link = undefined; } // Append oauth card const card = botbuilder_core_1.CardFactory.oauthCard(settings.connectionName, settings.title, settings.text, link, signInResource.tokenExchangeResource); // Set the appropriate ActionType for the button. card.content.buttons[0].type = cardActionType; msg.attachments.push(card); } // Add the login timeout specified in OAuthPromptSettings to TurnState so it can be referenced if polling is needed if (!turnContext.turnState.get(botbuilder_core_1.OAuthLoginTimeoutKey) && settings.timeout) { turnContext.turnState.set(botbuilder_core_1.OAuthLoginTimeoutKey, settings.timeout); } // Set input hint if (!msg.inputHint) { msg.inputHint = botbuilder_core_1.InputHints.AcceptingInput; } // Send prompt yield turnContext.sendActivity(msg); }); } /** * Shared implementation of the RecognizeTokenAsync function. This is intended for internal use, to consolidate * the implementation of the OAuthPrompt and OAuthInput. Application logic should use those dialog classes. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of the conversation. * @returns A Promise that resolves to the result */ recognizeToken(dc) { var _a; return __awaiter(this, void 0, void 0, function* () { const context = dc.context; let token; if (OAuthPrompt.isTokenResponseEvent(context)) { token = context.activity.value; // Fix-up the DialogContext's state context if this was received from a skill host caller. const state = dc.activeDialog.state[this.PersistedCaller]; if (state) { // Set the ServiceUrl to the skill host's Url context.activity.serviceUrl = state.callerServiceUrl; const claimsIdentity = context.turnState.get(context.adapter.BotIdentityKey); const connectorClient = yield UserTokenAccess.createConnectorClient(context, context.activity.serviceUrl, claimsIdentity, state.scope); context.turnState.set(context.adapter.ConnectorClientKey, connectorClient); } } else if (OAuthPrompt.isTeamsVerificationInvoke(context)) { const magicCode = context.activity.value.state; try { token = yield UserTokenAccess.getUserToken(context, this.settings, magicCode); if (token) { yield context.sendActivity({ type: 'invokeResponse', value: { status: botbuilder_core_1.StatusCodes.OK } }); } else { yield context.sendActivity({ type: 'invokeResponse', value: { status: 404 } }); } } catch (_err) { yield context.sendActivity({ type: 'invokeResponse', value: { status: 500 } }); } } else if (OAuthPrompt.isTokenExchangeRequestInvoke(context)) { // Received activity is not a token exchange request if (!(context.activity.value && OAuthPrompt.isTokenExchangeRequest(context.activity.value))) { yield context.sendActivity(this.getTokenExchangeInvokeResponse(botbuilder_core_1.StatusCodes.BAD_REQUEST, 'The bot received an InvokeActivity that is missing a TokenExchangeInvokeRequest value. This is required to be sent with the InvokeActivity.')); } else if (context.activity.value.connectionName != this.settings.connectionName) { // Connection name on activity does not match that of setting yield context.sendActivity(this.getTokenExchangeInvokeResponse(botbuilder_core_1.StatusCodes.BAD_REQUEST, 'The bot received an InvokeActivity with a TokenExchangeInvokeRequest containing a ConnectionName that does not match the ConnectionName' + 'expected by the bots active OAuthPrompt. Ensure these names match when sending the InvokeActivityInvalid ConnectionName in the TokenExchangeInvokeRequest')); } else { let tokenExchangeResponse; try { tokenExchangeResponse = yield UserTokenAccess.exchangeToken(context, this.settings, { token: context.activity.value.token, }); } catch (_err) { // Ignore errors. // If the token exchange failed for any reason, the tokenExchangeResponse stays undefined // and we send back a failure invoke response to the caller. } if (!tokenExchangeResponse || !tokenExchangeResponse.token) { yield context.sendActivity(this.getTokenExchangeInvokeResponse(botbuilder_core_1.StatusCodes.PRECONDITION_FAILED, 'The bot is unable to exchange token. Proceed with regular login.')); } else { yield context.sendActivity(this.getTokenExchangeInvokeResponse(botbuilder_core_1.StatusCodes.OK, null, context.activity.value.id)); token = { channelId: tokenExchangeResponse.channelId, connectionName: tokenExchangeResponse.connectionName, token: tokenExchangeResponse.token, expiration: null, }; } } } else if (context.activity.type === botbuilder_core_1.ActivityTypes.Message) { const [, magicCode] = (_a = /(\d{6})/.exec(context.activity.text)) !== null && _a !== void 0 ? _a : []; if (magicCode) { token = yield UserTokenAccess.getUserToken(context, this.settings, magicCode); } } return token !== undefined ? { succeeded: true, value: token } : { succeeded: false }; }); } /** * @private */ static createCallerInfo(context) { const botIdentity = context.turnState.get(context.adapter.BotIdentityKey); if (botIdentity && botframework_connector_1.SkillValidation.isSkillClaim(botIdentity.claims)) { return { callerServiceUrl: context.activity.serviceUrl, scope: botframework_connector_1.JwtTokenValidation.getAppIdFromClaims(botIdentity.claims), }; } return null; } /** * @private */ getTokenExchangeInvokeResponse(status, failureDetail, id) { const invokeResponse = { type: 'invokeResponse', value: { status, body: new TokenExchangeInvokeResponse(id, this.settings.connectionName, failureDetail) }, }; return invokeResponse; } /** * @private */ static isFromStreamingConnection(activity) { return activity && activity.serviceUrl && !activity.serviceUrl.toLowerCase().startsWith('http'); } /** * @private */ static isTokenResponseEvent(context) { const activity = context.activity; return activity.type === botbuilder_core_1.ActivityTypes.Event && activity.name === botbuilder_core_1.tokenResponseEventName; } /** * @private */ static isTeamsVerificationInvoke(context) { const activity = context.activity; return activity.type === botbuilder_core_1.ActivityTypes.Invoke && activity.name === botbuilder_core_1.verifyStateOperationName; } /** * @private */ static isOAuthCardSupported(context) { // Azure Bot Service OAuth cards are not supported in the community adapters. Since community adapters // have a 'name' in them, we cast the adapter to 'any' to check for the name. const adapter = context.adapter; if (adapter.name) { switch (adapter.name) { case 'Facebook Adapter': case 'Google Hangouts Adapter': case 'Slack Adapter': case 'Twilio SMS Adapter': case 'Web Adapter': case 'Webex Adapter': case 'Botkit CMS': return false; default: } } return this.channelSupportsOAuthCard(context.activity.channelId); } /** * @private */ static isTokenExchangeRequestInvoke(context) { const activity = context.activity; return activity.type === botbuilder_core_1.ActivityTypes.Invoke && activity.name === botbuilder_core_1.tokenExchangeOperationName; } /** * @private */ static isTokenExchangeRequest(obj) { if (Object.prototype.hasOwnProperty.call(obj, 'token')) { return true; } return false; } /** * @private */ static channelSupportsOAuthCard(channelId) { switch (channelId) { case botbuilder_core_1.Channels.Skype: case botbuilder_core_1.Channels.Skypeforbusiness: return false; default: } return true; } /** * @private */ static channelRequiresSignInLink(channelId) { switch (channelId) { case botbuilder_core_1.Channels.Msteams: return true; default: } return false; } } exports.OAuthPrompt = OAuthPrompt; //# sourceMappingURL=oauthPrompt.js.map