
エッジに Node は無い。Workers で Google サービスアカウント認証を“素手で”実装した話
こんにちは!Accel-Hack (アクセルハック)開発チームです。
社内AIアシスタント「Airi」の開発プロセスをお届けする開発記ブログ。
第19回目の今回から、連載は新しい章に入ります。
これまで Airi は freee や Slack と手を組んできましたが、ここからは Google の世界――カレンダーやメールとの連携に挑みます。
その第一歩として、今回は避けて通れない認証基盤、Google サービスアカウントの実装をお届けします。
結論から言うと、ここで私たちは「ライブラリに任せればいい」という常識が通じない壁にぶつかりました。
舞台が Cloudflare Workers というエッジ環境だからです。
今回は、その壁をどう越えたかの記録です。
1. なぜ「サービスアカウント」なのか
Google の API を叩く認証方式はいくつかありますが、Airi が選んだのは サービスアカウントです。
これは、特定の個人になりかわってログインするのではなく、「Airi という“プログラム用の利用者”」として Google にアクセスする仕組みです。
社内の共有カレンダーを参照するような用途では、「誰か一人の権限」を借りるより、「アプリ専用のアカウント」の権限で動くほうが自然で、管理もしやすくなります。
サービスアカウントを作ると、Google から鍵のペアが入った JSON ファイルが渡されます。
この鍵を使って「私は正規の Airi です」と証明し、API を叩くための通行証(アクセストークン)を得る――これが今回作る土台です。
2. 鍵はコードに置かない:Secret 注入と Zod 検証
まず大前提として、この鍵(秘密鍵)は、絶対にコードやリポジトリに置きません。
漏れれば Airi になりすまされてしまう、最重要の機密だからです。
Airi では、この JSON を GOOGLE_SERVICE_ACCOUNT_JSON という Secret(秘匿環境変数) として注入し、受け取った文字列を config/google.ts で検証・パースしています。
export const googleConfigSchema = z
.object({ serviceAccountJson: z.string().optional() })
.transform((val, ctx) => {
if (!val.serviceAccountJson) return null;
const creds = JSON.parse(val.serviceAccountJson);
if (!creds.client_email || !creds.private_key) {
ctx.addIssue({
code: "custom",
message: "missing client_email or private_key",
});
return z.NEVER;
}
return {
clientEmail: creds.client_email as string,
// 環境変数で \\n に化けた改行を、本物の改行へ戻す
privateKey: (creds.private_key as string).replace(/\\n/g, "\n"),
};
});ここで Vol.8 でも活躍した Zod が効いています。
client_email と private_key が揃っているかを起動時に検証し、欠けていればその場で弾く。
「鍵の入れ忘れ」に本番で気づく、といった事故を防げます。
そして地味に重要なのが、最後の replace(/\\n/g, "\n") です。
秘密鍵は複数行のテキストですが、環境変数に入れると改行が \n という2文字の文字列に化けてしまいます。
これを本物の改行に戻してやらないと、後の署名がまるごと失敗します。
ここは実際にハマった、小さいけれど痛いポイントでした。
3. エッジに Node は無い:Web Crypto で“素手で”署名する
さて、本題です。
サービスアカウント認証の流れは、「JWT という電子的な引換券を作り、秘密鍵で署名し、それをアクセストークンに交換する」というもの。
多くの環境では、この署名を専用ライブラリが一発でやってくれます。
ところが、Airi が動く Vol.7 の Cloudflare Workers には、Node.js の crypto モジュールがありません。
いつものライブラリがそのままでは使えないのです。
そこで頼ったのが、ブラウザにも備わる標準API Web Crypto(crypto.subtle) です。
秘密鍵(PEM形式)を読み込み、RS256(RSA + SHA-256)で署名を、いわば“素手で”組み立てます。
const key = await importPrivateKey(privateKey); // PEM を CryptoKey に変換
const signatureBuffer = await crypto.subtle.sign(
{ name: "RSASSA-PKCS1-v1_5" },
key,
new TextEncoder().encode(dataToSign), // header.payload を署名対象に
);
const jwt = `${dataToSign}.${encodeBase64Url(signatureBuffer)}`;JWT の各パートをつなぐ Base64URL エンコードも、+ / = を置換する関数を自前で用意しました。
ライブラリなら一行で済む部分ですが、標準APIだけで組み上げると、「JWT とは結局こういうデータなのか」と仕組みそのものが手に取るように分かります。
制約が、かえって理解を深めてくれました。
4. JWT を access_token に交換する
署名まで終われば、あとは Google に引き換えてもらうだけです。
iss(=サービスアカウントのメール)、欲しい権限を示す scope、宛先 aud、有効期限 exp(1時間)を詰めた JWT を、トークンエンドポイントへ送ります。
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
const data = (await response.json()) as { access_token: string };これで手に入った access_token を使えば、以降は Google のAPI(たとえばカレンダー)を叩けるようになります。
ここまでの一連の流れを図にすると、次のようになります。

5. 学び:制約が、理解を連れてくる
今回いちばんの学びは、「エッジ環境という制約が、むしろ認証の中身を理解させてくれた」ことです。
ライブラリがそのまま使えないからこそ、「JWT を作って署名して交換する」という認証の本質を、自分の手でなぞることになりました。
同時に、鍵は Secret に閉じ込め、設定は Zod で検証する――というセキュアな環境変数管理の“型”も、この回で固まりました。
この土台があるからこそ、次回以降、安心して Google の各サービスへ手を伸ばしていけます。
6. おわりに
今回は、Google 連携の足場となる サービスアカウント認証を、エッジ環境で一から組み上げる話をお届けしました。
鍵は Secret へ、検証は Zod で、署名は Web Crypto で。
派手さはありませんが、この地味な土台こそが、この先の便利機能すべてを支えます。
さて、認証の通行証が手に入りました。
次回はそれを使って、いよいよ実用的な機能を作ります。
「Vol.20: 「今日いつ空いてる?」をSlackで一発!予定ではなく“すき間”を返すcalendar機能」 として、/airi calendar コマンドで空き予定を照会する仕組みをお届けします。
お楽しみに!
この記事は以下の体制で作成されました。
ドラフト作成・執筆: Airi (社内AIアシスタント)
アイデア出し・レビュー: CTO
デザイン: 事務スタッフ
Accel-Hackでは、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。