Skip to content

feat: refresh upstream credentials when external secrets expire#1689

Merged
qstearns merged 11 commits intomainfrom
quinn/add-refresh-token-support
Mar 5, 2026
Merged

feat: refresh upstream credentials when external secrets expire#1689
qstearns merged 11 commits intomainfrom
quinn/add-refresh-token-support

Conversation

@qstearns
Copy link
Contributor

@qstearns qstearns commented Feb 25, 2026

Summary

  • When an MCP client presents a valid proxy token whose upstream credentials have expired, the server now automatically attempts to refresh them via the upstream provider's token endpoint
  • Captures and encrypts upstream refresh tokens during the initial OAuth token exchange
  • Stacked on feat: add refresh_token grant support to OAuth proxy #1688

Fixes AGE-1402

Changes

  • providers/provider.go: Add RefreshToken to TokenExchangeResult, add RefreshToken method to Provider interface
  • providers/custom.go: Capture refresh_token from upstream response, implement RefreshToken method (posts grant_type=refresh_token to upstream)
  • providers/gram.go: Stub RefreshToken (not supported for Gram provider)
  • storage.go: Add RefreshToken field to ExternalSecret
  • grant_manager.go: Accept, store, and encrypt upstream refresh tokens in grants
  • token_service.go: Add ErrExpiredExternalSecrets, ErrNoUpstreamRefreshToken sentinels; ValidateAccessToken returns token + error on expired external secrets (instead of deleting); add RefreshExternalSecrets method; encrypt/decrypt ExternalSecret.RefreshToken
  • impl.go (oauth): Pass upstream refresh token through callback flow, add RefreshProxyToken method
  • mcp/oauth_service.go: Add RefreshProxyToken to OAuthService interface
  • mcp/impl.go: Catch ErrExpiredExternalSecrets in custom provider auth path, attempt upstream refresh before failing

Test plan

  • Existing tests pass
  • Custom OAuth provider returns refresh_token → stored and encrypted in ExternalSecrets
  • When upstream access token expires, proxy transparently refreshes it on next MCP request
  • When upstream refresh also fails, client gets 401 as expected

🤖 Generated with Claude Code


Open with Devin

@qstearns qstearns requested a review from a team as a code owner February 25, 2026 03:41
@changeset-bot
Copy link

changeset-bot bot commented Feb 25, 2026

⚠️ No Changeset found

Latest commit: 6ba7343

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Mar 4, 2026 11:52pm

Request Review

devin-ai-integration[bot]

This comment was marked as resolved.

@linear
Copy link

linear bot commented Feb 25, 2026

@speakeasybot
Copy link
Collaborator

speakeasybot commented Mar 4, 2026

🚀 Preview Environment (PR #1689)

Preview URL: https://pr-1689.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-03-04 23:58:22.
✅ Images Available Container images ready 2026-03-04 23:58:04.

Gram Preview Bot

Comment on lines +97 to +104
func (p *GramProvider) RefreshToken(
_ context.Context,
_ string,
_ repo.OauthProxyProvider,
_ *toolsets_repo.Toolset,
) (*TokenExchangeResult, error) {
return nil, fmt.Errorf("refresh token not supported for gram provider")
}
Copy link
Member

Choose a reason for hiding this comment

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

Is there another way to go about this? Personally not keen on relying on a runtime error to surface an erroneous usage of the method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

there might be but I think the responsibility should fall to the external oauth server config in this case (which I believe captures this case by not advertising the access_token grant) - but on some level Gram will surface a token endpoint and if we find a Gram provider we should probably error on it in a manner at least similar to this. Let me verify that behavior though

Comment on lines +85 to +95
var scopesSupported []string
providers, err := oauthRepo.ListOAuthProxyProvidersByServer(ctx, repo.ListOAuthProxyProvidersByServerParams{
OauthProxyServerID: toolset.OauthProxyServerID.UUID,
ProjectID: toolset.ProjectID,
})
if err == nil {
for _, p := range providers {
scopesSupported = append(scopesSupported, p.ScopesSupported...)
}
}

Copy link
Member

Choose a reason for hiding this comment

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

I'm having a hard time following what's happening in this chunk of code. Would you refactor it a bit?

Eg - the method ListOAuthProxyProvidersByServer insinuates that there will be multiple OAuthProxyProviders, but we only provide a single OauthProxyServerID.

Happy to discuss to clarify as well.

Copy link
Member

Choose a reason for hiding this comment

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

Actually - it appears that our data design has a one-to-many relationship between oauth proxy servers and oauth proxy providers. This code block is accommodating for that.

In contrast, though, /oauth/impl.go is just grabbing the first provider on lines 286 and 535.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm changing the behavior to error in this case. I think it makes the most sense to consider this particular state invalid

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment on lines +105 to +112
// Always include offline_access — Gram issues refresh tokens for all provider types.
// The provider's own scopes describe upstream capabilities; offline_access describes
// Gram's capability as the authorization server.
scopes := []string{"offline_access"}
for _, s := range provider.ScopesSupported {
if s != "offline_access" {
scopes = append(scopes, s)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 wellknown.go unconditionally adds "offline_access" scope, causing test to fail and incorrect metadata for gram providers

The new code in server/internal/oauth/wellknown/wellknown.go:108-112 unconditionally adds "offline_access" to the scopes slice for ALL proxy provider types, making ScopesSupported always non-empty. However, the test added in the same PR at server/internal/mcp/wellknown_test.go:326-394 creates a gram-type provider with empty ScopesSupported and asserts require.False(t, hasScopes, "scopes_supported should be omitted when empty"). Since scopes will always contain at least ["offline_access"], the omitempty JSON tag won't omit the field, and this test will fail.

Additionally, adding "offline_access" for gram providers is semantically incorrect — gram providers explicitly don't support refresh (server/internal/oauth/providers/gram.go:102-103: "refresh token not supported for gram provider"). The offline_access scope should only be added for provider types that support upstream token refresh (i.e., custom providers).

Prompt for agents
In server/internal/oauth/wellknown/wellknown.go, inside the ResolveOAuthServerMetadataFromToolset function, around lines 105-112, the offline_access scope is unconditionally added for all provider types. This should be conditional on the provider type supporting refresh tokens. For gram providers, offline_access should not be added since they do not support refresh.

Change the scopes logic to only include offline_access when the provider type is custom (or more generally, when it supports refresh). For gram providers, just pass through the provider's own ScopesSupported.

Also update the test in server/internal/mcp/wellknown_test.go around lines 326-394 (scopes_supported omitted when empty) to match the corrected behavior, or split it into two tests: one for gram providers (scopes omitted when empty) and one for custom providers (scopes always include offline_access).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Devin, I dismiss this comment. Whether the upstream supports offline_access or NOT the client should request this particular scope so that Gram will grant it. It's not a big deal for it to request offline_access from the upstream even if unsupported. Even so I have made age-1509 to make the exchange more robust.

I did fix the tests tho

qstearns and others added 3 commits March 4, 2026 15:50
MCP clients can now refresh their proxy-issued tokens via
grant_type=refresh_token, receiving a rotated access/refresh pair.
Well-known metadata advertises the new grant type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When an MCP client presents a valid proxy token whose underlying upstream
credentials have expired, the server now attempts to refresh them using
the upstream provider's token endpoint before returning an error.

- Add RefreshToken to Provider interface and implement for CustomProvider
- Store and encrypt upstream refresh tokens in ExternalSecrets
- Add RefreshExternalSecrets to TokenService for in-place secret updates
- Add RefreshProxyToken to OAuth Service and MCP OAuthService interface
- Catch ErrExpiredExternalSecrets in MCP auth flow and attempt refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add ScopesSupported to OAuthServerMetadata and populate it from
the proxy provider's configured scopes. Also pass through scopes
from external MCP OAuth discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
qstearns and others added 8 commits March 4, 2026 15:50
When the upstream provider doesn't issue a new refresh_token in
the refresh response (which is valid per the OAuth spec), fall back
to the original refresh token instead of storing an empty string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three issues prevented refresh_token grant from working:

1. Token cache TTL equaled access token lifetime, so Redis evicted
   the refresh token entry at the exact moment it was needed.
   Added 24h grace period to Token.TTL().

2. ValidateAccessToken deleted the entire token (including refresh
   token cache key) when the access token expired. Removed the
   deletion so clients can still exchange the refresh token.

3. ExchangeRefreshToken rejected tokens past ExpiresAt, but the
   whole point of a refresh token is to work after access token
   expiry. Removed the expiry check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
getToken had its own expiry check that prevented ValidateAccessToken
and ExchangeRefreshToken from ever seeing expired tokens. This made
refresh_token grants impossible since the token couldn't be looked
up after the access token expired.

Expiry is already checked by ValidateAccessToken (the sole caller
of getToken), so the check here was redundant and harmful.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per OIDC Core §11, when offline_access is requested the authorization
request must include prompt=consent. Without it, spec-compliant
providers silently drop offline_access and no refresh token is issued.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover all 5 test groups from the refresh token QA spec:
- Group 1: downstream token refresh (ExchangeRefreshToken, token rotation, secret preservation)
- Group 2: upstream refresh via RefreshProxyToken (success, preserve RT, no RT, errors)
- Group 3: MCP ServePublic custom OAuth proxy (expired secrets refresh, failure 401, valid skip)
- Group 4: well-known metadata (grant_types includes refresh_token, scopes_supported)
- Group 5: CustomProvider.RefreshToken (auth methods, camelCase, rotation, errors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual map[string]any indexing with two typed structs
(snake_case and camelCase) and a shared parseTokenResponse helper.
Adds a warning log when the non-compliant camelCase path is hit
so we can track usage and eventually remove the fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment on lines 369 to 370
urlParams.Set("scope", req.Scope)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Well-known metadata advertises offline_access scope but authorize endpoint doesn't forward it to upstream, preventing refresh tokens from being issued

The well-known metadata (wellknown.go:108-113) always includes offline_access in scopes_supported, and the grant_types_supported now includes refresh_token. However, the authorize endpoint (impl.go:366-370) builds the upstream scope from provider.ScopesSupported directly, which does NOT include offline_access (it's only added in the well-known metadata). This means the upstream OAuth provider typically won't return a refresh_token in its token response (per OIDC spec, offline_access must be requested to get a refresh token). Without an upstream refresh token, the new RefreshProxyToken feature (impl.go:748-749) returns ErrNoUpstreamRefreshToken, making the entire upstream auto-refresh feature inoperable for providers that require offline_access to issue refresh tokens.

Scope mismatch details

In wellknown.go:108:

scopes := []string{"offline_access"}
for _, s := range provider.ScopesSupported {

But in impl.go:366:

if len(provider.ScopesSupported) > 0 {
    urlParams.Set("scope", strings.Join(provider.ScopesSupported, " "))

The prompt=consent guard at impl.go:373 only fires when offline_access is already in scope, but it never gets there because offline_access was never added to the upstream request.

(Refers to lines 366-370)

Prompt for agents
In server/internal/oauth/impl.go, in the handleAuthorize function around lines 365-370, the upstream scope is constructed from provider.ScopesSupported but never includes offline_access. To ensure the upstream provider returns a refresh token, offline_access should be included in the scope sent to the upstream authorize URL. This should mirror the logic in server/internal/oauth/wellknown/wellknown.go lines 108-113 where offline_access is prepended to the scopes. Specifically, after constructing the scope from provider.ScopesSupported (or req.Scope), add offline_access if it's not already present. For example:

scope := strings.Join(provider.ScopesSupported, " ")
if !strings.Contains(scope, "offline_access") {
    scope = "offline_access " + scope
}
urlParams.Set("scope", scope)

This must be done BEFORE the prompt=consent check at line 373, which depends on offline_access being in the scope.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@qstearns qstearns merged commit 1972fb3 into main Mar 5, 2026
34 checks passed
@qstearns qstearns deleted the quinn/add-refresh-token-support branch March 5, 2026 00:18
@github-actions github-actions bot locked and limited conversation to collaborators Mar 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants