mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Coder's MCP OAuth2 client never sent the RFC 8707 `resource` parameter, which the MCP authorization spec requires on authorization and token requests. Authorization servers that enforce it cannot be connected: with Dagster+, discovery and dynamic client registration succeed, then the authorize request fails with "MCP OAuth requests must include resource". MCP server configs get an `oauth2_resource` field (migration 000618, admin-only in API responses, audited). Coder sends it on the authorize redirect, the authorization-code exchange, and token refresh. Refresh now goes through `clientcredentials` with `grant_type=refresh_token`, because `oauth2.Config` cannot add parameters to refresh requests; it keeps the same auth-style detection and error types, and adds no dependency. Auto-discovery fills the field with the most specific Protected Resource Metadata `resource` that covers the server URL, stored as advertised. As in the MCP SDKs, a resource covers URLs on its origin at or under its path, so `/mcp` covers `/mcp/` but `/mcp/` does not cover `/mcp`. URLs compare in RFC 3986 normal form (default ports, percent-encodings, dot segments, and an empty path as `/`), ports ignore leading zeros, IPv6 literals compare by address, and `%2F` stays inside its segment. Unlike the SDKs, a resource with a query, even an empty one, only covers a server URL with the same query. When none covers it, Coder uses the server URL with the scheme and host lowercased and user information and fragment removed, or sends no resource when that is not a valid resource indicator. An explicit value in the create request wins. Manual configurations send no `resource` unless an admin sets one, because some providers reject it (Microsoft Entra ID v2 fails with AADSTS901002). Values must be absolute RFC 3986 URIs without a fragment, user information, or an IPv6 zone identifier. The resource binds grants like the other OAuth settings: changing it deletes stored user tokens, and a callback whose config resource changed after the user started connecting returns 409 before the code is redeemed. The connect step records the resource in a callback-scoped cookie for that check. Discovery only runs when a configuration is created, so existing auto-discovered configurations, including the Dagster+ one that prompted this, keep an empty resource until an admin sets the new "Resource URL" field or re-creates the server.  ## Testing - Go: discovery (most specific match, path and trailing-slash boundaries, query matching including empty queries, equivalent ports, IPv6 literals, escapes, and dot segments including escaped ones, the normalized fallback and an invalid one, explicit override), validation (URI characters, brackets, IP literals, user information, trimming), grant deletion on change, clearing on an auth type change, redaction for members, refresh with and without a resource, the 409 when the resource changes mid-flow (the authorization server never receives the code), and a connect and callback flow against an authorization server that rejects requests without the resource. Each new test was checked to fail when the behavior it covers is broken, except two malformed IP literal hosts that Go's URL parser already rejects. - Vitest: the form round-trips the stored value, and clearing the field sends `""` on update. - Remote UAT through Coder Agents ([chat](https://dogfood.cdr.dev/agents/58695d5b-2112-400b-88f7-ab64c678f756)) on a test deployment with loopback MCP and authorization server mocks: - Round 1 on2b875068e0: literal verdict FAIL. When the resource changed between connect and callback, the callback redeemed the code for the new resource and stored a grant. The 409 binding fixes it. - Round 2 on4b54ffb5f9: PASS. The mid-flow change returned 409 with no token request in every variant (strict and lenient servers, set, cleared, abandoned flows, parallel configs); auto-discovery, connect, refresh, validation, and the docs matched. Minor observations: the binding cookie is an unkeyed digest, so a member who edits their own HttpOnly cookie can redeem a code with the current resource, which a fresh connect also gives them; other settings changed mid-flow still reach the token endpoint, as on main; the 409 popup shows raw JSON; the UI does not warn that a resource change disconnects users; validation toasts omit the server's detail. - Round 3 on the final head2d6abfac49: PASS. The head moved during the round, so it ran in four parts, the last on2d6abfac49. That part covered the matching rules (queries including empty ones, default and zero-padded ports, `%2F`, escaped dot segments, trailing slashes, IPv6 literals), the invalid fallback (stored empty, no `resource` sent), every validation value through the API and the UI, and the discovery, connect, refresh, and mid-flow 409 flows. The equivalent-encoding cases last ran live on 5c23fe00dca; unit tests cover them on the final head. Observation: the path-aware metadata URL is built from the decoded path, so `/mcp%2Ftenant` fetches metadata for `/mcp/tenant`, as on main. - `frontend-review` and the eight-role `coder-frontend-review` ran on the `site/` diff; every role passes after the label, help text, row layout, API doc wording, and form test fixes. Declined: linking field descriptions with `aria-describedby`, because the form's shared `Field` primitive renders every description without one, so it belongs in a change to all fields. ## Review record | Review | Commit | Findings | Disposition | |---|---|---|---| | [Codex round 1](https://github.com/coder/coder/pull/30651#pullrequestreview-5492085359) |4b54ffb5f9| P2: a resource with another query was treated as covering the server URL | Reproduced with a failing test, fixed inbf0879560c, resolved | | [Codex round 2](https://github.com/coder/coder/pull/30651#pullrequestreview-5492305363) |bf0879560c| P2: explicit default ports; P2: `%2F` treated as a segment boundary; P2: `url.Parse` accepts spaces and invalid escapes | Each reproduced with a failing test, fixed in16a1d783fa, resolved | | [Codex round 3](https://github.com/coder/coder/pull/30651#pullrequestreview-5492677517) |16a1d783fa| P1: docs wrapped mid-sentence (DOC1); P2: equivalent percent-encodings compared unequal; P2: brackets accepted outside an IP literal host | Fixed in8d617f0528(the P2s reproduced with failing tests; the same pass also covers the query, dot segment, and user information cases), resolved | | [Codex round 4](https://github.com/coder/coder/pull/30651#pullrequestreview-5493033885) |8d617f0528| P2: the server URL fallback was not validated; P2: escaped dot segments were compared before decoding | Each reproduced with a failing test, fixed in5c23fe00dc, resolved | | [Codex round 5](https://github.com/coder/coder/pull/30651#pullrequestreview-5493342889) |5c23fe00dc| P2: an empty query (`?`) was treated as no query | Reproduced with a failing test, fixed in72c019ff5e, resolved | | [Codex round 6](https://github.com/coder/coder/pull/30651#pullrequestreview-5493524846) |72c019ff5e| P2: `/mcp/` covered `/mcp`; P2: equivalent IPv6 literals compared unequal; P2: malformed IP literals accepted | The first two reproduced with failing tests. Go 1.26.5 `url.Parse` already rejects the hosts named in the third, but it accepted an IPv6 zone identifier. All fixed inef2df2c4e7, resolved | | [Codex round 7](https://github.com/coder/coder/pull/30651#pullrequestreview-5493668131) |ef2df2c4e7| P2: leading zeros in a port made equal origins differ | Reproduced with a failing test, fixed in2d6abfac49, resolved | | [Codex round 8](https://github.com/coder/coder/pull/30651#issuecomment-6128743972) |2d6abfac49| None: "Didn't find any major issues" | Clean verdict on the current head | > Xum opened this PR on behalf of @ibetitsmike.
4434 lines
163 KiB
Go
4434 lines
163 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// mcpDeploymentValues returns deployment values for tests of the MCP
|
|
// server config endpoints.
|
|
func mcpDeploymentValues(t testing.TB) *codersdk.DeploymentValues {
|
|
t.Helper()
|
|
|
|
return coderdtest.DeploymentValues(t)
|
|
}
|
|
|
|
// newMCPClient creates a test server and returns the admin client.
|
|
func newMCPClient(t testing.TB) *codersdk.Client {
|
|
t.Helper()
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
return coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
}
|
|
|
|
// createMCPServerConfig is a helper that creates a minimal enabled
|
|
// MCP server config with auth_type=none.
|
|
func createMCPServerConfig(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, slug string, enabled bool) codersdk.MCPServerConfig {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config, err := client.CreateMCPServerConfig(ctx, organizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Test Server " + slug,
|
|
Slug: slug,
|
|
Description: "A test MCP server.",
|
|
IconURL: "https://example.com/icon.png",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: "none",
|
|
Availability: "default_on",
|
|
Enabled: enabled,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
return config
|
|
}
|
|
|
|
func newMCPDiscoveryServer(t testing.TB, registrationRequests *atomic.Int64) string {
|
|
t.Helper()
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"]
|
|
}`))
|
|
case "/register":
|
|
registrationRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "discovered-client-id",
|
|
"client_secret": "discovered-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/mcp",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
return mcpServer.URL + "/mcp"
|
|
}
|
|
|
|
func TestMCPServerConfigLegacyRoutesRemoved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "legacy-route-test", true)
|
|
|
|
for _, path := range []string{
|
|
"/api/experimental/mcp/servers",
|
|
"/api/experimental/mcp/servers/" + config.ID.String(),
|
|
"/api/experimental/mcp/servers/" + config.ID.String() + "/oauth2/connect",
|
|
} {
|
|
res, err := client.Request(ctx, http.MethodGet, path, nil)
|
|
require.NoError(t, err)
|
|
res.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, res.StatusCode, path)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigsCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Create a config with all fields populated including OAuth2
|
|
// secrets so we can verify they are not leaked.
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "My MCP Server",
|
|
Slug: "my-mcp-server",
|
|
Description: "Integration test server.",
|
|
IconURL: "https://example.com/icon.png",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/v1",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "client-id-123",
|
|
OAuth2ClientSecret: "super-secret-value",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2Scopes: "read write",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, uuid.Nil, created.ID)
|
|
require.Equal(t, firstUser.OrganizationID, created.OrganizationID)
|
|
require.Equal(t, "My MCP Server", created.DisplayName)
|
|
require.Equal(t, "my-mcp-server", created.Slug)
|
|
require.Equal(t, "Integration test server.", created.Description)
|
|
require.Equal(t, "streamable_http", created.Transport)
|
|
require.Equal(t, "https://mcp.example.com/v1", created.URL)
|
|
require.Equal(t, "oauth2", created.AuthType)
|
|
require.Equal(t, "client-id-123", created.OAuth2ClientID)
|
|
require.Equal(t, "default_on", created.Availability)
|
|
require.True(t, created.Enabled)
|
|
require.False(t, created.AllowInPlanMode)
|
|
require.False(t, created.ForwardCoderHeaders)
|
|
|
|
// Verify the secret is indicated but never returned.
|
|
require.True(t, created.HasOAuth2Secret)
|
|
|
|
// Verify the config appears in the list and direct get responses.
|
|
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.Equal(t, created.ID, configs[0].ID)
|
|
require.True(t, configs[0].HasOAuth2Secret)
|
|
require.False(t, configs[0].AllowInPlanMode)
|
|
require.False(t, configs[0].ForwardCoderHeaders)
|
|
|
|
fetched, err := client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, created.ID, fetched.ID)
|
|
require.False(t, fetched.AllowInPlanMode)
|
|
require.False(t, fetched.ForwardCoderHeaders)
|
|
|
|
// Update display name, availability, allow_in_plan_mode, and
|
|
// forward_coder_headers.
|
|
newName := "Renamed Server"
|
|
newAvail := "force_on"
|
|
allowInPlanMode := true
|
|
forwardCoderHeaders := true
|
|
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
DisplayName: &newName,
|
|
Availability: &newAvail,
|
|
AllowInPlanMode: &allowInPlanMode,
|
|
ForwardCoderHeaders: &forwardCoderHeaders,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Renamed Server", updated.DisplayName)
|
|
require.Equal(t, "force_on", updated.Availability)
|
|
require.True(t, updated.AllowInPlanMode)
|
|
require.True(t, updated.ForwardCoderHeaders)
|
|
// Unchanged fields should remain the same.
|
|
require.Equal(t, "my-mcp-server", updated.Slug)
|
|
require.Equal(t, "oauth2", updated.AuthType)
|
|
|
|
// Verify the update took effect through the list and direct get.
|
|
configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.Equal(t, "Renamed Server", configs[0].DisplayName)
|
|
require.Equal(t, "force_on", configs[0].Availability)
|
|
require.True(t, configs[0].AllowInPlanMode)
|
|
require.True(t, configs[0].ForwardCoderHeaders)
|
|
|
|
fetched, err = client.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, fetched.AllowInPlanMode)
|
|
require.True(t, fetched.ForwardCoderHeaders)
|
|
|
|
// Delete it.
|
|
err = client.DeleteMCPServerConfig(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's gone.
|
|
configs, err = client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, configs)
|
|
}
|
|
|
|
func TestMCPServerConfigSigningSecret(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
path := "/api/v2/organizations/" + firstUser.OrganizationID.String() + "/mcp-servers"
|
|
secret := strings.Repeat("a", 64)
|
|
|
|
request := func(method, path string, body any, status int) codersdk.MCPServerConfig {
|
|
t.Helper()
|
|
res, err := client.Request(ctx, method, path, body)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, status, res.StatusCode)
|
|
var response struct {
|
|
codersdk.MCPServerConfig
|
|
SigningSecret *string `json:"signing_secret"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&response))
|
|
require.Nil(t, response.SigningSecret, "secrets must never be returned")
|
|
return response.MCPServerConfig
|
|
}
|
|
created := request(http.MethodPost, path, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Signed", Slug: "signed", Transport: "streamable_http",
|
|
URL: "https://mcp.example.com", AuthType: "none", Availability: "default_on",
|
|
Enabled: true, ForwardCoderHeaders: true, SigningSecret: secret,
|
|
}, http.StatusCreated)
|
|
require.True(t, created.HasSigningSecret)
|
|
path += "/" + created.ID.String()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
req codersdk.UpdateMCPServerConfigRequest
|
|
want string
|
|
}{
|
|
{"preserve", codersdk.UpdateMCPServerConfigRequest{}, secret},
|
|
{"replace", codersdk.UpdateMCPServerConfigRequest{SigningSecret: new(strings.Repeat("b", 64))}, strings.Repeat("b", 64)},
|
|
{"clear", codersdk.UpdateMCPServerConfigRequest{SigningSecret: new("")}, ""},
|
|
} {
|
|
updated := request(http.MethodPatch, path, tc.req, http.StatusOK)
|
|
require.Equal(t, tc.want != "", updated.HasSigningSecret)
|
|
stored, err := db.GetMCPServerConfigByID(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, stored.SigningSecret)
|
|
read := request(http.MethodGet, path, nil, http.StatusOK)
|
|
require.Equal(t, updated.HasSigningSecret, read.HasSigningSecret)
|
|
}
|
|
|
|
member, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
_, err := member.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, created.ID,
|
|
codersdk.UpdateMCPServerConfigRequest{SigningSecret: &secret})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
}
|
|
|
|
func TestMCPServerConfigWrongOrganization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "wrong-org", true)
|
|
otherOrganization := dbgen.Organization(t, db, database.Organization{})
|
|
|
|
_, err := client.MCPServerConfigByID(ctx, otherOrganization.ID, config.ID)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
}
|
|
|
|
func TestMCPServerConfigsAudit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newAuditedMCPClient := func(t testing.TB) (*codersdk.Client, *audit.MockAuditor) {
|
|
t.Helper()
|
|
mAudit := audit.NewMock()
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
Auditor: mAudit,
|
|
})
|
|
return client, mAudit
|
|
}
|
|
|
|
t.Run("Create", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
mAudit.ResetLogs()
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Audit Create",
|
|
Slug: "audit-create",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/audit",
|
|
AuthType: "api_key",
|
|
APIKeyHeader: "X-Api-Key",
|
|
APIKeyValue: "super-secret-api-key",
|
|
CustomHeaders: map[string]string{
|
|
"X-Extra": "plaintext-header-value",
|
|
},
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
logs := mAudit.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
require.Equal(t, database.AuditActionCreate, logs[0].Action)
|
|
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
|
|
require.Equal(t, created.ID, logs[0].ResourceID)
|
|
require.Equal(t, "Audit Create", logs[0].ResourceTarget)
|
|
require.Equal(t, firstUser.UserID, logs[0].UserID)
|
|
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
|
|
require.EqualValues(t, http.StatusCreated, logs[0].StatusCode)
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-update", true)
|
|
|
|
mAudit.ResetLogs()
|
|
newName := "Audit Update"
|
|
updated, err := client.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
DisplayName: &newName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, newName, updated.DisplayName)
|
|
|
|
logs := mAudit.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
require.Equal(t, database.AuditActionWrite, logs[0].Action)
|
|
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
|
|
require.Equal(t, config.ID, logs[0].ResourceID)
|
|
require.Equal(t, newName, logs[0].ResourceTarget)
|
|
require.Equal(t, firstUser.UserID, logs[0].UserID)
|
|
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
|
|
require.EqualValues(t, http.StatusOK, logs[0].StatusCode)
|
|
})
|
|
|
|
t.Run("Delete", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete", true)
|
|
|
|
mAudit.ResetLogs()
|
|
err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
|
|
logs := mAudit.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
require.Equal(t, database.AuditActionDelete, logs[0].Action)
|
|
require.Equal(t, database.ResourceTypeMCPServerConfig, logs[0].ResourceType)
|
|
require.Equal(t, config.ID, logs[0].ResourceID)
|
|
require.Equal(t, firstUser.UserID, logs[0].UserID)
|
|
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
|
|
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
|
|
})
|
|
|
|
t.Run("DeleteAuditsPersistedRow", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
mAudit := audit.NewMock()
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
db, ps := dbtestutil.NewDB(t)
|
|
store := &staleMCPServerConfigReadStore{Store: db}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
Auditor: mAudit,
|
|
Database: store,
|
|
Pubsub: ps,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-stale", true)
|
|
|
|
// Simulate a concurrent update landing between the param
|
|
// middleware read and the delete transaction.
|
|
store.stale.Store(true)
|
|
mAudit.ResetLogs()
|
|
err := client.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
|
|
logs := mAudit.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
require.Equal(t, config.DisplayName, logs[0].ResourceTarget)
|
|
})
|
|
|
|
t.Run("AutoDiscoveryFailureNotAudited", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
mAudit.ResetLogs()
|
|
// Discovery fails immediately: nothing listens on the URL.
|
|
// The partially inserted row is cleaned up, so no audit
|
|
// entry may reference it.
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Audit Discovery Failure",
|
|
Slug: "audit-discovery-failure",
|
|
Transport: "streamable_http",
|
|
URL: "http://127.0.0.1:1",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Empty(t, mAudit.AuditLogs())
|
|
})
|
|
|
|
t.Run("CreateNotAuditedWhenInsertFails", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
mAudit := audit.NewMock()
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
db, ps := dbtestutil.NewDB(t)
|
|
store := &failingMCPServerConfigInsertStore{Store: db}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
Auditor: mAudit,
|
|
Database: store,
|
|
Pubsub: ps,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"]
|
|
}`))
|
|
case "/register":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "update-failure-client-id",
|
|
"client_secret": "update-failure-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
store.fail.Store(true)
|
|
mAudit.ResetLogs()
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Audit Update Failure",
|
|
Slug: "audit-update-failure",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode())
|
|
|
|
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, configs)
|
|
require.Empty(t, mAudit.AuditLogs())
|
|
})
|
|
|
|
t.Run("DeletedResourceMarked", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, _ := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-is-deleted", true)
|
|
deletedID := uuid.New()
|
|
|
|
err := client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Action: codersdk.AuditActionWrite,
|
|
ResourceType: codersdk.ResourceTypeMCPServerConfig,
|
|
ResourceID: config.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Action: codersdk.AuditActionDelete,
|
|
ResourceType: codersdk.ResourceTypeMCPServerConfig,
|
|
ResourceID: deletedID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
logs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
|
|
Pagination: codersdk.Pagination{Limit: 25},
|
|
})
|
|
require.NoError(t, err)
|
|
byResourceID := make(map[uuid.UUID]codersdk.AuditLog, len(logs.AuditLogs))
|
|
for _, alog := range logs.AuditLogs {
|
|
byResourceID[alog.ResourceID] = alog
|
|
}
|
|
require.Contains(t, byResourceID, config.ID)
|
|
require.False(t, byResourceID[config.ID].IsDeleted)
|
|
require.Contains(t, byResourceID, deletedID)
|
|
require.True(t, byResourceID[deletedID].IsDeleted)
|
|
})
|
|
|
|
t.Run("WriteDeniedConcealed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-denied", true)
|
|
|
|
mAudit.ResetLogs()
|
|
newName := "denied"
|
|
_, err := memberClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
DisplayName: &newName,
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
require.Empty(t, mAudit.AuditLogs())
|
|
})
|
|
|
|
t.Run("DeleteDeniedConcealed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, mAudit := newAuditedMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
|
config := createMCPServerConfig(t, client, firstUser.OrganizationID, "audit-delete-denied", true)
|
|
|
|
mAudit.ResetLogs()
|
|
err := memberClient.DeleteMCPServerConfig(ctx, firstUser.OrganizationID, config.ID)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
require.Empty(t, mAudit.AuditLogs())
|
|
})
|
|
}
|
|
|
|
// failingMCPServerConfigInsertStore fails config inserts once armed.
|
|
type failingMCPServerConfigInsertStore struct {
|
|
database.Store
|
|
|
|
fail atomic.Bool
|
|
}
|
|
|
|
func (s *failingMCPServerConfigInsertStore) InsertMCPServerConfig(ctx context.Context, arg database.InsertMCPServerConfigParams) (database.MCPServerConfig, error) {
|
|
if s.fail.Load() {
|
|
return database.MCPServerConfig{}, xerrors.New("injected insert failure")
|
|
}
|
|
return s.Store.InsertMCPServerConfig(ctx, arg)
|
|
}
|
|
|
|
// staleMCPServerConfigReadStore corrupts plain config reads once armed,
|
|
// simulating a concurrent update that outdates the param middleware's
|
|
// snapshot. Locked ForUpdate reads stay untouched.
|
|
type staleMCPServerConfigReadStore struct {
|
|
database.Store
|
|
|
|
stale atomic.Bool
|
|
}
|
|
|
|
func (s *staleMCPServerConfigReadStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) {
|
|
config, err := s.Store.GetMCPServerConfigByID(ctx, id)
|
|
if err == nil && s.stale.Load() {
|
|
config.DisplayName = "stale middleware snapshot"
|
|
}
|
|
return config, err
|
|
}
|
|
|
|
func TestMCPServerConfigsNonAdmin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
// Admin creates two configs: one enabled, one disabled.
|
|
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true)
|
|
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false)
|
|
|
|
// Admin sees both.
|
|
adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, adminConfigs, 2)
|
|
|
|
// Regular user sees only the enabled one.
|
|
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, memberConfigs, 1)
|
|
require.Equal(t, "enabled-server", memberConfigs[0].Slug)
|
|
|
|
// Auditors list ACL-restricted configs but cannot read them row-level.
|
|
restricted := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "restricted-server", true)
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, restricted.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
GroupRoles: map[string]codersdk.MCPServerConfigRole{
|
|
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Auditors need the full management view of the MCP configs their
|
|
// audit logs reference.
|
|
for name, roles := range map[string][]rbac.RoleIdentifier{
|
|
"SiteAuditor": {rbac.RoleAuditor()},
|
|
"OrgAuditor": {rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID)},
|
|
} {
|
|
auditorClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, roles...)
|
|
auditorConfigs, err := auditorClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err, name)
|
|
require.Len(t, auditorConfigs, 3, name)
|
|
for _, config := range auditorConfigs {
|
|
require.NotEmpty(t, config.URL, "%s: %s", name, config.Slug)
|
|
if !config.Enabled {
|
|
fetched, err := auditorClient.MCPServerConfigByID(ctx, config.OrganizationID, config.ID)
|
|
require.NoError(t, err, name)
|
|
require.NotEmpty(t, fetched.URL, name)
|
|
}
|
|
}
|
|
|
|
_, err = auditorClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, restricted.ID)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr, name)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), name)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigsScopedKeyFullView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "enabled-server", true)
|
|
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "disabled-server", false)
|
|
|
|
_, auditor := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID,
|
|
rbac.ScopedRoleOrgAuditor(firstUser.OrganizationID))
|
|
|
|
// MCP config scopes are not user-mintable, so seed scoped keys directly.
|
|
newScopedClient := func(userID uuid.UUID, scopes ...database.APIKeyScope) *codersdk.Client {
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: userID,
|
|
Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...),
|
|
})
|
|
client := codersdk.New(adminClient.URL)
|
|
client.SetSessionToken(token)
|
|
return client
|
|
}
|
|
|
|
// A key whose scope covers reading MCP configs keeps the full view.
|
|
fullViewConfigs, err := newScopedClient(firstUser.UserID,
|
|
"mcp_server_config:update", "mcp_server_config:read").MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, fullViewConfigs, 2)
|
|
|
|
// Update-or-audit roles make the caller full-view eligible, but a key
|
|
// scoped without mcp_server_config:read must not receive config data.
|
|
for name, client := range map[string]*codersdk.Client{
|
|
"AdminUpdateScope": newScopedClient(firstUser.UserID, "mcp_server_config:update"),
|
|
"AuditorAuditScope": newScopedClient(auditor.ID, "audit_log:read"),
|
|
} {
|
|
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err, name)
|
|
require.Empty(t, configs, name)
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigScopedKeyWithoutPersonalTokenRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Server",
|
|
Slug: "oauth-server-scoped-key",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/oauth",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
//nolint:gocritic // Seeding token state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: firstUser.UserID,
|
|
AccessToken: "access-token",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: firstUser.UserID,
|
|
Scopes: database.APIKeyScopes{
|
|
"mcp_server_config:read",
|
|
"organization:read",
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(adminClient.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
|
|
configs, err := scopedClient.MCPServerConfigs(ctx, created.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.False(t, configs[0].AuthConnected)
|
|
|
|
config, err := scopedClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, config.AuthConnected)
|
|
}
|
|
|
|
func TestMCPServerConfigScopedKeyRefreshPersistsRotatedToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
authConnected func(*testing.T, context.Context, *codersdk.Client, uuid.UUID, uuid.UUID) bool
|
|
}{
|
|
{
|
|
name: "List",
|
|
authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, _ uuid.UUID) bool {
|
|
configs, err := client.MCPServerConfigs(ctx, organizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
return configs[0].AuthConnected
|
|
},
|
|
},
|
|
{
|
|
name: "Get",
|
|
authConnected: func(t *testing.T, ctx context.Context, client *codersdk.Client, organizationID, configID uuid.UUID) bool {
|
|
config, err := client.MCPServerConfigByID(ctx, organizationID, configID)
|
|
require.NoError(t, err)
|
|
return config.AuthConnected
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.NoError(t, r.ParseForm())
|
|
require.Equal(t, "old-refresh", r.Form.Get("refresh_token"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"rotated-refresh","token_type":"Bearer","expires_in":3600}`))
|
|
}))
|
|
t.Cleanup(tokenSrv.Close)
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Refresh " + test.name,
|
|
Slug: "oauth-refresh-" + strings.ToLower(test.name),
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/refresh",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenSrv.URL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
//nolint:gocritic // Seeding token state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: firstUser.UserID,
|
|
AccessToken: "expired-access",
|
|
RefreshToken: "old-refresh",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: firstUser.UserID,
|
|
Scopes: database.APIKeyScopes{
|
|
database.ApiKeyScopeMcpServerConfigRead,
|
|
database.ApiKeyScopeOrganizationRead,
|
|
database.ApiKeyScopeUserReadPersonal,
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(adminClient.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
|
|
require.True(t, test.authConnected(t, ctx, scopedClient, created.OrganizationID, created.ID))
|
|
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: firstUser.UserID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "fresh-access", row.AccessToken)
|
|
require.Equal(t, "rotated-refresh", row.RefreshToken)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigACL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
mAudit := audit.NewMock()
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
Auditor: mAudit,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
groupMemberClient, groupMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
nonMemberClient, nonMember := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
|
|
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
|
|
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-server", true)
|
|
|
|
mAudit.ResetLogs()
|
|
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
GroupRoles: map[string]codersdk.MCPServerConfigRole{
|
|
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
|
|
group.ID.String(): codersdk.MCPServerConfigRoleRead,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
logs := mAudit.AuditLogs()
|
|
require.Len(t, logs, 1)
|
|
require.Equal(t, database.AuditActionWrite, logs[0].Action)
|
|
require.Equal(t, config.ID, logs[0].ResourceID)
|
|
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
|
|
|
|
aclResponse, err := adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, aclResponse.Groups, 1)
|
|
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
|
|
require.Equal(t, 1, aclResponse.Groups[0].TotalMemberCount)
|
|
|
|
configs, err := groupMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
_, err = groupMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
|
|
configs, err = nonMemberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, configs)
|
|
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
|
|
_, err = groupMemberClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
|
|
mAudit.ResetLogs()
|
|
err = groupMemberClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
require.Empty(t, mAudit.AuditLogs())
|
|
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
UserRoles: map[string]codersdk.MCPServerConfigRole{
|
|
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
|
|
// The sparse user-only update must merge with the existing ACL, not
|
|
// clobber the earlier group grant.
|
|
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, aclResponse.Groups, 1)
|
|
require.Equal(t, group.ID, aclResponse.Groups[0].ID)
|
|
require.Len(t, aclResponse.Users, 1)
|
|
require.Equal(t, nonMember.ID, aclResponse.Users[0].ID)
|
|
|
|
// Conflicting roles under two spellings of one principal are
|
|
// ambiguous and must be rejected rather than resolved by map order.
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
UserRoles: map[string]codersdk.MCPServerConfigRole{
|
|
nonMember.ID.String(): codersdk.MCPServerConfigRoleRead,
|
|
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
|
|
},
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Error(), "duplicate entries for ID "+nonMember.ID.String())
|
|
|
|
// Noncanonical UUID spellings pass validation, so grants and
|
|
// deletions must canonicalize to hit the same ACL keys RBAC reads.
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
UserRoles: map[string]codersdk.MCPServerConfigRole{
|
|
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleRead,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
require.Len(t, aclResponse.Users, 2)
|
|
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
UserRoles: map[string]codersdk.MCPServerConfigRole{
|
|
"urn:uuid:" + nonMember.ID.String(): codersdk.MCPServerConfigRoleDeleted,
|
|
strings.ToUpper(groupMember.ID.String()): codersdk.MCPServerConfigRoleDeleted,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
aclResponse, err = adminClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, aclResponse.Users)
|
|
_, err = nonMemberClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID)
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
|
|
otherOrg := dbgen.Organization(t, db, database.Organization{})
|
|
otherGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrg.ID})
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
GroupRoles: map[string]codersdk.MCPServerConfigRole{
|
|
otherGroup.ID.String(): codersdk.MCPServerConfigRoleRead,
|
|
},
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Error(), otherGroup.ID.String())
|
|
|
|
foreignUser := dbgen.User(t, db, database.User{})
|
|
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: otherOrg.ID, UserID: foreignUser.ID})
|
|
err = adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
|
|
UserRoles: map[string]codersdk.MCPServerConfigRole{
|
|
foreignUser.ID.String(): codersdk.MCPServerConfigRoleRead,
|
|
},
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Error(), foreignUser.ID.String())
|
|
}
|
|
|
|
// mcpServerConfigDeleteRaceStore deletes the config right after the param
|
|
// middleware read once armed, so the handler's locked re-fetch sees a
|
|
// concurrently deleted row.
|
|
type mcpServerConfigDeleteRaceStore struct {
|
|
database.Store
|
|
|
|
armed atomic.Bool
|
|
}
|
|
|
|
func (s *mcpServerConfigDeleteRaceStore) GetMCPServerConfigByID(ctx context.Context, id uuid.UUID) (database.MCPServerConfig, error) {
|
|
config, err := s.Store.GetMCPServerConfigByID(ctx, id)
|
|
if err == nil && s.armed.CompareAndSwap(true, false) {
|
|
if err := s.DeleteMCPServerConfigByID(ctx, id); err != nil {
|
|
return database.MCPServerConfig{}, err
|
|
}
|
|
}
|
|
return config, err
|
|
}
|
|
|
|
func TestMCPServerConfigACLConcurrentDelete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
db, ps := dbtestutil.NewDB(t)
|
|
store := &mcpServerConfigDeleteRaceStore{Store: db}
|
|
adminClient := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
Database: store,
|
|
Pubsub: ps,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
config := createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "acl-delete-race", true)
|
|
|
|
store.armed.Store(true)
|
|
err := adminClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
}
|
|
|
|
// TestMCPServerConfigsSecretsNeverLeaked is a load-bearing test that
|
|
// ensures secret fields (OAuth2 client secret, API key value, custom
|
|
// headers) are never present in API responses for any caller. If this
|
|
// test fails, it means a code change accidentally started exposing
|
|
// secrets. See: https://github.com/coder/coder/pull/23227#discussion_r2959461109
|
|
func TestMCPServerConfigsSecretsNeverLeaked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
// Create a config with ALL secret fields populated.
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Secrets Test",
|
|
Slug: "secrets-test",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/secrets",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "client-id-secret-test",
|
|
OAuth2ClientSecret: "THIS-IS-A-SECRET-VALUE",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2RevocationURL: "https://auth.example.com/revoke",
|
|
OAuth2Resource: "https://mcp.example.com/secrets",
|
|
OAuth2Scopes: "read write",
|
|
APIKeyHeader: "X-Api-Key",
|
|
APIKeyValue: "THIS-IS-A-SECRET-API-KEY",
|
|
CustomHeaders: map[string]string{"X-Custom": "THIS-IS-A-SECRET-HEADER"},
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The sentinel values we must never see in any JSON response.
|
|
secrets := []string{
|
|
"THIS-IS-A-SECRET-VALUE",
|
|
"THIS-IS-A-SECRET-API-KEY",
|
|
"THIS-IS-A-SECRET-HEADER",
|
|
}
|
|
|
|
assertNoSecrets := func(t *testing.T, label string, v interface{}) {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
jsonStr := string(data)
|
|
for _, secret := range secrets {
|
|
assert.False(t, strings.Contains(jsonStr, secret),
|
|
"%s: JSON response contains secret %q", label, secret)
|
|
}
|
|
}
|
|
|
|
// Verify the create response doesn't leak secrets.
|
|
assertNoSecrets(t, "admin create response", created)
|
|
|
|
// Verify boolean indicators are set correctly.
|
|
require.True(t, created.HasOAuth2Secret, "HasOAuth2Secret should be true")
|
|
require.Equal(t, "https://mcp.example.com/secrets", created.OAuth2Resource)
|
|
require.True(t, created.HasAPIKey, "HasAPIKey should be true")
|
|
require.True(t, created.HasCustomHeaders, "HasCustomHeaders should be true")
|
|
|
|
// Admin list endpoint.
|
|
adminConfigs, err := adminClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, adminConfigs)
|
|
for _, cfg := range adminConfigs {
|
|
assertNoSecrets(t, "admin list", cfg)
|
|
}
|
|
|
|
// Admin get-by-ID endpoint.
|
|
adminSingle, err := adminClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
assertNoSecrets(t, "admin get-by-id", adminSingle)
|
|
|
|
// Non-admin list endpoint.
|
|
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, memberConfigs)
|
|
for _, cfg := range memberConfigs {
|
|
assertNoSecrets(t, "member list", cfg)
|
|
// Non-admin should also not see admin-only fields.
|
|
assert.Empty(t, cfg.OAuth2ClientID, "member should not see OAuth2ClientID")
|
|
assert.Empty(t, cfg.OAuth2AuthURL, "member should not see OAuth2AuthURL")
|
|
assert.Empty(t, cfg.OAuth2TokenURL, "member should not see OAuth2TokenURL")
|
|
assert.Empty(t, cfg.OAuth2RevocationURL, "member should not see OAuth2RevocationURL")
|
|
assert.Empty(t, cfg.OAuth2Resource, "member should not see OAuth2Resource")
|
|
assert.Empty(t, cfg.APIKeyHeader, "member should not see APIKeyHeader")
|
|
assert.Empty(t, cfg.OAuth2Scopes, "member should not see OAuth2Scopes")
|
|
assert.Empty(t, cfg.URL, "member should not see URL")
|
|
assert.Empty(t, cfg.Transport, "member should not see Transport")
|
|
}
|
|
|
|
// Non-admin get-by-ID endpoint.
|
|
memberSingle, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
assertNoSecrets(t, "member get-by-id", memberSingle)
|
|
assert.Empty(t, memberSingle.OAuth2ClientID, "member should not see OAuth2ClientID")
|
|
assert.Empty(t, memberSingle.OAuth2AuthURL, "member should not see OAuth2AuthURL")
|
|
assert.Empty(t, memberSingle.OAuth2TokenURL, "member should not see OAuth2TokenURL")
|
|
assert.Empty(t, memberSingle.OAuth2Scopes, "member should not see OAuth2Scopes")
|
|
assert.Empty(t, memberSingle.OAuth2Resource, "member should not see OAuth2Resource")
|
|
assert.Empty(t, memberSingle.APIKeyHeader, "member should not see APIKeyHeader")
|
|
assert.Empty(t, memberSingle.URL, "member should not see URL")
|
|
assert.Empty(t, memberSingle.Transport, "member should not see Transport")
|
|
}
|
|
|
|
func TestMCPServerConfigsAuthConnected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
// Create an oauth2 server config (enabled).
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Server",
|
|
Slug: "oauth-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/oauth",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Regular user lists configs — auth_connected should be false
|
|
// because no token has been stored.
|
|
memberConfigs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, memberConfigs, 1)
|
|
require.Equal(t, created.ID, memberConfigs[0].ID)
|
|
require.False(t, memberConfigs[0].AuthConnected)
|
|
|
|
// Also create a non-oauth server. It should report
|
|
// auth_connected=true because no auth is needed.
|
|
_ = createMCPServerConfig(t, adminClient, firstUser.OrganizationID, "no-auth-server", true)
|
|
|
|
// And a user_oidc server. user_oidc never requires a per-user
|
|
// connect step, so auth_connected is always true regardless of
|
|
// whether the calling user has an OIDC link.
|
|
_, err = adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "User OIDC Server",
|
|
Slug: "user-oidc-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/oidc",
|
|
AuthType: "user_oidc",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
memberConfigs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, memberConfigs, 3)
|
|
for _, cfg := range memberConfigs {
|
|
switch cfg.AuthType {
|
|
case "none", "user_oidc":
|
|
require.True(t, cfg.AuthConnected, "%s should report auth_connected", cfg.AuthType)
|
|
default:
|
|
require.False(t, cfg.AuthConnected, "%s should not report auth_connected", cfg.AuthType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigsUserOIDCClearsFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Start with an oauth2 config that has a client secret, then
|
|
// switch the auth_type to user_oidc and verify all auth-specific
|
|
// fields are cleared.
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Switch Server",
|
|
Slug: "switch-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/v1",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2ClientSecret: "secret-value",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2RevocationURL: "https://auth.example.com/revoke",
|
|
OAuth2Resource: "https://mcp.example.com/v1",
|
|
OAuth2Scopes: "read write",
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, created.HasOAuth2Secret)
|
|
require.Equal(t, "cid", created.OAuth2ClientID)
|
|
require.Equal(t, "https://auth.example.com/revoke", created.OAuth2RevocationURL)
|
|
|
|
newRevocationURL := "https://auth.example.com/revoke2"
|
|
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &newRevocationURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL)
|
|
|
|
invalidURL := "not a url"
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &invalidURL,
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
|
|
// Plaintext URLs are rejected on save, not later at disconnect.
|
|
plaintextURL := "http://auth.example.com/revoke"
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &plaintextURL,
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
|
|
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Plaintext Revoke",
|
|
Slug: "plaintext-revoke",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/plaintext",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2RevocationURL: plaintextURL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
|
|
// An explicit empty string clears the stored URL.
|
|
emptyURL := ""
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &emptyURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, updated.OAuth2RevocationURL)
|
|
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &newRevocationURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, newRevocationURL, updated.OAuth2RevocationURL)
|
|
|
|
// RFC 8707 resource indicators must be valid absolute URIs without a
|
|
// fragment, on both create and update. Create rejects them before
|
|
// auto-discovery so an invalid value causes no client registration.
|
|
var discoveryRequests atomic.Int64
|
|
discoveryProbe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
discoveryRequests.Add(1)
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(discoveryProbe.Close)
|
|
for _, invalidResource := range []string{
|
|
"https://mcp.example.com/v1#frag",
|
|
"/v1/mcp",
|
|
"https://mcp.example.com/a b",
|
|
"https://mcp.example.com/mcp?tenant=%zz",
|
|
"https://mcp.example.com/a[b]",
|
|
"https://a]b.example.com/mcp",
|
|
"https://[x]/mcp",
|
|
"https://[::1][x]/mcp",
|
|
"https://[fe80::1%25en0]/mcp",
|
|
"https://user@mcp.example.com/mcp",
|
|
} {
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &invalidResource,
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr, invalidResource)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), invalidResource)
|
|
require.Equal(t, "Invalid OAuth2 resource.", sdkErr.Message, invalidResource)
|
|
|
|
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Invalid Resource",
|
|
Slug: "invalid-resource",
|
|
Transport: "streamable_http",
|
|
URL: discoveryProbe.URL + "/mcp",
|
|
AuthType: "oauth2",
|
|
OAuth2Resource: invalidResource,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr, invalidResource)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode(), invalidResource)
|
|
require.Equal(t, "Invalid OAuth2 resource.", sdkErr.Message, invalidResource)
|
|
}
|
|
require.Zero(t, discoveryRequests.Load(), "invalid resource must be rejected before discovery")
|
|
|
|
emptyResource := ""
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &emptyResource,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, updated.OAuth2Resource)
|
|
|
|
newResource := "https://mcp.example.com/v2"
|
|
paddedResource := " " + newResource + " "
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &paddedResource,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, newResource, updated.OAuth2Resource)
|
|
|
|
newAuth := "user_oidc"
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
AuthType: &newAuth,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "user_oidc", updated.AuthType)
|
|
require.False(t, updated.HasOAuth2Secret, "oauth2 secret should be cleared")
|
|
require.False(t, updated.HasAPIKey, "api key should remain unset")
|
|
require.False(t, updated.HasCustomHeaders, "custom headers should remain unset")
|
|
require.Empty(t, updated.OAuth2ClientID)
|
|
require.Empty(t, updated.OAuth2AuthURL)
|
|
require.Empty(t, updated.OAuth2TokenURL)
|
|
require.Empty(t, updated.OAuth2RevocationURL)
|
|
require.Empty(t, updated.OAuth2Resource)
|
|
require.Empty(t, updated.OAuth2Scopes)
|
|
require.Empty(t, updated.APIKeyHeader)
|
|
}
|
|
|
|
// TestMCPServerConfigsAuthTypeChangeClearsOAuth2Resource covers the
|
|
// non-OIDC auth types; TestMCPServerConfigsUserOIDCClearsFields covers
|
|
// user_oidc.
|
|
func TestMCPServerConfigsAuthTypeChangeClearsOAuth2Resource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
header := "X-Api-Key"
|
|
value := "api-key-value"
|
|
customHeaders := map[string]string{"X-Custom": "custom-value"}
|
|
cases := []struct {
|
|
authType string
|
|
req codersdk.UpdateMCPServerConfigRequest
|
|
}{
|
|
{authType: "none"},
|
|
{authType: "api_key", req: codersdk.UpdateMCPServerConfigRequest{APIKeyHeader: &header, APIKeyValue: &value}},
|
|
{authType: "custom_headers", req: codersdk.UpdateMCPServerConfigRequest{CustomHeaders: &customHeaders}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.authType, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
slug := "clear-resource-" + strings.ReplaceAll(tc.authType, "_", "-")
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Clear Resource " + tc.authType,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2Resource: "https://mcp.example.com/" + slug,
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, created.OAuth2Resource)
|
|
|
|
req := tc.req
|
|
req.AuthType = &tc.authType
|
|
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.authType, updated.AuthType)
|
|
require.Empty(t, updated.OAuth2Resource)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMCPServerConfigsAPIKeyRequiresHeaderAndValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
base := codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "API Key Server",
|
|
Slug: "api-key-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/api-key",
|
|
AuthType: "api_key",
|
|
APIKeyHeader: "Authorization",
|
|
APIKeyValue: "Bearer secret",
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
}
|
|
requireBadRequest := func(t *testing.T, err error) {
|
|
t.Helper()
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
}
|
|
|
|
// Whitespace-only values are trimmed before saving, so they count as empty.
|
|
blankHeader := base
|
|
blankHeader.APIKeyHeader = " "
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, blankHeader)
|
|
requireBadRequest(t, err)
|
|
blankValue := base
|
|
blankValue.APIKeyValue = " "
|
|
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, blankValue)
|
|
requireBadRequest(t, err)
|
|
|
|
noAuth := base
|
|
noAuth.AuthType = "none"
|
|
noAuth.APIKeyHeader = ""
|
|
noAuth.APIKeyValue = ""
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, noAuth)
|
|
require.NoError(t, err)
|
|
|
|
apiKeyAuth := "api_key"
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
AuthType: &apiKeyAuth,
|
|
})
|
|
requireBadRequest(t, err)
|
|
|
|
header := "Authorization"
|
|
value := "Bearer secret"
|
|
updated, err := client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
AuthType: &apiKeyAuth,
|
|
APIKeyHeader: &header,
|
|
APIKeyValue: &value,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, updated.HasAPIKey)
|
|
|
|
empty := ""
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
APIKeyHeader: &empty,
|
|
})
|
|
requireBadRequest(t, err)
|
|
_, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
APIKeyValue: &empty,
|
|
})
|
|
requireBadRequest(t, err)
|
|
|
|
disabled := false
|
|
updated, err = client.UpdateMCPServerConfig(ctx, created.OrganizationID, created.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
Enabled: &disabled,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, updated.Enabled)
|
|
require.True(t, updated.HasAPIKey)
|
|
}
|
|
|
|
func TestMCPServerConfigsUserOIDCDirect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create with user_oidc and confirm validation accepts the value
|
|
// while no auth-specific fields are persisted on the row.
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "User OIDC Direct",
|
|
Slug: "user-oidc-direct",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/oidc-direct",
|
|
AuthType: "user_oidc",
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "user_oidc", created.AuthType)
|
|
require.False(t, created.HasOAuth2Secret)
|
|
require.False(t, created.HasAPIKey)
|
|
require.False(t, created.HasCustomHeaders)
|
|
}
|
|
|
|
func TestMCPServerConfigsUpdateInvalidatesUserGrants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
_, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
newConfig := func(ctx context.Context, t *testing.T, slug string) codersdk.MCPServerConfig {
|
|
t.Helper()
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Grant Invalidation " + slug,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2RevocationURL: "https://auth.example.com/revoke",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
return created
|
|
}
|
|
|
|
seedToken := func(ctx context.Context, t *testing.T, configID uuid.UUID) {
|
|
t.Helper()
|
|
//nolint:gocritic // Seeding a member grant requires system access.
|
|
_, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: configID,
|
|
UserID: member.ID,
|
|
AccessToken: "access-token",
|
|
RefreshToken: "refresh-token",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
tokenExists := func(ctx context.Context, t *testing.T, configID uuid.UUID) bool {
|
|
t.Helper()
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
_, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: configID,
|
|
UserID: member.ID,
|
|
})
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false
|
|
}
|
|
require.NoError(t, err)
|
|
return true
|
|
}
|
|
|
|
t.Run("URLChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-url-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
newURL := "https://mcp.example.com/grant-url-change-moved"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
URL: &newURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("AuthTypeChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-auth-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
authType := "none"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
AuthType: &authType,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("TokenURLChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-token-url-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
movedEndpoint := "https://auth.example.com/other-endpoint"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2TokenURL: &movedEndpoint,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("RevocationURLChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-revocation-url-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
movedEndpoint := "https://auth.example.com/other-revocation-endpoint"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &movedEndpoint,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("ClientIDChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-client-id-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
newClientID := "cid-2"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2ClientID: &newClientID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("ResourceChangeDeletesGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-resource-change")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
resource := "https://mcp.example.com/grant-resource-change"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &resource,
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
|
|
t.Run("UnrelatedChangeKeepsGrants", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := newConfig(ctx, t, "grant-unrelated")
|
|
seedToken(ctx, t, config.ID)
|
|
|
|
displayName := "Grant Invalidation renamed"
|
|
newSecret := "rotated-secret"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
DisplayName: &displayName,
|
|
OAuth2ClientSecret: &newSecret,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, tokenExists(ctx, t, config.ID))
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2ScopedKeyAuthorization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
var exchangeRequests atomic.Int64
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
exchangeRequests.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"scoped-access-token","token_type":"Bearer"}`))
|
|
}))
|
|
t.Cleanup(tokenServer.Close)
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Scoped Key",
|
|
Slug: "oauth-scoped-key",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/oauth-scoped-key",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenServer.URL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: firstUser.UserID,
|
|
Scopes: database.APIKeyScopes{
|
|
database.ApiKeyScopeMcpServerConfigRead,
|
|
database.ApiKeyScopeOrganizationRead,
|
|
database.ApiKeyScopeUserReadPersonal,
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(adminClient.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
scopedClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
t.Run("ConnectRequiresPersonalTokenUpdate", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
connectURL := scopedClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, connectURL, nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: scopedClient.SessionToken()})
|
|
|
|
res, err := scopedClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, res.StatusCode)
|
|
})
|
|
|
|
t.Run("CallbackRequiresPersonalTokenUpdateBeforeExchange", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
state := "scoped-key-state"
|
|
callbackURL, err := scopedClient.URL.Parse(
|
|
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
|
|
)
|
|
require.NoError(t, err)
|
|
query := callbackURL.Query()
|
|
query.Set("code", "one-time-code")
|
|
query.Set("state", state)
|
|
callbackURL.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: scopedClient.SessionToken()})
|
|
req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + created.ID.String(), Value: state})
|
|
|
|
res, err := scopedClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusForbidden, res.StatusCode)
|
|
require.Zero(t, exchangeRequests.Load())
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
movedURL := "https://attacker.example.com/superseded"
|
|
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-callback", codersdk.UpdateMCPServerConfigRequest{
|
|
URL: &movedURL,
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededRevocationURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
movedRevocationURL := "https://attacker.example.com/revoke"
|
|
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-revocation-callback", codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2RevocationURL: &movedRevocationURL,
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2CallbackRejectsSupersededResource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
movedResource := "https://attacker.example.com/mcp"
|
|
runMCPServerConfigsOAuth2CallbackSupersessionTest(t, "superseded-resource-callback", codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &movedResource,
|
|
})
|
|
}
|
|
|
|
func runMCPServerConfigsOAuth2CallbackSupersessionTest(
|
|
t *testing.T,
|
|
slug string,
|
|
updateRequest codersdk.UpdateMCPServerConfigRequest,
|
|
) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
var configID atomic.Pointer[uuid.UUID]
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if id := configID.Load(); id != nil {
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, firstUser.OrganizationID, *id, updateRequest)
|
|
assert.NoError(t, err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"superseded-access-token","token_type":"Bearer","expires_in":3600}`))
|
|
}))
|
|
t.Cleanup(tokenServer.Close)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Superseded Callback " + slug,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenServer.URL + "/token",
|
|
OAuth2RevocationURL: "https://auth.example.com/revoke",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
configID.Store(&created.ID)
|
|
|
|
state := "superseded-state"
|
|
callbackURL, err := memberClient.URL.Parse(
|
|
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
|
|
)
|
|
require.NoError(t, err)
|
|
q := callbackURL.Query()
|
|
q.Set("code", "superseded-auth-code")
|
|
q.Set("state", state)
|
|
callbackURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()})
|
|
req.AddCookie(&http.Cookie{Name: "mcp_oauth2_state_" + created.ID.String(), Value: state})
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusConflict, res.StatusCode)
|
|
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
_, err = db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows,
|
|
"no grant may be stored for a callback completed against a superseded config")
|
|
}
|
|
|
|
func TestMCPServerConfigsUserOIDCRequiresDeploymentPerms(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
orgAdminClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID,
|
|
rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID))
|
|
|
|
newRequest := func(slug, authType string) codersdk.CreateMCPServerConfigRequest {
|
|
return codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OIDC Gate " + slug,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: authType,
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
}
|
|
}
|
|
|
|
orgAdminOwned, err := orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-none", "none"))
|
|
require.NoError(t, err)
|
|
|
|
var sdkErr *codersdk.Error
|
|
_, err = orgAdminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("org-admin-oidc", "user_oidc"))
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
|
|
userOIDC := "user_oidc"
|
|
_, err = orgAdminClient.UpdateMCPServerConfig(ctx, orgAdminOwned.OrganizationID, orgAdminOwned.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
AuthType: &userOIDC,
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
|
|
deploymentOwned, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, newRequest("deployment-oidc", "user_oidc"))
|
|
require.NoError(t, err)
|
|
|
|
// The URL determines where chat owners' OIDC tokens are sent.
|
|
newURL := "https://attacker.example.com/exfil"
|
|
_, err = orgAdminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
URL: &newURL,
|
|
})
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
|
|
updatedURL := "https://mcp.example.com/deployment-oidc-v2"
|
|
updated, err := adminClient.UpdateMCPServerConfig(ctx, deploymentOwned.OrganizationID, deploymentOwned.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
URL: &updatedURL,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, updatedURL, updated.URL)
|
|
}
|
|
|
|
func TestMCPServerConfigsAvailability(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
validValues := []string{"force_on", "default_on", "default_off"}
|
|
for _, av := range validValues {
|
|
av := av
|
|
t.Run(av, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Server " + av,
|
|
Slug: "server-" + av,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + av,
|
|
AuthType: "none",
|
|
Availability: av,
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, av, created.Availability)
|
|
})
|
|
}
|
|
|
|
t.Run("InvalidAvailability", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Bad Availability",
|
|
Slug: "bad-avail",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/bad",
|
|
AuthType: "none",
|
|
Availability: "always_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsUniqueSlug(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "First",
|
|
Slug: "test-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/first",
|
|
AuthType: "none",
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Attempt to create another config with the same slug.
|
|
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Second",
|
|
Slug: "test-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/second",
|
|
AuthType: "none",
|
|
Availability: "default_off",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2Disconnect(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newDisconnectFixture := func(t *testing.T, slug, revocationURL string) (memberClient *codersdk.Client, memberID uuid.UUID, db database.Store, configID uuid.UUID) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Disconnect " + slug,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/" + slug,
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
OAuth2RevocationURL: revocationURL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return memberClient, member.ID, db, created.ID
|
|
}
|
|
|
|
seedToken := func(t *testing.T, db database.Store, configID, userID uuid.UUID) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
_, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: configID,
|
|
UserID: userID,
|
|
AccessToken: "access-token",
|
|
RefreshToken: "refresh-token",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
requireTokenDeleted := func(t *testing.T, db database.Store, configID, userID uuid.UUID) {
|
|
t.Helper()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
_, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: configID,
|
|
UserID: userID,
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
}
|
|
|
|
t.Run("ScopedKeyWithoutPersonalTokenUpdate", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-scoped-key", "")
|
|
seedToken(t, db, configID, memberID)
|
|
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: memberID,
|
|
Scopes: database.APIKeyScopes{
|
|
database.ApiKeyScopeUserReadPersonal,
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(memberClient.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
|
|
_, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: configID,
|
|
UserID: memberID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "refresh-token", row.RefreshToken)
|
|
})
|
|
|
|
t.Run("ScopedKeyWithPersonalTokenUpdateOnly", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-update-only", "")
|
|
seedToken(t, db, configID, memberID)
|
|
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: memberID,
|
|
Scopes: database.APIKeyScopes{
|
|
database.ApiKeyScopeUserUpdatePersonal,
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(memberClient.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
|
|
_, err := scopedClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
require.NoError(t, err)
|
|
requireTokenDeleted(t, db, configID, memberID)
|
|
})
|
|
|
|
t.Run("NoToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
memberClient, _, _, configID := newDisconnectFixture(t, "disc-no-token", "")
|
|
|
|
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
require.NoError(t, err)
|
|
require.False(t, resp.TokenRevoked)
|
|
require.Empty(t, resp.TokenRevocationError)
|
|
})
|
|
|
|
t.Run("RemovedOrgMemberCanDisconnect", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
coderdtest.CreateFirstUser(t, adminClient)
|
|
|
|
secondOrg := dbgen.Organization(t, db, database.Organization{})
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, secondOrg.ID)
|
|
config := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{
|
|
OrganizationID: secondOrg.ID,
|
|
AuthType: "oauth2",
|
|
Enabled: true,
|
|
})
|
|
seedToken(t, db, config.ID, member.ID)
|
|
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
systemCtx := dbauthz.AsSystemRestricted(ctx)
|
|
err := db.DeleteOrganizationMember(systemCtx, database.DeleteOrganizationMemberParams{
|
|
OrganizationID: secondOrg.ID,
|
|
UserID: member.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// A token owner removed from the organization can no longer
|
|
// read the config, but must still be able to delete the
|
|
// stored token.
|
|
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, config.ID)
|
|
require.NoError(t, err)
|
|
requireTokenDeleted(t, db, config.ID, member.ID)
|
|
})
|
|
|
|
t.Run("RevokesAtProvider", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
var gotForm atomic.Pointer[url.Values]
|
|
revokeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.NoError(t, r.ParseForm())
|
|
form := r.PostForm
|
|
gotForm.Store(&form)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(revokeSrv.Close)
|
|
|
|
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-revoke", revokeSrv.URL)
|
|
seedToken(t, db, configID, memberID)
|
|
|
|
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.TokenRevoked)
|
|
require.Empty(t, resp.TokenRevocationError)
|
|
|
|
form := gotForm.Load()
|
|
require.NotNil(t, form)
|
|
require.Equal(t, "refresh-token", form.Get("token"))
|
|
require.Equal(t, "refresh_token", form.Get("token_type_hint"))
|
|
require.Equal(t, "cid", form.Get("client_id"))
|
|
|
|
requireTokenDeleted(t, db, configID, memberID)
|
|
})
|
|
|
|
t.Run("RefreshCannotRestoreDisconnectedToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
refreshStarted := make(chan struct{})
|
|
releaseRefresh := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
close(refreshStarted)
|
|
select {
|
|
case <-releaseRefresh:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"fresh-access","refresh_token":"fresh-refresh","token_type":"Bearer","expires_in":3600}`))
|
|
}))
|
|
t.Cleanup(tokenSrv.Close)
|
|
t.Cleanup(func() { releaseOnce.Do(func() { close(releaseRefresh) }) })
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Disconnect Refresh Race",
|
|
Slug: "disc-refresh-race",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/disc-refresh-race",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenSrv.URL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
AccessToken: "expired-access",
|
|
RefreshToken: "old-refresh",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
type configResult struct {
|
|
configs []codersdk.MCPServerConfig
|
|
err error
|
|
}
|
|
result := make(chan configResult, 1)
|
|
go func() {
|
|
configs, listErr := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
result <- configResult{configs: configs, err: listErr}
|
|
}()
|
|
|
|
select {
|
|
case <-refreshStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for token refresh")
|
|
}
|
|
|
|
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
releaseOnce.Do(func() { close(releaseRefresh) })
|
|
|
|
var listed configResult
|
|
select {
|
|
case listed = <-result:
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for refreshed config response")
|
|
}
|
|
require.NoError(t, listed.err)
|
|
require.Len(t, listed.configs, 1)
|
|
require.False(t, listed.configs[0].AuthConnected)
|
|
requireTokenDeleted(t, db, created.ID, member.ID)
|
|
})
|
|
|
|
t.Run("NoRevocationURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-no-url", "")
|
|
seedToken(t, db, configID, memberID)
|
|
|
|
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
require.NoError(t, err)
|
|
require.False(t, resp.TokenRevoked)
|
|
require.Empty(t, resp.TokenRevocationError)
|
|
|
|
requireTokenDeleted(t, db, configID, memberID)
|
|
})
|
|
|
|
t.Run("ProviderError", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
revokeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(revokeSrv.Close)
|
|
|
|
memberClient, memberID, db, configID := newDisconnectFixture(t, "disc-err", revokeSrv.URL)
|
|
seedToken(t, db, configID, memberID)
|
|
|
|
// Members get a generic error; provider bodies may echo the secret.
|
|
resp, err := memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, configID)
|
|
require.NoError(t, err)
|
|
require.False(t, resp.TokenRevoked)
|
|
require.NotEmpty(t, resp.TokenRevocationError)
|
|
require.NotContains(t, resp.TokenRevocationError, "HTTP 500")
|
|
|
|
requireTokenDeleted(t, db, configID, memberID)
|
|
})
|
|
|
|
t.Run("OnlyDisconnectsCallingUser", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
otherClient, other := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OAuth Disconnect Isolation",
|
|
Slug: "disc-isolation",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/disc-isolation",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for _, userID := range []uuid.UUID{member.ID, other.ID} {
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: userID,
|
|
AccessToken: "valid-access",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
requireAuthConnected := func(client *codersdk.Client, want bool) {
|
|
t.Helper()
|
|
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.Equal(t, want, configs[0].AuthConnected)
|
|
}
|
|
requireAuthConnected(memberClient, true)
|
|
requireAuthConnected(otherClient, true)
|
|
|
|
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
requireAuthConnected(memberClient, false)
|
|
requireAuthConnected(otherClient, true)
|
|
|
|
_, err = memberClient.MCPServerOAuth2DisconnectWithResponse(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("Success", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
registrationStarted := make(chan struct{})
|
|
completeRegistration := make(chan struct{})
|
|
var blockRegistration sync.Once
|
|
var completeRegistrationOnce sync.Once
|
|
releaseRegistration := func() {
|
|
completeRegistrationOnce.Do(func() {
|
|
close(completeRegistration)
|
|
})
|
|
}
|
|
|
|
// Stand up a mock auth server that serves RFC 8414 metadata and
|
|
// a RFC 7591 dynamic client registration endpoint.
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"revocation_endpoint": "` + "http://" + r.Host + `/revoke",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["read", "write"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
blockRegistration.Do(func() {
|
|
close(registrationStarted)
|
|
<-completeRegistration
|
|
})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "auto-discovered-client-id",
|
|
"client_secret": "auto-discovered-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// Stand up a mock MCP server that serves RFC 9728 Protected
|
|
// Resource Metadata at the path-aware well-known URL.
|
|
// The URL used for the config ends with /v1/mcp, so the
|
|
// path-aware metadata URL is
|
|
// /.well-known/oauth-protected-resource/v1/mcp.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
type createResult struct {
|
|
config codersdk.MCPServerConfig
|
|
err error
|
|
}
|
|
createdCh := make(chan createResult, 1)
|
|
testutil.Go(t, func() {
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Auto-Discovery Server",
|
|
Slug: "auto-discovery",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
createdCh <- createResult{config: created, err: err}
|
|
})
|
|
t.Cleanup(releaseRegistration)
|
|
|
|
testutil.TryReceive(ctx, t, registrationStarted)
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
_, err := db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Slug: "auto-discovery",
|
|
})
|
|
releaseRegistration()
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
|
|
create := testutil.RequireReceive(ctx, t, createdCh)
|
|
require.NoError(t, create.err)
|
|
created := create.config
|
|
require.Equal(t, "auto-discovered-client-id", created.OAuth2ClientID)
|
|
require.True(t, created.HasOAuth2Secret)
|
|
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
|
|
require.Equal(t, authServer.URL+"/revoke", created.OAuth2RevocationURL)
|
|
require.Equal(t, "read write", created.OAuth2Scopes)
|
|
// The origin-only PRM resource covers the /v1/mcp server URL.
|
|
require.Equal(t, mcpServer.URL, created.OAuth2Resource)
|
|
|
|
// Explicit values win over the discovered ones.
|
|
overridden, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Auto-Discovery Override",
|
|
Slug: "auto-discovery-override",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
OAuth2RevocationURL: "https://override.example.com/revoke",
|
|
OAuth2Resource: "https://override.example.com/mcp",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "https://override.example.com/revoke", overridden.OAuth2RevocationURL)
|
|
require.Equal(t, "https://override.example.com/mcp", overridden.OAuth2Resource)
|
|
})
|
|
|
|
t.Run("CreateOnlyScopeAllowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// MCP config scopes are not user-mintable, so seed a create-only key.
|
|
_, token := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: firstUser.UserID,
|
|
Scopes: database.APIKeyScopes{
|
|
"mcp_server_config:create",
|
|
"organization:read",
|
|
},
|
|
})
|
|
scopedClient := codersdk.New(client.URL)
|
|
scopedClient.SetSessionToken(token)
|
|
var registrationRequests atomic.Int64
|
|
|
|
created, err := scopedClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Create Only Discovery",
|
|
Slug: "create-only-discovery",
|
|
Transport: "streamable_http",
|
|
URL: newMCPDiscoveryServer(t, ®istrationRequests),
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "discovered-client-id", created.OAuth2ClientID)
|
|
require.EqualValues(t, 1, registrationRequests.Load())
|
|
})
|
|
|
|
t.Run("ExistingSlugSkipsRegistration", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
createMCPServerConfig(t, client, firstUser.OrganizationID, "discovery-conflict", true)
|
|
var registrationRequests atomic.Int64
|
|
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Discovery Conflict",
|
|
Slug: " discovery-conflict ",
|
|
Transport: "streamable_http",
|
|
URL: newMCPDiscoveryServer(t, ®istrationRequests),
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusConflict, sdkErr.StatusCode())
|
|
require.Equal(t, "MCP server config already exists.", sdkErr.Message)
|
|
require.Zero(t, registrationRequests.Load())
|
|
})
|
|
|
|
t.Run("PathAwareTakesPriority", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Auth server that returns "path-scope" as the supported
|
|
// scope.
|
|
pathAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["path-scope"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "path-client-id",
|
|
"client_secret": "path-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(pathAuthServer.Close)
|
|
|
|
// Auth server that returns "root-scope" as the supported
|
|
// scope.
|
|
rootAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["root-scope"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "root-client-id",
|
|
"client_secret": "root-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(rootAuthServer.Close)
|
|
|
|
// MCP server serves different protected resource metadata at
|
|
// path-aware vs root URLs, each pointing to a different auth
|
|
// server.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/v1/mcp",
|
|
"authorization_servers": ["` + pathAuthServer.URL + `"]
|
|
}`))
|
|
case "/.well-known/oauth-protected-resource":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + rootAuthServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Priority Test",
|
|
Slug: "priority-test",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
// The path-aware auth server returns "path-scope", the root
|
|
// auth server returns "root-scope". If path-aware takes
|
|
// priority, we get "path-scope".
|
|
require.Equal(t, "path-client-id", created.OAuth2ClientID)
|
|
require.Equal(t, "path-scope", created.OAuth2Scopes)
|
|
})
|
|
|
|
// Verify discovery works when the protected resource metadata
|
|
// is only available at the root-level well-known URL (no path
|
|
// component). This covers servers that don't use path-aware
|
|
// metadata.
|
|
t.Run("RootLevelFallback", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["all"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "root-client-id",
|
|
"client_secret": "root-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// MCP server only serves metadata at the root well-known
|
|
// URL, NOT at the path-aware location.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Root Fallback Server",
|
|
Slug: "root-fallback",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "root-client-id", created.OAuth2ClientID)
|
|
require.True(t, created.HasOAuth2Secret)
|
|
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "all", created.OAuth2Scopes)
|
|
})
|
|
|
|
// Verify that when the authorization server issuer URL has a
|
|
// path component (e.g. https://github.com/login/oauth), the
|
|
// discovery uses the path-aware metadata URL per RFC 8414 §3.1.
|
|
t.Run("PathAwareAuthServerMetadata", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Auth server that serves metadata at the path-aware URL.
|
|
// The issuer URL is http://host/login/oauth, so the
|
|
// metadata URL should be
|
|
// /.well-known/oauth-authorization-server/login/oauth.
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server/login/oauth":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `/login/oauth",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/login/oauth/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/login/oauth/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["repo", "read:org"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "path-aware-client-id"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// MCP server that points to an auth server with a path
|
|
// in its issuer URL (like GitHub's /login/oauth).
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/mcp",
|
|
"authorization_servers": ["` + authServer.URL + `/login/oauth"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Path-Aware Auth",
|
|
Slug: "path-aware-auth",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "path-aware-client-id", created.OAuth2ClientID)
|
|
require.Equal(t, authServer.URL+"/login/oauth/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/login/oauth/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "repo read:org", created.OAuth2Scopes)
|
|
})
|
|
|
|
// Regression test: verify that during dynamic client registration
|
|
// the redirect_uris sent to the authorization server contain the
|
|
// real config UUID, NOT the literal string "{id}". Before the
|
|
// fix, the callback URL was built before the config row existed,
|
|
// so it contained "{id}" literally, which caused "redirect URIs
|
|
// not approved" errors when the user later tried to connect.
|
|
t.Run("RedirectURIContainsRealConfigID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Buffered channel so the handler never blocks.
|
|
registeredRedirectURI := make(chan string, 1)
|
|
|
|
// Stand up a mock auth server that captures the redirect_uris
|
|
// from the RFC 7591 Dynamic Client Registration request.
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["read", "write"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Decode the registration body and capture redirect_uris.
|
|
var body map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if uris, ok := body["redirect_uris"].([]interface{}); ok && len(uris) > 0 {
|
|
if uri, ok := uris[0].(string); ok {
|
|
registeredRedirectURI <- uri
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "test-client-id",
|
|
"client_secret": "test-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// Stand up a mock MCP server that returns RFC 9728 Protected
|
|
// Resource Metadata pointing to the auth server.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp",
|
|
"/.well-known/oauth-protected-resource":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Create config with auth_type=oauth2 but no OAuth2 fields to
|
|
// trigger auto-discovery and dynamic client registration.
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Redirect URI Test",
|
|
Slug: "redirect-uri-test",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "test-client-id", created.OAuth2ClientID)
|
|
require.True(t, created.HasOAuth2Secret)
|
|
|
|
// The registration request has already completed by the time
|
|
// CreateMCPServerConfig returns, so the URI is in the channel.
|
|
var redirectURI string
|
|
select {
|
|
case redirectURI = <-registeredRedirectURI:
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for registration redirect URI")
|
|
}
|
|
|
|
// Core assertion: the redirect URI must NOT contain the
|
|
// literal placeholder "{id}". Before the fix the callback
|
|
// URL was built before the database insert, so it had
|
|
// "{id}" where the UUID should be.
|
|
require.NotContains(t, redirectURI, "{id}",
|
|
"redirect URI sent during registration must not contain the literal \"{id}\" placeholder")
|
|
|
|
// Verify the redirect URI contains the real config UUID that
|
|
// was assigned by the database.
|
|
require.Contains(t, redirectURI, created.ID.String(),
|
|
"redirect URI should contain the actual config UUID")
|
|
|
|
// Sanity-check the full path structure.
|
|
require.Contains(t, redirectURI,
|
|
"/api/experimental/mcp/servers/"+created.ID.String()+"/oauth2/callback",
|
|
"redirect URI should have the expected callback path")
|
|
|
|
// Double-check that the ID segment is a valid UUID (not some
|
|
// other placeholder or malformed value).
|
|
pathParts := strings.Split(redirectURI, "/")
|
|
var foundUUID bool
|
|
for _, part := range pathParts {
|
|
if _, err := uuid.Parse(part); err == nil {
|
|
foundUUID = true
|
|
require.Equal(t, created.ID.String(), part,
|
|
"UUID in redirect URI path should match created config ID")
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundUUID,
|
|
"redirect URI path should contain a valid UUID segment")
|
|
})
|
|
|
|
t.Run("PartialOAuth2FieldsRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Provide client_id but omit auth_url and token_url.
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Partial Fields",
|
|
Slug: "partial-oauth2",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/partial",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "only-client-id",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "automatic discovery")
|
|
})
|
|
|
|
t.Run("DiscoveryFailure", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// MCP server that returns 404 for the well-known endpoint and
|
|
// a non-401 status for the root — discovery has nothing to latch
|
|
// onto.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Will Fail",
|
|
Slug: "discovery-fail",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "auto-discovery failed")
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
_, err = db.GetMCPServerConfigByOrganizationAndSlug(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerConfigByOrganizationAndSlugParams{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Slug: "discovery-fail",
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
})
|
|
|
|
t.Run("ManualConfigStillWorks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Providing all three OAuth2 fields bypasses discovery entirely.
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Manual Config",
|
|
Slug: "manual-oauth2",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/manual",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "manual-client-id",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "manual-client-id", created.OAuth2ClientID)
|
|
require.Equal(t, "https://auth.example.com/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, "https://auth.example.com/token", created.OAuth2TokenURL)
|
|
})
|
|
}
|
|
|
|
// TestMCPServerConfigsOAuth2AutoDiscoverySSRF is a regression test for
|
|
// CDM-02-002: OAuth2 auto-discovery followed attacker-controlled
|
|
// redirects to internal addresses. The canary on 127.0.0.2 stands in
|
|
// for an internal-only service (e.g. cloud metadata) and must never
|
|
// be reached, while the attacker's MCP server on 127.0.0.1 is
|
|
// reachable via the test allowlist.
|
|
func TestMCPServerConfigsOAuth2AutoDiscoverySSRF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
canaryLn, err := net.Listen("tcp", "127.0.0.2:0")
|
|
if err != nil {
|
|
t.Skipf("cannot bind 127.0.0.2 (loopback aliasing unsupported?): %v", err)
|
|
}
|
|
var canaryHits atomic.Int64
|
|
canary := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
canaryHits.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"internal":"secret"}`))
|
|
}))
|
|
_ = canary.Listener.Close()
|
|
canary.Listener = canaryLn
|
|
canary.Start()
|
|
t.Cleanup(canary.Close)
|
|
|
|
// Attacker-controlled MCP server: redirects every discovery
|
|
// fetch to the internal canary.
|
|
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, canary.URL+r.URL.Path, http.StatusFound)
|
|
}))
|
|
t.Cleanup(attacker.Close)
|
|
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
// Allow only the attacker's address so the initial fetch
|
|
// succeeds; the canary's address stays blocked.
|
|
MCPAllowedPrivateCIDRs: []netip.Prefix{
|
|
netip.MustParsePrefix("127.0.0.1/32"),
|
|
},
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err = client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "SSRF Attacker",
|
|
Slug: "ssrf-attacker",
|
|
Transport: "streamable_http",
|
|
URL: attacker.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "auto-discovery failed")
|
|
require.EqualValues(t, 0, canaryHits.Load(), "internal canary must never be contacted via attacker redirect")
|
|
|
|
// The partially created config must have been cleaned up.
|
|
configs, err := client.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
for _, config := range configs {
|
|
require.NotEqual(t, "ssrf-attacker", config.Slug)
|
|
}
|
|
}
|
|
|
|
// TestMCPServerOAuth2ResourceIndicator runs the connect and callback flow
|
|
// against an authorization server that, like Dagster+, rejects requests
|
|
// without the expected RFC 8707 resource parameter.
|
|
func TestMCPServerOAuth2ResourceIndicator(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const resource = "https://mcp.example.com/mcp"
|
|
invalidTarget := func(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"invalid_target","error_description":"MCP OAuth requests must include resource"}`))
|
|
}
|
|
var redeemedCodes sync.Map
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/authorize":
|
|
if r.URL.Query().Get("resource") != resource {
|
|
invalidTarget(w)
|
|
return
|
|
}
|
|
redirect, err := url.Parse(r.URL.Query().Get("redirect_uri"))
|
|
if !assert.NoError(t, err) {
|
|
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
|
return
|
|
}
|
|
q := redirect.Query()
|
|
q.Set("code", "code-"+r.URL.Query().Get("state"))
|
|
q.Set("state", r.URL.Query().Get("state"))
|
|
redirect.RawQuery = q.Encode()
|
|
http.Redirect(w, r, redirect.String(), http.StatusFound)
|
|
case "/token":
|
|
if err := r.ParseForm(); err != nil {
|
|
invalidTarget(w)
|
|
return
|
|
}
|
|
redeemedCodes.Store(r.PostForm.Get("code"), struct{}{})
|
|
if r.PostForm.Get("resource") != resource {
|
|
invalidTarget(w)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"resource-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"resource-refresh-token"}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
createConfig := func(ctx context.Context, t *testing.T, slug, oauth2Resource string) codersdk.MCPServerConfig {
|
|
t.Helper()
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Resource " + slug,
|
|
Slug: slug,
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/mcp",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "resource-client",
|
|
OAuth2AuthURL: authServer.URL + "/authorize",
|
|
OAuth2TokenURL: authServer.URL + "/token",
|
|
OAuth2Resource: oauth2Resource,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
return created
|
|
}
|
|
|
|
connect := func(ctx context.Context, t *testing.T, config codersdk.MCPServerConfig) (*url.URL, []*http.Cookie) {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, memberClient.MCPServerOAuth2ConnectURL(config.OrganizationID, config.ID), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()})
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
|
|
location, err := res.Location()
|
|
require.NoError(t, err)
|
|
return location, res.Cookies()
|
|
}
|
|
|
|
authorize := func(ctx context.Context, t *testing.T, authorizeURL *url.URL) *url.URL {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL.String(), nil)
|
|
require.NoError(t, err)
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusFound, res.StatusCode, "strict authorization server rejected the authorize request")
|
|
callbackURL, err := res.Location()
|
|
require.NoError(t, err)
|
|
return callbackURL
|
|
}
|
|
|
|
callback := func(ctx context.Context, t *testing.T, callbackURL *url.URL, cookies []*http.Cookie) int {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, callbackURL.String(), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{Name: codersdk.SessionTokenCookie, Value: memberClient.SessionToken()})
|
|
for _, c := range cookies {
|
|
req.AddCookie(&http.Cookie{Name: c.Name, Value: c.Value})
|
|
}
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
return res.StatusCode
|
|
}
|
|
|
|
t.Run("ConfiguredResourceIsSent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := createConfig(ctx, t, "resource-configured", resource)
|
|
authorizeURL, cookies := connect(ctx, t, config)
|
|
require.Equal(t, resource, authorizeURL.Query().Get("resource"))
|
|
|
|
callbackURL := authorize(ctx, t, authorizeURL)
|
|
require.Equal(t, http.StatusOK, callback(ctx, t, callbackURL, cookies), "callback must exchange the code for the authorized resource")
|
|
|
|
fetched, err := memberClient.MCPServerConfigByID(ctx, config.OrganizationID, config.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, fetched.AuthConnected, "callback must store the exchanged token")
|
|
})
|
|
|
|
t.Run("ResourceChangedAfterConnectIsRejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := createConfig(ctx, t, "resource-changed", resource)
|
|
authorizeURL, cookies := connect(ctx, t, config)
|
|
callbackURL := authorize(ctx, t, authorizeURL)
|
|
|
|
movedResource := "https://mcp.example.com/moved"
|
|
_, err := adminClient.UpdateMCPServerConfig(ctx, config.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigRequest{
|
|
OAuth2Resource: &movedResource,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, http.StatusConflict, callback(ctx, t, callbackURL, cookies))
|
|
_, redeemed := redeemedCodes.Load(callbackURL.Query().Get("code"))
|
|
require.False(t, redeemed, "a code authorized for one resource must not be redeemed for another")
|
|
})
|
|
|
|
t.Run("ManualConfigWithoutResourceSendsNone", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
config := createConfig(ctx, t, "resource-unset", "")
|
|
authorizeURL, _ := connect(ctx, t, config)
|
|
require.False(t, authorizeURL.Query().Has("resource"),
|
|
"manual configs without oauth2_resource must not send a resource parameter")
|
|
})
|
|
}
|
|
|
|
// nolint:bodyclose
|
|
func TestMCPServerOAuth2PKCE(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ConnectSetsPKCEParams", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
// Create an OAuth2 MCP server config.
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "PKCE Test",
|
|
Slug: "pkce-test",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/pkce",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "test-client",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: "https://auth.example.com/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Prevent the HTTP client from following redirects so we
|
|
// can inspect the response headers and cookies directly.
|
|
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
connectURL := memberClient.MCPServerOAuth2ConnectURL(created.OrganizationID, created.ID)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", connectURL, nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: memberClient.SessionToken(),
|
|
})
|
|
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
|
|
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
|
|
|
|
// The redirect URL must contain PKCE query parameters.
|
|
location, err := res.Location()
|
|
require.NoError(t, err)
|
|
query := location.Query()
|
|
require.Equal(t, "S256", query.Get("code_challenge_method"),
|
|
"connect redirect must include code_challenge_method=S256")
|
|
require.NotEmpty(t, query.Get("code_challenge"),
|
|
"connect redirect must include a code_challenge")
|
|
|
|
// The callback path is frozen because it is registered as a
|
|
// redirect URI with external authorization servers.
|
|
frozenCallbackPath := "/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback"
|
|
redirectURI, err := url.Parse(query.Get("redirect_uri"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, frozenCallbackPath, redirectURI.Path,
|
|
"outbound redirect_uri must use the frozen callback path")
|
|
|
|
var stateCookie, verifierCookie *http.Cookie
|
|
for _, c := range res.Cookies() {
|
|
switch c.Name {
|
|
case "mcp_oauth2_state_" + created.ID.String():
|
|
stateCookie = c
|
|
case "mcp_oauth2_verifier_" + created.ID.String():
|
|
verifierCookie = c
|
|
}
|
|
}
|
|
require.NotNil(t, stateCookie, "response must set a state cookie")
|
|
require.Equal(t, frozenCallbackPath, stateCookie.Path,
|
|
"state cookie must be scoped to the frozen callback path")
|
|
require.NotNil(t, verifierCookie, "response must set a PKCE verifier cookie")
|
|
require.NotEmpty(t, verifierCookie.Value)
|
|
require.Equal(t, frozenCallbackPath, verifierCookie.Path,
|
|
"verifier cookie must be scoped to the frozen callback path")
|
|
|
|
// Verify the code_challenge matches SHA256(verifier).
|
|
h := sha256.Sum256([]byte(verifierCookie.Value))
|
|
expectedChallenge := base64.RawURLEncoding.EncodeToString(h[:])
|
|
require.Equal(t, expectedChallenge, query.Get("code_challenge"),
|
|
"code_challenge must equal base64url(SHA256(verifier))")
|
|
})
|
|
|
|
t.Run("CallbackSendsVerifier", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Track the code_verifier received by the mock token endpoint.
|
|
receivedVerifier := make(chan string, 1)
|
|
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/token" && r.Method == http.MethodPost {
|
|
if err := r.ParseForm(); err == nil {
|
|
receivedVerifier <- r.FormValue("code_verifier")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"access_token": "test-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "test-refresh-token"
|
|
}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(tokenServer.Close)
|
|
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "PKCE Callback Test",
|
|
Slug: "pkce-callback",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/pkce-cb",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "test-client",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenServer.URL + "/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
// Simulate the callback with a known state and verifier.
|
|
state := "test-state-value"
|
|
verifier := "test-verifier-value-that-is-at-least-43-chars-long-for-pkce-spec"
|
|
|
|
callbackURL, err := memberClient.URL.Parse(
|
|
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
|
|
)
|
|
require.NoError(t, err)
|
|
q := callbackURL.Query()
|
|
q.Set("code", "test-auth-code")
|
|
q.Set("state", state)
|
|
callbackURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", callbackURL.String(), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: memberClient.SessionToken(),
|
|
})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "mcp_oauth2_state_" + created.ID.String(),
|
|
Value: state,
|
|
})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "mcp_oauth2_verifier_" + created.ID.String(),
|
|
Value: verifier,
|
|
})
|
|
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, res.StatusCode,
|
|
"callback should succeed when given valid state, verifier, and code")
|
|
|
|
// Verify the mock token endpoint received the code_verifier.
|
|
var gotVerifier string
|
|
select {
|
|
case gotVerifier = <-receivedVerifier:
|
|
case <-ctx.Done():
|
|
t.Fatal("timed out waiting for token exchange")
|
|
}
|
|
require.Equal(t, verifier, gotVerifier,
|
|
"token exchange must send the PKCE code_verifier")
|
|
|
|
// Verify the verifier cookie is cleared in the response.
|
|
var clearedVerifier *http.Cookie
|
|
for _, c := range res.Cookies() {
|
|
if c.Name == "mcp_oauth2_verifier_"+created.ID.String() {
|
|
clearedVerifier = c
|
|
}
|
|
}
|
|
require.NotNil(t, clearedVerifier, "callback must clear the verifier cookie")
|
|
require.Equal(t, -1, clearedVerifier.MaxAge,
|
|
"verifier cookie must be cleared after callback")
|
|
require.Equal(t, callbackURL.Path, clearedVerifier.Path,
|
|
"cleared verifier cookie must be scoped to the frozen callback path")
|
|
})
|
|
|
|
t.Run("CallbackWithoutVerifierStillWorks", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Token endpoint that does not require a code_verifier.
|
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/token" && r.Method == http.MethodPost {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"access_token": "no-pkce-token",
|
|
"token_type": "Bearer"
|
|
}`))
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(tokenServer.Close)
|
|
|
|
adminClient := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "No PKCE Callback",
|
|
Slug: "no-pkce-callback",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/no-pkce",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "test-client",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenServer.URL + "/token",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
memberClient.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
// Call the callback without a verifier cookie to verify
|
|
// backwards compatibility with providers that don't use PKCE.
|
|
state := "test-state-no-pkce"
|
|
callbackURL, err := memberClient.URL.Parse(
|
|
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/callback",
|
|
)
|
|
require.NoError(t, err)
|
|
q := callbackURL.Query()
|
|
q.Set("code", "test-auth-code")
|
|
q.Set("state", state)
|
|
callbackURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", callbackURL.String(), nil)
|
|
require.NoError(t, err)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: memberClient.SessionToken(),
|
|
})
|
|
req.AddCookie(&http.Cookie{
|
|
Name: "mcp_oauth2_state_" + created.ID.String(),
|
|
Value: state,
|
|
})
|
|
// Deliberately omit the verifier cookie.
|
|
|
|
res, err := memberClient.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, res.StatusCode,
|
|
"callback without verifier cookie should still succeed")
|
|
})
|
|
}
|
|
|
|
func TestChatWithMCPServerIDs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
expClient := codersdk.NewExperimentalClient(client)
|
|
|
|
// Create the chat model config required for creating a chat.
|
|
_ = createChatModelForMCP(t, expClient)
|
|
|
|
// Create enabled MCP server configs.
|
|
mcpConfigA := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-a", true)
|
|
mcpConfigB := createMCPServerConfig(t, client, firstUser.OrganizationID, "chat-mcp-server-b", true)
|
|
|
|
// Create a chat referencing the MCP servers.
|
|
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{
|
|
{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "hello with mcp server",
|
|
},
|
|
},
|
|
MCPServerIDs: []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, uuid.Nil, chat.ID)
|
|
require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, chat.MCPServerIDs)
|
|
|
|
// Fetch the chat and verify the MCP server IDs persist.
|
|
fetched, err := expClient.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, fetched.MCPServerIDs)
|
|
|
|
err = client.DeleteMCPServerConfig(ctx, mcpConfigA.OrganizationID, mcpConfigA.ID)
|
|
require.NoError(t, err)
|
|
|
|
fetched, err = expClient.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, fetched.MCPServerIDs, mcpConfigA.ID)
|
|
require.Contains(t, fetched.MCPServerIDs, mcpConfigB.ID)
|
|
}
|
|
|
|
func createChatModelForMCP(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModel {
|
|
t.Helper()
|
|
return coderdtest.CreateOpenAICompatChatModel(t, client, "")
|
|
}
|
|
|
|
func TestMCPOAuth2DiscoveryEdgeCases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("EmptyAuthorizationServers", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// When the path-aware PRM returns an empty
|
|
// authorization_servers array, discovery should fall
|
|
// back to the root-level PRM.
|
|
t.Run("RootFallback", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["fallback-scope"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "fallback-client-id",
|
|
"client_secret": "fallback-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
// Path-aware: empty authorization_servers.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/v1/mcp",
|
|
"authorization_servers": []
|
|
}`))
|
|
case "/.well-known/oauth-protected-resource":
|
|
// Root: valid authorization_servers.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Empty Auth Servers Fallback",
|
|
Slug: "empty-as-fallback",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "fallback-client-id", created.OAuth2ClientID)
|
|
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "fallback-scope", created.OAuth2Scopes)
|
|
})
|
|
|
|
// When both path-aware and root PRM return empty
|
|
// authorization_servers, discovery should fail.
|
|
t.Run("BothEmpty", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp",
|
|
"/.well-known/oauth-protected-resource":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": []
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Both Empty",
|
|
Slug: "both-empty-as",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "auto-discovery failed")
|
|
})
|
|
})
|
|
|
|
// When the path-aware PRM returns malformed JSON,
|
|
// discovery should fall back to the root-level PRM.
|
|
t.Run("MalformedJSONFromDiscovery", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["json-fallback"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "json-fallback-client",
|
|
"client_secret": "json-fallback-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
// Return valid HTTP 200 but invalid JSON.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`not json`))
|
|
case "/.well-known/oauth-protected-resource":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Malformed JSON Fallback",
|
|
Slug: "malformed-json",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "json-fallback-client", created.OAuth2ClientID)
|
|
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "json-fallback", created.OAuth2Scopes)
|
|
})
|
|
|
|
// When the path-aware auth server metadata is missing required
|
|
// endpoints, discovery should fall back to the root-level
|
|
// metadata URL.
|
|
t.Run("AuthServerMetadataMissingEndpoints", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Auth server that returns incomplete metadata at the
|
|
// path-aware URL but complete metadata at the root URL.
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server/auth":
|
|
// Path-aware: missing required endpoints.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `/auth"
|
|
}`))
|
|
case "/.well-known/oauth-authorization-server":
|
|
// Root-level: complete metadata.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["endpoint-fallback"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "endpoint-fallback-client",
|
|
"client_secret": "endpoint-fallback-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// PRM points to auth server with a path (/auth) so that
|
|
// discoverAuthServerMetadata tries the path-aware URL first.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/v1/mcp",
|
|
"authorization_servers": ["` + authServer.URL + `/auth"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Missing Endpoints Fallback",
|
|
Slug: "missing-endpoints",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "endpoint-fallback-client", created.OAuth2ClientID)
|
|
require.Equal(t, authServer.URL+"/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "endpoint-fallback", created.OAuth2Scopes)
|
|
})
|
|
|
|
// When both RFC 8414 metadata URLs (path-aware and root) fail,
|
|
// discovery should fall back to the OIDC well-known URL.
|
|
// The auth server issuer has a path (/login/oauth) so the
|
|
// OIDC URL is {issuer}/.well-known/openid-configuration =
|
|
// /login/oauth/.well-known/openid-configuration.
|
|
t.Run("OIDCFallback", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/login/oauth/.well-known/openid-configuration":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `/login/oauth",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/login/oauth/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/login/oauth/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["oidc-scope"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "oidc-client-id",
|
|
"client_secret": "oidc-client-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// PRM points to auth server with a path (/login/oauth)
|
|
// so that RFC 8414 URLs are tried first and fail.
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/v1/mcp",
|
|
"authorization_servers": ["` + authServer.URL + `/login/oauth"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "OIDC Fallback",
|
|
Slug: "oidc-fallback",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "oidc-client-id", created.OAuth2ClientID)
|
|
require.Equal(t, authServer.URL+"/login/oauth/authorize", created.OAuth2AuthURL)
|
|
require.Equal(t, authServer.URL+"/login/oauth/token", created.OAuth2TokenURL)
|
|
require.Equal(t, "oidc-scope", created.OAuth2Scopes)
|
|
})
|
|
|
|
// When the registration endpoint returns a response
|
|
// without a client_id, the entire discovery flow should
|
|
// fail.
|
|
t.Run("RegistrationMissingClientID", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// Return response with client_secret but no
|
|
// client_id.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_secret": "secret-without-id"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/v1/mcp":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/v1/mcp",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Missing Client ID",
|
|
Slug: "missing-client-id",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/v1/mcp",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "auto-discovery failed")
|
|
})
|
|
|
|
// Regression test for the exact scenario that motivated the PR:
|
|
// an MCP server URL with a trailing slash (like
|
|
// https://api.githubcopilot.com/mcp/).
|
|
t.Run("TrailingSlashURL", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-authorization-server":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"issuer": "` + "http://" + r.Host + `",
|
|
"authorization_endpoint": "` + "http://" + r.Host + `/authorize",
|
|
"token_endpoint": "` + "http://" + r.Host + `/token",
|
|
"registration_endpoint": "` + "http://" + r.Host + `/register",
|
|
"response_types_supported": ["code"],
|
|
"scopes_supported": ["read"]
|
|
}`))
|
|
case "/register":
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{
|
|
"client_id": "trailing-slash-client",
|
|
"client_secret": "trailing-slash-secret"
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(authServer.Close)
|
|
|
|
// Serve protected resource metadata at the path-aware URL
|
|
// WITH the trailing slash: /.well-known/oauth-protected-resource/mcp/
|
|
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/oauth-protected-resource/mcp/":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"resource": "` + "http://" + r.Host + `/mcp/",
|
|
"authorization_servers": ["` + authServer.URL + `"]
|
|
}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(mcpServer.Close)
|
|
|
|
client := newMCPClient(t)
|
|
firstUser := coderdtest.CreateFirstUser(t, client)
|
|
|
|
// URL has a trailing slash, matching the GitHub Copilot URL
|
|
// pattern: https://api.githubcopilot.com/mcp/
|
|
created, err := client.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Trailing Slash",
|
|
Slug: "trailing-slash",
|
|
Transport: "streamable_http",
|
|
URL: mcpServer.URL + "/mcp/",
|
|
AuthType: "oauth2",
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "trailing-slash-client", created.OAuth2ClientID)
|
|
require.True(t, created.HasOAuth2Secret)
|
|
})
|
|
}
|
|
|
|
func TestMCPServerConfigsRevokedGrant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
var tokenEndpointHits atomic.Int64
|
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
tokenEndpointHits.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"grant revoked"}`))
|
|
}))
|
|
t.Cleanup(tokenSrv.Close)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Revoked Server",
|
|
Slug: "revoked-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/v1",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenSrv.URL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
require.False(t, created.AuthConnected)
|
|
|
|
// Seed an expired token whose refresh the provider rejects with
|
|
// invalid_grant.
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
seeded, err := db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
AccessToken: "expired-access",
|
|
RefreshToken: "dead-refresh",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// First list: the refresh fails permanently, so the server is
|
|
// reported as not connected and the failure is persisted.
|
|
configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.False(t, configs[0].AuthConnected)
|
|
// The oauth2 package may probe both client auth styles, so the
|
|
// exact count varies; what matters is that it never grows again.
|
|
hitsAfterFirstList := tokenEndpointHits.Load()
|
|
require.Positive(t, hitsAfterFirstList)
|
|
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, row.AccessToken)
|
|
require.Empty(t, row.RefreshToken)
|
|
require.False(t, row.Expiry.Valid)
|
|
require.Contains(t, row.OauthRefreshFailureReason, "invalid_grant")
|
|
|
|
// Second list: the cached failure short-circuits, so the provider
|
|
// is not called again.
|
|
configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.False(t, configs[0].AuthConnected)
|
|
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
|
|
|
|
// The single-config endpoint agrees.
|
|
single, err := memberClient.MCPServerConfigByID(ctx, created.OrganizationID, created.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, single.AuthConnected)
|
|
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
|
|
|
|
// A stale optimistic-lock update must not clobber the row.
|
|
//nolint:gocritic // Exercising the query requires system access.
|
|
_, err = db.MarkMCPServerUserTokenRefreshFailure(dbauthz.AsSystemRestricted(ctx), database.MarkMCPServerUserTokenRefreshFailureParams{
|
|
ID: seeded.ID,
|
|
UpdatedAt: seeded.UpdatedAt,
|
|
OauthRefreshFailureReason: "stale",
|
|
})
|
|
require.ErrorIs(t, err, sql.ErrNoRows)
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
row, err = db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, "stale", row.OauthRefreshFailureReason)
|
|
|
|
// Re-authenticating (upserting fresh token material) clears the
|
|
// failure and restores connected status.
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
AccessToken: "new-access",
|
|
RefreshToken: "new-refresh",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
configs, err = memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.True(t, configs[0].AuthConnected)
|
|
// The token is valid, so no refresh call is made.
|
|
require.Equal(t, hitsAfterFirstList, tokenEndpointHits.Load())
|
|
}
|
|
|
|
func TestMCPServerConfigsTransientRefreshFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t)
|
|
adminClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: mcpDeploymentValues(t),
|
|
ChatProviderAPIKeys: &providerKeys,
|
|
})
|
|
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
|
|
|
|
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(tokenSrv.Close)
|
|
|
|
created, err := adminClient.CreateMCPServerConfig(ctx, firstUser.OrganizationID, codersdk.CreateMCPServerConfigRequest{
|
|
DisplayName: "Flaky Server",
|
|
Slug: "flaky-server",
|
|
Transport: "streamable_http",
|
|
URL: "https://mcp.example.com/v1",
|
|
AuthType: "oauth2",
|
|
OAuth2ClientID: "cid",
|
|
OAuth2AuthURL: "https://auth.example.com/authorize",
|
|
OAuth2TokenURL: tokenSrv.URL,
|
|
Availability: "default_on",
|
|
Enabled: true,
|
|
ToolAllowList: []string{},
|
|
ToolDenyList: []string{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
//nolint:gocritic // Seeding test state requires system access.
|
|
_, err = db.UpsertMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.UpsertMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
AccessToken: "expired-access",
|
|
RefreshToken: "still-good-refresh",
|
|
TokenType: "Bearer",
|
|
Expiry: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
configs, err := memberClient.MCPServerConfigs(ctx, firstUser.OrganizationID)
|
|
require.NoError(t, err)
|
|
require.Len(t, configs, 1)
|
|
require.False(t, configs[0].AuthConnected)
|
|
|
|
// Transient failures must not destroy the token: a later refresh
|
|
// may succeed.
|
|
//nolint:gocritic // Verifying persisted state requires system access.
|
|
row, err := db.GetMCPServerUserToken(dbauthz.AsSystemRestricted(ctx), database.GetMCPServerUserTokenParams{
|
|
MCPServerConfigID: created.ID,
|
|
UserID: member.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "still-good-refresh", row.RefreshToken)
|
|
require.Empty(t, row.OauthRefreshFailureReason)
|
|
}
|