From c3d7d57c46f04ab6efb5209ec9a1db1f595d7b64 Mon Sep 17 00:00:00 2001 From: bentsai Date: Mon, 11 Nov 2024 11:30:36 -0800 Subject: [PATCH 1/9] initial commit --- .../botbuilder-core/src/signInConstants.ts | 1 + .../sharepoint/sharePointActivityHandler.ts | 16 +- .../sharePointSSOTokenExchangeMiddleware.ts | 190 ++++++++++++++++++ libraries/botframework-schema/src/index.ts | 1 + .../src/sharepoint/quickViewResponse.ts | 8 + 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts diff --git a/libraries/botbuilder-core/src/signInConstants.ts b/libraries/botbuilder-core/src/signInConstants.ts index 52bdade84e..1aa8551f32 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/src/sharepoint/sharePointActivityHandler.ts b/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts index 0a017f68b7..7d146cd457 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,15 @@ 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..753c0ef4fc --- /dev/null +++ b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts @@ -0,0 +1,190 @@ +// 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/src/index.ts b/libraries/botframework-schema/src/index.ts index 9198b6e170..5826557a1f 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; } From 821b587a002ca18859d6e50188b0bb6dd1b3ce58 Mon Sep 17 00:00:00 2001 From: bentsai Date: Mon, 11 Nov 2024 11:43:43 -0800 Subject: [PATCH 2/9] add export for middleware --- libraries/botbuilder/src/index.ts | 1 + 1 file changed, 1 insertion(+) 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'; From a54b36968c5491b35d154e54f29c550b03c6b961 Mon Sep 17 00:00:00 2001 From: bentsai Date: Tue, 3 Dec 2024 07:27:09 -0800 Subject: [PATCH 3/9] update api file --- libraries/botframework-schema/etc/botframework-schema.api.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libraries/botframework-schema/etc/botframework-schema.api.md b/libraries/botframework-schema/etc/botframework-schema.api.md index efb0d7dca9..2b22641144 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; From fe97d73a015a25196708666faf05437616692b7d Mon Sep 17 00:00:00 2001 From: bentsai Date: Fri, 6 Dec 2024 11:12:50 -0800 Subject: [PATCH 4/9] update api file again --- libraries/botframework-schema/etc/botframework-schema.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botframework-schema/etc/botframework-schema.api.md b/libraries/botframework-schema/etc/botframework-schema.api.md index 2b22641144..f4e015eee5 100644 --- a/libraries/botframework-schema/etc/botframework-schema.api.md +++ b/libraries/botframework-schema/etc/botframework-schema.api.md @@ -613,7 +613,7 @@ export enum Channels { // (undocumented) Line = "line", // (undocumented) - M365 = 'm365extensions', + M365 = "m365extensions", // (undocumented) Msteams = "msteams", // (undocumented) From 6d08f93feb91412507effd2c7d24f28d173e5dfe Mon Sep 17 00:00:00 2001 From: bentsai Date: Fri, 6 Dec 2024 12:35:23 -0800 Subject: [PATCH 5/9] update core api --- libraries/botbuilder-core/etc/botbuilder-core.api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/botbuilder-core/etc/botbuilder-core.api.md b/libraries/botbuilder-core/etc/botbuilder-core.api.md index a551446d4b..8c6d6f748c 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); From 5e8192ca77bf8abaed146a3f6234138dd05e17ed Mon Sep 17 00:00:00 2001 From: bentsai Date: Fri, 6 Dec 2024 12:35:49 -0800 Subject: [PATCH 6/9] update botbuilder api --- libraries/botbuilder/etc/botbuilder.api.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libraries/botbuilder/etc/botbuilder.api.md b/libraries/botbuilder/etc/botbuilder.api.md index b2c5138a13..130cbe59e3 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) From 87fc178f51013eb06ad12312c395c3fb4b947c46 Mon Sep 17 00:00:00 2001 From: bentsai Date: Thu, 12 Dec 2024 10:40:30 -0800 Subject: [PATCH 7/9] address comments --- libraries/botbuilder-core/src/signInConstants.ts | 2 +- .../src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/src/signInConstants.ts b/libraries/botbuilder-core/src/signInConstants.ts index 1aa8551f32..a90df0571b 100644 --- a/libraries/botbuilder-core/src/signInConstants.ts +++ b/libraries/botbuilder-core/src/signInConstants.ts @@ -9,4 +9,4 @@ export const verifyStateOperationName = 'signin/verifyState'; export const tokenExchangeOperationName = 'signin/tokenExchange'; export const tokenResponseEventName = 'tokens/response'; -export const sharePointTokenExchange = "cardExtension/token"; +export const sharePointTokenExchange = 'cardExtension/token'; diff --git a/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts index 753c0ef4fc..69fa3fc397 100644 --- a/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts +++ b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts @@ -68,7 +68,6 @@ const ExchangeToken = z.custom> * 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. From 2182d60f6e7036d0b4138d432dd01a4a366aaba3 Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Mon, 13 Jan 2025 16:29:38 -0300 Subject: [PATCH 8/9] Fix lint issues --- .../sharepoint/sharePointActivityHandler.ts | 4 +--- .../sharePointSSOTokenExchangeMiddleware.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts b/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts index aa4a6f87c7..2647cc6aa8 100644 --- a/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts +++ b/libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts @@ -158,9 +158,7 @@ export class SharePointActivityHandler extends ActivityHandler { * * @param _context - A strongly-typed context object for this turn */ - protected async onSignInInvoke( - _context: TurnContext - ): Promise { + 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 index 69fa3fc397..01e32e647e 100644 --- a/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts +++ b/libraries/botbuilder/src/sharepoint/sharePointSSOTokenExchangeMiddleware.ts @@ -50,7 +50,7 @@ async function sendInvokeResponse(context: TurnContext, body: unknown = null, st const ExchangeToken = z.custom>( (val: any) => typeof val.exchangeToken === 'function', - { message: 'ExtendedUserTokenProvider' } + { message: 'ExtendedUserTokenProvider' }, ); /** @@ -75,7 +75,10 @@ export class SharePointSSOTokenExchangeMiddleware implements Middleware { * @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) { + constructor( + private readonly storage: Storage, + private readonly oAuthConnectionName: string, + ) { if (!storage) { throw new TypeError('`storage` parameter is required'); } @@ -89,9 +92,9 @@ export class SharePointSSOTokenExchangeMiddleware implements Middleware { * 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. + * @param _next Function to call to continue execution to the next step in the middleware chain. */ - async onTurn(context: TurnContext, next: () => Promise): Promise { + 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))) { @@ -142,7 +145,7 @@ export class SharePointSSOTokenExchangeMiddleware implements Middleware { try { const userTokenClient = context.turnState.get( - (context.adapter as CloudAdapterBase).UserTokenClientKey + (context.adapter as CloudAdapterBase).UserTokenClientKey, ); const exchangeToken = ExchangeToken.safeParse(context.adapter); @@ -151,14 +154,14 @@ export class SharePointSSOTokenExchangeMiddleware implements Middleware { context.activity.from.id, this.oAuthConnectionName, context.activity.channelId, - { token: aceRequest.data as string } + { 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 } + { token: aceRequest.data as string }, ); } else { new Error('Token Exchange is not supported by the current adapter.'); @@ -174,7 +177,7 @@ export class SharePointSSOTokenExchangeMiddleware implements Middleware { // Notify the sender that PreconditionFailed so they can respond accordingly. const invokeResponse: TokenExchangeInvokeResponse = { - id: "FAKE ID", + id: 'FAKE ID', connectionName: this.oAuthConnectionName, failureDetail: 'The bot is unable to exchange token. Proceed with regular login.', }; From b37c78ac5b86dbc194f1f2953cbb5c8a6957240a Mon Sep 17 00:00:00 2001 From: CeciliaAvila Date: Mon, 13 Jan 2025 17:08:03 -0300 Subject: [PATCH 9/9] Update botbuilder.api.md --- libraries/botbuilder/etc/botbuilder.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder/etc/botbuilder.api.md b/libraries/botbuilder/etc/botbuilder.api.md index 130cbe59e3..9896b7fc8b 100644 --- a/libraries/botbuilder/etc/botbuilder.api.md +++ b/libraries/botbuilder/etc/botbuilder.api.md @@ -358,7 +358,7 @@ export class SharePointActivityHandler extends ActivityHandler { // @public export class SharePointSSOTokenExchangeMiddleware implements Middleware { constructor(storage: Storage_2, oAuthConnectionName: string); - onTurn(context: TurnContext, next: () => Promise): Promise; + onTurn(context: TurnContext, _next: () => Promise): Promise; } // @public @deprecated (undocumented)