UNPKG

botbuilder-dialogs

Version:

A dialog stack based conversation manager for Microsoft BotBuilder.

642 lines (578 loc) 24.8 kB
/** * @module botbuilder-dialogs */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { ActionTypes, Activity, ActivityTypes, CardFactory, Channels, CoreAppCredentials, InputHints, MessageFactory, OAuthCard, OAuthLoginTimeoutKey, StatusCodes, TokenExchangeInvokeRequest, TokenResponse, TurnContext, tokenExchangeOperationName, tokenResponseEventName, verifyStateOperationName, } from 'botbuilder-core'; import * as UserTokenAccess from './userTokenAccess'; import { ClaimsIdentity, JwtTokenValidation, SkillValidation } from 'botframework-connector'; import { Dialog, DialogTurnResult } from '../dialog'; import { DialogContext } from '../dialogContext'; import { PromptOptions, PromptRecognizerResult, PromptValidator } from './prompt'; /** * Response body returned for a token exchange invoke activity. */ class TokenExchangeInvokeResponse { id: string; connectionName: string; failureDetail: string; constructor(id: string, connectionName: string, failureDetail: string) { this.id = id; this.connectionName = connectionName; this.failureDetail = failureDetail; } } /** * Settings used to configure an `OAuthPrompt` instance. */ export interface OAuthPromptSettings { /** * AppCredentials for OAuth. */ oAuthAppCredentials?: CoreAppCredentials; /** * Name of the OAuth connection being used. */ connectionName: string; /** * Title of the cards signin button. */ title: string; /** * (Optional) additional text to include on the signin card. */ text?: string; /** * (Optional) number of milliseconds the prompt will wait for the user to authenticate. * Defaults to a value `900,000` (15 minutes.) */ timeout?: number; /** * (Optional) value indicating whether the OAuthPrompt should end upon * receiving an invalid message. Generally the OAuthPrompt will ignore * incoming messages from the user during the auth flow, if they are not related to the * auth flow. This flag enables ending the OAuthPrompt rather than * ignoring the user's message. Typically, this flag will be set to 'true', but is 'false' * by default for backwards compatibility. */ endOnInvalidMessage?: boolean; /** * (Optional) value to force the display of a Sign In link overriding the default behavior. * True to display the SignInLink. */ showSignInLink?: boolean; } /** * 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(); * } * } * ])); * ``` */ export class OAuthPrompt extends Dialog { private readonly PersistedCaller: string = 'botbuilder-dialogs.caller'; /** * 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: string, private settings: OAuthPromptSettings, private validator?: PromptValidator<TokenResponse> ) { super(dialogId); } /** * 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. */ async beginDialog(dc: DialogContext, options?: PromptOptions): Promise<DialogTurnResult> { // Ensure prompts have input hint set const o: Partial<PromptOptions> = { ...options }; if (o.prompt && typeof o.prompt === 'object' && typeof o.prompt.inputHint !== 'string') { o.prompt.inputHint = InputHints.AcceptingInput; } if (o.retryPrompt && typeof o.retryPrompt === 'object' && typeof o.retryPrompt.inputHint !== 'string') { o.retryPrompt.inputHint = InputHints.AcceptingInput; } // Initialize prompt state const timeout = typeof this.settings.timeout === 'number' ? this.settings.timeout : 900000; const state = dc.activeDialog.state as OAuthPromptState; 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 = await UserTokenAccess.getUserToken(dc.context, this.settings, undefined); if (output) { // Return token return await dc.endDialog(output); } // Prompt user to login await OAuthPrompt.sendOAuthCard(this.settings, dc.context, state.options.prompt); return 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. */ async continueDialog(dc: DialogContext): Promise<DialogTurnResult> { // Check for timeout const state: OAuthPromptState = dc.activeDialog.state as OAuthPromptState; const isMessage: boolean = dc.context.activity.type === ActivityTypes.Message; const isTimeoutActivityType: boolean = 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: boolean = isTimeoutActivityType && new Date().getTime() > state.expires; if (hasTimedOut) { return await dc.endDialog(undefined); } else { // Recognize token const recognized: PromptRecognizerResult<TokenResponse> = await this.recognizeToken(dc); if (state.state['attemptCount'] === undefined) { state.state['attemptCount'] = 0; } // Validate the return value let isValid = false; if (this.validator) { isValid = await 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 await dc.endDialog(recognized.value); } if (isMessage && this.settings.endOnInvalidMessage) { return await dc.endDialog(undefined); } // Send retry prompt if (!dc.context.responded && isMessage && state.options.retryPrompt) { await dc.context.sendActivity(state.options.retryPrompt); } return 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. */ async getUserToken(context: TurnContext, code?: string): Promise<TokenResponse | undefined> { 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. */ async signOutUser(context: TurnContext): Promise<void> { 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 async sendOAuthCard( settings: OAuthPromptSettings, turnContext: TurnContext, prompt?: string | Partial<Activity> ): Promise<void> { // Initialize outgoing message const msg: Partial<Activity> = typeof prompt === 'object' ? { ...prompt } : MessageFactory.text(prompt, undefined, 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 === CardFactory.contentTypes.signinCard)) { const signInResource = await UserTokenAccess.getSignInResource(turnContext, settings); msg.attachments.push(CardFactory.signinCard(settings.title, signInResource.signInLink, settings.text)); } } else if (!msg.attachments.some((a) => a.contentType === CardFactory.contentTypes.oauthCard)) { let cardActionType = ActionTypes.Signin; const signInResource = await UserTokenAccess.getSignInResource(turnContext, settings); let link = signInResource.signInLink; const identity = turnContext.turnState.get<ClaimsIdentity>(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 && SkillValidation.isSkillClaim(identity.claims)) || settings.oAuthAppCredentials ) { if (turnContext.activity.channelId === Channels.Emulator) { cardActionType = ActionTypes.OpenUrl; } } else if ( settings.showSignInLink === false || (!settings.showSignInLink && !this.channelRequiresSignInLink(turnContext.activity.channelId)) ) { link = undefined; } // Append oauth card const card = CardFactory.oauthCard( settings.connectionName, settings.title, settings.text, link, signInResource.tokenExchangeResource ); // Set the appropriate ActionType for the button. (card.content as OAuthCard).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(OAuthLoginTimeoutKey) && settings.timeout) { turnContext.turnState.set(OAuthLoginTimeoutKey, settings.timeout); } // Set input hint if (!msg.inputHint) { msg.inputHint = InputHints.AcceptingInput; } // Send prompt await 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 */ async recognizeToken(dc: DialogContext): Promise<PromptRecognizerResult<TokenResponse>> { const context = dc.context; let token: TokenResponse | undefined; if (OAuthPrompt.isTokenResponseEvent(context)) { token = context.activity.value as TokenResponse; // Fix-up the DialogContext's state context if this was received from a skill host caller. const state: CallerInfo = 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<ClaimsIdentity>(context.adapter.BotIdentityKey); const connectorClient = await 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 = await UserTokenAccess.getUserToken(context, this.settings, magicCode); if (token) { await context.sendActivity({ type: 'invokeResponse', value: { status: StatusCodes.OK } }); } else { await context.sendActivity({ type: 'invokeResponse', value: { status: 404 } }); } } catch (_err) { await 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))) { await context.sendActivity( this.getTokenExchangeInvokeResponse( 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 await context.sendActivity( this.getTokenExchangeInvokeResponse( 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: TokenResponse; try { tokenExchangeResponse = await 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) { await context.sendActivity( this.getTokenExchangeInvokeResponse( StatusCodes.PRECONDITION_FAILED, 'The bot is unable to exchange token. Proceed with regular login.' ) ); } else { await context.sendActivity( this.getTokenExchangeInvokeResponse(StatusCodes.OK, null, context.activity.value.id) ); token = { channelId: tokenExchangeResponse.channelId, connectionName: tokenExchangeResponse.connectionName, token: tokenExchangeResponse.token, expiration: null, }; } } } else if (context.activity.type === ActivityTypes.Message) { const [, magicCode] = /(\d{6})/.exec(context.activity.text) ?? []; if (magicCode) { token = await UserTokenAccess.getUserToken(context, this.settings, magicCode); } } return token !== undefined ? { succeeded: true, value: token } : { succeeded: false }; } /** * @private */ private static createCallerInfo(context: TurnContext) { const botIdentity = context.turnState.get<ClaimsIdentity>(context.adapter.BotIdentityKey); if (botIdentity && SkillValidation.isSkillClaim(botIdentity.claims)) { return { callerServiceUrl: context.activity.serviceUrl, scope: JwtTokenValidation.getAppIdFromClaims(botIdentity.claims), }; } return null; } /** * @private */ private getTokenExchangeInvokeResponse(status: number, failureDetail: string, id?: string): Activity { const invokeResponse: Partial<Activity> = { type: 'invokeResponse', value: { status, body: new TokenExchangeInvokeResponse(id, this.settings.connectionName, failureDetail) }, }; return invokeResponse as Activity; } /** * @private */ private static isFromStreamingConnection(activity: Activity): boolean { return activity && activity.serviceUrl && !activity.serviceUrl.toLowerCase().startsWith('http'); } /** * @private */ private static isTokenResponseEvent(context: TurnContext): boolean { const activity: Activity = context.activity; return activity.type === ActivityTypes.Event && activity.name === tokenResponseEventName; } /** * @private */ private static isTeamsVerificationInvoke(context: TurnContext): boolean { const activity: Activity = context.activity; return activity.type === ActivityTypes.Invoke && activity.name === verifyStateOperationName; } /** * @private */ private static isOAuthCardSupported(context: TurnContext): boolean { // 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: any = 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 */ private static isTokenExchangeRequestInvoke(context: TurnContext): boolean { const activity: Activity = context.activity; return activity.type === ActivityTypes.Invoke && activity.name === tokenExchangeOperationName; } /** * @private */ private static isTokenExchangeRequest(obj: unknown): obj is TokenExchangeInvokeRequest { if (Object.prototype.hasOwnProperty.call(obj, 'token')) { return true; } return false; } /** * @private */ private static channelSupportsOAuthCard(channelId: string): boolean { switch (channelId) { case Channels.Skype: case Channels.Skypeforbusiness: return false; default: } return true; } /** * @private */ private static channelRequiresSignInLink(channelId: string): boolean { switch (channelId) { case Channels.Msteams: return true; default: } return false; } } /** * @private */ interface OAuthPromptState { state: any; options: PromptOptions; expires: number; // Timestamp of when the prompt will timeout. } /** * @private */ interface CallerInfo { callerServiceUrl: string; scope: string; }