Compare commits
147 changed files with 3504 additions and 24838 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -5,11 +5,3 @@ updates:
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "monthly"
|
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
|
build-mode: autobuild
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
|
||||||
60
.github/workflows/conformance.yml
vendored
60
.github/workflows/conformance.yml
vendored
|
|
@ -13,62 +13,44 @@ concurrency:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
|
||||||
CONFORMANCE_VERSION: "0.2.0-alpha.11"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-conformance:
|
server-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: Start everything-server
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
run: |
|
||||||
|
go run ./conformance/everything-server/main.go -http=":3001" &
|
||||||
|
# Wait for the server to be ready.
|
||||||
|
timeout 30 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done'
|
||||||
|
- name: "Run conformance tests"
|
||||||
|
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
|
||||||
with:
|
with:
|
||||||
|
mode: server
|
||||||
|
url: http://localhost:3001/mcp
|
||||||
|
suite: active
|
||||||
|
expected-failures: ./conformance/baseline.yml
|
||||||
node-version: 22
|
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:
|
client-conformance:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: "Run conformance tests"
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: modelcontextprotocol/conformance@a2855b03582a6c0b31065ad4d9af248316ce61a3 # v0.1.15
|
||||||
with:
|
with:
|
||||||
|
mode: client
|
||||||
|
command: go run ./conformance/everything-client
|
||||||
|
suite: core
|
||||||
|
expected-failures: ./conformance/baseline.yml
|
||||||
node-version: 22
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Check docs are up-to-date
|
- name: Check docs are up-to-date
|
||||||
run: |
|
run: |
|
||||||
go generate ./...
|
go generate ./...
|
||||||
|
|
|
||||||
12
.github/workflows/nightly.yml
vendored
12
.github/workflows/nightly.yml
vendored
|
|
@ -11,29 +11,25 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vulncheck:
|
vulncheck:
|
||||||
# Don't run scheduled builds on forks.
|
|
||||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Run govulncheck
|
- name: Run govulncheck
|
||||||
uses: golang/govulncheck-action@032d45514ae346b1db93c04b0c90b841c370344f # v1.1.0
|
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
go-package: ./...
|
go-package: ./...
|
||||||
|
|
||||||
conformance:
|
conformance:
|
||||||
# Don't run scheduled builds on forks.
|
|
||||||
if: github.event_name != 'schedule' || github.repository == 'modelcontextprotocol/go-sdk'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
- name: Run server conformance tests
|
- name: Run server conformance tests
|
||||||
|
|
|
||||||
26
.github/workflows/publish-docs.yml
vendored
26
.github/workflows/publish-docs.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Publish Docs
|
name: Publish Docs to GitHub Pages
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
|
|
@ -13,20 +13,12 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- name: Check out code
|
||||||
- name: Configure Git Credentials
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
run: |
|
- name: Deploy to GitHub Pages
|
||||||
git config user.name github-actions[bot]
|
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4
|
||||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
|
||||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
publish_dir: ./docs
|
||||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
publish_branch: gh-pages
|
||||||
with:
|
enable_jekyll: true
|
||||||
key: mkdocs-material-${{ env.cache_id }}
|
|
||||||
path: .cache
|
|
||||||
restore-keys: |
|
|
||||||
mkdocs-material-
|
|
||||||
- run: pip install mkdocs-material
|
|
||||||
- run: mkdocs gh-deploy --force
|
|
||||||
|
|
|
||||||
8
.github/workflows/scorecard.yml
vendored
8
.github/workflows/scorecard.yml
vendored
|
|
@ -34,12 +34,12 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: "Run analysis"
|
- name: "Run analysis"
|
||||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||||
with:
|
with:
|
||||||
results_file: results.sarif
|
results_file: results.sarif
|
||||||
results_format: 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
|
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||||
# format to the repository Actions tab.
|
# format to the repository Actions tab.
|
||||||
- name: "Upload artifact"
|
- name: "Upload artifact"
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: SARIF file
|
name: SARIF file
|
||||||
path: results.sarif
|
path: results.sarif
|
||||||
|
|
@ -73,6 +73,6 @@ jobs:
|
||||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
|
||||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
|
|
@ -14,9 +14,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "^1.26"
|
go-version: "^1.26"
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
- name: Run Go vet
|
- name: Run Go vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
- name: Run staticcheck
|
- name: Run staticcheck
|
||||||
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1
|
uses: dominikh/staticcheck-action@024238d2898c874f26d723e7d0ff4308c35589a2 # v1
|
||||||
with:
|
with:
|
||||||
install-go: false
|
install-go: false
|
||||||
version: "v0.6.1"
|
version: "v0.6.1"
|
||||||
|
|
@ -43,9 +43,9 @@ jobs:
|
||||||
go: ["1.25", "1.26"]
|
go: ["1.25", "1.26"]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|
@ -55,9 +55,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
- name: Test with -race
|
- name: Test with -race
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
<!-- Autogenerated by weave; DO NOT EDIT -->
|
|
||||||
# Contributing to the Go MCP SDK
|
# Contributing to the Go MCP SDK
|
||||||
|
|
||||||
Thank you for your interest in contributing! The Go SDK needs active
|
Thank you for your interest in contributing! The Go SDK needs active
|
||||||
|
|
@ -7,31 +6,11 @@ new and emerging use-cases. We welcome all forms of contribution, from filing
|
||||||
and reviewing issues, to contributing fixes, to proposing and implementing new
|
and reviewing issues, to contributing fixes, to proposing and implementing new
|
||||||
features.
|
features.
|
||||||
|
|
||||||
As described in the [design document](https://github.com/modelcontextprotocol/go-sdk/blob/main/design/design.md), it is important for
|
As described in the [design document](./design/design.md), it is important for
|
||||||
the MCP SDK to remain idiomatic, future-proof, and extensible. The process
|
the MCP SDK to remain idiomatic, future-proof, and extensible. The process
|
||||||
described here is intended to ensure that the SDK evolves safely and
|
described here is intended to ensure that the SDK evolves safely and
|
||||||
transparently, while adhering to these goals.
|
transparently, while adhering to these goals.
|
||||||
|
|
||||||
1. [Development setup](#development-setup)
|
|
||||||
1. [Conformance tests](#conformance-tests)
|
|
||||||
1. [Filing issues](#filing-issues)
|
|
||||||
1. [Bugs](#bugs)
|
|
||||||
1. [Proposals](#proposals)
|
|
||||||
1. [Design discussion](#design-discussion)
|
|
||||||
1. [Contributing code](#contributing-code)
|
|
||||||
1. [Adding and updating dependencies](#adding-and-updating-dependencies)
|
|
||||||
1. [Updating the README](#updating-the-readme)
|
|
||||||
1. [Versioning](#versioning)
|
|
||||||
1. [What counts as a breaking change](#what-counts-as-a-breaking-change)
|
|
||||||
1. [Deprecation](#deprecation)
|
|
||||||
1. [Timeouts](#timeouts)
|
|
||||||
1. [Code of conduct](#code-of-conduct)
|
|
||||||
1. [Governance](#governance)
|
|
||||||
1. [Working Group meetings](#working-group-meetings)
|
|
||||||
1. [Discord](#discord)
|
|
||||||
1. [Antitrust considerations](#antitrust-considerations)
|
|
||||||
|
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
This module can be built and tested using the standard Go toolchain. Run `go
|
This module can be built and tested using the standard Go toolchain. Run `go
|
||||||
|
|
@ -191,57 +170,6 @@ The CI system will automatically check that the README is up-to-date by running
|
||||||
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
|
`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.
|
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
|
## Timeouts
|
||||||
|
|
||||||
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
If a contributor hasn't responded to issue questions or PR comments in two weeks,
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -34,24 +34,16 @@ contains feature documentation, mapping the MCP spec to the packages above.
|
||||||
|
|
||||||
The following table shows which versions of the Go SDK support which versions of the MCP specification:
|
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 |
|
| 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+ | 2025-11-25\* | 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.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 |
|
||||||
| v1.0.0 - v1.1.0 | 2025-06-18 | 2025-06-18, 2025-03-26, 2024-11-05 |
|
|
||||||
|
|
||||||
\* Client side OAuth has experimental support.
|
\* Client side OAuth has experimental support.
|
||||||
|
|
||||||
\*\* Partial support for 2025-11-25 (client side OAuth and Sampling with tools not available).
|
\*\* 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
|
New releases of the SDK target only supported versions of Go. See
|
||||||
https://go.dev/doc/devel/release#policy for more information.
|
https://go.dev/doc/devel/release#policy for more information.
|
||||||
|
|
||||||
|
|
|
||||||
42
auth/auth.go
42
auth/auth.go
|
|
@ -47,34 +47,6 @@ type RequireBearerTokenOptions struct {
|
||||||
ResourceMetadataURL string
|
ResourceMetadataURL string
|
||||||
// The required scopes.
|
// The required scopes.
|
||||||
Scopes []string
|
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{}
|
type tokenInfoKey struct{}
|
||||||
|
|
@ -157,17 +129,11 @@ func verify(req *http.Request, verifier TokenVerifier, opts *RequireBearerTokenO
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts == nil {
|
// Check expiration.
|
||||||
opts = &RequireBearerTokenOptions{}
|
|
||||||
}
|
|
||||||
// Check expiration, with optional clock-skew tolerance. Skew only applies
|
|
||||||
// when an expiration is present; a missing expiration is governed solely by
|
|
||||||
// AllowMissingExpiration.
|
|
||||||
if tokenInfo.Expiration.IsZero() {
|
if tokenInfo.Expiration.IsZero() {
|
||||||
if !opts.AllowMissingExpiration {
|
return nil, "token missing expiration", http.StatusUnauthorized
|
||||||
return nil, "token missing expiration", http.StatusUnauthorized
|
}
|
||||||
}
|
if tokenInfo.Expiration.Before(time.Now()) {
|
||||||
} else if tokenInfo.Expiration.Add(opts.ClockSkew).Before(time.Now()) {
|
|
||||||
return nil, "token expired", http.StatusUnauthorized
|
return nil, "token expired", http.StatusUnauthorized
|
||||||
}
|
}
|
||||||
return tokenInfo, "", 0
|
return tokenInfo, "", 0
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by the license
|
|
||||||
// that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package auth_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// savingTokenSource is an oauth2.TokenSource that passes the oauth2 config and
|
|
||||||
// token to the given saver function each time the access token value changes.
|
|
||||||
type savingTokenSource struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
src oauth2.TokenSource
|
|
||||||
saver func(*oauth2.Config, *oauth2.Token) error
|
|
||||||
config *oauth2.Config
|
|
||||||
accessToken string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *savingTokenSource) Token() (*oauth2.Token, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
tok, err := s.src.Token()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.accessToken != tok.AccessToken {
|
|
||||||
s.accessToken = tok.AccessToken
|
|
||||||
// This saver implementation always returns nil.
|
|
||||||
_ = s.saver(s.config, tok)
|
|
||||||
}
|
|
||||||
return tok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSavingTokenSource persists OAuth 2.0 sessions by intercepting token
|
|
||||||
// changes from the wrapped oauth2.TokenSource. When this wrapper detects an
|
|
||||||
// access token change, it calls the provided session saver with the
|
|
||||||
// oauth2.Config and the new oauth2.Token.
|
|
||||||
//
|
|
||||||
// initial is an optional access token the caller may already hold (such as a
|
|
||||||
// token loaded from storage). It initialises the wrapper's state so that if
|
|
||||||
// the first wrapped.Token() call returns the same token, does not trigger a
|
|
||||||
// redundant call to saver(). Pass nil when there is no existing token, in
|
|
||||||
// which case the first token produced by wrapped.Token() is saved.
|
|
||||||
func NewSavingTokenSource(wrapped oauth2.TokenSource, config *oauth2.Config, initial *oauth2.Token, saver func(*oauth2.Config, *oauth2.Token) error) oauth2.TokenSource {
|
|
||||||
if wrapped == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if saver == nil {
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
var accessToken string
|
|
||||||
if initial != nil {
|
|
||||||
accessToken = initial.AccessToken
|
|
||||||
}
|
|
||||||
return &savingTokenSource{
|
|
||||||
src: wrapped,
|
|
||||||
saver: saver,
|
|
||||||
config: config,
|
|
||||||
accessToken: accessToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionStore is an in-memory OAuth 2.0 session store used to demonstrate
|
|
||||||
// persistence. A production implementation would persist the session to disk or
|
|
||||||
// a secret store.
|
|
||||||
type sessionStore struct {
|
|
||||||
config *oauth2.Config
|
|
||||||
token *oauth2.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
// save persists the given OAuth 2.0 config and token.
|
|
||||||
func (s *sessionStore) save(config *oauth2.Config, token *oauth2.Token) error {
|
|
||||||
fmt.Printf("Saving token: %s\n", token.AccessToken)
|
|
||||||
s.config = config
|
|
||||||
s.token = token
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore loads a previously persisted OAuth 2.0 session, if one exists.
|
|
||||||
func (s *sessionStore) restore() (*oauth2.Config, *oauth2.Token, error) {
|
|
||||||
if s.config != nil && s.token != nil {
|
|
||||||
fmt.Println("Restoring session.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("No session found to restore.")
|
|
||||||
}
|
|
||||||
return s.config, s.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMockAuthServer returns an httptest.Server that simulates both an MCP
|
|
||||||
// resource server requiring authorization and its OAuth authorization server.
|
|
||||||
func newMockAuthServer() *httptest.Server {
|
|
||||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/.well-known/oauth-authorization-server":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = fmt.Fprintf(w, `{"issuer": "http://%s", "authorization_endpoint": "http://%s/auth", "token_endpoint": "http://%s/token", "code_challenge_methods_supported": ["S256"]}`, r.Host, r.Host, r.Host)
|
|
||||||
case "/token":
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "bearer"}`))
|
|
||||||
default:
|
|
||||||
// The mock MCP endpoint returns 401 until the client presents a valid
|
|
||||||
// bearer token.
|
|
||||||
if r.Header.Get("Authorization") == "Bearer mock-token" {
|
|
||||||
// A real server would return a valid MCP message here. The empty
|
|
||||||
// response causes Connect to return an error after authorization,
|
|
||||||
// which is ignored for this example which demonstrates token
|
|
||||||
// persistence only.
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// This example shows how OAuth2 session persistence might be implemented. It
|
|
||||||
// connects twice using a shared in-memory session store: the first connection
|
|
||||||
// authorizes and saves the session, and the second restores and reuses it.
|
|
||||||
func Example_persistence() {
|
|
||||||
// Simulate an MCP server that requires authorization and an OAuth server.
|
|
||||||
mockServer := newMockAuthServer()
|
|
||||||
defer mockServer.Close()
|
|
||||||
|
|
||||||
// store persists the OAuth2 session across both connections.
|
|
||||||
store := &sessionStore{}
|
|
||||||
|
|
||||||
// connect performs a single client connection, restoring any saved session
|
|
||||||
// beforehand and saving any new session acquired during authorization.
|
|
||||||
connect := func() {
|
|
||||||
// Load the OAuth2 session if available, and use it.
|
|
||||||
var initialTS oauth2.TokenSource
|
|
||||||
cfg, tok, err := store.restore()
|
|
||||||
if err == nil && cfg != nil && tok != nil {
|
|
||||||
initialTS = NewSavingTokenSource(
|
|
||||||
cfg.TokenSource(context.Background(), tok),
|
|
||||||
cfg, tok, store.save,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure and initialize the AuthorizationCodeHandler with a
|
|
||||||
// NewTokenSource that saves the session when the access token changes,
|
|
||||||
// and the InitialTokenSource set to the session loaded via restore().
|
|
||||||
config := &auth.AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "example",
|
|
||||||
},
|
|
||||||
Client: mockServer.Client(),
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
||||||
fmt.Println("No token source found. Transport is calling Authorize()...")
|
|
||||||
// Extract the generated state from the authorization URL
|
|
||||||
u, _ := url.Parse(args.URL)
|
|
||||||
state := u.Query().Get("state")
|
|
||||||
return &auth.AuthorizationResult{Code: "mock-code", State: state}, nil
|
|
||||||
},
|
|
||||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
|
||||||
// This save implementation always returns nil.
|
|
||||||
_ = store.save(cfg, token)
|
|
||||||
return NewSavingTokenSource(
|
|
||||||
cfg.TokenSource(ctx, token), cfg, token, store.save,
|
|
||||||
), nil
|
|
||||||
},
|
|
||||||
InitialTokenSource: initialTS,
|
|
||||||
}
|
|
||||||
handler, err := auth.NewAuthorizationCodeHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating handler: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the constructed handler on a transport.
|
|
||||||
transport := &mcp.StreamableClientTransport{
|
|
||||||
Endpoint: mockServer.URL + "/sse",
|
|
||||||
OAuthHandler: handler,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a client and attempt to connect using the configured
|
|
||||||
// transport. The transport will automatically:
|
|
||||||
// 1. Call TokenSource() to check for an existing session. This will
|
|
||||||
// return InitialTokenSource, if set.
|
|
||||||
// 2. Try the MCP endpoint, and encounter a 401 response from the mock
|
|
||||||
// server.
|
|
||||||
// 3. Call Authorize() to perform the OAuth flow, which calls
|
|
||||||
// NewTokenSource. In this example NewTokenSource saves the newly
|
|
||||||
// acquired session.
|
|
||||||
// 4. Retry the MCP endpoint with a valid bearer token and get a 200.
|
|
||||||
client := mcp.NewClient(
|
|
||||||
&mcp.Implementation{Name: "example", Version: "1.0.0"}, nil,
|
|
||||||
)
|
|
||||||
// Response ignored: this example asserts authorization only.
|
|
||||||
_, _ = client.Connect(context.Background(), transport, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first connection has no saved session, so it authorizes and saves.
|
|
||||||
fmt.Println("--- First connect ---")
|
|
||||||
connect()
|
|
||||||
// The second connection restores the saved session and reuses it, so no
|
|
||||||
// further authorization or save occurs.
|
|
||||||
fmt.Println("--- Second connect ---")
|
|
||||||
connect()
|
|
||||||
|
|
||||||
// Output:
|
|
||||||
// --- First connect ---
|
|
||||||
// No session found to restore.
|
|
||||||
// No token source found. Transport is calling Authorize()...
|
|
||||||
// Saving token: mock-token
|
|
||||||
// --- Second connect ---
|
|
||||||
// Restoring session.
|
|
||||||
}
|
|
||||||
|
|
@ -62,11 +62,6 @@ func TestVerify(t *testing.T) {
|
||||||
"no expiration", nil, "Bearer noexp",
|
"no expiration", nil, "Bearer noexp",
|
||||||
"token missing expiration", 401,
|
"token missing expiration", 401,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"no expiration with AllowMissingExpiration accepts",
|
|
||||||
&RequireBearerTokenOptions{AllowMissingExpiration: true}, "Bearer noexp",
|
|
||||||
"", 0,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"expired", nil, "Bearer expired",
|
"expired", nil, "Bearer expired",
|
||||||
"token expired", 401,
|
"token expired", 401,
|
||||||
|
|
@ -289,63 +284,3 @@ func TestRequireBearerToken(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRequireBearerToken_ClockSkew verifies that the ClockSkew option
|
|
||||||
// extends the expiration check tolerance: a token whose Expiration is in the
|
|
||||||
// recent past is accepted iff the elapsed interval is within ClockSkew.
|
|
||||||
func TestRequireBearerToken_ClockSkew(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
clockSkew time.Duration
|
|
||||||
expiredAgo time.Duration
|
|
||||||
wantStatus int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no skew, fresh token accepted",
|
|
||||||
clockSkew: 0,
|
|
||||||
expiredAgo: -time.Minute, // expires in 1 minute
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no skew, expired token rejected",
|
|
||||||
clockSkew: 0,
|
|
||||||
expiredAgo: 5 * time.Second, // expired 5s ago
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with skew, recently-expired token accepted",
|
|
||||||
clockSkew: 30 * time.Second,
|
|
||||||
expiredAgo: 5 * time.Second,
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "with skew, token expired beyond tolerance rejected",
|
|
||||||
clockSkew: 10 * time.Second,
|
|
||||||
expiredAgo: 30 * time.Second,
|
|
||||||
wantStatus: http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
verifier := func(_ context.Context, _ string, _ *http.Request) (*TokenInfo, error) {
|
|
||||||
return &TokenInfo{Expiration: time.Now().Add(-tt.expiredAgo)}, nil
|
|
||||||
}
|
|
||||||
handler := RequireBearerToken(verifier, &RequireBearerTokenOptions{
|
|
||||||
ClockSkew: tt.clockSkew,
|
|
||||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer anything")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != tt.wantStatus {
|
|
||||||
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/util"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
@ -37,10 +34,6 @@ type ClientIDMetadataDocumentConfig struct {
|
||||||
type DynamicClientRegistrationConfig struct {
|
type DynamicClientRegistrationConfig struct {
|
||||||
// Metadata to be used in dynamic client registration request as per
|
// Metadata to be used in dynamic client registration request as per
|
||||||
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
// https://datatracker.ietf.org/doc/html/rfc7591#section-2.
|
||||||
//
|
|
||||||
// If Metadata.ApplicationType is empty, it will be inferred from
|
|
||||||
// Metadata.RedirectURIs. When set, it will be validated against the inferred type
|
|
||||||
// and an error will be returned if they conflict.
|
|
||||||
Metadata *oauthex.ClientRegistrationMetadata
|
Metadata *oauthex.ClientRegistrationMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,12 +44,6 @@ type AuthorizationResult struct {
|
||||||
Code string
|
Code string
|
||||||
// State string returned by the authorization server.
|
// State string returned by the authorization server.
|
||||||
State string
|
State string
|
||||||
// Iss is the issuer identifier returned by the authorization server in the
|
|
||||||
// authorization response per [RFC 9207]. The AuthorizationCodeFetcher should
|
|
||||||
// populate this from the "iss" query parameter in the redirect URI if present.
|
|
||||||
//
|
|
||||||
// [RFC 9207]: https://www.rfc-editor.org/rfc/rfc9207
|
|
||||||
Iss string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
|
// AuthorizationArgs is the input to [AuthorizationCodeFetcher].
|
||||||
|
|
@ -98,37 +85,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
// See [AuthorizationCodeFetcher] for details.
|
// See [AuthorizationCodeFetcher] for details.
|
||||||
AuthorizationCodeFetcher AuthorizationCodeFetcher
|
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.
|
// Client is an optional HTTP client to use for HTTP requests.
|
||||||
// It is used for the following requests:
|
// It is used for the following requests:
|
||||||
// - Fetching Protected Resource Metadata
|
// - Fetching Protected Resource Metadata
|
||||||
|
|
@ -141,24 +97,6 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
// https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#server-side-request-forgery-ssrf
|
||||||
// If not provided, http.DefaultClient will be used.
|
// If not provided, http.DefaultClient will be used.
|
||||||
Client *http.Client
|
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
|
// AuthorizationCodeHandler is an implementation of [OAuthHandler] that uses
|
||||||
|
|
@ -166,21 +104,13 @@ type AuthorizationCodeHandlerConfig struct {
|
||||||
type AuthorizationCodeHandler struct {
|
type AuthorizationCodeHandler struct {
|
||||||
config *AuthorizationCodeHandlerConfig
|
config *AuthorizationCodeHandlerConfig
|
||||||
|
|
||||||
// mu protects concurrent access to tokenSource and grantedScopes.
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
// tokenSource is the token source to use for authorization.
|
// tokenSource is the token source to use for authorization.
|
||||||
tokenSource oauth2.TokenSource
|
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)
|
var _ OAuthHandler = (*AuthorizationCodeHandler)(nil)
|
||||||
|
|
||||||
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
func (h *AuthorizationCodeHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
return h.tokenSource, nil
|
return h.tokenSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,12 +150,6 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
||||||
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
} else if !slices.Contains(dCfg.Metadata.RedirectURIs, config.RedirectURL) {
|
||||||
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
return nil, fmt.Errorf("RedirectURL %q is not in the list of allowed redirect URIs for dynamic client registration", config.RedirectURL)
|
||||||
}
|
}
|
||||||
applicationType := inferApplicationType(dCfg.Metadata.RedirectURIs)
|
|
||||||
if dCfg.Metadata.ApplicationType == "" {
|
|
||||||
dCfg.Metadata.ApplicationType = applicationType
|
|
||||||
} else if dCfg.Metadata.ApplicationType != applicationType {
|
|
||||||
return nil, fmt.Errorf("application type %q conflicts with the application type inferred from redirect URIs", dCfg.Metadata.ApplicationType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if config.RedirectURL == "" {
|
if config.RedirectURL == "" {
|
||||||
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
// If the RedirectURL was supposed to be set by the dynamic client registration,
|
||||||
|
|
@ -235,11 +159,7 @@ func NewAuthorizationCodeHandler(config *AuthorizationCodeHandlerConfig) (*Autho
|
||||||
if config.Client == nil {
|
if config.Client == nil {
|
||||||
config.Client = http.DefaultClient
|
config.Client = http.DefaultClient
|
||||||
}
|
}
|
||||||
return &AuthorizationCodeHandler{
|
return &AuthorizationCodeHandler{config: config}, nil
|
||||||
config: config,
|
|
||||||
tokenSource: config.InitialTokenSource,
|
|
||||||
grantedScopes: make(map[string][]string),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNonRootHTTPSURL(u string) bool {
|
func isNonRootHTTPSURL(u string) bool {
|
||||||
|
|
@ -250,36 +170,6 @@ func isNonRootHTTPSURL(u string) bool {
|
||||||
return pu.Scheme == "https" && pu.Path != ""
|
return pu.Scheme == "https" && pu.Path != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// inferApplicationType returns an application type based on the redirect URIs.
|
|
||||||
func inferApplicationType(redirectURIs []string) string {
|
|
||||||
hasNative := false
|
|
||||||
hasWeb := false
|
|
||||||
for _, uri := range redirectURIs {
|
|
||||||
u, err := url.Parse(uri)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch u.Scheme {
|
|
||||||
case "http", "https":
|
|
||||||
if util.IsLoopback(u.Hostname()) {
|
|
||||||
hasNative = true
|
|
||||||
} else {
|
|
||||||
hasWeb = true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
hasNative = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasNative && hasWeb {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if hasNative {
|
|
||||||
return "native"
|
|
||||||
}
|
|
||||||
return "web"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authorize performs the authorization flow.
|
// Authorize performs the authorization flow.
|
||||||
// It is designed to perform the whole Authorization Code Grant flow.
|
// It is designed to perform the whole Authorization Code Grant flow.
|
||||||
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
// On success, [AuthorizationCodeHandler.TokenSource] will return a token source with the fetched token.
|
||||||
|
|
@ -327,33 +217,11 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedScopes := scopesFromChallenges(wwwChallenges)
|
scps := scopesFromChallenges(wwwChallenges)
|
||||||
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
if len(scps) == 0 && len(prm.ScopesSupported) > 0 {
|
||||||
requestedScopes = prm.ScopesSupported
|
scps = prm.ScopesSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let the client adjust the discovered scopes before offline_access and the
|
|
||||||
// step-up union below so neither is affected.
|
|
||||||
if h.config.ScopeFilter != nil {
|
|
||||||
requestedScopes = h.config.ScopeFilter(requestedScopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SEP-2207: when the client desires refresh tokens and the Authorization
|
|
||||||
// Server advertises offline_access support, add it to the requested scopes.
|
|
||||||
if h.config.RequestRefreshToken &&
|
|
||||||
slices.Contains(asm.ScopesSupported, "offline_access") &&
|
|
||||||
!slices.Contains(requestedScopes, "offline_access") {
|
|
||||||
requestedScopes = append(requestedScopes, "offline_access")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate scopes: union previously granted scopes with the newly
|
|
||||||
// challenged scopes so that step-up authorization does not lose
|
|
||||||
// permissions granted in earlier rounds (SEP-2350).
|
|
||||||
h.mu.RLock()
|
|
||||||
granted := h.grantedScopes[asm.Issuer]
|
|
||||||
h.mu.RUnlock()
|
|
||||||
requestedScopes = authutil.UnionScopes(granted, requestedScopes)
|
|
||||||
|
|
||||||
cfg := &oauth2.Config{
|
cfg := &oauth2.Config{
|
||||||
ClientID: resolvedClientConfig.clientID,
|
ClientID: resolvedClientConfig.clientID,
|
||||||
ClientSecret: resolvedClientConfig.clientSecret,
|
ClientSecret: resolvedClientConfig.clientSecret,
|
||||||
|
|
@ -364,7 +232,7 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
AuthStyle: resolvedClientConfig.authStyle,
|
AuthStyle: resolvedClientConfig.authStyle,
|
||||||
},
|
},
|
||||||
RedirectURL: h.config.RedirectURL,
|
RedirectURL: h.config.RedirectURL,
|
||||||
Scopes: requestedScopes,
|
Scopes: scps,
|
||||||
}
|
}
|
||||||
|
|
||||||
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
authRes, err := h.getAuthorizationCode(ctx, cfg, prm.Resource)
|
||||||
|
|
@ -372,16 +240,8 @@ func (h *AuthorizationCodeHandler) Authorize(ctx context.Context, req *http.Requ
|
||||||
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
// Purposefully leaving the error unwrappable so it can be handled by the caller.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateIssuerResponse(authRes.Iss, asm.Issuer, asm.AuthorizationResponseIssParameterSupported, h.config.AcceptUnadvertisedIss); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.exchangeAuthorizationCode(ctx, cfg, authRes, prm.Resource)
|
return 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,
|
// resourceMetadataURLFromChallenges returns a resource metadata URL from the given "WWW-Authenticate" header challenges,
|
||||||
|
|
@ -555,9 +415,6 @@ func (h *AuthorizationCodeHandler) handleRegistration(ctx context.Context, asm *
|
||||||
// 2. Attempt to use pre-registered client configuration.
|
// 2. Attempt to use pre-registered client configuration.
|
||||||
preCfg := h.config.PreregisteredClient
|
preCfg := h.config.PreregisteredClient
|
||||||
if preCfg != nil {
|
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)
|
authStyle := selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported)
|
||||||
clientSecret := ""
|
clientSecret := ""
|
||||||
if preCfg.ClientSecretAuth != nil {
|
if preCfg.ClientSecretAuth != nil {
|
||||||
|
|
@ -620,36 +477,6 @@ func (h *AuthorizationCodeHandler) getAuthorizationCode(ctx context.Context, cfg
|
||||||
}, nil
|
}, 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
|
// exchangeAuthorizationCode exchanges the authorization code for a token
|
||||||
// and stores it in a token source.
|
// and stores it in a token source.
|
||||||
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
|
func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context, cfg *oauth2.Config, authResult *authResult, resourceURL string) error {
|
||||||
|
|
@ -662,52 +489,6 @@ func (h *AuthorizationCodeHandler) exchangeAuthorizationCode(ctx context.Context
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("token exchange failed: %w", err)
|
return fmt.Errorf("token exchange failed: %w", err)
|
||||||
}
|
}
|
||||||
// The token source outlives this authorization request: it is stored on the
|
h.tokenSource = cfg.TokenSource(clientCtx, token)
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"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/internal/oauthtest"
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
@ -78,7 +75,6 @@ func TestAuthorize(t *testing.T) {
|
||||||
return &AuthorizationResult{
|
return &AuthorizationResult{
|
||||||
Code: location.Query().Get("code"),
|
Code: location.Query().Get("code"),
|
||||||
State: location.Query().Get("state"),
|
State: location.Query().Get("state"),
|
||||||
Iss: location.Query().Get("iss"),
|
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -115,227 +111,6 @@ func TestAuthorize(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthorize_RefreshAfterContextCancel verifies that the token source built
|
|
||||||
// by Authorize keeps refreshing after the context passed to Authorize is
|
|
||||||
// cancelled. The token source is stored on the handler and used by the
|
|
||||||
// transport for the whole connection lifetime, whereas Authorize is typically
|
|
||||||
// called from a request- or connect-scoped context. golang.org/x/oauth2
|
|
||||||
// captures the context handed to Config.TokenSource and reuses it for every
|
|
||||||
// refresh, so binding it to the request context made every refresh after the
|
|
||||||
// access token expired fail with "context canceled". Regression test for that.
|
|
||||||
func TestAuthorize_RefreshAfterContextCancel(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
// expires_in below oauth2's 10s expiry delta, so the reuse token source
|
|
||||||
// treats the access token as expired immediately and must refresh.
|
|
||||||
AccessTokenTTL: 1,
|
|
||||||
IssueRefreshToken: true,
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
||||||
resp, err := client.Get(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
location, err := resp.Location()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
|
||||||
}
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: location.Query().Get("code"),
|
|
||||||
State: location.Query().Get("state"),
|
|
||||||
Iss: location.Query().Get("iss"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", "Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource")
|
|
||||||
|
|
||||||
// Authorize under a context that we cancel immediately afterwards, mimicking
|
|
||||||
// the request/connect context the transport passes and that is already done
|
|
||||||
// by the time a later token refresh runs.
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
if err := handler.Authorize(ctx, req, resp); err != nil {
|
|
||||||
t.Fatalf("Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
tokenSource, err := handler.TokenSource(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get token source: %v", err)
|
|
||||||
}
|
|
||||||
// The access token is already expired, so this forces a refresh round-trip.
|
|
||||||
// It must not fail with "context canceled" from the cancelled Authorize ctx.
|
|
||||||
token, err := tokenSource.Token()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("token refresh after Authorize context cancellation failed: %v", err)
|
|
||||||
}
|
|
||||||
if token.AccessToken != "test_access_token_refreshed" {
|
|
||||||
t.Errorf("expected refreshed access token %q, got %q", "test_access_token_refreshed", token.AccessToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorize_ScopeAccumulation(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TokenScopeFunc: func(requestedScope string) string {
|
|
||||||
// Simulate a server that never grants "write".
|
|
||||||
var granted []string
|
|
||||||
for _, s := range strings.Fields(requestedScope) {
|
|
||||||
if s != "write" {
|
|
||||||
granted = append(granted, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(granted, " ")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
var capturedAuthURLs []string
|
|
||||||
noRedirectClient := &http.Client{
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
||||||
ClientSecret: "test_client_secret",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
capturedAuthURLs = append(capturedAuthURLs, args.URL)
|
|
||||||
resp, err := noRedirectClient.Get(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
loc, err := resp.Location()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: loc.Query().Get("code"),
|
|
||||||
State: loc.Query().Get("state"),
|
|
||||||
Iss: loc.Query().Get("iss"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First authorization: 401 with scope="read write".
|
|
||||||
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate",
|
|
||||||
fmt.Sprintf(`Bearer scope="read write", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
|
||||||
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
|
||||||
t.Fatalf("First Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify first auth URL requested "read" and "write".
|
|
||||||
firstURL, err := url.Parse(capturedAuthURLs[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse first auth URL: %v", err)
|
|
||||||
}
|
|
||||||
firstScopes := strings.Fields(firstURL.Query().Get("scope"))
|
|
||||||
if diff := cmp.Diff([]string{"read", "write"}, firstScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
|
||||||
t.Errorf("First auth scopes mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify only "read" was granted (the token omitted "write").
|
|
||||||
issuer := authServer.URL()
|
|
||||||
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
|
||||||
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second authorization: 403 insufficient_scope with scope="admin".
|
|
||||||
// Accumulated scopes should be "read" (previously granted) + "admin" (new).
|
|
||||||
req2 := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp2 := &http.Response{
|
|
||||||
StatusCode: http.StatusForbidden,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
resp2.Header.Set("WWW-Authenticate",
|
|
||||||
fmt.Sprintf(`Bearer error="insufficient_scope", scope="admin", resource_metadata="%s/.well-known/oauth-protected-resource/resource"`, resourceServer.URL))
|
|
||||||
if err := handler.Authorize(context.Background(), req2, resp2); err != nil {
|
|
||||||
t.Fatalf("Second Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify second auth URL accumulated "read" (granted) + "admin" (challenged),
|
|
||||||
// but NOT "write" (requested but never granted).
|
|
||||||
secondURL, err := url.Parse(capturedAuthURLs[1])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse second auth URL: %v", err)
|
|
||||||
}
|
|
||||||
secondScopes := strings.Fields(secondURL.Query().Get("scope"))
|
|
||||||
if diff := cmp.Diff([]string{"admin", "read"}, secondScopes, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
|
||||||
t.Errorf("Second auth scopes mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
func TestAuthorize_ForbiddenUnhandledError(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/resource", nil)
|
||||||
resp := &http.Response{
|
resp := &http.Response{
|
||||||
|
|
@ -704,8 +479,6 @@ func TestHandleRegistration(t *testing.T) {
|
||||||
asm *oauthex.AuthServerMeta
|
asm *oauthex.AuthServerMeta
|
||||||
want *resolvedClientConfig
|
want *resolvedClientConfig
|
||||||
wantError bool
|
wantError bool
|
||||||
issuerMatch bool
|
|
||||||
issuerSuffix string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "ClientIDMetadataDocument",
|
name: "ClientIDMetadataDocument",
|
||||||
|
|
@ -744,79 +517,6 @@ func TestHandleRegistration(t *testing.T) {
|
||||||
authStyle: oauth2.AuthStyleInParams,
|
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",
|
name: "NoneSupported",
|
||||||
handlerConfig: &AuthorizationCodeHandlerConfig{
|
handlerConfig: &AuthorizationCodeHandlerConfig{
|
||||||
|
|
@ -830,10 +530,6 @@ func TestHandleRegistration(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig})
|
s := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{RegistrationConfig: tt.serverConfig})
|
||||||
s.Start(t)
|
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) {
|
tt.handlerConfig.AuthorizationCodeFetcher = func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -853,9 +549,6 @@ func TestHandleRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tt.wantError {
|
|
||||||
t.Fatal("handleRegistration() expected error, got nil")
|
|
||||||
}
|
|
||||||
if got.registrationType != tt.want.registrationType {
|
if got.registrationType != tt.want.registrationType {
|
||||||
t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType)
|
t.Errorf("handleRegistration() registrationType = %v, want %v", got.registrationType, tt.want.registrationType)
|
||||||
}
|
}
|
||||||
|
|
@ -915,551 +608,6 @@ func TestDynamicRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateIssuerResponse(t *testing.T) {
|
|
||||||
const expectedIssuer = "https://auth.example.com"
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
iss string
|
|
||||||
issSupported bool
|
|
||||||
acceptUnadvertisedIss bool
|
|
||||||
wantErr bool
|
|
||||||
wantErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ValidIss",
|
|
||||||
iss: expectedIssuer,
|
|
||||||
issSupported: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "WrongIss",
|
|
||||||
iss: "https://attacker.example.com",
|
|
||||||
issSupported: true,
|
|
||||||
wantErr: true,
|
|
||||||
wantErrContains: "does not match expected issuer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MissingIssWhenRequired",
|
|
||||||
iss: "",
|
|
||||||
issSupported: true,
|
|
||||||
wantErr: true,
|
|
||||||
wantErrContains: "RFC 9207",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MissingIssWhenNotRequired",
|
|
||||||
iss: "",
|
|
||||||
issSupported: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Default / zero-value policy: reject a matching unadvertised iss
|
|
||||||
// (released v1.7.0 behavior).
|
|
||||||
name: "UnadvertisedIssCorrectRejectedByDefault",
|
|
||||||
iss: expectedIssuer,
|
|
||||||
issSupported: false,
|
|
||||||
wantErr: true,
|
|
||||||
wantErrContains: "does not advertise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "UnadvertisedIssCorrectAcceptedWhenOptedIn",
|
|
||||||
iss: expectedIssuer,
|
|
||||||
issSupported: false,
|
|
||||||
acceptUnadvertisedIss: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "UnadvertisedIssWrong",
|
|
||||||
iss: "https://attacker.example.com",
|
|
||||||
issSupported: false,
|
|
||||||
wantErr: true,
|
|
||||||
wantErrContains: "does not match expected issuer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "UnadvertisedIssWrongStillRejectedWhenOptedIn",
|
|
||||||
iss: "https://attacker.example.com",
|
|
||||||
issSupported: false,
|
|
||||||
acceptUnadvertisedIss: true,
|
|
||||||
wantErr: true,
|
|
||||||
wantErrContains: "does not match expected issuer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := validateIssuerResponse(tt.iss, expectedIssuer, tt.issSupported, tt.acceptUnadvertisedIss)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("validateIssuerResponse() = nil, want error containing %q", tt.wantErrContains)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
|
||||||
t.Errorf("validateIssuerResponse() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
t.Fatalf("validateIssuerResponse() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAuthorize_AcceptUnadvertisedIssPlumbing checks that Authorize passes
|
|
||||||
// AuthorizationCodeHandlerConfig.AcceptUnadvertisedIss through to
|
|
||||||
// validateIssuerResponse. The fake server returns iss without advertising
|
|
||||||
// authorization_response_iss_parameter_supported.
|
|
||||||
func TestAuthorize_AcceptUnadvertisedIssPlumbing(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
acceptUnadvertisedIss bool
|
|
||||||
wantErrContains string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ZeroValueRejects",
|
|
||||||
wantErrContains: "does not advertise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "OptInAccepts",
|
|
||||||
acceptUnadvertisedIss: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
UnadvertiseIssParameter: true,
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
AcceptUnadvertisedIss: tt.acceptUnadvertisedIss,
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
||||||
ClientSecret: "test_client_secret",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
client := &http.Client{
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Get(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to visit auth URL: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
location, err := resp.Location()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get location header: %v", err)
|
|
||||||
}
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: location.Query().Get("code"),
|
|
||||||
State: location.Query().Get("state"),
|
|
||||||
Iss: location.Query().Get("iss"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set(
|
|
||||||
"WWW-Authenticate",
|
|
||||||
"Bearer resource_metadata="+resourceServer.URL+"/.well-known/oauth-protected-resource/resource",
|
|
||||||
)
|
|
||||||
err = handler.Authorize(context.Background(), req, resp)
|
|
||||||
if tt.wantErrContains != "" {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Authorize() = nil, want error containing %q", tt.wantErrContains)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
|
||||||
t.Fatalf("Authorize() error = %q, want it to contain %q", err.Error(), tt.wantErrContains)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInferApplicationType(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
redirectURIs []string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "localhost",
|
|
||||||
redirectURIs: []string{"http://localhost:8085/callback"},
|
|
||||||
want: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "127.0.0.1",
|
|
||||||
redirectURIs: []string{"http://127.0.0.1:8085/callback"},
|
|
||||||
want: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 loopback",
|
|
||||||
redirectURIs: []string{"http://[::1]:8085/callback"},
|
|
||||||
want: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom scheme",
|
|
||||||
redirectURIs: []string{"myapp://callback"},
|
|
||||||
want: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "HTTPS remote",
|
|
||||||
redirectURIs: []string{"https://myapp.example.com/callback"},
|
|
||||||
want: "web",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed native and web",
|
|
||||||
redirectURIs: []string{"https://myapp.example.com/callback", "http://localhost:8085/callback"},
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple remote",
|
|
||||||
redirectURIs: []string{"https://app1.example.com/cb", "https://app2.example.com/cb"},
|
|
||||||
want: "web",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := inferApplicationType(tt.redirectURIs)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("inferApplicationType() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplicationTypeInference(t *testing.T) {
|
|
||||||
fetcher := func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
redirectURIs []string
|
|
||||||
initialAppType string
|
|
||||||
wantAppType string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "inferred as native for localhost",
|
|
||||||
redirectURIs: []string{"http://localhost:8085/callback"},
|
|
||||||
wantAppType: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inferred as web for remote",
|
|
||||||
redirectURIs: []string{"https://example.com/callback"},
|
|
||||||
wantAppType: "web",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed native and web URIs sets empty application type",
|
|
||||||
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
|
|
||||||
wantAppType: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit value matching inference is preserved",
|
|
||||||
redirectURIs: []string{"http://localhost:8085/callback"},
|
|
||||||
initialAppType: "native",
|
|
||||||
wantAppType: "native",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit value conflicts with inference returns error",
|
|
||||||
redirectURIs: []string{"http://localhost:8085/callback"},
|
|
||||||
initialAppType: "web",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit value when inference is ambiguous returns error",
|
|
||||||
redirectURIs: []string{"https://example.com/callback", "http://localhost:8085/callback"},
|
|
||||||
initialAppType: "web",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid URI returns empty application type",
|
|
||||||
redirectURIs: []string{"http://%/"},
|
|
||||||
wantAppType: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cfg := &AuthorizationCodeHandlerConfig{
|
|
||||||
DynamicClientRegistrationConfig: &DynamicClientRegistrationConfig{
|
|
||||||
Metadata: &oauthex.ClientRegistrationMetadata{
|
|
||||||
RedirectURIs: tt.redirectURIs,
|
|
||||||
ApplicationType: tt.initialAppType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: fetcher,
|
|
||||||
}
|
|
||||||
_, err := NewAuthorizationCodeHandler(cfg)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
if tt.wantErr {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
got := cfg.DynamicClientRegistrationConfig.Metadata.ApplicationType
|
|
||||||
if got != tt.wantAppType {
|
|
||||||
t.Errorf("ApplicationType = %q, want %q", got, tt.wantAppType)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorize_OfflineAccessScope(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
requestRefreshToken bool
|
|
||||||
asScopesSupported []string
|
|
||||||
challengeScopes string
|
|
||||||
wantOfflineAccess bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "AddedWhenASSupportsAndClientRequests",
|
|
||||||
requestRefreshToken: true,
|
|
||||||
asScopesSupported: []string{"openid", "offline_access"},
|
|
||||||
wantOfflineAccess: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NotAddedWhenClientDoesNotRequest",
|
|
||||||
requestRefreshToken: false,
|
|
||||||
asScopesSupported: []string{"openid", "offline_access"},
|
|
||||||
wantOfflineAccess: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NotAddedWhenASDoesNotSupport",
|
|
||||||
requestRefreshToken: true,
|
|
||||||
asScopesSupported: []string{"openid"},
|
|
||||||
wantOfflineAccess: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NotDuplicatedWhenAlreadyInScopes",
|
|
||||||
requestRefreshToken: true,
|
|
||||||
asScopesSupported: []string{"openid", "offline_access"},
|
|
||||||
challengeScopes: "read offline_access",
|
|
||||||
wantOfflineAccess: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
ScopesSupported: tt.asScopesSupported,
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
var capturedAuthURL string
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
|
||||||
ClientSecret: "test_client_secret",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RequestRefreshToken: tt.requestRefreshToken,
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
capturedAuthURL = args.URL
|
|
||||||
return nil, fmt.Errorf("stop after capturing URL")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
wwwAuth := "Bearer resource_metadata=" + resourceServer.URL + "/.well-known/oauth-protected-resource/resource"
|
|
||||||
if tt.challengeScopes != "" {
|
|
||||||
wwwAuth += fmt.Sprintf(", scope=%q", tt.challengeScopes)
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", wwwAuth)
|
|
||||||
|
|
||||||
// Authorize will fail at the fetcher, but we only care about the URL.
|
|
||||||
handler.Authorize(context.Background(), req, resp)
|
|
||||||
|
|
||||||
if capturedAuthURL == "" {
|
|
||||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
|
||||||
}
|
|
||||||
u, err := url.Parse(capturedAuthURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
|
||||||
}
|
|
||||||
scopes := strings.Fields(u.Query().Get("scope"))
|
|
||||||
hasOfflineAccess := slices.Contains(scopes, "offline_access")
|
|
||||||
if hasOfflineAccess != tt.wantOfflineAccess {
|
|
||||||
t.Errorf("offline_access in scopes = %v, want %v (scopes: %v)", hasOfflineAccess, tt.wantOfflineAccess, scopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When offline_access was already present in challenge scopes,
|
|
||||||
// verify it appears exactly once.
|
|
||||||
if tt.wantOfflineAccess {
|
|
||||||
count := 0
|
|
||||||
for _, s := range scopes {
|
|
||||||
if s == "offline_access" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("offline_access appears %d times in scopes, want 1", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthorize_ScopeFilter(t *testing.T) {
|
|
||||||
// advertised is delivered via the WWW-Authenticate "scope" challenge, which
|
|
||||||
// the handler treats as the discovered scopes passed to ScopeFilter.
|
|
||||||
const advertised = "gmail.metadata gmail.readonly gmail.compose"
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
filter func(discovered []string) []string
|
|
||||||
want []string // requested scopes, order-independent
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil filter leaves scopes unchanged",
|
|
||||||
filter: nil,
|
|
||||||
want: []string{"gmail.metadata", "gmail.readonly", "gmail.compose"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter drops a scope",
|
|
||||||
filter: func(d []string) []string {
|
|
||||||
return slices.DeleteFunc(slices.Clone(d), func(s string) bool { return s == "gmail.metadata" })
|
|
||||||
},
|
|
||||||
want: []string{"gmail.readonly", "gmail.compose"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter replaces the set entirely",
|
|
||||||
filter: func([]string) []string { return []string{"gmail.readonly"} },
|
|
||||||
want: []string{"gmail.readonly"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test_client_id": {
|
|
||||||
Secret: "test_client_secret",
|
|
||||||
RedirectURIs: []string{"http://localhost:12345/callback"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
var capturedAuthURL string
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test_client_secret"},
|
|
||||||
},
|
|
||||||
ScopeFilter: tt.filter,
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
capturedAuthURL = args.URL
|
|
||||||
return nil, fmt.Errorf("stop after capturing URL")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, resourceURL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", fmt.Sprintf(
|
|
||||||
"Bearer resource_metadata=%s/.well-known/oauth-protected-resource/resource, scope=%q",
|
|
||||||
resourceServer.URL, advertised))
|
|
||||||
|
|
||||||
handler.Authorize(context.Background(), req, resp)
|
|
||||||
|
|
||||||
if capturedAuthURL == "" {
|
|
||||||
t.Fatal("AuthorizationCodeFetcher was not called")
|
|
||||||
}
|
|
||||||
u, err := url.Parse(capturedAuthURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse captured auth URL: %v", err)
|
|
||||||
}
|
|
||||||
// Compare as a set: UnionScopes (applied downstream) returns map keys,
|
|
||||||
// so the order of the requested scope parameter is not deterministic.
|
|
||||||
got := strings.Fields(u.Query().Get("scope"))
|
|
||||||
want := slices.Clone(tt.want)
|
|
||||||
slices.Sort(got)
|
|
||||||
slices.Sort(want)
|
|
||||||
if !slices.Equal(got, want) {
|
|
||||||
t.Errorf("requested scopes = %v, want %v (any order)", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
// validConfig for test to create an AuthorizationCodeHandler using its constructor.
|
||||||
// Values that are relevant to the test should be set explicitly.
|
// Values that are relevant to the test should be set explicitly.
|
||||||
func validConfig() *AuthorizationCodeHandlerConfig {
|
func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
|
|
@ -1471,92 +619,3 @@ func validConfig() *AuthorizationCodeHandlerConfig {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewTokenSource(t *testing.T) {
|
|
||||||
// mock the /token endpoint to successfully return an access token on code exchange
|
|
||||||
mockTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/token" {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(`{"access_token": "test_token", "token_type": "bearer"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer mockTS.Close()
|
|
||||||
|
|
||||||
// configure the handler and set NewTokenSource
|
|
||||||
var called bool
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client",
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
u, _ := url.Parse(args.URL)
|
|
||||||
return &AuthorizationResult{
|
|
||||||
Code: "test_code",
|
|
||||||
State: u.Query().Get("state"),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
NewTokenSource: func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
|
|
||||||
called = true
|
|
||||||
return oauth2.StaticTokenSource(token), nil
|
|
||||||
},
|
|
||||||
Client: mockTS.Client(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate a 401 response from a resource server.
|
|
||||||
// The WWW-Authenticate: Bearer header triggers the authorization logic.
|
|
||||||
req := httptest.NewRequest(http.MethodGet, mockTS.URL, nil)
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
Request: req,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", "Bearer")
|
|
||||||
|
|
||||||
// Authorize and confirm NewTokenSource was called.
|
|
||||||
err = handler.Authorize(t.Context(), req, resp)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if !called {
|
|
||||||
t.Error("expected NewTokenSource to be called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitialTokenSource(t *testing.T) {
|
|
||||||
handler, err := NewAuthorizationCodeHandler(&AuthorizationCodeHandlerConfig{
|
|
||||||
RedirectURL: "http://localhost:12345/callback",
|
|
||||||
PreregisteredClient: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test_client_id",
|
|
||||||
},
|
|
||||||
AuthorizationCodeFetcher: func(ctx context.Context, args *AuthorizationArgs) (*AuthorizationResult, error) {
|
|
||||||
return nil, nil
|
|
||||||
},
|
|
||||||
InitialTokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "set_token"}),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewAuthorizationCodeHandler failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get token source: %v", err)
|
|
||||||
}
|
|
||||||
if ts == nil {
|
|
||||||
t.Fatal("expected token source to be non-nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := ts.Token()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to get Token: %v", err)
|
|
||||||
}
|
|
||||||
if tok.AccessToken != "set_token" {
|
|
||||||
t.Errorf("expected access token 'set_token', got '%s'", tok.AccessToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by the license
|
|
||||||
// that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package extauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/authutil"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClientCredentialsHandlerConfig is the configuration for [ClientCredentialsHandler].
|
|
||||||
type ClientCredentialsHandlerConfig struct {
|
|
||||||
// Credentials contains the pre-registered client ID and secret.
|
|
||||||
// REQUIRED. Both ClientID and ClientSecretAuth must be set, since the
|
|
||||||
// client credentials grant requires a confidential client.
|
|
||||||
Credentials *oauthex.ClientCredentials
|
|
||||||
|
|
||||||
// HTTPClient is an optional HTTP client for customization.
|
|
||||||
// If nil, http.DefaultClient is used.
|
|
||||||
// OPTIONAL.
|
|
||||||
HTTPClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientCredentialsHandler is an implementation of [auth.OAuthHandler] that
|
|
||||||
// uses the OAuth 2.0 Client Credentials grant (RFC 6749 Section 4.4) to
|
|
||||||
// obtain access tokens.
|
|
||||||
//
|
|
||||||
// This handler is intended for service-to-service authentication where the
|
|
||||||
// client has pre-registered credentials (client ID and secret) and does not
|
|
||||||
// require user interaction. It bypasses both dynamic client registration and
|
|
||||||
// the authorization code flow.
|
|
||||||
//
|
|
||||||
// The token endpoint and scopes are discovered automatically via Protected
|
|
||||||
// Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414),
|
|
||||||
// following the ext-auth specification SEP-1046.
|
|
||||||
type ClientCredentialsHandler struct {
|
|
||||||
config *ClientCredentialsHandlerConfig
|
|
||||||
tokenSource oauth2.TokenSource
|
|
||||||
|
|
||||||
// grantedScopes maps authorization server issuer to the list of scopes granted by that issuer.
|
|
||||||
grantedScopes map[string][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile-time check that ClientCredentialsHandler implements auth.OAuthHandler.
|
|
||||||
var _ auth.OAuthHandler = (*ClientCredentialsHandler)(nil)
|
|
||||||
|
|
||||||
// NewClientCredentialsHandler creates a new ClientCredentialsHandler.
|
|
||||||
// It validates the configuration and returns an error if invalid.
|
|
||||||
func NewClientCredentialsHandler(config *ClientCredentialsHandlerConfig) (*ClientCredentialsHandler, error) {
|
|
||||||
if config == nil {
|
|
||||||
return nil, fmt.Errorf("config must be provided")
|
|
||||||
}
|
|
||||||
if config.Credentials == nil {
|
|
||||||
return nil, fmt.Errorf("credentials are required")
|
|
||||||
}
|
|
||||||
if err := config.Credentials.Validate(); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid credentials: %w", err)
|
|
||||||
}
|
|
||||||
if config.Credentials.ClientSecretAuth == nil {
|
|
||||||
return nil, fmt.Errorf("clientSecretAuth is required for client credentials grant")
|
|
||||||
}
|
|
||||||
return &ClientCredentialsHandler{
|
|
||||||
config: config,
|
|
||||||
grantedScopes: make(map[string][]string),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenSource returns the token source for outgoing requests.
|
|
||||||
// Returns nil if authorization has not been performed yet.
|
|
||||||
func (h *ClientCredentialsHandler) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
|
|
||||||
return h.tokenSource, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authorize performs the Client Credentials grant to obtain an access token.
|
|
||||||
// It is called when a request fails with 401 or 403.
|
|
||||||
//
|
|
||||||
// The flow follows the ext-auth specification SEP-1046:
|
|
||||||
// 1. Discover Protected Resource Metadata from the request URL
|
|
||||||
// 2. Discover Authorization Server Metadata from PRM
|
|
||||||
// 3. Exchange client credentials for an access token at the token endpoint
|
|
||||||
func (h *ClientCredentialsHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
defer io.Copy(io.Discard, resp.Body)
|
|
||||||
|
|
||||||
httpClient := h.config.HTTPClient
|
|
||||||
if httpClient == nil {
|
|
||||||
httpClient = http.DefaultClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Discover Protected Resource Metadata.
|
|
||||||
wwwChallenges, err := oauthex.ParseWWWAuthenticate(resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to parse WWW-Authenticate header: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
prm, err := getProtectedResourceMetadata(ctx, wwwChallenges, req.URL.String(), httpClient)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(prm.AuthorizationServers) == 0 {
|
|
||||||
return fmt.Errorf("protected resource metadata has no authorization servers specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Discover Authorization Server Metadata.
|
|
||||||
asm, err := auth.GetAuthServerMetadata(ctx, prm.AuthorizationServers[0], httpClient)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get authorization server metadata: %w", err)
|
|
||||||
}
|
|
||||||
if asm == nil {
|
|
||||||
// Fallback to 2025-03-26 spec: predefined endpoints.
|
|
||||||
authServerURL := prm.AuthorizationServers[0]
|
|
||||||
asm = &oauthex.AuthServerMeta{
|
|
||||||
Issuer: authServerURL,
|
|
||||||
TokenEndpoint: authServerURL + "/token",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
creds := h.config.Credentials
|
|
||||||
if creds.Issuer != "" && !authutil.IssuersEqual(creds.Issuer, asm.Issuer) {
|
|
||||||
return fmt.Errorf("authorization server issuer %q does not match pre-registered credentials issuer %q", asm.Issuer, creds.Issuer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine requestedScopes: use PRM's scopes_supported if available.
|
|
||||||
requestedScopes := scopesFromChallenges(wwwChallenges)
|
|
||||||
if len(requestedScopes) == 0 && len(prm.ScopesSupported) > 0 {
|
|
||||||
requestedScopes = prm.ScopesSupported
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accumulate scopes: union previously granted scopes with the newly
|
|
||||||
// challenged scopes so that step-up authorization does not lose
|
|
||||||
// permissions granted in earlier rounds (SEP-2350).
|
|
||||||
requestedScopes = authutil.UnionScopes(h.grantedScopes[asm.Issuer], requestedScopes)
|
|
||||||
|
|
||||||
// Step 3: Exchange client credentials for an access token.
|
|
||||||
cfg := &clientcredentials.Config{
|
|
||||||
ClientID: creds.ClientID,
|
|
||||||
ClientSecret: creds.ClientSecretAuth.ClientSecret,
|
|
||||||
TokenURL: asm.TokenEndpoint,
|
|
||||||
Scopes: requestedScopes,
|
|
||||||
AuthStyle: selectTokenAuthMethod(asm.TokenEndpointAuthMethodsSupported),
|
|
||||||
}
|
|
||||||
|
|
||||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
|
||||||
h.tokenSource = cfg.TokenSource(ctxWithClient)
|
|
||||||
|
|
||||||
return h.updateGrantedScopes(asm.Issuer, requestedScopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *ClientCredentialsHandler) updateGrantedScopes(issuer string, requestedScopes []string) error {
|
|
||||||
if h.tokenSource == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
tok, err := h.tokenSource.Token()
|
|
||||||
if err != nil {
|
|
||||||
h.tokenSource = nil
|
|
||||||
return fmt.Errorf("client credentials token request failed: %w", err)
|
|
||||||
}
|
|
||||||
if tokenScopes := authutil.ScopesFromToken(tok); tokenScopes == nil {
|
|
||||||
h.grantedScopes[issuer] = requestedScopes
|
|
||||||
} else {
|
|
||||||
h.grantedScopes[issuer] = tokenScopes
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProtectedResourceMetadata discovers Protected Resource Metadata (RFC 9728)
|
|
||||||
// from the request URL. This mirrors the logic in AuthorizationCodeHandler.
|
|
||||||
func getProtectedResourceMetadata(ctx context.Context, wwwChallenges []oauthex.Challenge, mcpServerURL string, httpClient *http.Client) (*oauthex.ProtectedResourceMetadata, error) {
|
|
||||||
// Use MCP server URL as the resource URI per
|
|
||||||
// https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#canonical-server-uri.
|
|
||||||
for _, u := range protectedResourceMetadataURLs(resourceMetadataURLFromChallenges(wwwChallenges), mcpServerURL) {
|
|
||||||
prm, err := oauthex.GetProtectedResourceMetadata(ctx, u.url, u.resource, httpClient)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if prm == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(prm.AuthorizationServers) == 0 {
|
|
||||||
// If we found PRM, we enforce the 2025-11-25 spec and not search further.
|
|
||||||
return nil, fmt.Errorf("protected resource metadata has no authorization servers specified")
|
|
||||||
}
|
|
||||||
return prm, nil
|
|
||||||
}
|
|
||||||
// Fallback to 2025-03-26 spec: MCP server root is the Authorization Server.
|
|
||||||
// https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery
|
|
||||||
u, err := url.Parse(mcpServerURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse MCP server URL: %v", err)
|
|
||||||
}
|
|
||||||
u.Path = ""
|
|
||||||
return &oauthex.ProtectedResourceMetadata{
|
|
||||||
AuthorizationServers: []string{u.String()},
|
|
||||||
Resource: mcpServerURL,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type prmURL struct {
|
|
||||||
url string
|
|
||||||
resource string
|
|
||||||
}
|
|
||||||
|
|
||||||
// protectedResourceMetadataURLs returns URLs to try for PRM discovery.
|
|
||||||
// This mirrors the logic in AuthorizationCodeHandler.
|
|
||||||
func protectedResourceMetadataURLs(metadataURL, resourceURL string) []prmURL {
|
|
||||||
var urls []prmURL
|
|
||||||
if metadataURL != "" {
|
|
||||||
urls = append(urls, prmURL{url: metadataURL, resource: resourceURL})
|
|
||||||
}
|
|
||||||
ru, err := url.Parse(resourceURL)
|
|
||||||
if err != nil {
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
mu := *ru
|
|
||||||
// At the path of the server's MCP endpoint.
|
|
||||||
mu.Path = "/.well-known/oauth-protected-resource/" + strings.TrimLeft(ru.Path, "/")
|
|
||||||
urls = append(urls, prmURL{url: mu.String(), resource: resourceURL})
|
|
||||||
// At the root.
|
|
||||||
mu.Path = "/.well-known/oauth-protected-resource"
|
|
||||||
ru.Path = ""
|
|
||||||
urls = append(urls, prmURL{url: mu.String(), resource: ru.String()})
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
||||||
// resourceMetadataURLFromChallenges returns a resource metadata URL from
|
|
||||||
// WWW-Authenticate challenges, or the empty string if there is none.
|
|
||||||
func resourceMetadataURLFromChallenges(cs []oauthex.Challenge) string {
|
|
||||||
for _, c := range cs {
|
|
||||||
if u := c.Params["resource_metadata"]; u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// scopesFromChallenges returns scopes from WWW-Authenticate challenges.
|
|
||||||
// It only looks at challenges with the "Bearer" scheme.
|
|
||||||
func scopesFromChallenges(cs []oauthex.Challenge) []string {
|
|
||||||
for _, c := range cs {
|
|
||||||
if c.Scheme == "bearer" && c.Params["scope"] != "" {
|
|
||||||
return strings.Fields(c.Params["scope"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectTokenAuthMethod selects the preferred token endpoint auth method based on
|
|
||||||
// the authorization server's supported methods. Prefers client_secret_post over
|
|
||||||
// client_secret_basic per the OAuth 2.1 draft.
|
|
||||||
func selectTokenAuthMethod(supported []string) oauth2.AuthStyle {
|
|
||||||
prefOrder := []string{
|
|
||||||
"client_secret_post",
|
|
||||||
"client_secret_basic",
|
|
||||||
}
|
|
||||||
for _, method := range prefOrder {
|
|
||||||
if slices.Contains(supported, method) {
|
|
||||||
switch method {
|
|
||||||
case "client_secret_post":
|
|
||||||
return oauth2.AuthStyleInParams
|
|
||||||
case "client_secret_basic":
|
|
||||||
return oauth2.AuthStyleInHeader
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return oauth2.AuthStyleAutoDetect
|
|
||||||
}
|
|
||||||
|
|
@ -1,420 +0,0 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by the license
|
|
||||||
// that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package extauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func validClientCredentialsConfig() *ClientCredentialsHandlerConfig {
|
|
||||||
return &ClientCredentialsHandlerConfig{
|
|
||||||
Credentials: &oauthex.ClientCredentials{
|
|
||||||
ClientID: "test-client",
|
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: "test-secret"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewClientCredentialsHandler_Validation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
config *ClientCredentialsHandlerConfig
|
|
||||||
wantError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil config",
|
|
||||||
config: nil,
|
|
||||||
wantError: "config must be provided",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil credentials",
|
|
||||||
config: func() *ClientCredentialsHandlerConfig {
|
|
||||||
c := validClientCredentialsConfig()
|
|
||||||
c.Credentials = nil
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantError: "credentials are required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing client ID",
|
|
||||||
config: func() *ClientCredentialsHandlerConfig {
|
|
||||||
c := validClientCredentialsConfig()
|
|
||||||
c.Credentials.ClientID = ""
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantError: "ClientID is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing client secret auth",
|
|
||||||
config: func() *ClientCredentialsHandlerConfig {
|
|
||||||
c := validClientCredentialsConfig()
|
|
||||||
c.Credentials.ClientSecretAuth = nil
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantError: "clientSecretAuth is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty client secret",
|
|
||||||
config: func() *ClientCredentialsHandlerConfig {
|
|
||||||
c := validClientCredentialsConfig()
|
|
||||||
c.Credentials.ClientSecretAuth.ClientSecret = ""
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantError: "ClientSecret is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid config",
|
|
||||||
config: validClientCredentialsConfig(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := NewClientCredentialsHandler(tc.config)
|
|
||||||
if tc.wantError != "" {
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("expected error containing %q, got nil", tc.wantError)
|
|
||||||
} else if !strings.Contains(err.Error(), tc.wantError) {
|
|
||||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantError)
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
t.Errorf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientCredentialsHandler_Authorize(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
|
||||||
ServeOAuthInsertedEndpoint: true,
|
|
||||||
},
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test-client": {Secret: "test-secret"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
ScopesSupported: []string{"mcp:read", "mcp:write"},
|
|
||||||
}))
|
|
||||||
|
|
||||||
t.Run("successful authorization", func(t *testing.T) {
|
|
||||||
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenSource should be nil before Authorize.
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ts != nil {
|
|
||||||
t.Fatal("expected nil TokenSource before Authorize")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate a 401 response from the MCP server.
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
|
||||||
t.Fatalf("Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenSource should now return a valid token.
|
|
||||||
ts, err = handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ts == nil {
|
|
||||||
t.Fatal("expected non-nil TokenSource after Authorize")
|
|
||||||
}
|
|
||||||
tok, err := ts.Token()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Token() failed: %v", err)
|
|
||||||
}
|
|
||||||
if tok.AccessToken != "test_access_token" {
|
|
||||||
t.Errorf("got access token %q, want %q", tok.AccessToken, "test_access_token")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("bad credentials", func(t *testing.T) {
|
|
||||||
config := validClientCredentialsConfig()
|
|
||||||
config.Credentials.ClientSecretAuth.ClientSecret = "wrong-secret"
|
|
||||||
handler, err := NewClientCredentialsHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err == nil {
|
|
||||||
t.Error("expected Authorize to fail with bad credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenSource should still be nil after failed Authorize.
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ts != nil {
|
|
||||||
t.Error("expected nil TokenSource after failed Authorize")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("scopes from WWW-Authenticate challenge", func(t *testing.T) {
|
|
||||||
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{
|
|
||||||
"WWW-Authenticate": []string{`Bearer scope="read write"`},
|
|
||||||
},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
|
||||||
t.Fatalf("Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ts == nil {
|
|
||||||
t.Fatal("expected non-nil TokenSource")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("issuer mismatch", func(t *testing.T) {
|
|
||||||
config := validClientCredentialsConfig()
|
|
||||||
config.Credentials.Issuer = "https://other-issuer.example.com"
|
|
||||||
handler, err := NewClientCredentialsHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
err = handler.Authorize(t.Context(), req, resp)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected Authorize to fail with issuer mismatch")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "does not match") {
|
|
||||||
t.Errorf("error %q does not mention issuer mismatch", err.Error())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("issuer match ignoring trailing slash", func(t *testing.T) {
|
|
||||||
config := validClientCredentialsConfig()
|
|
||||||
// authServer.URL() has no trailing slash; configure with one to
|
|
||||||
// verify the comparison tolerates the difference (per RFC 8414 §3.3
|
|
||||||
// normalization applied in oauthex.IssuersEqual).
|
|
||||||
config.Credentials.Issuer = authServer.URL() + "/"
|
|
||||||
handler, err := NewClientCredentialsHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
|
||||||
t.Fatalf("Authorize() unexpected error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("PRM via resource_metadata in challenge", func(t *testing.T) {
|
|
||||||
prmMux := http.NewServeMux()
|
|
||||||
prmMux.Handle("/custom-prm", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
prmServer := httptest.NewServer(prmMux)
|
|
||||||
t.Cleanup(prmServer.Close)
|
|
||||||
|
|
||||||
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: http.Header{
|
|
||||||
"WWW-Authenticate": []string{`Bearer resource_metadata="` + prmServer.URL + `/custom-prm"`},
|
|
||||||
},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
|
||||||
t.Fatalf("Authorize with resource_metadata challenge failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := handler.TokenSource(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if ts == nil {
|
|
||||||
t.Fatal("expected non-nil TokenSource")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientCredentialsHandler_ScopeAccumulation(t *testing.T) {
|
|
||||||
authServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
|
||||||
MetadataEndpointConfig: &oauthtest.MetadataEndpointConfig{
|
|
||||||
ServeOAuthInsertedEndpoint: true,
|
|
||||||
},
|
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
|
||||||
"test-client": {Secret: "test-secret"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ClientCredentialsConfig: &oauthtest.ClientCredentialsConfig{
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
TokenScopeFunc: func(requestedScope string) string {
|
|
||||||
// Simulate a server that never grants "write".
|
|
||||||
var granted []string
|
|
||||||
for _, s := range strings.Fields(requestedScope) {
|
|
||||||
if s != "write" {
|
|
||||||
granted = append(granted, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(granted, " ")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authServer.Start(t)
|
|
||||||
|
|
||||||
resourceMux := http.NewServeMux()
|
|
||||||
resourceServer := httptest.NewServer(resourceMux)
|
|
||||||
t.Cleanup(resourceServer.Close)
|
|
||||||
resourceURL := resourceServer.URL + "/resource"
|
|
||||||
|
|
||||||
resourceMux.Handle("/.well-known/oauth-protected-resource/resource", auth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{
|
|
||||||
Resource: resourceURL,
|
|
||||||
AuthorizationServers: []string{authServer.URL()},
|
|
||||||
}))
|
|
||||||
|
|
||||||
handler, err := NewClientCredentialsHandler(validClientCredentialsConfig())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First authorization: 401 with scope="read write".
|
|
||||||
// The token response will only grant "read" (TokenScopeFunc strips "write").
|
|
||||||
resp := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
resp.Header.Set("WWW-Authenticate", `Bearer scope="read write"`)
|
|
||||||
req := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req, resp); err != nil {
|
|
||||||
t.Fatalf("First Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify only "read" was granted (the token omitted "write").
|
|
||||||
issuer := authServer.URL()
|
|
||||||
if diff := cmp.Diff([]string{"read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
|
||||||
t.Errorf("After first Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second authorization: 401 with scope="admin" (simulating step-up).
|
|
||||||
// Accumulated scopes should be "read" (previously granted) + "admin" (new),
|
|
||||||
// but NOT "write" (requested but never granted).
|
|
||||||
resp2 := &http.Response{
|
|
||||||
StatusCode: http.StatusUnauthorized,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
resp2.Header.Set("WWW-Authenticate", `Bearer scope="admin"`)
|
|
||||||
req2 := httptest.NewRequest("GET", resourceURL, nil)
|
|
||||||
if err := handler.Authorize(t.Context(), req2, resp2); err != nil {
|
|
||||||
t.Fatalf("Second Authorize failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify accumulated scopes: "read" (granted) + "admin" (new), no "write".
|
|
||||||
if diff := cmp.Diff([]string{"admin", "read"}, handler.grantedScopes[issuer], cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" {
|
|
||||||
t.Errorf("After second Authorize, grantedScopes mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectTokenAuthMethod(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
supported []string
|
|
||||||
want oauth2.AuthStyle
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prefers client_secret_post",
|
|
||||||
supported: []string{"client_secret_basic", "client_secret_post"},
|
|
||||||
want: oauth2.AuthStyleInParams,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "falls back to client_secret_basic",
|
|
||||||
supported: []string{"client_secret_basic"},
|
|
||||||
want: oauth2.AuthStyleInHeader,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "auto detect when none supported",
|
|
||||||
supported: []string{"private_key_jwt"},
|
|
||||||
want: oauth2.AuthStyleAutoDetect,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "auto detect when empty",
|
|
||||||
supported: nil,
|
|
||||||
want: oauth2.AuthStyleAutoDetect,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := selectTokenAuthMethod(tc.supported)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("selectTokenAuthMethod(%v) = %v, want %v", tc.supported, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,43 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||||
// Use of this source code is governed by the license
|
// Use of this source code is governed by an MIT-style
|
||||||
// that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
package extauth
|
package extauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func validEnterpriseHandlerConfig() *EnterpriseHandlerConfig {
|
// TestNewEnterpriseHandler_Validation tests validation in NewEnterpriseHandler.
|
||||||
return &EnterpriseHandlerConfig{
|
func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
||||||
IdPIssuerURL: "https://idp.example.com",
|
validConfig := &EnterpriseHandlerConfig{
|
||||||
IdPCredentials: &oauthex.ClientCredentials{ClientID: "idp_client_id"},
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "idp_client_id",
|
||||||
|
},
|
||||||
MCPAuthServerURL: "https://mcp-auth.example.com",
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
MCPResourceURI: "https://mcp.example.com",
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
MCPCredentials: &oauthex.ClientCredentials{ClientID: "mcp_client_id"},
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "mcp_client_id",
|
||||||
|
},
|
||||||
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||||
token := &oauth2.Token{AccessToken: "mock", TokenType: "Bearer"}
|
token := &oauth2.Token{
|
||||||
|
AccessToken: "mock_access_token",
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *EnterpriseHandlerConfig
|
config *EnterpriseHandlerConfig
|
||||||
|
|
@ -44,101 +50,151 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing IdPIssuerURL",
|
name: "missing IdPIssuerURL",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
c.IdPIssuerURL = ""
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
return c
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
}(),
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "IdPIssuerURL is required",
|
wantError: "IdPIssuerURL is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil IdPCredentials",
|
name: "nil IdPCredentials",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.IdPCredentials = nil
|
IdPCredentials: nil,
|
||||||
return c
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
}(),
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "IdPCredentials is required",
|
wantError: "IdPCredentials is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid IdPCredentials - empty ClientID",
|
name: "invalid IdPCredentials - empty ClientID",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.IdPCredentials = &oauthex.ClientCredentials{ClientID: ""}
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
return c
|
ClientID: "", // Invalid - empty
|
||||||
}(),
|
},
|
||||||
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "invalid IdPCredentials",
|
wantError: "invalid IdPCredentials",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth",
|
name: "invalid IdPCredentials - empty ClientSecret in ClientSecretAuth",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.IdPCredentials = &oauthex.ClientCredentials{
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
ClientID: "idp_client_id",
|
ClientID: "idp_client_id",
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{ClientSecret: ""},
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
}
|
ClientSecret: "", // Invalid - empty secret when ClientSecretAuth is set
|
||||||
return c
|
},
|
||||||
}(),
|
},
|
||||||
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "invalid IdPCredentials",
|
wantError: "invalid IdPCredentials",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing MCPAuthServerURL",
|
name: "missing MCPAuthServerURL",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.MCPAuthServerURL = ""
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
return c
|
MCPAuthServerURL: "",
|
||||||
}(),
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "MCPAuthServerURL is required",
|
wantError: "MCPAuthServerURL is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing MCPResourceURI",
|
name: "missing MCPResourceURI",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.MCPResourceURI = ""
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
return c
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
}(),
|
MCPResourceURI: "",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "MCPResourceURI is required",
|
wantError: "MCPResourceURI is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nil MCPCredentials",
|
name: "nil MCPCredentials",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.MCPCredentials = nil
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
return c
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
}(),
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: nil,
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "MCPCredentials is required",
|
wantError: "MCPCredentials is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid MCPCredentials - empty ClientID",
|
name: "invalid MCPCredentials - empty ClientID",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.MCPCredentials = &oauthex.ClientCredentials{ClientID: ""}
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
return c
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
}(),
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "", // Invalid - empty
|
||||||
|
},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) { return nil, nil },
|
||||||
|
},
|
||||||
wantError: "invalid MCPCredentials",
|
wantError: "invalid MCPCredentials",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing IDTokenFetcher",
|
name: "missing IDTokenFetcher",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.IDTokenFetcher = nil
|
IdPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
return c
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
}(),
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{ClientID: "id"},
|
||||||
|
IDTokenFetcher: nil,
|
||||||
|
},
|
||||||
wantError: "IDTokenFetcher is required",
|
wantError: "IDTokenFetcher is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid public clients",
|
name: "valid config - public clients (no ClientSecretAuth)",
|
||||||
config: validEnterpriseHandlerConfig(),
|
config: validConfig,
|
||||||
wantError: "",
|
wantError: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid confidential clients",
|
name: "valid config - confidential clients (with ClientSecretAuth)",
|
||||||
config: func() *EnterpriseHandlerConfig {
|
config: &EnterpriseHandlerConfig{
|
||||||
c := validEnterpriseHandlerConfig()
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
c.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
c.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
|
ClientID: "idp_client_id",
|
||||||
return c
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
}(),
|
ClientSecret: "idp_secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "mcp_client_id",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "mcp_secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||||
|
token := &oauth2.Token{
|
||||||
|
AccessToken: "mock_access_token",
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
wantError: "",
|
wantError: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -165,47 +221,45 @@ func TestNewEnterpriseHandler_Validation(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEnterpriseHandler_Authorize_E2E tests the complete enterprise authorization flow.
|
||||||
func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
||||||
const idJAGToken = "id-jag-token-from-idp"
|
// Set up IdP (Identity Provider) fake server with token exchange support
|
||||||
|
idpServer := setupIdPServer(t)
|
||||||
|
|
||||||
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
|
// Set up MCP authorization server with JWT bearer grant support
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
mcpAuthServer := setupMCPAuthServer(t)
|
||||||
"idp_client_id": {Secret: "idp_secret"},
|
|
||||||
},
|
|
||||||
TokenExchangeConfig: &oauthtest.TokenExchangeConfig{
|
|
||||||
IDJAGToken: idJAGToken,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
idpServer.Start(t)
|
|
||||||
|
|
||||||
mcpAuthServer := oauthtest.NewFakeAuthorizationServer(oauthtest.Config{
|
// Create enterprise handler
|
||||||
RegistrationConfig: &oauthtest.RegistrationConfig{
|
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
IdPIssuerURL: idpServer.URL,
|
||||||
"mcp_client_id": {Secret: "mcp_secret"},
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "idp_client_id",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "idp_secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
JWTBearerConfig: &oauthtest.JWTBearerConfig{
|
MCPAuthServerURL: mcpAuthServer.URL,
|
||||||
ValidAssertions: []string{idJAGToken},
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "mcp_client_id",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "mcp_secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MCPScopes: []string{"read", "write"},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||||
|
token := &oauth2.Token{
|
||||||
|
AccessToken: "mock_access_token",
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
mcpAuthServer.Start(t)
|
|
||||||
|
|
||||||
config := validEnterpriseHandlerConfig()
|
|
||||||
config.IdPIssuerURL = idpServer.URL()
|
|
||||||
config.IdPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "idp_secret"}
|
|
||||||
config.MCPAuthServerURL = mcpAuthServer.URL()
|
|
||||||
config.MCPCredentials.ClientSecretAuth = &oauthex.ClientSecretAuth{ClientSecret: "mcp_secret"}
|
|
||||||
config.MCPScopes = []string{"read", "write"}
|
|
||||||
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
|
|
||||||
token := &oauth2.Token{AccessToken: "mock_access_token", TokenType: "Bearer"}
|
|
||||||
return token.WithExtra(map[string]any{"id_token": "mock_id_token_from_user_login"}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
handler, err := NewEnterpriseHandler(config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simulate a 401 response from MCP server
|
||||||
req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil)
|
req := httptest.NewRequest(http.MethodGet, "https://mcp.example.com/api", nil)
|
||||||
resp := &http.Response{
|
resp := &http.Response{
|
||||||
StatusCode: http.StatusUnauthorized,
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
|
@ -214,10 +268,12 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
||||||
Request: req,
|
Request: req,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform authorization
|
||||||
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
if err := handler.Authorize(context.Background(), req, resp); err != nil {
|
||||||
t.Fatalf("Authorize failed: %v", err)
|
t.Fatalf("Authorize failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify token source is set
|
||||||
tokenSource, err := handler.TokenSource(context.Background())
|
tokenSource, err := handler.TokenSource(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("TokenSource failed: %v", err)
|
t.Fatalf("TokenSource failed: %v", err)
|
||||||
|
|
@ -226,22 +282,164 @@ func TestEnterpriseHandler_Authorize_E2E(t *testing.T) {
|
||||||
t.Fatal("expected token source to be set after authorization")
|
t.Fatal("expected token source to be set after authorization")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify we can get a token
|
||||||
token, err := tokenSource.Token()
|
token, err := tokenSource.Token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Token() failed: %v", err)
|
t.Fatalf("Token() failed: %v", err)
|
||||||
}
|
}
|
||||||
if token.AccessToken != "test_access_token" {
|
if token.AccessToken != "mcp_access_token_from_jwt_bearer" {
|
||||||
t.Errorf("AccessToken = %q, want %q", token.AccessToken, "test_access_token")
|
t.Errorf("unexpected access token: got %q, want %q",
|
||||||
|
token.AccessToken, "mcp_access_token_from_jwt_bearer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
|
// setupIdPServer creates a fake IdP server that supports token exchange.
|
||||||
config := validEnterpriseHandlerConfig()
|
func setupIdPServer(t *testing.T) *httptest.Server {
|
||||||
config.IDTokenFetcher = func(ctx context.Context) (*oauth2.Token, error) {
|
t.Helper()
|
||||||
return nil, fmt.Errorf("user cancelled login")
|
mux := http.NewServeMux()
|
||||||
}
|
|
||||||
|
|
||||||
handler, err := NewEnterpriseHandler(config)
|
var server *httptest.Server
|
||||||
|
|
||||||
|
// OAuth/OIDC metadata endpoint - uses closure to get server URL
|
||||||
|
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": server.URL,
|
||||||
|
"token_endpoint": server.URL + "/token",
|
||||||
|
"authorization_endpoint": server.URL + "/authorize",
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Token endpoint - supports token exchange (RFC 8693)
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grantType := r.Form.Get("grant_type")
|
||||||
|
if grantType != oauthex.GrantTypeTokenExchange {
|
||||||
|
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client authentication
|
||||||
|
clientID := r.Form.Get("client_id")
|
||||||
|
clientSecret := r.Form.Get("client_secret")
|
||||||
|
if clientID != "idp_client_id" || clientSecret != "idp_secret" {
|
||||||
|
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token exchange parameters
|
||||||
|
if r.Form.Get("requested_token_type") != oauthex.TokenTypeIDJAG {
|
||||||
|
http.Error(w, "invalid requested_token_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Form.Get("subject_token_type") != oauthex.TokenTypeIDToken {
|
||||||
|
http.Error(w, "invalid subject_token_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Form.Get("subject_token") == "" {
|
||||||
|
http.Error(w, "missing subject_token", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return ID-JAG (Identity Assertion JWT Authorization Grant)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "id-jag-token-from-idp",
|
||||||
|
"issued_token_type": oauthex.TokenTypeIDJAG,
|
||||||
|
"token_type": "N_A",
|
||||||
|
"expires_in": 300,
|
||||||
|
"scope": "read write",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupMCPAuthServer creates a fake MCP authorization server that supports JWT bearer grant.
|
||||||
|
func setupMCPAuthServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
var server *httptest.Server
|
||||||
|
|
||||||
|
// OAuth metadata endpoint - uses closure to get server URL
|
||||||
|
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": server.URL,
|
||||||
|
"token_endpoint": server.URL + "/token",
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Token endpoint - supports JWT bearer grant (RFC 7523)
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grantType := r.Form.Get("grant_type")
|
||||||
|
if grantType != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
|
||||||
|
http.Error(w, fmt.Sprintf("unsupported grant_type: %s", grantType), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client authentication
|
||||||
|
clientID := r.Form.Get("client_id")
|
||||||
|
clientSecret := r.Form.Get("client_secret")
|
||||||
|
if clientID != "mcp_client_id" || clientSecret != "mcp_secret" {
|
||||||
|
http.Error(w, "invalid client credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify assertion (ID-JAG)
|
||||||
|
assertion := r.Form.Get("assertion")
|
||||||
|
if assertion != "id-jag-token-from-idp" {
|
||||||
|
http.Error(w, "invalid assertion", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return access token
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "mcp_access_token_from_jwt_bearer",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"scope": "read write",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server = httptest.NewServer(mux)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnterpriseHandler_Authorize_IDTokenFetcherError tests error handling when IDTokenFetcher fails.
|
||||||
|
func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
|
||||||
|
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||||
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "idp_client_id",
|
||||||
|
},
|
||||||
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "mcp_client_id",
|
||||||
|
},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||||
|
return nil, fmt.Errorf("user cancelled login")
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -259,12 +457,30 @@ func TestEnterpriseHandler_Authorize_IDTokenFetcherError(t *testing.T) {
|
||||||
t.Fatal("expected error from Authorize, got nil")
|
t.Fatal("expected error from Authorize, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "failed to obtain ID token") {
|
if !strings.Contains(err.Error(), "failed to obtain ID token") {
|
||||||
t.Errorf("error = %v, want error containing %q", err, "failed to obtain ID token")
|
t.Errorf("expected error about ID token, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEnterpriseHandler_TokenSource_BeforeAuthorization tests TokenSource before authorization.
|
||||||
func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) {
|
func TestEnterpriseHandler_TokenSource_BeforeAuthorization(t *testing.T) {
|
||||||
handler, err := NewEnterpriseHandler(validEnterpriseHandlerConfig())
|
handler, err := NewEnterpriseHandler(&EnterpriseHandlerConfig{
|
||||||
|
IdPIssuerURL: "https://idp.example.com",
|
||||||
|
IdPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "idp_client_id",
|
||||||
|
},
|
||||||
|
MCPAuthServerURL: "https://mcp-auth.example.com",
|
||||||
|
MCPResourceURI: "https://mcp.example.com",
|
||||||
|
MCPCredentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "mcp_client_id",
|
||||||
|
},
|
||||||
|
IDTokenFetcher: func(ctx context.Context) (*oauth2.Token, error) {
|
||||||
|
token := &oauth2.Token{
|
||||||
|
AccessToken: "mock_access_token",
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
return token.WithExtra(map[string]any{"id_token": "mock_id_token"}), nil
|
||||||
|
},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
t.Fatalf("NewEnterpriseHandler failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,468 @@
|
||||||
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
// Copyright 2026 The Go MCP SDK Authors. All rights reserved.
|
||||||
// Use of this source code is governed by the license
|
// Use of this source code is governed by an MIT-style
|
||||||
// that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
package extauth
|
package extauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/auth"
|
"github.com/modelcontextprotocol/go-sdk/auth"
|
||||||
"github.com/modelcontextprotocol/go-sdk/internal/oauthtest"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
"github.com/modelcontextprotocol/go-sdk/oauthex"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const testRedirectURL = "http://localhost:18927/callback"
|
// TestInitiateOIDCLogin tests the OIDC authorization request generation.
|
||||||
|
func TestInitiateOIDCLogin(t *testing.T) {
|
||||||
|
// Create mock IdP server
|
||||||
|
idpServer := createMockOIDCServer(t)
|
||||||
|
defer idpServer.Close()
|
||||||
|
config := &OIDCLoginConfig{
|
||||||
|
IssuerURL: idpServer.URL,
|
||||||
|
Credentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "test-client",
|
||||||
|
},
|
||||||
|
RedirectURL: "http://localhost:8080/callback",
|
||||||
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
HTTPClient: idpServer.Client(),
|
||||||
|
}
|
||||||
|
t.Run("successful initiation", func(t *testing.T) {
|
||||||
|
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
// Validate authURL
|
||||||
|
if authReq.authURL == "" {
|
||||||
|
t.Error("authURL is empty")
|
||||||
|
}
|
||||||
|
// Parse and validate URL parameters
|
||||||
|
u, err := url.Parse(authReq.authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse authURL: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if q.Get("response_type") != "code" {
|
||||||
|
t.Errorf("expected response_type 'code', got '%s'", q.Get("response_type"))
|
||||||
|
}
|
||||||
|
if q.Get("client_id") != "test-client" {
|
||||||
|
t.Errorf("expected client_id 'test-client', got '%s'", q.Get("client_id"))
|
||||||
|
}
|
||||||
|
if q.Get("redirect_uri") != "http://localhost:8080/callback" {
|
||||||
|
t.Errorf("expected redirect_uri 'http://localhost:8080/callback', got '%s'", q.Get("redirect_uri"))
|
||||||
|
}
|
||||||
|
if q.Get("scope") != "openid profile email" {
|
||||||
|
t.Errorf("expected scope 'openid profile email', got '%s'", q.Get("scope"))
|
||||||
|
}
|
||||||
|
if q.Get("code_challenge_method") != "S256" {
|
||||||
|
t.Errorf("expected code_challenge_method 'S256', got '%s'", q.Get("code_challenge_method"))
|
||||||
|
}
|
||||||
|
// Validate state is generated
|
||||||
|
if authReq.state == "" {
|
||||||
|
t.Error("state is empty")
|
||||||
|
}
|
||||||
|
if q.Get("state") != authReq.state {
|
||||||
|
t.Errorf("state in URL doesn't match returned state")
|
||||||
|
}
|
||||||
|
// Validate PKCE parameters
|
||||||
|
if authReq.codeVerifier == "" {
|
||||||
|
t.Error("codeVerifier is empty")
|
||||||
|
}
|
||||||
|
if q.Get("code_challenge") == "" {
|
||||||
|
t.Error("code_challenge is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("with login_hint", func(t *testing.T) {
|
||||||
|
configWithHint := *config
|
||||||
|
configWithHint.LoginHint = "user@example.com"
|
||||||
|
authReq, _, err := initiateOIDCLogin(context.Background(), &configWithHint)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(authReq.authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse authURL: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if q.Get("login_hint") != "user@example.com" {
|
||||||
|
t.Errorf("expected login_hint 'user@example.com', got '%s'", q.Get("login_hint"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("without login_hint", func(t *testing.T) {
|
||||||
|
authReq, _, err := initiateOIDCLogin(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(authReq.authURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse authURL: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
if q.Has("login_hint") {
|
||||||
|
t.Errorf("expected no login_hint parameter, but got '%s'", q.Get("login_hint"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("nil config", func(t *testing.T) {
|
||||||
|
_, _, err := initiateOIDCLogin(context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nil config, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("missing openid scope", func(t *testing.T) {
|
||||||
|
badConfig := *config
|
||||||
|
badConfig.Scopes = []string{"profile", "email"} // Missing "openid"
|
||||||
|
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing openid scope, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "openid") {
|
||||||
|
t.Errorf("expected error about missing 'openid', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("missing required fields", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*OIDCLoginConfig)
|
||||||
|
expectErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing IssuerURL",
|
||||||
|
mutate: func(c *OIDCLoginConfig) { c.IssuerURL = "" },
|
||||||
|
expectErr: "IssuerURL is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing ClientID",
|
||||||
|
mutate: func(c *OIDCLoginConfig) { c.Credentials.ClientID = "" },
|
||||||
|
expectErr: "ClientID is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing RedirectURL",
|
||||||
|
mutate: func(c *OIDCLoginConfig) {
|
||||||
|
c.RedirectURL = ""
|
||||||
|
// Ensure ClientID is present to test RedirectURL validation
|
||||||
|
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||||
|
},
|
||||||
|
expectErr: "RedirectURL is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing Scopes",
|
||||||
|
mutate: func(c *OIDCLoginConfig) {
|
||||||
|
c.Scopes = nil
|
||||||
|
// Ensure required fields are present to test Scopes validation
|
||||||
|
c.Credentials = &oauthex.ClientCredentials{ClientID: "test"}
|
||||||
|
c.RedirectURL = "http://localhost:8080/callback"
|
||||||
|
},
|
||||||
|
expectErr: "at least one scope is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
badConfig := *config
|
||||||
|
tt.mutate(&badConfig)
|
||||||
|
_, _, err := initiateOIDCLogin(context.Background(), &badConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||||
|
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func validOIDCLoginConfig(issuerURL string) *OIDCLoginConfig {
|
// TestCompleteOIDCLogin tests the authorization code exchange.
|
||||||
return &OIDCLoginConfig{
|
func TestCompleteOIDCLogin(t *testing.T) {
|
||||||
IssuerURL: issuerURL,
|
// Create mock IdP server
|
||||||
|
idpServer := createMockOIDCServerWithToken(t)
|
||||||
|
defer idpServer.Close()
|
||||||
|
config := &OIDCLoginConfig{
|
||||||
|
IssuerURL: idpServer.URL,
|
||||||
Credentials: &oauthex.ClientCredentials{
|
Credentials: &oauthex.ClientCredentials{
|
||||||
ClientID: "test-client",
|
ClientID: "test-client",
|
||||||
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
ClientSecret: "test-secret",
|
ClientSecret: "test-secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
RedirectURL: testRedirectURL,
|
RedirectURL: "http://localhost:8080/callback",
|
||||||
Scopes: []string{"openid", "profile", "email"},
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
HTTPClient: idpServer.Client(),
|
||||||
|
}
|
||||||
|
t.Run("successful code exchange", func(t *testing.T) {
|
||||||
|
// First initiate to get oauth2Config
|
||||||
|
_, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := completeOIDCLogin(
|
||||||
|
context.Background(),
|
||||||
|
config,
|
||||||
|
oauth2Config,
|
||||||
|
"test-auth-code",
|
||||||
|
"test-code-verifier",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
// Validate tokens
|
||||||
|
idToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || idToken == "" {
|
||||||
|
t.Error("id_token is missing or empty")
|
||||||
|
}
|
||||||
|
if token.AccessToken == "" {
|
||||||
|
t.Error("AccessToken is empty")
|
||||||
|
}
|
||||||
|
if token.TokenType != "Bearer" {
|
||||||
|
t.Errorf("expected TokenType 'Bearer', got '%s'", token.TokenType)
|
||||||
|
}
|
||||||
|
if token.Expiry.IsZero() {
|
||||||
|
t.Error("Expiry is zero")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("missing parameters", func(t *testing.T) {
|
||||||
|
_, oauth2Config, _ := initiateOIDCLogin(context.Background(), config)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authCode string
|
||||||
|
codeVerifier string
|
||||||
|
expectErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing authCode",
|
||||||
|
authCode: "",
|
||||||
|
codeVerifier: "test-verifier",
|
||||||
|
expectErr: "authCode is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing codeVerifier",
|
||||||
|
authCode: "test-code",
|
||||||
|
codeVerifier: "",
|
||||||
|
expectErr: "codeVerifier is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := completeOIDCLogin(
|
||||||
|
context.Background(),
|
||||||
|
config,
|
||||||
|
oauth2Config,
|
||||||
|
tt.authCode,
|
||||||
|
tt.codeVerifier,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.expectErr) {
|
||||||
|
t.Errorf("expected error containing '%s', got: %v", tt.expectErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOIDCLoginE2E tests the complete OIDC login flow end-to-end.
|
||||||
|
func TestOIDCLoginE2E(t *testing.T) {
|
||||||
|
// Create mock IdP server
|
||||||
|
idpServer := createMockOIDCServerWithToken(t)
|
||||||
|
defer idpServer.Close()
|
||||||
|
config := &OIDCLoginConfig{
|
||||||
|
IssuerURL: idpServer.URL,
|
||||||
|
Credentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RedirectURL: "http://localhost:8080/callback",
|
||||||
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
HTTPClient: idpServer.Client(),
|
||||||
|
}
|
||||||
|
// Step 1: Initiate login
|
||||||
|
authReq, oauth2Config, err := initiateOIDCLogin(context.Background(), config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initiateOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
// Step 2: Simulate user authentication and redirect
|
||||||
|
// (In real flow, user would visit authReq.authURL and IdP would redirect back)
|
||||||
|
// Here we just use a mock authorization code
|
||||||
|
mockAuthCode := "mock-authorization-code"
|
||||||
|
// Step 3: Complete login with authorization code
|
||||||
|
token, err := completeOIDCLogin(
|
||||||
|
context.Background(),
|
||||||
|
config,
|
||||||
|
oauth2Config,
|
||||||
|
mockAuthCode,
|
||||||
|
authReq.codeVerifier,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("completeOIDCLogin failed: %v", err)
|
||||||
|
}
|
||||||
|
// Validate we got an ID token
|
||||||
|
idToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || idToken == "" {
|
||||||
|
t.Error("Expected ID token, got empty or missing")
|
||||||
|
}
|
||||||
|
// Validate ID token is a JWT (has 3 parts)
|
||||||
|
parts := strings.Split(idToken, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createMockOIDCServer creates a mock OIDC server for testing initiateOIDCLogin.
|
||||||
|
func createMockOIDCServer(t *testing.T) *httptest.Server {
|
||||||
|
var serverURL string
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Handle OIDC discovery
|
||||||
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": serverURL,
|
||||||
|
"authorization_endpoint": serverURL + "/authorize",
|
||||||
|
"token_endpoint": serverURL + "/token",
|
||||||
|
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
"grant_types_supported": []string{"authorization_code"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
serverURL = server.URL
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMockOIDCServerWithToken creates a mock OIDC server that also handles token exchange.
|
||||||
|
func createMockOIDCServerWithToken(t *testing.T) *httptest.Server {
|
||||||
|
var serverURL string
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Handle OIDC discovery
|
||||||
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": serverURL,
|
||||||
|
"authorization_endpoint": serverURL + "/authorize",
|
||||||
|
"token_endpoint": serverURL + "/token",
|
||||||
|
"jwks_uri": serverURL + "/.well-known/jwks.json",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
"grant_types_supported": []string{"authorization_code"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Handle token endpoint
|
||||||
|
if r.URL.Path == "/token" {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate grant type
|
||||||
|
if r.FormValue("grant_type") != "authorization_code" {
|
||||||
|
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Create mock ID token (JWT)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
idToken := fmt.Sprintf("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.%s.mock-signature",
|
||||||
|
base64EncodeClaims(map[string]any{
|
||||||
|
"iss": serverURL,
|
||||||
|
"sub": "test-user",
|
||||||
|
"aud": "test-client",
|
||||||
|
"exp": now + 3600,
|
||||||
|
"iat": now,
|
||||||
|
"email": "test@example.com",
|
||||||
|
}))
|
||||||
|
// Return token response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "mock-access-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token": "mock-refresh-token",
|
||||||
|
"id_token": idToken,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
serverURL = server.URL
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64EncodeClaims encodes JWT claims for testing.
|
||||||
|
func base64EncodeClaims(claims map[string]any) string {
|
||||||
|
claimsJSON, _ := json.Marshal(claims)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPerformOIDCLogin tests the combined OIDC login flow with callback.
|
||||||
func TestPerformOIDCLogin(t *testing.T) {
|
func TestPerformOIDCLogin(t *testing.T) {
|
||||||
idpServer := oauthtest.NewFakeIdPServer(oauthtest.IdPConfig{
|
// Create mock IdP server
|
||||||
PreregisteredClients: map[string]oauthtest.ClientInfo{
|
idpServer := createMockOIDCServerWithToken(t)
|
||||||
"test-client": {
|
defer idpServer.Close()
|
||||||
Secret: "test-secret",
|
config := &OIDCLoginConfig{
|
||||||
RedirectURIs: []string{testRedirectURL},
|
IssuerURL: idpServer.URL,
|
||||||
|
Credentials: &oauthex.ClientCredentials{
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecretAuth: &oauthex.ClientSecretAuth{
|
||||||
|
ClientSecret: "test-secret",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
RedirectURL: "http://localhost:8080/callback",
|
||||||
idpServer.Start(t)
|
Scopes: []string{"openid", "profile", "email"},
|
||||||
|
HTTPClient: idpServer.Client(),
|
||||||
config := validOIDCLoginConfig(idpServer.URL())
|
|
||||||
|
|
||||||
// fetchAuthCode visits the authorization URL on the fake IdP server,
|
|
||||||
// follows the redirect, and extracts the authorization code and state.
|
|
||||||
fetchAuthCode := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
||||||
client := &http.Client{
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Get(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to visit auth URL: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusFound {
|
|
||||||
return nil, fmt.Errorf("expected redirect, got status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
loc, err := resp.Location()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("missing Location header: %w", err)
|
|
||||||
}
|
|
||||||
return &auth.AuthorizationResult{
|
|
||||||
Code: loc.Query().Get("code"),
|
|
||||||
State: loc.Query().Get("state"),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyOIDCToken := func(token *oauth2.Token) error {
|
|
||||||
idToken, ok := token.Extra("id_token").(string)
|
|
||||||
if !ok || idToken == "" {
|
|
||||||
return fmt.Errorf("id_token is missing or empty")
|
|
||||||
}
|
|
||||||
parts := strings.Split(idToken, ".")
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return fmt.Errorf("id_token is not a JWT (expected 3 parts, got %d)", len(parts))
|
|
||||||
}
|
|
||||||
if token.AccessToken == "" {
|
|
||||||
return fmt.Errorf("access token is empty")
|
|
||||||
}
|
|
||||||
if token.TokenType != "Bearer" {
|
|
||||||
return fmt.Errorf("token type = %q, want %q", token.TokenType, "Bearer")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("successful flow", func(t *testing.T) {
|
t.Run("successful flow", func(t *testing.T) {
|
||||||
token, err := PerformOIDCLogin(context.Background(), config, fetchAuthCode)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := verifyOIDCToken(token); err != nil {
|
|
||||||
t.Errorf("invalid token returned: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("with login_hint", func(t *testing.T) {
|
|
||||||
configWithHint := *config
|
|
||||||
configWithHint.LoginHint = "user@example.com"
|
|
||||||
|
|
||||||
token, err := PerformOIDCLogin(context.Background(), &configWithHint,
|
|
||||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
||||||
u, err := url.Parse(args.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid authURL: %w", err)
|
|
||||||
}
|
|
||||||
if got := u.Query().Get("login_hint"); got != "user@example.com" {
|
|
||||||
t.Errorf("login_hint = %q, want %q", got, "user@example.com")
|
|
||||||
}
|
|
||||||
return fetchAuthCode(ctx, args)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := verifyOIDCToken(token); err != nil {
|
|
||||||
t.Errorf("invalid token returned: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("without login_hint", func(t *testing.T) {
|
|
||||||
token, err := PerformOIDCLogin(context.Background(), config,
|
token, err := PerformOIDCLogin(context.Background(), config,
|
||||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||||
|
// Validate authURL has required parameters
|
||||||
u, err := url.Parse(args.URL)
|
u, err := url.Parse(args.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid authURL: %w", err)
|
return nil, fmt.Errorf("invalid authURL: %w", err)
|
||||||
}
|
}
|
||||||
if u.Query().Has("login_hint") {
|
q := u.Query()
|
||||||
t.Errorf("login_hint should be absent, got %q", u.Query().Get("login_hint"))
|
if q.Get("response_type") != "code" {
|
||||||
|
return nil, fmt.Errorf("missing response_type")
|
||||||
}
|
}
|
||||||
return fetchAuthCode(ctx, args)
|
if q.Get("state") == "" {
|
||||||
|
return nil, fmt.Errorf("missing state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate successful user authentication
|
||||||
|
return &auth.AuthorizationResult{
|
||||||
|
Code: "mock-auth-code",
|
||||||
|
State: q.Get("state"), // Return the expected state from URL
|
||||||
|
}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("PerformOIDCLogin() error = %v", err)
|
t.Fatalf("PerformOIDCLogin failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := verifyOIDCToken(token); err != nil {
|
|
||||||
t.Errorf("invalid token returned: %v", err)
|
idToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || idToken == "" {
|
||||||
|
t.Error("id_token is missing or empty")
|
||||||
|
}
|
||||||
|
if token.AccessToken == "" {
|
||||||
|
t.Error("AccessToken is empty")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("state mismatch", func(t *testing.T) {
|
t.Run("state mismatch", func(t *testing.T) {
|
||||||
_, err := PerformOIDCLogin(context.Background(), config,
|
_, err := PerformOIDCLogin(context.Background(), config,
|
||||||
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
||||||
|
// Return wrong state to simulate CSRF attack
|
||||||
return &auth.AuthorizationResult{
|
return &auth.AuthorizationResult{
|
||||||
Code: "mock-auth-code",
|
Code: "mock-auth-code",
|
||||||
State: "wrong-state",
|
State: "wrong-state",
|
||||||
|
|
@ -154,10 +470,10 @@ func TestPerformOIDCLogin(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for state mismatch, got nil")
|
t.Error("expected error for state mismatch, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "state mismatch") {
|
if !strings.Contains(err.Error(), "state mismatch") {
|
||||||
t.Errorf("error = %v, want error containing %q", err, "state mismatch")
|
t.Errorf("expected state mismatch error, got: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -168,96 +484,20 @@ func TestPerformOIDCLogin(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Error("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "user cancelled") {
|
if !strings.Contains(err.Error(), "user cancelled") {
|
||||||
t.Errorf("error = %v, want error containing %q", err, "user cancelled")
|
t.Errorf("expected 'user cancelled' error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil fetcher", func(t *testing.T) {
|
||||||
|
_, err := PerformOIDCLogin(context.Background(), config, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nil fetcher, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "authCodeFetcher is required") {
|
||||||
|
t.Errorf("expected 'authCodeFetcher is required' error, got: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPerformOIDCLogin_ValidationErrors(t *testing.T) {
|
|
||||||
const nonexistentIssuer = "https://idp.example.com"
|
|
||||||
|
|
||||||
noopFetcher := func(ctx context.Context, args *auth.AuthorizationArgs) (*auth.AuthorizationResult, error) {
|
|
||||||
return nil, fmt.Errorf("should not be called")
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
config *OIDCLoginConfig
|
|
||||||
fetcher auth.AuthorizationCodeFetcher
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil config",
|
|
||||||
config: nil,
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "config is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil fetcher",
|
|
||||||
config: validOIDCLoginConfig(nonexistentIssuer),
|
|
||||||
fetcher: nil,
|
|
||||||
wantErr: "authCodeFetcher is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing IssuerURL",
|
|
||||||
config: validOIDCLoginConfig(""),
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "IssuerURL is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing ClientID",
|
|
||||||
config: func() *OIDCLoginConfig {
|
|
||||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
|
||||||
c.Credentials = &oauthex.ClientCredentials{}
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "ClientID is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing RedirectURL",
|
|
||||||
config: func() *OIDCLoginConfig {
|
|
||||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
|
||||||
c.RedirectURL = ""
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "RedirectURL is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing Scopes",
|
|
||||||
config: func() *OIDCLoginConfig {
|
|
||||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
|
||||||
c.Scopes = nil
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "at least one scope is required",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing openid scope",
|
|
||||||
config: func() *OIDCLoginConfig {
|
|
||||||
c := validOIDCLoginConfig(nonexistentIssuer)
|
|
||||||
c.Scopes = []string{"profile", "email"}
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
fetcher: noopFetcher,
|
|
||||||
wantErr: "openid",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
_, err := PerformOIDCLogin(context.Background(), tt.config, tt.fetcher)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
||||||
t.Errorf("error = %v, want error containing %q", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,7 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 8414, section 3.1 requires any terminating "/" to be removed from the
|
if baseURL.Path == "" {
|
||||||
// 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".
|
// "OAuth 2.0 Authorization Server Metadata".
|
||||||
baseURL.Path = "/.well-known/oauth-authorization-server"
|
baseURL.Path = "/.well-known/oauth-authorization-server"
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
|
|
@ -59,14 +54,15 @@ func authorizationServerMetadataURLs(issuerURL string) []string {
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalPath := baseURL.Path
|
||||||
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
// "OAuth 2.0 Authorization Server Metadata with path insertion".
|
||||||
baseURL.Path = "/.well-known/oauth-authorization-server/" + issuerPath
|
baseURL.Path = "/.well-known/oauth-authorization-server/" + strings.TrimLeft(originalPath, "/")
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
// "OpenID Connect Discovery 1.0 with path insertion".
|
// "OpenID Connect Discovery 1.0 with path insertion".
|
||||||
baseURL.Path = "/.well-known/openid-configuration/" + issuerPath
|
baseURL.Path = "/.well-known/openid-configuration/" + strings.TrimLeft(originalPath, "/")
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
// "OpenID Connect Discovery 1.0 with path appending".
|
// "OpenID Connect Discovery 1.0 with path appending".
|
||||||
baseURL.Path = "/" + issuerPath + "/.well-known/openid-configuration"
|
baseURL.Path = "/" + strings.Trim(originalPath, "/") + "/.well-known/openid-configuration"
|
||||||
urls = append(urls, baseURL.String())
|
urls = append(urls, baseURL.String())
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue