はじめに
gh が GitHub に繋がらない。手元で動かしている AI コーディングエージェントが作業の途中で止まり、認証を求めてきた。
見覚えがある。3日前にも同じことをやった。
$ gh auth status
X Failed to log in to github.com account USER (default)
- The token in default is invalid.
渋々ブラウザを開いて認証を通す。直る。作業は続けられる。
……が、おかしい。3日で切れるトークンなんてあるか?
その違和感で調べ始めた結果がこの記事になる。結論を先に書く。
このトークンは一度も失効していなかった。
GitHub 側ではずっと有効なままで、失効したように見えていたのは完全に手元の事情だった。そして gh auth status は、その事情を invalid という言葉で報告する。つまり、エラーメッセージが誤診を誘導していた。
さらに「3日周期」も錯覚だった。時間は最初から一切関係なく、しかも毎回の「修理」そのものが、次の再発を仕込んでいた。
前提: 家でも触るし、リモートからも触る
この記事の症状は、同じマシンを2通りの入り方で使っているときに出る。先に自分の使い方を書いておく。
- 普段は家で、マシンの前に座って普通のターミナルから作業する
- ただし軽い作業のときはリモートから入る。ちょっとした修正をして
git pushして終わり、みたいなやつ。外出先からだったり、別の部屋の端末からだったりする - リモート接続は OpenSSH。つまり
sshd配下のセッションで作業することになる
環境:
- Windows 11 (build 10.0.29648)
- GitHub CLI 2.97.0
この「普段はローカル、たまにリモート」という混在が、症状を分かりにくくしていた張本人だった。
というのも、この構成だと壊れ方が間欠的に見える。家で触っているぶんには何も起きない。リモートから入ったときだけ落ちる。しかもリモートで作業するのは「軽い作業のとき」なので、間隔が空くし、そのとき自分が普段と違う入り方をしているという意識も薄い。
結果として、頭の中では「時々トークンが切れる」という時間の問題として記録されてしまう。実際には時間は一切関係なく、入り口が違うだけだったのに。
原因が「経過時間」ではなく「接続方法」だと気づくまで、期限切れの線をずっと疑い続けることになった。同じ罠は、開発機をサーバーに置いて手元から繋ぐ人や、AI コーディングエージェントを常駐させてリモートで動かしている人も踏みうると思う。
先に、疑って外したもの
読者もおそらく同じ順で疑うので、潰し方だけ先に置いておく。
| 疑ったもの | 確かめ方 | 結果 |
|---|---|---|
| SSL インスペクション / 証明書の差し替え | 証明書チェーンをライブで検証し、指紋を保存済みルートと突き合わせる | 健全。無関係だった |
| トークンの期限切れ |
gh の device flow が取る gho_ は既定では失効しない。8時間で切れるのはアプリ側で "Expire user authorization tokens" を有効にした場合のみ |
該当せず |
| 別マシンでの二重ログイン | 2台目で gh auth login しても1台目は無効化されない。古いトークンが revoke されず残るのは、むしろ改善要望として issue が立っている |
該当せず |
最後の1つだけ例外がある。同一の (ユーザー, アプリ, スコープ) の組でトークンが10個を超えると、古い方から自動的に revoke される。環境をまたいで gh auth login / gh auth refresh を繰り返していると到達しうるので、心当たりがあるならここは潰しておく価値がある。今回はこれでもなかった。
分岐点は gh auth token だった
決め手になった観測はこれ。
$ gh auth token
(何も出力されない)
空だった。
ここが分岐点になる。もしトークンが期限切れや revoke 済みなら、トークン自体は手元にあって、GitHub がそれを拒否するという状態になるはずだ。gh auth token はトークンを表示し、API 呼び出しの側が 401 になる。
ところが実際には何も出てこない。つまり gh はトークンを取り出せていない。GitHub まで到達すらしていない。
裏付けとして、未認証扱いのレート上限が返ってきていた。
$ gh api rate_limit
("limit": 60)
認証済みなら 5000。60 は「そもそも認証情報を送っていない」ときの値だ。
これは認証エラーではなく、可視性の問題だった。
真因: セキュアストレージが対話セッションを前提にしていた
gh は 2023年2月以降、トークンを OS のキーリングに保存する。Windows では wincred ── つまり資格情報マネージャだ。
そして資格情報マネージャ経由の読み出しは、対話的なデスクトップセッションを前提にしている。
自分のプロセスがどのセッションで動いているか辿ってみる。
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$PID"
for ($i=0; $i -lt 6 -and $p; $i++) {
"{0,-6} {1,-16} sess={2}" -f $p.ProcessId, $p.Name, $p.SessionId
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.ParentProcessId)" -ErrorAction SilentlyContinue
}
21864 pwsh.exe sess=0
32116 <開発ツール> sess=0
31572 pwsh.exe sess=0
32236 conhost.exe sess=0
23256 sshd.exe sess=0
26304 sshd.exe sess=0
sshd.exe 配下で、SessionId は全部 0。Session 0 は Windows のサービス用セッションで、対話的なデスクトップを持たない。
一方、このトークンを最初に保存したときは、マシンの前に座って普通のターミナルから gh auth login を叩いていた。そちらは対話セッションだ。
起きていたことを並べるとこうなる。
- 対話セッションで
gh auth login→ トークンが資格情報マネージャに入る(当然ここでは動く) - 作業を SSH 経由に切り替える
- SSH セッション(Session 0)からは、そのエントリを読み出せない
- gh は「トークンが取り出せない」状態を
The token in default is invalid.と報告する
トークンは GitHub 側でずっと有効なまま。セッションの種類が変わっただけで、見えなくなった。
「DPAPI が壊れた」ではない
紛らわしいので書いておくと、DPAPI そのものは SSH セッションでも普通に動く。
Add-Type -AssemblyName System.Security
$b = [Text.Encoding]::UTF8.GetBytes('probe')
$e = [Security.Cryptography.ProtectedData]::Protect($b, $null, 'CurrentUser')
[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($e, $null, 'CurrentUser'))
# => probe
ラウンドトリップは成功する。だから「DPAPI が使えない環境だから」という切り分けは間違いになる。使えなくなっているのは資格情報マネージャ経由の読み出しの方だ。
なぜ「3日で切れる」ように見えたのか
ここまでで「セッションが違うと読めない」ことは分かった。だがそれだけだと、なぜ周期的に再発するのかが説明できない。一度リモートで壊れたなら、ずっと壊れたままのはずだ。
犯人は、毎回の「修理」の方だった。
invalid を見たとき、自分は家のマシンの前で gh auth login を叩き直していた。そこは対話セッションなので、キーリングへの書き込みが成功してしまう。
- リモートで作業する → 資格情報マネージャが読めず
invalid - 家で再ログインする → 成功する。トークンが資格情報マネージャに戻る
- しばらく家で作業する → 何も起きない
- また軽い作業をリモートでやる → 1 に戻る
「3日」というのは、再ログインしてから次にリモートで作業するまでの間隔でしかなかった。トークンの寿命ではない。
修理そのものが次の故障を仕込んでいた。だから何度直しても直らないし、直した本人には「また切れた」としか見えない。
一番効いた証拠: 平文で保存されていたこと
ここが今回いちばん面白かったところ。
SSH セッションから gh auth login をやり直したあと、保存先を確認したらこうなっていた。
$ grep -c "oauth_token: gho_" "$APPDATA/GitHub CLI/hosts.yml"
1
hosts.yml に平文でトークンが入っている。--insecure-storage は付けていない。にもかかわらず平文になった。
これは gh の既知の挙動で、キーリングが使えないと、フラグ無しでも黙って平文ファイルにフォールバックする。「キーリングが使えないなら黙って平文に落とさずエラーにすべきだ」という issue が立っている。
つまり、こう言える。
平文で保存されていること自体が、キーリングが使えなかったことの証拠になる。
原因を事後に確定できる観測点として、これはかなり強い。同じ症状を踏んだら、まず hosts.yml に oauth_token が入っているかを見ればいい。入っていたら、その環境ではキーリングが機能していない。
一般化: 認証の失敗には2種類ある
一段抽象化すると、今回の教訓はこうなる。
認証の失敗には、性質の違う2つがある。
| 拒否された (rejected) | 読み出せない (unreadable) | |
|---|---|---|
| 何が起きたか | サーバーが資格情報を蹴った | 資格情報を手元で取り出せない |
| トークンは | 手元にある | 手元に無い / 読めない |
| サーバーは | 401 を返した | そもそも呼ばれていない |
| 原因の所在 | リモート側(失効・revoke・スコープ不足) | ローカル側(保管庫・権限・セッション種別) |
| 効く対処 | 再発行・スコープ追加 | 保管庫を読める状態にする |
問題は、多くの CLI が後者を前者の言葉で報告することだ。「invalid」「authentication failed」「unauthorized」はどれもリモート側の話に聞こえる。だから人は再ログインを繰り返す。そして再ログインは(その場では成功するので)一時的に効いてしまい、誤った因果が強化される。
だから最初に見るべきはサーバーの応答ではない。
資格情報を、手元で取り出せるか。
gh なら gh auth token。値が出るか空か、それだけで問題がどちら側にあるかが決まる。空なら、GitHub のことはいったん忘れて保管庫の話をすればいい。この一手を最初に入れていれば、回り道は要らなかった。
実務: 平文保存が「正解」になる逆転
対処を書くと身も蓋もない。平文のまま使う。
SSH セッションからログインし直すと自動的に平文へフォールバックするので、その状態を維持する。これがこの構成で git push が通る唯一の状態になる。セキュリティ的に劣るはずの設定が、可用性の唯一解になるという逆転が起きている。
運用上の注意を整理しておく。
-
使う分には問題ない。
hosts.ymlはセッション種別を問わず読めるので、ローカルのコンソールからでも SSH からでも同じトークンで動く -
壊れるのはログインし直したときだけ。ローカルのコンソールから素の
gh auth loginを叩くと、対話セッションなのでキーリング書き込みが成功してしまう。トークンが資格情報マネージャ側へ移動し、SSH からの push が再び全滅する -
ローカルから再ログインするときは
gh auth login --insecure-storageを明示する。これで平文のまま維持され、両方の環境で動く状態が保たれる
平文であることのリスクは実在する。そのファイルを読める者はトークンを読める。緩和策としては、スコープを必要最小限に絞る、定期的に gh auth refresh で回す、そもそも長命トークンを置かず都度発行する仕組みへ寄せる、あたりが現実的。
「動くから」で放置するのではなく、トレードオフを理解した上で意図的に選んでいる状態にしておきたい。
正直な限界
1点、確定できていないことがある。
元の資格情報マネージャのエントリが今も残っているかは確認していない。残っていれば「revoke されておらず、見えなかっただけ」が完全に確定する。消えていれば、別の理由(前述の10個ルールなど)で revoke された可能性が残る。
ただ、現在は平文保存になっていて、平文は「セッション種別のせいで読めなくなる」ことがない。したがって:
- この先また
invalidが出たら → サーバー側 revoke が実在した - 出ないまま使い続けられたら → 「見えなかっただけ」で確定
どちらに転んでも対処は変わらないので、時間が勝手に切り分けてくれる状態にして放置している。
また、この症状について日本語で書かれた記事を探したが見つけられなかった。部品(gh の平文保存問題、資格情報マネージャがリモート接続で効かない話)は個別に存在するものの、繋げて書かれたものは見当たらなかった、という程度の話で、「存在しない」とまでは言えない。
おわりに
エラーメッセージは症状であって、診断ではない。
「トークンが無効です」と言われたとき、それがサーバーに拒否されたのか、手元で読めていないのかは、メッセージからは区別できない。だが対処は正反対だ。前者は再発行、後者は保管庫の修理で、片方をやっている限りもう片方は永久に直らない。
リモートのマシンで開発する構成はこれからも増える。手元で叩けば動くのに SSH や自動化経由だと認証まわりだけ落ちる、という現象を見たら、まずセッションの種類を疑ってみてほしい。
参考
- cli/cli #7757 — gh auth login writes oauth_token to hosts.yaml even without passing --insecure-storage
- cli/cli #10108 —
gh auth loginshould error if the system keyring is unavailable rather than falling back to insecure storage - cli/cli #9233 — Invalidate previous OAuth token when a new one is generated with
gh auth - GitHub Docs — Token expiration and revocation