botbuilder-dialogs
Version:
A dialog stack based conversation manager for Microsoft BotBuilder.
499 lines • 23.7 kB
JavaScript
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
;