mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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.
269 lines
6.9 KiB
SQL
269 lines
6.9 KiB
SQL
-- name: GetMCPServerConfigByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
id = @id::uuid;
|
|
|
|
-- name: GetMCPServerConfigBySlug :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
slug = @slug::text;
|
|
|
|
-- name: GetMCPServerConfigs :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
ORDER BY
|
|
display_name ASC;
|
|
|
|
-- name: GetEnabledMCPServerConfigs :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
enabled = TRUE
|
|
ORDER BY
|
|
display_name ASC;
|
|
|
|
-- name: GetMCPServerConfigsByIDs :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
id = ANY(@ids::uuid[])
|
|
ORDER BY
|
|
display_name ASC;
|
|
|
|
-- name: GetForcedMCPServerConfigs :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
enabled = TRUE
|
|
AND availability = 'force_on'
|
|
ORDER BY
|
|
display_name ASC;
|
|
|
|
-- name: InsertMCPServerConfig :one
|
|
INSERT INTO mcp_server_configs (
|
|
display_name,
|
|
slug,
|
|
description,
|
|
icon_url,
|
|
transport,
|
|
url,
|
|
auth_type,
|
|
oauth2_client_id,
|
|
oauth2_client_secret,
|
|
oauth2_client_secret_key_id,
|
|
oauth2_auth_url,
|
|
oauth2_token_url,
|
|
oauth2_revocation_url,
|
|
oauth2_scopes,
|
|
api_key_header,
|
|
api_key_value,
|
|
api_key_value_key_id,
|
|
custom_headers,
|
|
custom_headers_key_id,
|
|
tool_allow_list,
|
|
tool_deny_list,
|
|
availability,
|
|
enabled,
|
|
model_intent,
|
|
allow_in_plan_mode,
|
|
forward_coder_headers,
|
|
created_by,
|
|
updated_by
|
|
) VALUES (
|
|
@display_name::text,
|
|
@slug::text,
|
|
@description::text,
|
|
@icon_url::text,
|
|
@transport::text,
|
|
@url::text,
|
|
@auth_type::text,
|
|
@oauth2_client_id::text,
|
|
@oauth2_client_secret::text,
|
|
sqlc.narg('oauth2_client_secret_key_id')::text,
|
|
@oauth2_auth_url::text,
|
|
@oauth2_token_url::text,
|
|
@oauth2_revocation_url::text,
|
|
@oauth2_scopes::text,
|
|
@api_key_header::text,
|
|
@api_key_value::text,
|
|
sqlc.narg('api_key_value_key_id')::text,
|
|
@custom_headers::text,
|
|
sqlc.narg('custom_headers_key_id')::text,
|
|
@tool_allow_list::text[],
|
|
@tool_deny_list::text[],
|
|
@availability::text,
|
|
@enabled::boolean,
|
|
@model_intent::boolean,
|
|
@allow_in_plan_mode::boolean,
|
|
@forward_coder_headers::boolean,
|
|
@created_by::uuid,
|
|
@updated_by::uuid
|
|
)
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateMCPServerConfig :one
|
|
UPDATE
|
|
mcp_server_configs
|
|
SET
|
|
display_name = @display_name::text,
|
|
slug = @slug::text,
|
|
description = @description::text,
|
|
icon_url = @icon_url::text,
|
|
transport = @transport::text,
|
|
url = @url::text,
|
|
auth_type = @auth_type::text,
|
|
oauth2_client_id = @oauth2_client_id::text,
|
|
oauth2_client_secret = @oauth2_client_secret::text,
|
|
oauth2_client_secret_key_id = sqlc.narg('oauth2_client_secret_key_id')::text,
|
|
oauth2_auth_url = @oauth2_auth_url::text,
|
|
oauth2_token_url = @oauth2_token_url::text,
|
|
oauth2_revocation_url = @oauth2_revocation_url::text,
|
|
oauth2_scopes = @oauth2_scopes::text,
|
|
api_key_header = @api_key_header::text,
|
|
api_key_value = @api_key_value::text,
|
|
api_key_value_key_id = sqlc.narg('api_key_value_key_id')::text,
|
|
custom_headers = @custom_headers::text,
|
|
custom_headers_key_id = sqlc.narg('custom_headers_key_id')::text,
|
|
tool_allow_list = @tool_allow_list::text[],
|
|
tool_deny_list = @tool_deny_list::text[],
|
|
availability = @availability::text,
|
|
enabled = @enabled::boolean,
|
|
model_intent = @model_intent::boolean,
|
|
allow_in_plan_mode = @allow_in_plan_mode::boolean,
|
|
forward_coder_headers = @forward_coder_headers::boolean,
|
|
updated_by = @updated_by::uuid,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: DeleteMCPServerConfigByID :exec
|
|
DELETE FROM
|
|
mcp_server_configs
|
|
WHERE
|
|
id = @id::uuid;
|
|
|
|
-- name: GetMCPServerUserToken :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_user_tokens
|
|
WHERE
|
|
mcp_server_config_id = @mcp_server_config_id::uuid
|
|
AND user_id = @user_id::uuid;
|
|
|
|
-- name: GetMCPServerUserTokensByUserID :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
mcp_server_user_tokens
|
|
WHERE
|
|
user_id = @user_id::uuid;
|
|
|
|
-- name: UpsertMCPServerUserToken :one
|
|
INSERT INTO mcp_server_user_tokens (
|
|
mcp_server_config_id,
|
|
user_id,
|
|
access_token,
|
|
access_token_key_id,
|
|
refresh_token,
|
|
refresh_token_key_id,
|
|
token_type,
|
|
expiry
|
|
) VALUES (
|
|
@mcp_server_config_id::uuid,
|
|
@user_id::uuid,
|
|
@access_token::text,
|
|
sqlc.narg('access_token_key_id')::text,
|
|
@refresh_token::text,
|
|
sqlc.narg('refresh_token_key_id')::text,
|
|
@token_type::text,
|
|
sqlc.narg('expiry')::timestamptz
|
|
)
|
|
ON CONFLICT (mcp_server_config_id, user_id) DO UPDATE SET
|
|
access_token = @access_token::text,
|
|
access_token_key_id = sqlc.narg('access_token_key_id')::text,
|
|
refresh_token = @refresh_token::text,
|
|
refresh_token_key_id = sqlc.narg('refresh_token_key_id')::text,
|
|
token_type = @token_type::text,
|
|
expiry = sqlc.narg('expiry')::timestamptz,
|
|
-- New token material means the user re-authenticated, so any
|
|
-- cached permanent refresh failure no longer applies.
|
|
oauth_refresh_failure_reason = '',
|
|
updated_at = NOW()
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateMCPServerUserTokenFromRefresh :one
|
|
-- Refresh persistence must not recreate a token deleted by disconnect.
|
|
-- The optimistic lock also prevents stale refreshes from replacing newer tokens.
|
|
UPDATE mcp_server_user_tokens
|
|
SET
|
|
access_token = @access_token::text,
|
|
access_token_key_id = sqlc.narg('access_token_key_id')::text,
|
|
refresh_token = @refresh_token::text,
|
|
refresh_token_key_id = sqlc.narg('refresh_token_key_id')::text,
|
|
token_type = @token_type::text,
|
|
expiry = sqlc.narg('expiry')::timestamptz,
|
|
oauth_refresh_failure_reason = '',
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
AND updated_at = @updated_at::timestamptz
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: MarkMCPServerUserTokenRefreshFailure :one
|
|
-- Records a permanent refresh failure (e.g. revoked grant) and clears
|
|
-- the dead token material so it is never attached to a request again.
|
|
-- The updated_at predicate provides optimistic concurrency: if another
|
|
-- request refreshed or replaced the token since it was read, this
|
|
-- update matches zero rows and returns sql.ErrNoRows.
|
|
UPDATE mcp_server_user_tokens
|
|
SET
|
|
access_token = '',
|
|
access_token_key_id = NULL,
|
|
refresh_token = '',
|
|
refresh_token_key_id = NULL,
|
|
expiry = NULL,
|
|
oauth_refresh_failure_reason = @oauth_refresh_failure_reason::text,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
AND updated_at = @updated_at::timestamptz
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: DeleteMCPServerUserToken :exec
|
|
DELETE FROM
|
|
mcp_server_user_tokens
|
|
WHERE
|
|
mcp_server_config_id = @mcp_server_config_id::uuid
|
|
AND user_id = @user_id::uuid;
|
|
|
|
-- name: CleanupDeletedMCPServerIDsFromChats :exec
|
|
UPDATE chats
|
|
SET mcp_server_ids = (
|
|
SELECT COALESCE(array_agg(sid), '{}')
|
|
FROM unnest(chats.mcp_server_ids) AS sid
|
|
WHERE sid IN (SELECT id FROM mcp_server_configs)
|
|
)
|
|
WHERE mcp_server_ids != '{}'
|
|
AND NOT (mcp_server_ids <@ COALESCE((SELECT array_agg(id) FROM mcp_server_configs), '{}'));
|