FCMでInstance ID APIをRubyから利用する際の認証方法

スマートフォンアプリ向けのプッシュ通知配信に便利なFCMだがRuby実装のSDKが存在しない。

プッシュ通知送信でFCMを使う機会があり調査していたものの、トピックを使ったプッシュ通知の一斉配信をする際に、それまでのFCM API認証とは異なる認証を利用する必要が出てきた。
プッシュ通知を送るだけなのに2種類の認証を実装しないといけなくなってしまった。

gemも fcmandpush の2つを読んだが、いずれも古いバージョンのAPIと認証方式(※後述)を利用していてあまり使いたくなかった。

FCMのドキュメントによるとLegacy APIからHTTP v1 APIに移行が推奨されているため、自分で実装してみることにした。

Migrate from legacy HTTP to HTTP v1  |  Firebase

今回のユースケースではHTTP v1 APIを使った単発のプッシュ通知配信のほか、トピックを使った一斉配信も実現したかったためInstance ID APIも使うことになったが、ここの認証でつまづいた。

Instance ID API  |  Google Developers

HTTP v1 APIとInstance ID APIの認証方式の違い

実装を始めてから気付いたが、2種類のAPIで認証方式が違った。

HTTP v1 API

プッシュ通知を送信する際に利用するAPI
HTTP v1 APIの認証は先述のドキュメントでも触れられているように、OAuth2.0の認証トークンを利用することが推奨されている。

require 'googleauth'
require 'faraday'

authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
  json_key_io: File.open('fcm-your-secret-key.json'),
  scope: 'https://www.googleapis.com/auth/firebase.messaging',
)
access_token = authorizer.fetch_access_token!

conn = Faraday.new(url: 'https://fcm.googleapis.com/v1/projects/$YOUR_PROJECT_ID/messages:send')
response = conn.post do |req|
    req.headers['Content-Type'] = 'application/json'
    req.headers['Authorization'] = "Bearer #{access_token['access_token']}" # Bearer Tokenとして取得したトークンを埋める

    req.body = ...
end

Instance ID API

対してトピック操作などで利用する Instance ID API は Legacy API でサポートされているサーバーキーを使った認証を行う。
サーバーキーはFCMコンソールの設定画面から取得可能。

server_key = 'YOUR_SEVER_KEY'

conn = Faraday.new(url: 'https://iid.googleapis.com/iid/v1:batchAdd')
response = conn.post do |req|
    req.headers['Content-Type'] = 'application/json'
    req.headers['Authorization'] = "key=#{server_key}"

    req.body = {
      to: '/topics/your_topic',
      registration_tokens: ['$YOUR_FCM_TOKENS']
    }.to_json
end

公式SDKでの認証

公式SDKのドキュメントではHTTP v1 APIで用いる秘密鍵 + OAuth2.0のトークンを利用した認証方法しか記載していないにもかかわらず
Instance ID APIを用いるはずの SubscribeToTopic メソッドが実装されている。

https://godoc.org/firebase.google.com/go/messaging#Client.SubscribeToTopic

HTTP v1 APIhttps://iid.googleapis.com/iid/v1:batchAdd に相当するエンドポイントが実は存在するのかと思ったが
SDKの実装を読むと Instance ID API を利用していることがわかった。

https://github.com/firebase/firebase-admin-go/blob/cef91acd46f2fc5d0b3408d8154a0005db5bdb0b/messaging/topic_mgt.go#L27-L31

サーバーキーを渡している形跡がないにもかかわらず、なぜ Instance ID API が利用できるのかわからなかったので、さらに読み進めると見覚えのないHTTP Headerを設定している箇所を見つけた。

func newIIDClient(hc *http.Client) *iidClient {
    client := internal.WithDefaultRetryConfig(hc)
    client.CreateErrFn = handleIIDError
    client.Opts = []internal.HTTPOption{internal.WithHeader("access_token_auth", "true")}
    return &iidClient{
        iidEndpoint: iidEndpoint,
        httpClient:  client,
    }
}

https://github.com/firebase/firebase-admin-go/blob/cef91acd46f2fc5d0b3408d8154a0005db5bdb0b/messaging/topic_mgt.go#L69

SDKではBearer Tokenに加えて access_token_auth: true なHeaderを付与していた。
公式ドキュメントでは見たことがないヘッダーだったので site:developers.google.com "access_token_auth"ググるなどしてそれらしき記述を探したが、見つけることはできなかった。
隠れ機能なんだろうと思うことにしている。

Rubyで公式SDK流の認証を試してみる

まずは access_token_auth: true なしでBearer Tokenを使った認証を試す。
Instance ID APIではサポートされていないはずの認証なので 401 Unauthorized が返ってくる。

require 'googleauth'
require 'faraday'

authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
  json_key_io: File.open('fcm-your-secret-key.json'),
  scope: 'https://www.googleapis.com/auth/firebase.messaging',
)
access_token = authorizer.fetch_access_token!

conn = Faraday.new(url: 'https://iid.googleapis.com/iid/v1:batchAdd')
response = conn.post do |req|
    req.headers['Content-Type'] = 'application/json'
    req.headers['Authorization'] = "Bearer #{access_token['access_token']}" # Bearer Tokenとして取得したトークンを埋める

    req.body = {
      to: '/topics/your_topic',
      registration_tokens: ['$YOUR_FCM_TOKENS']
    }.to_json
end

p response.body

=> "<HTML>\n<HEAD>\n<TITLE>Unauthorized</TITLE>\n</HEAD>\n<BODY BGCOLOR=\"#FFFFFF\" TEXT=\"#000000\">\n<H1>Unauthorized</H1>\n<H2>Error 401</H2>\n</BODY>\n</HTML>\n"

今度は access_token_auth: true を付与してリクエストしてみると...

require 'googleauth'
require 'faraday'

authorizer = Google::Auth::ServiceAccountCredentials.make_creds(
  json_key_io: File.open('fcm-your-secret-key.json'),
  scope: 'https://www.googleapis.com/auth/firebase.messaging',
)
access_token = authorizer.fetch_access_token!

conn = Faraday.new(url: 'https://iid.googleapis.com/iid/v1:batchAdd')
response = conn.post do |req|
    req.headers['Content-Type'] = 'application/json'
    req.headers['access_token_auth'] = 'true'
    req.headers['Authorization'] = "Bearer #{access_token['access_token']}" # Bearer Tokenとして取得したトークンを埋める

    req.body = {
      to: '/topics/your_topic',
      registration_tokens: ['$YOUR_FCM_TOKENS']
    }.to_json
end

p response.body

=> {:results=>[{}]}

正常にリクエストが通った。

まとめ

ドキュメントには存在しない認証方式がInstance ID APIには存在することがわかった。
この記事で検証したとおりSDKで使われている方法で実装することも可能だが、公式に言及されているわけではないので気軽に使うとある日突然痛い目を見るかもしれない。

公式でRuby SDKを提供してくれれば全て解決する話なので、Googleさん色々忙しいとは思うんですがそこをなんとか頼んます。