Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions auth/authorization_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
165 changes: 165 additions & 0 deletions auth/authorization_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
18 changes: 16 additions & 2 deletions auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
}
5 changes: 2 additions & 3 deletions conformance/baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions conformance/everything-client/dpop.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions conformance/everything-client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// ============================================================================
Expand Down
8 changes: 7 additions & 1 deletion mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
Loading