【C#】Firebase Admin SDKなしでFCMのHTTP v1 APIでプッシュ送信してみた

# はじめに
スマートフォンなどへプッシュ送信のテストがコンソール画面からお手軽にできるFCM（Firebase Cloud Messaging）ですが、サーバー側もテスト実装したい！となった場合、**Firebase Admin SDK**を使用すると、これまたお手軽に.NET + C#でWindowsPCでも作れちゃいます。

https://firebase.google.com/docs/admin/setup

ところが、組込機器をサーバーにしてプッシュ送信したい、となるとFirebase Admin SDKが適用できるようなリッチ環境ではないですよね。でも、HTTPSならしゃべれるから**HTTP v1 API**を使えばできんじゃね？というのが本件の背景です。
開発環境はVisual Studio 2022の.NET6 + C#ですが、組込みでも暗号化ライブラリがあればいけます。

# 手順
公式ドキュメントに書いてはありますが、ざっくり以下にまとめ。

https://developers.google.com/identity/protocols/oauth2/service-account#httprest

1. JSON WebToken（JWT）を作成する
1. Google OAuth2.0アクセストークンをリクエストする
1. JSON応答からOAuth2.0アクセストークンを抽出する
1. App Instance Tokenをtopicに紐づける（Subscribed）※必要なら
1. App instance Tokenをtopicから削除する（Unsubscribed）※必要なら
1. topicにメッセージを送信する

ちなみに**HTTP v1 API**を使用しているのは 6. だけです。token宛てにメッセージ送信するなら 6. だけでいいんですけど、topic宛てにメッセージ送信するとなると 4.（Subscribed）と 5.（Unsubscribed）が必要で**レガシーHTTP API**を使用するしかなさそうです。
GoogleではレガシーHTTP APIからHTTP v1 APIへの移行を推奨しているのに、レガシーHTTP APIでしかtopic登録削除できないなんてどうなのこれ。
なお、Firebase Admin SDKを使えばもちろんHTTP v1 APIでtopic登録削除ができます。

# JSON WebToken（JWT）を作成する
公式ドキュメントのまんまです。がんばって作っていきまっしょい。
```
JWT = {Base64url encoded header}.{Base64url encoded claim set}.{Base64url encoded signature}
```
- header = {"alg":"RS256","typ":"JWT"}
- claim set = {
"iss": "FCMプロジェクトのFirebaseサービスアカウント",
"scope": "https://www.googleapis.com/auth/firebase.messaging",
"aud": "https://oauth2.googleapis.com/token",
"exp": 有効期限（最大値は発行時刻から1時間後）,
"iat": 発行時刻のUNIX時間
}
- signature = {header}.{claim set}のSHA256ハッシュをRSASSA-PKCS1-v1_5秘密鍵で署名

scopeはfirebase.messagingを設定してね。
Base64urlエンコードはBase64エンコードをパディング（=）なしでURLアンセーフ文字（ + と / ）をURLセーフ文字（ - と _ ）に置き換えしたもの。
headerとclaim setはこんなかんじになります。
```C#
var headerStr = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
var headerBytes = Encoding.UTF8.GetBytes(headerStr);
var headerBase64url = Convert.ToBase64String(headerBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var ts = DateTimeOffset.Now.ToUnixTimeSeconds();
var claimStr = "{\"iss\":\"firebase-adminsdk-****@プロジェクト名.iam.gserviceaccount.com\"," +
               "\"scope\":\"https://www.googleapis.com/auth/firebase.messaging\"," +
               "\"aud\":\"https://oauth2.googleapis.com/token\"," +
              $"\"exp\":{ts+60*60}," +
              $"\"iat\":{ts}" +
               "}";
var claimBytes = Encoding.UTF8.GetBytes(claimStr);
var claimBase64url = Convert.ToBase64String(claimBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
```
あとはsignatureです。
署名生成にあたり.NETでRSACryptoServiceProviderにRSA秘密鍵をセットしたいんですが、これXML形式しか読み込まねぇという素敵仕様なんです。仕方がないので下記サイトで手動変換しましょう。

https://superdry.apphb.com/tools/online-rsa-key-converter

RSA秘密鍵はFCMコンソールから [プロジェクトの設定] > [サービスアカウント] > [新しい秘密鍵の生成] でダウンロードしたjsonファイル内の "private_key" です。
これがPEM形式なのでXML形式にコンバートします。文字列から \n を削除して -----BEGIN PRIVATE KEY----- と -----END PRIVATE KEY----- だけ改行しておけばサイト側でマーカーとして認識するので、PEM to XMLにコピペしてConvert。
変換出力された文字列をRSACryptoServiceProviderに放り込みましょう。
```C#
var xml = "XML形式の秘密鍵文字列";
var rsa = new RSACryptoServiceProvider();
rsa.FromXmlString(xml);
var token = headerBase64url + "." + claimBase64url;
var tokenBytes = Encoding.UTF8.GetBytes(token);
var tokenHash = SHA256.Create().ComputeHash(tokenBytes);
var rsaFormatter = new RSAPKCS1SignatureFormatter(rsa);
rsaFormatter.SetHashAlgorithm("SHA256");
var signBytes = rsaFormatter.CreateSignature(tokenHash);
var signBase64url = Convert.ToBase64String(signBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var jwt = token + "." + signBase64url;
```
JWTさえ作ればあとは簡単です。

# Google OAuth2.0アクセストークンをリクエストする
JWTをPOSTしましょう。とりあえずこんなかんじで。
```C#
var httpClient = new HttpClient();
var request = new HttpRequestMessage();
request.Method = HttpMethod.Post;
request.RequestUri = new Uri("https://oauth2.googleapis.com/token");
var contentBody = "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + jwt;
request.Content = new StringContent(contentBody);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
var response = httpClient.Send(request);
```

# JSON応答からOAuth2.0アクセストークンを抽出する
responseからアクセストークンゲット。
```C#
if (response.IsSuccessStatusCode) {
    var resStr = response.Content.ReadAsStringAsync().Result;
    var keyStr = "\"access_token\":";
    var startIndex = resStr.IndexOf(keyStr);
    var endIndex = resStr.IndexOf(',', startIndex);
    accessToken = resStr.Substring(startIndex + keyStr.Length, endIndex - startIndex - keyStr.Length).Replace("\"", string.Empty);
}
else {
    Debug.WriteLine($"アクセストークンの取得に失敗しました... {response.StatusCode}");
    Debug.WriteLine($"{response.Content.ReadAsStringAsync().Result}");
}
```

# App Instance Tokenをtopicに紐づける／削除する
topicのSubscribed/Unsubscribedは、レガシーHTTP APIでしかアクセスする方法が見つかりませんでした。レガシーHTTP APIではサーバーキーで認証するため、最大１時間有限のOAuth2.0アクセストークン認証より漏洩リスク大ですがどうすれば。たすけてドラえもん。

https://developers.google.com/instance-id/reference/server#manage_relationship_maps_for_multiple_app_instances

ドラえもんは助けてくれないので、レガシーHTTP APIを使うことにしましょう。サーバーキーを使って対象のtokenをPOSTしてあげればいいので、こんなかんじ。足りない部分は適宜補完を。
削除もURI以外は同じ。
```C#
List<string> registrationTokens = new List<string>();

registrationTokens.Clear();
/* フォームから対象のtoken文字列をセット */
for (var i = 0; i < TOKEN_NUM; i++) {
    if (checkBoxes[i].Checked) {
        var token = textBoxes[i].Text;
        if (token != String.Empty)
            registrationTokens.Add(token);
    }
}

if (registrationTokens.Any()) {
    var httpClient = new HttpClient();
    var request = new HttpRequestMessage();
    request.Method = HttpMethod.Post;
    request.RequestUri = new Uri("https://iid.googleapis.com/iid/v1:batchAdd");
    request.Headers.TryAddWithoutValidation("Authorization", "key=" + serverKey);
    var contentBody = "{" +
                     $"\"to\": \"/topics/{TOPIC}\"," +
                      "\"registration_tokens\": [";
    foreach(var token in registrationTokens) {
        contentBody = contentBody + "\"" + token.ToString() + "\",";
    }
    contentBody = contentBody + "],}";
    request.Content = new StringContent(contentBody);
    request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
    var response = httpClient.Send(request);
}
```

# topicにメッセージを送信する
OAuth2.0アクセストークンを使ってPOSTします。

https://firebase.google.com/docs/cloud-messaging/migrate-v1

```C#
var httpClient = new HttpClient();
var request = new HttpRequestMessage();
request.Method = HttpMethod.Post;
request.RequestUri = new Uri("https://fcm.googleapis.com/v1/projects/my-push-test-notification/messages:send");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + accessToken);
var contentBody = "{ 公式ドキュメントに沿って送りたい内容を書いてください }";
request.Content = new StringContent(contentBody);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
var response = httpClient.Send(request);
```

# まとめ
こんなかんじなので、組込機器からでもSHA256ハッシュの生成とRSA秘密鍵でのPKCS#1 v1.5署名さえできればFCMでプッシュ送信できます。本来はFirebase Admin SDKを使うべきなんでしょうけれども。
どなたかの参考になれば幸いです。