Closed frankchen76 closed 7 months ago
Hi @frankchen76 Currently, Copilot Plugins assume the token is cached and ignores the response after user SSO (which is respected by Message Extension). So you will see Copilot always ask you to sign-in.
The SSO support in TeamsFx is already consolidated into the Teams-AI library and added cache support (there's in-memory cache by default). So you can use the Teams-AI
library to build message extensions, which has same SSO experience with TeamsFx. Here's a sample project: https://github.com/microsoft/teams-ai/tree/main/js/samples/06.auth.teamsSSO.messageExtension
@blackchoey, thanks for your note. I tried Teams-AI library, but I wasn't able to make the extension working. I referred the sample code which you mentioned, replace my index.ts to use team-ai library sample code (06.auth.teamsSSO.messageExtension->index.ts). but I always got "501 not implemented" error right after my code sent "silentAuth". I didn't even see the "signin" button. please see below screenshot:
Anything you can recommend for debug? anything we can turn on trace/log from team-ai library? Thanks
index.ts which is almost exactly copied from 06.auth.teamsSSO.messageExtension->index.ts
/* eslint-disable @typescript-eslint/no-unused-vars */
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// Import required packages
// import { config } from 'dotenv';
import * as path from 'path';
import * as restify from 'restify';
import axios from 'axios';
// Import required bot services.
// See https://aka.ms/bot-services to learn more about the different parts of a bot.
import {
CardFactory,
ConfigurationServiceClientCredentialFactory,
MemoryStorage,
MessagingExtensionAttachment,
MessagingExtensionResult,
TurnContext
} from 'botbuilder';
import { ApplicationBuilder, TurnState, TeamsAdapter } from '@microsoft/teams-ai';
import { GraphClient } from './graphClient';
import { createNpmPackageCard, createNpmSearchResultCard, createSignOutCard } from './cards';
// Read botFilePath and botFileSecret from .env file.
// const ENV_FILE = path.join(__dirname, '..', '.env');
// config({ path: ENV_FILE });
console.log(`botId: ${process.env.BOT_ID}`);
console.log(`botPassword: ${process.env.BOT_PASSWORD}`);
console.log(`clienId: ${process.env.M365_CLIENT_ID}`);
console.log(`clientSecret: ${process.env.M365_CLIENT_SECRET}`);
console.log(`authority: ${process.env.M365_AUTHORITY_HOST}/${process.env.M365_TENANT_ID}`);
console.log(`signinlink: ${process.env.INITIATE_LOGIN_ENDPOINT}`);
// Create adapter.
// See https://aka.ms/about-bot-adapter to learn more about how bots work.
const adapter = new TeamsAdapter(
{},
new ConfigurationServiceClientCredentialFactory({
MicrosoftAppId: process.env.BOT_ID,
MicrosoftAppPassword: process.env.BOT_PASSWORD,
MicrosoftAppType: 'MultiTenant'
})
);
// Catch-all for errors.
const onTurnErrorHandler = async (context: any, error: any) => {
// This check writes out errors to console log .vs. app insights.
// NOTE: In production environment, you should consider logging this to Azure
// application insights.
console.error(`\n [onTurnError] unhandled error: ${error.toString()}`);
// Send a trace activity, which will be displayed in Bot Framework Emulator
await context.sendTraceActivity(
'OnTurnError Trace',
`${error.toString()}`,
'https://www.botframework.com/schemas/error',
'TurnError'
);
// Send a message to the user
await context.sendActivity('The bot encountered an error or bug.');
await context.sendActivity('To continue to run this bot, please fix the bot source code.');
};
// Set the onTurnError for the singleton CloudAdapter.
adapter.onTurnError = onTurnErrorHandler;
// Create HTTP server.
const server = restify.createServer();
server.use(restify.plugins.bodyParser());
server.listen(process.env.port || process.env.PORT || 3978, () => {
console.log(`\n${server.name} listening to ${server.url}`);
console.log('\nGet Bot Framework Emulator: https://aka.ms/botframework-emulator');
console.log('\nTo test your bot in Teams, sideload the app manifest.json within Teams Apps.');
});
// Define storage and application
const storage = new MemoryStorage();
const app = new ApplicationBuilder()
.withStorage(storage)
.withAuthentication(adapter, {
settings: {
graph: {
scopes: ['User.Read'],
msalConfig: {
auth: {
clientId: process.env.M365_CLIENT_ID!,
clientSecret: process.env.M365_CLIENT_SECRET!,
authority: `${process.env.M365_AUTHORITY_HOST}/${process.env.M365_TENANT_ID}`
}
},
// signInLink: `https://${process.env.BOT_DOMAIN}/auth-start.html`,
signInLink: `${process.env.INITIATE_LOGIN_ENDPOINT}`,
endOnInvalidMessage: true
}
},
autoSignIn: (context: TurnContext) => {
const signOutActivity = context.activity?.value.commandId === 'signOutCommand';
if (signOutActivity) {
return Promise.resolve(false);
}
return Promise.resolve(true);
}
})
.build();
// Handles when the user makes a Messaging Extension query.
app.messageExtensions.query('searchCmd', async (_context: TurnContext, state: TurnState, query) => {
const searchQuery = query.parameters.queryText ?? '';
const count = query.count ?? 10;
const results: MessagingExtensionAttachment[] = [];
if (searchQuery == 'profile') {
const token = state.temp.authTokens['graph'];
if (!token) {
throw new Error('No auth token found in state. Authentication failed.');
}
const user = await getUserDetailsFromGraph(token);
const profileCard = CardFactory.thumbnailCard(user.displayName, CardFactory.images([user.profilePhoto]));
results.push(profileCard);
} else {
const response = await axios.get(
`http://registry.npmjs.com/-/v1/search?${new URLSearchParams({
size: count.toString(),
text: searchQuery
}).toString()}`
);
// Format search results
response?.data?.objects?.forEach((obj: any) => results.push(createNpmSearchResultCard(obj.package)));
}
// Return results as a list
return {
attachmentLayout: 'list',
attachments: results,
type: 'result'
} as MessagingExtensionResult;
});
// Listen for item selection
app.messageExtensions.selectItem(async (_context: TurnContext, _state: TurnState, item) => {
// Generate detailed result
const card = createNpmPackageCard(item);
// Return results
return {
attachmentLayout: 'list',
attachments: [card],
type: 'result'
} as MessagingExtensionResult;
});
// Handles when the user clicks the Messaging Extension "Sign Out" command.
app.messageExtensions.fetchTask('signOutCommand', async (context: TurnContext, state: TurnState) => {
await app.authentication.signOutUser(context, state, 'graph');
const signoutCard = createSignOutCard();
return {
card: signoutCard,
heigth: 100,
width: 400,
title: 'Adaptive Card: Inputs'
};
});
// Handles the 'Close' button on the confirmation Task Module after the user signs out.
app.messageExtensions.submitAction('signOutCommand', async (_context: TurnContext, _state: TurnState) => {
return null;
});
/**
* Get the user details from Graph
* @param {string} token The token to use to get the user details
* @returns {Promise<{ displayName: string; profilePhoto: string }>} A promise that resolves to the user details
*/
async function getUserDetailsFromGraph(token: string): Promise<{ displayName: string; profilePhoto: string }> {
// The user is signed in, so use the token to create a Graph Clilent and show profile
const graphClient = new GraphClient(token);
const profile = await graphClient.getMyProfile();
const profilePhoto = await graphClient.getProfilePhotoAsync();
return { displayName: profile.displayName, profilePhoto: profilePhoto };
}
// Listen for incoming server requests.
server.post('/api/messages', async (req, res) => {
// Route received a request to adapter for processing
await adapter.process(req, res as any, async (context) => {
// Dispatch to application for routing
await app.run(context);
});
});
server.get(
'/auth-:name(start|end).html',
restify.plugins.serveStatic({
directory: path.join(__dirname, 'public')
})
);
{
"name": "northwinddbmsteamext",
"version": "1.0.0",
"description": "Microsoft Teams Toolkit message extension search sample",
"engines": {
"node": "16 || 18"
},
"author": "Microsoft",
"license": "MIT",
"main": "./lib/src/index.js",
"scripts": {
"dev:teamsfx": "env-cmd --silent -f .localConfigs npm run dev",
"dev": "nodemon --exec node --trace-deprecation --inspect=9239 --signal SIGINT -r ts-node/register ./src/index.ts",
"build-old": "tsc --build",
"build": "tsc --build && copyfiles ./public/*.html lib/",
"start": "node ./lib/src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "nodemon --exec \"npm run start\""
},
"repository": {
"type": "git",
"url": "https://github.com"
},
"dependencies": {
"@microsoft/microsoft-graph-client": "^3.0.7",
"@microsoft/teams-ai": "~1.1.0",
"botbuilder": "^4.21.4",
"adaptive-expressions": "^4.20.0",
"adaptivecards": "^3.0.1",
"adaptivecards-templating": "^2.3.1",
"axios": "^1.6.3",
"debug": "^4.3.4",
"isomorphic-fetch": "^3.0.0",
"restify": "~11.1.0",
"shx": "^0.3.4"
},
"devDependencies": {
"@types/node": "^16.0.0",
"@types/restify": "8.5.12",
"env-cmd": "^10.1.0",
"nodemon": "~3.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
first time the extension sent silentAuth:
immediately see 501 not implemented error
@blackchoey, I just figured out that I used wrong commandId which caused 501 not implemented. After testing, it seems like Teams-AI library internally uses msal lib to handle the SSO token via acquire on behalf of process. It works pretty well to handle SSO from M365 Copilot plugins. I'll close this issue.
@frankchen76 Glad to hear that! By the way, your screenshot contains token, it would be better to ensure there's no security risk with the exposure.
Hi @blackchoey, thanks for your note. that was a test app which was removed after testing. also updated image. thanks
hi @frankchen76, you were able to get it working? Where is the token stored?
@luisJarmanCP, yes I was able to make the code working. the MS Teams-AI library used msal library to retrieve the token based on on behalf of flow. msal library also has token cache process. below is the code from teams-ai/js/packages/teams-ai/src/authentication /TeamsSsoMessageExtensionAuthentication.ts
public async handleSsoTokenExchange(context: TurnContext): Promise<TokenResponse | undefined> {
const tokenExchangeRequest = context.activity.value.authentication;
if (!tokenExchangeRequest || !tokenExchangeRequest.token) {
return;
}
const result = await this.msal.acquireTokenOnBehalfOf({
oboAssertion: tokenExchangeRequest.token!, // The parent class ensures that this is not undefined
scopes: this.settings.scopes
});
if (result) {
return {
connectionName: '',
token: result.accessToken,
expiration: result.expiresOn?.toISOString() ?? ''
};
}
return undefined;
}
Hi @frankchen76 can you explain how you implemented this in the 'Northwind copilot sample' ? can you explain us how you implemented this in the Northwind example? What changes have you made to the handleMessageExtensionQueryWithSSO function? tk's.
@ddupre14 Please use the Teams-AI library suggested in this comment to build your app.
@ddupre14, sorry to get back to you late. You can refer the following steps for a high level instruction. you can refer a full sample from frankchen76/northwinddb-msteamext-sample
once you have REST API created and secured by an AAD application, you can change your copilot sample to use ai-library with the following code:
const app = new ApplicationBuilder()
.withStorage(storage)
.withAuthentication(adapter, {
settings: {
graph: {
scopes: ['{the resource id you used to secure your REST API above}'], // the sample should be like api://[aad-app-id]/.default
msalConfig: {
auth: {
clientId: process.env.M365_CLIENT_ID!, // this is app id you created for ms team ext SSO.
clientSecret: process.env.M365_CLIENT_SECRET!,
authority: `${process.env.M365_AUTHORITY_HOST}/${process.env.M365_TENANT_ID}`
}
},
signInLink: `${process.env.INITIATE_LOGIN_ENDPOINT}`, // this should auth page like https://${process.env.BOT_DOMAIN}/auth-start.html
endOnInvalidMessage: true
}
},
autoSignIn: (context: TurnContext) => {
const signOutActivity = context.activity?.value?.commandId === 'signOutCommand';
if (signOutActivity) {
return Promise.resolve(false);
}
return Promise.resolve(true);
}
})
.build();
// Handles when the user makes a Messaging Extension query. the "inventorySearch" is the command you defined in your copilot manifest file -> search command. the issue I had previously was because I used different command name as what I defined in manifest file.
app.messageExtensions.query('inventorySearch', async (_context: TurnContext, state: TurnState, query: Query<Record<string, string>>) => {
const token = state.temp.authTokens['graph']; // since we defined "graph" from ApplicationBuilder above, we just need to get token based on "graph" name. ai-library will take care the token acquisition using msal.
const extService = new TeamsExtService();
if (!token) {
throw new Error('No auth token found in state. Authentication failed.');
}
const cred = new TeamAICredential(token);
const ret = await extService.searchProductsFromCopilot(_context, query, cred);
return ret.composeExtension;
});
Describe the bug Rewrote northwind copilot sample to use API and wanted to test SSO. When I used
handleMessageExtensionQueryWithSSO
for the message extension, it doesn't provide SSO token from M365 Copilot plugins context. but it works for MS Teams message extension context. does TeamsFx message extension SSO support M365 Copilot plugins? I also changed the MS Teams project to use ngrok and figure out that the message returned from M365 Copilot context doesn't include "authentication"->"token" property inside "value" which we can find from MS Teams message extension context. (see screenshot)To Reproduce Steps to reproduce the behavior:
Expected behavior After authentication, the plugin should return the result. it was working for MS Teams extension context. but it doesn't work for M365 Copilot context.
Screenshots
![image](https://github.com/OfficeDev/TeamsFx/assets/9618662/b6eede63-942e-483d-8952-130a2a3b92df)
VS Code Extension Information (please complete the following information):
CLI Information (please complete the following information):
Additional context Add any other context about the problem here.