OfficeDev / teams-toolkit

Developer tools for building Teams apps
Other
450 stars 176 forks source link

Does TeamsFx message extension SSO support Copilot Plugins? #10727

Closed frankchen76 closed 7 months ago

frankchen76 commented 7 months ago

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:

  1. Go to MS Teams
  2. Click on "M365 Chat"
  3. enable the plugins
  4. type prompt and see "Sign in to [plugins]" button.
  5. click "Sign in" button, complete the authentication and see "Sign in to [plugins]" button grayed out
  6. retype prompt and see "Sign in to [plugins]" button again.

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 image

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.

blackchoey commented 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

frankchen76 commented 7 months ago

@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: image

immediately see 501 not implemented error image

frankchen76 commented 7 months ago

@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.

blackchoey commented 7 months ago

@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.

frankchen76 commented 7 months ago

Hi @blackchoey, thanks for your note. that was a test app which was removed after testing. also updated image. thanks

luisJarmanCP commented 6 months ago

hi @frankchen76, you were able to get it working? Where is the token stored?

frankchen76 commented 6 months ago

@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;
    }
ddupre14 commented 6 months ago

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.

blackchoey commented 6 months ago

@ddupre14 Please use the Teams-AI library suggested in this comment to build your app.

frankchen76 commented 5 months ago

@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

// 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;

});