feat: handle revoked OAuth grants for MCP servers gracefully (#27264)

Closes
[CODAGT-792](https://linear.app/codercom/issue/CODAGT-792/handle-revoked-oauth-grants-for-mcp-servers-gracefully).

When a user revokes an upstream OAuth grant for an MCP server used by
Coder Agents, Coder kept treating the cached token as valid:
`invalid_grant` refresh failures were logged and swallowed, the dead
bearer token kept being attached, the list endpoints re-attempted the
refresh on every call, and the UI kept showing the server as
authenticated.

## Changes

Backend, mirroring the `external_auth_links` prior art:

- New migration adds
`mcp_server_user_tokens.oauth_refresh_failure_reason`.
`UpsertMCPServerUserToken` clears it, so completing the OAuth flow again
recovers the row.
- New `MarkMCPServerUserTokenRefreshFailure` query records the failure
and clears all token material, guarded by an `updated_at` optimistic
lock so a stale failure never clobbers a concurrently refreshed token
(on a lock miss the winner's row is used).
- `mcpclient.IsPermanentRefreshError` classifies `*oauth2.RetrieveError`
codes: only `invalid_grant` and `bad_refresh_token` are permanent.
Client/config errors (`invalid_client`, `unauthorized_client`, ...) stay
transient for the user row since reconnecting cannot fix them.
- chatd token refresh and the MCP list/get endpoints persist permanent
failures, return cleared tokens for the in-flight request, and skip
provider calls for already-failed rows.
- `buildAuthHeaders` no longer attaches an Authorization header for
failed tokens, so chat degrades by omitting that server's tools instead
of sending a dead bearer.

API and UI:

- No new API surface. A permanently failed token simply reports
`auth_connected: false`, so the existing "Auth" button and "Not
authenticated" tooltip appear and the user re-runs the same OAuth flow
to recover. An earlier revision added an `auth_status` enum (`connected`
/ `not_connected` / `reconnect_required`) with a dedicated "Reconnect"
button; it was collapsed to keep the API minimal since both states lead
to the identical re-auth action.

Out of scope (follow-up): typed 401-on-connect detection and forced
refresh. mcp-go exposes no stable typed 401 signal in the static-header
path, so a revocation while the access token still looks valid locally
stays undetected until expiry triggers a refresh.

## Testing

- Unit and integration tests: classifier, chatd refresh paths
(permanent/transient/race/persist-failure), API endpoints (revoked,
transient, no-retry caching, re-auth recovery, stale-lock), dbauthz,
dbcrypt, migrations.
- Dogfood UAT against a dev instance with a mock IdP returning
`invalid_grant`: revoked grant detected on refresh and persisted once
(no repeated IdP calls), chat with the revoked server selected completes
with the server's tools omitted, and re-auth restores the connected
state.

> This PR was authored by Mux, working on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-07-16 11:43:05 +00:00
committed by GitHub
parent 213f5ce606
commit e489092154
20 changed files with 913 additions and 25 deletions
+70 -10
View File
@@ -208,8 +208,6 @@ func (api *API) listMCPServerConfigs(rw http.ResponseWriter, r *http.Request) {
}
if config.AuthType == "oauth2" {
sdkConfig.AuthConnected = tokenMap[config.ID]
} else {
sdkConfig.AuthConnected = true
}
resp = append(resp, sdkConfig)
}
@@ -537,8 +535,6 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) {
break
}
}
} else {
sdkConfig.AuthConnected = true
}
httpapi.Write(ctx, rw, http.StatusOK, sdkConfig)
@@ -1167,12 +1163,12 @@ func (api *API) mcpServerOAuth2Disconnect(rw http.ResponseWriter, r *http.Reques
rw.WriteHeader(http.StatusNoContent)
}
// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter.
// refreshMCPUserToken attempts to refresh an expired OAuth2 token
// for the given MCP server config. Returns true when the token is
// valid (either still fresh or successfully refreshed), false when
// the token is expired and cannot be refreshed.
// the token is expired and cannot be refreshed. Permanent refresh
// failures (e.g. revoked grants) are persisted so subsequent calls
// skip the provider without a network call.
func (api *API) refreshMCPUserToken(
ctx context.Context,
cfg database.MCPServerConfig,
@@ -1182,9 +1178,12 @@ func (api *API) refreshMCPUserToken(
if cfg.AuthType != "oauth2" {
return true
}
if tok.OauthRefreshFailureReason != "" {
return false
}
if tok.RefreshToken == "" {
// No refresh token — consider connected only if not
// expired (or no expiry set).
// No refresh token; connected only if not expired (or no
// expiry set).
return !tok.Expiry.Valid || tok.Expiry.Time.After(time.Now())
}
@@ -1194,7 +1193,11 @@ func (api *API) refreshMCPUserToken(
slog.F("server_slug", cfg.Slug),
slog.Error(err),
)
// Refresh failed — token is dead.
if mcpclient.IsPermanentRefreshError(err) {
return api.markMCPTokenRefreshFailure(ctx, cfg, tok, err)
}
// Transient failure; the token is unusable right now but a
// later refresh may succeed.
return false
}
@@ -1230,6 +1233,59 @@ func (api *API) refreshMCPUserToken(
return true
}
// markMCPTokenRefreshFailure persists a permanent refresh failure so
// later status checks skip the provider. The updated_at optimistic
// lock loses to concurrent refreshes: in that case the winner's row
// determines whether the token is still usable.
func (api *API) markMCPTokenRefreshFailure(
ctx context.Context,
cfg database.MCPServerConfig,
tok database.MCPServerUserToken,
refreshErr error,
) bool {
//nolint:gocritic // Need system-level write access to persist
// the refresh failure.
_, err := api.Database.MarkMCPServerUserTokenRefreshFailure(
dbauthz.AsSystemRestricted(ctx),
database.MarkMCPServerUserTokenRefreshFailureParams{
ID: tok.ID,
UpdatedAt: tok.UpdatedAt,
OauthRefreshFailureReason: mcpclient.RefreshFailureReason(refreshErr),
},
)
if err == nil {
return false
}
if xerrors.Is(err, sql.ErrNoRows) {
// A concurrent request updated the token after we read it;
// report its state instead of poisoning the fresh token.
//nolint:gocritic // Need system-level read access to load
// the concurrently updated token.
current, readErr := api.Database.GetMCPServerUserToken(
dbauthz.AsSystemRestricted(ctx),
database.GetMCPServerUserTokenParams{
MCPServerConfigID: tok.MCPServerConfigID,
UserID: tok.UserID,
},
)
if readErr == nil {
return current.OauthRefreshFailureReason == "" &&
current.AccessToken != "" &&
(!current.Expiry.Valid || current.Expiry.Time.After(time.Now()))
}
err = readErr
}
api.Logger.Warn(ctx, "failed to persist MCP oauth2 refresh failure",
slog.F("server_slug", cfg.Slug),
slog.Error(err),
)
return false
}
// parseMCPServerConfigID extracts the MCP server config UUID from the
// "mcpServer" path parameter.
func parseMCPServerConfigID(rw http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
mcpServerID, err := uuid.Parse(chi.URLParam(r, "mcpServer"))
if err != nil {
@@ -1279,6 +1335,10 @@ func convertMCPServerConfig(config database.MCPServerConfig) codersdk.MCPServerC
ForwardCoderHeaders: config.ForwardCoderHeaders,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
// Default per-user auth state. Handlers that know the
// calling user's token state (list/get) overwrite this.
AuthConnected: config.AuthType != "oauth2",
}
}