Skip to content

[RFC] feat(oauth): proactive token refresh to avoid re-auth on every session - #8386

Merged
DOsinga merged 4 commits into
aaif-goose:mainfrom
vincenzopalazzo:fix/oauth-proactive-refresh
May 14, 2026
Merged

[RFC] feat(oauth): proactive token refresh to avoid re-auth on every session#8386
DOsinga merged 4 commits into
aaif-goose:mainfrom
vincenzopalazzo:fix/oauth-proactive-refresh

Conversation

@vincenzopalazzo

@vincenzopalazzo vincenzopalazzo commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Every time a new chat session starts, goose creates a fresh ExtensionManager that reconnects to all configured MCP extensions. For OAuth-protected servers (e.g. RUbe), the connection logic always attempts an unauthenticated request first, receives a 401, and then triggers the full browser-based OAuth flow — even when valid refresh tokens are already stored in the keyring.

This means users must re-authenticate via the browser on every single new chat, making OAuth MCP servers impractical to use.

Root Cause

The connection flow was:

New Session → ExtensionManager → connect (no auth) → 401 → oauth_flow() → browser popup

The oauth_flow() function already had the logic to load stored credentials and silently refresh tokens, but it was only ever called after getting a 401 rejection — never proactively.

Fix

Added a credential check before the unauthenticated connection attempt:

New Session → has_stored_credentials? 
  → YES: oauth_flow() → silent refresh → connect (authed) ✅
  → NO:  connect (no auth) → 401 → oauth_flow() → browser popup (same as before)

If the proactive refresh fails (e.g., network issue), it gracefully falls back to the existing unauthenticated → 401 → browser flow. No new failure modes.

Changes

  1. oauth/persist.rs — Added has_stored_credentials() to check if OAuth credentials exist for an extension
  2. extension_manager.rs — Before connecting, check for stored credentials and attempt silent refresh
  3. extension_manager.rs — Extracted connect_with_auth() helper to eliminate duplicated auth client construction (was in 2 places)
  4. oauth/mod.rs — Added diagnostic logging to surface refresh outcomes and token metadata

Diagnostic Logging

The new logging will tell you exactly what is happening:

Log Meaning
Token refresh succeeded - has_refresh_token: true Silent refresh works, no browser needed ✅
Token refresh succeeded - has_refresh_token: false Access token refreshed, but no refresh token in response
Token refresh failed: No refresh token available Server did not issue a refresh token — browser auth required every time
No stored credentials found First time connecting to this server

Important Caveat

This fix is necessary but may not be sufficient for all OAuth MCP servers. The silent refresh only works if the server issues refresh tokens (long-lived tokens that can be exchanged for new access tokens). Some servers may only issue short-lived access tokens without refresh tokens — in that case, the diagnostic logging will show has_refresh_token: false and we will know a different approach is needed (e.g., persisting the full authorization session state, or background token refresh).

Testing

  • cargo build -p goose
  • cargo clippy --all-targets -- -D warnings ✅ zero warnings
  • cargo fmt
  • cargo test -p goose -- extension_manager

@vincenzopalazzo vincenzopalazzo changed the title feat(oauth): proactive token refresh to avoid re-auth on every session [RFC] feat(oauth): proactive token refresh to avoid re-auth on every session Apr 7, 2026

@DOsinga DOsinga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @vincenzopalazzo — nice idea here! The proactive token refresh to skip the unnecessary 401 → browser cycle is a real UX improvement. The connect_with_auth extraction is a clean DRY win too. A few things to tighten up before this is ready:

Logging is a bit heavy. There are 8 new log lines and several just narrate what the code is about to do (e.g. "Stored credentials found, attempting proactive token refresh", "No stored credentials found, starting browser OAuth flow"). The debug! block that logs has_refresh_token, expires_in, and scopes after browser auth is diagnostic info that's useful during development but not something we want in production. I'd keep the warn! on refresh failure (that's genuinely useful) and drop most of the rest.

has_stored_credentials duplicates existing logic. It reaches directly into Config::global() to do the same thing GooseCredentialStore::load() already does. Could you reuse the existing CredentialStore trait instead? Something like instantiating a GooseCredentialStore and calling .load().await — that way if the storage mechanism ever changes, there's only one place to update.

Looking forward to the next iteration!

@DOsinga DOsinga added the needs_human label to set when a robot looks at a PR and can't handle it label Apr 8, 2026
vincenzopalazzo added a commit to vincenzopalazzo/goose that referenced this pull request Apr 8, 2026
…ate credential check

- Remove 6 info!/debug! log lines that narrate control flow; keep only
  the warn! on refresh failure (genuinely useful for production).
- Remove has_stored_credentials() which duplicated Config::global() logic;
  reuse GooseCredentialStore via the CredentialStore trait instead.

Addresses review feedback from @DOsinga on aaif-goose#8386.

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
@vincenzopalazzo

Copy link
Copy Markdown
Contributor Author

Re: review feedback from @DOsinga

Fixed in f44795c.

Two changes:

  1. Logging trimmed. Dropped 6 info!/debug! lines that narrate control flow. Kept only the warn! on refresh failure — that one's genuinely useful.

  2. has_stored_credentials removed. Replaced with GooseCredentialStore::new(name).load().await which reuses the existing CredentialStore trait. If the storage mechanism changes, there's one place to update.

@vincenzopalazzo
vincenzopalazzo requested a review from DOsinga April 8, 2026 16:26
@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity for 23 days.

What happens next?

  • If no further activity occurs, this PR will be automatically closed in 7 days
  • To keep this PR active, simply add a comment, push new commits, or add the keep-open label
  • If you believe this PR was marked as stale in error, please comment and we'll review it

Thank you for your contribution! 🚀

@github-actions github-actions Bot added the stale label May 2, 2026
When connecting to OAuth-protected MCP servers, goose now checks for
stored credentials before attempting an unauthenticated connection.
If credentials exist, it silently refreshes the token and connects
directly, avoiding the unnecessary 401 → browser re-auth cycle that
previously happened on every new chat session.

Also extracts a connect_with_auth() helper to eliminate duplicated
HTTP client + transport construction between the proactive and
fallback OAuth paths, and adds diagnostic logging to surface
whether servers issue refresh tokens.

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
…ate credential check

- Remove 6 info!/debug! log lines that narrate control flow; keep only
  the warn! on refresh failure (genuinely useful for production).
- Remove has_stored_credentials() which duplicated Config::global() logic;
  reuse GooseCredentialStore via the CredentialStore trait instead.

Addresses review feedback from @DOsinga on aaif-goose#8386.

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Douwe Osinga added 2 commits May 14, 2026 12:43
The non-exhaustive StreamableHttpClientTransportConfig struct can't be
constructed via struct expression. Use the existing with_uri builder
pattern, matching the other call sites in this file.

Signed-off-by: Douwe Osinga <douwe@squareup.com>
@DOsinga
DOsinga added this pull request to the merge queue May 14, 2026
Merged via the queue into aaif-goose:main with commit edb5b84 May 14, 2026
22 checks passed
lifeizhou-ap added a commit that referenced this pull request May 15, 2026
* main: (102 commits)
  Dynamically refresh skill instructions each turn (#9217)
  Build non-vulkan linux variants using ubuntu 22.04 (#9211)
  fix(ui): show tool name in approval prompt (#9216)
  feat: add Atomic Chat as declarative OpenAI-compatible provider (#9210)
  chore: bump package.json versions from 0.19.1 to 0.20.0 (#9218)
  feat: support GOOSE_OAUTH_CALLBACK_PORT for stable OAuth redirect_uri (#9209)
  [RFC] feat(oauth): proactive token refresh to avoid re-auth on every session (#8386)
  fix: resolve Azure CLI on Windows by using az.cmd (#9215)
  fix: handle non-interactive terminal in goose configure on Windows (#9214)
  Better parsing of pasted html as markdown so agents understand (#9190)
  fix: persist accumulated cost in session DB to survive reload (#9191)
  fix(publish-npm): build binary from current SHA + add compat check (#9212)
  feat(desktop): add goose://new-session deep link to open fresh chat (#9196)
  Add PR previews using cloudflare pages (#9208)
  fix: prevent tool-use marker leakage in toolshim output (#8310)
  Prompt injection mitigation: update pattern-based detection (#9198)
  remove goose2 related skills (#9189)
  Switch GH pages deploy to actions/artifact workflow (#9025)
  fix(summon): re-apply canonical limits when delegate overrides model (#9183)
  Split code signing from build (#8587)
  ...
hydrosquall added a commit to hydrosquall/goose that referenced this pull request May 24, 2026
…path

connect_with_auth() only set User-Agent, silently dropping any headers
configured on a StreamableHttp extension when the OAuth path was taken.

This affected two call sites in create_streamable_http_client:
- The proactive credential refresh path (added in aaif-goose#8386), which fires on
  every new session when OAuth credentials are stored for an extension.
- The reactive 401 fallback path.

Fix: add a headers parameter to connect_with_auth and insert them into
the reqwest client's default headers before building the AuthClient,
mirroring what the non-OAuth path already does.

Signed-off-by: Cameron Yick <cameron.yick@datadoghq.com>
hydrosquall added a commit to hydrosquall/goose that referenced this pull request May 25, 2026
Bug traces to aaif-goose#8148 (01a3d14), not aaif-goose#8386. Added header forwarding
test scenarios 8-10 to test-scenarios-oauth-fallback.md.

Signed-off-by: Cameron Yick <cameron.yick@datadoghq.com>
shafqatevo pushed a commit to shafqatevo/goose that referenced this pull request Aug 7, 2026
…session (aaif-goose#8386)

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs_human label to set when a robot looks at a PR and can't handle it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants