Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support Sso for SharePoint bot ACEs #4806

Merged
merged 11 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libraries/botbuilder-core/etc/botbuilder-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions libraries/botbuilder-core/src/signInConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
export const verifyStateOperationName = 'signin/verifyState';
export const tokenExchangeOperationName = 'signin/tokenExchange';
export const tokenResponseEventName = 'tokens/response';
export const sharePointTokenExchange = 'cardExtension/token';
7 changes: 7 additions & 0 deletions libraries/botbuilder/etc/botbuilder.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,13 @@ export class SharePointActivityHandler extends ActivityHandler {
protected onSharePointTaskGetQuickViewAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<QuickViewResponse>;
protected onSharePointTaskHandleActionAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<HandleActionResponse>;
protected onSharePointTaskSetPropertyPaneConfigurationAsync(_context: TurnContext, _aceRequest: AceRequest): Promise<SetPropertyPaneConfigurationResponse>;
protected onSignInInvoke(_context: TurnContext): Promise<void>;
}

// @public
export class SharePointSSOTokenExchangeMiddleware implements Middleware {
constructor(storage: Storage_2, oAuthConnectionName: string);
onTurn(context: TurnContext, _next: () => Promise<void>): Promise<void>;
}

// @public @deprecated (undocumented)
Expand Down
1 change: 1 addition & 0 deletions libraries/botbuilder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -149,4 +152,13 @@ export class SharePointActivityHandler extends ActivityHandler {
): Promise<HandleActionResponse> {
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<void> {
throw new Error('NotImplemented');
}
}
Original file line number Diff line number Diff line change
@@ -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<void> {
await context.sendActivity({
type: ActivityTypes.InvokeResponse,
value: { body, status },
});
}

const ExchangeToken = z.custom<Pick<ExtendedUserTokenProvider, 'exchangeToken'>>(
(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<void>): Promise<void> {
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<boolean> {
// 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<boolean> {
let tokenExchangeResponse: TokenResponse;
const aceRequest: AceRequest = context.activity.value;

try {
const userTokenClient = context.turnState.get<UserTokenClient>(
(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,8 @@ export enum Channels {
// (undocumented)
Line = "line",
// (undocumented)
M365 = "m365extensions",
// (undocumented)
Msteams = "msteams",
// (undocumented)
Omni = "omnichannel",
Expand Down Expand Up @@ -2899,6 +2901,8 @@ export interface QuickViewResponse {
data: QuickViewData;
externalLink: ExternalLinkActionParameters;
focusParameters: FocusParameters;
postSsoViewId?: string;
requiresSso?: boolean;
template: AdaptiveCard;
title: string;
viewId: string;
Expand Down
1 change: 1 addition & 0 deletions libraries/botframework-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,7 @@ export enum Channels {
*/
Kik = 'kik',
Line = 'line',
M365 = 'm365extensions',
Msteams = 'msteams',
Omni = 'omnichannel',
Outlook = 'outlook',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading