node.js Firebase Functionで作ったGoogle Chat BotでChatからのリクエストであるかの検証を行う

皆さんこんにちわ。Chat Bot作ってますか？まだApps Scriptで作ってますか？
このごろは外部のG SuiteテナントユーザーともやりとりできるようになったGoogle Chat。
Apps Scriptのままじゃテナントまたげません[^1]。
やっぱりFirebase Functionsでしょう！ (Cloud Functionsもほぼ同じ)

…ってことでCloud Functionsをnode.jsで作り始めると、開発ドキュメントの「[Verifying bot authenticity][1]」でGoogle Chatからのリクエストかどうかを検証するためのサンプルコードが書かれていることに気づきます。
この対策をしないと、Bot Function URLが知らんやつに知られたら、Bot messageのsender情報を偽装されちゃうかもしれません。Botでユーザー情報を保持していてメッセージによって返すようにしていたら、その情報が抜かれちゃいます。そりゃまずいってことでサンプルのとおりに実装しようとしたところ…

### node.jsでBot Function作ってるのに、JavaとPythonのサンプルしか書かれていない！[^3]

まじありえんと…そこから調べるのいろいろ苦労したんですがシンプルな方法で解決したので記録します。
以下ざっくりとした手順です。

1. まずありがたい、[`google-id-token`][2] を使わせてもらう。
1. node_modules/google-id-token/Readme.md をざっと読む。
1. Readme.mdに記載のサンプルコードを、まるっとfunctionにする。
1. 好みの問題もあるけど `required('request')` は使いづらいので、`node-fetch` で代替しちゃう。
1. Firebaseプロジェクトに紐付くGCPのプロジェクト番号を、firebase functionsコンフィグにセット。
1. BOT URLとなるエンドポイント関数のド頭で、まるっと作ったfunctionに、req.headers.authorizationを渡して評価させてNGなら「botから呼べ！」と怒るコードを書く。


では手順の詳細。

firebase initしたプロジェクトディレクトリ内には、functionsディレクトリが作成されて、その中にindex.jsやpackage.jsonが作られてます。プロジェクトディレクトリ内はfirebaseコマンドを叩く場所。functionsディレクトリ内はFirebase Functionsのパッケージ構成する場所。…としっかり分けて考えましょう。たまに私もnpmコマンドをプロジェクトディレクトリ内で叩いて「うわーまたやっちまったー」と泣いてます。注意しましょう。
functionsディレクトリ内で、手順 １.と手順 4.をいっぺんにやっちゃいます。

```
# npm i google-id-token
# npm i node-fetch
```

まるっとfunctionにしたものは、verifyChatIdToken.jsとでもしておきます。

google-id-tokenのReadme.mdでは、記載されているgetGoogleCerts関数でトークンの検証に使用する署名のURLが`'https://www.googleapis.com/oauth2/v1/certs'` となっています。しかし[Google Chatの開発ドキュメントのJava/Pythonのサンプル][1]を読むと、`'https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com'`が使われていることがわかりますので差し替える必要があります。
これをしないとJWTのデコード自体はできるものの署名による検証結果(isAuthentic)はtrueになりません。


まるっとコピーしてfunctionにしたのち、getGoogleCerts関数はgetChatCertsに名前を変更して、requestはfetchに差し替えてます。またJWTのBearerトークンからデコードした結果から、署名の検証が成功した場合のみtrueを返すようにしたものがこちら。

```js:verifyChatIdToken.js
'use strict'

const functions = require('firebase-functions')
const fetch = require('node-fetch')
const googleIdToken = require('google-id-token')
const PROJECT_NUMBER = functions.config().project.number
const CHAT_SERVICE_ISS = "chat@system.gserviceaccount.com"
const CHAT_SERVICE_CERTS_URI =
  `https://www.googleapis.com/service_accounts/v1/metadata/x509/${CHAT_SERVICE_ISS}`


function getChatCerts(kid, callback) {
  fetch(CHAT_SERVICE_CERTS_URI)
    .then(res => res.json())
    .then(certs => {
      callback(null, certs[kid])
    }).catch(err => {
      callback(err, {})
    })
}

module.exports = async function verifyChatIdToken(authorization) {
  const BEARER_STARTS = "Bearer "

  if (!authorization) {
    console.log("authorization not found")
    return false
  }
  if (!authorization.startsWith(BEARER_STARTS)) {
    console.log(`authorization is not a ${BEARER_STARTS}idtoken`)
    return false
  }
  const idToken = authorization.substring(BEARER_STARTS.length)

  const parser = new googleIdToken({ getKeys: getChatCerts })

  return new Promise((resolve) => {
    parser.decode(idToken, function (err, token) {
      let result = false
      if (err || !token) {
        console.log("error while parsing the google token: " + err)
      } else {
        console.log("parsed id_token is:\n" + JSON.stringify(token))
        if (!token.isAuthentic) {
          console.log("failed verify by signature\n")
        } else {
          result = token.header && token.data &&
            PROJECT_NUMBER == token.data.aud &&
            CHAT_SERVICE_ISS == token.data.iss
        }
      }
      resolve(result)
    })
  })
}
```

それと、PROJECT_NUMBERという定数で、Bot URLとなるFirebase Functionが属するGCPプロジェクトのプロジェクト番号をFirebase Function CONFIGから参照するようにしていますので、手順 5.をやっちゃいます。GCPのプロジェクトダッシュボードでプロジェクトIDに並んでプロジェクト番号が記載されていますのでコピってきて、firebaseプロジェクトディレクトリで設定します。

```
# firebase functions:config:set project.number="{コピってきたプロジェクト番号}"
```

audienceがPROJECT_NUMBER、issuerがCHAT_SERVICE_ISSと一致するかも併せてチェックして、これでChatからの要求確認が十分できました。

これであとは手順 6.のみ。ド頭でチェックしてGoogle Chatから呼ばれていない場合は怒ってあげるだけです。開発ドキュメントにあるように、User-Agentも固有の名前があるので、ついでにチェックします。

```js:index.js

const verifyChatIdToken = require('./verifyChatIdToken')

exports.{ＢＯＴ名} = functions.https.onRequest(async (req, res)=>{
  if (req.method !== 'POST' || !req.body || !req.body.message
        || req.headers["user-agent"] !== "Google-Dynamite"
        || !(await verifyChatIdToken(req.headers.authorization))) {
    res.status(400).send('Hello! This function is meant to be used in a Google Chat Room or DM.\n')
    return
  }
  :
  // ボットの主処理
  :
})
```

以上でございます。
これだけで安心・安全なボットにグレードアップしますよ！ お試しください。


[1]: https://developers.google.com/hangouts/chat/how-tos/bots-develop#verifying_bot_authenticity
[2]: https://www.npmjs.com/package/google-id-token
[^1]: 厳密に言えばMarketplaceに公開できればApps Scriptでもいけますが、公開審査までが大変だし、そもそも開発中にマルチテナントテストができないのがApps ScriptのChat Botの難点だったりします。
[^3]: 同ページからリクエスト送りましたが音沙汰なしです…。