|
| 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 | +} |
0 commit comments