Skip to content

Commit 31c72e9

Browse files
bentsai10bentsaiceciliaavila
authored
feat: Support Sso for SharePoint bot ACEs (#4806)
* initial commit * add export for middleware * update api file * update api file again * update core api * update botbuilder api * address comments * Fix lint issues * Update botbuilder.api.md --------- Co-authored-by: bentsai <[email protected]> Co-authored-by: CeciliaAvila <[email protected]>
1 parent cd05a19 commit 31c72e9

File tree

9 files changed

+230
-1
lines changed

9 files changed

+230
-1
lines changed

libraries/botbuilder-core/etc/botbuilder-core.api.md

+3
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,9 @@ export enum Severity {
570570
Warning = 2
571571
}
572572

573+
// @public (undocumented)
574+
export const sharePointTokenExchange = "cardExtension/token";
575+
573576
// @public
574577
export class ShowTypingMiddleware implements Middleware {
575578
constructor(delay?: number, period?: number);

libraries/botbuilder-core/src/signInConstants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
export const verifyStateOperationName = 'signin/verifyState';
1010
export const tokenExchangeOperationName = 'signin/tokenExchange';
1111
export const tokenResponseEventName = 'tokens/response';
12+
export const sharePointTokenExchange = 'cardExtension/token';

libraries/botbuilder/etc/botbuilder.api.md

+7
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ export class SharePointActivityHandler extends ActivityHandler {
352352
protected onSharePointTaskGetQuickViewAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<QuickViewResponse>;
353353
protected onSharePointTaskHandleActionAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<HandleActionResponse>;
354354
protected onSharePointTaskSetPropertyPaneConfigurationAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<SetPropertyPaneConfigurationResponse>;
355+
protected onSignInInvoke(_context: TurnContext): Promise<void>;
356+
}
357+
358+
// @public
359+
export class SharePointSSOTokenExchangeMiddleware implements Middleware {
360+
constructor(storage: Storage_2, oAuthConnectionName: string);
361+
onTurn(context: TurnContext, _next: () => Promise<void>): Promise<void>;
355362
}
356363

357364
// @public @deprecated (undocumented)

libraries/botbuilder/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ export { Request, Response, WebRequest, WebResponse } from './interfaces';
3030
export { StatusCodeError } from './statusCodeError';
3131
export { StreamingHttpClient, TokenResolver } from './streaming';
3232
export { SharePointActivityHandler } from './sharepoint/sharePointActivityHandler';
33+
export { SharePointSSOTokenExchangeMiddleware } from './sharepoint/sharePointSSOTokenExchangeMiddleware';

libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export class SharePointActivityHandler extends ActivityHandler {
6666
return ActivityHandler.createInvokeResponse(
6767
await this.onSharePointTaskHandleActionAsync(context, context.activity.value as AceRequest),
6868
);
69+
case 'cardExtension/token':
70+
await this.onSignInInvoke(context);
71+
return ActivityHandler.createInvokeResponse();
6972
default:
7073
return super.onInvokeActivity(context);
7174
}
@@ -137,7 +140,7 @@ export class SharePointActivityHandler extends ActivityHandler {
137140
}
138141

139142
/**
140-
* Override this in a derived class to provide logic for setting configuration pane properties.
143+
* Override this in a derived class to provide logic for handling ACE action.
141144
*
142145
* @param _context - A strongly-typed context object for this turn
143146
* @param _aceRequest - The Ace invoke request value payload
@@ -149,4 +152,13 @@ export class SharePointActivityHandler extends ActivityHandler {
149152
): Promise<HandleActionResponse> {
150153
throw new Error('NotImplemented');
151154
}
155+
156+
/**
157+
* Override this method to support channel-specific behavior across multiple channels.
158+
*
159+
* @param _context - A strongly-typed context object for this turn
160+
*/
161+
protected async onSignInInvoke(_context: TurnContext): Promise<void> {
162+
throw new Error('NotImplemented');
163+
}
152164
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import * as z from 'zod';
5+
6+
import {
7+
ActivityTypes,
8+
Channels,
9+
ExtendedUserTokenProvider,
10+
Middleware,
11+
StatusCodes,
12+
Storage,
13+
StoreItem,
14+
AceRequest,
15+
TokenExchangeInvokeResponse,
16+
TokenResponse,
17+
TurnContext,
18+
sharePointTokenExchange,
19+
CloudAdapterBase,
20+
} from 'botbuilder-core';
21+
import { UserTokenClient } from 'botframework-connector';
22+
23+
function getStorageKey(context: TurnContext): string {
24+
const activity = context.activity;
25+
26+
const channelId = activity.channelId;
27+
if (!channelId) {
28+
throw new Error('invalid activity. Missing channelId');
29+
}
30+
31+
const conversationId = activity.conversation?.id;
32+
if (!conversationId) {
33+
throw new Error('invalid activity. Missing conversation.id');
34+
}
35+
36+
const value = activity.value;
37+
if (!value?.id) {
38+
throw new Error('Invalid signin/tokenExchange. Missing activity.value.id.');
39+
}
40+
41+
return `${channelId}/${conversationId}/${value.id}`;
42+
}
43+
44+
async function sendInvokeResponse(context: TurnContext, body: unknown = null, status = StatusCodes.OK): Promise<void> {
45+
await context.sendActivity({
46+
type: ActivityTypes.InvokeResponse,
47+
value: { body, status },
48+
});
49+
}
50+
51+
const ExchangeToken = z.custom<Pick<ExtendedUserTokenProvider, 'exchangeToken'>>(
52+
(val: any) => typeof val.exchangeToken === 'function',
53+
{ message: 'ExtendedUserTokenProvider' },
54+
);
55+
56+
/**
57+
* If the activity name is cardExtension/token, this middleware will attempt to
58+
* exchange the token, and deduplicate the incoming call, ensuring only one
59+
* exchange request is processed.
60+
*
61+
* If a user is signed into multiple devices, the Bot could receive a
62+
* "cardExtension/token" from each device. Each token exchange request for a
63+
* specific user login will have an identical activity.value.id.
64+
*
65+
* Only one of these token exchange requests should be processed by the bot.
66+
* The others return [StatusCodes.PRECONDITION_FAILED](xref:botframework-schema:StatusCodes.PRECONDITION_FAILED).
67+
* For a distributed bot in production, this requires distributed storage
68+
* ensuring only one token exchange is processed. This middleware supports
69+
* CosmosDb storage found in botbuilder-azure, or MemoryStorage for local development.
70+
*/
71+
export class SharePointSSOTokenExchangeMiddleware implements Middleware {
72+
/**
73+
* Initializes a new instance of the SharePointSSOTokenExchangeMiddleware class.
74+
*
75+
* @param storage The [Storage](xref:botbuilder-core.Storage) to use for deduplication
76+
* @param oAuthConnectionName The connection name to use for the single sign on token exchange
77+
*/
78+
constructor(
79+
private readonly storage: Storage,
80+
private readonly oAuthConnectionName: string,
81+
) {
82+
if (!storage) {
83+
throw new TypeError('`storage` parameter is required');
84+
}
85+
86+
if (!oAuthConnectionName) {
87+
throw new TypeError('`oAuthConnectionName` parameter is required');
88+
}
89+
}
90+
91+
/**
92+
* Called each time the bot receives a new request.
93+
*
94+
* @param context Context for current turn of conversation with the user.
95+
* @param _next Function to call to continue execution to the next step in the middleware chain.
96+
*/
97+
async onTurn(context: TurnContext, _next: () => Promise<void>): Promise<void> {
98+
if (context.activity.channelId === Channels.M365 && context.activity.name === sharePointTokenExchange) {
99+
// If the TokenExchange is NOT successful, the response will have already been sent by exchangedToken
100+
if (!(await this.exchangedToken(context))) {
101+
return;
102+
}
103+
104+
// Only one token exchange should proceed from here. Deduplication is performed second because in the case
105+
// of failure due to consent required, every caller needs to receive a response
106+
if (!(await this.deduplicatedTokenExchangeId(context))) {
107+
// If the token is not exchangeable, do not process this activity further.
108+
return;
109+
}
110+
}
111+
112+
return;
113+
}
114+
115+
private async deduplicatedTokenExchangeId(context: TurnContext): Promise<boolean> {
116+
// Create a StoreItem with Etag of the unique 'signin/tokenExchange' request
117+
const storeItem: StoreItem = {
118+
eTag: context.activity.value?.id,
119+
};
120+
121+
try {
122+
// Writing the IStoreItem with ETag of unique id will succeed only once
123+
await this.storage.write({
124+
[getStorageKey(context)]: storeItem,
125+
});
126+
} catch (err) {
127+
const message = err.message?.toLowerCase();
128+
129+
// Do NOT proceed processing this message, some other thread or machine already has processed it.
130+
// Send 200 invoke response.
131+
if (message.includes('etag conflict') || message.includes('precondition is not met')) {
132+
await sendInvokeResponse(context);
133+
return false;
134+
}
135+
136+
throw err;
137+
}
138+
139+
return true;
140+
}
141+
142+
private async exchangedToken(context: TurnContext): Promise<boolean> {
143+
let tokenExchangeResponse: TokenResponse;
144+
const aceRequest: AceRequest = context.activity.value;
145+
146+
try {
147+
const userTokenClient = context.turnState.get<UserTokenClient>(
148+
(context.adapter as CloudAdapterBase).UserTokenClientKey,
149+
);
150+
const exchangeToken = ExchangeToken.safeParse(context.adapter);
151+
152+
if (userTokenClient) {
153+
tokenExchangeResponse = await userTokenClient.exchangeToken(
154+
context.activity.from.id,
155+
this.oAuthConnectionName,
156+
context.activity.channelId,
157+
{ token: aceRequest.data as string },
158+
);
159+
} else if (exchangeToken.success) {
160+
tokenExchangeResponse = await exchangeToken.data.exchangeToken(
161+
context,
162+
this.oAuthConnectionName,
163+
context.activity.from.id,
164+
{ token: aceRequest.data as string },
165+
);
166+
} else {
167+
new Error('Token Exchange is not supported by the current adapter.');
168+
}
169+
} catch (_err) {
170+
// Ignore Exceptions
171+
// If token exchange failed for any reason, tokenExchangeResponse above stays null,
172+
// and hence we send back a failure invoke response to the caller.
173+
}
174+
175+
if (!tokenExchangeResponse?.token) {
176+
// The token could not be exchanged (which could be due to a consent requirement)
177+
// Notify the sender that PreconditionFailed so they can respond accordingly.
178+
179+
const invokeResponse: TokenExchangeInvokeResponse = {
180+
id: 'FAKE ID',
181+
connectionName: this.oAuthConnectionName,
182+
failureDetail: 'The bot is unable to exchange token. Proceed with regular login.',
183+
};
184+
185+
await sendInvokeResponse(context, invokeResponse, StatusCodes.PRECONDITION_FAILED);
186+
187+
return false;
188+
}
189+
190+
return true;
191+
}
192+
}

libraries/botframework-schema/etc/botframework-schema.api.md

+4
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,8 @@ export enum Channels {
613613
// (undocumented)
614614
Line = "line",
615615
// (undocumented)
616+
M365 = "m365extensions",
617+
// (undocumented)
616618
Msteams = "msteams",
617619
// (undocumented)
618620
Omni = "omnichannel",
@@ -2899,6 +2901,8 @@ export interface QuickViewResponse {
28992901
data: QuickViewData;
29002902
externalLink: ExternalLinkActionParameters;
29012903
focusParameters: FocusParameters;
2904+
postSsoViewId?: string;
2905+
requiresSso?: boolean;
29022906
template: AdaptiveCard;
29032907
title: string;
29042908
viewId: string;

libraries/botframework-schema/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2295,6 +2295,7 @@ export enum Channels {
22952295
*/
22962296
Kik = 'kik',
22972297
Line = 'line',
2298+
M365 = 'm365extensions',
22982299
Msteams = 'msteams',
22992300
Omni = 'omnichannel',
23002301
Outlook = 'outlook',

libraries/botframework-schema/src/sharepoint/quickViewResponse.ts

+8
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,12 @@ export interface QuickViewResponse {
3434
* An optional focus element to set focus when the view is rendered for accessibility purposes.
3535
*/
3636
focusParameters: FocusParameters;
37+
/**
38+
* Value indicating whether the client should trigger a single sign on flow
39+
*/
40+
requiresSso?: boolean;
41+
/**
42+
* Value representing the view id of the view to load once SSO is complete
43+
*/
44+
postSsoViewId?: string;
3745
}

0 commit comments

Comments
 (0)