Compare commits
177 changed files with 6549 additions and 29762 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -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/*"
|
||||
|
|
|
|||
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
|
|
@ -8,9 +8,6 @@ on:
|
|||
schedule:
|
||||
- cron: '31 9 * * 4'
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
|
|
@ -36,13 +33,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}}"
|
||||
|
|
|
|||
64
.github/workflows/conformance.yml
vendored
64
.github/workflows/conformance.yml
vendored
|
|
@ -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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
go-version: "^1.25"
|
||||
- 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
go-version: "^1.25"
|
||||
- name: "Run conformance tests"
|
||||
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
|
||||
with:
|
||||
mode: client
|
||||
command: go run -tags mcp_go_client_oauth ./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
|
||||
|
|
|
|||
6
.github/workflows/docs-check.yml
vendored
6
.github/workflows/docs-check.yml
vendored
|
|
@ -16,11 +16,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
- 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 ./...
|
||||
|
|
|
|||
14
.github/workflows/nightly.yml
vendored
14
.github/workflows/nightly.yml
vendored
|
|
@ -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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
go-version: "^1.25"
|
||||
- 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
|
||||
|
|
|
|||
26
.github/workflows/publish-docs.yml
vendored
26
.github/workflows/publish-docs.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.github/workflows/scorecard.yml
vendored
8
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||
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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
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@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
|
|
@ -14,11 +14,11 @@ 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
go-version: "^1.25"
|
||||
- name: Check formatting
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
|
|
@ -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"
|
||||
|
|
@ -40,12 +40,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25", "1.26"]
|
||||
go: ["1.24", "1.25"]
|
||||
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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Test
|
||||
|
|
@ -55,10 +55,10 @@ 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@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
go-version: "1.24"
|
||||
- name: Test with -race
|
||||
run: go test -v -race ./...
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
56
auth/auth.go
56
auth/auth.go
|
|
@ -8,7 +8,6 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
|
@ -47,34 +46,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{}
|
||||
|
|
@ -102,17 +73,8 @@ func RequireBearerToken(verifier TokenVerifier, opts *RequireBearerTokenOptions)
|
|||
tokenInfo, errmsg, code := verify(r, verifier, opts)
|
||||
if code != 0 {
|
||||
if code == http.StatusUnauthorized || code == http.StatusForbidden {
|
||||
if opts != nil {
|
||||
var params []string
|
||||
if opts.ResourceMetadataURL != "" {
|
||||
params = append(params, fmt.Sprintf("resource_metadata=%q", opts.ResourceMetadataURL))
|
||||
}
|
||||
if len(opts.Scopes) > 0 {
|
||||
params = append(params, fmt.Sprintf("scope=%q", strings.Join(opts.Scopes, " ")))
|
||||
}
|
||||
if len(params) > 0 {
|
||||
w.Header().Add("WWW-Authenticate", "Bearer "+strings.Join(params, ", "))
|
||||
}
|
||||
if opts != nil && opts.ResourceMetadataURL != "" {
|
||||
w.Header().Add("WWW-Authenticate", "Bearer resource_metadata="+opts.ResourceMetadataURL)
|
||||
}
|
||||
}
|
||||
http.Error(w, errmsg, code)
|
||||
|
|
@ -157,17 +119,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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -193,159 +188,3 @@ func TestProtectedResourceMetadataHandler(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBearerToken(t *testing.T) {
|
||||
verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) {
|
||||
if token == "valid" {
|
||||
return &TokenInfo{Expiration: time.Now().Add(time.Hour), Scopes: []string{"read"}}, nil
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *RequireBearerTokenOptions
|
||||
authHeader string
|
||||
wantHeader string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "no middleware options",
|
||||
opts: nil,
|
||||
authHeader: "Bearer invalid",
|
||||
wantHeader: "",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "metadata only",
|
||||
opts: &RequireBearerTokenOptions{
|
||||
ResourceMetadataURL: "https://example.com/resource-metadata",
|
||||
},
|
||||
authHeader: "Bearer invalid",
|
||||
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\"",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "scopes only",
|
||||
opts: &RequireBearerTokenOptions{
|
||||
Scopes: []string{"read", "write"},
|
||||
},
|
||||
authHeader: "Bearer invalid",
|
||||
wantHeader: "Bearer scope=\"read write\"",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "metadata and scopes",
|
||||
opts: &RequireBearerTokenOptions{
|
||||
ResourceMetadataURL: "https://example.com/resource-metadata",
|
||||
Scopes: []string{"read", "write"},
|
||||
},
|
||||
authHeader: "Bearer invalid",
|
||||
wantHeader: "Bearer resource_metadata=\"https://example.com/resource-metadata\", scope=\"read write\"",
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "insufficient scope",
|
||||
opts: &RequireBearerTokenOptions{
|
||||
Scopes: []string{"admin"},
|
||||
},
|
||||
authHeader: "Bearer valid", // Has "read", needs "admin" -> 403
|
||||
wantHeader: "Bearer scope=\"admin\"",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
opts: &RequireBearerTokenOptions{
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
authHeader: "Bearer valid",
|
||||
wantHeader: "",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := RequireBearerToken(verifier, tt.opts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.authHeader != "" {
|
||||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
|
||||
got := rec.Header().Get("WWW-Authenticate")
|
||||
if got != tt.wantHeader {
|
||||
t.Errorf("WWW-Authenticate = %q, want %q", got, tt.wantHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// Use of this source code is governed by the license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
//go:build mcp_go_client_oauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
|
|
@ -9,19 +11,27 @@ import (
|
|||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ClientSecretAuthConfig is used to configure client authentication using client_secret.
|
||||
// Authentication method will be selected based on the authorization server's supported methods,
|
||||
// according to the following preference order:
|
||||
// 1. client_secret_post
|
||||
// 2. client_secret_basic
|
||||
type ClientSecretAuthConfig struct {
|
||||
// ClientID is the client ID to be used for client authentication.
|
||||
ClientID string
|
||||
// ClientSecret is the client secret to be used for client authentication.
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// ClientIDMetadataDocumentConfig is used to configure the Client ID Metadata Document
|
||||
// based client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents.
|
||||
|
|
@ -32,15 +42,19 @@ type ClientIDMetadataDocumentConfig struct {
|
|||
URL string
|
||||
}
|
||||
|
||||
// PreregisteredClientConfig is used to configure a pre-registered client per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration.
|
||||
// Currently only "client_secret_basic" and "client_secret_post" authentication methods are supported.
|
||||
type PreregisteredClientConfig struct {
|
||||
// ClientSecretAuthConfig is the client_secret based configuration to be used for client authentication.
|
||||
ClientSecretAuthConfig *ClientSecretAuthConfig
|
||||
}
|
||||
|
||||
// DynamicClientRegistrationConfig is used to configure dynamic client registration per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration.
|
||||
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,26 +65,14 @@ 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].
|
||||
// AuthorizationArgs is the input to [AuthorizationCodeHandlerConfig].AuthorizationCodeFetcher.
|
||||
type AuthorizationArgs struct {
|
||||
// Authorization URL to be opened in a browser for the user to start the authorization process.
|
||||
URL string
|
||||
}
|
||||
|
||||
// AuthorizationCodeFetcher is called to initiate the OAuth authorization flow.
|
||||
// It is responsible for directing the user to the authorization URL (e.g., opening
|
||||
// in a browser) and returning the authorization code and state once the Authorization
|
||||
// Server redirects back to the configured RedirectURL.
|
||||
type AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
|
||||
|
||||
// AuthorizationCodeHandlerConfig is the configuration for [AuthorizationCodeHandler].
|
||||
type AuthorizationCodeHandlerConfig struct {
|
||||
// Client registration configuration.
|
||||
|
|
@ -80,7 +82,7 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// 3. Dynamic Client Registration
|
||||
// At least one method must be configured.
|
||||
ClientIDMetadataDocumentConfig *ClientIDMetadataDocumentConfig
|
||||
PreregisteredClient *oauthex.ClientCredentials
|
||||
PreregisteredClientConfig *PreregisteredClientConfig
|
||||
DynamicClientRegistrationConfig *DynamicClientRegistrationConfig
|
||||
|
||||
// RedirectURL is a required URL to redirect to after authorization.
|
||||
|
|
@ -95,70 +97,10 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
RedirectURL string
|
||||
|
||||
// AuthorizationCodeFetcher is a required function called to initiate the authorization flow.
|
||||
// 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
|
||||
// - Fetching Authorization Server Metadata
|
||||
// - Registering a client dynamically
|
||||
// - Exchanging an authorization code for an access token
|
||||
// - Refreshing an access token
|
||||
// Custom clients can include additional security configurations,
|
||||
// such as SSRF protections, see
|
||||
// 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
|
||||
// It is responsible for opening the URL in a browser for the user to start the authorization process.
|
||||
// It should return the authorization code and state once the Authorization Server
|
||||
// redirects back to the RedirectURL.
|
||||
AuthorizationCodeFetcher func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error)
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
|
|
@ -166,21 +108,15 @@ 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) isOAuthHandler() {}
|
||||
|
||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.tokenSource, nil
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +128,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
return nil, errors.New("config must be provided")
|
||||
}
|
||||
if config.ClientIDMetadataDocumentConfig == nil &&
|
||||
config.PreregisteredClient == nil &&
|
||||
config.PreregisteredClientConfig == nil &&
|
||||
config.DynamicClientRegistrationConfig == nil {
|
||||
return nil, errors.New("at least one client registration configuration must be provided")
|
||||
}
|
||||
|
|
@ -202,15 +138,19 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
if config.ClientIDMetadataDocumentConfig != nil && !isNonRootHTTPSURL(config.ClientIDMetadataDocumentConfig.URL) {
|
||||
return nil, fmt.Errorf("client ID metadata document URL must be a non-root HTTPS URL")
|
||||
}
|
||||
if config.PreregisteredClient != nil {
|
||||
if err := config.PreregisteredClient.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid PreregisteredClient configuration: %w", err)
|
||||
preCfg := config.PreregisteredClientConfig
|
||||
if preCfg != nil {
|
||||
if preCfg.ClientSecretAuthConfig == nil {
|
||||
return nil, errors.New("ClientSecretAuthConfig is required for pre-registered client")
|
||||
}
|
||||
if preCfg.ClientSecretAuthConfig.ClientID == "" || preCfg.ClientSecretAuthConfig.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("pre-registered client ID or secret is empty")
|
||||
}
|
||||
}
|
||||
dCfg := config.DynamicClientRegistrationConfig
|
||||
if dCfg != nil {
|
||||
if dCfg.Metadata == nil {
|
||||
return nil, errors.New("dynamic client registration requires non-nil Metadata")
|
||||
return nil, errors.New("Metadata is required for dynamic client registration")
|
||||
}
|
||||
if len(dCfg.Metadata.RedirectURIs) == 0 {
|
||||
return nil, errors.New("Metadata.RedirectURIs is required for dynamic client registration")
|
||||
|
|
@ -220,26 +160,13 @@ 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,
|
||||
// it should have been set by now. Otherwise, it is required.
|
||||
return nil, errors.New("RedirectURL is required")
|
||||
}
|
||||
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,42 +177,11 @@ 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.
|
||||
func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
defer io.Copy(io.Discard, resp.Body)
|
||||
|
||||
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
|
||||
if err != nil {
|
||||
|
|
@ -306,20 +202,9 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
return err
|
||||
}
|
||||
|
||||
asm, err := GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], h.config.Client)
|
||||
asm, err := h.getAuthServerMetadata(ctx, prm)
|
||||
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.
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
||||
authServerURL := prm.AuthorizationServers[0]
|
||||
asm = &oauthex.AuthServerMeta{
|
||||
Issuer: authServerURL,
|
||||
AuthorizationEndpoint: authServerURL + "/authorize",
|
||||
TokenEndpoint: authServerURL + "/token",
|
||||
RegistrationEndpoint: authServerURL + "/register",
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
resolvedClientConfig, err := h.handleRegistration(ctx, asm)
|
||||
|
|
@ -327,33 +212,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,24 +227,16 @@ 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)
|
||||
authRes, err := h.getAuthorizationCode(ctx, cfg, req.URL.String())
|
||||
if err != nil {
|
||||
// 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,
|
||||
|
|
@ -421,34 +276,22 @@ func errorFromChallenges(cs []oauthex.Challenge) string {
|
|||
// If no metadata was found or the fetched metadata fails security checks,
|
||||
// it returns an error.
|
||||
func (h *AuthorizationCodeHandler) getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string) (*oauthex.ProtectedResourceMetadata, error) {
|
||||
var errs []error
|
||||
// Use MCP server URL as the resource URI per
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
|
||||
for _, url := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, h.config.Client)
|
||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, url.URL, url.Resource, http.DefaultClient)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if prm == nil {
|
||||
errs = append(errs, fmt.Errorf("protected resource metadata is 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 = ""
|
||||
prm := &oauthex.ProtectedResourceMetadata{
|
||||
AuthorizationServers: []string{u.String()},
|
||||
Resource: mcpServerURL,
|
||||
}
|
||||
return prm, nil
|
||||
return nil, fmt.Errorf("failed to get protected resource metadata: %v", errors.Join(errs...))
|
||||
}
|
||||
|
||||
type prmURL struct {
|
||||
|
|
@ -491,6 +334,82 @@ func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
|
|||
return urls
|
||||
}
|
||||
|
||||
// getAuthServerMetadata returns the authorization server metadata.
|
||||
// The provided Protected Resource Metadata must not be nil.
|
||||
// It returns an error if the metadata request fails with non-4xx HTTP status code
|
||||
// or the fetched metadata fails security checks.
|
||||
// If no metadata was found, it returns a minimal set of endpoints
|
||||
// as a fallback to 2025-03-26 spec.
|
||||
func (h *AuthorizationCodeHandler) getAuthServerMetadata(ctx context.Context, prm *oauthex.ProtectedResourceMetadata) (*oauthex.AuthServerMeta, error) {
|
||||
var authServerURL string
|
||||
if len(prm.AuthorizationServers) > 0 {
|
||||
// Use the first authorization server, similarly to other SDKs.
|
||||
authServerURL = prm.AuthorizationServers[0]
|
||||
} else {
|
||||
// Fallback to 2025-03-26 spec: MCP server base URL acts as Authorization Server.
|
||||
authURL, err := url.Parse(prm.Resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse resource URL: %v", err)
|
||||
}
|
||||
authURL.Path = ""
|
||||
authServerURL = authURL.String()
|
||||
}
|
||||
|
||||
for _, u := range authorizationServerMetadataURLs(authServerURL) {
|
||||
asm, err := oauthex.GetAuthServerMeta(ctx, u, authServerURL, http.DefaultClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get authorization server metadata: %w", err)
|
||||
}
|
||||
if asm != nil {
|
||||
return asm, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 2025-03-26 spec: predefined endpoints.
|
||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
||||
asm := &oauthex.AuthServerMeta{
|
||||
Issuer: authServerURL,
|
||||
AuthorizationEndpoint: authServerURL + "/authorize",
|
||||
TokenEndpoint: authServerURL + "/token",
|
||||
RegistrationEndpoint: authServerURL + "/register",
|
||||
}
|
||||
return asm, nil
|
||||
}
|
||||
|
||||
// authorizationServerMetadataURLs returns a list of URLs to try when looking for
|
||||
// authorization server metadata as mandated by the MCP specification:
|
||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery.
|
||||
func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||
var urls []string
|
||||
|
||||
baseURL, err := url.Parse(issuerURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0".
|
||||
baseURL.Path = "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
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/" + strings.TrimLeft(originalPath, "/")
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
return urls
|
||||
}
|
||||
|
||||
type registrationType int
|
||||
|
||||
const (
|
||||
|
|
@ -553,27 +472,20 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
|
|||
}, nil
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
pCfg := h.config.PreregisteredClientConfig
|
||||
if pCfg != nil {
|
||||
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
|
||||
clientSecret := ""
|
||||
if preCfg.ClientSecretAuth != nil {
|
||||
clientSecret = preCfg.ClientSecretAuth.ClientSecret
|
||||
}
|
||||
return &resolvedClientConfig{
|
||||
registrationType: registrationTypePreregistered,
|
||||
clientID: preCfg.ClientID,
|
||||
clientSecret: clientSecret,
|
||||
clientID: pCfg.ClientSecretAuthConfig.ClientID,
|
||||
clientSecret: pCfg.ClientSecretAuthConfig.ClientSecret,
|
||||
authStyle: authStyle,
|
||||
}, nil
|
||||
}
|
||||
// 3. Attempt to use dynamic client registration.
|
||||
dcrCfg := h.config.DynamicClientRegistrationConfig
|
||||
if dcrCfg != nil && asm.RegistrationEndpoint != "" {
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, h.config.Client)
|
||||
regResp, err := oauthex.RegisterClient(ctx, asm.RegistrationEndpoint, dcrCfg.Metadata, http.DefaultClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||
}
|
||||
|
|
@ -620,36 +532,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 {
|
||||
|
|
@ -657,57 +539,10 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
|||
oauth2.VerifierOption(authResult.usedCodeVerifier),
|
||||
oauth2.SetAuthURLParam("resource", resourceURL),
|
||||
}
|
||||
clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
|
||||
token, err := cfg.Exchange(clientCtx, authResult.Code, opts...)
|
||||
token, err := cfg.Exchange(ctx, authResult.Code, opts...)
|
||||
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(ctx, token)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,8 @@ import (
|
|||
// [github.com/modelcontextprotocol/go-sdk/mcp.StreamableClientTransport]
|
||||
// for an example.
|
||||
type OAuthHandler interface {
|
||||
isOAuthHandler()
|
||||
|
||||
// TokenSource returns a token source to be used for outgoing requests.
|
||||
// Returned token source might be nil. In that case, the transport will not
|
||||
// add any authorization headers to the request.
|
||||
|
|
|
|||
135
auth/client_private.go
Normal file
135
auth/client_private.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build mcp_go_client_oauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// An OAuthHandlerLegacy conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization
|
||||
// is approved, or an error if not.
|
||||
// The handler receives the HTTP request and response that triggered the authentication flow.
|
||||
// To obtain the protected resource metadata, call [oauthex.GetProtectedResourceMetadataFromHeader].
|
||||
//
|
||||
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
|
||||
// into the streamable transport. This struct will be removed in v1.5.0.
|
||||
type OAuthHandlerLegacy func(req *http.Request, res *http.Response) (oauth2.TokenSource, error)
|
||||
|
||||
// HTTPTransport is an [http.RoundTripper] that follows the MCP
|
||||
// OAuth protocol when it encounters a 401 Unauthorized response.
|
||||
//
|
||||
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
|
||||
// into the streamable transport. This struct will be removed in v1.5.0.
|
||||
type HTTPTransport struct {
|
||||
handler OAuthHandlerLegacy
|
||||
mu sync.Mutex // protects opts.Base
|
||||
opts HTTPTransportOptions
|
||||
}
|
||||
|
||||
// NewHTTPTransport returns a new [*HTTPTransport].
|
||||
// The handler is invoked when an HTTP request results in a 401 Unauthorized status.
|
||||
// It is called only once per transport. Once a TokenSource is obtained, it is used
|
||||
// for the lifetime of the transport; subsequent 401s are not processed.
|
||||
//
|
||||
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
|
||||
// into the streamable transport. This struct will be removed in v1.5.0.
|
||||
func NewHTTPTransport(handler OAuthHandlerLegacy, opts *HTTPTransportOptions) (*HTTPTransport, error) {
|
||||
if handler == nil {
|
||||
return nil, errors.New("handler cannot be nil")
|
||||
}
|
||||
t := &HTTPTransport{
|
||||
handler: handler,
|
||||
}
|
||||
if opts != nil {
|
||||
t.opts = *opts
|
||||
}
|
||||
if t.opts.Base == nil {
|
||||
t.opts.Base = http.DefaultTransport
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// HTTPTransportOptions are options to [NewHTTPTransport].
|
||||
//
|
||||
// Deprecated: Please use the new [OAuthHandler] abstraction that is built
|
||||
// into the streamable transport. This struct will be removed in v1.5.0.
|
||||
type HTTPTransportOptions struct {
|
||||
// Base is the [http.RoundTripper] to use.
|
||||
// If nil, [http.DefaultTransport] is used.
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.mu.Lock()
|
||||
base := t.opts.Base
|
||||
t.mu.Unlock()
|
||||
|
||||
var (
|
||||
// If haveBody is set, the request has a nontrivial body, and we need avoid
|
||||
// reading (or closing) it multiple times. In that case, bodyBytes is its
|
||||
// content.
|
||||
haveBody bool
|
||||
bodyBytes []byte
|
||||
)
|
||||
if req.Body != nil && req.Body != http.NoBody {
|
||||
// if we're setting Body, we must mutate first.
|
||||
req = req.Clone(req.Context())
|
||||
haveBody = true
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Now that we've read the request body, http.RoundTripper requires that we
|
||||
// close it.
|
||||
req.Body.Close() // ignore error
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
|
||||
resp, err := base.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
if _, ok := base.(*oauth2.Transport); ok {
|
||||
// We failed to authorize even with a token source; give up.
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
// Try to authorize.
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// If we don't have a token source, get one by following the OAuth flow.
|
||||
// (We may have obtained one while t.mu was not held above.)
|
||||
// TODO: We hold the lock for the entire OAuth flow. This could be a long
|
||||
// time. Is there a better way?
|
||||
if _, ok := t.opts.Base.(*oauth2.Transport); !ok {
|
||||
ts, err := t.handler(req, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts}
|
||||
}
|
||||
|
||||
// If we don't have a body, the request is reusable, though it will be cloned
|
||||
// by the base. However, if we've had to read the body, we must clone.
|
||||
if haveBody {
|
||||
req = req.Clone(req.Context())
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
|
||||
return t.opts.Base.RoundTrip(req)
|
||||
}
|
||||
153
auth/client_test.go
Normal file
153
auth/client_test.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
||||
// Use of this source code is governed by an MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build mcp_go_client_oauth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// A basicReader is an io.Reader to be used as a non-rereadable request body.
|
||||
//
|
||||
// net/http has special handling for strings.Reader that we want to avoid.
|
||||
type basicReader struct {
|
||||
r *strings.Reader
|
||||
}
|
||||
|
||||
func (r *basicReader) Read(p []byte) (n int, err error) { return r.r.Read(p) }
|
||||
|
||||
// TestHTTPTransport validates the OAuth HTTPTransport.
|
||||
func TestHTTPTransport(t *testing.T) {
|
||||
const testToken = "test-token-123"
|
||||
fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
|
||||
AccessToken: testToken,
|
||||
TokenType: "Bearer",
|
||||
})
|
||||
|
||||
// authServer simulates a resource that requires OAuth.
|
||||
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
// Ensure that the body was properly cloned, by reading it completely.
|
||||
// If the body is not cloned, reading it the second time may yield no
|
||||
// bytes.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
http.Error(w, "empty body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == fmt.Sprintf("Bearer %s", testToken) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer authServer.Close()
|
||||
|
||||
t.Run("successful auth flow", func(t *testing.T) {
|
||||
var handlerCalls int
|
||||
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
||||
handlerCalls++
|
||||
// Verify that the response has the expected WWW-Authenticate header
|
||||
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
|
||||
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
return fakeTokenSource, nil
|
||||
}
|
||||
|
||||
transport, err := NewHTTPTransport(handler, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Get(authServer.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("client.Get() failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if handlerCalls != 1 {
|
||||
t.Errorf("handler was called %d times, want 1", handlerCalls)
|
||||
}
|
||||
|
||||
// Second request should reuse the token and not call the handler again.
|
||||
resp2, err := client.Get(authServer.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("second client.Get() failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK)
|
||||
}
|
||||
if handlerCalls != 1 {
|
||||
t.Errorf("handler should still be called only once, but was %d", handlerCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request body is cloned", func(t *testing.T) {
|
||||
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
||||
// Verify that the response has the expected WWW-Authenticate header
|
||||
if res.Header.Get("WWW-Authenticate") != `Bearer resource_metadata="http://metadata.example.com"` {
|
||||
t.Errorf("handler got WWW-Authenticate header %q", res.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
return fakeTokenSource, nil
|
||||
}
|
||||
|
||||
transport, err := NewHTTPTransport(handler, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
resp, err := client.Post(authServer.URL, "application/json", &basicReader{strings.NewReader("{}")})
|
||||
if err != nil {
|
||||
t.Fatalf("client.Post() failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handler returns error", func(t *testing.T) {
|
||||
handlerErr := errors.New("user rejected auth")
|
||||
handler := func(req *http.Request, res *http.Response) (oauth2.TokenSource, error) {
|
||||
return nil, handlerErr
|
||||
}
|
||||
|
||||
transport, err := NewHTTPTransport(handler, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPTransport() failed: %v", err)
|
||||
}
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
_, err = client.Get(authServer.URL)
|
||||
if !errors.Is(err, handlerErr) {
|
||||
t.Errorf("client.Get() returned error %v, want %v", err, handlerErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue