mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: revoke MCP server OAuth grants at the provider on disconnect (#27300)
Closes [CODAGT-805](https://linear.app/codercom/issue/CODAGT-805/revoke-oauth-grants-at-the-source-for-mcp-servers). The experimental MCP server OAuth2 disconnect endpoint previously deleted only the stored token row, leaving the grant active at the OAuth provider. This PR adds provider-side token revocation while keeping local disconnect independent of provider availability. ## Changes - Add `mcp_server_configs.oauth2_revocation_url` in migration `000547`. The value can be configured manually, discovered from RFC 8414 metadata, and managed through the MCP server settings UI. Non-admin responses redact it with the other OAuth2 fields. - Revoke the refresh token first through the RFC 7009 endpoint, then fall back to the access token only for `unsupported_token_type`. Public clients send `client_id`; confidential clients use `client_secret_basic`. - Delete the local token transactionally before best-effort provider revocation. Callers without a token receive the same response for hidden and nonexistent config IDs, and provider failures return a generic warning without exposing provider response bodies. - Require HTTPS revocation endpoints except for HTTP loopback URLs. Redirects must preserve the POST and remain on the configured origin. Redirect errors omit provider-controlled paths and query strings so reflected token material cannot enter logs. - Treat `200 OK` and `204 No Content` as completed revocations. `202 Accepted` remains a failure because it does not confirm completion. - Prevent an in-flight refresh from recreating a token deleted by disconnect. Refresh persistence now uses an optimistic update keyed by token ID and `updated_at`; only the OAuth callback can create a token row. Refresh conflicts reload the current row or clear in-memory auth when disconnect deleted it. - Return `{token_revoked, token_revocation_error}` from disconnect, while retaining SDK compatibility with the legacy `204` response. The UI surfaces provider revocation failures as warning toasts. - Document revocation endpoint discovery, HTTPS requirements, and best-effort disconnect behavior. No token or no configured revocation URL returns `token_revoked: false` without an error, so disconnect remains idempotent. > Updated by Mux, an AI coding agent, on Mike's behalf.
This commit is contained in:
+55
-28
@@ -17,18 +17,38 @@ func (c *Client) MCPServerOAuth2ConnectURL(id uuid.UUID) string {
|
||||
return fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/connect", c.URL.String(), id)
|
||||
}
|
||||
|
||||
// MCPServerOAuth2DisconnectResponse reports whether the removed token
|
||||
// was also revoked at the OAuth provider.
|
||||
type MCPServerOAuth2DisconnectResponse struct {
|
||||
TokenRevoked bool `json:"token_revoked"`
|
||||
TokenRevocationError string `json:"token_revocation_error,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServerOAuth2Disconnect removes the user's OAuth2 token for an
|
||||
// MCP server.
|
||||
// MCP server. Use MCPServerOAuth2DisconnectWithResponse for the
|
||||
// provider revocation outcome.
|
||||
func (c *Client) MCPServerOAuth2Disconnect(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := c.MCPServerOAuth2DisconnectWithResponse(ctx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MCPServerOAuth2DisconnectWithResponse removes the user's OAuth2
|
||||
// token for an MCP server and reports the provider revocation outcome.
|
||||
func (c *Client) MCPServerOAuth2DisconnectWithResponse(ctx context.Context, id uuid.UUID) (MCPServerOAuth2DisconnectResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/disconnect", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return MCPServerOAuth2DisconnectResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
// Servers from before provider revocation respond 204 without a body.
|
||||
if res.StatusCode == http.StatusNoContent {
|
||||
return MCPServerOAuth2DisconnectResponse{}, nil
|
||||
}
|
||||
return nil
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return MCPServerOAuth2DisconnectResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp MCPServerOAuth2DisconnectResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// MCPServerConfig represents an admin-configured MCP server.
|
||||
@@ -45,11 +65,12 @@ type MCPServerConfig struct {
|
||||
AuthType string `json:"auth_type"` // "none", "oauth2", "api_key", "custom_headers", "user_oidc"
|
||||
|
||||
// OAuth2 fields (only populated for admins).
|
||||
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
||||
HasOAuth2Secret bool `json:"has_oauth2_secret"`
|
||||
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty"`
|
||||
OAuth2TokenURL string `json:"oauth2_token_url,omitempty"`
|
||||
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
||||
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
||||
HasOAuth2Secret bool `json:"has_oauth2_secret"`
|
||||
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty"`
|
||||
OAuth2TokenURL string `json:"oauth2_token_url,omitempty"`
|
||||
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty"`
|
||||
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
||||
|
||||
// API key fields (only populated for admins).
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
@@ -91,15 +112,18 @@ type CreateMCPServerConfigRequest struct {
|
||||
Transport string `json:"transport" validate:"required,oneof=streamable_http sse"`
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
|
||||
AuthType string `json:"auth_type" validate:"required,oneof=none oauth2 api_key custom_headers user_oidc"`
|
||||
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
||||
OAuth2ClientSecret string `json:"oauth2_client_secret,omitempty"`
|
||||
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2TokenURL string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
APIKeyValue string `json:"api_key_value,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
AuthType string `json:"auth_type" validate:"required,oneof=none oauth2 api_key custom_headers user_oidc"`
|
||||
OAuth2ClientID string `json:"oauth2_client_id,omitempty"`
|
||||
OAuth2ClientSecret string `json:"oauth2_client_secret,omitempty"`
|
||||
OAuth2AuthURL string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2TokenURL string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
||||
// OAuth2RevocationURL is the provider's RFC 7009 revocation
|
||||
// endpoint; auto-populated by OAuth2 discovery when omitted.
|
||||
OAuth2RevocationURL string `json:"oauth2_revocation_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2Scopes string `json:"oauth2_scopes,omitempty"`
|
||||
APIKeyHeader string `json:"api_key_header,omitempty"`
|
||||
APIKeyValue string `json:"api_key_value,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
|
||||
ToolAllowList []string `json:"tool_allow_list,omitempty"`
|
||||
ToolDenyList []string `json:"tool_deny_list,omitempty"`
|
||||
@@ -124,15 +148,18 @@ type UpdateMCPServerConfigRequest struct {
|
||||
Transport *string `json:"transport,omitempty" validate:"omitempty,oneof=streamable_http sse"`
|
||||
URL *string `json:"url,omitempty" validate:"omitempty,url"`
|
||||
|
||||
AuthType *string `json:"auth_type,omitempty" validate:"omitempty,oneof=none oauth2 api_key custom_headers user_oidc"`
|
||||
OAuth2ClientID *string `json:"oauth2_client_id,omitempty"`
|
||||
OAuth2ClientSecret *string `json:"oauth2_client_secret,omitempty"`
|
||||
OAuth2AuthURL *string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2TokenURL *string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2Scopes *string `json:"oauth2_scopes,omitempty"`
|
||||
APIKeyHeader *string `json:"api_key_header,omitempty"`
|
||||
APIKeyValue *string `json:"api_key_value,omitempty"`
|
||||
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
|
||||
AuthType *string `json:"auth_type,omitempty" validate:"omitempty,oneof=none oauth2 api_key custom_headers user_oidc"`
|
||||
OAuth2ClientID *string `json:"oauth2_client_id,omitempty"`
|
||||
OAuth2ClientSecret *string `json:"oauth2_client_secret,omitempty"`
|
||||
OAuth2AuthURL *string `json:"oauth2_auth_url,omitempty" validate:"omitempty,url"`
|
||||
OAuth2TokenURL *string `json:"oauth2_token_url,omitempty" validate:"omitempty,url"`
|
||||
// OAuth2RevocationURL is validated in the handler because a
|
||||
// validate tag would reject the pointer to "" that clears it.
|
||||
OAuth2RevocationURL *string `json:"oauth2_revocation_url,omitempty"`
|
||||
OAuth2Scopes *string `json:"oauth2_scopes,omitempty"`
|
||||
APIKeyHeader *string `json:"api_key_header,omitempty"`
|
||||
APIKeyValue *string `json:"api_key_value,omitempty"`
|
||||
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
|
||||
|
||||
ToolAllowList *[]string `json:"tool_allow_list,omitempty"`
|
||||
ToolDenyList *[]string `json:"tool_deny_list,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user