fix(coderd): add PKCE support to MCP server OAuth2 flow (#23503)

## Problem

MCP servers like Linear (`mcp.linear.app`) require PKCE (RFC 7636) for
their OAuth2 flow. Without it, the token exchange may succeed but the
resulting access token is immediately rejected with a 401
`invalid_token` error when the chat daemon tries to connect to the MCP
server.

This means users can authenticate successfully in the UI (the OAuth
popup completes, `auth_connected` shows `true`), but the model never
receives the MCP tools — they silently fail to load.

### Root cause

The `mcpServerOAuth2Connect` handler was calling
`oauth2Config.AuthCodeURL(state)` without any PKCE parameters
(`code_challenge`, `code_challenge_method`). The callback was calling
`oauth2Config.Exchange(ctx, code)` without a `code_verifier`. Linear's
MCP OAuth endpoint decoded state confirms it expected PKCE with
`codeChallengeMethod: "plain"`.

### Investigation

- The chat (`c2c04fc5-5622-4b71-a5a9-80508e86f78e`) had the Linear MCP
server ID in `mcp_server_ids`
- `auth_connected: true` (token row exists in DB)
- No "expired" or "empty token" warnings in logs
- Server log showed: `skipping MCP server due to connection failure ...
error="initialize: transport error: request failed with status 401:
{"error":"invalid_token","error_description":"Missing or invalid access
token"}"`
- Decoding Linear's OAuth state revealed PKCE was expected

## Changes

- Generate a PKCE `code_verifier` during the OAuth2 connect step using
`oauth2.GenerateVerifier()` and store it in a cookie scoped to the
callback path
- Include `code_challenge` (S256) in the authorization redirect URL via
`oauth2.S256ChallengeOption()`
- Pass the `code_verifier` during the token exchange in the callback via
`oauth2.VerifierOption()`
- Fix a nil-pointer guard on `api.HTTPClient` in the callback
- Add tests verifying PKCE parameters are sent correctly and backwards
compatibility when no verifier cookie is present
This commit is contained in:
Kyle Carberry
2026-03-24 11:55:14 -05:00
committed by GitHub
parent f71e897a83
commit e87ea1e0f5
2 changed files with 304 additions and 7 deletions
+262
View File
@@ -1,6 +1,8 @@
package coderd_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -734,6 +736,266 @@ func TestMCPServerConfigsOAuth2AutoDiscovery(t *testing.T) {
})
}
// 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, 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, err := memberClient.URL.Parse(
"/api/experimental/mcp/servers/" + created.ID.String() + "/oauth2/connect",
)
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", connectURL.String(), 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")
// A verifier cookie must be set.
var verifierCookie *http.Cookie
for _, c := range res.Cookies() {
if c.Name == "mcp_oauth2_verifier_"+created.ID.String() {
verifierCookie = c
break
}
}
require.NotNil(t, verifierCookie, "response must set a PKCE verifier cookie")
require.NotEmpty(t, verifierCookie.Value)
// 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, 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.
for _, c := range res.Cookies() {
if c.Name == "mcp_oauth2_verifier_"+created.ID.String() {
require.Equal(t, -1, c.MaxAge,
"verifier cookie must be cleared after callback")
}
}
})
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, 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()