Compare commits

..
147 changed files with 3504 additions and 24838 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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "^1.26"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "22"
- name: Run server conformance tests

View file

@ -1,4 +1,4 @@
name: Publish Docs
name: Publish Docs to GitHub Pages
on:
release:
@ -13,20 +13,12 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- run: pip install mkdocs-material
- run: mkdocs gh-deploy --force
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
publish_branch: gh-pages
enable_jekyll: true

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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "^1.26"
- name: Check formatting
@ -31,7 +31,7 @@ jobs:
- name: Run Go vet
run: go vet ./...
- name: Run staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1
uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1
with:
install-go: false
version: "v0.6.1"
@ -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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: "1.26"
- name: Test with -race

View file

@ -1,4 +1,3 @@
<!-- Autogenerated by weave; DO NOT EDIT -->
# Contributing to the Go MCP SDK
Thank you for your interest in contributing! The Go SDK needs active
@ -7,31 +6,11 @@ new and emerging use-cases. We welcome all forms of contribution, from filing
and reviewing issues, to contributing fixes, to proposing and implementing new
features.
As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for
As described in the [design document](./design/design.md), it is important for
the MCP SDK to remain idiomatic, future-proof, and extensible. The process
described here is intended to ensure that the SDK evolves safely and
transparently, while adhering to these goals.
1. [Development setup](#development-setup)
1. [Conformance tests](#conformance-tests)
1. [Filing issues](#filing-issues)
1. [Bugs](#bugs)
1. [Proposals](#proposals)
1. [Design discussion](#design-discussion)
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)
1. [Working Group meetings](#working-group-meetings)
1. [Discord](#discord)
1. [Antitrust considerations](#antitrust-considerations)
## Development setup
This module can be built and tested using the standard Go toolchain. Run `go
@ -191,57 +170,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,10 +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"
)
@ -37,10 +34,6 @@ type ClientIDMetadataDocumentConfig struct {
type DynamicClientRegistrationConfig struct {
// Metadata to be used in dynamic client registration request as per
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
//
// If Metadata.ApplicationType is empty, it will be inferred from
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
// and an error will be returned if they conflict.
Metadata *oauthex.ClientRegistrationMetadata
}
@ -51,12 +44,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 +85,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 +97,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 +104,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
}
@ -220,12 +150,6 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
}
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
if dCfg.Metadata.ApplicationType == "" {
dCfg.Metadata.ApplicationType = applicationType
} else if dCfg.Metadata.ApplicationType != applicationType {
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
}
}
if config.RedirectURL == "" {
// If the RedirectURL was supposed to be set by the dynamic client registration,
@ -235,11 +159,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 {
@ -250,36 +170,6 @@ func isNonRootHTTPSURL(u string) bool {
return pu.Scheme == "https" && pu.Path != ""
}
// inferApplicationType returns an application type based on the redirect URIs.
func inferApplicationType(redirectURIs []string) string {
hasNative := false
hasWeb := false
for _, uri := range redirectURIs {
u, err := url.Parse(uri)
if err != nil {
return ""
}
switch u.Scheme {
case "http", "https":
if util.IsLoopback(u.Hostname()) {
hasNative = true
} else {
hasWeb = true
}
default:
hasNative = true
}
}
if hasNative && hasWeb {
return ""
}
if hasNative {
return "native"
}
return "web"
}
// Authorize performs the authorization flow.
// It is designed to perform the whole Authorization Code Grant flow.
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
@ -327,33 +217,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 +232,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 +240,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 +415,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 +477,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 +489,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,551 +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
redirectURIs []string
want string
}{
{
name: "localhost",
redirectURIs: []string{"http://localhost:8085/callback"},
want: "native",
},
{
name: "127.0.0.1",
redirectURIs: []string{"http://127.0.0.1:8085/callback"},
want: "native",
},
{
name: "IPv6 loopback",
redirectURIs: []string{"http://[::1]:8085/callback"},
want: "native",
},
{
name: "custom scheme",
redirectURIs: []string{"myapp://callback"},
want: "native",
},
{
name: "HTTPS remote",
redirectURIs: []string{"https://myapp.example.com/callback"},
want: "web",
},
{
name: "mixed native and web",
redirectURIs: []string{"https://myapp.example.com/callback", "http://localhost:8085/callback"},
want: "",
},
{
name: "multiple remote",
redirectURIs: []string{"https://app1.example.com/cb", "https://app2.example.com/cb"},
want: "web",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := inferApplicationType(tt.redirectURIs)
if got != tt.want {
t.Errorf("inferApplicationType() = %q, want %q", got, tt.want)
}
})
}
}
func TestApplicationTypeInference(t *testing.T) {
fetcher := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
return nil, nil
}
tests := []struct {
name string
redirectURIs []string
initialAppType string
wantAppType string
wantErr bool
}{
{
name: "inferred as native for localhost",
redirectURIs: []string{"http://localhost:8085/callback"},
wantAppType: "native",
},
{
name: "inferred as web for remote",
redirectURIs: []string{"https://example.com/callback"},
wantAppType: "web",
},
{
name: "mixed native and web URIs sets empty application type",
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
wantAppType: "",
},
{
name: "explicit value matching inference is preserved",
redirectURIs: []string{"http://localhost:8085/callback"},
initialAppType: "native",
wantAppType: "native",
},
{
name: "explicit value conflicts with inference returns error",
redirectURIs: []string{"http://localhost:8085/callback"},
initialAppType: "web",
wantErr: true,
},
{
name: "explicit value when inference is ambiguous returns error",
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
initialAppType: "web",
wantErr: true,
},
{
name: "invalid URI returns empty application type",
redirectURIs: []string{"http://%/"},
wantAppType: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &AuthorizationCodeHandlerConfig{
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
Metadata: &oauthex.ClientRegistrationMetadata{
RedirectURIs: tt.redirectURIs,
ApplicationType: tt.initialAppType,
},
},
AuthorizationCodeFetcher: fetcher,
}
_, err := NewAuthorizationCodeHandler(cfg)
if (err != nil) != tt.wantErr {
t.Fatalf("NewAuthorizationCodeHandler() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
got := cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType
if got != tt.wantAppType {
t.Errorf("ApplicationType = %q, want %q", got, tt.wantAppType)
}
})
}
}
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 +619,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

@ -1,279 +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 extauth
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"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"
)
// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler].
type ClientCredentialsHandlerConfig struct {
// Credentials contains the pre-registered client ID and secret.
// REQUIRED. Both ClientID and ClientSecretAuth must be set, since the
// client credentials grant requires a confidential client.
Credentials *oauthex.ClientCredentials
// HTTPClient is an optional HTTP client for customization.
// If nil, http.DefaultClient is used.
// OPTIONAL.
HTTPClient *http.Client
}
// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that
// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to
// obtain access tokens.
//
// This handler is intended for service-to-service authentication where the
// client has pre-registered credentials (client ID and secret) and does not
// require user interaction. It bypasses both dynamic client registration and
// the authorization code flow.
//
// The token endpoint and scopes are discovered automatically via Protected
// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414),
// following the ext-auth specification SEP-1046.
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.
var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil)
// NewClientCredentialsHandler creates a new ClientCredentialsHandler.
// It validates the configuration and returns an error if invalid.
func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) {
if config == nil {
return nil, fmt.Errorf("config must be provided")
}
if config.Credentials == nil {
return nil, fmt.Errorf("credentials are required")
}
if err := config.Credentials.Validate(); err != nil {
return nil, fmt.Errorf("invalid credentials: %w", err)
}
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
}
// TokenSource returns the token source for outgoing requests.
// Returns nil if authorization has not been performed yet.
func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
return h.tokenSource, nil
}
// Authorize performs the Client Credentials grant to obtain an access token.
// It is called when a request fails with 401 or 403.
//
// The flow follows the ext-auth specification SEP-1046:
// 1. Discover Protected Resource Metadata from the request URL
// 2. Discover Authorization Server Metadata from PRM
// 3. Exchange client credentials for an access token at the token endpoint
func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
defer resp.Body.Close()
defer io.Copy(io.Discard, resp.Body)
httpClient := h.config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
// Step 1: Discover Protected Resource Metadata.
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
if err != nil {
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
}
prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient)
if err != nil {
return err
}
if len(prm.AuthorizationServers) == 0 {
return fmt.Errorf("protected resource metadata has no authorization servers specified")
}
// Step 2: Discover Authorization Server Metadata.
asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient)
if err != nil {
return fmt.Errorf("failed to get authorization server metadata: %w", err)
}
if asm == nil {
// Fallback to 2025-03-26 spec: predefined endpoints.
authServerURL := prm.AuthorizationServers[0]
asm = &oauthex.AuthServerMeta{
Issuer: authServerURL,
TokenEndpoint: authServerURL + "/token",
}
}
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 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.
cfg := &clientcredentials.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecretAuth.ClientSecret,
TokenURL: asm.TokenEndpoint,
Scopes: requestedScopes,
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 {
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
}
// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728)
// from the request URL. This mirrors the logic in AuthorizationCodeHandler.
func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) {
// Use MCP server URL as the resource URI per
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient)
if err != nil {
continue
}
if prm == nil {
continue
}
if len(prm.AuthorizationServers) == 0 {
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
}
return prm, nil
}
// Fallback to 2025-03-26 spec: MCP server root is the Authorization Server.
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
u, err := url.Parse(mcpServerURL)
if err != nil {
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
}
u.Path = ""
return &oauthex.ProtectedResourceMetadata{
AuthorizationServers: []string{u.String()},
Resource: mcpServerURL,
}, nil
}
type prmURL struct {
url string
resource string
}
// protectedResourceMetadataURLs returns URLs to try for PRM discovery.
// This mirrors the logic in AuthorizationCodeHandler.
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
var urls []prmURL
if metadataURL != "" {
urls = append(urls, prmURL{url: metadataURL, resource: resourceURL})
}
ru, err := url.Parse(resourceURL)
if err != nil {
return urls
}
mu := *ru
// At the path of the server's MCP endpoint.
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
urls = append(urls, prmURL{url: mu.String(), resource: resourceURL})
// At the root.
mu.Path = "/.well-known/oauth-protected-resource"
ru.Path = ""
urls = append(urls, prmURL{url: mu.String(), resource: ru.String()})
return urls
}
// resourceMetadataURLFromChallenges returns a resource metadata URL from
// WWW-Authenticate challenges, or the empty string if there is none.
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
for _, c := range cs {
if u := c.Params["resource_metadata"]; u != "" {
return u
}
}
return ""
}
// scopesFromChallenges returns scopes from WWW-Authenticate challenges.
// It only looks at challenges with the "Bearer" scheme.
func scopesFromChallenges(cs []oauthex.Challenge) []string {
for _, c := range cs {
if c.Scheme == "bearer" && c.Params["scope"] != "" {
return strings.Fields(c.Params["scope"])
}
}
return nil
}
// selectTokenAuthMethod selects the preferred token endpoint auth method based on
// the authorization server's supported methods. Prefers client_secret_post over
// client_secret_basic per the OAuth 2.1 draft.
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
prefOrder := []string{
"client_secret_post",
"client_secret_basic",
}
for _, method := range prefOrder {
if slices.Contains(supported, method) {
switch method {
case "client_secret_post":
return oauth2.AuthStyleInParams
case "client_secret_basic":
return oauth2.AuthStyleInHeader
}
}
}
return oauth2.AuthStyleAutoDetect
}

View file

@ -1,420 +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 extauth
import (
"net/http"
"net/http/httptest"
"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"
"golang.org/x/oauth2"
)
func validClientCredentialsConfig() *ClientCredentialsHandlerConfig {
return &ClientCredentialsHandlerConfig{
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"},
},
}
}
func TestNewClientCredentialsHandler_Validation(t *testing.T) {
tests := []struct {
name string
config *ClientCredentialsHandlerConfig
wantError string
}{
{
name: "nil config",
config: nil,
wantError: "config must be provided",
},
{
name: "nil credentials",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials = nil
return c
}(),
wantError: "credentials are required",
},
{
name: "missing client ID",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientID = ""
return c
}(),
wantError: "ClientID is required",
},
{
name: "missing client secret auth",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth = nil
return c
}(),
wantError: "clientSecretAuth is required",
},
{
name: "empty client secret",
config: func() *ClientCredentialsHandlerConfig {
c := validClientCredentialsConfig()
c.Credentials.ClientSecretAuth.ClientSecret = ""
return c
}(),
wantError: "ClientSecret is required",
},
{
name: "valid config",
config: validClientCredentialsConfig(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewClientCredentialsHandler(tc.config)
if tc.wantError != "" {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantError)
} else if !strings.Contains(err.Error(), tc.wantError) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantError)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestClientCredentialsHandler_Authorize(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,
},
})
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()},
ScopesSupported: []string{"mcp:read", "mcp:write"},
}))
t.Run("successful authorization", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
// TokenSource should be nil before Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Fatal("expected nil TokenSource before Authorize")
}
// Simulate a 401 response from the MCP server.
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 failed: %v", err)
}
// TokenSource should now return a valid token.
ts, err = handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource after Authorize")
}
tok, err := ts.Token()
if err != nil {
t.Fatalf("Token() failed: %v", err)
}
if tok.AccessToken != "test_access_token" {
t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token")
}
})
t.Run("bad credentials", func(t *testing.T) {
config := validClientCredentialsConfig()
config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret"
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.Error("expected Authorize to fail with bad credentials")
}
// TokenSource should still be nil after failed Authorize.
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts != nil {
t.Error("expected nil TokenSource after failed Authorize")
}
})
t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) {
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer scope="read write"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
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{
Resource: resourceURL,
AuthorizationServers: []string{authServer.URL()},
}))
prmServer := httptest.NewServer(prmMux)
t.Cleanup(prmServer.Close)
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
if err != nil {
t.Fatal(err)
}
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
Header: http.Header{
"WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`},
},
Body: http.NoBody,
}
req := httptest.NewRequest("GET", resourceURL, nil)
if err := handler.Authorize(t.Context(), req, resp); err != nil {
t.Fatalf("Authorize with resource_metadata challenge failed: %v", err)
}
ts, err := handler.TokenSource(t.Context())
if err != nil {
t.Fatal(err)
}
if ts == nil {
t.Fatal("expected non-nil TokenSource")
}
})
}
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
supported []string
want oauth2.AuthStyle
}{
{
name: "prefers client_secret_post",
supported: []string{"client_secret_basic", "client_secret_post"},
want: oauth2.AuthStyleInParams,
},
{
name: "falls back to client_secret_basic",
supported: []string{"client_secret_basic"},
want: oauth2.AuthStyleInHeader,
},
{
name: "auto detect when none supported",
supported: []string{"private_key_jwt"},
want: oauth2.AuthStyleAutoDetect,
},
{
name: "auto detect when empty",
supported: nil,
want: oauth2.AuthStyleAutoDetect,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := selectTokenAuthMethod(tc.supported)
if got != tc.want {
t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want)
}
})
}
}

View file

@ -1,37 +1,43 @@
// 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.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package extauth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
func validEnterpriseHandlerConfig() *EnterpriseHandlerConfig {
return &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "idp_client_id"},
// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler.
func TestNewEnterpriseHandler_Validation(t *testing.T) {
validConfig := &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "mcp_client_id"},
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "mcp_client_id",
},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
token := &oauth2.Token{AccessToken: "mock", TokenType: "Bearer"}
token := &oauth2.Token{
AccessToken: "mock_access_token",
TokenType: "Bearer",
}
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
},
}
}
func TestNewEnterpriseHandler_Validation(t *testing.T) {
tests := []struct {
name string
config *EnterpriseHandlerConfig
@ -44,101 +50,151 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
},
{
name: "missing IdPIssuerURL",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IdPIssuerURL = ""
return c
}(),
config: &EnterpriseHandlerConfig{
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "IdPIssuerURL is required",
},
{
name: "nil IdPCredentials",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IdPCredentials = nil
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: nil,
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "IdPCredentials is required",
},
{
name: "invalid IdPCredentials - empty ClientID",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IdPCredentials = &oauthex.ClientCredentials{ClientID: ""}
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "", // Invalid - empty
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "invalid IdPCredentials",
},
{
name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IdPCredentials = &oauthex.ClientCredentials{
ClientID: "idp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: ""},
}
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set
},
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "invalid IdPCredentials",
},
{
name: "missing MCPAuthServerURL",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.MCPAuthServerURL = ""
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "MCPAuthServerURL is required",
},
{
name: "missing MCPResourceURI",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.MCPResourceURI = ""
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "MCPResourceURI is required",
},
{
name: "nil MCPCredentials",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.MCPCredentials = nil
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: nil,
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "MCPCredentials is required",
},
{
name: "invalid MCPCredentials - empty ClientID",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.MCPCredentials = &oauthex.ClientCredentials{ClientID: ""}
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "", // Invalid - empty
},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
},
wantError: "invalid MCPCredentials",
},
{
name: "missing IDTokenFetcher",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IDTokenFetcher = nil
return c
}(),
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
IDTokenFetcher: nil,
},
wantError: "IDTokenFetcher is required",
},
{
name: "valid public clients",
config: validEnterpriseHandlerConfig(),
name: "valid config - public clients (no ClientSecretAuth)",
config: validConfig,
wantError: "",
},
{
name: "valid confidential clients",
config: func() *EnterpriseHandlerConfig {
c := validEnterpriseHandlerConfig()
c.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
c.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
return c
}(),
name: "valid config - confidential clients (with ClientSecretAuth)",
config: &EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "idp_secret",
},
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "mcp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "mcp_secret",
},
},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
token := &oauth2.Token{
AccessToken: "mock_access_token",
TokenType: "Bearer",
}
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
},
},
wantError: "",
},
}
@ -165,47 +221,45 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
}
}
// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow.
func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
const idJAGToken = "id-jag-token-from-idp"
// Set up IdP (Identity Provider) fake server with token exchange support
idpServer := setupIdPServer(t)
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"idp_client_id": {Secret: "idp_secret"},
},
TokenExchangeConfig: &oauthtest.TokenExchangeConfig{
IDJAGToken: idJAGToken,
},
})
idpServer.Start(t)
// Set up MCP authorization server with JWT bearer grant support
mcpAuthServer := setupMCPAuthServer(t)
mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
RegistrationConfig: &oauthtest.RegistrationConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"mcp_client_id": {Secret: "mcp_secret"},
// Create enterprise handler
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
IdPIssuerURL: idpServer.URL,
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "idp_secret",
},
},
JWTBearerConfig: &oauthtest.JWTBearerConfig{
ValidAssertions: []string{idJAGToken},
MCPAuthServerURL: mcpAuthServer.URL,
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "mcp_client_id",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "mcp_secret",
},
},
MCPScopes: []string{"read", "write"},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
token := &oauth2.Token{
AccessToken: "mock_access_token",
TokenType: "Bearer",
}
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
},
})
mcpAuthServer.Start(t)
config := validEnterpriseHandlerConfig()
config.IdPIssuerURL = idpServer.URL()
config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
config.MCPAuthServerURL = mcpAuthServer.URL()
config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
config.MCPScopes = []string{"read", "write"}
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
token := &oauth2.Token{AccessToken: "mock_access_token", TokenType: "Bearer"}
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
}
handler, err := NewEnterpriseHandler(config)
if err != nil {
t.Fatalf("NewEnterpriseHandler failed: %v", err)
}
// Simulate a 401 response from MCP server
req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil)
resp := &http.Response{
StatusCode: http.StatusUnauthorized,
@ -214,10 +268,12 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
Request: req,
}
// Perform authorization
if err := handler.Authorize(context.Background(), req, resp); err != nil {
t.Fatalf("Authorize failed: %v", err)
}
// Verify token source is set
tokenSource, err := handler.TokenSource(context.Background())
if err != nil {
t.Fatalf("TokenSource failed: %v", err)
@ -226,22 +282,164 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
t.Fatal("expected token source to be set after authorization")
}
// Verify we can get a token
token, err := tokenSource.Token()
if err != nil {
t.Fatalf("Token() failed: %v", err)
}
if token.AccessToken != "test_access_token" {
t.Errorf("AccessToken = %q, want %q", token.AccessToken, "test_access_token")
if token.AccessToken != "mcp_access_token_from_jwt_bearer" {
t.Errorf("unexpected access token: got %q, want %q",
token.AccessToken, "mcp_access_token_from_jwt_bearer")
}
}
func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
config := validEnterpriseHandlerConfig()
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
return nil, fmt.Errorf("user cancelled login")
}
// setupIdPServer creates a fake IdP server that supports token exchange.
func setupIdPServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
handler, err := NewEnterpriseHandler(config)
var server *httptest.Server
// OAuth/OIDC metadata endpoint - uses closure to get server URL
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": server.URL,
"token_endpoint": server.URL + "/token",
"authorization_endpoint": server.URL + "/authorize",
"code_challenge_methods_supported": []string{"S256"},
})
})
// Token endpoint - supports token exchange (RFC 8693)
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "failed to parse form", http.StatusBadRequest)
return
}
grantType := r.Form.Get("grant_type")
if grantType != oauthex.GrantTypeTokenExchange {
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
return
}
// Verify client authentication
clientID := r.Form.Get("client_id")
clientSecret := r.Form.Get("client_secret")
if clientID != "idp_client_id" || clientSecret != "idp_secret" {
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
return
}
// Verify token exchange parameters
if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG {
http.Error(w, "invalid requested_token_type", http.StatusBadRequest)
return
}
if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken {
http.Error(w, "invalid subject_token_type", http.StatusBadRequest)
return
}
if r.Form.Get("subject_token") == "" {
http.Error(w, "missing subject_token", http.StatusBadRequest)
return
}
// Return ID-JAG (Identity Assertion JWT Authorization Grant)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "id-jag-token-from-idp",
"issued_token_type": oauthex.TokenTypeIDJAG,
"token_type": "N_A",
"expires_in": 300,
"scope": "read write",
})
})
server = httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant.
func setupMCPAuthServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var server *httptest.Server
// OAuth metadata endpoint - uses closure to get server URL
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": server.URL,
"token_endpoint": server.URL + "/token",
"code_challenge_methods_supported": []string{"S256"},
})
})
// Token endpoint - supports JWT bearer grant (RFC 7523)
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "failed to parse form", http.StatusBadRequest)
return
}
grantType := r.Form.Get("grant_type")
if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
return
}
// Verify client authentication
clientID := r.Form.Get("client_id")
clientSecret := r.Form.Get("client_secret")
if clientID != "mcp_client_id" || clientSecret != "mcp_secret" {
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
return
}
// Verify assertion (ID-JAG)
assertion := r.Form.Get("assertion")
if assertion != "id-jag-token-from-idp" {
http.Error(w, "invalid assertion", http.StatusBadRequest)
return
}
// Return access token
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "mcp_access_token_from_jwt_bearer",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write",
})
})
server = httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails.
func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "mcp_client_id",
},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
return nil, fmt.Errorf("user cancelled login")
},
})
if err != nil {
t.Fatalf("NewEnterpriseHandler failed: %v", err)
}
@ -259,12 +457,30 @@ func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
t.Fatal("expected error from Authorize, got nil")
}
if !strings.Contains(err.Error(), "failed to obtain ID token") {
t.Errorf("error = %v, want error containing %q", err, "failed to obtain ID token")
t.Errorf("expected error about ID token, got: %v", err)
}
}
// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization.
func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) {
handler, err := NewEnterpriseHandler(validEnterpriseHandlerConfig())
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
IdPIssuerURL: "https://idp.example.com",
IdPCredentials: &oauthex.ClientCredentials{
ClientID: "idp_client_id",
},
MCPAuthServerURL: "https://mcp-auth.example.com",
MCPResourceURI: "https://mcp.example.com",
MCPCredentials: &oauthex.ClientCredentials{
ClientID: "mcp_client_id",
},
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
token := &oauth2.Token{
AccessToken: "mock_access_token",
TokenType: "Bearer",
}
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
},
})
if err != nil {
t.Fatalf("NewEnterpriseHandler failed: %v", err)
}

View file

@ -1,152 +1,468 @@
// 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.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package extauth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/auth"
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
"github.com/modelcontextprotocol/go-sdk/oauthex"
"golang.org/x/oauth2"
)
const testRedirectURL = "http://localhost:18927/callback"
// TestInitiateOIDCLogin tests the OIDC authorization request generation.
func TestInitiateOIDCLogin(t *testing.T) {
// Create mock IdP server
idpServer := createMockOIDCServer(t)
defer idpServer.Close()
config := &OIDCLoginConfig{
IssuerURL: idpServer.URL,
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
},
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email"},
HTTPClient: idpServer.Client(),
}
t.Run("successful initiation", func(t *testing.T) {
authReq, _, err := initiateOIDCLogin(context.Background(), config)
if err != nil {
t.Fatalf("initiateOIDCLogin failed: %v", err)
}
// Validate authURL
if authReq.authURL == "" {
t.Error("authURL is empty")
}
// Parse and validate URL parameters
u, err := url.Parse(authReq.authURL)
if err != nil {
t.Fatalf("Failed to parse authURL: %v", err)
}
q := u.Query()
if q.Get("response_type") != "code" {
t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type"))
}
if q.Get("client_id") != "test-client" {
t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id"))
}
if q.Get("redirect_uri") != "http://localhost:8080/callback" {
t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri"))
}
if q.Get("scope") != "openid profile email" {
t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope"))
}
if q.Get("code_challenge_method") != "S256" {
t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method"))
}
// Validate state is generated
if authReq.state == "" {
t.Error("state is empty")
}
if q.Get("state") != authReq.state {
t.Errorf("state in URL doesn't match returned state")
}
// Validate PKCE parameters
if authReq.codeVerifier == "" {
t.Error("codeVerifier is empty")
}
if q.Get("code_challenge") == "" {
t.Error("code_challenge is empty")
}
})
t.Run("with login_hint", func(t *testing.T) {
configWithHint := *config
configWithHint.LoginHint = "user@example.com"
authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint)
if err != nil {
t.Fatalf("initiateOIDCLogin failed: %v", err)
}
u, err := url.Parse(authReq.authURL)
if err != nil {
t.Fatalf("Failed to parse authURL: %v", err)
}
q := u.Query()
if q.Get("login_hint") != "user@example.com" {
t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint"))
}
})
t.Run("without login_hint", func(t *testing.T) {
authReq, _, err := initiateOIDCLogin(context.Background(), config)
if err != nil {
t.Fatalf("initiateOIDCLogin failed: %v", err)
}
u, err := url.Parse(authReq.authURL)
if err != nil {
t.Fatalf("Failed to parse authURL: %v", err)
}
q := u.Query()
if q.Has("login_hint") {
t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint"))
}
})
t.Run("nil config", func(t *testing.T) {
_, _, err := initiateOIDCLogin(context.Background(), nil)
if err == nil {
t.Error("expected error for nil config, got nil")
}
})
t.Run("missing openid scope", func(t *testing.T) {
badConfig := *config
badConfig.Scopes = []string{"profile", "email"} // Missing "openid"
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
if err == nil {
t.Error("expected error for missing openid scope, got nil")
}
if !strings.Contains(err.Error(), "openid") {
t.Errorf("expected error about missing 'openid', got: %v", err)
}
})
t.Run("missing required fields", func(t *testing.T) {
tests := []struct {
name string
mutate func(*OIDCLoginConfig)
expectErr string
}{
{
name: "missing IssuerURL",
mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" },
expectErr: "IssuerURL is required",
},
{
name: "missing ClientID",
mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" },
expectErr: "ClientID is required",
},
{
name: "missing RedirectURL",
mutate: func(c *OIDCLoginConfig) {
c.RedirectURL = ""
// Ensure ClientID is present to test RedirectURL validation
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
},
expectErr: "RedirectURL is required",
},
{
name: "missing Scopes",
mutate: func(c *OIDCLoginConfig) {
c.Scopes = nil
// Ensure required fields are present to test Scopes validation
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
c.RedirectURL = "http://localhost:8080/callback"
},
expectErr: "at least one scope is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
badConfig := *config
tt.mutate(&badConfig)
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
if err == nil {
t.Error("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.expectErr) {
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
}
})
}
})
}
func validOIDCLoginConfig(issuerURL string) *OIDCLoginConfig {
return &OIDCLoginConfig{
IssuerURL: issuerURL,
// TestCompleteOIDCLogin tests the authorization code exchange.
func TestCompleteOIDCLogin(t *testing.T) {
// Create mock IdP server
idpServer := createMockOIDCServerWithToken(t)
defer idpServer.Close()
config := &OIDCLoginConfig{
IssuerURL: idpServer.URL,
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "test-secret",
},
},
RedirectURL: testRedirectURL,
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email"},
HTTPClient: idpServer.Client(),
}
t.Run("successful code exchange", func(t *testing.T) {
// First initiate to get oauth2Config
_, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
if err != nil {
t.Fatalf("initiateOIDCLogin failed: %v", err)
}
token, err := completeOIDCLogin(
context.Background(),
config,
oauth2Config,
"test-auth-code",
"test-code-verifier",
)
if err != nil {
t.Fatalf("completeOIDCLogin failed: %v", err)
}
// Validate tokens
idToken, ok := token.Extra("id_token").(string)
if !ok || idToken == "" {
t.Error("id_token is missing or empty")
}
if token.AccessToken == "" {
t.Error("AccessToken is empty")
}
if token.TokenType != "Bearer" {
t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType)
}
if token.Expiry.IsZero() {
t.Error("Expiry is zero")
}
})
t.Run("missing parameters", func(t *testing.T) {
_, oauth2Config, _ := initiateOIDCLogin(context.Background(), config)
tests := []struct {
name string
authCode string
codeVerifier string
expectErr string
}{
{
name: "missing authCode",
authCode: "",
codeVerifier: "test-verifier",
expectErr: "authCode is required",
},
{
name: "missing codeVerifier",
authCode: "test-code",
codeVerifier: "",
expectErr: "codeVerifier is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := completeOIDCLogin(
context.Background(),
config,
oauth2Config,
tt.authCode,
tt.codeVerifier,
)
if err == nil {
t.Error("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.expectErr) {
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
}
})
}
})
}
// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end.
func TestOIDCLoginE2E(t *testing.T) {
// Create mock IdP server
idpServer := createMockOIDCServerWithToken(t)
defer idpServer.Close()
config := &OIDCLoginConfig{
IssuerURL: idpServer.URL,
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "test-secret",
},
},
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email"},
HTTPClient: idpServer.Client(),
}
// Step 1: Initiate login
authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
if err != nil {
t.Fatalf("initiateOIDCLogin failed: %v", err)
}
// Step 2: Simulate user authentication and redirect
// (In real flow, user would visit authReq.authURL and IdP would redirect back)
// Here we just use a mock authorization code
mockAuthCode := "mock-authorization-code"
// Step 3: Complete login with authorization code
token, err := completeOIDCLogin(
context.Background(),
config,
oauth2Config,
mockAuthCode,
authReq.codeVerifier,
)
if err != nil {
t.Fatalf("completeOIDCLogin failed: %v", err)
}
// Validate we got an ID token
idToken, ok := token.Extra("id_token").(string)
if !ok || idToken == "" {
t.Error("Expected ID token, got empty or missing")
}
// Validate ID token is a JWT (has 3 parts)
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
}
}
// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin.
func createMockOIDCServer(t *testing.T) *httptest.Server {
var serverURL string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle OIDC discovery
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/authorize",
"token_endpoint": serverURL + "/token",
"jwks_uri": serverURL + "/.well-known/jwks.json",
"response_types_supported": []string{"code"},
"code_challenge_methods_supported": []string{"S256"},
"grant_types_supported": []string{"authorization_code"},
})
return
}
http.NotFound(w, r)
}))
serverURL = server.URL
return server
}
// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange.
func createMockOIDCServerWithToken(t *testing.T) *httptest.Server {
var serverURL string
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Handle OIDC discovery
if r.URL.Path == "/.well-known/openid-configuration" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/authorize",
"token_endpoint": serverURL + "/token",
"jwks_uri": serverURL + "/.well-known/jwks.json",
"response_types_supported": []string{"code"},
"code_challenge_methods_supported": []string{"S256"},
"grant_types_supported": []string{"authorization_code"},
})
return
}
// Handle token endpoint
if r.URL.Path == "/token" {
if err := r.ParseForm(); err != nil {
http.Error(w, "failed to parse form", http.StatusBadRequest)
return
}
// Validate grant type
if r.FormValue("grant_type") != "authorization_code" {
http.Error(w, "invalid grant_type", http.StatusBadRequest)
return
}
// Create mock ID token (JWT)
now := time.Now().Unix()
idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature",
base64EncodeClaims(map[string]any{
"iss": serverURL,
"sub": "test-user",
"aud": "test-client",
"exp": now + 3600,
"iat": now,
"email": "test@example.com",
}))
// Return token response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "mock-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "mock-refresh-token",
"id_token": idToken,
})
return
}
http.NotFound(w, r)
}))
serverURL = server.URL
return server
}
// base64EncodeClaims encodes JWT claims for testing.
func base64EncodeClaims(claims map[string]any) string {
claimsJSON, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(claimsJSON)
}
// TestPerformOIDCLogin tests the combined OIDC login flow with callback.
func TestPerformOIDCLogin(t *testing.T) {
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
PreregisteredClients: map[string]oauthtest.ClientInfo{
"test-client": {
Secret: "test-secret",
RedirectURIs: []string{testRedirectURL},
// Create mock IdP server
idpServer := createMockOIDCServerWithToken(t)
defer idpServer.Close()
config := &OIDCLoginConfig{
IssuerURL: idpServer.URL,
Credentials: &oauthex.ClientCredentials{
ClientID: "test-client",
ClientSecretAuth: &oauthex.ClientSecretAuth{
ClientSecret: "test-secret",
},
},
})
idpServer.Start(t)
config := validOIDCLoginConfig(idpServer.URL())
// fetchAuthCode visits the authorization URL on the fake IdP server,
// follows the redirect, and extracts the authorization code and state.
fetchAuthCode := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.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: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
return nil, fmt.Errorf("expected redirect, got status %d", resp.StatusCode)
}
loc, err := resp.Location()
if err != nil {
return nil, fmt.Errorf("missing Location header: %w", err)
}
return &auth.AuthorizationResult{
Code: loc.Query().Get("code"),
State: loc.Query().Get("state"),
}, nil
}
verifyOIDCToken := func(token *oauth2.Token) error {
idToken, ok := token.Extra("id_token").(string)
if !ok || idToken == "" {
return fmt.Errorf("id_token is missing or empty")
}
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return fmt.Errorf("id_token is not a JWT (expected 3 parts, got %d)", len(parts))
}
if token.AccessToken == "" {
return fmt.Errorf("access token is empty")
}
if token.TokenType != "Bearer" {
return fmt.Errorf("token type = %q, want %q", token.TokenType, "Bearer")
}
return nil
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "profile", "email"},
HTTPClient: idpServer.Client(),
}
t.Run("successful flow", func(t *testing.T) {
token, err := PerformOIDCLogin(context.Background(), config, fetchAuthCode)
if err != nil {
t.Fatalf("PerformOIDCLogin() error = %v", err)
}
if err := verifyOIDCToken(token); err != nil {
t.Errorf("invalid token returned: %v", err)
}
})
t.Run("with login_hint", func(t *testing.T) {
configWithHint := *config
configWithHint.LoginHint = "user@example.com"
token, err := PerformOIDCLogin(context.Background(), &configWithHint,
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
u, err := url.Parse(args.URL)
if err != nil {
return nil, fmt.Errorf("invalid authURL: %w", err)
}
if got := u.Query().Get("login_hint"); got != "user@example.com" {
t.Errorf("login_hint = %q, want %q", got, "user@example.com")
}
return fetchAuthCode(ctx, args)
})
if err != nil {
t.Fatalf("PerformOIDCLogin() error = %v", err)
}
if err := verifyOIDCToken(token); err != nil {
t.Errorf("invalid token returned: %v", err)
}
})
t.Run("without login_hint", func(t *testing.T) {
token, err := PerformOIDCLogin(context.Background(), config,
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
// Validate authURL has required parameters
u, err := url.Parse(args.URL)
if err != nil {
return nil, fmt.Errorf("invalid authURL: %w", err)
}
if u.Query().Has("login_hint") {
t.Errorf("login_hint should be absent, got %q", u.Query().Get("login_hint"))
q := u.Query()
if q.Get("response_type") != "code" {
return nil, fmt.Errorf("missing response_type")
}
return fetchAuthCode(ctx, args)
if q.Get("state") == "" {
return nil, fmt.Errorf("missing state")
}
// Simulate successful user authentication
return &auth.AuthorizationResult{
Code: "mock-auth-code",
State: q.Get("state"), // Return the expected state from URL
}, nil
})
if err != nil {
t.Fatalf("PerformOIDCLogin() error = %v", err)
t.Fatalf("PerformOIDCLogin failed: %v", err)
}
if err := verifyOIDCToken(token); err != nil {
t.Errorf("invalid token returned: %v", err)
idToken, ok := token.Extra("id_token").(string)
if !ok || idToken == "" {
t.Error("id_token is missing or empty")
}
if token.AccessToken == "" {
t.Error("AccessToken is empty")
}
})
t.Run("state mismatch", func(t *testing.T) {
_, err := PerformOIDCLogin(context.Background(), config,
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
// Return wrong state to simulate CSRF attack
return &auth.AuthorizationResult{
Code: "mock-auth-code",
State: "wrong-state",
@ -154,10 +470,10 @@ func TestPerformOIDCLogin(t *testing.T) {
})
if err == nil {
t.Fatal("expected error for state mismatch, got nil")
t.Error("expected error for state mismatch, got nil")
}
if !strings.Contains(err.Error(), "state mismatch") {
t.Errorf("error = %v, want error containing %q", err, "state mismatch")
t.Errorf("expected state mismatch error, got: %v", err)
}
})
@ -168,96 +484,20 @@ func TestPerformOIDCLogin(t *testing.T) {
})
if err == nil {
t.Fatal("expected error, got nil")
t.Error("expected error, got nil")
}
if !strings.Contains(err.Error(), "user cancelled") {
t.Errorf("error = %v, want error containing %q", err, "user cancelled")
t.Errorf("expected 'user cancelled' error, got: %v", err)
}
})
t.Run("nil fetcher", func(t *testing.T) {
_, err := PerformOIDCLogin(context.Background(), config, nil)
if err == nil {
t.Error("expected error for nil fetcher, got nil")
}
if !strings.Contains(err.Error(), "authCodeFetcher is required") {
t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err)
}
})
}
func TestPerformOIDCLogin_ValidationErrors(t *testing.T) {
const nonexistentIssuer = "https://idp.example.com"
noopFetcher := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
return nil, fmt.Errorf("should not be called")
}
tests := []struct {
name string
config *OIDCLoginConfig
fetcher auth.AuthorizationCodeFetcher
wantErr string
}{
{
name: "nil config",
config: nil,
fetcher: noopFetcher,
wantErr: "config is required",
},
{
name: "nil fetcher",
config: validOIDCLoginConfig(nonexistentIssuer),
fetcher: nil,
wantErr: "authCodeFetcher is required",
},
{
name: "missing IssuerURL",
config: validOIDCLoginConfig(""),
fetcher: noopFetcher,
wantErr: "IssuerURL is required",
},
{
name: "missing ClientID",
config: func() *OIDCLoginConfig {
c := validOIDCLoginConfig(nonexistentIssuer)
c.Credentials = &oauthex.ClientCredentials{}
return c
}(),
fetcher: noopFetcher,
wantErr: "ClientID is required",
},
{
name: "missing RedirectURL",
config: func() *OIDCLoginConfig {
c := validOIDCLoginConfig(nonexistentIssuer)
c.RedirectURL = ""
return c
}(),
fetcher: noopFetcher,
wantErr: "RedirectURL is required",
},
{
name: "missing Scopes",
config: func() *OIDCLoginConfig {
c := validOIDCLoginConfig(nonexistentIssuer)
c.Scopes = nil
return c
}(),
fetcher: noopFetcher,
wantErr: "at least one scope is required",
},
{
name: "missing openid scope",
config: func() *OIDCLoginConfig {
c := validOIDCLoginConfig(nonexistentIssuer)
c.Scopes = []string{"profile", "email"}
return c
}(),
fetcher: noopFetcher,
wantErr: "openid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := PerformOIDCLogin(context.Background(), tt.config, tt.fetcher)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %v, want error containing %q", err, tt.wantErr)
}
})
}
}

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

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