コンテンツにスキップ

ラボ M4 - 認証の追加

このラボでは、前のラボで作成した Northwind プラグインを Entra ID SSO (single sign-on) で保護し、Outlook からサプライヤー情報などの自分の連絡先を検索できるようにします。

Extend Teams Message Extension ラボのナビゲーション (Extend Path)

注意

このラボでは、ボット サービスをプロビジョニングするための Azure サブスクリプションが必要です。

NOTE

すべてのコード変更を含む完成済みの演習は こちら からダウンロードできます。トラブルシューティングに役立ちます。
編集内容をリセットする必要がある場合は、リポジトリを再度クローンしてやり直してください。

このラボで学ぶ内容:

  • Entra ID SSO をプラグインに追加し、ユーザーが Microsoft Teams と同じアカウントでシームレスにログインできるようにする方法
  • Microsoft Graph API を使用して Microsoft 365 内のユーザーデータにアクセスする方法。本ラボでは、ユーザーが Outlook の連絡先などの自身のコンテンツに安全にアクセスできるよう、アプリがログイン済みユーザーの代理で動作します。

はじめに : SSO 実装に関わるタスク (概要)

プラグイン (メッセージ拡張アプリ) に SSO を実装するには、いくつかの手順が必要です。以下は高レベルの概要です。

Microsoft Entra ID でアプリを登録し、Azure Bot Service でボットを構成

  • Azure ポータルで新しいアプリ登録を作成
  • 必要なアクセス許可とスコープを構成
  • クライアント シークレットを生成
  • Azure Bot Service でボットを作成
  • ボットに Microsoft 365 チャンネルを追加
  • Azure ポータルで OAuth 接続設定を構成

Teams アプリで SSO を有効化

  • メッセージ拡張ボット コードを更新して認証とトークン交換を処理
  • Bot Framework SDK を使用して SSO 機能を統合
  • OAuth フローを実装してユーザーのアクセストークンを取得

Teams で認証を構成

  • Teams アプリ マニフェストに必要なアクセス許可を追加

演習 1: Microsoft Entra ID でアプリを登録し、Azure Bot Service を構成

幸いなことに、F5 を押せばすぐに動作するようにすべてを簡素化しています。ただし、これらのリソースを登録・構成するためにプロジェクトで行う必要がある具体的な変更点を確認しましょう。

手順 1: ファイルとフォルダーをコピー

ルート フォルダーの infra フォルダー内に entra という新しいフォルダーを作成します。

entra フォルダーに entra.bot.manifest.jsonentra.graph.manifest.json という 2 つの新しいファイルを作成します。

次に、この ファイル からコードをコピーして entra.bot.manifest.json に貼り付け、同様にこの ファイル からコードをコピーして entra.graph.manifest.json に貼り付けます。

これらのファイルは、ボット用およびトークン交換用の Graph アプリ用に必要な Entra ID アプリ登録 (以前の Azure Active Directory アプリ登録) をプロビジョニングするために必要です。

次に infra フォルダーに azure.local.bicep ファイルを作成し、この ファイル からコードをコピーします。そして同じ infra フォルダーに azure.parameters.local.json ファイルを作成し、この ファイル からコードをコピーします。

これらのファイルはボット登録を補助します。ローカルでアプリを実行しても Azure にボット サービスがプロビジョニングされるようにします。この認証フローには必須です。

これらのファイルで何が起こるのか?

Agents Toolkit がアプリをローカルで実行するとき、F0 SKU を使用する新しい Azure AI Bot Service がリソース グループにプロビジョニングされます。F0 SKU は標準チャネル (Microsoft Teams と Microsoft 365 チャンネル―Outlook と Copilot を含む) に無制限にメッセージを送信でき、料金は発生しません。

手順 2: 既存コードの更新

次に、infra フォルダー配下の botRegistration フォルダーにある azurebot.bicep ファイルを開き、"param botAppDomain" の宣言の後に以下のコード スニペットを追加します。

param graphAadAppClientId string
@secure()
param graphAadAppClientSecret string

param connectionName string

次にボット サービスをプロビジョニングするためのスニペットを同じファイルの末尾行に追加します。

resource botServicesMicrosoftGraphConnection 'Microsoft.BotService/botServices/connections@2022-09-15' = {
  parent: botService
  name: connectionName
  location: 'global'
  properties: {
    serviceProviderDisplayName: 'Azure Active Directory v2'
    serviceProviderId: '30dd229c-58e3-4a48-bdfd-91ec48eb906c'
    clientId: graphAadAppClientId
    clientSecret: graphAadAppClientSecret
    scopes: 'email offline_access openid profile Contacts.Read'
    parameters: [
      {
        key: 'tenantID'
        value: 'common'
      }
      {
        key: 'tokenExchangeUrl'
        value: 'api://${botAppDomain}/botid-${botAadAppClientId}'
      }
    ]
  }
}

これにより、ボット サービスと Graph Entra ID アプリ間のトークン交換のための新しい OAUTH 接続が作成されます。

プラグイン用インフラの変更

これまでの非認証プラグインとは異なるインフラが必要なため、配線をやり直します。次の手順がサポートします。

続いて teamsapp.local.yml ファイルを開き、その内容を以下のコード スニペットに置き換えます。これにより、インフラの一部が再配線され、このラボ用に Azure にボット サービスがデプロイされます。

# yaml-language-server: $schema=https://aka.ms/teams-toolkit/1.0.0/yaml.schema.json
# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file
# Visit https://aka.ms/teamsfx-actions for details on actions
version: 1.0.0

provision:

  - uses: script
    name: Ensure database
    with:
      run: node db-setup.js
      workingDirectory: scripts

  # Creates a Teams app
  - uses: teamsApp/create
    with:
      # Teams app name
      name: NorthwindProducts-${{TEAMSFX_ENV}}
    # Write the information of created resources into environment file for
    # the specified environment variable(s).
    writeToEnvironmentFile:
      teamsAppId: TEAMS_APP_ID

  - uses: aadApp/create
    with:
      name: ${{APP_INTERNAL_NAME}}-bot-${{TEAMSFX_ENV}}
      generateClientSecret: true
      signInAudience: AzureADMultipleOrgs
    writeToEnvironmentFile:
      clientId: BOT_ID
      clientSecret: SECRET_BOT_PASSWORD
      objectId: BOT_AAD_APP_OBJECT_ID
      tenantId: BOT_AAD_APP_TENANT_ID
      authority: BOT_AAD_APP_OAUTH_AUTHORITY
      authorityHost: BOT_AAD_APP_OAUTH_AUTHORITY_HOST

  - uses: aadApp/update
    with:
      manifestPath: "./infra/entra/entra.bot.manifest.json"
      outputFilePath : "./build/entra.bot.manifest.${{TEAMSFX_ENV}}.json"
  - uses: aadApp/create
    with:
      name: ${{APP_INTERNAL_NAME}}-graph-${{TEAMSFX_ENV}}
      generateClientSecret: true
      signInAudience: AzureADMultipleOrgs
    writeToEnvironmentFile:
      clientId: GRAPH_AAD_APP_ID
      clientSecret: SECRET_GRAPH_AAD_APP_CLIENT_SECRET
      objectId: GRAPH_AAD_APP_OBJECT_ID
      tenantId: GRAPH_AAD_APP_TENANT_ID
      authority: GRAPH_AAD_APP_OAUTH_AUTHORITY
      authorityHost: GRAPH_AAD_APP_OAUTH_AUTHORITY_HOST

  - uses: aadApp/update
    with:
      manifestPath: "./infra/entra/entra.graph.manifest.json"
      outputFilePath : "./build/entra.graph.manifest.${{TEAMSFX_ENV}}.json"

  - uses: arm/deploy
    with:
      subscriptionId: ${{AZURE_SUBSCRIPTION_ID}}
      resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}}
      templates:
        - path: ./infra/azure.local.bicep
          parameters: ./infra/azure.parameters.local.json
          deploymentName: Create-resources-for-${{APP_INTERNAL_NAME}}-${{TEAMSFX_ENV}}
      bicepCliVersion: v0.9.1

  # Validate using manifest schema
  - uses: teamsApp/validateManifest
    with:
      # Path to manifest template
      manifestPath: ./appPackage/manifest.json

  # Build Teams app package with latest env value
  - uses: teamsApp/zipAppPackage
    with:
      # Path to manifest template
      manifestPath: ./appPackage/manifest.json
      outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
      outputJsonPath: ./appPackage/build/manifest.${{TEAMSFX_ENV}}.json
  # Validate app package using validation rules
  - uses: teamsApp/validateAppPackage
    with:
      # Relative path to this file. This is the path for built zip file.
      appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip

  # Apply the Teams app manifest to an existing Teams app in
  # Teams Developer Portal.
  # Will use the app id in manifest file to determine which Teams app to update.
  - uses: teamsApp/update
    with:
      # Relative path to this file. This is the path for built zip file.
      appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip

  # Extend your Teams app to Outlook and the Microsoft 365 app
  - uses: teamsApp/extendToM365
    with:
      # Relative path to the build app package.
      appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
    # Write the information of created resources into environment file for
    # the specified environment variable(s).
    writeToEnvironmentFile:
      titleId: M365_TITLE_ID
      appId: M365_APP_ID

deploy:
# Run npm command
  - uses: cli/runNpmCommand
    name: install dependencies
    with:
      args: install --no-audit

  # Generate runtime environment variables
  - uses: file/createOrUpdateEnvironmentFile
    with:
      target: ./.localConfigs
      envs:
        BOT_ID: ${{BOT_ID}}
        BOT_PASSWORD: ${{SECRET_BOT_PASSWORD}}
        STORAGE_ACCOUNT_CONNECTION_STRING: ${{SECRET_STORAGE_ACCOUNT_CONNECTION_STRING}}
        CONNECTION_NAME: ${{CONNECTION_NAME}}

env フォルダーの .env.local ファイルを開き、すべての変数を削除して以下を追加します。

APP_INTERNAL_NAME=Northwind
APP_DISPLAY_NAME=Northwind
CONNECTION_NAME=MicrosoftGraph

同じく env フォルダーの .env.local.user ファイルを開き、すべての変数を削除して以下を追加します。

SECRET_BOT_PASSWORD=
SECRET_GRAPH_AAD_APP_CLIENT_SECRET=
SECRET_STORAGE_ACCOUNT_CONNECTION_STRING=UseDevelopmentStorage=true

演習 2: 連絡先を検索する新しいコマンド

手順 1: 連絡先 (サプライヤー) 検索コマンドを追加

まず、連絡先を検索する新しいコマンドを追加します。最終的には Microsoft Graph から連絡先を取得しますが、まずはモック データを使用してメッセージ拡張コマンドが正しく機能するか確認します。
src フォルダー > messageExtensions へ進み、supplierContactSearchCommand.ts という新しいファイルを追加します。

新しいファイルに下記内容をコピーします。

import {
    CardFactory,
    TurnContext
} from "botbuilder";


const COMMAND_ID = "supplierContactSearch";

let queryCount = 0;
async function handleTeamsMessagingExtensionQuery(context: TurnContext, query: any): Promise<any> {

    let name = '';
    if (query.parameters.length === 1 && query.parameters[0]?.name === "name") {
        [name] = (query.parameters[0]?.value.split(','));
    } else {
        name = cleanupParam(query.parameters.find((element) => element.name === "name")?.value);
    }
    console.log(`🍽️ Query #${++queryCount}:\name of contact=${name}`);
    const filteredProfile = [];
    const attachments = [];

    const allContacts = [
    {
        displayName: "John Doe",
        emailAddresses: [
        { address: "john.doe@example.com" }
        ]
    },
    {
        displayName: "Jane Smith",
        emailAddresses: [
        { address: "jane.smith@example.com" }
        ]
    },
    {
        displayName: "Alice Johnson",
        emailAddresses: [
        { address: "alice.johnson@example.com" }
        ]
    }
];

    allContacts.forEach((contact) => {
        if (contact.displayName.toLowerCase().includes(name.toLowerCase()) || contact.emailAddresses[0]?.address.toLowerCase().includes(name.toLowerCase())) {
            filteredProfile.push(contact);
        }
    });

    filteredProfile.forEach((prof) => {
        const preview = CardFactory.heroCard(prof.displayName,
            `with email ${prof.emailAddresses[0]?.address}`);

        const resultCard = CardFactory.heroCard(prof.displayName,
            `with email ${prof.emailAddresses[0]?.address}`);
        const attachment = { ...resultCard, preview };
        attachments.push(attachment);
    });
    return {
        composeExtension: {
            type: "result",
            attachmentLayout: "list",
            attachments: attachments,
        },
    };

}
function cleanupParam(value: string): string {

    if (!value) {
        return "";
    } else {
        let result = value.trim();
        result = result.split(',')[0];          // Remove extra data
        result = result.replace("*", "");       // Remove wildcard characters from Copilot
        return result;
    }
}

export default { COMMAND_ID, handleTeamsMessagingExtensionQuery }

src フォルダーの searchApp.ts ファイルを開き、先ほど作成したコマンドをインポートします。

import supplierContactSearchCommand from "./messageExtensions/supplierContactSearchCommand";

そして handleTeamsMessagingExtensionQuery 内で case customerSearchCommand.COMMAND_ID: の後に、以下のように新しいコマンド用の case を追加します。

  case supplierContactSearchCommand.COMMAND_ID: {
        return supplierContactSearchCommand.handleTeamsMessagingExtensionQuery(context, query);
      } 

次に appPackage > manifest.json を開き、composeExtensions ノードの commands 配列内にコマンドを追加します。

 {
                    "id": "supplierContactSearch",
                    "context": [
                        "compose",
                        "commandBox"
                    ],
                    "description": "Search for a contact in the user's Outlook contacts list for Northwind",
                    "title": "Contact search",
                    "type": "query",
                    "parameters": [
                        {
                            "name": "name",
                            "title": "Contact search",
                            "description": "Type name of the contact or company which forms the domain for email address of the contact, to search my Outlook contacts list",
                            "inputType": "text"
                        }
                    ] 
         } 

これでモック リストから連絡先を検索する非認証コマンドが追加されました。

手順 2: Agents Toolkit で Azure にサインイン

Agents Toolkit では、リソース インスタンスをプロビジョニングする前に Azure アカウントへのサインインとサブスクリプションが必要です。その後、これらのリソースを使用してアプリを Azure にデプロイします。

プロジェクト エディターのアクティビティ バーで Microsoft Teams アイコン 1️⃣ を選択し、Agents Toolkit 拡張機能パネルを開きます。

Agents Toolkit パネルの Accounts セクションで "Sign in to Azure" 2️⃣ を選択します。

Sign into azure

表示されるダイアログで "Sign in" を選択します。

Sign in dialog

手順 3: Teams でアプリを実行し、新しいコマンドをテスト

新しいコマンドをテストするには、ローカルでアプリを実行します。

F5 を押すか、スタート ボタン 1️⃣ をクリックしてデバッグを開始します。デバッグ プロファイル選択の際、Debug in Teams (Edge) 2️⃣ を選択するか、別のプロファイルを選択します。

Run application locally

このラボでの F5

F5 を押してアプリを実行すると、演習 1 で設定した Team Toolkit のアクションにより、認証フローに必要なすべてのリソースもプロビジョニングされます。

環境変数をクリアしたため、Entra ID アプリとボット サービスがすべて Azure にインストールされます。初回実行時には、Agents Toolkit でログインした Azure サブスクリプションのリソース グループを選択する必要があります。

resource group selection

整理のため + New resource group を選択します。Agents Toolkit が提案するデフォルト名をそのまま使用し、Enter キーを押します。

次に Location を選択します。このラボでは Central US を選択してください。

resource group selection

続いて Agents Toolkit がリソースをプロビジョニングしますが、その前に確認ダイアログが表示されます。

provision

Provision を選択します。

すべてのリソースがプロビジョニングされると、ブラウザーで Northwind アプリのインストール ダイアログが表示されるので Add を選択します。

provision

インストール後、アプリを開くかどうかのダイアログが表示されます。Open を選択すると、個人チャットでメッセージ拡張としてアプリが開きます。

app open

コマンドが機能するかを確認するため、Teams チャットでテストします。
アプリとの個人チャットで Contact search を選択し、a と入力します。

app open

上図のように連絡先が表示されれば、コマンドはモック データで正しく機能しています。次の演習でこれを修正します。

演習 3 : 新しいコマンドに認証を有効化

前のステップで新しいコマンドの基盤を作成しました。次に、認証を追加し、モックの連絡先リストを削除して、ログイン ユーザーの Outlook 連絡先を取得するように置き換えます。

まず、プラグインに必要な npm パッケージをインストールします。プロジェクトで新しいターミナル ウィンドウを開きます。

ターミナルで以下のスクリプトを実行します。

npm i @microsoft/microsoft-graph-client @microsoft/microsoft-graph-types

src フォルダーの config.ts ファイルを探します。storageAccountConnectionString: process.env.STORAGE_ACCOUNT_CONNECTION_STRING の行の後ろに , を追加し、connectionName のプロパティと値を以下のように追加します。

 const config = {
  botId: process.env.BOT_ID,
  botPassword: process.env.BOT_PASSWORD,
  storageAccountConnectionString: process.env.STORAGE_ACCOUNT_CONNECTION_STRING,
  connectionName: process.env.CONNECTION_NAME
};

次に、ベース プロジェクトの src フォルダーに services フォルダーを作成します。
この services フォルダーに AuthService.tsGraphService.ts の 2 つのファイルを作成します。

  • AuthService : 認証サービスを提供するクラスです。getSignInLink メソッドで接続情報を使用してサインイン URL を取得します。
  • GraphService : Microsoft Graph API と対話するクラスです。認証トークンを使用して Graph クライアントを初期化し、getContacts メソッドでユーザーの連絡先 (displayName と emailAddresses) を取得します。

まず AuthService.ts に次のコードを貼り付けます。

import {
  AdaptiveCardInvokeResponse,
  CloudAdapter,
  MessagingExtensionQuery,
  MessagingExtensionResponse,
  TurnContext,
} from 'botbuilder';
import { UserTokenClient } from 'botframework-connector';
import { Activity } from 'botframework-schema';
import config from '../config';

export class AuthService {
  private client: UserTokenClient;
  private activity: Activity;
  private connectionName: string;

  constructor(context: TurnContext) {
    const adapter = context.adapter as CloudAdapter;
    this.client = context.turnState.get<UserTokenClient>(
      adapter.UserTokenClientKey
    );
    this.activity = context.activity;
    this.connectionName = config.connectionName;
  }

  async getUserToken(
    query?: MessagingExtensionQuery
  ): Promise<string | undefined> {
    const magicCode =
      query?.state && Number.isInteger(Number(query.state)) ? query.state : '';

    const tokenResponse = await this.client.getUserToken(
      this.activity.from.id,
      this.connectionName,
      this.activity.channelId,
      magicCode
    );

    return tokenResponse?.token;
  }

  async getSignInComposeExtension(): Promise<MessagingExtensionResponse> {
    const signInLink = await this.getSignInLink();

    return {
      composeExtension: {
        type: 'auth',
        suggestedActions: {
          actions: [
            {
              type: 'openUrl',
              value: signInLink,
              title: 'SignIn',
            },
          ],
        },
      },
    };
  }

  async getSignInAdaptiveCardInvokeResponse(): Promise<AdaptiveCardInvokeResponse> {
    const signInLink = await this.getSignInLink();

    return {
      statusCode: 401,
      type: 'application/vnd.microsoft.card.signin',

      value: {
        signinurl: signInLink,
      },
    };
  }

  async getSignInLink(): Promise<string> {
    const { signInLink } = await this.client.getSignInResource(
      this.connectionName,
      this.activity,
      ''
    );

    return signInLink;
  }
}

次に GraphService.ts に次のコードを貼り付けます。

import { Client } from '@microsoft/microsoft-graph-client';


export class GraphService {
  private _token: string;
  private graphClient: Client;

  constructor(token: string) {
    if (!token || !token.trim()) {
      throw new Error('SimpleGraphClient: Invalid token received.');
    }
    this._token = token;

    this.graphClient = Client.init({
      authProvider: done => {
        done(null, this._token);
      },
    });
  }
  async getContacts(): Promise<any> {
    const response = await this.graphClient
      .api(`me/contacts`)
      .select('displayName,emailAddresses')
      .get();

    return response.value;
  }
}

続いて supplierContactSearchCommand.ts ファイルに戻り、追加した 2 つのサービスをインポートします。

import { AuthService } from "../services/AuthService";
import { GraphService } from "../services/GraphService";

次に、認証を初期化し、ユーザートークンを取得して有効性を確認し、有効な場合は Microsoft Graph API と対話するサービスを設定するコードを追加します。トークンが無効な場合は、ユーザーへサインインを促します。

handleTeamsMessagingExtensionQuery 関数内の allContacts 定数のモック定義の上に、以下のコードをコピーします。

  const credentials = new AuthService(context);
  const token = await credentials.getUserToken(query);
  if (!token) {
    return credentials.getSignInComposeExtension();
  }
  const graphService = new GraphService(token);

続いて allContacts のモック定義を以下のコードに置き換えます。

const allContacts = await graphService.getContacts();

次に appPackage/manifest.json を開き、validDomains ノードを以下のように更新します。

"validDomains": [
        "token.botframework.com",
        "${{BOT_DOMAIN}}"
    ]

さらに、validDomains 配列の後に "," を追加し、webApplicationInfo ノードを以下の値で追加します。

    "webApplicationInfo": {
        "id": "${{BOT_ID}}",
        "resource": "api://${{BOT_DOMAIN}}/botid-${{BOT_ID}}"
    },

最後にマニフェストのバージョンを "1.0.10" から "1.0.11" に更新して変更を反映させます。

これらのマニフェスト変更により、サインイン URL が正しく形成され、ユーザーに同意を求めるようになります。

演習 4: 認証をテスト

手順 1: アプリをローカルで実行

デバッガーが実行中の場合は停止してください。マニフェストに新しいコマンドを追加したため、新しいパッケージでアプリを再インストールする必要があります。

F5 を押すかスタート ボタン 1️⃣ をクリックしてデバッガーを再起動します。デバッグ プロファイル選択で Debug in Teams (Edge) 2️⃣ を選択するか、別のプロファイルを選択します。

Run application locally

Provision

再度リソースをプロビジョニングするか確認するダイアログが表示されます。Provision を選択してください。これは新規リソースを作成するのではなく、既存リソースを上書きします。

デバッグにより Teams がブラウザー ウィンドウで開きます。Agents Toolkit にサインインしたのと同じ資格情報でログインしてください。
Teams が開いたら、アプリを開くかどうかのダイアログが表示されます。

Open

開くとすぐに、どこでアプリを開くかを尋ねられます。既定は個人チャットです。チャンネルやグループ チャットも選択できます。Open をクリックします。

Open surfaces

これでアプリとの個人チャットになります。ただし Copilot でテストするため、次の手順に従います。

Teams で Chat をクリックし、Copilot を選択します (一番上に表示されるはずです)。
Plugin アイコン をクリックし、Northwind Inventory を選択してプラグインを有効化します。

手順 2 : テスト データを入力

実際の連絡先を取得する前に、Microsoft 365 に連絡先情報を追加する必要があります。

1️⃣ Microsoft Teams で "ワッフル" メニューをクリック
2️⃣ Microsoft Outlook を選択

outlook

1️⃣ Outlook 内で "Contacts" ボタンをクリック
2️⃣ 新しい連絡先を入力

アプリは名前とメール アドレスのみを表示します。ビジネス シナリオに合わせたい場合は、サプライヤーらしい名前にしてください。

outlook

手順 2: Copilot でテスト

Copilot に次のプロンプトを入力します。
Find my conacts with name {first name} in Northwind
({first name} を手順 1 で登録した連絡先の名前に置き換えてください)

サインイン ボタンが表示され、一度だけ認証が必要です。

prompt

これは、このプラグイン機能に認証が設定されたことを示します。Sign in to Northwind Inventory を選択してください。

下の GIF のように同意を求めるダイアログが表示されます。同意すると Microsoft 365 Copilot から結果が返ってくるはずです。
working gif

おめでとうございます

難しい内容でしたが、見事にクリアしました!
Message Extension エージェント トラックを完走いただき、ありがとうございました。