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