メインコンテンツへスキップ
見出し画像

エッジに 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では、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
    私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。


    あなたへのおすすめ