Microsoft GraphAPIでカレンダーイベントの通知(webhook)を受け取る

## 概要

- MicrosoftのOutlookからイベント受け取ったりしたい
- カレンダーで何か予定が追加されたり削除されたり変更されたりしたときに、その情報をイベントドリブンに受け取って、他システムに連携させたい

## この記事のゴール

- GraphAPIにサブスクリプション登録し、そこで指定した通知先エンドポイントに対して、カレンダーイベントの作成や削除のデータが連携される
- 連携されたデータが復号化され、別システムに連携するための準備が整った状態になっている

## 範囲外

- GraphAPIそのものの話
- Firebaseそのもの話
- 実装の細かい話

## 手順

### 前提

- 面倒なのでアクセストークンとったりする部分は未実装にし、[Graph Explorer](https://developer.microsoft.com/ja-jp/graph/graph-explorer) に任せる
- サブスクリプションを受け止めるのは、Firebase Functions Gen1で実装する
- しかしただの検証でいちいちデプロイするのは面倒なので、[ngrok](https://ngrok.com/)を使ってローカルで完結させる

## Azureへの新規アプリケーション登録

- ただの検証なので、[Azure ADアプリを登録する]([https://docs.microsoft.com/ja-jp/graph/toolkit/get-started/add-aad-app-registration#add-new-application-registration-in-azure-active-directory](https://docs.microsoft.com/ja-jp/graph/toolkit/get-started/add-aad-app-registration#add-new-application-registration-in-azure-active-directory))を参考に適当に作る

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80163/6e98bc39-f18e-98ef-c923-c8cdb01c1012.png)


- API Permissionsを設定する。今回はカレンダー関連だけなので、`Calenders.Read`だけでOK
    - この画像では、SharedとかUserも入れてあるけど

![e29b1822-c662-bde7-cc68-6cda14a217a1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80163/76df58c0-26dd-572c-3b21-1f9283442010.png)

## RSAキーペアの作成

* イベント作成に関するペイロード、例えば何時から何時まで？とか参加者の情報は暗号化されて飛んでくる
    * その暗号化されたデータを復号しなければ実態を確認できない。
* 何で暗号して何で復号するか？のキーペアは予め利用者側で作成して、サブスクリプション登録時にGraphAPI側に伝える必要がある。

* まずはその元ネタとなるキーペアを作成する
    * プロダクション実装するならここは定期的にローテーションさせたりする実装が必要になるだろう

```bash
$ openssl genrsa -out private.key 1024
$ openssl req -new -x509 -key private.key -out publickey.cer -days 365
$ openssl pkcs12 -export -out public_privatekey.pfx -inkey private.key -in publickey.cer
```

## Firebase Functionsを実装

- 満たしたい要件は2つ
    - サブスクリプション登録を行った際に、検証トークンが指定した通知先エンドポイントに飛んでくるので、それを捕まえてクエリからトークンをとってGraphAPI側に投げ返す機能実装
    - サブスクリプション登録後、イベント作成や削除に応じて飛んでくる通知のペイロードを復号する機能
        - `encryptedContent` に暗号化されたデータが込められているので、それをキーペアの秘密鍵で復号する
- 事前に作成したキーペアの公開鍵を環境変数(PRIV_KEY_PEM)に事前にセットしておく
    - 上述のコマンドそのまま打ったならファイル名はprivate.keyのはず
- 

```tsx
import * as functions from "firebase-functions";
import { privateDecrypt, createDecipheriv } from "crypto";

export const outlookEventsSubscription = functions.https.onRequest(
  (request, response) => {
    // GraphAPIへのSubscription登録の検証のために渡されたトークンをそのまま返す
    if ("validationToken" in request.query) {
      response.send(request.query.validationToken);
      return;

    // Subscription検証以外はこっち
    } else if (
      "value" in request.body &&
      request.body.value[0].encryptedContent
    ) {
      const base64encodedKey = request.body.value[0].encryptedContent.dataKey;
      const asymetricPrivateKey = proccess.env.PRIV_KEY_PEM;
      const decodedKey = Buffer.from(base64encodedKey, "base64");
      const decryptedSymetricKey = privateDecrypt(
        asymetricPrivateKey,
        decodedKey
      );

      const base64encodedPayload = request.body.value[0].encryptedContent.data;
      const iv = Buffer.alloc(16, 0);
      decryptedSymetricKey.copy(iv, 0, 0, 16);
      const decipher = createDecipheriv(
        "aes-256-cbc",
        decryptedSymetricKey,
        iv
      );
      let decryptedPayload = decipher.update(
        base64encodedPayload,
        "base64",
        "utf8"
      );
      decryptedPayload += decipher.final("utf8");
	  //復号したオブジェクトを表示
      console.log(`decrypted event payload: ${decryptedPayload}`);
    }

    response.send({});
    return;
  }
)
```

## ngrokでパブリッシュ

- ngrokを未インストールの場合はhomebrew経由で入れるのが簡単 (macの場合)
    - ref. [https://formulae.brew.sh/cask/ngrok](https://formulae.brew.sh/cask/ngrok)
- ngrokはアカウント作らないと外に公開できないので、適当にアカウント作ってauthtokenを取得してコンフィグに入れ込む

```bash
$ brew install --cask ngrok
$ ngrok authtoken [token]
```

- firebase functionsはデフォでTCP:5001を使うので、そのポートを指定してngrokで外部公開
- 生成されたhttpsなURLをコピっておく

```bash
$ ngrok http 5001
```

### Graph Explorer経由でサブスクリプションを登録

- 対象URLは [`https://graph.microsoft.com/beta/subscriptions`](https://graph.microsoft.com/beta/subscriptions)
    - 詳細なリソースデータをもらうためにはbetaじゃないとダメ
    - v1だと未サポートだよーってエラー出る
- ペイロード(要求本文)として以下を投げる
    - `changeType`: 何のイベント契機で通知を投げてもらうか？
    - `resource`: 今回はカレンダーイベントを狙い撃ち。かつ、selectとして参加者と開始時刻、終了時刻を指定
    - `expirationDateTime`: 参加者など具体的なリソースデータを取得する場合は現在時刻から有効期間は最大1日
    - `encryptionCertificate`: キーペアの公開鍵(publickey.cer)を指定するが、事前にbase64エンコードしておくこと

```json
{
    "changeType": "created,deleted",
    "notificationUrl": "ngrokで公開したエンドポイントを指定(httpsが必須)",
    "resource": "me/events?$select=attendees,start,end",
    "expirationDateTime": "現在時刻から24時間以内を指定",
    "clientState": "適当に何か入れる",
    "includeResourceData": "true",
    "encryptionCertificate": "事前に生成したキーペアの公開鍵をbase64エンコードして入れる",
    "encryptionCertificateId": "生成した鍵を示す任意IDなのでこれも適当で"
}
```

こんな感じのレスポンスが返って来れば成功。
ダメならダメで比較的わかりやすいエラーメッセージも添えて返ってくるので、その通りにペイロードを調整する。

```json
{
    "@odata.context": "https://graph.microsoft.com/beta/$metadata#subscriptions/$entity",
    "id": "適当なuuid",
    "resource": "me/events?$select=attendees,start,end",
    "applicationId": "予めazureで作ったappId",
    "changeType": "created,deleted",
    "clientState": "hogehogefoobar",
    "notificationUrl": "指定した通知先エンドポイント",
    "notificationQueryOptions": null,
    "notificationContentType": null,
    "lifecycleNotificationUrl": null,
    "expirationDateTime": "指定した有効期限",
    "creatorId": "AzureのuserId",
    "includeResourceData": true,
    "latestSupportedTlsVersion": "v1_2",
    "encryptionCertificate": "指定した鍵データ",
    "encryptionCertificateId": "指定した鍵データのID",
    "notificationUrlAppId": null
}
```

## 動作確認

- あとはoutlook側で適当にカレンダーイベントを作成したり削除すれば、およそ3秒程度で通知が飛んでくるはず
    - キーペアまわりで問題なければ復号されて表示されるはず
    - 逆に言えば、何かしらエラーが起きているならサブスクリプション登録時などで間違った鍵データを登録してしまっているかもしれない
    - もちろん実装コード側での秘密鍵の読み込み方が違う、とかもあるかもだけど

## 参考

めちゃくちゃ参考にしました。ありがとうございました。

- [https://qiita.com/kenakamu/items/e7dfd7a93ea6a3c631bf](https://qiita.com/kenakamu/items/e7dfd7a93ea6a3c631bf)
- [https://dev.classmethod.jp/articles/msgraph-changenotifications-trackchanges/](https://dev.classmethod.jp/articles/msgraph-changenotifications-trackchanges/)
- [https://docs.microsoft.com/ja-jp/graph/api/subscription-post-subscriptions?view=graph-rest-1.0&tabs=http](https://docs.microsoft.com/ja-jp/graph/api/subscription-post-subscriptions?view=graph-rest-1.0&tabs=http)
- [https://docs.microsoft.com/ja-jp/graph/outlook-change-notifications-overview?view=graph-rest-1.0](https://docs.microsoft.com/ja-jp/graph/outlook-change-notifications-overview?view=graph-rest-1.0)

復号処理まわりは以下のstack overflowの話が非常に参考になりました。
- https://stackoverflow.com/questions/62802831/https-graph-microsoft-com-beta-subscriptions-includeresourcedata-encryptionc