CLI認証、正しいやり方
(abgeo.dev)- 多くのCLIは、ノートPCのローカルブラウザではすぐ終わる localhost OAuthリダイレクト をデフォルトで使うが、SSH・コンテナ・WSLのような開発環境ではその前提が崩れ、ログインフローが止まる
- 現在の方式は、CLIが
127.0.0.1に一時的なHTTPサーバーを立て、ブラウザを認証URLへ送り、認証プロバイダーが authorization code をローカルのcallbackへ返す構造になっている - 2019年に標準化された RFC 8628 Device Authorization Grant は、トークンを要求するCLIと、ユーザーが認証するブラウザ端末を分離し、ポートバインドやローカルブラウザ依存をなくす
- Device flow は
device_code,user_code,verification_uri,intervalを受け取り、/tokenを定期的にポーリングしながら、authorization_pending,slow_down,access_denied,expired_tokenといった標準状態を処理する - 新しいCLIなら、device flow をデフォルトにし、
.well-known/openid-configurationでエンドポイントを見つけ、refresh token は~/.configのJSONファイルではなく OSのキーチェーン に保存すべき
localhostリダイレクトが前提としているもの
- 一般的なCLIログインは、ローカルHTTPサーバー とシステムブラウザが同じマシン上にあるという前提で動いている
- CLIが
127.0.0.1の特定ポートにHTTPサーバーをバインドする - システムブラウザでOAuth authorization endpointを開き、
redirect_uri=http://127.0.0.1:<port>/callbackを含める - ユーザーがログインすると、認証プロバイダーが authorization code を loopback URL に
302リダイレクトする - CLIの小さなHTTPサーバーが code を読み取り、token endpoint でトークンに交換する
- たいていは PKCE が付いており、その後「このタブは閉じても構いません」というページが表示される
- CLIが
gcloud auth login,wrangler login, 以前のvercel login、その他多くのベンダー製CLIがこの方式を使っている- Wrangler は
8976ポートを使う - gcloud は
8085を使う - Claude Code は実行のたびに一時ポートを確保する
- Wrangler は
- RFC 8252 は、ネイティブアプリでブラウザがある場合にこのパターンを推奨しているが、ホストにブラウザがない場合の扱いは扱っていない
ユーザーがlocalhostの段階をうまく認識できない理由
- localhost callback はごく短時間で通過するため、ほとんどのユーザーは目にしない
- CLIが出力するURLは長く、その中に redirect URI がクエリ文字列として入っている
- ユーザーは認証プロバイダーの実際のドメインでログインし、承認する
- 認証プロバイダーはブラウザを localhost callback に送り、CLIに code を読ませたあと、洗練された “signed in” ページへ移動させる
- 表面的には「WebサイトでログインしたらCLIが認証された」ように見えるが、実際には ローカルHTTPサーバーとブラウザの共存 がフローを支えている
SSH・コンテナ・WSLで壊れる箇所
- フロー全体は、CLIが動くマシンとブラウザが動くマシンが同じ という前提に依存している
- SSHセッションでは、リモートホストにブラウザがなく、
xdg-openが失敗したり、X forwarding環境で見えないリモートブラウザを開いてしまうことがある- callbackポートをノートPCへトンネルできる場合はあるが、認証プロバイダーに登録された redirect URI が、そのトンネル越しのポートを許可している必要がある
- コンテナにはブラウザがなく、多くのイメージには
xdg-openやopenすらない-pで callbackポートを公開することはできるが、CLIがどのポートを使うかを事前に知る必要がある- Cloudflare CLI では、この問題で詰まったユーザーの issue が続いている
- WSLではブラウザはWindows側で開き、loopback server はLinux側で動く
- WSL2のポートフォワーディングはたいてい動くが、常にそうとは限らない
- 共有ボックスでは、同じマシン上の別プロセスが
/proc/net/tcpから listening port を見つけたり、既知のポートを先にバインドしようと競合したりできる- PKCE は code exchange を保護するが、リダイレクト自体の authenticated session は保護しない
fallbackがすでに示している設計上の問題
- loopbackフローをデフォルトで提供するCLIは、壊れたときのための fallback も一緒に提供している
- gcloud には
--no-launch-browserがある - Wrangler は停止してしまい、受け入れられている workaround は、2つ目のターミナルで localhost URL を直接 curl する方法
- Anthropic の
claudeは “Paste code here if prompted” と表示して待機する - こうした fallback は、実質的には 手動device flow であり、CLIが実際に使われる環境でデフォルトフローが動かないから存在している
RFC 8628 Device Authorization Grant
- RFC 8628 は、2019年に “input-constrained devices” 向けに策定された OAuth 2.0 Device Authorization Grant
- TV、コンソール、CLIが対象に含まれる
- トークンを要求するデバイスと、ユーザーが認証するデバイスを分離することが中核となる
- CLIは認証プロバイダーの
device_authorization_endpointにPOSTする- 例では
client_id=my-cli&scope=openid+offline_accessを送る
- 例では
- 認証プロバイダーは、次の値を含むJSONを返す
device_codeuser_codeverification_uriverification_uri_completeexpires_ininterval
- CLIはURLと短いコードを表示し、可能なら
verification_uri_completeのQRコードも表示する - ユーザーは好きな端末でURLを開いてログインし、要求されたscopeとclient nameを確認し、CLIに表示された短いコードと一致していることを確かめたうえで承認する
ポーリングと標準状態の処理
- CLIは token endpoint を
interval秒ごとにポーリングする - grant type には
urn:ietf:params:oauth:grant-type:device_codeを使う - RFC 8628 section 3.5 は次の状態を定義している
authorization_pending: ユーザーの承認待ちslow_down: 認証プロバイダーがポーリング間隔を遅くするよう求める状態で、仕様では interval を最低5秒増やすよう明記しているaccess_denied: ユーザーが拒否した状態expired_token: 長く待ちすぎてトークンが期限切れになった状態
- device flow では、CLIはポートをバインドせず、実行ホストにブラウザがあることも前提にしない
- 同じログイン方式が、ノートPC、コンテナ、人の承認待ちが必要なCIジョブで動作する
ポーリングコストとエンドポイント検出
- デフォルトのポーリング interval は5秒
- 認証の大半は1分以内に終わるため、一般的なログインでは
/tokenに10回前後ポーリングして終了する - サーバーは
slow_downによって interval を伸ばせるため、適切に実装されたclientはそれに従う必要がある - pending login ごとに stateful endpoint へWebSocketやSSE接続を維持する方式に比べると、
/tokenへの stateless polling のほうがシンプルで安価 - 認証プロバイダーが OpenID Connect Discovery をサポートしていれば、CLIは
.well-known/openid-configurationからdevice_authorization_endpointとtoken_endpointを取得でき、URLのハードコードを避けられる
device flowのフィッシングリスク
- device flow には、攻撃者が本物の認証プロバイダーの
device_authorization_endpointを呼び出してuser_codeとdevice_codeを取得し、被害者に入力させる攻撃がある - 被害者は本物のURLで本物のコードを使ってログインし、本物の consent screen を承認してしまう可能性がある
- 攻撃者は自分が生成した
device_codeで/tokenをポーリングし続け、access token を受け取る - ロシア系のthreat actorは、2024年8月以降、M365 tenant を標的にこのキャンペーンを実施している
- Microsoft Threat Intelligence はこれを Storm-2372 として追跡している
- Volexity は APT29/Midnight Blizzard と attribution している
- 政府、防衛、NGOの tenant が複数の大陸で影響を受けている
フィッシング防御は認証プロバイダーの責任
- フィッシング防御はCLIではなく、認証プロバイダー 側で行うべき
- 必要な緩和策は次のとおり
- 短い
user_codeの有効期限 - verification page で client name とリクエスト元を目立つように表示すること
- code入力試行への rate limiting
verification_uri_completeを公開せず、被害者がリンクをクリックするのではなくコードを手入力するようにすること- 価値の高い tenant では、known network や known device でない場合に device code flow をブロックする conditional access policy
- 短い
- CLIの役割は、仕様に従い、安易なショートカットを作らないこと
- device flow は local attack surface を social attack surface に置き換えるが、より多くの環境で動作するフローを提供し、認証プロバイダー側の mitigation を活用できる点で、こちらのほうが適切
Go実装例の中核フロー
- 実装全体はGoの
net/httpだけでおよそ30行に収まる - 実装の流れは次のとおり
client_idとscopeを入れてDeviceAuthorizationEndpointにhttp.PostFormを呼ぶ- 応答JSONから
DeviceCode,UserCode,VerificationURIComplete,Intervalをデコードする - ユーザーに
VerificationURICompleteとUserCodeを表示する TokenEndpointにdevice_code,client_id, device grant type を入れて繰り返しPOSTするauthorization_pendingなら待機を続けるslow_downなら interval を5秒増やす- error がなければ
access_tokenとrefresh_tokenを返す - それ以外の error は失敗として扱う
- Keycloak realm で “OAuth 2.0 Device Authorization Grant” capability を有効にするか、この grant をサポートする OpenID-certified provider を使えば、device-flow login は動作する
新しいCLIのデフォルトにすべき方式
- デフォルトは device flow にすべき
.well-known/openid-configurationでエンドポイントを見つけ、URLをハードコードしないようにすべきintervalとslow_downは必ず守るべき- refresh token は
~/.config配下のJSONファイルではなく、OSのキーチェーンに保存すべき - ノートPCでの素早いログインのために loopback path を提供したいなら、
--webフラグの後ろに置き、デフォルトにすべきではない
すでに移行したCLIと、まだ残るツール群
- device flow をデフォルトにしているCLIがある
gh auth loginは最初から device flow を使っており、オープンソースの中でも最もクリーンな reference implementation と評価されているaws sso loginは IAM Identity Center に対して end-to-end で device flow を実行するvercel loginは2025年9月に RFC 8628へ移行 し、email-based login と以前の--oobフラグを置き換えた- Stripe CLI は RFC 8628 そのものではないが、UXをうまく実装した pairing-code flow を採用している
- 依然として loopback flow をデフォルトにし、paste-the-code fallback を付けているツールもある
- Google
gcloud - Cloudflare
wrangler - Anthropic
claude
- Google
- CLIがノートPCの外へ出るたびに手動の paste-the-code fallback が必要になるなら、その fallback をデフォルトフローとして提供するほうが正しい
1件のコメント
Lobste.rs の意見
表現はやや雑だが興味深い。デバイスコード/リンクを1分ごとに交換すれば、フィッシングへの悪用も減らせそうだ。
一度使われた後はローテーションを止め、そのセッションをIPやブラウザに紐付けておけばよい。
Microsoft のようにユーザーがコードを直接入力するプロバイダーなら、ランディングページが案内文を表示してコードをクリップボードにコピーさせ、さらにフィッシングに引っかかりやすくすることもできる。
良い記事だし、みんながRFC 8628の方向へ移るべきだという点には同意する。
リモート開発マシンで CLI の OAuth 手順をあまりに頻繁に踏まされるので、
xdg-openをフックし、ポートを自動転送して劣悪なユーザー体験をごまかす個人用ツールを作った: https://github.com/phinze/bankshot興味深い。最近ちょうど「古い」認証方式であるRFC 8252を実装したのだが、「新しい」方式である RFC 8628 は知らなかった。
主なユースケースが Google サーバー認証だったために、知識の抜けが生じたようだ。私が RFC 8628 フローだと思っている文書には次のように明記されている。
Google のスコープ制約は、OIDC がややこしく顔を出す部分だ。理想的には、Google はアクセストークンに押し込む代わりに ID トークンを返すべきだが、それは Google の OAuth 設定の問題であって、8628 自体の性質ではない。
OAuth の果てしない複雑さはここから来ている。標準は認可の仕組みをどう作り、どう受け渡すかの枠組みはうまく定義しているが、それが何であるべきかについては意図的に沈黙している。「大半」のプロバイダーが同意する共通の HTTP エンドポイント群を得るのにさえ、OIDC の発明と何年もの時間が必要だった。
もうひとつのハックは、サーバーのxdg-open 呼び出しをノートPCへ転送することだ。個人インフラ向けにそれをやってくれる小さなツールを作った: https://github.com/zimbatm/subportal/
2つのアプローチを組み合わせることはできないのだろうか。
localhostURL にリダイレクトしてhelloを返すようにし、クライアントがhelloを受け取れなければ CLI に URL を表示する方式だ。同時に、サーバーが送った
helloへの応答を受け取れなければ、ブラウザにコードを表示して「ログインを試みているか確認してください」のようなメッセージを見せればよい。Google のように、スマホで選ぶ数字を表示する形にして、もっと簡単にすることもできる。利点は、2番目のケースでも人はリンクのクリックは簡単にする一方で、OTP/コードの共有は比較的しにくいこと、そして攻撃者が攻撃中ずっとソーシャルエンジニアリングで介入し続けなければならないことだ。
ローカルマシンでうまく動くときは対話が不要なので、デフォルトはブラウザベースのフローであってほしい。