OpenRouter OAuth PKCE をデスクトップで実装 2026:もうキーを手貼りしない
OpenRouter OAuth PKCE をデスクトップで実装 2026:もうキーを手貼りしない
結論から言うと: デスクトップ AI アプリの最もダサいオンボーディングは「OpenRouter のサイトを開いてキーを作成 → sk-or-… をコピー → アプリに貼り付け」です。OpenRouter は 2024 年から PKCE OAuth フローを用意していますが、公式ドキュメントは主に Web 向け。geminidesktop.app と bibigpt-desktop に組み込む中で 3 つの落とし穴を踏んだあと、全体を 1 つの共通フックに抽出し、ユーザーはワンクリックで認可を終えられるようになりました。キーを見る必要はもうありません。
一行要約: PKCE フロー = Tauri のカスタム deep-link スキーム + localStorage で code_verifier を保存 + コールバックで code → access_token を交換。ユーザーは「キー」という概念を知らなくて済む。
OpenRouter が OAuth を推す理由
以前は openrouter.ai/keys で API キーを作成してクライアントに貼り付けるフローでした。問題は 3 つあります。
- UX が悪い:エンジニア以外の 90% のユーザーは “sk-or-…” を見た瞬間に離脱する
- セキュリティリスク:キーはクリップボードに載り、他のアプリから読まれたりスクショで漏れたりする
- アトリビューションの欠落:OpenRouter は「どのアプリ経由で課金したか」を把握したい(レベニューシェアに影響)
OAuth PKCE は 3 つすべてを解決します。アプリがブラウザを開き、ユーザーが OpenRouter にログインすると、ブラウザがワンタイムコードを返し、アプリが code + verifier で access_token に交換。ユーザー操作はクリック 1 回、キーは画面に現れません。
geminidesktop.app のクローズドベータでキー貼り付けを PKCE に置き換えたところ、7 日目のリテンションが 38% から 71% に改善しました。
踏んだ 3 つの落とし穴
落とし穴 1:コールバック URI の設計
Web なら HTTPS のコールバックで済みますが、デスクトップアプリに公開 URL はありません。候補は 2 つ。
| 方式 | 長所 | 短所 |
|---|---|---|
| localhost loopback(http://127.0.0.1:任意ポート) | OAuth 標準の推奨方式 | ローカル HTTP サーバーが必要、ファイアウォールで弾かれる、Windows Defender が稀に誤検知 |
| カスタムスキーム(geminidesktop://callback) | サーバー不要 | OS にスキーム登録が必要、OpenRouter 側でリダイレクト URI をホワイトリストに追加 |
最終的にカスタムスキームを採用しました。Tauri の tauri-plugin-deep-link は一度登録すれば macOS/Windows/Linux で geminidesktop://callback?code=... を受け取れます。
落とし穴 2:code_verifier をどこに保存するか
PKCE は「ブラウザに飛ぶ → 戻ってくる」の間 verifier を保持する必要があります。試した方法:
- メモリ変数:アプリがバックグラウンドで 2 分放置されると消える
- Tauri store plugin:永続化できるが
Rust <-> JSの同期が面倒 - localStorage:最終採用。シンプルで永続、Webview 間でも共有される
落とし穴 3:トークン期限切れの処理
OpenRouter の access_token は 1 時間、refresh_token は 30 日です。デスクトップでは呼び出し前に能動的に更新しないと、61 分目に呼び出したユーザーが 401 を食らいます。fetch インターセプタで自動更新し、refresh 自体が失敗した場合のみ「再認可してください」モーダルを出します。
Tauri + カスタムスキームの実装
Tauri 2.x なら拍子抜けするほど簡単です。src-tauri/tauri.conf.json に登録:
{
"plugins": {
"deep-link": {
"schemes": ["geminidesktop"]
}
}
}
Rust 側でリスン→Webview へ転送:
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
app.listen("deep-link://new-url", |event| {
window.emit("oauth-callback", event.payload());
});
Ok(())
})
bibigpt-desktop、geminidesktop、aigtd いずれもスキームは <product>://callback。OAuth ロジック本体は共通で、スキーム名だけが違います。
エラーハンドリング 3 つの UX
ケース A:ユーザーが認可を拒否
ブラウザが error=access_denied を返します。アプリは柔らかいカードを表示:「認可がキャンセルされました — sk-or-… を手動で貼ることもできます」。OAuth に戻ることを強制しない。
ケース B:ネットワーク瞬断
code → token の交換は一瞬の不安定さで失敗します。1s / 3s / 9s の指数バックオフで 3 回リトライ。最終失敗時は「ネットワークが不安定です。後で再試行するか、AI Studio キーを使用」と案内。
ケース C:使用中にトークン失効
fetch インターセプタが 401 を受けたら黙ってリフレッシュ。refresh 自体が失敗した(refresh_token が失効・取り消し)場合のみ再認可モーダル。入力中に突然フルスクリーンのログイン画面が現れるのは NG。
3 製品で共有する useOpenRouterOAuth
このロジックはすべて 1 つのフックに収束し、3 製品で共有されています。
bibigpt-desktop:チャットと動画要約のモデル切替- geminidesktop:Menubar Quick Chat とメインのチャットウィンドウ
- aigtd:AI によるタスク分解
公開するフィールドは 4 つだけ:authorize() / signOut() / token / status。内部では PKCE チャレンジの生成、state 検証、deep-link コールバック、トークン更新を全て面倒を見ます。新しいアプリに組み込むのはスキーム設定を 1 行書き換えるだけ。

コアの 10 行
PKCE の本体は verifier と SHA-256 challenge の生成だけ、実際に 10 行程度です:
const verifier = btoa(crypto.getRandomValues(new Uint8Array(32))
.reduce((s, b) => s + String.fromCharCode(b), ''))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
const challenge = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier))
.then(buf => btoa(String.fromCharCode(...new Uint8Array(buf))))
.then(s => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''))
localStorage.setItem('or_pkce_verifier', verifier)
localStorage.setItem('or_pkce_state', crypto.randomUUID())
window.open(`https://openrouter.ai/auth?code_challenge=${challenge}&...`)
コールバック側では state を検証(CSRF 対策)し、verifier を使ってコードをトークンに交換。標準的な OAuth なのでここでは割愛。
なぜ OpenRouter の Web OAuth を使わないか
OpenRouter には openrouter.ai/chat という Web 版があり、そこにユーザーを送ることもできます。ただ実測の UX は deep-link より明らかに劣ります — Web 版は毎回ログイン、refresh_token が取れない、リダイレクトが新タブで開く。Deep-link なら「インストールしたアプリにログイン」という感覚で、セッションも 30 日維持されます。
次のステップ
- バックグラウンドでのトークン更新:現状は呼び出し時に更新。定時更新に変えて初回のレスポンスをさらに短縮
- AI Studio キーへのフォールバック:OpenRouter が 502 を返した際、ユーザーが事前設定した AI Studio キーに自動フォールバックし、会話を止めない
デスクトップ AI 機能の設計ノートは Gemini Mac App に足りない 6 つの機能 でも紹介しています。
よくある質問
Q1: bibigpt-desktop の OAuth コードをそのまま使えますか?
A: はい。スキーム名を自分のアプリに合わせ、OpenRouter の管理画面でリダイレクト URI を追加すれば動きます。PKCE ロジックは変更不要。
Q2: カスタムスキームを他のアプリに乗っ取られませんか?
A: macOS は bundle ID にバインドされるため衝突しません。Windows はレジストリの last-writer-wins で理論上リスクはありますが、本番では遭遇していません。
Q3: Tauri を使わずに同じフローは作れますか?
A: Electron は app.setAsDefaultProtocolClient を提供しており、考え方は同じ。WKWebView/WebView2 のネイティブアプリはプロトコルハンドラを手で登録する必要があり、少し手間は増えますが可能です。
まとめ
OpenRouter OAuth PKCE は新しい技術ではありませんが、「ワンクリックで終わる」デスクトップ体験にするには deep-link、verifier の保存、トークン更新の 3 点をきちんと設計する必要があります。共有フックに落とし込めば、新しいアプリへの組み込みは 20 分の作業です。次に geminidesktop.app を開いてキーを一度も見ずに使えるなら、それはこのフックが裏で動いているからです。