mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+42
-7
@@ -743,10 +743,24 @@ func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request)
|
||||
// The callback URL is on our server; after the exchange we store
|
||||
// the token and close the popup.
|
||||
state := uuid.New().String()
|
||||
callbackPath := fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID)
|
||||
http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: "mcp_oauth2_state_" + config.ID.String(),
|
||||
Value: state,
|
||||
Path: fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID),
|
||||
Path: callbackPath,
|
||||
MaxAge: 600, // 10 minutes
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}))
|
||||
|
||||
// PKCE (RFC 7636) is required by many OAuth2 providers (e.g.
|
||||
// Linear). We always send it because it is harmless when the
|
||||
// server ignores it and essential when it does not.
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: "mcp_oauth2_verifier_" + config.ID.String(),
|
||||
Value: verifier,
|
||||
Path: callbackPath,
|
||||
MaxAge: 600, // 10 minutes
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -759,14 +773,14 @@ func (api *API) mcpServerOAuth2Connect(rw http.ResponseWriter, r *http.Request)
|
||||
AuthURL: config.OAuth2AuthURL,
|
||||
TokenURL: config.OAuth2TokenURL,
|
||||
},
|
||||
RedirectURL: fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/callback", api.AccessURL.String(), config.ID),
|
||||
RedirectURL: fmt.Sprintf("%s%s", api.AccessURL.String(), callbackPath),
|
||||
}
|
||||
var scopes []string
|
||||
if config.OAuth2Scopes != "" {
|
||||
scopes = strings.Split(config.OAuth2Scopes, " ")
|
||||
}
|
||||
oauth2Config.Scopes = scopes
|
||||
authURL := oauth2Config.AuthCodeURL(state)
|
||||
authURL := oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
http.Redirect(rw, r, authURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
@@ -848,10 +862,26 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
// Clear the state cookie.
|
||||
callbackPath := fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID)
|
||||
http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: "mcp_oauth2_state_" + config.ID.String(),
|
||||
Value: "",
|
||||
Path: fmt.Sprintf("/api/experimental/mcp/servers/%s/oauth2/callback", config.ID),
|
||||
Path: callbackPath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}))
|
||||
|
||||
// Recover the PKCE code_verifier set during the connect step.
|
||||
var exchangeOpts []oauth2.AuthCodeOption
|
||||
if verifierCookie, err := r.Cookie("mcp_oauth2_verifier_" + config.ID.String()); err == nil {
|
||||
exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(verifierCookie.Value))
|
||||
}
|
||||
// Clear the verifier cookie regardless of whether it was present.
|
||||
http.SetCookie(rw, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: "mcp_oauth2_verifier_" + config.ID.String(),
|
||||
Value: "",
|
||||
Path: callbackPath,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -865,7 +895,7 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request)
|
||||
AuthURL: config.OAuth2AuthURL,
|
||||
TokenURL: config.OAuth2TokenURL,
|
||||
},
|
||||
RedirectURL: fmt.Sprintf("%s/api/experimental/mcp/servers/%s/oauth2/callback", api.AccessURL.String(), config.ID),
|
||||
RedirectURL: fmt.Sprintf("%s%s", api.AccessURL.String(), callbackPath),
|
||||
}
|
||||
var scopes []string
|
||||
if config.OAuth2Scopes != "" {
|
||||
@@ -875,8 +905,13 @@ func (api *API) mcpServerOAuth2Callback(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Use the deployment's HTTP client for the token exchange to
|
||||
// respect proxy settings and avoid using http.DefaultClient.
|
||||
exchangeCtx := context.WithValue(ctx, oauth2.HTTPClient, api.HTTPClient)
|
||||
token, err := oauth2Config.Exchange(exchangeCtx, code)
|
||||
// Guard against nil so the oauth2 library falls back to the
|
||||
// default client instead of panicking.
|
||||
exchangeCtx := ctx
|
||||
if api.HTTPClient != nil {
|
||||
exchangeCtx = context.WithValue(ctx, oauth2.HTTPClient, api.HTTPClient)
|
||||
}
|
||||
token, err := oauth2Config.Exchange(exchangeCtx, code, exchangeOpts...)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{
|
||||
Message: "Failed to exchange authorization code for token.",
|
||||
|
||||
Reference in New Issue
Block a user