Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4608cda997 |
||
|
|
3b917b466c |
||
|
|
b58aa8d8d8 |
||
|
|
826e653c27 |
||
|
|
3785c500b5 |
||
|
|
fbd36cb787 |
||
|
|
c1918e19ad |
||
|
|
0c0eb23625 |
||
|
|
3f43bcf012 |
||
|
|
a82c86a6cd |
||
|
|
3210c2b34c |
||
|
|
e40f35d137 |
||
|
|
5bc078a795 |
||
|
|
f99cdb649b |
||
|
|
a5026ea7b7 |
||
|
|
3f3b699b2b |
||
|
|
830f0b76e7 |
||
|
|
12cbafe54d |
||
|
|
3632967404 |
||
|
|
0d3036f785 |
||
|
|
cb0de64136 |
||
|
|
2fdabde139 |
||
|
|
59185e6969 |
||
|
|
a6764cf838 |
||
|
|
8227246472 |
||
|
|
f47d6ce156 |
||
|
|
ce3a92a0a8 |
||
|
|
69c17de411 |
||
|
|
d19bfebab9 |
||
|
|
21c18c6229 |
||
|
|
5a89b63764 |
||
|
|
165121e53e |
||
|
|
b55bcbd610 |
||
|
|
2732839dba |
||
|
|
c68ad9a4e6 |
||
|
|
32404652e6 |
||
|
|
810e6cb751 |
||
|
|
b8c53fedd6 |
||
|
|
6f0a645c20 |
||
|
|
3d6450fccf |
||
|
|
b074c7f3af |
||
|
|
ce0b198202 |
||
|
|
fe27fa9151 |
||
|
|
b0408f2821 |
||
|
|
1fcaf0c393 |
||
|
|
ba1496a9aa |
||
|
|
0eb1600eef |
||
|
|
64e454e35c |
||
|
|
4282916668 |
||
|
|
e9a85e285f |
||
|
|
039119057f |
||
|
|
71dfbc0d3e |
||
|
|
958dfcc532 |
||
|
|
d462fbd303 |
||
|
|
6b1518cb0b |
||
|
|
725694100a |
||
|
|
c4a4ce4543 |
||
|
|
1acd66e9d7 |
||
|
|
0d0cdbc943 |
||
|
|
ec29d60bb9 |
||
|
|
02610a2075 |
||
|
|
7b3bbc0f32 |
||
|
|
5d1040061b |
||
|
|
d32302ee7a |
||
|
|
0c004ee48a |
||
|
|
7743b71b1c |
||
|
|
bc72835f62 |
||
|
|
cc4db25347 |
||
|
|
e761950d97 |
||
|
|
2781b8f7e3 |
||
|
|
827f90ba0c |
||
|
|
5cb8339210 |
||
|
|
fa548d7401 |
||
|
|
8baa4ccebe |
||
|
|
9576afc114 |
||
|
|
0a8953d71c |
||
|
|
7fde7d3452 |
||
|
|
8f54783331 |
||
|
|
beb3d1ab7a |
||
|
|
c79a30ae0a |
||
|
|
dd79ca3d44 |
||
|
|
40f034322f |
||
|
|
ce393acb95 |
||
|
|
b426fbf615 |
||
|
|
703b0f4875 |
||
|
|
acbc1099d1 |
||
|
|
1dcf38f76f |
||
|
|
1af1a087ef |
||
|
|
deecf8a432 |
||
|
|
7f67c851d1 |
||
|
|
4c2aece5d7 |
||
|
|
6de85ec3ed |
||
|
|
d651e2d2e1 |
||
|
|
ec564f9ccb |
||
|
|
224a240875 |
||
|
|
a901f97b6d |
||
|
|
b53d2e3dc3 |
||
|
|
b03fae3155 |
||
|
|
ede1894174 |
||
|
|
65ece59643 |
||
|
|
22d4988a16 |
||
|
|
af893e8d1e |
||
|
|
a817f61412 |
||
|
|
7f4aa4a0ce |
||
|
|
b9e6fc5a66 |
||
|
|
01847b35b3 |
||
|
|
b6649db4f9 |
||
|
|
cd7d80c591 |
||
|
|
3486f6bcaf |
||
|
|
17029489ff |
||
|
|
b85574f685 |
||
|
|
6333aa7c63 |
||
|
|
71ffcb412a |
||
|
|
d9728a88f3 |
||
|
|
96074c9918 |
||
|
|
6a6ade920b |
||
|
|
79e1a13dcf |
||
|
|
91c010f664 |
||
|
|
bcbfe0ce36 |
||
|
|
4dc99b48e1 |
||
|
|
88c58c3006 |
||
|
|
dd978160ab |
||
|
|
00d3642840 |
||
|
|
d9714c61f1 |
||
|
|
5045d86fb4 |
||
|
|
c60a318d22 |
||
|
|
d54751a8ee |
||
|
|
ad054d3c5e |
||
|
|
6d2bbff7d8 |
||
|
|
8805aa85f7 |
||
|
|
dfb45f1e11 |
||
|
|
189a85ad92 |
||
|
|
2d47cc9664 |
||
|
|
4cbdd6a514 |
||
|
|
c044cdb734 |
||
|
|
bed83e104b |
||
|
|
bf69179066 |
||
|
|
1253d17add |
||
|
|
8eb54632ad |
||
|
|
da3535b678 |
||
|
|
9f5e89fea2 |
||
|
|
909372d8d1 |
||
|
|
d10c315008 |
||
|
|
438cd4384c |
||
|
|
9604499eed |
||
|
|
06c5f290f2 |
||
|
|
2b7c9937e6 |
||
|
|
ec54d41a17 |
||
|
|
93a09db45b |
||
|
|
49ef49acf1 |
||
|
|
9e0d8979df |
||
|
|
4f1b3e5062 |
||
|
|
8e45782dbd |
124 changed files with 21425 additions and 1083 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -5,3 +5,11 @@ 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/*"
|
||||
|
|
|
|||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -36,13 +36,13 @@ jobs:
|
|||
build-mode: autobuild
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
60
.github/workflows/conformance.yml
vendored
60
.github/workflows/conformance.yml
vendored
|
|
@ -13,44 +13,62 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- 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
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: "Run conformance tests"
|
||||
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
mode: client
|
||||
command: go run ./conformance/everything-client
|
||||
suite: core
|
||||
expected-failures: ./conformance/baseline.yml
|
||||
node-version: 22
|
||||
- name: Run conformance tests (full suite)
|
||||
run: |
|
||||
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
|
||||
--command "go run ./conformance/everything-client" \
|
||||
--suite all \
|
||||
--expected-failures ./conformance/baseline.yml
|
||||
|
|
|
|||
4
.github/workflows/docs-check.yml
vendored
4
.github/workflows/docs-check.yml
vendored
|
|
@ -16,11 +16,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check docs are up-to-date
|
||||
run: |
|
||||
go generate ./...
|
||||
|
|
|
|||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -11,25 +11,29 @@ 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@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Run server conformance tests
|
||||
|
|
|
|||
6
.github/workflows/publish-docs.yml
vendored
6
.github/workflows/publish-docs.yml
vendored
|
|
@ -13,16 +13,16 @@ jobs:
|
|||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
|
|
|
|||
8
.github/workflows/scorecard.yml
vendored
8
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
|
@ -14,9 +14,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "^1.26"
|
||||
- name: Check formatting
|
||||
|
|
@ -43,9 +43,9 @@ jobs:
|
|||
go: ["1.25", "1.26"]
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Test
|
||||
|
|
@ -55,9 +55,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: "1.26"
|
||||
- name: Test with -race
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ transparently, while adhering to these goals.
|
|||
1. [Contributing code](#contributing-code)
|
||||
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
|
||||
1. [Updating the README](#updating-the-readme)
|
||||
1. [Versioning](#versioning)
|
||||
1. [What counts as a breaking change](#what-counts-as-a-breaking-change)
|
||||
1. [Deprecation](#deprecation)
|
||||
1. [Timeouts](#timeouts)
|
||||
1. [Code of conduct](#code-of-conduct)
|
||||
1. [Governance](#governance)
|
||||
|
|
@ -188,6 +191,57 @@ 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,16 +34,24 @@ 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.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 |
|
||||
| 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 |
|
||||
|
||||
\* 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.
|
||||
|
||||
|
|
|
|||
42
auth/auth.go
42
auth/auth.go
|
|
@ -47,6 +47,34 @@ 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{}
|
||||
|
|
@ -129,11 +157,17 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
|
|||
}
|
||||
}
|
||||
|
||||
// Check expiration.
|
||||
if tokenInfo.Expiration.IsZero() {
|
||||
return nil, "token missing expiration", http.StatusUnauthorized
|
||||
if opts == nil {
|
||||
opts = &RequireBearerTokenOptions{}
|
||||
}
|
||||
if tokenInfo.Expiration.Before(time.Now()) {
|
||||
// Check expiration, with optional clock-skew tolerance. Skew only applies
|
||||
// when an expiration is present; a missing expiration is governed solely by
|
||||
// AllowMissingExpiration.
|
||||
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 expired", http.StatusUnauthorized
|
||||
}
|
||||
return tokenInfo, "", 0
|
||||
|
|
|
|||
222
auth/auth_example_test.go
Normal file
222
auth/auth_example_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// 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,6 +62,11 @@ 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,
|
||||
|
|
@ -284,3 +289,63 @@ func TestRequireBearerToken(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
|
||||
// extends the expiration check tolerance: a token whose Expiration is in the
|
||||
// recent past is accepted iff the elapsed interval is within ClockSkew.
|
||||
func TestRequireBearerToken_ClockSkew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
clockSkew time.Duration
|
||||
expiredAgo time.Duration
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "no skew, fresh token accepted",
|
||||
clockSkew: 0,
|
||||
expiredAgo: -time.Minute, // expires in 1 minute
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no skew, expired token rejected",
|
||||
clockSkew: 0,
|
||||
expiredAgo: 5 * time.Second, // expired 5s ago
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "with skew, recently-expired token accepted",
|
||||
clockSkew: 30 * time.Second,
|
||||
expiredAgo: 5 * time.Second,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "with skew, token expired beyond tolerance rejected",
|
||||
clockSkew: 10 * time.Second,
|
||||
expiredAgo: 30 * time.Second,
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
|
||||
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
|
||||
}
|
||||
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
|
||||
ClockSkew: tt.clockSkew,
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer anything")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import (
|
|||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
|
|
@ -49,6 +51,12 @@ 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].
|
||||
|
|
@ -90,6 +98,37 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// See [AuthorizationCodeFetcher] for details.
|
||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
||||
|
||||
// ScopeFilter, if non-nil, is called with the scopes discovered from the
|
||||
// protected resource metadata (or WWW-Authenticate challenge) and returns
|
||||
// the scopes to request during authorization. It gives the client full
|
||||
// control to narrow, extend, or reorder that set — e.g. dropping Gmail's
|
||||
// gmail.metadata, which the Gmail API refuses to combine with the search "q"
|
||||
// parameter even alongside gmail.readonly. It runs before offline_access
|
||||
// (see RequestRefreshToken) and the step-up union, so neither is affected.
|
||||
ScopeFilter func(discovered []string) []string
|
||||
|
||||
// RequestRefreshToken indicates that the client intends to use refresh
|
||||
// tokens and is capable of storing them securely.
|
||||
//
|
||||
// When true and the Authorization Server metadata contains "offline_access"
|
||||
// in its scopes_supported, the client adds "offline_access" to the
|
||||
// requested scopes.
|
||||
//
|
||||
// When using Dynamic Client Registration, callers should include
|
||||
// "refresh_token" in [DynamicClientRegistrationConfig].Metadata.GrantTypes
|
||||
// directly to advertise refresh token support to the Authorization Server.
|
||||
//
|
||||
// When using Client ID Metadata Document, the document hosted at the
|
||||
// Client ID URL should include "refresh_token" in its grant_types.
|
||||
//
|
||||
// See https://modelcontextprotocol.io/seps/2207-oidc-refresh-token-guidance.
|
||||
RequestRefreshToken bool
|
||||
|
||||
// AcceptUnadvertisedIss accepts a matching RFC 9207 iss even when the
|
||||
// authorization server metadata omits authorization_response_iss_parameter_supported.
|
||||
// The zero value (false) keeps the historical reject-unadvertised-iss behavior.
|
||||
AcceptUnadvertisedIss bool
|
||||
|
||||
// Client is an optional HTTP client to use for HTTP requests.
|
||||
// It is used for the following requests:
|
||||
// - Fetching Protected Resource Metadata
|
||||
|
|
@ -102,6 +141,24 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||
// If not provided, http.DefaultClient will be used.
|
||||
Client *http.Client
|
||||
|
||||
// NewTokenSource is an optional function that can be set to construct the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is called after the authorization code is successfully
|
||||
// exchanged for a token in [AuthorizationCodeHandler.Authorize]
|
||||
// to obtain the [oauth2.TokenSource] returned by
|
||||
// [AuthorizationCodeHandler.TokenSource]. Implementations must use the
|
||||
// provided context, which is properly configured for constructing a
|
||||
// TokenSource. The default is to call [oauth2.Config.TokenSource].
|
||||
NewTokenSource func(context.Context, *oauth2.Config, *oauth2.Token) (oauth2.TokenSource, error)
|
||||
|
||||
// InitialTokenSource is an optional field that can be set to inject the
|
||||
// token source that will be used by the [AuthorizationCodeHandler]. If
|
||||
// non-nil, it is set as the token source that will be returned by
|
||||
// [AuthorizationCodeHandler.TokenSource] during handler initialization.
|
||||
// The default is nil, which means no token source has been set initially,
|
||||
// and will trigger a call to [AuthorizationCodeHandler.Authorize].
|
||||
InitialTokenSource oauth2.TokenSource
|
||||
}
|
||||
|
||||
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||
|
|
@ -109,13 +166,21 @@ type AuthorizationCodeHandlerConfig struct {
|
|||
type AuthorizationCodeHandler struct {
|
||||
config *AuthorizationCodeHandlerConfig
|
||||
|
||||
// mu protects concurrent access to tokenSource and grantedScopes.
|
||||
mu sync.RWMutex
|
||||
|
||||
// tokenSource is the token source to use for authorization.
|
||||
tokenSource oauth2.TokenSource
|
||||
|
||||
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||
grantedScopes map[string][]string
|
||||
}
|
||||
|
||||
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||
|
||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.tokenSource, nil
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +235,11 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
|||
if config.Client == nil {
|
||||
config.Client = http.DefaultClient
|
||||
}
|
||||
return &AuthorizationCodeHandler{config: config}, nil
|
||||
return &AuthorizationCodeHandler{
|
||||
config: config,
|
||||
tokenSource: config.InitialTokenSource,
|
||||
grantedScopes: make(map[string][]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isNonRootHTTPSURL(u string) bool {
|
||||
|
|
@ -258,11 +327,33 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
return err
|
||||
}
|
||||
|
||||
scps := scopesFromChallenges(wwwChallenges)
|
||||
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
|
||||
scps = prm.ScopesSupported
|
||||
requestedScopes := scopesFromChallenges(wwwChallenges)
|
||||
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||
requestedScopes = 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,
|
||||
|
|
@ -273,7 +364,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
AuthStyle: resolvedClientConfig.authStyle,
|
||||
},
|
||||
RedirectURL: h.config.RedirectURL,
|
||||
Scopes: scps,
|
||||
Scopes: requestedScopes,
|
||||
}
|
||||
|
||||
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
||||
|
|
@ -281,8 +372,16 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||
return err
|
||||
}
|
||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
||||
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
||||
}
|
||||
|
||||
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
||||
|
|
@ -456,6 +555,9 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
|
|||
// 2. Attempt to use pre-registered client configuration.
|
||||
preCfg := h.config.PreregisteredClient
|
||||
if preCfg != nil {
|
||||
if preCfg.Issuer != "" && !authutil.IssuersEqual(preCfg.Issuer, asm.Issuer) {
|
||||
return nil, fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, preCfg.Issuer)
|
||||
}
|
||||
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
|
||||
clientSecret := ""
|
||||
if preCfg.ClientSecretAuth != nil {
|
||||
|
|
@ -518,6 +620,36 @@ 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 {
|
||||
|
|
@ -530,6 +662,52 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
|||
if err != nil {
|
||||
return fmt.Errorf("token exchange failed: %w", err)
|
||||
}
|
||||
h.tokenSource = cfg.TokenSource(clientCtx, token)
|
||||
// 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()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
|
|
@ -75,6 +78,7 @@ func TestAuthorize(t *testing.T) {
|
|||
return &AuthorizationResult{
|
||||
Code: location.Query().Get("code"),
|
||||
State: location.Query().Get("state"),
|
||||
Iss: location.Query().Get("iss"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
|
@ -111,6 +115,227 @@ func TestAuthorize(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestAuthorize_RefreshAfterContextCancel verifies that the token source built
|
||||
// by Authorize keeps refreshing after the context passed to Authorize is
|
||||
// cancelled. The token source is stored on the handler and used by the
|
||||
// transport for the whole connection lifetime, whereas Authorize is typically
|
||||
// called from a request- or connect-scoped context. golang.org/x/oauth2
|
||||
// captures the context handed to Config.TokenSource and reuses it for every
|
||||
// refresh, so binding it to the request context made every refresh after the
|
||||
// access token expired fail with "context canceled". Regression test for that.
|
||||
func TestAuthorize_RefreshAfterContextCancel(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
// expires_in below oauth2's 10s expiry delta, so the reuse token source
|
||||
// treats the access token as expired immediately and must refresh.
|
||||
AccessTokenTTL: 1,
|
||||
IssueRefreshToken: true,
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Get(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
location, err := resp.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
||||
}
|
||||
return &AuthorizationResult{
|
||||
Code: location.Query().Get("code"),
|
||||
State: location.Query().Get("state"),
|
||||
Iss: location.Query().Get("iss"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource")
|
||||
|
||||
// Authorize under a context that we cancel immediately afterwards, mimicking
|
||||
// the request/connect context the transport passes and that is already done
|
||||
// by the time a later token refresh runs.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := handler.Authorize(ctx, req, resp); err != nil {
|
||||
t.Fatalf("Authorize failed: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
tokenSource, err := handler.TokenSource(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get token source: %v", err)
|
||||
}
|
||||
// The access token is already expired, so this forces a refresh round-trip.
|
||||
// It must not fail with "context canceled" from the cancelled Authorize ctx.
|
||||
token, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
t.Fatalf("token refresh after Authorize context cancellation failed: %v", err)
|
||||
}
|
||||
if token.AccessToken != "test_access_token_refreshed" {
|
||||
t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_ScopeAccumulation(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
TokenScopeFunc: func(requestedScope string) string {
|
||||
// Simulate a server that never grants "write".
|
||||
var granted []string
|
||||
for _, s := range strings.Fields(requestedScope) {
|
||||
if s != "write" {
|
||||
granted = append(granted, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(granted, " ")
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
var capturedAuthURLs []string
|
||||
noRedirectClient := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test_client_secret",
|
||||
},
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
capturedAuthURLs = append(capturedAuthURLs, args.URL)
|
||||
resp, err := noRedirectClient.Get(args.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
loc, err := resp.Location()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthorizationResult{
|
||||
Code: loc.Query().Get("code"),
|
||||
State: loc.Query().Get("state"),
|
||||
Iss: loc.Query().Get("iss"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
// First authorization: 401 with scope="read write".
|
||||
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate",
|
||||
fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
||||
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
||||
t.Fatalf("First Authorize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify first auth URL requested "read" and "write".
|
||||
firstURL, err := url.Parse(capturedAuthURLs[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse first auth URL: %v", err)
|
||||
}
|
||||
firstScopes := strings.Fields(firstURL.Query().Get("scope"))
|
||||
if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||
t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Verify only "read" was granted (the token omitted "write").
|
||||
issuer := authServer.URL()
|
||||
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Second authorization: 403 insufficient_scope with scope="admin".
|
||||
// Accumulated scopes should be "read" (previously granted) + "admin" (new).
|
||||
req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp2 := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}
|
||||
resp2.Header.Set("WWW-Authenticate",
|
||||
fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
||||
if err := handler.Authorize(context.Background(), req2, resp2); err != nil {
|
||||
t.Fatalf("Second Authorize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
|
||||
// but NOT "write" (requested but never granted).
|
||||
secondURL, err := url.Parse(capturedAuthURLs[1])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse second auth URL: %v", err)
|
||||
}
|
||||
secondScopes := strings.Fields(secondURL.Query().Get("scope"))
|
||||
if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||
t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
|
||||
resp := &http.Response{
|
||||
|
|
@ -479,6 +704,8 @@ func TestHandleRegistration(t *testing.T) {
|
|||
asm *oauthex.AuthServerMeta
|
||||
want *resolvedClientConfig
|
||||
wantError bool
|
||||
issuerMatch bool
|
||||
issuerSuffix string
|
||||
}{
|
||||
{
|
||||
name: "ClientIDMetadataDocument",
|
||||
|
|
@ -517,6 +744,79 @@ func TestHandleRegistration(t *testing.T) {
|
|||
authStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Preregistered_IssuerMatch",
|
||||
serverConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"pre_client_id": {
|
||||
Secret: "pre_client_secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "pre_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "pre_client_secret",
|
||||
},
|
||||
Issuer: "", // set dynamically in the test
|
||||
},
|
||||
},
|
||||
want: &resolvedClientConfig{
|
||||
registrationType: registrationTypePreregistered,
|
||||
clientID: "pre_client_id",
|
||||
clientSecret: "pre_client_secret",
|
||||
authStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
issuerMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Preregistered_IssuerMismatch",
|
||||
serverConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"pre_client_id": {
|
||||
Secret: "pre_client_secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "pre_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "pre_client_secret",
|
||||
},
|
||||
Issuer: "https://other-issuer.example.com",
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "Preregistered_IssuerMatchTrailingSlash",
|
||||
serverConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"pre_client_id": {
|
||||
Secret: "pre_client_secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "pre_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "pre_client_secret",
|
||||
},
|
||||
Issuer: "", // set dynamically in the test (with trailing slash)
|
||||
},
|
||||
},
|
||||
want: &resolvedClientConfig{
|
||||
registrationType: registrationTypePreregistered,
|
||||
clientID: "pre_client_id",
|
||||
clientSecret: "pre_client_secret",
|
||||
authStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
issuerMatch: true,
|
||||
issuerSuffix: "/",
|
||||
},
|
||||
{
|
||||
name: "NoneSupported",
|
||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||
|
|
@ -530,6 +830,10 @@ func TestHandleRegistration(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig})
|
||||
s.Start(t)
|
||||
// Set the Issuer dynamically if requested by the test case.
|
||||
if tt.issuerMatch {
|
||||
tt.handlerConfig.PreregisteredClient.Issuer = s.URL() + tt.issuerSuffix
|
||||
}
|
||||
tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -549,6 +853,9 @@ func TestHandleRegistration(t *testing.T) {
|
|||
}
|
||||
return
|
||||
}
|
||||
if tt.wantError {
|
||||
t.Fatal("handleRegistration() expected error, got nil")
|
||||
}
|
||||
if got.registrationType != tt.want.registrationType {
|
||||
t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType)
|
||||
}
|
||||
|
|
@ -608,6 +915,196 @@ func TestDynamicRegistration(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestValidateIssuerResponse(t *testing.T) {
|
||||
const expectedIssuer = "https://auth.example.com"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
iss string
|
||||
issSupported bool
|
||||
acceptUnadvertisedIss bool
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "ValidIss",
|
||||
iss: expectedIssuer,
|
||||
issSupported: true,
|
||||
},
|
||||
{
|
||||
name: "WrongIss",
|
||||
iss: "https://attacker.example.com",
|
||||
issSupported: true,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not match expected issuer",
|
||||
},
|
||||
{
|
||||
name: "MissingIssWhenRequired",
|
||||
iss: "",
|
||||
issSupported: true,
|
||||
wantErr: true,
|
||||
wantErrContains: "RFC 9207",
|
||||
},
|
||||
{
|
||||
name: "MissingIssWhenNotRequired",
|
||||
iss: "",
|
||||
issSupported: false,
|
||||
},
|
||||
{
|
||||
// Default / zero-value policy: reject a matching unadvertised iss
|
||||
// (released v1.7.0 behavior).
|
||||
name: "UnadvertisedIssCorrectRejectedByDefault",
|
||||
iss: expectedIssuer,
|
||||
issSupported: false,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not advertise",
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssCorrectAcceptedWhenOptedIn",
|
||||
iss: expectedIssuer,
|
||||
issSupported: false,
|
||||
acceptUnadvertisedIss: true,
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssWrong",
|
||||
iss: "https://attacker.example.com",
|
||||
issSupported: false,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not match expected issuer",
|
||||
},
|
||||
{
|
||||
name: "UnadvertisedIssWrongStillRejectedWhenOptedIn",
|
||||
iss: "https://attacker.example.com",
|
||||
issSupported: false,
|
||||
acceptUnadvertisedIss: true,
|
||||
wantErr: true,
|
||||
wantErrContains: "does not match expected issuer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes
|
||||
// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to
|
||||
// validateIssuerResponse. The fake server returns iss without advertising
|
||||
// authorization_response_iss_parameter_supported.
|
||||
func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptUnadvertisedIss bool
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "ZeroValueRejects",
|
||||
wantErrContains: "does not advertise",
|
||||
},
|
||||
{
|
||||
name: "OptInAccepts",
|
||||
acceptUnadvertisedIss: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
UnadvertiseIssParameter: true,
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
AcceptUnadvertisedIss: tt.acceptUnadvertisedIss,
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test_client_secret",
|
||||
},
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Get(args.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
location, err := resp.Location()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
||||
}
|
||||
return &AuthorizationResult{
|
||||
Code: location.Query().Get("code"),
|
||||
State: location.Query().Get("state"),
|
||||
Iss: location.Query().Get("iss"),
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set(
|
||||
"WWW-Authenticate",
|
||||
"Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource",
|
||||
)
|
||||
err = handler.Authorize(context.Background(), req, resp)
|
||||
if tt.wantErrContains != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||
t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferApplicationType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -738,6 +1235,231 @@ func TestApplicationTypeInference(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_OfflineAccessScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestRefreshToken bool
|
||||
asScopesSupported []string
|
||||
challengeScopes string
|
||||
wantOfflineAccess bool
|
||||
}{
|
||||
{
|
||||
name: "AddedWhenASSupportsAndClientRequests",
|
||||
requestRefreshToken: true,
|
||||
asScopesSupported: []string{"openid", "offline_access"},
|
||||
wantOfflineAccess: true,
|
||||
},
|
||||
{
|
||||
name: "NotAddedWhenClientDoesNotRequest",
|
||||
requestRefreshToken: false,
|
||||
asScopesSupported: []string{"openid", "offline_access"},
|
||||
wantOfflineAccess: false,
|
||||
},
|
||||
{
|
||||
name: "NotAddedWhenASDoesNotSupport",
|
||||
requestRefreshToken: true,
|
||||
asScopesSupported: []string{"openid"},
|
||||
wantOfflineAccess: false,
|
||||
},
|
||||
{
|
||||
name: "NotDuplicatedWhenAlreadyInScopes",
|
||||
requestRefreshToken: true,
|
||||
asScopesSupported: []string{"openid", "offline_access"},
|
||||
challengeScopes: "read offline_access",
|
||||
wantOfflineAccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
ScopesSupported: tt.asScopesSupported,
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
var capturedAuthURL string
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||
ClientSecret: "test_client_secret",
|
||||
},
|
||||
},
|
||||
RequestRefreshToken: tt.requestRefreshToken,
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
capturedAuthURL = args.URL
|
||||
return nil, fmt.Errorf("stop after capturing URL")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource"
|
||||
if tt.challengeScopes != "" {
|
||||
wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes)
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", wwwAuth)
|
||||
|
||||
// Authorize will fail at the fetcher, but we only care about the URL.
|
||||
handler.Authorize(context.Background(), req, resp)
|
||||
|
||||
if capturedAuthURL == "" {
|
||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
||||
}
|
||||
u, err := url.Parse(capturedAuthURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
||||
}
|
||||
scopes := strings.Fields(u.Query().Get("scope"))
|
||||
hasOfflineAccess := slices.Contains(scopes, "offline_access")
|
||||
if hasOfflineAccess != tt.wantOfflineAccess {
|
||||
t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes)
|
||||
}
|
||||
|
||||
// When offline_access was already present in challenge scopes,
|
||||
// verify it appears exactly once.
|
||||
if tt.wantOfflineAccess {
|
||||
count := 0
|
||||
for _, s := range scopes {
|
||||
if s == "offline_access" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("offline_access appears %d times in scopes, want 1", count)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_ScopeFilter(t *testing.T) {
|
||||
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
|
||||
// the handler treats as the discovered scopes passed to ScopeFilter.
|
||||
const advertised = "gmail.metadata gmail.readonly gmail.compose"
|
||||
tests := []struct {
|
||||
name string
|
||||
filter func(discovered []string) []string
|
||||
want []string // requested scopes, order-independent
|
||||
}{
|
||||
{
|
||||
name: "nil filter leaves scopes unchanged",
|
||||
filter: nil,
|
||||
want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"},
|
||||
},
|
||||
{
|
||||
name: "filter drops a scope",
|
||||
filter: func(d []string) []string {
|
||||
return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" })
|
||||
},
|
||||
want: []string{"gmail.readonly", "gmail.compose"},
|
||||
},
|
||||
{
|
||||
name: "filter replaces the set entirely",
|
||||
filter: func([]string) []string { return []string{"gmail.readonly"} },
|
||||
want: []string{"gmail.readonly"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test_client_id": {
|
||||
Secret: "test_client_secret",
|
||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
var capturedAuthURL string
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
||||
},
|
||||
ScopeFilter: tt.filter,
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
capturedAuthURL = args.URL
|
||||
return nil, fmt.Errorf("stop after capturing URL")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", fmt.Sprintf(
|
||||
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
|
||||
resourceServer.URL, advertised))
|
||||
|
||||
handler.Authorize(context.Background(), req, resp)
|
||||
|
||||
if capturedAuthURL == "" {
|
||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
||||
}
|
||||
u, err := url.Parse(capturedAuthURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
||||
}
|
||||
// Compare as a set: UnionScopes (applied downstream) returns map keys,
|
||||
// so the order of the requested scope parameter is not deterministic.
|
||||
got := strings.Fields(u.Query().Get("scope"))
|
||||
want := slices.Clone(tt.want)
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("requested scopes = %v, want %v (any order)", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||
// Values that are relevant to the test should be set explicitly.
|
||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||
|
|
@ -749,3 +1471,92 @@ func validConfig() *AuthorizationCodeHandlerConfig {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTokenSource(t *testing.T) {
|
||||
// mock the /token endpoint to successfully return an access token on code exchange
|
||||
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/token" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockTS.Close()
|
||||
|
||||
// configure the handler and set NewTokenSource
|
||||
var called bool
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client",
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
u, _ := url.Parse(args.URL)
|
||||
return &AuthorizationResult{
|
||||
Code: "test_code",
|
||||
State: u.Query().Get("state"),
|
||||
}, nil
|
||||
},
|
||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
||||
called = true
|
||||
return oauth2.StaticTokenSource(token), nil
|
||||
},
|
||||
Client: mockTS.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a 401 response from a resource server.
|
||||
// The WWW-Authenticate: Bearer header triggers the authorization logic.
|
||||
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", "Bearer")
|
||||
|
||||
// Authorize and confirm NewTokenSource was called.
|
||||
err = handler.Authorize(t.Context(), req, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("expected NewTokenSource to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialTokenSource(t *testing.T) {
|
||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
PreregisteredClient: &oauthex.ClientCredentials{
|
||||
ClientID: "test_client_id",
|
||||
},
|
||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
||||
}
|
||||
|
||||
ts, err := handler.TokenSource(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get token source: %v", err)
|
||||
}
|
||||
if ts == nil {
|
||||
t.Fatal("expected token source to be non-nil")
|
||||
}
|
||||
|
||||
tok, err := ts.Token()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get Token: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "set_token" {
|
||||
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
|
@ -47,6 +48,9 @@ type ClientCredentialsHandlerConfig struct {
|
|||
type ClientCredentialsHandler struct {
|
||||
config *ClientCredentialsHandlerConfig
|
||||
tokenSource oauth2.TokenSource
|
||||
|
||||
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
||||
grantedScopes map[string][]string
|
||||
}
|
||||
|
||||
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
|
||||
|
|
@ -67,7 +71,10 @@ func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*Clien
|
|||
if config.Credentials.ClientSecretAuth == nil {
|
||||
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
|
||||
}
|
||||
return &ClientCredentialsHandler{config: config}, nil
|
||||
return &ClientCredentialsHandler{
|
||||
config: config,
|
||||
grantedScopes: make(map[string][]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TokenSource returns the token source for outgoing requests.
|
||||
|
|
@ -121,30 +128,51 @@ func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Requ
|
|||
}
|
||||
}
|
||||
|
||||
// Determine scopes: use PRM's scopes_supported if available.
|
||||
scopes := scopesFromChallenges(wwwChallenges)
|
||||
if len(scopes) == 0 && len(prm.ScopesSupported) > 0 {
|
||||
scopes = prm.ScopesSupported
|
||||
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.
|
||||
creds := h.config.Credentials
|
||||
cfg := &clientcredentials.Config{
|
||||
ClientID: creds.ClientID,
|
||||
ClientSecret: creds.ClientSecretAuth.ClientSecret,
|
||||
TokenURL: asm.TokenEndpoint,
|
||||
Scopes: scopes,
|
||||
Scopes: requestedScopes,
|
||||
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
|
||||
}
|
||||
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
h.tokenSource = cfg.TokenSource(ctxWithClient)
|
||||
|
||||
// Eagerly fetch a token to surface errors immediately.
|
||||
if _, err := h.tokenSource.Token(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||
|
|
@ -219,6 +221,51 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("issuer mismatch", func(t *testing.T) {
|
||||
config := validClientCredentialsConfig()
|
||||
config.Credentials.Issuer = "https://other-issuer.example.com"
|
||||
handler, err := NewClientCredentialsHandler(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: http.Header{},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
||||
err = handler.Authorize(t.Context(), req, resp)
|
||||
if err == nil {
|
||||
t.Fatal("expected Authorize to fail with issuer mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not match") {
|
||||
t.Errorf("error %q does not mention issuer mismatch", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issuer match ignoring trailing slash", func(t *testing.T) {
|
||||
config := validClientCredentialsConfig()
|
||||
// authServer.URL() has no trailing slash; configure with one to
|
||||
// verify the comparison tolerates the difference (per RFC 8414 §3.3
|
||||
// normalization applied in oauthex.IssuersEqual).
|
||||
config.Credentials.Issuer = authServer.URL() + "/"
|
||||
handler, err := NewClientCredentialsHandler(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: http.Header{},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PRM via resource_metadata in challenge", func(t *testing.T) {
|
||||
prmMux := http.NewServeMux()
|
||||
prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
|
|
@ -255,6 +302,86 @@ func TestClientCredentialsHandler_Authorize(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
|
||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
||||
ServeOAuthInsertedEndpoint: true,
|
||||
},
|
||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
||||
"test-client": {Secret: "test-secret"},
|
||||
},
|
||||
},
|
||||
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
TokenScopeFunc: func(requestedScope string) string {
|
||||
// Simulate a server that never grants "write".
|
||||
var granted []string
|
||||
for _, s := range strings.Fields(requestedScope) {
|
||||
if s != "write" {
|
||||
granted = append(granted, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(granted, " ")
|
||||
},
|
||||
})
|
||||
authServer.Start(t)
|
||||
|
||||
resourceMux := http.NewServeMux()
|
||||
resourceServer := httptest.NewServer(resourceMux)
|
||||
t.Cleanup(resourceServer.Close)
|
||||
resourceURL := resourceServer.URL + "/resource"
|
||||
|
||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
||||
Resource: resourceURL,
|
||||
AuthorizationServers: []string{authServer.URL()},
|
||||
}))
|
||||
|
||||
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First authorization: 401 with scope="read write".
|
||||
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}
|
||||
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
|
||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
||||
t.Fatalf("First Authorize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify only "read" was granted (the token omitted "write").
|
||||
issuer := authServer.URL()
|
||||
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Second authorization: 401 with scope="admin" (simulating step-up).
|
||||
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
|
||||
// but NOT "write" (requested but never granted).
|
||||
resp2 := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
}
|
||||
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
|
||||
req2 := httptest.NewRequest("GET", resourceURL, nil)
|
||||
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
|
||||
t.Fatalf("Second Authorize failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
|
||||
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
||||
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTokenAuthMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return nil
|
||||
}
|
||||
|
||||
if baseURL.Path == "" {
|
||||
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
||||
// issuer identifier before the well-known suffix is inserted, so an issuer
|
||||
// such as "https://auth.example.com/" has no path component at all.
|
||||
issuerPath := strings.Trim(baseURL.Path, "/")
|
||||
|
||||
if issuerPath == "" {
|
||||
// "OAuth 2.0 Authorization Server Metadata".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
|
@ -54,15 +59,14 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
|||
return urls
|
||||
}
|
||||
|
||||
originalPath := baseURL.Path
|
||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
||||
urls = append(urls, baseURL.String())
|
||||
// "OpenID Connect Discovery 1.0 with path appending".
|
||||
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
||||
urls = append(urls, baseURL.String())
|
||||
|
||||
return urls
|
||||
|
|
|
|||
|
|
@ -6,11 +6,81 @@ package auth
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
||||
)
|
||||
|
||||
func TestAuthorizationServerMetadataURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
issuerURL string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
issuerURL: "https://auth.example.com",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server",
|
||||
"https://auth.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
{
|
||||
issuerURL: "https://auth.example.com/tenant1/",
|
||||
want: []string{
|
||||
"https://auth.example.com/.well-known/oauth-authorization-server/tenant1",
|
||||
"https://auth.example.com/.well-known/openid-configuration/tenant1",
|
||||
"https://auth.example.com/tenant1/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.issuerURL, func(t *testing.T) {
|
||||
if got := authorizationServerMetadataURLs(tt.issuerURL); !slices.Equal(got, tt.want) {
|
||||
t.Errorf("authorizationServerMetadataURLs(%q) =\n%v\nwant\n%v", tt.issuerURL, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAuthServerMetadataTrailingSlashIssuer checks that an issuer identifier
|
||||
// written with a terminating slash still finds metadata served at the root
|
||||
// well-known path.
|
||||
func TestGetAuthServerMetadataTrailingSlashIssuer(t *testing.T) {
|
||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
||||
ServeOAuthInsertedEndpoint: true,
|
||||
},
|
||||
})
|
||||
s.Start(t)
|
||||
|
||||
got, err := GetAuthServerMetadata(t.Context(), s.URL()+"/", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthServerMetadata() error = %v, want nil", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("GetAuthServerMetadata() got nil, want metadata")
|
||||
}
|
||||
if got.Issuer != s.URL() {
|
||||
t.Errorf("GetAuthServerMetadata() issuer = %q, want %q", got.Issuer, s.URL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthServerMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -1,2 +1,14 @@
|
|||
server: [] # All tests pass!
|
||||
client: [] # All tests pass!
|
||||
client:
|
||||
# OAuth mechanisms not implemented by the Go MCP SDK.
|
||||
- auth/client-credentials-basic
|
||||
- auth/client-credentials-jwt
|
||||
- auth/enterprise-managed-authorization
|
||||
|
||||
# SEP-1932 (DPoP Sender-Constrained Tokens) client-side support not yet
|
||||
# implemented by the Go MCP SDK.
|
||||
- auth/dpop
|
||||
- auth/dpop-nonce
|
||||
|
||||
# SEP-1933 (Workload Identity Federation JWT-bearer) client-side support
|
||||
# not yet implemented by the Go MCP SDK.
|
||||
- auth/wif-jwt-bearer
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue