Skip to content

Commit 36e87f8

Browse files
committed
auth: client-side OAuth flow
This is a preliminary implementation of OAuth 2.1 for the client. It provides an http.RoundTripper, auth.HTTPTransport, that invokes a user-provided callback of type auth.OAuthHandler. The latter is responsible for all the OAuth work. We will add code to make that easier in later PRs. Much remains to be done : Dynamic client registration is not implemented. Since it is optional, we also need another way of supplying the client ID and secret to this code. Resource Indicators, as described in section 2.5.1 of the MCP spec. And, of course, tests. We should test against fake implementations but also, if we can find any, real reference implementations.
1 parent 07f6c49 commit 36e87f8

File tree

8 files changed

+127
-9
lines changed

8 files changed

+127
-9
lines changed

auth/client.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package auth
6+
7+
import (
8+
"context"
9+
"log"
10+
"net/http"
11+
"sync"
12+
13+
"github.com/modelcontextprotocol/go-sdk/internal/oauthex"
14+
"golang.org/x/oauth2"
15+
)
16+
17+
// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization
18+
// is approved, or an error if not.
19+
type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error)
20+
21+
// OAuthHandlerArgs are arguments to an [OAuthHandler].
22+
type OAuthHandlerArgs struct {
23+
// The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header.
24+
// Empty if not present or there was an error obtaining it.
25+
ResourceMetadataURL string
26+
}
27+
28+
// HTTPTransport is an [http.RoundTripper] that follows the MCP
29+
// OAuth protocol when it encounters a 401 Unauthorized response.
30+
type HTTPTransport struct {
31+
handler OAuthHandler
32+
mu sync.Mutex // protects opts.Base
33+
opts HTTPTransportOptions
34+
}
35+
36+
// NewHTTPTransport returns a new [*HTTPTransport].
37+
// The handler is invoked when an HTTP request results in a 401 Unauthorized status.
38+
// It is called only once per transport. Once a TokenSource is obtained, it is used
39+
// for the lifetime of the transport; subsequent 401s are not processed.
40+
func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) {
41+
t := &HTTPTransport{}
42+
if opts != nil {
43+
t.opts = *opts
44+
}
45+
if t.opts.Base == nil {
46+
t.opts.Base = http.DefaultTransport
47+
}
48+
return t, nil
49+
}
50+
51+
// HTTPTransportOptions are options to [NewHTTPTransport].
52+
type HTTPTransportOptions struct {
53+
// Base is the [http.RoundTripper] to use.
54+
// If nil, [http.DefaultTransport] is used.
55+
Base http.RoundTripper
56+
}
57+
58+
func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
59+
t.mu.Lock()
60+
base := t.opts.Base
61+
_, haveTokenSource := base.(*oauth2.Transport)
62+
t.mu.Unlock()
63+
64+
resp, err := base.RoundTrip(req)
65+
if err != nil {
66+
return nil, err
67+
}
68+
if resp.StatusCode != http.StatusUnauthorized {
69+
return resp, nil
70+
}
71+
if haveTokenSource {
72+
// We failed to authorize even with a token source; give up.
73+
return resp, nil
74+
}
75+
// Try to authorize.
76+
t.mu.Lock()
77+
// If we don't have a token source, get one by following the OAuth flow.
78+
// (We may have obtained one while t.mu was not held above.)
79+
if _, ok := t.opts.Base.(*oauth2.Transport); !ok {
80+
authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]
81+
ts, err := t.handler(req.Context(), OAuthHandlerArgs{
82+
ResourceMetadataURL: extractResourceMetadataURL(authHeaders),
83+
})
84+
if err != nil {
85+
t.mu.Unlock()
86+
return nil, err
87+
}
88+
t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts}
89+
}
90+
t.mu.Unlock()
91+
// Only one level of recursion, because we now have a token source.
92+
return t.RoundTrip(req)
93+
}
94+
95+
func extractResourceMetadataURL(authHeaders []string) string {
96+
cs, err := oauthex.ParseWWWAuthenticate(authHeaders)
97+
if err != nil {
98+
log.Printf("parsing auth headers %q: %v", authHeaders, err)
99+
return ""
100+
}
101+
return oauthex.ResourceMetadataURL(cs)
102+
}

examples/server/auth-middleware/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module auth-middleware-example
22

3-
go 1.23.0
3+
go 1.24.0
4+
5+
toolchain go1.24.4
46

57
require (
68
github.com/golang-jwt/jwt/v5 v5.2.2
@@ -10,6 +12,7 @@ require (
1012
require (
1113
github.com/google/jsonschema-go v0.3.0 // indirect
1214
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
15+
golang.org/x/oauth2 v0.31.0 // indirect
1316
)
1417

1518
replace github.com/modelcontextprotocol/go-sdk => ../../../

examples/server/auth-middleware/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
66
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
77
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
88
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
9+
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
10+
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
911
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1012
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

examples/server/rate-limiting/go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting
22

3-
go 1.23.0
3+
go 1.24.0
4+
5+
toolchain go1.24.4
46

57
require (
68
github.com/modelcontextprotocol/go-sdk v0.3.0
@@ -10,6 +12,7 @@ require (
1012
require (
1113
github.com/google/jsonschema-go v0.3.0 // indirect
1214
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
15+
golang.org/x/oauth2 v0.31.0 // indirect
1316
)
1417

1518
replace github.com/modelcontextprotocol/go-sdk => ../../../

examples/server/rate-limiting/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
44
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
55
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
66
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
7+
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
8+
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
79
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
810
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
911
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module github.com/modelcontextprotocol/go-sdk
22

3-
go 1.23.0
3+
go 1.24.0
4+
5+
toolchain go1.24.4
46

57
require (
68
github.com/golang-jwt/jwt/v5 v5.2.2
@@ -9,3 +11,5 @@ require (
911
github.com/yosida95/uritemplate/v3 v3.0.2
1012
golang.org/x/tools v0.34.0
1113
)
14+
15+
require golang.org/x/oauth2 v0.31.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy
1010
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
1111
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
1212
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
13+
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
14+
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
1315
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1416
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

internal/oauthex/resource_meta.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea
146146
if len(headers) == 0 {
147147
return nil, nil
148148
}
149-
cs, err := parseWWWAuthenticate(headers)
149+
cs, err := ParseWWWAuthenticate(headers)
150150
if err != nil {
151151
return nil, err
152152
}
153-
url := resourceMetadataURL(cs)
153+
url := ResourceMetadataURL(cs)
154154
if url == "" {
155155
return nil, nil
156156
}
@@ -187,9 +187,9 @@ type challenge struct {
187187
Params map[string]string
188188
}
189189

190-
// resourceMetadataURL returns a resource metadata URL from the given challenges,
190+
// ResourceMetadataURL returns a resource metadata URL from the given challenges,
191191
// or the empty string if there is none.
192-
func resourceMetadataURL(cs []challenge) string {
192+
func ResourceMetadataURL(cs []challenge) string {
193193
for _, c := range cs {
194194
if u := c.Params["resource_metadata"]; u != "" {
195195
return u
@@ -198,11 +198,11 @@ func resourceMetadataURL(cs []challenge) string {
198198
return ""
199199
}
200200

201-
// parseWWWAuthenticate parses a WWW-Authenticate header string.
201+
// ParseWWWAuthenticate parses a WWW-Authenticate header string.
202202
// The header format is defined in RFC 9110, Section 11.6.1, and can contain
203203
// one or more challenges, separated by commas.
204204
// It returns a slice of challenges or an error if one of the headers is malformed.
205-
func parseWWWAuthenticate(headers []string) ([]challenge, error) {
205+
func ParseWWWAuthenticate(headers []string) ([]challenge, error) {
206206
// GENERATED BY GEMINI 2.5 (human-tweaked)
207207
var challenges []challenge
208208
for _, h := range headers {

0 commit comments

Comments
 (0)