1 ポイント 投稿者 GN⁺ 4 시간 전 | 1件のコメント | WhatsAppで共有
  • 多くの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 が付いており、その後「このタブは閉じても構いません」というページが表示される
  • gcloud auth login, wrangler login, 以前の vercel login、その他多くのベンダー製CLIがこの方式を使っている
    • Wrangler は 8976 ポートを使う
    • gcloud は 8085 を使う
    • Claude Code は実行のたびに一時ポートを確保する
  • 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-openopen すらない
    • -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_code
    • user_code
    • verification_uri
    • verification_uri_complete
    • expires_in
    • interval
  • 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_endpointtoken_endpoint を取得でき、URLのハードコードを避けられる

device flowのフィッシングリスク

  • device flow には、攻撃者が本物の認証プロバイダーの device_authorization_endpoint を呼び出して user_codedevice_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_idscope を入れて DeviceAuthorizationEndpointhttp.PostForm を呼ぶ
    • 応答JSONから DeviceCode, UserCode, VerificationURIComplete, Interval をデコードする
    • ユーザーに VerificationURICompleteUserCode を表示する
    • TokenEndpointdevice_code, client_id, device grant type を入れて繰り返しPOSTする
    • authorization_pending なら待機を続ける
    • slow_down なら interval を5秒増やす
    • error がなければ access_tokenrefresh_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をハードコードしないようにすべき
  • intervalslow_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
  • CLIがノートPCの外へ出るたびに手動の paste-the-code fallback が必要になるなら、その fallback をデフォルトフローとして提供するほうが正しい

1件のコメント

 
GN⁺ 4 시간 전
Lobste.rs の意見
  • 表現はやや雑だが興味深い。デバイスコード/リンクを1分ごとに交換すれば、フィッシングへの悪用も減らせそうだ。
    一度使われた後はローテーションを止め、そのセッションをIPやブラウザに紐付けておけばよい。

    • この方法は、記事で言うほど大きな助けにはならない。ユーザーが来たらフローを開始し、すぐに正規プロバイダーへリダイレクトするフィッシング用ランディングページを作るのはかなり簡単だ。
      Microsoft のようにユーザーがコードを直接入力するプロバイダーなら、ランディングページが案内文を表示してコードをクリップボードにコピーさせ、さらにフィッシングに引っかかりやすくすることもできる。
  • 良い記事だし、みんながRFC 8628の方向へ移るべきだという点には同意する。
    リモート開発マシンで CLI の OAuth 手順をあまりに頻繁に踏まされるので、xdg-open をフックし、ポートを自動転送して劣悪なユーザー体験をごまかす個人用ツールを作った: https://github.com/phinze/bankshot

  • 興味深い。最近ちょうど「古い」認証方式であるRFC 8252を実装したのだが、「新しい」方式である RFC 8628 は知らなかった。
    主なユースケースが Google サーバー認証だったために、知識の抜けが生じたようだ。私が RFC 8628 フローだと思っている文書には次のように明記されている

    Alternatives

    If you are writing an app for a platform such as Android, iOS, macOS, Linux, or Windows (including the Universal Windows Platform), that has access to the browser and full input capabilities, use the OAuth 2.0 flow for mobile and desktop applications. (You should use that flow even if your app is a command-line tool without a graphical interface.)
    なので、そのままRFC 8252 フローだけを読んで実装した。私のツールはたしかに CLI だが、ユースケースがローカル専用なので SSH やコンテナ環境は考慮していなかった。
    さらに RFC 8628 フローでは、Google が制限付き OAuth 2.0 スコープしか許可していないため、一部のアプリケーションでは致命的な制約になりうる。

    • 些細な訂正だが、元の番号を確認し直したらRFC 8628だった。
      Google のスコープ制約は、OIDC がややこしく顔を出す部分だ。理想的には、Google はアクセストークンに押し込む代わりに ID トークンを返すべきだが、それは Google の OAuth 設定の問題であって、8628 自体の性質ではない。
      OAuth の果てしない複雑さはここから来ている。標準は認可の仕組みをどう作り、どう受け渡すかの枠組みはうまく定義しているが、それが何であるべきかについては意図的に沈黙している。「大半」のプロバイダーが同意する共通の HTTP エンドポイント群を得るのにさえ、OIDC の発明と何年もの時間が必要だった。
  • もうひとつのハックは、サーバーのxdg-open 呼び出しをノートPCへ転送することだ。個人インフラ向けにそれをやってくれる小さなツールを作った: https://github.com/zimbatm/subportal/

  • 2つのアプローチを組み合わせることはできないのだろうか。localhost URL にリダイレクトして hello を返すようにし、クライアントが hello を受け取れなければ CLI に URL を表示する方式だ。
    同時に、サーバーが送った hello への応答を受け取れなければ、ブラウザにコードを表示して「ログインを試みているか確認してください」のようなメッセージを見せればよい。Google のように、スマホで選ぶ数字を表示する形にして、もっと簡単にすることもできる。

    cli -> server/auth?r=localhost&fallback_choices=10,20,30  
    server -> localhost/hello
    
    Case 1: hello request received, go to redirect URI on localhost  
    Case 2: server has not received a hello reply, client has not received a hello request
    - CLI displays a/the webpage url and prompts for selecting a fallback_choice
    - Webpage displays a number say `20` from choices
      - Warn in the webpage not to share this code
    - User enters/selects it on the CLI
      - solves the token copy/paste problem if choices  
    

    利点は、2番目のケースでも人はリンクのクリックは簡単にする一方で、OTP/コードの共有は比較的しにくいこと、そして攻撃者が攻撃中ずっとソーシャルエンジニアリングで介入し続けなければならないことだ。

  • ローカルマシンでうまく動くときは対話が不要なので、デフォルトはブラウザベースのフローであってほしい。

    • このフローも、うまく動く場合にはブラウザベースで動作する。ただ、失敗したときにより良い代替経路がある。