
外部APIの壁「OAuth」を越える。Freee連携で学んだトークン管理のリアル
こんにちは!Accel-Hack(アクセルハック) 開発チームです。
社内AIアシスタント「Airi」の開発プロセスをお届けする開発記ブログ。
第11回目の今回は、Airi が初めて本格的な外部サービスと手を組む節目――人事労務システム「freee」との OAuth 認証連携についてお届けします。
前回までの Vol.10 で、Slack から Airi へ指示を届ける対話基盤は完成しました。
しかし、勤怠や人事の「実データ」を持っているのは Airi ではなく freee です。
Airi が本当に役に立つには、この外部サービスの扉を安全に開ける必要があります。
その扉の鍵となるのが OAuth 2.0 です。
今回は、認証フローの実装と、地味だけれど極めて重要な「トークン管理」の裏側を、泥臭い学びとともにお届けします。
1. なぜパスワードを預かってはいけないのか
freee のデータを取りに行くなら、ユーザーの freee のIDとパスワードを Airi に登録してもらえばいい――そう考えたくなりますが、これは絶対にやってはいけません。
他人のパスワードを預かって保管することは、万が一漏れたときの被害が計り知れず、そもそも freee 側も許していません。
そこで登場するのが OAuth 2.0 という仕組みです。
ざっくり言えば、「パスワードは渡さないけれど、"このアプリが私の代わりにAPIを叩くことを許可します" という許可証(トークン)だけを発行してもらう」やり方です。
ホテルのフロントで部屋の鍵を作ってもらうイメージに近く、マスターキー(パスワード)そのものは渡さずに、必要な範囲だけ開けられるカードキーを受け取ります。
2. 認可コードフローを、ざっくり理解する
OAuth にはいくつか流儀がありますが、Airi が使うのは最も標準的な 「認可コードフロー」 です。
登場人物は3者――ユーザー・Airi・freee。流れはこうです。
ユーザーが「freee と連携する」と Airi に伝える
Airi はユーザーを freee の認可画面へ案内する
ユーザーが freee にログインし「許可」する
freee は Airi に、一時的な認可コードを返す
Airi はその認可コードを、裏側でアクセストークンに交換する
ここで「なぜ一度コードを挟むの? 最初からトークンをくれれば早いのに」と思うかもしれません。
ポイントは安全性です。
ブラウザのURLに載って戻ってくるのは、単体では大した力を持たない短命の「コード」だけ。
本物の鍵であるトークンは、Airi と freee がサーバー同士で直接やり取りするため、ブラウザの履歴やログに漏れにくいのです。
3. 実装:/freee/auth から /freee/callback まで
Airi 側の実装は、Vol.6 で構築した Hono のルーターに、3つのエンドポイントを用意することで実現しています。
/freee/auth/start … 連携の案内画面を表示する
/freee/auth … state を発行し、freee の認可画面へリダイレクトする
/freee/callback … freee から戻ってきたコードを受け取り、トークンに交換する
このなかで地味に重要なのが、/freee/auth での CSRF(なりすまし)対策です。
Airi は連携開始時に、ユーザーIDとランダムな文字列を組み合わせた state を作り、それを暗号的に安全な Cookie に保存します。
// state に「誰の連携か」+「使い捨てのランダム値」を仕込む
const state = `${userId}:${crypto.randomUUID()}`;
setCookie(c, "freee_auth_state", state, {
httpOnly: true, // JS から読めないようにする
secure: true, // HTTPS でのみ送信
sameSite: "Lax",
maxAge: 60 * 10, // 10分で失効
});
return c.redirect(api.freee.auth.getAuthorizationUrl(state));そして /freee/callback に戻ってきたとき、freee が返してきた state と、Cookie に保存しておいた state が一致するかを必ず確認します。
一致しなければ「不正なリクエスト」として弾く。
これにより、第三者が仕込んだ偽のコールバックで勝手に連携させられる、といった攻撃を防ぎます。
この初回連携の流れと、次章のトークン更新を並べたのが下の図です。

4. トークン管理の肝は「持ち歩かない」こと
無事にトークンを手に入れたら、次の悩みは「どこに、どう保管するか」です。
ここで Airi は、トークンの性質に応じて保存先を使い分ける戦略をとりました。
freee のトークンには2種類あります。
実際にAPIを叩くための短命なアクセストークン(約10分で失効)と、それを再発行するための長命なリフレッシュトークン(有効期限90日)です。
Airi では、この2つを次のように振り分けています。
アクセストークン → Vol.7 で紹介した Cloudflare KV に、TTL 10分で保存。頻繁に読み書きする短命データは、高速なKVが最適。
リフレッシュトークン → Vol.8 の D1(Drizzle) に永続保存。長く大切に持つべきデータは、型安全なデータベースへ。
過去記事で整えたインフラの伏線が、ここで綺麗に回収されました。
「短命で高速に使い回すもの」と「長命で堅く守るもの」を役割で分ける。この適材適所が、安全と快適さを両立させる肝でした。
5. ensureFreeeAccessToken:使うたびに“いい感じ”にする
とはいえ、機能を作るたびに「トークンあるかな、切れてないかな、更新しなきゃ」と気を配るのは大変です。
そこで Airi は、ensureFreeeAccessToken という一枚の関数に、その面倒を丸ごと引き受けさせました。
呼ぶだけで、いつでも「使える状態のアクセストークン」が返ってきます。
中の動きはシンプルです。
まずKVを覗き、あればそのまま即利用(数ミリ秒)。
無ければ、D1のリフレッシュトークンを使って freee から新しいアクセストークンを取得し、KVに保存し直します。
呼び出し側は、トークンが生きているか切れているかを一切気にしなくてよくなりました。
そして地味に効いてくるのが、エラーの切り分けです。
リフレッシュが 4xx(トークンが無効・期限切れなど)で失敗 → null を返し、「もう一度連携してください」と促す
5xx やネットワーク障害など一時的な失敗 → 例外として投げ、リトライに委ねる
「ユーザーの再連携が必要な失敗」と「時間を置けば直る失敗」を明確に分けることで、無駄に「連携し直して」と迫ることも、逆に黙って壊れることもなくなります。
連携の完了時や失効時には publishHomeView(Vol.12で詳しく触れます)を呼び、Slack ホームの「連携済み/未連携」表示も自動で最新化しています。
6. おわりに
今回は、Airi が外部サービスと安全に手を組むための OAuth 2.0 認証と、その裏で動くトークン管理の実装を見てきました。
パスワードは預からず許可証だけを受け取り、トークンは役割ごとに保存先を分け、state でなりすましを防ぎ、更新は一枚の関数に任せる。
派手さはありませんが、この堅実な土台があるからこそ、Airi は安心してユーザーの代理人として働けます。
さて、認証で「連携済み」になったことを、ユーザーはどこで確認するのでしょうか。
今回たびたび登場した publishHomeView――その正体こそ、次回のテーマです。
次回は 「Vol.12: ユーザーの入り口を作る『Slack App Home』」 として、Block Kit を使って Slack 内にリッチなダッシュボードを組み立てる方法をお届けします。お楽しみに!
この記事は以下の体制で作成されました。
ドラフト作成・執筆: Airi (社内AIアシスタント)
アイデア出し・レビュー: CTO
デザイン: 事務スタッフ
Accel-Hackでは、テクノロジーを駆使して世の中の課題を解決していく仲間を募集しています。
私たちの取り組みに少しでも興味を持っていただけたら、ぜひ採用ページや会社サイトも覗いてみてくださいね。