mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+70
-10
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user