okadato の雑記帳

スタートアップでSREとしてはたらくokadatoの雑記です。

JWT、完全に理解した / Firebase と G Suite をつなぐサービスアカウントの挙動を整理

今回の記事は完全に自分のための備忘録です。
GCP の認証スキームのひとつであるサービスアカウントの認証・認可まわりがなかなか理解し難かったため、整理のために投稿します。


Firebase の Cloud Functions 経由で Google Spreadsheet を編集することが目的でした。そのうえで、実際に採用した手順を紹介します。



サービスアカウントの使用

目的を一段階具体的に書くと、 Cloud Functions で作成したアプリケーションから Spreadsheet の Cloud API サービスを実行し、スプレッドシートに行を追加すること です。
そのためには Spreadsheet 側の Cloud API サービスに対する認証が必要であることがわかりました(そりゃそうだ。ぼくのスプレッドシートに対して誰からも勝手に書き込まれたら困るもんね)


そこで今回採用したのがサービスアカウントという認証スキームです。主に以下のページを参考にしました。
人間が作業するためのアカウントがユーザアカウント、サービスが作業するためのアカウントがサービスアカウント、という理解です。わかりやすい。
結論からいうと、以下の手順にしたがって設定すれば問題なく動作はします。


記事中での手順を要約すると、


1. GCP のコンソールからサービスアカウントを作成
2. 該当サービスアカウントの認証情報を含むJSONファイルをダウンロード(credentials.json)
3. APIキーを作成
4. APIキーをダウンロード(api_key.json)
5. Spreadsheet 側で、作成したサービスアカウントに対して編集権限を付与
6. JSONファイルから作成した JWT と APIキー を使用し、Cloud API を呼び出す



That's all です。作業内容として難しいことはほぼほぼありません。
問題は裏側で何をしているのか?というところで、JWTやAPIキーがなんのために存在するのか というのを整理します。



立ちはだかる双璧

今回ぼくが理解するうえで時間を要したのには、以下にあげる2つの理由がありました。


1. 登場する要素の関係性を誤認していた

2. JWTとAPIキーの役割の差異を理解できていなかった


それぞれの内容を説明します。



問題1. 登場する要素の関係性を誤認していた


今回は OAuth 2.0 x JWT を使用するので、以下のドキュメントを特に参考にしました。



記事の中から認証実施時のシーケンス図を抜粋します。


f:id:okadato623:20191129012735p:plain
シーケンス図


今回の場合、上図での Server AppCloud Functions で、Google ServersSpreadsheet にあたるものかと誤解していました(というのも以下の getJwt という関数がいかにも JWT を生成しているように見受けられたので… )


ところが以下の関数では user_id にあたる client_emailpassword にあたる private_key、認可範囲を指定する "https://www.googleapis.com/auth/spreadsheets" を指定し、POST用フォーマットに整形しているだけでした。


function getJwt() {
var credentials = require("./credentials.json");
return new google.auth.JWT(
credentials.client_email,
null,
credentials.private_key,
["https://www.googleapis.com/auth/spreadsheets"]
);
}


これにより該当の client_email を持つサービスアカウントは Spreadsheet に対する読み書きの権限を得られることになります。

developers.google.com


つまり以下のようなシーケンスを描くものと想定していたのですが…




f:id:okadato623:20191129013356p:plain
勝手に描いていた想像図




実態としては以下のように、Cloud Functions が処理開始のトリガー・裏側に Google Servers が存在することとなり、登場する要素が3つに増えることが明らかになりました。




f:id:okadato623:20191129013450p:plain
実際のシーケンス図




以下のコード中に auth: jwt と指定することで Sheets API のなかで暗黙的に JWT token が生成され、その token を使用して Cloud API を呼び出しているのでした。


const sheets = google.sheets({ version: "v4" });
sheets.spreadsheets.values.append(
{
spreadsheetId: spreadsheetId,
range: range,
auth: jwt,
key: apiKey,
valueInputOption: "USER_ENTERED",
resource: { values: [row] }
}


これは getJwt という関数名がよくない(他責)
実際には JWT を生成するのではなく、JWT の生成に必要な オブジェクトを作成していただけだったのです。アーメン。


秘密鍵をあえて不正な値にして認証を試みたときに、Sheets API の呼び出し場所で下記のエラーが発生していることから判明しました。


Error: error:0D07209B:asn1 encoding routines:ASN1_get_object:too long
    at Sign.sign (crypto.js:331:26)
    at Object.sign (/srv/node_modules/jwa/index.js:152:45)
    at Object.jwsSign [as sign] (/srv/node_modules/jws/lib/sign-stream.js:32:24)
    at GoogleToken.requestToken (/srv/node_modules/google-auth-library/node_modules/gtoken/build/src/index.js:196:31)
    at GoogleToken.getTokenAsync (/srv/node_modules/google-auth-library/node_modules/gtoken/build/src/index.js:135:21)
    at GoogleToken.getToken (/srv/node_modules/google-auth-library/node_modules/gtoken/build/src/index.js:77:21)
    at JWT.refreshTokenNoCache (/srv/node_modules/google-auth-library/build/src/auth/jwtclient.js:132:36)
    at JWT.refreshToken (/srv/node_modules/google-auth-library/build/src/auth/oauth2client.js:148:24)
    at JWT.getRequestMetadataAsync (/srv/node_modules/google-auth-library/build/src/auth/oauth2client.js:264:28)
    at JWT.getRequestMetadataAsync (/srv/node_modules/google-auth-library/build/src/auth/jwtclient.js:79:26)


わかりやすくマッピングできそうだからといって、安直に飛びついてはいけないですね。良い教訓となりました。





問題2. JWTとAPIキーの役割の差異を理解できていなかった

JSONファイルから生成される JWT 単体でも動作可能なのですが、理解を困難にしていた一因として APIキー の存在がありました。


API キーの役割は上記の記事中にある通り、


  • プロジェクトの識別
  • プロジェクトの承認


の2種類です。
わかりやすく言い換えると、認証は JWT 側で行い APIキーでは 操作可能な Cloud API の範囲に制限を設ける のです。


f:id:okadato623:20191129014127p:plain
APIキーを使用して、Spreadsheet にのみ操作権限を付与


上記の画像では Spreadsheet にのみ読み書きの権限を付与しています。 これにより仮に認証情報が漏えいしたとしても、権限を付与した Spreadsheet 以外への閲覧・編集はできず、被害を最小範囲に留められるというわけです。


いっぽうで、APIキーのみを Cloud API に渡した場合には、以下のようなエラーが出力されました。


Error: Login Required.
    at Gaxios._request (/srv/node_modules/google-auth-library/node_modules/gaxios/build/src/gaxios.js:85:23)
    at <anonymous>
    at process._tickDomainCallback (internal/process/next_tick.js:229:7)


ログインが必要ということで、やはり認証が正常に行われていない模様。
というわけで、 JWT による認証は必須、APIキーによるアクセス制限はアディショナルで設定可能という認識は正しそうです。


これは上記の記事中でも以下のように説明されていました。


・If the request requires authorization (such as a request for an individual's private data), then the application must provide an OAuth 2.0 token with the request. The application may also provide the API key, but it doesn't have to.

・If the request doesn't require authorization (such as a request for public data), then the application must provide either the API key or an OAuth 2.0 token, or both—whatever option is most convenient for you.


簡潔にまとめると、OAuth 2.0 用の JWT を使用する際には APIキーは必須ではないものの、認証情報を使用しない場合には APIキーのみをアプリケーションに渡す、でもアクセスは可能であるという挙動で認識は正しそうです 👌




さいごに

ブログを書き始めてから、今回はじめて純粋に自分のためのメモを残してみました。
実際に集めた情報を整理・アウトプットしていく中で理解の浅さが浮き彫りになり、良い経験になりました 💮


Firebase / GCP はほとんど趣味の範囲でしか使用したことがないため、記載内容には誤りがある可能性が大いにあります。もしお気づきの点がございましたらコメントなどいただけると幸いです。


JWT の運用まわりは良くも悪くも隠蔽されており正確に理解できている自信がないので、もう少し抽象度の高いレベルでも自分の理解を整理したいと思っています。
また FIrebase と G Suite が気軽に連携できれば、バックエンドなしに作れるWebサービスの幅がかなり広がるはずなので、引き続き技術調査や新しい機能へのキャッチアップは続けていきます。
Firebase、もっともっと使いこなしていくぞ!!! 💪