はじめに
スマートフォンなどへプッシュ送信のテストがコンソール画面からお手軽にできるFCM(Firebase Cloud Messaging)ですが、サーバー側もテスト実装したい!となった場合、Firebase Admin SDKを使用すると、これまたお手軽に.NET + C#でWindowsPCでも作れちゃいます。
ところが、組込機器をサーバーにしてプッシュ送信したい、となるとFirebase Admin SDKが適用できるようなリッチ環境ではないですよね。でも、HTTPSならしゃべれるからHTTP v1 APIを使えばできんじゃね?というのが本件の背景です。
開発環境はVisual Studio 2022の.NET6 + C#ですが、組込みでも暗号化ライブラリがあればいけます。
手順
公式ドキュメントに書いてはありますが、ざっくり以下にまとめ。
- JSON WebToken(JWT)を作成する
- Google OAuth2.0アクセストークンをリクエストする
- JSON応答からOAuth2.0アクセストークンを抽出する
- App Instance Tokenをtopicに紐づける(Subscribed)※必要なら
- App instance Tokenをtopicから削除する(Unsubscribed)※必要なら
- 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はこんなかんじになります。
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形式しか読み込まねぇという素敵仕様なんです。仕方がないので下記サイトで手動変換しましょう。
RSA秘密鍵はFCMコンソールから [プロジェクトの設定] > [サービスアカウント] > [新しい秘密鍵の生成] でダウンロードしたjsonファイル内の "private_key" です。
これがPEM形式なのでXML形式にコンバートします。文字列から \n を削除して -----BEGIN PRIVATE KEY----- と -----END PRIVATE KEY----- だけ改行しておけばサイト側でマーカーとして認識するので、PEM to XMLにコピペしてConvert。
変換出力された文字列をRSACryptoServiceProviderに放り込みましょう。
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しましょう。とりあえずこんなかんじで。
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からアクセストークンゲット。
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ではサーバーキーで認証するため、最大1時間有限のOAuth2.0アクセストークン認証より漏洩リスク大ですがどうすれば。たすけてドラえもん。
ドラえもんは助けてくれないので、レガシーHTTP APIを使うことにしましょう。サーバーキーを使って対象のtokenをPOSTしてあげればいいので、こんなかんじ。足りない部分は適宜補完を。
削除もURI以外は同じ。
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します。
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を使うべきなんでしょうけれども。
どなたかの参考になれば幸いです。