diff --git a/auth/authorization_code.go b/auth/authorization_code.go index 1673a2e6..4327f9d7 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 } @@ -674,6 +706,31 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context return nil } +// 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 token == nil { + return nil + } + 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("Authorization", token.Type()+" "+token.AccessToken) + 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/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 db32d97a..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] @@ -38,3 +39,16 @@ 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 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/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..e80a9551 --- /dev/null +++ b/conformance/everything-client/dpop.go @@ -0,0 +1,68 @@ +// 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" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" +) + +// 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", + }, + 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{}, + } + 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, + }, + } + } + } + + authHandler, err := auth.NewAuthorizationCodeHandler(authConfig) + if err != nil { + return fmt.Errorf("failed to create auth handler: %w", err) + } + + session, err := connectToServer(ctx, serverURL, withOAuthHandler(authHandler)) + if err != nil { + return 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 +} 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) } // ============================================================================ diff --git a/mcp/streamable.go b/mcp/streamable.go index f642db1d..117d2ff0 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2399,7 +2399,13 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request, msg jsonrpc.Mess return err } } else if token != nil { - req.Header.Set("Authorization", "Bearer "+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 5b523692..3b9be3b6 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1029,6 +1029,82 @@ func TestStreamableClientOAuth_AuthorizationHeader(t *testing.T) { session.Close() } +func TestStreamableClientOAuth_DPoPSchemeAndPrepareRequest(t *testing.T) { + ctx := context.Background() + // 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 + 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("Authorization", "DPoP "+token.AccessToken) + 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) + } +}