Compare commits

..
Author SHA1 Message Date
Guglielmo Colombo
d454bbaf06
mcp: add MCPGPDEBUG for opt-in Content-Type check (#972)
Fixes #957
2026-05-22 13:30:38 +02:00
124 changed files with 1090 additions and 21408 deletions

View file

@ -5,11 +5,3 @@ updates:
directory: "/"
schedule:
interval: "monthly"
# Group all github/codeql-action/* sub-paths (init, analyze, ...) into a
# single PR so they are always bumped together. Bumping them independently
# leaves the workflow with mismatched versions across steps, which
# github/codeql-action rejects at runtime.
groups:
codeql-action:
patterns:
- "github/codeql-action/*"

View file

@ -36,13 +36,13 @@ jobs:
build-mode: autobuild
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: "/language:${{matrix.language}}"

View file

@ -13,62 +13,44 @@ concurrency:
permissions:
contents: read
env:
CONFORMANCE_VERSION: "0.2.0-alpha.11"
jobs:
server-conformance:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- name: Start everything-server
run: |
go run ./conformance/everything-server/main.go -http=":3001" &
# Wait for the server to be ready.
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done'
- name: "Run conformance tests"
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
with:
mode: server
url: http://localhost:3001/mcp
suite: active
expected-failures: ./conformance/baseline.yml
node-version: 22
- name: Build everything-server
run: go build -o everything-server ./conformance/everything-server
- name: Run conformance tests (2025-11-25, stateful)
run: |
./everything-server -http=":3001" -stateless=false &
SERVER_PID=$!
trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://localhost:3001/mcp \
--requirements 2025-11-25 \
--expected-failures ./conformance/baseline.yml
- name: Run conformance tests (2026-07-28, stateless)
run: |
./everything-server -http=":3001" -stateless &
SERVER_PID=$!
trap "kill -9 $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp > /dev/null; do sleep 0.5; done'
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://localhost:3001/mcp \
--requirements 2026-07-28 \
--expected-failures ./conformance/baseline.yml
client-conformance:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- name: "Run conformance tests"
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
with:
mode: client
command: go run ./conformance/everything-client
suite: core
expected-failures: ./conformance/baseline.yml
node-version: 22
- name: Run conformance tests (full suite)
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "go run ./conformance/everything-client" \
--suite all \
--expected-failures ./conformance/baseline.yml

View file

@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check docs are up-to-date
run: |
go generate ./...

View file

@ -11,29 +11,25 @@ permissions:
jobs:
vulncheck:
# Don't run scheduled builds on forks.
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
runs-on: ubuntu-latest
steps:
- name: Run govulncheck
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
with:
go-version-file: go.mod
go-package: ./...
conformance:
# Don't run scheduled builds on forks.
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
- name: Run server conformance tests

View file

@ -13,16 +13,16 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache

View file

@ -34,12 +34,12 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
@ -64,7 +64,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: SARIF file
path: results.sarif
@ -73,6 +73,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
sarif_file: results.sarif

View file

@ -14,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "^1.26"
- name: Check formatting
@ -43,9 +43,9 @@ jobs:
go: ["1.25", "1.26"]
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: ${{ matrix.go }}
- name: Test
@ -55,9 +55,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26"
- name: Test with -race

View file

@ -21,9 +21,6 @@ transparently, while adhering to these goals.
1. [Contributing code](#contributing-code)
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
1. [Updating the README](#updating-the-readme)
1. [Versioning](#versioning)
1. [What counts as a breaking change](#what-counts-as-a-breaking-change)
1. [Deprecation](#deprecation)
1. [Timeouts](#timeouts)
1. [Code of conduct](#code-of-conduct)
1. [Governance](#governance)
@ -191,57 +188,6 @@ The CI system will automatically check that the README is up-to-date by running
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
README being out of sync, follow the steps above to regenerate it.
## Versioning
The SDK follows [semantic versioning](https://semver.org), as the Go module
system requires: the major version is part of the module path, so a breaking
release would import as `github.com/modelcontextprotocol/go-sdk/v2`. A breaking
change therefore cannot reach existing users by accident; they have to change
their import path to receive one.
This policy covers the exported API of the SDK's importable packages — `mcp`,
`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/`
is not importable outside the module and may change in any release.
Which MCP spec revisions each SDK version speaks is documented in the
[README](README.md#version-compatibility). Support for a new revision arrives
in a minor release; dropping a revision the SDK previously negotiated is a
breaking change.
### What counts as a breaking change
- Removing or renaming an exported identifier.
- Changing the signature of an exported function or method.
- Removing or renaming a field of an exported struct, or changing its type.
- Adding a method to an exported interface that users are meant to implement.
- Changing documented behavior that callers could reasonably rely on.
- Dropping support for an MCP spec revision.
The following are not breaking, and may land in a minor or patch release:
- Adding an exported identifier, or a field to an exported struct.
- Fixing behavior that contradicted its documentation.
- Refactoring that leaves the exported API unchanged.
- Adding support for a new MCP spec revision.
- Raising the minimum Go version to one still supported upstream, per the
[README](README.md#version-compatibility).
### Deprecation
An API on its way out is marked with a `// Deprecated:` comment naming its
replacement, which `gopls` and `staticcheck` surface at call sites. It keeps
working until the next major version, since removing it would itself be a
breaking change.
Protocol features that the *specification* deprecates are a separate matter and
follow the spec's timeline rather than this policy. The features deprecated by
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging)
— roots, sampling and logging — remain supported for at least twelve months, as
noted in the [README](README.md#version-compatibility).
Any change to the exported API, breaking or not, goes through the
[proposal process](#proposals).
## Timeouts
If a contributor hasn't responded to issue questions or PR comments in two weeks,

View file

@ -34,24 +34,16 @@ contains feature documentation, mapping the MCP spec to the packages above.
The following table shows which versions of the Go SDK support which versions of the MCP specification:
| SDK Version | Latest MCP Spec | All Supported MCP Specs |
|-----------------|-------------------|------------------------------------------------------------------|
| v1.7.0+ | 2026-07-28 | 2026-07-28, 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.4.0 - v1.6.1 | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 |
| SDK Version | Latest MCP Spec | All Supported MCP Specs |
|-----------------|-------------------|----------------------------------------------------|
| v1.4.0+ | 2025-11-25\* | 2025-11-25\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.2.0 - v1.3.1 | 2025-11-25\*\* | 2025-11-25\*\*, 2025-06-18, 2025-03-26, 2024-11-05 |
| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 |
\* Client side OAuth has experimental support.
\*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available).
The roots, sampling, and logging features are deprecated as of protocol version
2026-07-28 by
[SEP-2577](https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging).
The SDK continues to support them for compatibility during the deprecation
window (at least twelve months). See the individual feature documentation for
migration guidance.
New releases of the SDK target only supported versions of Go. See
https://go.dev/doc/devel/release#policy for more information.

View file

@ -47,34 +47,6 @@ type RequireBearerTokenOptions struct {
ResourceMetadataURL string
// The required scopes.
Scopes []string
// AllowMissingExpiration opts the middleware out of the
// `tokenInfo.Expiration.IsZero()` reject. Default false preserves the
// existing strict behaviour (every TokenInfo must carry an Expiration).
//
// Some IdPs emit session-bound bearer tokens that do not carry a standalone
// `exp` claim — the token's lifetime is bounded by an external session and
// is not advertised in-band. Resource servers integrating with such IdPs
// need to opt in to validating the rest of the token (scopes, signature
// via the verifier callback, etc.) without requiring the expiration field
// to be present.
//
// When enabled, the verifier is still responsible for any session-level
// validity check it can perform; this option only relaxes the middleware's
// own expiration enforcement.
AllowMissingExpiration bool
// ClockSkew bounds the tolerance applied to a token's Expiration when
// deciding whether it has elapsed. A token is rejected only if
// Expiration + ClockSkew is before the current time. Zero (the default)
// preserves strict comparison: any expired token is rejected immediately.
//
// Resource servers running behind a CDN, in distributed deployments, or
// communicating with an authorization server whose clock drifts a few
// seconds (common with cloud-managed IdPs) need a small positive value
// here to avoid rejecting tokens that are valid by the issuer's clock
// but momentarily appear expired by the verifier's. The same tolerance
// guards against an issuer's clock running slightly fast at /token
// issuance time.
ClockSkew time.Duration
}
type tokenInfoKey struct{}
@ -157,17 +129,11 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
}
}
if opts == nil {
opts = &RequireBearerTokenOptions{}
}
// Check expiration, with optional clock-skew tolerance. Skew only applies
// when an expiration is present; a missing expiration is governed solely by
// AllowMissingExpiration.
// Check expiration.
if tokenInfo.Expiration.IsZero() {
if !opts.AllowMissingExpiration {
return nil, "token missing expiration", http.StatusUnauthorized
}
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
return nil, "token missing expiration", http.StatusUnauthorized
}
if tokenInfo.Expiration.Before(time.Now()) {
return nil, "token expired", http.StatusUnauthorized
}
return tokenInfo, "", 0

View file

@ -1,222 +0,0 @@
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by the license
// that can be found in the LICENSE file.
package auth_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
// token to the given saver function each time the access token value changes.
type savingTokenSource struct {
mu sync.Mutex
src oauth2.TokenSource
saver func(*oauth2.Config, *oauth2.Token) error
config *oauth2.Config
accessToken string
}
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
s.mu.Lock()
defer s.mu.Unlock()
tok, err := s.src.Token()
if err != nil {
return nil, err
}
if s.accessToken != tok.AccessToken {
s.accessToken = tok.AccessToken
// This saver implementation always returns nil.
_ = s.saver(s.config, tok)
}
return tok, nil
}
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
// access token change, it calls the provided session saver with the
// oauth2.Config and the new oauth2.Token.
//
// initial is an optional access token the caller may already hold (such as a
// token loaded from storage). It initialises the wrapper's state so that if
// the first wrapped.Token() call returns the same token, does not trigger a
// redundant call to saver(). Pass nil when there is no existing token, in
// which case the first token produced by wrapped.Token() is saved.
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
if wrapped == nil {
return nil
}
if saver == nil {
return wrapped
}
var accessToken string
if initial != nil {
accessToken = initial.AccessToken
}
return &savingTokenSource{
src: wrapped,
saver: saver,
config: config,
accessToken: accessToken,
}
}
// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate
// persistence. A production implementation would persist the session to disk or
// a secret store.
type sessionStore struct {
config *oauth2.Config
token *oauth2.Token
}
// save persists the given OAuth 2.0 config and token.
func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error {
fmt.Printf("Saving token: %s\n", token.AccessToken)
s.config = config
s.token = token
return nil
}
// restore loads a previously persisted OAuth 2.0 session, if one exists.
func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) {
if s.config != nil && s.token != nil {
fmt.Println("Restoring session.")
} else {
fmt.Println("No session found to restore.")
}
return s.config, s.token, nil
}
// newMockAuthServer returns an httptest.Server that simulates both an MCP
// resource server requiring authorization and its OAuth authorization server.
func newMockAuthServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/oauth-authorization-server":
w.Header().Set("Content-Type", "application/json")
_, _ = 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)
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
default:
// The mock MCP endpoint returns 401 until the client presents a valid
// bearer token.
if r.Header.Get("Authorization") == "Bearer mock-token" {
// A real server would return a valid MCP message here. The empty
// response causes Connect to return an error after authorization,
// which is ignored for this example which demonstrates token
// persistence only.
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("WWW-Authenticate", "Bearer")
w.WriteHeader(http.StatusUnauthorized)
}
}))
}
// This example shows how OAuth2 session persistence might be implemented. It
// connects twice using a shared in-memory session store: the first connection
// authorizes and saves the session, and the second restores and reuses it.
func Example_persistence() {
// Simulate an MCP server that requires authorization and an OAuth server.
mockServer := newMockAuthServer()
defer mockServer.Close()
// store persists the OAuth2 session across both connections.
store := &sessionStore{}
// connect performs a single client connection, restoring any saved session
// beforehand and saving any new session acquired during authorization.
connect := func() {
// Load the OAuth2 session if available, and use it.
var initialTS oauth2.TokenSource
cfg, tok, err := store.restore()
if err == nil && cfg != nil && tok != nil {
initialTS = NewSavingTokenSource(
cfg.TokenSource(context.Background(), tok),
cfg, tok, store.save,
)
}
// Configure and initialize the AuthorizationCodeHandler with a
// NewTokenSource that saves the session when the access token changes,
// and the InitialTokenSource set to the session loaded via restore().
config := &auth.AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "example",
},
Client: mockServer.Client(),
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
fmt.Println("No token source found. Transport is calling Authorize()...")
// Extract the generated state from the authorization URL
u, _ := url.Parse(args.URL)
state := u.Query().Get("state")
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
},
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
// This save implementation always returns nil.
_ = store.save(cfg, token)
return NewSavingTokenSource(
cfg.TokenSource(ctx, token), cfg, token, store.save,
), nil
},
InitialTokenSource: initialTS,
}
handler, err := auth.NewAuthorizationCodeHandler(config)
if err != nil {
fmt.Printf("Error creating handler: %v\n", err)
return
}
// Set the constructed handler on a transport.
transport := &mcp.StreamableClientTransport{
Endpoint: mockServer.URL + "/sse",
OAuthHandler: handler,
}
// Create a client and attempt to connect using the configured
// transport. The transport will automatically:
// 1. Call TokenSource() to check for an existing session. This will
// return InitialTokenSource, if set.
// 2. Try the MCP endpoint, and encounter a 401 response from the mock
// server.
// 3. Call Authorize() to perform the OAuth flow, which calls
// NewTokenSource. In this example NewTokenSource saves the newly
// acquired session.
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
client := mcp.NewClient(
&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil,
)
// Response ignored: this example asserts authorization only.
_, _ = client.Connect(context.Background(), transport, nil)
}
// The first connection has no saved session, so it authorizes and saves.
fmt.Println("--- First connect ---")
connect()
// The second connection restores the saved session and reuses it, so no
// further authorization or save occurs.
fmt.Println("--- Second connect ---")
connect()
// Output:
// --- First connect ---
// No session found to restore.
// No token source found. Transport is calling Authorize()...
// Saving token: mock-token
// --- Second connect ---
// Restoring session.
}

View file

@ -62,11 +62,6 @@ func TestVerify(t *testing.T) {
"no expiration", nil, "Bearer noexp",
"token missing expiration", 401,
},
{
"no expiration with AllowMissingExpiration accepts",
&RequireBearerTokenOptions{AllowMissingExpiration: true}, "Bearer noexp",
"", 0,
},
{
"expired", nil, "Bearer expired",
"token expired", 401,
@ -289,63 +284,3 @@ func TestRequireBearerToken(t *testing.T) {
})
}
}
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
// extends the expiration check tolerance: a token whose Expiration is in the
// recent past is accepted iff the elapsed interval is within ClockSkew.
func TestRequireBearerToken_ClockSkew(t *testing.T) {
tests := []struct {
name string
clockSkew time.Duration
expiredAgo time.Duration
wantStatus int
}{
{
name: "no skew, fresh token accepted",
clockSkew: 0,
expiredAgo: -time.Minute, // expires in 1 minute
wantStatus: http.StatusOK,
},
{
name: "no skew, expired token rejected",
clockSkew: 0,
expiredAgo: 5 * time.Second, // expired 5s ago
wantStatus: http.StatusUnauthorized,
},
{
name: "with skew, recently-expired token accepted",
clockSkew: 30 * time.Second,
expiredAgo: 5 * time.Second,
wantStatus: http.StatusOK,
},
{
name: "with skew, token expired beyond tolerance rejected",
clockSkew: 10 * time.Second,
expiredAgo: 30 * time.Second,
wantStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
}
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
ClockSkew: tt.clockSkew,
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer anything")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}

View file

@ -14,9 +14,7 @@ import (
"net/url"
"slices"
"strings"
"sync"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/internal/util"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
@ -51,12 +49,6 @@ type AuthorizationResult struct {
Code string
// State string returned by the authorization server.
State string
// Iss is the issuer identifier returned by the authorization server in the
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
// populate this from the "iss" query parameter in the redirect URI if present.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
Iss string
}
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
@ -98,37 +90,6 @@ type AuthorizationCodeHandlerConfig struct {
// See [AuthorizationCodeFetcher] for details.
AuthorizationCodeFetcher AuthorizationCodeFetcher
// ScopeFilter, if non-nil, is called with the scopes discovered from the
// protected resource metadata (or WWW-Authenticate challenge) and returns
// the scopes to request during authorization. It gives the client full
// control to narrow, extend, or reorder that set — e.g. dropping Gmail's
// gmail.metadata, which the Gmail API refuses to combine with the search "q"
// parameter even alongside gmail.readonly. It runs before offline_access
// (see RequestRefreshToken) and the step-up union, so neither is affected.
ScopeFilter func(discovered []string) []string
// RequestRefreshToken indicates that the client intends to use refresh
// tokens and is capable of storing them securely.
//
// When true and the Authorization Server metadata contains "offline_access"
// in its scopes_supported, the client adds "offline_access" to the
// requested scopes.
//
// When using Dynamic Client Registration, callers should include
// "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes
// directly to advertise refresh token support to the Authorization Server.
//
// When using Client ID Metadata Document, the document hosted at the
// Client ID URL should include "refresh_token" in its grant_types.
//
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
RequestRefreshToken bool
// AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the
// authorization server metadata omits authorization_response_iss_parameter_supported.
// The zero value (false) keeps the historical reject-unadvertised-iss behavior.
AcceptUnadvertisedIss bool
// Client is an optional HTTP client to use for HTTP requests.
// It is used for the following requests:
// - Fetching Protected Resource Metadata
@ -141,24 +102,6 @@ type AuthorizationCodeHandlerConfig struct {
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
// If not provided, http.DefaultClient will be used.
Client *http.Client
// NewTokenSource is an optional function that can be set to construct the
// token source that will be used by the [AuthorizationCodeHandler]. If
// non-nil, it is called after the authorization code is successfully
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
// to obtain the [oauth2.TokenSource] returned by
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
// provided context, which is properly configured for constructing a
// TokenSource. The default is to call [oauth2.Config.TokenSource].
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
// InitialTokenSource is an optional field that can be set to inject the
// token source that will be used by the [AuthorizationCodeHandler]. If
// non-nil, it is set as the token source that will be returned by
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
// The default is nil, which means no token source has been set initially,
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
InitialTokenSource oauth2.TokenSource
}
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
@ -166,21 +109,13 @@ type AuthorizationCodeHandlerConfig struct {
type AuthorizationCodeHandler struct {
config *AuthorizationCodeHandlerConfig
// mu protects concurrent access to tokenSource and grantedScopes.
mu sync.RWMutex
// tokenSource is the token source to use for authorization.
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.tokenSource, nil
}
@ -235,11 +170,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
if config.Client == nil {
config.Client = http.DefaultClient
}
return &AuthorizationCodeHandler{
config: config,
tokenSource: config.InitialTokenSource,
grantedScopes: make(map[string][]string),
}, nil
return &AuthorizationCodeHandler{config: config}, nil
}
func isNonRootHTTPSURL(u string) bool {
@ -327,33 +258,11 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
return err
}
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
scps := scopesFromChallenges(wwwChallenges)
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
scps = prm.ScopesSupported
}
// Let the client adjust the discovered scopes before offline_access and the
// step-up union below so neither is affected.
if h.config.ScopeFilter != nil {
requestedScopes = h.config.ScopeFilter(requestedScopes)
}
// SEP-2207: when the client desires refresh tokens and the Authorization
// Server advertises offline_access support, add it to the requested scopes.
if h.config.RequestRefreshToken &&
slices.Contains(asm.ScopesSupported, "offline_access") &&
!slices.Contains(requestedScopes, "offline_access") {
requestedScopes = append(requestedScopes, "offline_access")
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
h.mu.RLock()
granted := h.grantedScopes[asm.Issuer]
h.mu.RUnlock()
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
cfg := &oauth2.Config{
ClientID: resolvedClientConfig.clientID,
ClientSecret: resolvedClientConfig.clientSecret,
@ -364,7 +273,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
AuthStyle: resolvedClientConfig.authStyle,
},
RedirectURL: h.config.RedirectURL,
Scopes: requestedScopes,
Scopes: scps,
}
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
@ -372,16 +281,8 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
// Purposefully leaving the error unwrappable so it can be handled by the caller.
return err
}
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
return err
}
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
if err != nil {
return err
}
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
}
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
@ -555,9 +456,6 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
// 2. Attempt to use pre-registered client configuration.
preCfg := h.config.PreregisteredClient
if preCfg != nil {
if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) {
return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer)
}
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
clientSecret := ""
if preCfg.ClientSecretAuth != nil {
@ -620,36 +518,6 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
}, nil
}
// validateIssuerResponse validates the "iss" parameter in an authorization response
// per [RFC 9207]. When the server advertises authorization_response_iss_parameter_supported,
// iss is required and must match expectedIssuer. When it does not advertise support,
// an empty iss is accepted; a present iss is compared to expectedIssuer (RFC 9207 §2.4)
// and a mismatch is always rejected. A matching unadvertised iss is accepted only when
// acceptUnadvertisedIss is true (local policy); the default is the historical reject.
//
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
func validateIssuerResponse(iss, expectedIssuer string, issParameterSupported, acceptUnadvertisedIss bool) error {
if issParameterSupported {
if iss == "" {
return fmt.Errorf("authorization server advertises RFC 9207 iss parameter support but none was received in the authorization response")
}
if iss != expectedIssuer {
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
}
return nil
}
if iss == "" {
return nil
}
if iss != expectedIssuer {
return fmt.Errorf("authorization response issuer %q does not match expected issuer %q", iss, expectedIssuer)
}
if !acceptUnadvertisedIss {
return fmt.Errorf("authorization server does not advertise RFC 9207 iss parameter support but iss was received in the authorization response")
}
return nil
}
// exchangeAuthorizationCode exchanges the authorization code for a token
// and stores it in a token source.
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
@ -662,52 +530,6 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
// The token source outlives this authorization request: it is stored on the
// handler and used by the transport for the lifetime of the connection. The
// oauth2 library captures the context passed to TokenSource and reuses it for
// every subsequent token refresh (see golang.org/x/oauth2: tokenRefresher
// retains the context and passes it to each refresh round-trip). Binding it to
// the per-request ctx makes all later refreshes fail with "context canceled"
// once that request (or the connect operation that triggered authorization)
// completes. Use a background context that still carries the configured HTTP
// client so refreshes keep working for the life of the token source.
refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
var ts oauth2.TokenSource
if h.config.NewTokenSource == nil {
ts = cfg.TokenSource(refreshCtx, token)
} else {
var err error
ts, err = h.config.NewTokenSource(refreshCtx, cfg, token)
if err != nil {
return fmt.Errorf("constructing token source failed: %w", err)
}
}
h.mu.Lock()
h.tokenSource = ts
h.mu.Unlock()
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()
ts := h.tokenSource
h.mu.RUnlock()
if ts == nil {
return nil
}
tok, err := ts.Token()
if err != nil {
return err
}
h.mu.Lock()
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
h.mu.Unlock()
h.tokenSource = cfg.TokenSource(clientCtx, token)
return nil
}

View file

@ -10,13 +10,10 @@ import (
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"slices"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
@ -78,7 +75,6 @@ func TestAuthorize(t *testing.T) {
return &AuthorizationResult{
Code: location.Query().Get("code"),
State: location.Query().Get("state"),
Iss: location.Query().Get("iss"),
}, nil
},
})
@ -115,227 +111,6 @@ func TestAuthorize(t *testing.T) {
}
}
// TestAuthorize_RefreshAfterContextCancel verifies that the token source built
// by Authorize keeps refreshing after the context passed to Authorize is
// cancelled. The token source is stored on the handler and used by the
// transport for the whole connection lifetime, whereas Authorize is typically
// called from a request- or connect-scoped context. golang.org/x/oauth2
// captures the context handed to Config.TokenSource and reuses it for every
// refresh, so binding it to the request context made every refresh after the
// access token expired fail with "context canceled". Regression test for that.
func TestAuthorize_RefreshAfterContextCancel(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
// expires_in below oauth2's 10s expiry delta, so the reuse token source
// treats the access token as expired immediately and must refresh.
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
},
})
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")
// Authorize under a context that we cancel immediately afterwards, mimicking
// the request/connect context the transport passes and that is already done
// by the time a later token refresh runs.
ctx, cancel := context.WithCancel(context.Background())
if err := handler.Authorize(ctx, req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
cancel()
tokenSource, err := handler.TokenSource(context.Background())
if err != nil {
t.Fatalf("Failed to get token source: %v", err)
}
// The access token is already expired, so this forces a refresh round-trip.
// It must not fail with "context canceled" from the cancelled Authorize ctx.
token, err := tokenSource.Token()
if err != nil {
t.Fatalf("token refresh after Authorize context cancellation failed: %v", err)
}
if token.AccessToken != "test_access_token_refreshed" {
t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken)
}
}
func TestAuthorize_ScopeAccumulation(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test_client_id": {
Secret: "test_client_secret",
RedirectURIs: []string{"http://localhost:12345/callback"},
},
},
},
TokenScopeFunc: func(requestedScope string) string {
// Simulate a server that never grants "write".
var granted []string
for _, s := range strings.Fields(requestedScope) {
if s != "write" {
granted = append(granted, s)
}
}
return strings.Join(granted, " ")
},
})
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()},
}))
var capturedAuthURLs []string
noRedirectClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
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) {
capturedAuthURLs = append(capturedAuthURLs, args.URL)
resp, err := noRedirectClient.Get(args.URL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
loc, err := resp.Location()
if err != nil {
return nil, err
}
return &AuthorizationResult{
Code: loc.Query().Get("code"),
State: loc.Query().Get("state"),
Iss: loc.Query().Get("iss"),
}, nil
},
})
if err != nil {
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
}
// First authorization: 401 with scope="read write".
// The token response will only grant "read" (TokenScopeFunc strips "write").
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp.Header.Set("WWW-Authenticate",
fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
if err := handler.Authorize(context.Background(), req, resp); err != nil {
t.Fatalf("First Authorize failed: %v", err)
}
// Verify first auth URL requested "read" and "write".
firstURL, err := url.Parse(capturedAuthURLs[0])
if err != nil {
t.Fatalf("Failed to parse first auth URL: %v", err)
}
firstScopes := strings.Fields(firstURL.Query().Get("scope"))
if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff)
}
// Verify only "read" was granted (the token omitted "write").
issuer := authServer.URL()
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
// Second authorization: 403 insufficient_scope with scope="admin".
// Accumulated scopes should be "read" (previously granted) + "admin" (new).
req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil)
resp2 := &http.Response{
StatusCode: http.StatusForbidden,
Header: make(http.Header),
Body: http.NoBody,
}
resp2.Header.Set("WWW-Authenticate",
fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
if err := handler.Authorize(context.Background(), req2, resp2); err != nil {
t.Fatalf("Second Authorize failed: %v", err)
}
// Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
// but NOT "write" (requested but never granted).
secondURL, err := url.Parse(capturedAuthURLs[1])
if err != nil {
t.Fatalf("Failed to parse second auth URL: %v", err)
}
secondScopes := strings.Fields(secondURL.Query().Get("scope"))
if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff)
}
}
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
resp := &http.Response{
@ -704,8 +479,6 @@ func TestHandleRegistration(t *testing.T) {
asm *oauthex.AuthServerMeta
want *resolvedClientConfig
wantError bool
issuerMatch bool
issuerSuffix string
}{
{
name: "ClientIDMetadataDocument",
@ -744,79 +517,6 @@ func TestHandleRegistration(t *testing.T) {
authStyle: oauth2.AuthStyleInParams,
},
},
{
name: "Preregistered_IssuerMatch",
serverConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"pre_client_id": {
Secret: "pre_client_secret",
},
},
},
handlerConfig: &AuthorizationCodeHandlerConfig{
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "pre_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "pre_client_secret",
},
Issuer: "", // set dynamically in the test
},
},
want: &resolvedClientConfig{
registrationType: registrationTypePreregistered,
clientID: "pre_client_id",
clientSecret: "pre_client_secret",
authStyle: oauth2.AuthStyleInParams,
},
issuerMatch: true,
},
{
name: "Preregistered_IssuerMismatch",
serverConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"pre_client_id": {
Secret: "pre_client_secret",
},
},
},
handlerConfig: &AuthorizationCodeHandlerConfig{
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "pre_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "pre_client_secret",
},
Issuer: "https://other-issuer.example.com",
},
},
wantError: true,
},
{
name: "Preregistered_IssuerMatchTrailingSlash",
serverConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"pre_client_id": {
Secret: "pre_client_secret",
},
},
},
handlerConfig: &AuthorizationCodeHandlerConfig{
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "pre_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "pre_client_secret",
},
Issuer: "", // set dynamically in the test (with trailing slash)
},
},
want: &resolvedClientConfig{
registrationType: registrationTypePreregistered,
clientID: "pre_client_id",
clientSecret: "pre_client_secret",
authStyle: oauth2.AuthStyleInParams,
},
issuerMatch: true,
issuerSuffix: "/",
},
{
name: "NoneSupported",
handlerConfig: &AuthorizationCodeHandlerConfig{
@ -830,10 +530,6 @@ func TestHandleRegistration(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig})
s.Start(t)
// Set the Issuer dynamically if requested by the test case.
if tt.issuerMatch {
tt.handlerConfig.PreregisteredClient.Issuer = s.URL() + tt.issuerSuffix
}
tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
return nil, nil
}
@ -853,9 +549,6 @@ func TestHandleRegistration(t *testing.T) {
}
return
}
if tt.wantError {
t.Fatal("handleRegistration() expected error, got nil")
}
if got.registrationType != tt.want.registrationType {
t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType)
}
@ -915,196 +608,6 @@ func TestDynamicRegistration(t *testing.T) {
}
}
func TestValidateIssuerResponse(t *testing.T) {
const expectedIssuer = "https://auth.example.com"
tests := []struct {
name string
iss string
issSupported bool
acceptUnadvertisedIss bool
wantErr bool
wantErrContains string
}{
{
name: "ValidIss",
iss: expectedIssuer,
issSupported: true,
},
{
name: "WrongIss",
iss: "https://attacker.example.com",
issSupported: true,
wantErr: true,
wantErrContains: "does not match expected issuer",
},
{
name: "MissingIssWhenRequired",
iss: "",
issSupported: true,
wantErr: true,
wantErrContains: "RFC 9207",
},
{
name: "MissingIssWhenNotRequired",
iss: "",
issSupported: false,
},
{
// Default / zero-value policy: reject a matching unadvertised iss
// (released v1.7.0 behavior).
name: "UnadvertisedIssCorrectRejectedByDefault",
iss: expectedIssuer,
issSupported: false,
wantErr: true,
wantErrContains: "does not advertise",
},
{
name: "UnadvertisedIssCorrectAcceptedWhenOptedIn",
iss: expectedIssuer,
issSupported: false,
acceptUnadvertisedIss: true,
},
{
name: "UnadvertisedIssWrong",
iss: "https://attacker.example.com",
issSupported: false,
wantErr: true,
wantErrContains: "does not match expected issuer",
},
{
name: "UnadvertisedIssWrongStillRejectedWhenOptedIn",
iss: "https://attacker.example.com",
issSupported: false,
acceptUnadvertisedIss: true,
wantErr: true,
wantErrContains: "does not match expected issuer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
if tt.wantErr {
if err == nil {
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
}
} else if err != nil {
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
}
})
}
}
// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes
// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to
// validateIssuerResponse. The fake server returns iss without advertising
// authorization_response_iss_parameter_supported.
func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) {
tests := []struct {
name string
acceptUnadvertisedIss bool
wantErrContains string
}{
{
name: "ZeroValueRejects",
wantErrContains: "does not advertise",
},
{
name: "OptInAccepts",
acceptUnadvertisedIss: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
UnadvertiseIssParameter: 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",
AcceptUnadvertisedIss: tt.acceptUnadvertisedIss,
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(req *http.Request, via []*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
},
})
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",
)
err = handler.Authorize(context.Background(), req, resp)
if tt.wantErrContains != "" {
if err == nil {
t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains)
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
}
return
}
if err != nil {
t.Fatalf("Authorize() unexpected error = %v", err)
}
})
}
}
func TestInferApplicationType(t *testing.T) {
tests := []struct {
name string
@ -1235,231 +738,6 @@ func TestApplicationTypeInference(t *testing.T) {
}
}
func TestAuthorize_OfflineAccessScope(t *testing.T) {
tests := []struct {
name string
requestRefreshToken bool
asScopesSupported []string
challengeScopes string
wantOfflineAccess bool
}{
{
name: "AddedWhenASSupportsAndClientRequests",
requestRefreshToken: true,
asScopesSupported: []string{"openid", "offline_access"},
wantOfflineAccess: true,
},
{
name: "NotAddedWhenClientDoesNotRequest",
requestRefreshToken: false,
asScopesSupported: []string{"openid", "offline_access"},
wantOfflineAccess: false,
},
{
name: "NotAddedWhenASDoesNotSupport",
requestRefreshToken: true,
asScopesSupported: []string{"openid"},
wantOfflineAccess: false,
},
{
name: "NotDuplicatedWhenAlreadyInScopes",
requestRefreshToken: true,
asScopesSupported: []string{"openid", "offline_access"},
challengeScopes: "read offline_access",
wantOfflineAccess: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
ScopesSupported: tt.asScopesSupported,
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()},
}))
var capturedAuthURL string
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost:12345/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "test_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "test_client_secret",
},
},
RequestRefreshToken: tt.requestRefreshToken,
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
capturedAuthURL = args.URL
return nil, fmt.Errorf("stop after capturing URL")
},
})
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,
}
wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource"
if tt.challengeScopes != "" {
wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes)
}
resp.Header.Set("WWW-Authenticate", wwwAuth)
// Authorize will fail at the fetcher, but we only care about the URL.
handler.Authorize(context.Background(), req, resp)
if capturedAuthURL == "" {
t.Fatal("AuthorizationCodeFetcher was not called")
}
u, err := url.Parse(capturedAuthURL)
if err != nil {
t.Fatalf("failed to parse captured auth URL: %v", err)
}
scopes := strings.Fields(u.Query().Get("scope"))
hasOfflineAccess := slices.Contains(scopes, "offline_access")
if hasOfflineAccess != tt.wantOfflineAccess {
t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes)
}
// When offline_access was already present in challenge scopes,
// verify it appears exactly once.
if tt.wantOfflineAccess {
count := 0
for _, s := range scopes {
if s == "offline_access" {
count++
}
}
if count != 1 {
t.Errorf("offline_access appears %d times in scopes, want 1", count)
}
}
})
}
}
func TestAuthorize_ScopeFilter(t *testing.T) {
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
// the handler treats as the discovered scopes passed to ScopeFilter.
const advertised = "gmail.metadata gmail.readonly gmail.compose"
tests := []struct {
name string
filter func(discovered []string) []string
want []string // requested scopes, order-independent
}{
{
name: "nil filter leaves scopes unchanged",
filter: nil,
want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"},
},
{
name: "filter drops a scope",
filter: func(d []string) []string {
return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" })
},
want: []string{"gmail.readonly", "gmail.compose"},
},
{
name: "filter replaces the set entirely",
filter: func([]string) []string { return []string{"gmail.readonly"} },
want: []string{"gmail.readonly"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
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()},
}))
var capturedAuthURL string
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost:12345/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "test_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
},
ScopeFilter: tt.filter,
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
capturedAuthURL = args.URL
return nil, fmt.Errorf("stop after capturing URL")
},
})
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", fmt.Sprintf(
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
resourceServer.URL, advertised))
handler.Authorize(context.Background(), req, resp)
if capturedAuthURL == "" {
t.Fatal("AuthorizationCodeFetcher was not called")
}
u, err := url.Parse(capturedAuthURL)
if err != nil {
t.Fatalf("failed to parse captured auth URL: %v", err)
}
// Compare as a set: UnionScopes (applied downstream) returns map keys,
// so the order of the requested scope parameter is not deterministic.
got := strings.Fields(u.Query().Get("scope"))
want := slices.Clone(tt.want)
slices.Sort(got)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("requested scopes = %v, want %v (any order)", got, want)
}
})
}
}
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
// Values that are relevant to the test should be set explicitly.
func validConfig() *AuthorizationCodeHandlerConfig {
@ -1471,92 +749,3 @@ func validConfig() *AuthorizationCodeHandlerConfig {
},
}
}
func TestNewTokenSource(t *testing.T) {
// mock the /token endpoint to successfully return an access token on code exchange
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer mockTS.Close()
// configure the handler and set NewTokenSource
var called bool
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
RedirectURL: "http://localhost/callback",
PreregisteredClient: &oauthex.ClientCredentials{
ClientID: "test_client",
},
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
u, _ := url.Parse(args.URL)
return &AuthorizationResult{
Code: "test_code",
State: u.Query().Get("state"),
}, nil
},
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
called = true
return oauth2.StaticTokenSource(token), nil
},
Client: mockTS.Client(),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Simulate a 401 response from a resource server.
// The WWW-Authenticate: Bearer header triggers the authorization logic.
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}
resp.Header.Set("WWW-Authenticate", "Bearer")
// Authorize and confirm NewTokenSource was called.
err = handler.Authorize(t.Context(), req, resp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Error("expected NewTokenSource to be called")
}
}
func TestInitialTokenSource(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, nil
},
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
})
if err != nil {
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatalf("failed to get token source: %v", err)
}
if ts == nil {
t.Fatal("expected token source to be non-nil")
}
tok, err := ts.Token()
if err != nil {
t.Fatalf("failed to get Token: %v", err)
}
if tok.AccessToken != "set_token" {
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
}
}

View file

@ -14,7 +14,6 @@ import (
"strings"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
@ -48,9 +47,6 @@ type ClientCredentialsHandlerConfig struct {
type ClientCredentialsHandler struct {
config *ClientCredentialsHandlerConfig
tokenSource oauth2.TokenSource
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
grantedScopes map[string][]string
}
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
@ -71,10 +67,7 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien
if config.Credentials.ClientSecretAuth == nil {
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
}
return &ClientCredentialsHandler{
config: config,
grantedScopes: make(map[string][]string),
}, nil
return &ClientCredentialsHandler{config: config}, nil
}
// TokenSource returns the token source for outgoing requests.
@ -128,51 +121,30 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
}
}
creds := h.config.Credentials
if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) {
return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer)
// Determine scopes: use PRM's scopes_supported if available.
scopes := scopesFromChallenges(wwwChallenges)
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
scopes = prm.ScopesSupported
}
// Determine requestedScopes: use PRM's scopes_supported if available.
requestedScopes := scopesFromChallenges(wwwChallenges)
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
requestedScopes = prm.ScopesSupported
}
// Accumulate scopes: union previously granted scopes with the newly
// challenged scopes so that step-up authorization does not lose
// permissions granted in earlier rounds (SEP-2350).
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
// Step 3: Exchange client credentials for an access token.
creds := h.config.Credentials
cfg := &clientcredentials.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecretAuth.ClientSecret,
TokenURL: asm.TokenEndpoint,
Scopes: requestedScopes,
Scopes: scopes,
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
}
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
h.tokenSource = cfg.TokenSource(ctxWithClient)
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
}
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
if h.tokenSource == nil {
return nil
}
tok, err := h.tokenSource.Token()
if err != nil {
// Eagerly fetch a token to surface errors immediately.
if _, err := h.tokenSource.Token(); err != nil {
h.tokenSource = nil
return fmt.Errorf("client credentials token request failed: %w", err)
}
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
h.grantedScopes[issuer] = requestedScopes
} else {
h.grantedScopes[issuer] = tokenScopes
}
return nil
}

View file

@ -10,8 +10,6 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
@ -221,51 +219,6 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
}
})
t.Run("issuer mismatch", func(t *testing.T) {
config := validClientCredentialsConfig()
config.Credentials.Issuer = "https://other-issuer.example.com"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
err = handler.Authorize(t.Context(), req, resp)
if err == nil {
t.Fatal("expected Authorize to fail with issuer mismatch")
}
if !strings.Contains(err.Error(), "does not match") {
t.Errorf("error %q does not mention issuer mismatch", err.Error())
}
})
t.Run("issuer match ignoring trailing slash", func(t *testing.T) {
config := validClientCredentialsConfig()
// authServer.URL() has no trailing slash; configure with one to
// verify the comparison tolerates the difference (per RFC 8414 §3.3
// normalization applied in oauthex.IssuersEqual).
config.Credentials.Issuer = authServer.URL() + "/"
handler, err := NewClientCredentialsHandler(config)
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize() unexpected error = %v", err)
}
})
t.Run("PRM via resource_metadata in challenge", func(t *testing.T) {
prmMux := http.NewServeMux()
prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
@ -302,86 +255,6 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
})
}
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {Secret: "test-secret"},
},
},
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
Enabled: true,
},
TokenScopeFunc: func(requestedScope string) string {
// Simulate a server that never grants "write".
var granted []string
for _, s := range strings.Fields(requestedScope) {
if s != "write" {
granted = append(granted, s)
}
}
return strings.Join(granted, " ")
},
})
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", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// First authorization: 401 with scope="read write".
// The token response will only grant "read" (TokenScopeFunc strips "write").
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("First Authorize failed: %v", err)
}
// Verify only "read" was granted (the token omitted "write").
issuer := authServer.URL()
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
// Second authorization: 401 with scope="admin" (simulating step-up).
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
// but NOT "write" (requested but never granted).
resp2 := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: make(http.Header),
Body: http.NoBody,
}
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
req2 := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
t.Fatalf("Second Authorize failed: %v", err)
}
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
}
}
func TestSelectTokenAuthMethod(t *testing.T) {
tests := []struct {
name string

View file

@ -44,12 +44,7 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
return nil
}
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
// issuer identifier before the well-known suffix is inserted, so an issuer
// such as "https://auth.example.com/" has no path component at all.
issuerPath := strings.Trim(baseURL.Path, "/")
if issuerPath == "" {
if baseURL.Path == "" {
// "OAuth 2.0 Authorization Server Metadata".
baseURL.Path = "/.well-known/oauth-authorization-server"
urls = append(urls, baseURL.String())
@ -59,14 +54,15 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
return urls
}
originalPath := baseURL.Path
// "OAuth 2.0 Authorization Server Metadata with path insertion".
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path insertion".
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
urls = append(urls, baseURL.String())
// "OpenID Connect Discovery 1.0 with path appending".
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
urls = append(urls, baseURL.String())
return urls

View file

@ -6,81 +6,11 @@ package auth
import (
"net/http"
"slices"
"testing"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
)
func TestAuthorizationServerMetadataURLs(t *testing.T) {
tests := []struct {
issuerURL string
want []string
}{
{
issuerURL: "https://auth.example.com",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server",
"https://auth.example.com/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server",
"https://auth.example.com/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/tenant1",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
"https://auth.example.com/.well-known/openid-configuration/tenant1",
"https://auth.example.com/tenant1/.well-known/openid-configuration",
},
},
{
issuerURL: "https://auth.example.com/tenant1/",
want: []string{
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
"https://auth.example.com/.well-known/openid-configuration/tenant1",
"https://auth.example.com/tenant1/.well-known/openid-configuration",
},
},
}
for _, tt := range tests {
t.Run(tt.issuerURL, func(t *testing.T) {
if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) {
t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want)
}
})
}
}
// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier
// written with a terminating slash still finds metadata served at the root
// well-known path.
func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) {
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
ServeOAuthInsertedEndpoint: true,
},
})
s.Start(t)
got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient)
if err != nil {
t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err)
}
if got == nil {
t.Fatal("GetAuthServerMetadata() got nil, want metadata")
}
if got.Issuer != s.URL() {
t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL())
}
}
func TestGetAuthServerMetadata(t *testing.T) {
tests := []struct {
name string

View file

@ -1,14 +1,2 @@
client:
# OAuth mechanisms not implemented by the Go MCP SDK.
- auth/client-credentials-basic
- 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
- auth/dpop-nonce
# SEP-1933 (Workload Identity Federation JWT-bearer) client-side support
# not yet implemented by the Go MCP SDK.
- auth/wif-jwt-bearer
server: [] # All tests pass!
client: [] # All tests pass!

Some files were not shown because too many files have changed in this diff Show more