diff --git a/libraries/botbuilder-core/etc/botbuilder-core.api.md b/libraries/botbuilder-core/etc/botbuilder-core.api.md index 425beadb03..cde4b222af 100644 --- a/libraries/botbuilder-core/etc/botbuilder-core.api.md +++ b/libraries/botbuilder-core/etc/botbuilder-core.api.md @@ -570,6 +570,9 @@ export enum Severity { Warning = 2 } +// @public (undocumented) +export const sharePointTokenExchange = "cardExtension/token"; + // @public export class ShowTypingMiddleware implements Middleware { constructor(delay?: number, period?: number); diff --git a/libraries/botbuilder-core/src/signInConstants.ts b/libraries/botbuilder-core/src/signInConstants.ts index 52bdade84e..a90df0571b 100644 --- a/libraries/botbuilder-core/src/signInConstants.ts +++ b/libraries/botbuilder-core/src/signInConstants.ts @@ -9,3 +9,4 @@ export const verifyStateOperationName = 'signin/verifyState'; export const tokenExchangeOperationName = 'signin/tokenExchange'; export const tokenResponseEventName = 'tokens/response'; +export const sharePointTokenExchange = 'cardExtension/token'; diff --git a/libraries/botbuilder/etc/botbuilder.api.md b/libraries/botbuilder/etc/botbuilder.api.md index b2c5138a13..9896b7fc8b 100644 --- a/libraries/botbuilder/etc/botbuilder.api.md +++ b/libraries/botbuilder/etc/botbuilder.api.md @@ -352,6 +352,13 @@ export class SharePointActivityHandler extends ActivityHandler { protected onSharePointTaskGetQuickViewAsync(_context: TurnContext, _aceRequest: AceRequest): Promise; protected onSharePointTaskHandleActionAsync(_context: TurnContext, _aceRequest: AceRequest): Promise; protected onSharePointTaskSetPropertyPaneConfigurationAsync(_context: TurnContext, _aceRequest: AceRequest): Promise; + protected onSignInInvoke(_context: TurnContext): Promise; +} + +// @public +export class SharePointSSOTokenExchangeMiddleware implements Middleware { + constructor(storage: Storage_2, oAuthConnectionName: string); + onTurn(context: TurnContext, _next: () => Promise): Promise; } // @public @deprecated (undocumented) diff --git a/libraries/botbuilder/src/index.ts b/libraries/botbuilder/src/index.ts index 072b3edc80..c90620dc93 100644 --- a/libraries/botbuilder/src/index.ts +++ b/libraries/botbuilder/src/index.ts @@ -30,3 +30,4 @@ export { Request, Response, WebRequest, WebResponse } from './interfaces'; export { StatusCodeError } from './statusCodeError'; export { StreamingHttpClient, TokenResolver } from './streaming'; export { SharePointActivityHandler } from './sharepoint/sharePointActivityHandler'; +export { SharePointSSOTokenExchangeMiddleware } from './sharepoint/sharePointSSOTokenExchangeMiddleware'; diff --git a/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts b/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts index 821251e3f8..2647cc6aa8 100644 --- a/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts +++ b/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts @@ -66,6 +66,9 @@ export class SharePointActivityHandler extends ActivityHandler { return ActivityHandler.createInvokeResponse( await this.onSharePointTaskHandleActionAsync(context, context.activity.value as AceRequest), ); + case 'cardExtension/token': + await this.onSignInInvoke(context); + return ActivityHandler.createInvokeResponse(); default: return super.onInvokeActivity(context); } @@ -137,7 +140,7 @@ export class SharePointActivityHandler extends ActivityHandler { } /** - * Override this in a derived class to provide logic for setting configuration pane properties. + * Override this in a derived class to provide logic for handling ACE action. * * @param _context - A strongly-typed context object for this turn * @param _aceRequest - The Ace invoke request value payload @@ -149,4 +152,13 @@ export class SharePointActivityHandler extends ActivityHandler { ): Promise { throw new Error('NotImplemented'); } + + /** + * Override this method to support channel-specific behavior across multiple channels. + * + * @param _context - A strongly-typed context object for this turn + */ + protected async onSignInInvoke(_context: TurnContext): Promise { + throw new Error('NotImplemented'); + } } diff --git a/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts new file mode 100644 index 0000000000..01e32e647e --- /dev/null +++ b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as z from 'zod'; + +import { + ActivityTypes, + Channels, + ExtendedUserTokenProvider, + Middleware, + StatusCodes, + Storage, + StoreItem, + AceRequest, + TokenExchangeInvokeResponse, + TokenResponse, + TurnContext, + sharePointTokenExchange, + CloudAdapterBase, +} from 'botbuilder-core'; +import { UserTokenClient } from 'botframework-connector'; + +function getStorageKey(context: TurnContext): string { + const activity = context.activity; + + const channelId = activity.channelId; + if (!channelId) { + throw new Error('invalid activity. Missing channelId'); + } + + const conversationId = activity.conversation?.id; + if (!conversationId) { + throw new Error('invalid activity. Missing conversation.id'); + } + + const value = activity.value; + if (!value?.id) { + throw new Error('Invalid signin/tokenExchange. Missing activity.value.id.'); + } + + return `${channelId}/${conversationId}/${value.id}`; +} + +async function sendInvokeResponse(context: TurnContext, body: unknown = null, status = StatusCodes.OK): Promise { + await context.sendActivity({ + type: ActivityTypes.InvokeResponse, + value: { body, status }, + }); +} + +const ExchangeToken = z.custom>( + (val: any) => typeof val.exchangeToken === 'function', + { message: 'ExtendedUserTokenProvider' }, +); + +/** + * If the activity name is cardExtension/token, this middleware will attempt to + * exchange the token, and deduplicate the incoming call, ensuring only one + * exchange request is processed. + * + * If a user is signed into multiple devices, the Bot could receive a + * "cardExtension/token" from each device. Each token exchange request for a + * specific user login will have an identical activity.value.id. + * + * Only one of these token exchange requests should be processed by the bot. + * The others return [StatusCodes.PRECONDITION_FAILED](xref:botframework-schema:StatusCodes.PRECONDITION_FAILED). + * For a distributed bot in production, this requires distributed storage + * ensuring only one token exchange is processed. This middleware supports + * CosmosDb storage found in botbuilder-azure, or MemoryStorage for local development. + */ +export class SharePointSSOTokenExchangeMiddleware implements Middleware { + /** + * Initializes a new instance of the SharePointSSOTokenExchangeMiddleware class. + * + * @param storage The [Storage](xref:botbuilder-core.Storage) to use for deduplication + * @param oAuthConnectionName The connection name to use for the single sign on token exchange + */ + constructor( + private readonly storage: Storage, + private readonly oAuthConnectionName: string, + ) { + if (!storage) { + throw new TypeError('`storage` parameter is required'); + } + + if (!oAuthConnectionName) { + throw new TypeError('`oAuthConnectionName` parameter is required'); + } + } + + /** + * Called each time the bot receives a new request. + * + * @param context Context for current turn of conversation with the user. + * @param _next Function to call to continue execution to the next step in the middleware chain. + */ + async onTurn(context: TurnContext, _next: () => Promise): Promise { + if (context.activity.channelId === Channels.M365 && context.activity.name === sharePointTokenExchange) { + // If the TokenExchange is NOT successful, the response will have already been sent by exchangedToken + if (!(await this.exchangedToken(context))) { + return; + } + + // Only one token exchange should proceed from here. Deduplication is performed second because in the case + // of failure due to consent required, every caller needs to receive a response + if (!(await this.deduplicatedTokenExchangeId(context))) { + // If the token is not exchangeable, do not process this activity further. + return; + } + } + + return; + } + + private async deduplicatedTokenExchangeId(context: TurnContext): Promise { + // Create a StoreItem with Etag of the unique 'signin/tokenExchange' request + const storeItem: StoreItem = { + eTag: context.activity.value?.id, + }; + + try { + // Writing the IStoreItem with ETag of unique id will succeed only once + await this.storage.write({ + [getStorageKey(context)]: storeItem, + }); + } catch (err) { + const message = err.message?.toLowerCase(); + + // Do NOT proceed processing this message, some other thread or machine already has processed it. + // Send 200 invoke response. + if (message.includes('etag conflict') || message.includes('precondition is not met')) { + await sendInvokeResponse(context); + return false; + } + + throw err; + } + + return true; + } + + private async exchangedToken(context: TurnContext): Promise { + let tokenExchangeResponse: TokenResponse; + const aceRequest: AceRequest = context.activity.value; + + try { + const userTokenClient = context.turnState.get( + (context.adapter as CloudAdapterBase).UserTokenClientKey, + ); + const exchangeToken = ExchangeToken.safeParse(context.adapter); + + if (userTokenClient) { + tokenExchangeResponse = await userTokenClient.exchangeToken( + context.activity.from.id, + this.oAuthConnectionName, + context.activity.channelId, + { token: aceRequest.data as string }, + ); + } else if (exchangeToken.success) { + tokenExchangeResponse = await exchangeToken.data.exchangeToken( + context, + this.oAuthConnectionName, + context.activity.from.id, + { token: aceRequest.data as string }, + ); + } else { + new Error('Token Exchange is not supported by the current adapter.'); + } + } catch (_err) { + // Ignore Exceptions + // If token exchange failed for any reason, tokenExchangeResponse above stays null, + // and hence we send back a failure invoke response to the caller. + } + + if (!tokenExchangeResponse?.token) { + // The token could not be exchanged (which could be due to a consent requirement) + // Notify the sender that PreconditionFailed so they can respond accordingly. + + const invokeResponse: TokenExchangeInvokeResponse = { + id: 'FAKE ID', + connectionName: this.oAuthConnectionName, + failureDetail: 'The bot is unable to exchange token. Proceed with regular login.', + }; + + await sendInvokeResponse(context, invokeResponse, StatusCodes.PRECONDITION_FAILED); + + return false; + } + + return true; + } +} diff --git a/libraries/botframework-schema/etc/botframework-schema.api.md b/libraries/botframework-schema/etc/botframework-schema.api.md index efb0d7dca9..f4e015eee5 100644 --- a/libraries/botframework-schema/etc/botframework-schema.api.md +++ b/libraries/botframework-schema/etc/botframework-schema.api.md @@ -613,6 +613,8 @@ export enum Channels { // (undocumented) Line = "line", // (undocumented) + M365 = "m365extensions", + // (undocumented) Msteams = "msteams", // (undocumented) Omni = "omnichannel", @@ -2899,6 +2901,8 @@ export interface QuickViewResponse { data: QuickViewData; externalLink: ExternalLinkActionParameters; focusParameters: FocusParameters; + postSsoViewId?: string; + requiresSso?: boolean; template: AdaptiveCard; title: string; viewId: string; diff --git a/libraries/botframework-schema/src/index.ts b/libraries/botframework-schema/src/index.ts index e7b0486810..6f330f47db 100644 --- a/libraries/botframework-schema/src/index.ts +++ b/libraries/botframework-schema/src/index.ts @@ -2295,6 +2295,7 @@ export enum Channels { */ Kik = 'kik', Line = 'line', + M365 = 'm365extensions', Msteams = 'msteams', Omni = 'omnichannel', Outlook = 'outlook', diff --git a/libraries/botframework-schema/src/sharepoint/quickViewResponse.ts b/libraries/botframework-schema/src/sharepoint/quickViewResponse.ts index 933209604b..1cb2073d24 100644 --- a/libraries/botframework-schema/src/sharepoint/quickViewResponse.ts +++ b/libraries/botframework-schema/src/sharepoint/quickViewResponse.ts @@ -34,4 +34,12 @@ export interface QuickViewResponse { * An optional focus element to set focus when the view is rendered for accessibility purposes. */ focusParameters: FocusParameters; + /** + * Value indicating whether the client should trigger a single sign on flow + */ + requiresSso?: boolean; + /** + * Value representing the view id of the view to load once SSO is complete + */ + postSsoViewId?: string; }