OAuth credentials for streamable HTTP extensions erased by concurrent session starts — endless re-auth loop
- Dominant language
- Rust
- Stars
- 54.2k
- Forks
- 6.2k
- Avg merge
- 3d 2h
- Merged PRs (30d)
- 262
Description
## 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.
Contributor guide
Assessment
This issue has not been assessed yet.