UNPKG

@microsoft/agents-hosting

Version:

Microsoft 365 Agents SDK for JavaScript

429 lines 23.5 kB
"use strict"; /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AzureBotAuthorization = void 0; const logger_1 = require("@microsoft/agents-activity/logger"); const types_1 = require("../types"); const messageFactory_1 = require("../../../messageFactory"); const cards_1 = require("../../../cards"); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const handlerStorage_1 = require("../handlerStorage"); const agents_activity_1 = require("@microsoft/agents-activity"); const logger = (0, logger_1.debug)('agents:authorization:azurebot'); const DEFAULT_SIGN_IN_ATTEMPTS = 2; var Category; (function (Category) { Category["SIGNIN"] = "signin"; Category["UNKNOWN"] = "unknown"; })(Category || (Category = {})); /** * Default implementation of an authorization handler using Azure Bot Service. */ class AzureBotAuthorization { /** * Creates an instance of the AzureBotAuthorization. * @param id The unique identifier for the handler. * @param options The settings for the handler. * @param app The agent application instance. */ constructor(id, options, settings) { this.id = id; this.settings = settings; this._key = `${AzureBotAuthorization.name}/${this.id}`; /** * Predefined messages with dynamic placeholders. */ this.messages = { invalidCode: (code) => { var _a, _b; const message = (_b = (_a = this._options.messages) === null || _a === void 0 ? void 0 : _a.invalidCode) !== null && _b !== void 0 ? _b : 'Invalid **{code}** code entered. Please try again with a new sign-in request.'; return message.replaceAll('{code}', code); }, invalidCodeFormat: (attemptsLeft) => { var _a, _b; const message = (_b = (_a = this._options.messages) === null || _a === void 0 ? void 0 : _a.invalidCodeFormat) !== null && _b !== void 0 ? _b : 'Please enter a valid **6-digit** code format (_e.g. 123456_).\r\n**{attemptsLeft} attempt(s) left...**'; return message.replaceAll('{attemptsLeft}', attemptsLeft.toString()); }, maxAttemptsExceeded: (maxAttempts) => { var _a, _b; const message = (_b = (_a = this._options.messages) === null || _a === void 0 ? void 0 : _a.maxAttemptsExceeded) !== null && _b !== void 0 ? _b : 'You have exceeded the maximum number of sign-in attempts ({maxAttempts}). Please try again with a new sign-in request.'; return message.replaceAll('{maxAttempts}', maxAttempts.toString()); }, }; if (!this.settings.storage) { throw new Error(this.prefix('The \'storage\' option is not available in the app options. Ensure that the app is properly configured.')); } if (!this.settings.connections) { throw new Error(this.prefix('The \'connections\' option is not available in the app options. Ensure that the app is properly configured.')); } this._options = this.loadOptions(options); } /** * Loads and validates the authorization handler options. */ loadOptions(settings) { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r; const result = { name: (_a = settings.name) !== null && _a !== void 0 ? _a : (process.env[`${this.id}_connectionName`]), title: (_c = (_b = settings.title) !== null && _b !== void 0 ? _b : (process.env[`${this.id}_connectionTitle`])) !== null && _c !== void 0 ? _c : 'Sign-in', text: (_e = (_d = settings.text) !== null && _d !== void 0 ? _d : (process.env[`${this.id}_connectionText`])) !== null && _e !== void 0 ? _e : 'Please sign-in to continue', maxAttempts: (_f = settings.maxAttempts) !== null && _f !== void 0 ? _f : parseInt(process.env[`${this.id}_maxAttempts`]), messages: { invalidCode: (_h = (_g = settings.messages) === null || _g === void 0 ? void 0 : _g.invalidCode) !== null && _h !== void 0 ? _h : process.env[`${this.id}_messages_invalidCode`], invalidCodeFormat: (_k = (_j = settings.messages) === null || _j === void 0 ? void 0 : _j.invalidCodeFormat) !== null && _k !== void 0 ? _k : process.env[`${this.id}_messages_invalidCodeFormat`], maxAttemptsExceeded: (_m = (_l = settings.messages) === null || _l === void 0 ? void 0 : _l.maxAttemptsExceeded) !== null && _m !== void 0 ? _m : process.env[`${this.id}_messages_maxAttemptsExceeded`], }, obo: { connection: (_p = (_o = settings.obo) === null || _o === void 0 ? void 0 : _o.connection) !== null && _p !== void 0 ? _p : process.env[`${this.id}_obo_connection`], scopes: (_r = (_q = settings.obo) === null || _q === void 0 ? void 0 : _q.scopes) !== null && _r !== void 0 ? _r : this.loadScopes(process.env[`${this.id}_obo_scopes`]), }, enableSso: process.env[`${this.id}_enableSso`] !== 'false' // default value is true }; if (!result.name) { throw new Error(this.prefix(`The 'name' property or '${this.id}_connectionName' env variable is required to initialize the handler.`)); } return result; } /** * Maximum number of attempts for magic code entry. */ get maxAttempts() { const attempts = this._options.maxAttempts; const result = typeof attempts === 'number' && Number.isFinite(attempts) ? Math.round(attempts) : NaN; return result > 0 ? result : DEFAULT_SIGN_IN_ATTEMPTS; } /** * Sets a handler to be called when a user successfully signs in. * @param callback The callback function to be invoked on successful sign-in. */ onSuccess(callback) { this._onSuccess = callback; } /** * Sets a handler to be called when a user fails to sign in. * @param callback The callback function to be invoked on sign-in failure. */ onFailure(callback) { this._onFailure = callback; } /** * Retrieves the token for the user, optionally using on-behalf-of flow for specified scopes. * @param context The turn context. * @param options Optional options for token acquisition, including connection and scopes for on-behalf-of flow. * @returns The token response containing the token or undefined if not available. */ async token(context, options) { var _a; let { token } = this.getContext(context); if (!(token === null || token === void 0 ? void 0 : token.trim())) { const { activity } = context; const userTokenClient = await this.getUserTokenClient(context); // Using getTokenOrSignInResource instead of getUserToken to avoid HTTP 404 errors. const { tokenResponse } = await userTokenClient.getTokenOrSignInResource((_a = activity.from) === null || _a === void 0 ? void 0 : _a.id, this._options.name, activity.channelId, activity.getConversationReference(), activity.relatesTo, ''); token = tokenResponse === null || tokenResponse === void 0 ? void 0 : tokenResponse.token; } if (!(token === null || token === void 0 ? void 0 : token.trim())) { return { token: undefined }; } return await this.handleOBO(token, options); } /** * Signs out the user from the service. * @param context The turn context. * @returns True if the signout was successful, false otherwise. */ async signout(context) { var _a; const user = (_a = context.activity.from) === null || _a === void 0 ? void 0 : _a.id; const channel = context.activity.channelId; const connection = this._options.name; if (!channel || !user) { throw new Error(this.prefix('Both \'activity.channelId\' and \'activity.from.id\' are required to perform signout.')); } logger.debug(this.prefix(`Signing out User '${user}' from => Channel: '${channel}', Connection: '${connection}'`), context.activity); const userTokenClient = await this.getUserTokenClient(context); await userTokenClient.signOut(user, connection, channel); return true; } /** * Initiates the sign-in process for the handler. * @param context The turn context. * @param active Optional active handler data. * @returns The status of the sign-in attempt. */ async signin(context, active) { var _a, _b, _c, _d, _e; const { activity } = context; const [category] = (_b = (_a = activity.name) === null || _a === void 0 ? void 0 : _a.split('/')) !== null && _b !== void 0 ? _b : [Category.UNKNOWN]; const storage = new handlerStorage_1.HandlerStorage(this.settings.storage, context); if (!active) { return this.setToken(storage, context); } logger.debug(this.prefix('Sign-in active session detected'), active.activity); if (((_c = active.activity.conversation) === null || _c === void 0 ? void 0 : _c.id) !== ((_d = activity.conversation) === null || _d === void 0 ? void 0 : _d.id)) { await this.sendInvokeResponse(context, { status: 400 }); logger.warn(this.prefix('Discarding the active session due to the conversation has changed during an active sign-in process'), activity); return types_1.AuthorizationHandlerStatus.IGNORED; } if (active.attemptsLeft <= 0) { logger.warn(this.prefix('Maximum sign-in attempts exceeded'), activity); await context.sendActivity(messageFactory_1.MessageFactory.text(this.messages.maxAttemptsExceeded(this.maxAttempts))); return types_1.AuthorizationHandlerStatus.REJECTED; } if (category === Category.SIGNIN) { await storage.write({ ...active, category }); const status = await this.handleSignInActivities(context); if (status !== types_1.AuthorizationHandlerStatus.IGNORED) { return status; } } else if (active.category === Category.SIGNIN) { // This is only for safety in case of unexpected behaviors during the MS Teams sign-in process, // e.g., user interrupts the flow by clicking the Consent Cancel button. logger.warn(this.prefix('The incoming activity will be revalidated due to a change in the sign-in flow'), activity); return types_1.AuthorizationHandlerStatus.REVALIDATE; } const { status, code } = await this.codeVerification(storage, context, active); if (status !== types_1.AuthorizationHandlerStatus.APPROVED) { return status; } try { const result = await this.setToken(storage, context, active, code); if (result !== types_1.AuthorizationHandlerStatus.APPROVED) { await this.sendInvokeResponse(context, { status: 404 }); return result; } await this.sendInvokeResponse(context, { status: 200 }); await ((_e = this._onSuccess) === null || _e === void 0 ? void 0 : _e.call(this, context)); return result; } catch (error) { await this.sendInvokeResponse(context, { status: 500 }); if (error instanceof Error) { error.message = this.prefix(error.message); } throw error; } } /** * Handles on-behalf-of token acquisition. */ async handleOBO(token, options) { var _a, _b, _c; const oboConnection = (_a = options === null || options === void 0 ? void 0 : options.connection) !== null && _a !== void 0 ? _a : (_b = this._options.obo) === null || _b === void 0 ? void 0 : _b.connection; const oboScopes = (options === null || options === void 0 ? void 0 : options.scopes) && options.scopes.length > 0 ? options.scopes : (_c = this._options.obo) === null || _c === void 0 ? void 0 : _c.scopes; if (!oboScopes || oboScopes.length === 0) { return { token }; } if (!this.isExchangeable(token)) { throw new Error(this.prefix('The current token is not exchangeable for an on-behalf-of flow. Ensure the token audience starts with \'api://\'.')); } try { const provider = oboConnection ? this.settings.connections.getConnection(oboConnection) : this.settings.connections.getDefaultConnection(); const newToken = await provider.acquireTokenOnBehalfOf(oboScopes, token); logger.debug(this.prefix('Successfully acquired on-behalf-of token'), { connection: oboConnection, scopes: oboScopes }); return { token: newToken }; } catch (error) { logger.error(this.prefix('Failed to exchange on-behalf-of token'), { connection: oboConnection, scopes: oboScopes }, error); return { token: undefined }; } } /** * Checks if a token is exchangeable for an on-behalf-of flow. */ isExchangeable(token) { if (!token || typeof token !== 'string') { return false; } const payload = jsonwebtoken_1.default.decode(token); const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; return audiences.some(aud => typeof aud === 'string' && aud.startsWith('api://')); } /** * Sets the token from the token response or initiates the sign-in flow. */ async setToken(storage, context, active, code) { var _a; const { activity } = context; const userTokenClient = await this.getUserTokenClient(context); const { tokenResponse, signInResource } = await userTokenClient.getTokenOrSignInResource((_a = activity.from) === null || _a === void 0 ? void 0 : _a.id, this._options.name, activity.channelId, activity.getConversationReference(), activity.relatesTo, code !== null && code !== void 0 ? code : ''); if (!tokenResponse && active) { logger.warn(this.prefix('Invalid code entered. Restarting sign-in flow'), activity); await context.sendActivity(messageFactory_1.MessageFactory.text(this.messages.invalidCode(code !== null && code !== void 0 ? code : ''))); return types_1.AuthorizationHandlerStatus.REJECTED; } if (!tokenResponse) { logger.debug(this.prefix('Cannot find token. Sending sign-in card'), activity); const oCard = cards_1.CardFactory.oauthCard(this._options.name, this._options.title, this._options.text, signInResource, this._options.enableSso); await context.sendActivity(messageFactory_1.MessageFactory.attachment(oCard)); await storage.write({ activity, id: this.id, ...(active !== null && active !== void 0 ? active : {}), attemptsLeft: this.maxAttempts }); return types_1.AuthorizationHandlerStatus.PENDING; } logger.debug(this.prefix('Successfully acquired token'), activity); this.setContext(context, { token: tokenResponse.token }); return types_1.AuthorizationHandlerStatus.APPROVED; } /** * Handles sign-in related activities. */ async handleSignInActivities(context) { var _a, _b, _c, _d; const { activity } = context; // Ignore signin/verifyState here (handled in codeVerification). if (activity.name === 'signin/verifyState') { return types_1.AuthorizationHandlerStatus.IGNORED; } const userTokenClient = await this.getUserTokenClient(context); if (activity.name === 'signin/tokenExchange') { const tokenExchangeInvokeRequest = activity.value; const tokenExchangeRequest = { token: tokenExchangeInvokeRequest.token }; if (!(tokenExchangeRequest === null || tokenExchangeRequest === void 0 ? void 0 : tokenExchangeRequest.token)) { const reason = 'The Agent received an InvokeActivity that is missing a TokenExchangeInvokeRequest value. This is required to be sent with the InvokeActivity.'; await this.sendInvokeResponse(context, { status: 400, body: { connectionName: this._options.name, failureDetail: reason } }); logger.error(this.prefix(reason)); await ((_a = this._onFailure) === null || _a === void 0 ? void 0 : _a.call(this, context, reason)); return types_1.AuthorizationHandlerStatus.REJECTED; } if (tokenExchangeInvokeRequest.connectionName !== this._options.name) { const reason = `The Agent received an InvokeActivity with a TokenExchangeInvokeRequest for a different connection name ('${tokenExchangeInvokeRequest.connectionName}') than expected ('${this._options.name}').`; await this.sendInvokeResponse(context, { status: 400, body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name, failureDetail: reason } }); logger.error(this.prefix(reason)); await ((_b = this._onFailure) === null || _b === void 0 ? void 0 : _b.call(this, context, reason)); return types_1.AuthorizationHandlerStatus.REJECTED; } const { token } = await userTokenClient.exchangeTokenAsync((_c = activity.from) === null || _c === void 0 ? void 0 : _c.id, this._options.name, activity.channelId, tokenExchangeRequest); if (!token) { const reason = 'The MS Teams token service didn\'t send back the exchanged token. Waiting for MS Teams to send another signin/tokenExchange request. After multiple failed attempts, the user will be asked to enter the magic code.'; await this.sendInvokeResponse(context, { status: 412, body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name, failureDetail: reason } }); logger.debug(this.prefix(reason)); return types_1.AuthorizationHandlerStatus.PENDING; } await this.sendInvokeResponse(context, { status: 200, body: { id: tokenExchangeInvokeRequest.id, connectionName: this._options.name } }); logger.debug(this.prefix('Successfully exchanged token')); this.setContext(context, { token }); await ((_d = this._onSuccess) === null || _d === void 0 ? void 0 : _d.call(this, context)); return types_1.AuthorizationHandlerStatus.APPROVED; } if (activity.name === 'signin/failure') { await this.sendInvokeResponse(context, { status: 200 }); const reason = 'Failed to sign-in'; const value = activity.value; logger.error(this.prefix(reason), value, activity); if (this._onFailure) { await this._onFailure(context, value.message || reason); } else { await context.sendActivity(messageFactory_1.MessageFactory.text(`${reason}. Please try again.`)); } return types_1.AuthorizationHandlerStatus.REJECTED; } logger.error(this.prefix(`Unknown sign-in activity name: ${activity.name}`), activity); return types_1.AuthorizationHandlerStatus.REJECTED; } /** * Verifies the magic code provided by the user. */ async codeVerification(storage, context, active) { if (!active) { logger.debug(this.prefix('No active session found. Skipping code verification.'), context.activity); return { status: types_1.AuthorizationHandlerStatus.IGNORED }; } const { activity } = context; let state = activity.text; if (activity.name === 'signin/verifyState') { logger.debug(this.prefix('Getting code from activity.value'), activity); const { state: teamsState } = activity.value; state = teamsState; } if (state === 'CancelledByUser') { await this.sendInvokeResponse(context, { status: 200 }); logger.warn(this.prefix('Sign-in process was cancelled by the user'), activity); return { status: types_1.AuthorizationHandlerStatus.REJECTED }; } if (!(state === null || state === void 0 ? void 0 : state.match(/^\d{6}$/))) { logger.warn(this.prefix(`Invalid magic code entered. Attempts left: ${active.attemptsLeft}`), activity); await context.sendActivity(messageFactory_1.MessageFactory.text(this.messages.invalidCodeFormat(active.attemptsLeft))); await storage.write({ ...active, attemptsLeft: active.attemptsLeft - 1 }); return { status: types_1.AuthorizationHandlerStatus.PENDING }; } await this.sendInvokeResponse(context, { status: 200 }); logger.debug(this.prefix('Code verification successful'), activity); return { status: types_1.AuthorizationHandlerStatus.APPROVED, code: state }; } /** * Sets the authorization context in the turn state. */ setContext(context, data) { return context.turnState.set(this._key, () => data); } /** * Gets the authorization context from the turn state. */ getContext(context) { var _a; const result = context.turnState.get(this._key); return (_a = result === null || result === void 0 ? void 0 : result()) !== null && _a !== void 0 ? _a : { token: undefined }; } /** * Gets the user token client from the turn context. */ async getUserTokenClient(context) { const userTokenClient = context.turnState.get(context.adapter.UserTokenClientKey); if (!userTokenClient) { throw new Error(this.prefix('The \'userTokenClient\' is not available in the adapter. Ensure that the adapter supports user token operations.')); } return userTokenClient; } /** * Sends an InvokeResponse activity if the channel is Microsoft Teams. */ sendInvokeResponse(context, response) { if (context.activity.channelId !== agents_activity_1.Channels.Msteams) { return Promise.resolve(); } return context.sendActivity(agents_activity_1.Activity.fromObject({ type: agents_activity_1.ActivityTypes.InvokeResponse, value: response })); } /** * Prefixes a message with the handler ID. */ prefix(message) { return `[handler:${this.id}] ${message}`; } /** * Loads the OAuth scopes from the environment variables. */ loadScopes(value) { var _a; return (_a = value === null || value === void 0 ? void 0 : value.split(',').reduce((acc, scope) => { const trimmed = scope.trim(); if (trimmed) { acc.push(trimmed); } return acc; }, [])) !== null && _a !== void 0 ? _a : []; } } exports.AzureBotAuthorization = AzureBotAuthorization; //# sourceMappingURL=azureBotAuthorization.js.map