Skip to content

Commit 7615caf

Browse files
committed
auth: support OAuth2 session persistence
This change adds NewTokenSource and InitialTokenSource fields to AuthorizationCodeHandlerConfig. NewTokenSource enables wrapping the underlying token source to intercept token refreshes. InitialTokenSource enables configuring the token source used by the AuthorizationCodeHandler on initialization. Together these features facilitate persisting OAuth2 sessions across restarts. Includes a package example demonstrating the persistence pattern.
1 parent 4c2aece commit 7615caf

3 files changed

Lines changed: 305 additions & 3 deletions

File tree

auth/auth_example_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by the license
3+
// that can be found in the LICENSE file.
4+
5+
package auth_test
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"net/http"
11+
"net/http/httptest"
12+
"net/url"
13+
"sync"
14+
15+
"github.com/modelcontextprotocol/go-sdk/auth"
16+
"github.com/modelcontextprotocol/go-sdk/mcp"
17+
"github.com/modelcontextprotocol/go-sdk/oauthex"
18+
"golang.org/x/oauth2"
19+
)
20+
21+
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
22+
// token to the given saver function each time the access token value changes.
23+
type savingTokenSource struct {
24+
mu sync.Mutex
25+
src oauth2.TokenSource
26+
saver func(*oauth2.Config, *oauth2.Token) error
27+
config *oauth2.Config
28+
accessToken string
29+
}
30+
31+
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
32+
s.mu.Lock()
33+
defer s.mu.Unlock()
34+
tok, err := s.src.Token()
35+
if err != nil {
36+
return nil, err
37+
}
38+
if s.accessToken != tok.AccessToken {
39+
s.accessToken = tok.AccessToken
40+
// This saver implementation always returns nil.
41+
_ = s.saver(s.config, tok)
42+
}
43+
return tok, nil
44+
}
45+
46+
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
47+
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
48+
// access token change, it calls the provided session saver with the
49+
// oauth2.Config and the new oauth2.Token.
50+
//
51+
// initial is an optional access token the caller may already hold (such as a
52+
// token loaded from storage). It initialises the wrapper's state so that if
53+
// the first wrapped.Token() call returns the same token, does not trigger a
54+
// redundant call to saver(). Pass nil when there is no existing token, in
55+
// which case the first token produced by wrapped.Token() is saved.
56+
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
57+
if wrapped == nil {
58+
return nil
59+
}
60+
if saver == nil {
61+
return wrapped
62+
}
63+
var accessToken string
64+
if initial != nil {
65+
accessToken = initial.AccessToken
66+
}
67+
return &savingTokenSource{
68+
src: wrapped,
69+
saver: saver,
70+
config: config,
71+
accessToken: accessToken,
72+
}
73+
}
74+
75+
// This example shows how OAuth2 session persistence might be implemented.
76+
func Example_persistence() {
77+
var savedConfig *oauth2.Config
78+
var savedToken *oauth2.Token
79+
80+
// Save OAuth2 session.
81+
// This function might persist the session to disk or key storage.
82+
save := func(config *oauth2.Config, token *oauth2.Token) error {
83+
fmt.Printf("Saving token: %s\n", token.AccessToken)
84+
savedConfig = config
85+
savedToken = token
86+
return nil
87+
}
88+
89+
// Restore OAuth2 session.
90+
// This function might load the session from disk or key storage.
91+
restore := func() (*oauth2.Config, *oauth2.Token, error) {
92+
if savedConfig != nil && savedToken != nil {
93+
fmt.Println("Restoring session.")
94+
} else {
95+
fmt.Println("No session found to restore.")
96+
}
97+
return savedConfig, savedToken, nil
98+
}
99+
100+
// Simulate an MCP server that requires authorization and an OAuth server.
101+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+
switch r.URL.Path {
103+
case "/.well-known/oauth-authorization-server":
104+
w.Header().Set("Content-Type", "application/json")
105+
_, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host)
106+
case "/token":
107+
w.Header().Set("Content-Type", "application/json")
108+
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
109+
default:
110+
// The mock MCP endpoint returns 401 until the client presents a valid
111+
// bearer token.
112+
if r.Header.Get("Authorization") == "Bearer mock-token" {
113+
// A real server would return a valid MCP message here. The empty
114+
// response causes Connect to return an error after authorization,
115+
// which is ignored for this example which demonstrates token
116+
// persistence only.
117+
w.WriteHeader(http.StatusOK)
118+
return
119+
}
120+
w.Header().Set("WWW-Authenticate", "Bearer")
121+
w.WriteHeader(http.StatusUnauthorized)
122+
}
123+
}))
124+
defer mockServer.Close()
125+
126+
// Load the OAuth2 session if available, and use it.
127+
var initialTS oauth2.TokenSource
128+
cfg, tok, err := restore()
129+
if err == nil && cfg != nil && tok != nil {
130+
initialTS = NewSavingTokenSource(cfg.TokenSource(context.Background(), tok), cfg, tok, save)
131+
}
132+
133+
// Configure and initialize the AuthorizationCodeHandler with a
134+
// NewTokenSource that saves the session when the access token changes, and
135+
// the InitialTokenSource set to the session loaded via restore().
136+
config := &auth.AuthorizationCodeHandlerConfig{
137+
RedirectURL: "http://localhost/callback",
138+
PreregisteredClient: &oauthex.ClientCredentials{
139+
ClientID: "example",
140+
},
141+
Client: mockServer.Client(),
142+
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
143+
fmt.Println("No token source found. Transport is calling Authorize()...")
144+
// Extract the generated state from the authorization URL
145+
u, _ := url.Parse(args.URL)
146+
state := u.Query().Get("state")
147+
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
148+
},
149+
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
150+
// This save implementation always returns nil.
151+
_ = save(cfg, token)
152+
return NewSavingTokenSource(cfg.TokenSource(ctx, token), cfg, token, save), nil
153+
},
154+
InitialTokenSource: initialTS,
155+
}
156+
handler, err := auth.NewAuthorizationCodeHandler(config)
157+
if err != nil {
158+
fmt.Printf("Error creating handler: %v\n", err)
159+
return
160+
}
161+
162+
// Set the constructed handler on a transport.
163+
transport := &mcp.StreamableClientTransport{
164+
Endpoint: mockServer.URL + "/sse",
165+
OAuthHandler: handler,
166+
}
167+
168+
// Create a client and attempt to connect using the configured transport.
169+
// The transport will automatically:
170+
// 1. Call TokenSource() to check for an existing session. This will return
171+
// InitialTokenSource, if set.
172+
// 2. Try the MCP endpoint, and encounter a 401 response from the mock server.
173+
// 3. Call Authorize() to perform the OAuth flow, which calls NewTokenSource.
174+
// In this example NewTokenSource saves the newly acquired session.
175+
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
176+
client := mcp.NewClient(&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil)
177+
// Response ignored: this example asserts authorization only.
178+
_, _ = client.Connect(context.Background(), transport, nil)
179+
180+
// Output:
181+
// No session found to restore.
182+
// No token source found. Transport is calling Authorize()...
183+
// Saving token: mock-token
184+
}

auth/authorization_code.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ type AuthorizationCodeHandlerConfig struct {
126126
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
127127
// If not provided, http.DefaultClient will be used.
128128
Client *http.Client
129+
130+
// NewTokenSource is an optional function that can be set to construct the
131+
// token source that will be used by the [AuthorizationCodeHandler]. If
132+
// non-nil, it is called after the authorization code is successfully
133+
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
134+
// to obtain the [oauth2.TokenSource] returned by
135+
// [AuthorizationCodeHandler.TokenSource]. The default is to call
136+
// [oauth2.Config.TokenSource].
137+
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
138+
139+
// InitialTokenSource is an optional field that can be set to inject the
140+
// token source that will be used by the [AuthorizationCodeHandler]. If
141+
// non-nil, it is set as the token source that will be returned by
142+
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
143+
// The default is nil, which means no token source has been set initially,
144+
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
145+
InitialTokenSource oauth2.TokenSource
129146
}
130147

131148
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
@@ -197,10 +214,14 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
197214
if config.Client == nil {
198215
config.Client = http.DefaultClient
199216
}
200-
return &AuthorizationCodeHandler{
217+
h := &AuthorizationCodeHandler{
201218
config: config,
202219
grantedScopes: make(map[string][]string),
203-
}, nil
220+
}
221+
if config.InitialTokenSource != nil {
222+
h.tokenSource = config.InitialTokenSource
223+
}
224+
return h, nil
204225
}
205226

206227
func isNonRootHTTPSURL(u string) bool {
@@ -615,7 +636,15 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
615636
// completes. Use a background context that still carries the configured HTTP
616637
// client so refreshes keep working for the life of the token source.
617638
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
618-
h.tokenSource = cfg.TokenSource(refreshCtx, token)
639+
if h.config.NewTokenSource != nil {
640+
ts, err := h.config.NewTokenSource(refreshCtx, cfg, token)
641+
if err != nil {
642+
return fmt.Errorf("construction of new token source failed: %v", err)
643+
}
644+
h.tokenSource = ts
645+
} else {
646+
h.tokenSource = cfg.TokenSource(refreshCtx, token)
647+
}
619648
return nil
620649
}
621650

auth/authorization_code_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,3 +1233,92 @@ func validConfig() *AuthorizationCodeHandlerConfig {
12331233
},
12341234
}
12351235
}
1236+
1237+
func TestNewTokenSource(t *testing.T) {
1238+
// mock the /token endpoint to successfully return an access token on code exchange
1239+
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1240+
if r.URL.Path == "/token" {
1241+
w.Header().Set("Content-Type", "application/json")
1242+
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
1243+
return
1244+
}
1245+
w.WriteHeader(http.StatusNotFound)
1246+
}))
1247+
defer mockTS.Close()
1248+
1249+
// configure the handler and set NewTokenSource
1250+
var called bool
1251+
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
1252+
RedirectURL: "http://localhost/callback",
1253+
PreregisteredClient: &oauthex.ClientCredentials{
1254+
ClientID: "test_client",
1255+
},
1256+
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
1257+
u, _ := url.Parse(args.URL)
1258+
return &AuthorizationResult{
1259+
Code: "test_code",
1260+
State: u.Query().Get("state"),
1261+
}, nil
1262+
},
1263+
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
1264+
called = true
1265+
return oauth2.StaticTokenSource(token), nil
1266+
},
1267+
Client: mockTS.Client(),
1268+
})
1269+
if err != nil {
1270+
t.Fatalf("unexpected error: %v", err)
1271+
}
1272+
1273+
// Simulate a 401 response from a resource server.
1274+
// The WWW-Authenticate: Bearer header triggers the authorization logic.
1275+
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
1276+
resp := &http.Response{
1277+
StatusCode: http.StatusUnauthorized,
1278+
Header: make(http.Header),
1279+
Body: http.NoBody,
1280+
Request: req,
1281+
}
1282+
resp.Header.Set("WWW-Authenticate", "Bearer")
1283+
1284+
// Authorize and confirm NewTokenSource was called.
1285+
err = handler.Authorize(t.Context(), req, resp)
1286+
if err != nil {
1287+
t.Fatalf("unexpected error: %v", err)
1288+
}
1289+
if !called {
1290+
t.Error("expected NewTokenSource to be called")
1291+
}
1292+
}
1293+
1294+
func TestInitialTokenSource(t *testing.T) {
1295+
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
1296+
RedirectURL: "http://localhost:12345/callback",
1297+
PreregisteredClient: &oauthex.ClientCredentials{
1298+
ClientID: "test_client_id",
1299+
},
1300+
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
1301+
return nil, nil
1302+
},
1303+
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
1304+
})
1305+
if err != nil {
1306+
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
1307+
}
1308+
1309+
ts, err := handler.TokenSource(t.Context())
1310+
if err != nil {
1311+
t.Fatalf("failed to get token source: %v", err)
1312+
}
1313+
if ts == nil {
1314+
t.Fatal("expected token source to be non-nil")
1315+
}
1316+
1317+
tok, err := ts.Token()
1318+
if err != nil {
1319+
t.Fatalf("failed to get Token: %v", err)
1320+
}
1321+
if tok.AccessToken != "set_token" {
1322+
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
1323+
}
1324+
}

0 commit comments

Comments
 (0)