UNPKG

@microsoft/agents-hosting

Version:

Microsoft 365 Agents SDK for JavaScript

316 lines 16.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OAuthFlow = void 0; // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. const logger_1 = require("@microsoft/agents-activity/logger"); const agents_activity_1 = require("@microsoft/agents-activity"); const __1 = require("../"); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const logger = (0, logger_1.debug)('agents:oauth-flow'); /** * Manages the OAuth flow */ class OAuthFlow { /** * Creates a new instance of OAuthFlow. * @param storage The storage provider for persisting flow state. * @param absOauthConnectionName The absolute OAuth connection name. * @param tokenClient Optional user token client. If not provided, will be initialized automatically. * @param cardTitle Optional title for the OAuth card. Defaults to 'Sign in'. * @param cardText Optional text for the OAuth card. Defaults to 'login'. */ constructor(storage, absOauthConnectionName, tokenClient, cardTitle, cardText) { this.storage = storage; /** * The ID of the token exchange request, used to deduplicate requests. */ this.tokenExchangeId = null; /** * In-memory cache for tokens with expiration. */ this.tokenCache = new Map(); /** * The title of the OAuth card. */ this.cardTitle = 'Sign in'; /** * The text of the OAuth card. */ this.cardText = 'login'; this.state = { flowStarted: undefined, flowExpires: undefined, absOauthConnectionName }; this.absOauthConnectionName = absOauthConnectionName; this.userTokenClient = tokenClient; this.cardTitle = cardTitle !== null && cardTitle !== void 0 ? cardTitle : this.cardTitle; this.cardText = cardText !== null && cardText !== void 0 ? cardText : this.cardText; } /** * Retrieves the user token from the user token service with in-memory caching for 10 minutes. * @param context The turn context containing the activity information. * @returns A promise that resolves to the user token response. * @throws Will throw an error if the channelId or from properties are not set in the activity. */ async getUserToken(context) { const activity = context.activity; if (!activity.channelId || !activity.from || !activity.from.id) { throw new Error('UserTokenService requires channelId and from to be set'); } const cacheKey = this.getCacheKey(context); const cachedEntry = this.tokenCache.get(cacheKey); if (cachedEntry && Date.now() < cachedEntry.expiresAt) { logger.info(`Returning cached token for user with cache key: ${cacheKey}`); return cachedEntry.token; } logger.info('Get token from user token service'); await this.refreshToken(context); const tokenResponse = await this.userTokenClient.getUserToken(this.absOauthConnectionName, activity.channelId, activity.from.id); // Cache the token if it's valid (has a token value) if (tokenResponse && tokenResponse.token) { const cacheExpiry = Date.now() + (10 * 60 * 1000); // 10 minutes from now this.tokenCache.set(cacheKey, { token: tokenResponse, expiresAt: cacheExpiry }); logger.info('Token cached for 10 minutes'); } return tokenResponse; } /** * Begins the OAuth flow. * @param context The turn context. * @returns A promise that resolves to the user token if available, or undefined if OAuth flow needs to be started. */ async beginFlow(context) { var _a; this.state = await this.getFlowState(context); if (this.absOauthConnectionName === '') { throw new Error('connectionName is not set'); } logger.info('Starting OAuth flow for connectionName:', this.absOauthConnectionName); await this.refreshToken(context); const act = context.activity; // Check cache first before starting OAuth flow if (act.channelId && act.from && act.from.id) { const cacheKey = this.getCacheKey(context); const cachedEntry = this.tokenCache.get(cacheKey); if (cachedEntry && Date.now() < cachedEntry.expiresAt) { logger.info(`Returning cached token for user in beginFlow with cache key: ${cacheKey}`); return cachedEntry.token; } } const output = await this.userTokenClient.getTokenOrSignInResource((_a = act.from) === null || _a === void 0 ? void 0 : _a.id, this.absOauthConnectionName, act.channelId, act.getConversationReference(), act.relatesTo, undefined); if (output && output.tokenResponse) { // Cache the token if it's valid if (act.channelId && act.from && act.from.id) { const cacheKey = this.getCacheKey(context); const cacheExpiry = Date.now() + (10 * 60 * 1000); // 10 minutes from now this.tokenCache.set(cacheKey, { token: output.tokenResponse, expiresAt: cacheExpiry }); logger.info('Token cached for 10 minutes in beginFlow'); this.state = { flowStarted: false, flowExpires: 0, absOauthConnectionName: this.absOauthConnectionName }; } logger.info('Token retrieved successfully'); return output.tokenResponse; } const oCard = __1.CardFactory.oauthCard(this.absOauthConnectionName, this.cardTitle, this.cardText, output.signInResource); await context.sendActivity(__1.MessageFactory.attachment(oCard)); this.state = { flowStarted: true, flowExpires: Date.now() + 60 * 5 * 1000, absOauthConnectionName: this.absOauthConnectionName }; await this.storage.write({ [this.getFlowStateKey(context)]: this.state }); logger.info('OAuth card sent, flow started'); return undefined; } /** * Continues the OAuth flow. * @param context The turn context. * @returns A promise that resolves to the user token response. */ async continueFlow(context) { var _a, _b, _c, _d, _e, _f, _g, _h; this.state = await this.getFlowState(context); await this.refreshToken(context); if (((_a = this.state) === null || _a === void 0 ? void 0 : _a.flowExpires) !== 0 && Date.now() > ((_b = this.state) === null || _b === void 0 ? void 0 : _b.flowExpires)) { logger.warn('Flow expired'); await context.sendActivity(__1.MessageFactory.text('Sign-in session expired. Please try again.')); this.state.flowStarted = false; return { token: undefined }; } const contFlowActivity = context.activity; if (contFlowActivity.type === agents_activity_1.ActivityTypes.Message) { const magicCode = contFlowActivity.text; if (magicCode.match(/^\d{6}$/)) { const result = await ((_c = this.userTokenClient) === null || _c === void 0 ? void 0 : _c.getUserToken(this.absOauthConnectionName, contFlowActivity.channelId, (_d = contFlowActivity.from) === null || _d === void 0 ? void 0 : _d.id, magicCode)); if (result && result.token) { // Cache the token if it's valid if (contFlowActivity.channelId && contFlowActivity.from && contFlowActivity.from.id) { const cacheKey = this.getCacheKey(context); const cacheExpiry = Date.now() + (10 * 60 * 1000); // 10 minutes from now this.tokenCache.set(cacheKey, { token: result, expiresAt: cacheExpiry }); logger.info('Token cached for 10 minutes in continueFlow (magic code)'); } await this.storage.delete([this.getFlowStateKey(context)]); logger.info('Token retrieved successfully'); return result; } else { // await context.sendActivity(MessageFactory.text('Invalid code. Please try again.')) logger.warn('Invalid magic code provided'); this.state = { flowStarted: true, flowExpires: Date.now() + 30000, absOauthConnectionName: this.absOauthConnectionName }; await this.storage.write({ [this.getFlowStateKey(context)]: this.state }); return { token: undefined }; } } else { logger.warn('Invalid magic code format'); await context.sendActivity(__1.MessageFactory.text('Invalid code format. Please enter a 6-digit code.')); return { token: undefined }; } } if (contFlowActivity.type === agents_activity_1.ActivityTypes.Invoke && contFlowActivity.name === 'signin/verifyState') { logger.info('Continuing OAuth flow with verifyState'); const tokenVerifyState = contFlowActivity.value; const magicCode = tokenVerifyState.state; const result = await ((_e = this.userTokenClient) === null || _e === void 0 ? void 0 : _e.getUserToken(this.absOauthConnectionName, contFlowActivity.channelId, (_f = contFlowActivity.from) === null || _f === void 0 ? void 0 : _f.id, magicCode)); // Cache the token if it's valid if (result && result.token && contFlowActivity.channelId && contFlowActivity.from && contFlowActivity.from.id) { const cacheKey = this.getCacheKey(context); const cacheExpiry = Date.now() + (10 * 60 * 1000); // 10 minutes from now this.tokenCache.set(cacheKey, { token: result, expiresAt: cacheExpiry }); logger.info('Token cached for 10 minutes in continueFlow (verifyState)'); } return result; } if (contFlowActivity.type === agents_activity_1.ActivityTypes.Invoke && contFlowActivity.name === 'signin/tokenExchange') { logger.info('Continuing OAuth flow with tokenExchange'); const tokenExchangeRequest = contFlowActivity.value; if (this.tokenExchangeId === tokenExchangeRequest.id) { // dedupe logger.debug('Token exchange request already processed, skipping'); return { token: undefined }; } this.tokenExchangeId = tokenExchangeRequest.id; const userTokenResp = await ((_g = this.userTokenClient) === null || _g === void 0 ? void 0 : _g.exchangeTokenAsync((_h = contFlowActivity.from) === null || _h === void 0 ? void 0 : _h.id, this.absOauthConnectionName, contFlowActivity.channelId, tokenExchangeRequest)); if (userTokenResp && userTokenResp.token) { // Cache the token if it's valid if (contFlowActivity.channelId && contFlowActivity.from && contFlowActivity.from.id) { const cacheKey = this.getCacheKey(context); const cacheExpiry = Date.now() + (10 * 60 * 1000); // 10 minutes from now this.tokenCache.set(cacheKey, { token: userTokenResp, expiresAt: cacheExpiry }); logger.info('Token cached for 10 minutes in continueFlow (tokenExchange)'); } logger.info('Token exchanged'); this.state.flowStarted = false; await this.storage.write({ [this.getFlowStateKey(context)]: this.state }); return userTokenResp; } else { logger.warn('Token exchange failed'); this.state.flowStarted = true; return { token: undefined }; } } return { token: undefined }; } /** * Signs the user out. * @param context The turn context. * @returns A promise that resolves when the sign-out operation is complete. */ async signOut(context) { var _a, _b; await this.refreshToken(context); // Clear cached token for this user const activity = context.activity; if (activity.channelId && activity.from && activity.from.id) { const cacheKey = this.getCacheKey(context); this.tokenCache.delete(cacheKey); logger.info('Cached token cleared for user'); } await ((_a = this.userTokenClient) === null || _a === void 0 ? void 0 : _a.signOut((_b = context.activity.from) === null || _b === void 0 ? void 0 : _b.id, this.absOauthConnectionName, context.activity.channelId)); this.state = { flowStarted: false, flowExpires: 0, absOauthConnectionName: this.absOauthConnectionName }; await this.storage.delete([this.getFlowStateKey(context)]); logger.info('User signed out successfully from connection:', this.absOauthConnectionName); } /** * Gets the user state for the OAuth flow. * @param context The turn context. * @returns A promise that resolves to the flow state. */ async getFlowState(context) { const key = this.getFlowStateKey(context); const data = await this.storage.read([key]); const flowState = data[key]; // ?? { flowStarted: false, flowExpires: 0 } return flowState; } /** * Sets the flow state for the OAuth flow. * @param context The turn context. * @param flowState The flow state to set. * @returns A promise that resolves when the flow state is set. */ async setFlowState(context, flowState) { const key = this.getFlowStateKey(context); await this.storage.write({ [key]: flowState }); this.state = flowState; logger.debug(`Flow state set: ${JSON.stringify(flowState)}`); } /** * Initializes the user token client if not already initialized. * @param context The turn context used to get authentication credentials. */ async refreshToken(context) { const cachedToken = this.tokenCache.get('__access_token__'); if (!cachedToken || Date.now() > cachedToken.expiresAt) { const accessToken = await context.adapter.authProvider.getAccessToken(context.adapter.authConfig, 'https://api.botframework.com'); const decodedToken = jsonwebtoken_1.default.decode(accessToken); this.tokenCache.set('__access_token__', { token: { token: accessToken }, expiresAt: (decodedToken === null || decodedToken === void 0 ? void 0 : decodedToken.exp) ? decodedToken.exp * 1000 - 1000 : Date.now() + 10 * 60 * 1000 }); this.userTokenClient.updateAuthToken(accessToken); } } /** * Generates a cache key for storing user tokens. * @param context The turn context containing activity information. * @returns The cache key string in format: channelId_userId_connectionName. * @throws Will throw an error if required activity properties are missing. */ getCacheKey(context) { const activity = context.activity; if (!activity.channelId || !activity.from || !activity.from.id) { throw new Error('ChannelId and from.id must be set in the activity for cache key generation'); } return `${activity.channelId}_${activity.from.id}_${this.absOauthConnectionName}`; } /** * Generates a storage key for persisting OAuth flow state. * @param context The turn context containing activity information. * @returns The storage key string in format: oauth/channelId/conversationId/userId/flowState. * @throws Will throw an error if required activity properties are missing. */ getFlowStateKey(context) { var _a, _b; const channelId = context.activity.channelId; const conversationId = (_a = context.activity.conversation) === null || _a === void 0 ? void 0 : _a.id; const userId = (_b = context.activity.from) === null || _b === void 0 ? void 0 : _b.id; if (!channelId || !conversationId || !userId) { throw new Error('ChannelId, conversationId, and userId must be set in the activity'); } return `oauth/${channelId}/${userId}/${this.absOauthConnectionName}/flowState`; } } exports.OAuthFlow = OAuthFlow; //# sourceMappingURL=oAuthFlow.js.map