Firebase Authenticationで使うGoogle公開鍵をfaraday-http-cacheで楽にキャッシュする

Firebase Authenticationで自前のバックエンドサーバーを使う
==========

Firebase Authenticationでの認証 ＋ 自前のバックエンドサーバー、という構成にしたい場合、クライアント側から「自分はFirebaseではこういうIDトークンの者です」という情報を送ってあげる必要がありますが、その際に本当に該当ユーザであり、悪意のあるユーザによるリクエストではないことを判別する必要があります。

認証結果として得られるIDトークンはJWTなので、Google側の公開鍵を利用して検証することで、改竄されておらず、Firebase側で認証されたユーザであるということが確認できます。

参考: https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja

SDKの対応状況
--------

さて、上記のIDトークンの検証処理は、一部の言語ではSDKが提供されているため簡単に実装できます。
参考までに、2018/5時点で対応しているのは以下言語です。

- node
- Java
- Python
- Go

しかし、それ以外の言語では、独自で同等の検証処理を実装する必要があります。

### Rubyでやりたい

RubyはSDKが無いため、上記の通り独自で検証する必要があります。

基本的な検証ロジック自体はfirebaseのドキュメントを参考にすれば良いですが、
Google公開鍵の取得頻度についてどうするかは自分で考える必要があります。

### Google公開鍵を毎回取得する

これも場合によっては1つの解ではあるかもしれません。

しかし、例えば認証を前提とするSPAの構築などを想定した場合、
APIを呼ぶたびに都度公開鍵を取得しに行くことになり、オーバヘッドになるかもしれません。

### Google公開鍵をキャッシュする

ドキュメントには以下のような記述があります。

> 最後に、トークンの kid 要件に対応する秘密鍵によって ID トークンが署名されたことを確認します。https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com から公開鍵を取得し、JWT ライブラリを使用して署名を確認します。該当エンドポイントからのレスポンスの Cache-Control ヘッダーに含まれる max-age の値を使用して、公開鍵を更新する時期を確認します。

つまり、max-ageを見たうえでキャッシュすることは想定されているようです。

これであれば必要最低限の取得で済むので良さそうですね。

あとは、どのようにキャッシュするかです。


faraday-http-cache
=================

さて、Rubyにはfaraday-http-cacheというGemが存在します。

https://github.com/plataformatec/faraday-http-cache


これを利用すると、faradayを利用した特定のリクエストのみをキャッシュすることができ、
ActiveSupport::Cacheにも対応しているので、Railsと組み合わせるのも簡単です。

しかも、内部で`max-age`をもとにしたTTLも行ってくれます。

これで完璧！と思いきや...


キャッシュされない
------------

試しに以下のようなコードで公開鍵を取得してみます。

```ruby
client = Faraday.new do |builder|
  builder.use :http_cache, store: Rails.cache, logger: Rails.logger
  builder.adapter Faraday.default_adapter
end

response = client.get(CERTIFICATION_URL)
```

しかし、動かしてみると以下のようなログが出力され、一切キャッシュされません。

```
HTTP Cache: [GET /robot/v1/metadata/x509/securetoken@system.gserviceaccount.com] miss, uncacheable
```

### 原因

公開鍵を取得した際のレスポンスヘッダを見てみると、`must-revalidate` が含まれていることがわかります。
(参考: https://developer.mozilla.org/ja/docs/Web/HTTP/Caching#Cache_validation)

これには、「キャッシュにヒットしても、期限が有効かどうかを確認せずに利用してはならない」という意味合いがあります。

また、`must-revalidate`がある場合には、`ETag`ヘッダーや`Last-Modified`ヘッダと組み合わせた陳腐化の検証を行うことが可能ですが、これらは公開鍵のレスポンスヘッダには含まれていません。

faraday-http-cacheでは、`must-revalidate`が含まれる場合には`ETag`,`Last-Modified`を利用した検証を行うような実装となっているのですが、含まれていない場合には「キャッシュすることが出来ない」という判断を行います。

このため、一切キャッシュされないという動きになっていたようです。

## キャッシュされるようにする

firebaseのドキュメント上には

> 該当エンドポイントからのレスポンスの Cache-Control ヘッダーに含まれる max-age の値を使用して、公開鍵を更新する時期を確認します。

とあるので、`ETag`,`Last-Modified`ではなく、`max-age`を元にしてキャッシュすることは特に問題なさそうです。

というわけで、やや強引ですが、レスポンスから強制的に`must-revalidate`を削り落とすFaradayのMiddlewareを挟むことでキャッシュ可能となるようにしてあげます。


```ruby
class IgnoreMustRevalidate < Faraday::Middleware
  def initialize(app, *args)
    super(app)
  end

  def call(env)
    dup.call!(env)
  end

  def call!(env)
    response = @app.call(env)
    # faraday-http-cacheでキャッシュさせるためにmust-revalidateをヘッダから落とす
    response.headers["cache-control"] = response.headers["cache-control"].gsub(/must-revalidate(, )?/, "")
    response
  end
end
```

```ruby
client = Faraday.new do |builder|
  builder.use :http_cache, store: Rails.cache, logger: Rails.logger
  # これを足す
  builder.use IgnoreMustRevalidate
  builder.adapter Faraday.default_adapter
end

response = client.get(CERTIFICATION_URL)
```

これで再実行してみます。


```
HTTP Cache: [GET /robot/v1/metadata/x509/securetoken@system.gserviceaccount.com] miss, store
```

ちゃんとキャッシュされているようです！

再度リクエストを投げてみると..

```
HTTP Cache: [GET /robot/v1/metadata/x509/securetoken@system.gserviceaccount.com] fresh
```

2回目以降のリクエストではキャッシュが利用されていますね！


まとめ
======

というわけで、自分でキャッシュを頑張りたくないために、
結果として余計なところで頑張った結果をまとめてみました。

SPAで認証だけ楽にしたい〜〜！
というときにFirebaseAuthenticationだけ使うと便利ぽいので、積極的に試していきたい。