From 8c0ec10d2cf98a31fad2c93f7b233f0aaae634bd Mon Sep 17 00:00:00 2001 From: Bhavani Shankar Garikapati <6279355+gbshankar@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:03:42 -0700 Subject: [PATCH 1/3] conformance: pass auth/dpop (SEP-1932 baseline) Wire the everything-client to the DPoP baseline scenario with a hand-rolled OAuth + per-request proof path so CI stops expecting auth/dpop to fail. Nonce posture (auth/dpop-nonce) remains deferred; this is harness coverage, not SDK DPoP support. Co-authored-by: Cursor --- conformance/baseline.yml | 5 +- conformance/everything-client/dpop.go | 372 ++++++++++++++++++++++++++ conformance/everything-client/main.go | 4 + 3 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 conformance/everything-client/dpop.go diff --git a/conformance/baseline.yml b/conformance/baseline.yml index 4867fe99..a736510d 100644 --- a/conformance/baseline.yml +++ b/conformance/baseline.yml @@ -4,9 +4,8 @@ client: - auth/client-credentials-jwt - auth/enterprise-managed-authorization - # SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet - # implemented by the Go MCP SDK. - - auth/dpop + # SEP-1932 (DPoP Sender-Constrained Tokens): baseline auth/dpop is + # implemented by the conformance client; nonce posture is not yet. - auth/dpop-nonce # SEP-1933 (Workload Identity Federation JWT-bearer) client-side support diff --git a/conformance/everything-client/dpop.go b/conformance/everything-client/dpop.go new file mode 100644 index 00000000..578712b0 --- /dev/null +++ b/conformance/everything-client/dpop.go @@ -0,0 +1,372 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const dpopRedirectURI = "http://127.0.0.1:9876/callback" + +// dpopKeyPair is an ES256 (P-256) key pair used to mint DPoP proofs (RFC 9449). +type dpopKeyPair struct { + private *ecdsa.PrivateKey + publicJWK map[string]string +} + +func generateDpopKeyPair() (*dpopKeyPair, error) { + private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + return &dpopKeyPair{ + private: private, + publicJWK: map[string]string{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(padCoord(private.X, 32)), + "y": base64.RawURLEncoding.EncodeToString(padCoord(private.Y, 32)), + }, + }, nil +} + +func padCoord(n *big.Int, size int) []byte { + b := n.Bytes() + if len(b) >= size { + return b + } + out := make([]byte, size) + copy(out[size-len(b):], b) + return out +} + +// accessTokenHash returns base64url(SHA-256(ASCII(accessToken))) (RFC 9449 §4.1). +func accessTokenHash(accessToken string) string { + sum := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// buildDpopProof builds a well-formed dpop+jwt for the given request. +// When accessToken is non-empty, the ath claim is included. +func buildDpopProof(kp *dpopKeyPair, htm, htu, accessToken string) (string, error) { + jti := make([]byte, 16) + if _, err := rand.Read(jti); err != nil { + return "", err + } + claims := jwt.MapClaims{ + "jti": base64.RawURLEncoding.EncodeToString(jti), + "htm": htm, + "htu": htu, + "iat": time.Now().Unix(), + } + if accessToken != "" { + claims["ath"] = accessTokenHash(accessToken) + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["typ"] = "dpop+jwt" + token.Header["jwk"] = kp.publicJWK + return token.SignedString(kp.private) +} + +func stripQuery(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + u.RawQuery = "" + u.Fragment = "" + return u.Scheme + "://" + u.Host + u.EscapedPath(), nil +} + +// runDpopClient exercises SEP-1932 / RFC 9449 baseline DPoP (auth/dpop): +// proof at the token endpoint, Authorization: DPoP on MCP requests, and a +// fresh proof per request. Nonce handling is intentionally omitted. +func runDpopClient(ctx context.Context, serverURL string, _ map[string]any) error { + kp, err := generateDpopKeyPair() + if err != nil { + return fmt.Errorf("generate DPoP key pair: %w", err) + } + + accessToken, err := acquireDpopBoundToken(ctx, serverURL, kp) + if err != nil { + return err + } + log.Printf("Obtained DPoP-bound access token") + + httpClient := &http.Client{ + Transport: &dpopRoundTripper{ + base: http.DefaultTransport, + kp: kp, + accessToken: accessToken, + }, + } + + client := mcp.NewClient(&mcp.Implementation{ + Name: "conformance-dpop-client", + Version: "1.0.0", + }, nil) + transport := &mcp.StreamableClientTransport{ + Endpoint: serverURL, + HTTPClient: httpClient, + } + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return fmt.Errorf("client.Connect(): %w", err) + } + defer session.Close() + + if _, err := session.ListTools(ctx, nil); err != nil { + return fmt.Errorf("session.ListTools(): %v", err) + } + if _, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "test-tool", + Arguments: map[string]any{}, + }); err != nil { + return fmt.Errorf("session.CallTool('test-tool'): %v", err) + } + return nil +} + +type dpopRoundTripper struct { + base http.RoundTripper + kp *dpopKeyPair + accessToken string +} + +func (t *dpopRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + htu, err := stripQuery(req.URL.String()) + if err != nil { + return nil, fmt.Errorf("normalize request URL: %w", err) + } + proof, err := buildDpopProof(t.kp, req.Method, htu, t.accessToken) + if err != nil { + return nil, fmt.Errorf("build DPoP proof: %w", err) + } + req.Header.Set("Authorization", "DPoP "+t.accessToken) + req.Header.Set("DPoP", proof) + base := t.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(req) +} + +func acquireDpopBoundToken(ctx context.Context, serverURL string, kp *dpopKeyPair) (string, error) { + httpClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + base, err := url.Parse(serverURL) + if err != nil { + return "", fmt.Errorf("parse server URL: %w", err) + } + + // 1. Protected Resource Metadata → authorization server issuer. + prmURL := &url.URL{ + Scheme: base.Scheme, + Host: base.Host, + Path: "/.well-known/oauth-protected-resource/mcp", + } + var prm struct { + AuthorizationServers []string `json:"authorization_servers"` + } + if err := getJSON(ctx, httpClient, prmURL.String(), &prm); err != nil { + return "", fmt.Errorf("fetch PRM: %w", err) + } + if len(prm.AuthorizationServers) == 0 { + return "", fmt.Errorf("PRM has no authorization_servers") + } + authServerURL := strings.TrimRight(prm.AuthorizationServers[0], "/") + + // 2. Authorization server metadata. + asMetaURL := authServerURL + "/.well-known/oauth-authorization-server" + var asMeta struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint"` + } + if err := getJSON(ctx, httpClient, asMetaURL, &asMeta); err != nil { + return "", fmt.Errorf("fetch AS metadata: %w", err) + } + if asMeta.AuthorizationEndpoint == "" || asMeta.TokenEndpoint == "" || asMeta.RegistrationEndpoint == "" { + return "", fmt.Errorf("AS metadata missing required endpoints") + } + + // 3. Dynamic client registration. + regBody, err := json.Marshal(map[string]any{ + "client_name": "conformance-dpop-client", + "redirect_uris": []string{dpopRedirectURI}, + "application_type": "native", + }) + if err != nil { + return "", err + } + regReq, err := http.NewRequestWithContext(ctx, http.MethodPost, asMeta.RegistrationEndpoint, strings.NewReader(string(regBody))) + if err != nil { + return "", err + } + regReq.Header.Set("Content-Type", "application/json") + regResp, err := httpClient.Do(regReq) + if err != nil { + return "", fmt.Errorf("DCR: %w", err) + } + defer regResp.Body.Close() + if regResp.StatusCode < 200 || regResp.StatusCode >= 300 { + body, _ := io.ReadAll(regResp.Body) + return "", fmt.Errorf("DCR failed: HTTP %d: %s", regResp.StatusCode, body) + } + var reg struct { + ClientID string `json:"client_id"` + } + if err := json.NewDecoder(regResp.Body).Decode(®); err != nil { + return "", fmt.Errorf("decode DCR response: %w", err) + } + if reg.ClientID == "" { + return "", fmt.Errorf("DCR response missing client_id") + } + + // 4. Authorization request (PKCE). The test AS redirects immediately. + state, err := randomB64URL(16) + if err != nil { + return "", err + } + codeVerifier, err := randomB64URL(32) + if err != nil { + return "", err + } + challengeSum := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(challengeSum[:]) + + authorizeURL, err := url.Parse(asMeta.AuthorizationEndpoint) + if err != nil { + return "", err + } + q := authorizeURL.Query() + q.Set("response_type", "code") + q.Set("client_id", reg.ClientID) + q.Set("state", state) + q.Set("redirect_uri", dpopRedirectURI) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + authorizeURL.RawQuery = q.Encode() + + authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL.String(), nil) + if err != nil { + return "", err + } + authResp, err := httpClient.Do(authReq) + if err != nil { + return "", fmt.Errorf("authorize: %w", err) + } + io.Copy(io.Discard, authResp.Body) + authResp.Body.Close() + loc := authResp.Header.Get("Location") + if loc == "" { + return "", fmt.Errorf("authorization endpoint did not redirect with a code") + } + locURL, err := url.Parse(loc) + if err != nil { + return "", fmt.Errorf("parse redirect: %w", err) + } + code := locURL.Query().Get("code") + if code == "" { + return "", fmt.Errorf("no authorization code in redirect") + } + + // 5. Token request with a DPoP proof → DPoP-bound access token. + tokenHTU, err := stripQuery(asMeta.TokenEndpoint) + if err != nil { + return "", err + } + proof, err := buildDpopProof(kp, http.MethodPost, tokenHTU, "") + if err != nil { + return "", fmt.Errorf("token-request DPoP proof: %w", err) + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {dpopRedirectURI}, + "code_verifier": {codeVerifier}, + "client_id": {reg.ClientID}, + } + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, asMeta.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenReq.Header.Set("DPoP", proof) + tokenResp, err := httpClient.Do(tokenReq) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer tokenResp.Body.Close() + tokenBody, err := io.ReadAll(tokenResp.Body) + if err != nil { + return "", err + } + if tokenResp.StatusCode < 200 || tokenResp.StatusCode >= 300 { + return "", fmt.Errorf("token request failed: HTTP %d: %s", tokenResp.StatusCode, tokenBody) + } + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(tokenBody, &tok); err != nil { + return "", fmt.Errorf("decode token response: %w", err) + } + if tok.AccessToken == "" { + return "", fmt.Errorf("token response missing access_token") + } + if !strings.EqualFold(tok.TokenType, "DPoP") { + return "", fmt.Errorf("expected token_type DPoP, got %q", tok.TokenType) + } + return tok.AccessToken, nil +} + +func getJSON(ctx context.Context, client *http.Client, rawURL string, dest any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + return json.NewDecoder(resp.Body).Decode(dest) +} + +func randomB64URL(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/conformance/everything-client/main.go b/conformance/everything-client/main.go index 37765227..b43ae23b 100644 --- a/conformance/everything-client/main.go +++ b/conformance/everything-client/main.go @@ -86,6 +86,10 @@ func init() { for _, scenario := range authScenarios { registerScenario(scenario, runAuthClient) } + + // SEP-1932 DPoP baseline (nonce-less). Nonce posture is auth/dpop-nonce + // and is not implemented yet. + registerScenario("auth/dpop", runDpopClient) } // ============================================================================ From 06b616eb9c002c2aab2220af7e0634a0297e4f72 Mon Sep 17 00:00:00 2001 From: Bhavani Shankar Garikapati <6279355+gbshankar@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:17:13 -0700 Subject: [PATCH 2/3] auth/mcp: client DPoP support (SEP-1932 baseline, no nonce) Add opt-in DPoP for AuthorizationCodeHandler and StreamableClientTransport: proof helpers in oauthex, token.Type() for the Authorization scheme, and an optional RequestPreparer hook for per-request proofs. Migrate the conformance auth/dpop client onto this path. Nonce handling remains out of scope (see #1139). Co-authored-by: Cursor --- auth/authorization_code.go | 53 ++++ auth/client.go | 9 + conformance/everything-client/dpop.go | 368 +++----------------------- mcp/streamable.go | 7 +- mcp/streamable_client_test.go | 73 +++++ oauthex/dpop.go | 170 ++++++++++++ oauthex/dpop_test.go | 181 +++++++++++++ 7 files changed, 524 insertions(+), 337 deletions(-) create mode 100644 oauthex/dpop.go create mode 100644 oauthex/dpop_test.go diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 1673a2e6..93dec45f 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -154,6 +154,12 @@ type AuthorizationCodeHandlerConfig struct { // The default is nil, which means no token source has been set initially, // and will trigger a call to [AuthorizationCodeHandler.Authorize]. InitialTokenSource oauth2.TokenSource + + // DPoP, when non-nil, enables DPoP (RFC 9449 / SEP-1932) for this handler: + // a proof is attached to token exchange and refresh requests, and + // [RequestPreparer] attaches a fresh proof to each MCP request. + // Server-provided nonces are not handled yet. + DPoP *oauthex.DPoPConfig } // AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses @@ -169,9 +175,13 @@ type AuthorizationCodeHandler struct { // grantedScopes maps authorization server issuer to the list of scopes granted by that issuer. grantedScopes map[string][]string + + // dpopKey is non-nil when DPoP is enabled for this handler. + dpopKey *oauthex.DPoPKeyPair } var _ OAuthHandler = (*AuthorizationCodeHandler)(nil) +var _ RequestPreparer = (*AuthorizationCodeHandler)(nil) func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) { h.mu.RLock() @@ -230,10 +240,32 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho if config.Client == nil { config.Client = http.DefaultClient } + + var dpopKey *oauthex.DPoPKeyPair + if config.DPoP != nil { + dpopKey = config.DPoP.KeyPair + if dpopKey == nil { + var err error + dpopKey, err = oauthex.GenerateDPoPKeyPair() + if err != nil { + return nil, fmt.Errorf("generate DPoP key pair: %w", err) + } + } + // Clone the client so we do not mutate a shared http.DefaultClient. + c := *config.Client + base := c.Transport + if base == nil { + base = http.DefaultTransport + } + c.Transport = &oauthex.DPoPRoundTripper{Base: base, Key: dpopKey} + config.Client = &c + } + return &AuthorizationCodeHandler{ config: config, tokenSource: config.InitialTokenSource, grantedScopes: make(map[string][]string), + dpopKey: dpopKey, }, nil } @@ -648,6 +680,9 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context if err != nil { return fmt.Errorf("token exchange failed: %w", err) } + if h.dpopKey != nil && token.TokenType == "" { + token.TokenType = "DPoP" + } // The token source outlives this authorization request: it is stored on the // handler and used by the transport for the lifetime of the connection. The // oauth2 library captures the context passed to TokenSource and reuses it for @@ -674,6 +709,24 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context return nil } +// PrepareRequest implements [RequestPreparer]. When DPoP is enabled, it +// attaches a fresh DPoP proof for the request. +func (h *AuthorizationCodeHandler) PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error { + if h.dpopKey == nil || token == nil { + return nil + } + htu, err := oauthex.HTU(req.URL.String()) + if err != nil { + return fmt.Errorf("DPoP htu: %w", err) + } + proof, err := oauthex.BuildDPoPProof(h.dpopKey, req.Method, htu, token.AccessToken) + if err != nil { + return fmt.Errorf("DPoP proof: %w", err) + } + req.Header.Set("DPoP", proof) + return nil +} + // updateGrantedScopes updates the granted scopes based on the token source and requested scopes. func (h *AuthorizationCodeHandler) updateGrantedScopes(issuer string, requestedScopes []string) error { h.mu.RLock() diff --git a/auth/client.go b/auth/client.go index db32d97a..b22b49eb 100644 --- a/auth/client.go +++ b/auth/client.go @@ -38,3 +38,12 @@ type OAuthHandler interface { // The function is responsible for closing the response body. Authorize(context.Context, *http.Request, *http.Response) error } + +// RequestPreparer is an optional interface that an [OAuthHandler] may +// implement to decorate outgoing MCP HTTP requests after the Authorization +// header is set (for example, to attach a fresh DPoP proof). Transports that +// support it should type-assert the handler; handlers that do not implement +// it are unaffected. +type RequestPreparer interface { + PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error +} diff --git a/conformance/everything-client/dpop.go b/conformance/everything-client/dpop.go index 578712b0..e80a9551 100644 --- a/conformance/everything-client/dpop.go +++ b/conformance/everything-client/dpop.go @@ -6,133 +6,53 @@ package main import ( "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" "fmt" - "io" - "log" - "math/big" - "net/http" - "net/url" - "strings" - "time" - "github.com/golang-jwt/jwt/v5" + "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" ) -const dpopRedirectURI = "http://127.0.0.1:9876/callback" - -// dpopKeyPair is an ES256 (P-256) key pair used to mint DPoP proofs (RFC 9449). -type dpopKeyPair struct { - private *ecdsa.PrivateKey - publicJWK map[string]string -} - -func generateDpopKeyPair() (*dpopKeyPair, error) { - private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - return &dpopKeyPair{ - private: private, - publicJWK: map[string]string{ - "kty": "EC", - "crv": "P-256", - "x": base64.RawURLEncoding.EncodeToString(padCoord(private.X, 32)), - "y": base64.RawURLEncoding.EncodeToString(padCoord(private.Y, 32)), +// runDpopClient exercises SEP-1932 / RFC 9449 baseline DPoP (auth/dpop) +// using the SDK AuthorizationCodeHandler DPoP path. Nonce handling is +// intentionally omitted (auth/dpop-nonce remains an expected failure). +func runDpopClient(ctx context.Context, serverURL string, configCtx map[string]any) error { + authConfig := &auth.AuthorizationCodeHandlerConfig{ + RedirectURL: "http://127.0.0.1:9876/callback", + AuthorizationCodeFetcher: fetchAuthorizationCodeAndState, + ClientIDMetadataDocumentConfig: &auth.ClientIDMetadataDocumentConfig{ + URL: "https://conformance-test.local/client-metadata.json", }, - }, nil -} - -func padCoord(n *big.Int, size int) []byte { - b := n.Bytes() - if len(b) >= size { - return b - } - out := make([]byte, size) - copy(out[size-len(b):], b) - return out -} - -// accessTokenHash returns base64url(SHA-256(ASCII(accessToken))) (RFC 9449 §4.1). -func accessTokenHash(accessToken string) string { - sum := sha256.Sum256([]byte(accessToken)) - return base64.RawURLEncoding.EncodeToString(sum[:]) -} - -// buildDpopProof builds a well-formed dpop+jwt for the given request. -// When accessToken is non-empty, the ath claim is included. -func buildDpopProof(kp *dpopKeyPair, htm, htu, accessToken string) (string, error) { - jti := make([]byte, 16) - if _, err := rand.Read(jti); err != nil { - return "", err - } - claims := jwt.MapClaims{ - "jti": base64.RawURLEncoding.EncodeToString(jti), - "htm": htm, - "htu": htu, - "iat": time.Now().Unix(), - } - if accessToken != "" { - claims["ath"] = accessTokenHash(accessToken) + DynamicClientRegistrationConfig: &auth.DynamicClientRegistrationConfig{ + Metadata: &oauthex.ClientRegistrationMetadata{ + RedirectURIs: []string{"http://127.0.0.1:9876/callback"}, + ApplicationType: "native", + ClientName: "conformance-dpop-client", + TokenEndpointAuthMethod: "none", + }, + }, + DPoP: &oauthex.DPoPConfig{}, } - - token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) - token.Header["typ"] = "dpop+jwt" - token.Header["jwk"] = kp.publicJWK - return token.SignedString(kp.private) -} - -func stripQuery(rawURL string) (string, error) { - u, err := url.Parse(rawURL) - if err != nil { - return "", err + if clientID, ok := configCtx["client_id"].(string); ok { + if clientSecret, ok := configCtx["client_secret"].(string); ok { + authConfig.PreregisteredClient = &oauthex.ClientCredentials{ + ClientID: clientID, + ClientSecretAuth: &oauthex.ClientSecretAuth{ + ClientSecret: clientSecret, + }, + } + } } - u.RawQuery = "" - u.Fragment = "" - return u.Scheme + "://" + u.Host + u.EscapedPath(), nil -} -// runDpopClient exercises SEP-1932 / RFC 9449 baseline DPoP (auth/dpop): -// proof at the token endpoint, Authorization: DPoP on MCP requests, and a -// fresh proof per request. Nonce handling is intentionally omitted. -func runDpopClient(ctx context.Context, serverURL string, _ map[string]any) error { - kp, err := generateDpopKeyPair() + authHandler, err := auth.NewAuthorizationCodeHandler(authConfig) if err != nil { - return fmt.Errorf("generate DPoP key pair: %w", err) + return fmt.Errorf("failed to create auth handler: %w", err) } - accessToken, err := acquireDpopBoundToken(ctx, serverURL, kp) + session, err := connectToServer(ctx, serverURL, withOAuthHandler(authHandler)) if err != nil { return err } - log.Printf("Obtained DPoP-bound access token") - - httpClient := &http.Client{ - Transport: &dpopRoundTripper{ - base: http.DefaultTransport, - kp: kp, - accessToken: accessToken, - }, - } - - client := mcp.NewClient(&mcp.Implementation{ - Name: "conformance-dpop-client", - Version: "1.0.0", - }, nil) - transport := &mcp.StreamableClientTransport{ - Endpoint: serverURL, - HTTPClient: httpClient, - } - session, err := client.Connect(ctx, transport, nil) - if err != nil { - return fmt.Errorf("client.Connect(): %w", err) - } defer session.Close() if _, err := session.ListTools(ctx, nil); err != nil { @@ -146,227 +66,3 @@ func runDpopClient(ctx context.Context, serverURL string, _ map[string]any) erro } return nil } - -type dpopRoundTripper struct { - base http.RoundTripper - kp *dpopKeyPair - accessToken string -} - -func (t *dpopRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - htu, err := stripQuery(req.URL.String()) - if err != nil { - return nil, fmt.Errorf("normalize request URL: %w", err) - } - proof, err := buildDpopProof(t.kp, req.Method, htu, t.accessToken) - if err != nil { - return nil, fmt.Errorf("build DPoP proof: %w", err) - } - req.Header.Set("Authorization", "DPoP "+t.accessToken) - req.Header.Set("DPoP", proof) - base := t.base - if base == nil { - base = http.DefaultTransport - } - return base.RoundTrip(req) -} - -func acquireDpopBoundToken(ctx context.Context, serverURL string, kp *dpopKeyPair) (string, error) { - httpClient := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - base, err := url.Parse(serverURL) - if err != nil { - return "", fmt.Errorf("parse server URL: %w", err) - } - - // 1. Protected Resource Metadata → authorization server issuer. - prmURL := &url.URL{ - Scheme: base.Scheme, - Host: base.Host, - Path: "/.well-known/oauth-protected-resource/mcp", - } - var prm struct { - AuthorizationServers []string `json:"authorization_servers"` - } - if err := getJSON(ctx, httpClient, prmURL.String(), &prm); err != nil { - return "", fmt.Errorf("fetch PRM: %w", err) - } - if len(prm.AuthorizationServers) == 0 { - return "", fmt.Errorf("PRM has no authorization_servers") - } - authServerURL := strings.TrimRight(prm.AuthorizationServers[0], "/") - - // 2. Authorization server metadata. - asMetaURL := authServerURL + "/.well-known/oauth-authorization-server" - var asMeta struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - RegistrationEndpoint string `json:"registration_endpoint"` - } - if err := getJSON(ctx, httpClient, asMetaURL, &asMeta); err != nil { - return "", fmt.Errorf("fetch AS metadata: %w", err) - } - if asMeta.AuthorizationEndpoint == "" || asMeta.TokenEndpoint == "" || asMeta.RegistrationEndpoint == "" { - return "", fmt.Errorf("AS metadata missing required endpoints") - } - - // 3. Dynamic client registration. - regBody, err := json.Marshal(map[string]any{ - "client_name": "conformance-dpop-client", - "redirect_uris": []string{dpopRedirectURI}, - "application_type": "native", - }) - if err != nil { - return "", err - } - regReq, err := http.NewRequestWithContext(ctx, http.MethodPost, asMeta.RegistrationEndpoint, strings.NewReader(string(regBody))) - if err != nil { - return "", err - } - regReq.Header.Set("Content-Type", "application/json") - regResp, err := httpClient.Do(regReq) - if err != nil { - return "", fmt.Errorf("DCR: %w", err) - } - defer regResp.Body.Close() - if regResp.StatusCode < 200 || regResp.StatusCode >= 300 { - body, _ := io.ReadAll(regResp.Body) - return "", fmt.Errorf("DCR failed: HTTP %d: %s", regResp.StatusCode, body) - } - var reg struct { - ClientID string `json:"client_id"` - } - if err := json.NewDecoder(regResp.Body).Decode(®); err != nil { - return "", fmt.Errorf("decode DCR response: %w", err) - } - if reg.ClientID == "" { - return "", fmt.Errorf("DCR response missing client_id") - } - - // 4. Authorization request (PKCE). The test AS redirects immediately. - state, err := randomB64URL(16) - if err != nil { - return "", err - } - codeVerifier, err := randomB64URL(32) - if err != nil { - return "", err - } - challengeSum := sha256.Sum256([]byte(codeVerifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(challengeSum[:]) - - authorizeURL, err := url.Parse(asMeta.AuthorizationEndpoint) - if err != nil { - return "", err - } - q := authorizeURL.Query() - q.Set("response_type", "code") - q.Set("client_id", reg.ClientID) - q.Set("state", state) - q.Set("redirect_uri", dpopRedirectURI) - q.Set("code_challenge", codeChallenge) - q.Set("code_challenge_method", "S256") - authorizeURL.RawQuery = q.Encode() - - authReq, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL.String(), nil) - if err != nil { - return "", err - } - authResp, err := httpClient.Do(authReq) - if err != nil { - return "", fmt.Errorf("authorize: %w", err) - } - io.Copy(io.Discard, authResp.Body) - authResp.Body.Close() - loc := authResp.Header.Get("Location") - if loc == "" { - return "", fmt.Errorf("authorization endpoint did not redirect with a code") - } - locURL, err := url.Parse(loc) - if err != nil { - return "", fmt.Errorf("parse redirect: %w", err) - } - code := locURL.Query().Get("code") - if code == "" { - return "", fmt.Errorf("no authorization code in redirect") - } - - // 5. Token request with a DPoP proof → DPoP-bound access token. - tokenHTU, err := stripQuery(asMeta.TokenEndpoint) - if err != nil { - return "", err - } - proof, err := buildDpopProof(kp, http.MethodPost, tokenHTU, "") - if err != nil { - return "", fmt.Errorf("token-request DPoP proof: %w", err) - } - form := url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "redirect_uri": {dpopRedirectURI}, - "code_verifier": {codeVerifier}, - "client_id": {reg.ClientID}, - } - tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, asMeta.TokenEndpoint, strings.NewReader(form.Encode())) - if err != nil { - return "", err - } - tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - tokenReq.Header.Set("DPoP", proof) - tokenResp, err := httpClient.Do(tokenReq) - if err != nil { - return "", fmt.Errorf("token request: %w", err) - } - defer tokenResp.Body.Close() - tokenBody, err := io.ReadAll(tokenResp.Body) - if err != nil { - return "", err - } - if tokenResp.StatusCode < 200 || tokenResp.StatusCode >= 300 { - return "", fmt.Errorf("token request failed: HTTP %d: %s", tokenResp.StatusCode, tokenBody) - } - var tok struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - if err := json.Unmarshal(tokenBody, &tok); err != nil { - return "", fmt.Errorf("decode token response: %w", err) - } - if tok.AccessToken == "" { - return "", fmt.Errorf("token response missing access_token") - } - if !strings.EqualFold(tok.TokenType, "DPoP") { - return "", fmt.Errorf("expected token_type DPoP, got %q", tok.TokenType) - } - return tok.AccessToken, nil -} - -func getJSON(ctx context.Context, client *http.Client, rawURL string, dest any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) - } - return json.NewDecoder(resp.Body).Decode(dest) -} - -func randomB64URL(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} diff --git a/mcp/streamable.go b/mcp/streamable.go index f642db1d..0f6fa26a 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2399,7 +2399,12 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess return err } } else if token != nil { - req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) + if p, ok := c.oauthHandler.(auth.RequestPreparer); ok { + if err := p.PrepareRequest(c.ctx, req, token); err != nil { + return err + } + } } } } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 5b523692..0d077405 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1029,6 +1029,79 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { session.Close() } +func TestStreamableClientOAuth_DPoPSchemeAndPrepareRequest(t *testing.T) { + ctx := context.Background() + token := &oauth2.Token{AccessToken: "dpop-token", TokenType: "DPoP"} + oauthHandler := &dpopMockOAuthHandler{mockOAuthHandler: mockOAuthHandler{token: token}} + + var mu sync.Mutex + var gotAuth, gotDPoP string + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + {"POST", "123", notificationInitialized, ""}: { + status: http.StatusAccepted, + wantProtocolVersion: protocolVersion20251125, + }, + {"GET", "123", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + }, + {"DELETE", "123", "", ""}: {}, + }, + } + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + mu.Lock() + gotAuth = r.Header.Get("Authorization") + gotDPoP = r.Header.Get("DPoP") + mu.Unlock() + } + fake.ServeHTTP(w, r) + })) + t.Cleanup(httpServer.Close) + + transport := &StreamableClientTransport{ + Endpoint: httpServer.URL, + OAuthHandler: oauthHandler, + } + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("client.Connect() failed: %v", err) + } + session.Close() + + mu.Lock() + defer mu.Unlock() + if gotAuth != "DPoP dpop-token" { + t.Fatalf("Authorization = %q, want %q", gotAuth, "DPoP dpop-token") + } + if gotDPoP != "test-proof" { + t.Fatalf("DPoP header = %q, want %q", gotDPoP, "test-proof") + } + if oauthHandler.prepareCalls == 0 { + t.Fatal("expected PrepareRequest to be called") + } +} + +type dpopMockOAuthHandler struct { + mockOAuthHandler + prepareCalls int +} + +func (h *dpopMockOAuthHandler) PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error { + h.prepareCalls++ + req.Header.Set("DPoP", "test-proof") + return nil +} + func TestStreamableClientOAuth_401(t *testing.T) { ctx := context.Background() oauthHandler := &mockOAuthHandler{token: nil} diff --git a/oauthex/dpop.go b/oauthex/dpop.go new file mode 100644 index 00000000..1d30e401 --- /dev/null +++ b/oauthex/dpop.go @@ -0,0 +1,170 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// DPoPConfig configures DPoP (RFC 9449) for an OAuth client. Nonce handling +// is intentionally out of scope for this baseline API. +type DPoPConfig struct { + // KeyPair signs DPoP proofs. If nil, [GenerateDPoPKeyPair] is used when + // the config is applied. + KeyPair *DPoPKeyPair +} + +// DPoPKeyPair is an ES256 (P-256) key pair used to mint DPoP proofs. +type DPoPKeyPair struct { + Private *ecdsa.PrivateKey + PublicJWK map[string]string + // Thumbprint is the RFC 7638 JWK SHA-256 thumbprint (base64url). + Thumbprint string +} + +// GenerateDPoPKeyPair creates a new ES256 key pair for DPoP proofs. +func GenerateDPoPKeyPair() (*DPoPKeyPair, error) { + private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + jwk := map[string]string{ + "kty": "EC", + "crv": "P-256", + "x": base64.RawURLEncoding.EncodeToString(padCoord(private.X, 32)), + "y": base64.RawURLEncoding.EncodeToString(padCoord(private.Y, 32)), + } + thumb, err := JWKThumbprint(jwk) + if err != nil { + return nil, err + } + return &DPoPKeyPair{ + Private: private, + PublicJWK: jwk, + Thumbprint: thumb, + }, nil +} + +func padCoord(n *big.Int, size int) []byte { + b := n.Bytes() + if len(b) >= size { + return b + } + out := make([]byte, size) + copy(out[size-len(b):], b) + return out +} + +// AccessTokenHash returns base64url(SHA-256(ASCII(accessToken))) per RFC 9449 §4.1. +func AccessTokenHash(accessToken string) string { + sum := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// JWKThumbprint returns the RFC 7638 SHA-256 thumbprint of an EC public JWK. +func JWKThumbprint(jwk map[string]string) (string, error) { + if jwk["kty"] != "EC" { + return "", fmt.Errorf("JWKThumbprint: only EC keys are supported") + } + // Required members in lexicographic order: crv, kty, x, y. + canonical, err := json.Marshal(struct { + Crv string `json:"crv"` + Kty string `json:"kty"` + X string `json:"x"` + Y string `json:"y"` + }{ + Crv: jwk["crv"], + Kty: jwk["kty"], + X: jwk["x"], + Y: jwk["y"], + }) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return base64.RawURLEncoding.EncodeToString(sum[:]), nil +} + +// HTU returns the HTTP URI for a DPoP htu claim: scheme + host + path, +// with query and fragment removed (RFC 9449 §4.2). +func HTU(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + return u.Scheme + "://" + u.Host + u.EscapedPath(), nil +} + +// BuildDPoPProof builds a well-formed dpop+jwt for the given request. +// When accessToken is non-empty, the ath claim is included. +func BuildDPoPProof(kp *DPoPKeyPair, htm, htu, accessToken string) (string, error) { + if kp == nil || kp.Private == nil { + return "", fmt.Errorf("BuildDPoPProof: nil key pair") + } + jti := make([]byte, 16) + if _, err := rand.Read(jti); err != nil { + return "", err + } + claims := jwt.MapClaims{ + "jti": base64.RawURLEncoding.EncodeToString(jti), + "htm": htm, + "htu": htu, + "iat": time.Now().Unix(), + } + if accessToken != "" { + claims["ath"] = AccessTokenHash(accessToken) + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["typ"] = "dpop+jwt" + token.Header["jwk"] = kp.PublicJWK + return token.SignedString(kp.Private) +} + +// DPoPRoundTripper attaches a DPoP proof to OAuth token-endpoint style +// requests (POST with application/x-www-form-urlencoded). Other requests +// pass through unchanged (PRM/ASM GETs, DCR JSON POSTs). +type DPoPRoundTripper struct { + Base http.RoundTripper + Key *DPoPKeyPair +} + +// RoundTrip implements [http.RoundTripper]. +func (t *DPoPRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.Base + if base == nil { + base = http.DefaultTransport + } + if t.Key == nil || req.Method != http.MethodPost { + return base.RoundTrip(req) + } + ct := req.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/x-www-form-urlencoded") { + return base.RoundTrip(req) + } + req = req.Clone(req.Context()) + htu, err := HTU(req.URL.String()) + if err != nil { + return nil, fmt.Errorf("DPoP htu: %w", err) + } + proof, err := BuildDPoPProof(t.Key, req.Method, htu, "") + if err != nil { + return nil, fmt.Errorf("DPoP proof: %w", err) + } + req.Header.Set("DPoP", proof) + return base.RoundTrip(req) +} diff --git a/oauthex/dpop_test.go b/oauthex/dpop_test.go new file mode 100644 index 00000000..4aff95fd --- /dev/null +++ b/oauthex/dpop_test.go @@ -0,0 +1,181 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package oauthex_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +func TestAccessTokenHash_RFC9449(t *testing.T) { + // RFC 9449 §4.1 example. + const accessToken = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU" + const want = "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" + if got := oauthex.AccessTokenHash(accessToken); got != want { + t.Fatalf("AccessTokenHash = %q, want %q", got, want) + } +} + +func TestJWKThumbprint_RFC9449(t *testing.T) { + // RFC 9449 §4 / §6.1 example. + jwk := map[string]string{ + "kty": "EC", + "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", + "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA", + "crv": "P-256", + } + const want = "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" + got, err := oauthex.JWKThumbprint(jwk) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("JWKThumbprint = %q, want %q", got, want) + } +} + +func TestGenerateDPoPKeyPair(t *testing.T) { + kp, err := oauthex.GenerateDPoPKeyPair() + if err != nil { + t.Fatal(err) + } + if kp.PublicJWK["kty"] != "EC" || kp.PublicJWK["crv"] != "P-256" { + t.Fatalf("unexpected JWK: %v", kp.PublicJWK) + } + if _, ok := kp.PublicJWK["d"]; ok { + t.Fatal("public JWK must not contain private key") + } + thumb, err := oauthex.JWKThumbprint(kp.PublicJWK) + if err != nil { + t.Fatal(err) + } + if thumb != kp.Thumbprint { + t.Fatalf("Thumbprint = %q, recomputed %q", kp.Thumbprint, thumb) + } +} + +func TestBuildDPoPProof(t *testing.T) { + kp, err := oauthex.GenerateDPoPKeyPair() + if err != nil { + t.Fatal(err) + } + accessToken := "example-access-token" + proof, err := oauthex.BuildDPoPProof(kp, "POST", "https://example.com/mcp", accessToken) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(proof, ".") + if len(parts) != 3 { + t.Fatalf("want 3 JWT parts, got %d", len(parts)) + } + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatal(err) + } + var header map[string]any + if err := json.Unmarshal(headerJSON, &header); err != nil { + t.Fatal(err) + } + if header["typ"] != "dpop+jwt" { + t.Fatalf("typ = %v", header["typ"]) + } + if header["alg"] != "ES256" { + t.Fatalf("alg = %v", header["alg"]) + } + jwk, ok := header["jwk"].(map[string]any) + if !ok { + t.Fatalf("jwk missing: %v", header["jwk"]) + } + if jwk["d"] != nil { + t.Fatal("embedded jwk must be public") + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatal(err) + } + var claims map[string]any + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + t.Fatal(err) + } + for _, key := range []string{"jti", "htm", "htu", "iat", "ath"} { + if claims[key] == nil { + t.Fatalf("missing claim %q", key) + } + } + if claims["htm"] != "POST" { + t.Fatalf("htm = %v", claims["htm"]) + } + if claims["htu"] != "https://example.com/mcp" { + t.Fatalf("htu = %v", claims["htu"]) + } + if claims["ath"] != oauthex.AccessTokenHash(accessToken) { + t.Fatalf("ath = %v", claims["ath"]) + } + + // Verify signature with the public key from the JWK. + x, _ := base64.RawURLEncoding.DecodeString(jwk["x"].(string)) + y, _ := base64.RawURLEncoding.DecodeString(jwk["y"].(string)) + pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)} + parsed, err := jwt.Parse(proof, func(token *jwt.Token) (any, error) { + return pub, nil + }, jwt.WithValidMethods([]string{"ES256"})) + if err != nil || !parsed.Valid { + t.Fatalf("signature verify: %v valid=%v", err, parsed != nil && parsed.Valid) + } +} + +func TestBuildDPoPProof_FreshJTI(t *testing.T) { + kp, err := oauthex.GenerateDPoPKeyPair() + if err != nil { + t.Fatal(err) + } + p1, err := oauthex.BuildDPoPProof(kp, "GET", "https://example.com/mcp", "") + if err != nil { + t.Fatal(err) + } + p2, err := oauthex.BuildDPoPProof(kp, "GET", "https://example.com/mcp", "") + if err != nil { + t.Fatal(err) + } + jti := func(proof string) string { + payload, _ := base64.RawURLEncoding.DecodeString(strings.Split(proof, ".")[1]) + var c map[string]any + _ = json.Unmarshal(payload, &c) + return c["jti"].(string) + } + if jti(p1) == jti(p2) { + t.Fatal("expected distinct jti values") + } +} + +func TestHTU(t *testing.T) { + got, err := oauthex.HTU("https://example.com:8443/mcp?x=1#frag") + if err != nil { + t.Fatal(err) + } + if got != "https://example.com:8443/mcp" { + t.Fatalf("HTU = %q", got) + } +} + +func TestAccessTokenHash_MatchesSHA256(t *testing.T) { + tok := "abc" + sum := sha256.Sum256([]byte(tok)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if got := oauthex.AccessTokenHash(tok); got != want { + t.Fatalf("got %q want %q", got, want) + } +} From 39cf416803ea48b42466c5294a9086e96d3735ca Mon Sep 17 00:00:00 2001 From: Bhavani Shankar Garikapati <6279355+gbshankar@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:56:09 -0700 Subject: [PATCH 3/3] auth/mcp: let RequestPreparer own Authorization for DPoP Avoid scheme/proof split after refresh when AS token_type is empty or Bearer: the preparer sets Authorization and DPoP together from client DPoP mode instead of trusting token.Type(). Co-authored-by: Cursor --- auth/authorization_code.go | 32 ++++--- auth/authorization_code_test.go | 165 ++++++++++++++++++++++++++++++++ auth/client.go | 17 ++-- mcp/streamable.go | 3 +- mcp/streamable_client_test.go | 5 +- 5 files changed, 200 insertions(+), 22 deletions(-) diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 93dec45f..4327f9d7 100644 --- a/auth/authorization_code.go +++ b/auth/authorization_code.go @@ -680,9 +680,6 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context if err != nil { return fmt.Errorf("token exchange failed: %w", err) } - if h.dpopKey != nil && token.TokenType == "" { - token.TokenType = "DPoP" - } // The token source outlives this authorization request: it is stored on the // handler and used by the transport for the lifetime of the connection. The // oauth2 library captures the context passed to TokenSource and reuses it for @@ -709,21 +706,28 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context return nil } -// PrepareRequest implements [RequestPreparer]. When DPoP is enabled, it -// attaches a fresh DPoP proof for the request. +// PrepareRequest implements [RequestPreparer]. It sets the Authorization header +// from the access token. When DPoP is enabled, the scheme is always "DPoP" +// (independent of the AS-reported token_type) and a fresh DPoP proof is +// attached, so scheme and proof cannot diverge after token refresh. func (h *AuthorizationCodeHandler) PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error { - if h.dpopKey == nil || token == nil { + if token == nil { return nil } - htu, err := oauthex.HTU(req.URL.String()) - if err != nil { - return fmt.Errorf("DPoP htu: %w", err) - } - proof, err := oauthex.BuildDPoPProof(h.dpopKey, req.Method, htu, token.AccessToken) - if err != nil { - return fmt.Errorf("DPoP proof: %w", err) + if h.dpopKey != nil { + req.Header.Set("Authorization", "DPoP "+token.AccessToken) + htu, err := oauthex.HTU(req.URL.String()) + if err != nil { + return fmt.Errorf("DPoP htu: %w", err) + } + proof, err := oauthex.BuildDPoPProof(h.dpopKey, req.Method, htu, token.AccessToken) + if err != nil { + return fmt.Errorf("DPoP proof: %w", err) + } + req.Header.Set("DPoP", proof) + return nil } - req.Header.Set("DPoP", proof) + req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) return nil } diff --git a/auth/authorization_code_test.go b/auth/authorization_code_test.go index c2f963f7..9da8c121 100644 --- a/auth/authorization_code_test.go +++ b/auth/authorization_code_test.go @@ -1423,3 +1423,168 @@ func TestInitialTokenSource(t *testing.T) { t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken) } } + +func TestPrepareRequest_DPoPOwnsScheme(t *testing.T) { + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, fmt.Errorf("unused") + }, + DPoP: &oauthex.DPoPConfig{}, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler: %v", err) + } + + for _, tt := range []struct { + name string + tokenType string + }{ + {name: "empty-token-type", tokenType: ""}, + {name: "bearer-token-type", tokenType: "Bearer"}, + {name: "dpop-token-type", tokenType: "DPoP"}, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://example.com/mcp", nil) + tok := &oauth2.Token{AccessToken: "access", TokenType: tt.tokenType} + if err := handler.PrepareRequest(t.Context(), req, tok); err != nil { + t.Fatalf("PrepareRequest: %v", err) + } + if got, want := req.Header.Get("Authorization"), "DPoP access"; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + if req.Header.Get("DPoP") == "" { + t.Error("expected DPoP proof header") + } + }) + } +} + +func TestPrepareRequest_WithoutDPoP(t *testing.T) { + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + return nil, fmt.Errorf("unused") + }, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "https://example.com/mcp", nil) + tok := &oauth2.Token{AccessToken: "access", TokenType: "Bearer"} + if err := handler.PrepareRequest(t.Context(), req, tok); err != nil { + t.Fatalf("PrepareRequest: %v", err) + } + if got, want := req.Header.Get("Authorization"), "Bearer access"; got != want { + t.Errorf("Authorization = %q, want %q", got, want) + } + if req.Header.Get("DPoP") != "" { + t.Errorf("unexpected DPoP header %q", req.Header.Get("DPoP")) + } +} + +// TestAuthorize_DPoPPrepareRequestAfterRefresh verifies that after a refresh +// that returns token_type=Bearer (as many ASes do), PrepareRequest still emits +// Authorization: DPoP and a proof. Scheme comes from client DPoP mode, not AS +// token_type — so they cannot diverge the way token.Type() + a separate proof did. +func TestAuthorize_DPoPPrepareRequestAfterRefresh(t *testing.T) { + authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{ + AccessTokenTTL: 1, + IssueRefreshToken: true, + RegistrationConfig: &oauthtest.RegistrationConfig{ + PreregisteredClients: map[string]oauthtest.ClientInfo{ + "test_client_id": { + Secret: "test_client_secret", + RedirectURIs: []string{"http://localhost:12345/callback"}, + }, + }, + }, + }) + authServer.Start(t) + + resourceMux := http.NewServeMux() + resourceServer := httptest.NewServer(resourceMux) + t.Cleanup(resourceServer.Close) + resourceURL := resourceServer.URL + "/resource" + resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: resourceURL, + AuthorizationServers: []string{authServer.URL()}, + })) + + handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{ + RedirectURL: "http://localhost:12345/callback", + PreregisteredClient: &oauthex.ClientCredentials{ + ClientID: "test_client_id", + ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"}, + }, + AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) { + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(args.URL) + if err != nil { + return nil, fmt.Errorf("failed to visit auth URL: %v", err) + } + defer resp.Body.Close() + location, err := resp.Location() + if err != nil { + return nil, fmt.Errorf("failed to get location header: %v", err) + } + return &AuthorizationResult{ + Code: location.Query().Get("code"), + State: location.Query().Get("state"), + Iss: location.Query().Get("iss"), + }, nil + }, + DPoP: &oauthex.DPoPConfig{}, + }) + if err != nil { + t.Fatalf("NewAuthorizationCodeHandler failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, resourceURL, nil) + resp := &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: http.NoBody, + Request: req, + } + resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource") + + if err := handler.Authorize(t.Context(), req, resp); err != nil { + t.Fatalf("Authorize failed: %v", err) + } + + tokenSource, err := handler.TokenSource(t.Context()) + if err != nil { + t.Fatalf("TokenSource: %v", err) + } + token, err := tokenSource.Token() + if err != nil { + t.Fatalf("token refresh: %v", err) + } + if token.AccessToken != "test_access_token_refreshed" { + t.Fatalf("AccessToken = %q, want refreshed token", token.AccessToken) + } + // Fake AS returns Bearer; token.Type() alone would produce the wrong scheme. + if token.Type() != "Bearer" { + t.Fatalf("precondition: refreshed token.Type() = %q, want Bearer from fake AS", token.Type()) + } + + mcpReq := httptest.NewRequest(http.MethodPost, "https://example.com/mcp", nil) + if err := handler.PrepareRequest(t.Context(), mcpReq, token); err != nil { + t.Fatalf("PrepareRequest: %v", err) + } + wantAuth := "DPoP test_access_token_refreshed" + if got := mcpReq.Header.Get("Authorization"); got != wantAuth { + t.Errorf("Authorization = %q, want %q", got, wantAuth) + } + if mcpReq.Header.Get("DPoP") == "" { + t.Error("expected DPoP proof header after refresh") + } +} diff --git a/auth/client.go b/auth/client.go index b22b49eb..c0467292 100644 --- a/auth/client.go +++ b/auth/client.go @@ -15,8 +15,9 @@ import ( // // If a transport wishes to support OAuth 2 authorization, it should support // being configured with an OAuthHandler. It should call the handler's -// TokenSource method whenever it sends an HTTP request to set the -// Authorization header. If a request fails with a 401 or 403, it should call +// TokenSource method whenever it sends an HTTP request, then either call +// [RequestPreparer.PrepareRequest] (when implemented) or set the Authorization +// header from the token. If a request fails with a 401 or 403, it should call // Authorize, and if that returns nil, it should retry the request. It should // not call Authorize after the second failure. See // [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport] @@ -40,10 +41,14 @@ type OAuthHandler interface { } // RequestPreparer is an optional interface that an [OAuthHandler] may -// implement to decorate outgoing MCP HTTP requests after the Authorization -// header is set (for example, to attach a fresh DPoP proof). Transports that -// support it should type-assert the handler; handlers that do not implement -// it are unaffected. +// implement to apply OAuth credentials to an outgoing MCP HTTP request. +// +// When a handler implements RequestPreparer, it owns the Authorization header +// and any related headers (for example a DPoP proof). Transports that detect +// this interface MUST NOT set Authorization themselves; they obtain a token +// from [OAuthHandler.TokenSource] and call PrepareRequest. Handlers that do +// not implement it are unaffected: the transport sets Authorization from +// token.Type() and the access token. type RequestPreparer interface { PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error } diff --git a/mcp/streamable.go b/mcp/streamable.go index 0f6fa26a..117d2ff0 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2399,11 +2399,12 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess return err } } else if token != nil { - req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) if p, ok := c.oauthHandler.(auth.RequestPreparer); ok { if err := p.PrepareRequest(c.ctx, req, token); err != nil { return err } + } else { + req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) } } } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 0d077405..3b9be3b6 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1031,7 +1031,9 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { func TestStreamableClientOAuth_DPoPSchemeAndPrepareRequest(t *testing.T) { ctx := context.Background() - token := &oauth2.Token{AccessToken: "dpop-token", TokenType: "DPoP"} + // Empty TokenType would make token.Type() return "Bearer"; the preparer must + // own Authorization so the scheme stays DPoP. + token := &oauth2.Token{AccessToken: "dpop-token"} oauthHandler := &dpopMockOAuthHandler{mockOAuthHandler: mockOAuthHandler{token: token}} var mu sync.Mutex @@ -1098,6 +1100,7 @@ type dpopMockOAuthHandler struct { func (h *dpopMockOAuthHandler) PrepareRequest(ctx context.Context, req *http.Request, token *oauth2.Token) error { h.prepareCalls++ + req.Header.Set("Authorization", "DPoP "+token.AccessToken) req.Header.Set("DPoP", "test-proof") return nil }