aaif-goose / aaif-goose/goose

OAuth credentials for streamable HTTP extensions erased by concurrent session starts — endless re-auth loop

オープン
#11,021 コメント 2 件 リアクション 0 件 担当者 2 名 @DOsinga に割り当て済み GitHub で見る
主要言語
Rust
スター
54.2k
フォーク
6.2k
平均マージ
3日 4時間
マージ済み PR(30日)
240

説明

## Summary

Stored OAuth credentials for a streamable HTTP extension can be **erased by Goose itself** when multiple sessions start concurrently, producing an endless re-authentication loop: the user completes the vendor's browser auth, and within minutes/hours the browser window opens again. We reproduced this repeatedly with Miro's official MCP (`https://mcp.miro.com/`) on the pinned commit `2694fff7e`.

## Observed behavior (evidence)

Timeline from a single machine, **one goosed process running the entire time** (verified via `ps`):

1. `11:40` — user completes Miro browser auth. Keychain secrets blob (service `goose`, account `secrets`) contains a valid `oauth_creds_miro` entry (client_id + token_response). Verified by reading the blob.
2. `12:12–12:13` — a scheduled automation creates one ACP session plus two sub-agent sessions (session IDs `20260806_86/87/88` in sessions.db, created within 54s of each other). All three eagerly connect enabled extensions, including `miro` (confirmed via the sessions' stored `enabled_extensions.v0` extension_data).
3. `12:14` — the Miro auth page opens in the browser again. The keychain secrets blob now reads `{}` — **all stored OAuth credentials gone**.

This loop repeats on every concurrent-session event. The user in question completed Miro auth 3+ times over two days.

## Mechanism (verified in source at `2694fff7e`)

1. **Every auth-required connect forces a refresh.** `crates/goose/src/oauth/mod.rs` (`oauth_flow`, ~line 93): when `initialize_from_store()` finds stored credentials, it *unconditionally* calls `refresh_token()` — regardless of token expiry.
2. **Any refresh failure erases stored credentials.** Same function (~lines 98–108): on any `refresh_token()` error it calls `credential_store.clear()` and falls back to browser auth. A second clear path exists in `crates/goose/src/agents/extension_manager.rs` (`clear_credentials_on_post_refresh_auth_failure`).
3. **Concurrent connections don't coordinate.** Each session's connection builds its own `AuthorizationManager` + `GooseCredentialStore` (`crates/goose/src/oauth/persist.rs`). N sessions starting together produce N concurrent refresh attempts using the same stored refresh token.
4. **Rotating refresh tokens make the race destructive.** (Inference — consistent with all observed evidence, not provable without vendor logs.) If the authorization server rotates refresh tokens (single-use, common practice), only one concurrent refresh succeeds; the others receive `invalid_grant` → step 2 fires → `clear()` deletes `oauth_creds_`, **wiping the fresh credentials the winning refresh just saved**. Net result: completed auth, then `{}`.

Secondary related fragility, separately verified: all secrets share one JSON blob with read-modify-write on every change (`crates/goose/src/config/base.rs`, `mutate_secrets`), serialized by an in-process mutex only — two goosed processes (e.g. a dev build and a production app) can last-writer-wins clobber each other's saves. We observed this variant too before isolating to a single process.

## Impact

Any user with (a) an installed OAuth-based streamable HTTP extension and (b) anything that creates concurrent sessions — schedules, sub-agents/subrecipes, or simply opening the app while an automation fires — can hit a loop where completed logins are silently destroyed and the browser auth page reappears indefinitely. It presents to users as "this integration never stays logged in."

## Possible directions (non-prescriptive)

- Serialize refresh per extension name (in-process mutex keyed by extension) so concurrent connects share one refresh result.
- Don't clear stored credentials on refresh failures that may be transient/racy (`invalid_grant` from a concurrent rotation, network errors); reserve `clear()` for definitive rejection after a serialized retry.
- Skip the forced refresh when the stored access token is still comfortably valid (rmcp's `get_access_token` already has expiry-aware refresh logic).
- Longer-term: per-key secret entries or cross-process locking for the secrets blob.

---

## Related

- #11084 — Reopening old sessions re-triggers OAuth browser flows for uninstalled MCP extensions. Same symptom family (surprise OAuth browser windows) but a distinct root cause: session resume replays a stale per-session extension snapshot with no reconciliation against installed extensions. The two compound: after this issue wipes `oauth_creds_`, resuming any old chat whose snapshot contains that MCP hits the 401 → browser-auth path even if the user has uninstalled the extension.

コントリビューションガイド

コントリビューションガイドを開く

調査の方向性

問題は crates/goose/src/oauth/mod.rs (oauth_flow) と crates/goose/src/agents/extension_manager.rs にあります。まず refresh_token ロジックと credential_store.clear() の呼び出しを調べてください。並行セッションがどのように管理されているか、また crates/goose/src/config/base.rs (mutate_secrets) の共有 secrets blob がどのように扱われているかを確認してください。Miro MCP 拡張機能を使って再現してください。修正では、refresh を調整し、認証情報をクリアせずに refresh の失敗を処理します。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
rust
領域
authentication, backend
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
45/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。