Skip to content

Commit

Permalink
Merge branch 'main' into southworks/update/p-map
Browse files Browse the repository at this point in the history
  • Loading branch information
JhontSouth committed Jan 14, 2025
2 parents f3dddee + 31c72e9 commit dbdc56a
Show file tree
Hide file tree
Showing 15 changed files with 251 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:
run: yarn test:github

- name: coveralls
if: matrix.node-version == '22.x'
if: matrix.node-version == '22.x' && matrix.os == 'windows'
uses: coverallsapp/[email protected]
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
10 changes: 6 additions & 4 deletions libraries/adaptive-expressions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"big-integer": "^1.6.52",
"@types/xmldom": "^0.1.34",
"btoa-lite": "^1.0.0",
"d3-format": "^2.0.0",
"d3-format": "^3.1.0",
"dayjs": "^1.11.13",
"jspath": "^0.4.0",
"lodash": "^4.17.21",
Expand All @@ -58,18 +58,20 @@
"build:browser": "npm-run-all build:browser:clean build:browser:run",
"build:browser:clean": "rimraf --glob lib/browser.*",
"build:browser:run": "tsup --config ../../tsup/browser.config.ts",
"clean": "rimraf lib tsconfig.tsbuildinfo",
"depcheck": "depcheck --config ../../.depcheckrc --ignores sinon,@types/xmldom",
"clean": "rimraf lib vendors tsconfig.tsbuildinfo",
"depcheck": "depcheck --config ../../.depcheckrc --ignores @types/xmldom,d3-format,sinon",
"build-docs": "typedoc --theme markdown --entryPoint adaptive-expressions --excludePrivate --includeDeclarations --ignoreCompilerErrors --module amd --out ..\\..\\doc\\adaptive-expressions .\\lib\\index.d.ts --hideGenerator --name \"Bot Builder SDK - Expression\" --readme none",
"test": "yarn build && mocha tests --timeout 60000",
"test:compat": "api-extractor run --verbose",
"lint": "eslint .",
"prebuild": "tsup ./node_modules/d3-format/src/*.js --format cjs --out-dir vendors/d3-format --clean --sourcemap",
"antlr-build-expression": "antlr4ts src/parser/ExpressionAntlrLexer.g4 -o src/parser/generated && antlr4ts src/parser/ExpressionAntlrParser.g4 -visitor -o src/parser/generated",
"antlr-build-commonregex": "antlr4ts src/CommonRegex.g4 -o src/generated -visitor"
},
"files": [
"lib",
"src",
"types"
"types",
"vendors"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Licensed under the MIT License.
*/

import { formatLocale as d3formatLocale, format as d3format } from 'd3-format';
import { formatLocale as d3formatLocale, format as d3format } from '../../vendors/d3-format';
import { Expression } from '../expression';
import { EvaluateExpressionDelegate, ExpressionEvaluator } from '../expressionEvaluator';
import { ExpressionType } from '../expressionType';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Licensed under the MIT License.
*/

import { formatLocale as d3formatLocale, format as d3format } from 'd3-format';
import { formatLocale as d3formatLocale, format as d3format } from '../../vendors/d3-format';
import { EvaluateExpressionDelegate, ExpressionEvaluator, ValueWithError } from '../expressionEvaluator';
import { ExpressionType } from '../expressionType';
import { FunctionUtils } from '../functionUtils';
Expand Down
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';
14 changes: 13 additions & 1 deletion libraries/botbuilder/src/sharepoint/sharePointActivityHandler.ts
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;
}
}
4 changes: 4 additions & 0 deletions libraries/botframework-schema/etc/botframework-schema.api.md
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;
}
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
"**/@types/selenium-webdriver",
"botbuilder/filenamify",
"botbuilder-azure/p-map",
"botbuilder-azure-blobs/p-map"
"botbuilder-azure-blobs/p-map",
"adaptive-expressions/d3-format"
],
"nohoistComments": {
"**/@types/selenium-webdriver": "This package is excluded from the root @types folder as it requires ES2015+, whereas some BotBuilder libraries support ES5+.",
"botbuilder/filenamify": "This package is excluded because it's compiled as CJS by tsup as it's ESM-only.",
"botbuilder-azure/p-map": "This package is excluded because it's compiled as CJS by tsup as it's ESM-only.",
"botbuilder-azure-blobs/p-map": "This package is excluded because it's compiled as CJS by tsup as it's ESM-only."
"botbuilder-azure-blobs/p-map": "This package is excluded because it's compiled as CJS by tsup as it's ESM-only.",
"adaptive-expressions/d3-format": "This package is excluded because it's compiled as CJS by tsup as it's ESM-only."
}
},
"scripts": {
Expand Down
Loading

0 comments on commit dbdc56a

Please sign in to comment.