Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ae212ebf |
||
|
|
cb6a62f7c2 |
||
|
|
6132fd4b5b |
||
|
|
973b46b8f7 |
||
|
|
6112a3995a |
||
|
|
6fa1a97b41 |
||
|
|
8eef5fda00 |
||
|
|
c0407a0b52 |
||
|
|
fb6fffbecd |
||
|
|
ac90e679cd |
||
|
|
78d187716e |
||
|
|
f1b50e6de2 |
||
|
|
e019cb6909 |
||
|
|
e76d61155b |
||
|
|
0121dccf7e |
||
|
|
8a50d896c1 |
||
|
|
7c58239ff6 |
||
|
|
778ab33d23 |
||
|
|
e2afccdcaa |
||
|
|
63fd276dbd |
||
|
|
9ad382d397 |
||
|
|
f3b9ba4a59 |
||
|
|
c95d0c0354 |
||
|
|
494b5b55d3 |
||
|
|
36865fb056 |
||
|
|
7a76001187 |
||
|
|
609cdc709b |
||
|
|
14fddc44c4 |
||
|
|
9742933d2f |
||
|
|
a59b50bf5a |
||
|
|
3b505ee575 |
||
|
|
fac4df6b1a |
||
|
|
ddde66dc08 |
||
|
|
fec680a243 |
||
|
|
87f33d022a |
||
|
|
266c02d2d1 |
||
|
|
720a8102e9 |
||
|
|
3690509fa0 |
||
|
|
bc5360ecce |
||
|
|
800131fb9c |
||
|
|
d3f061dacd |
||
|
|
305cf7e13f |
||
|
|
23cb19268a |
||
|
|
32cf6cb7e6 |
||
|
|
daef492821 |
||
|
|
bdaa2585c5 |
||
|
|
3ff1085e01 |
||
|
|
2b67a1233f |
||
|
|
48c19c282b |
||
|
|
ccb070f2f0 |
||
|
|
f6e401aa97 |
||
|
|
99517b0564 |
||
|
|
25dee3bb4c |
||
|
|
29aa74d137 |
||
|
|
046fa3ee96 |
||
|
|
d583151871 |
||
|
|
4143f35b41 |
||
|
|
c748bc1549 |
||
|
|
82f4fd29b3 |
||
|
|
2a978d175e |
||
|
|
960c1dda0b |
||
|
|
1e61f205a1 |
||
|
|
20d4fe6f5a |
||
|
|
cfcead8121 |
||
|
|
0725c46969 |
||
|
|
402904eab2 |
||
|
|
7c84c20faf |
||
|
|
09da2a1c01 |
||
|
|
a9bcbf9ad9 |
||
|
|
de829ee648 |
||
|
|
5551f66d0e |
||
|
|
3372a14a10 |
||
|
|
daa7dad30a |
||
|
|
19857af5bf |
||
|
|
d55140bc46 |
||
|
|
97b756c0f4 |
||
|
|
8dfc9c7875 |
||
|
|
b5c9978922 |
||
|
|
ef647106b9 |
||
|
|
da97a3db3c |
||
|
|
36a55a5b57 |
||
|
|
75322c3baf |
||
|
|
2510dda70e |
||
|
|
e0186b5ad5 |
||
|
|
c672358e7b |
||
|
|
59b6b755c0 |
||
|
|
eba4f62bd5 |
||
|
|
6f1f65a392 |
||
|
|
3e628db6d8 |
||
|
|
4ed52bc2c3 |
||
|
|
a2a0044abb |
96 changed files with 32094 additions and 1131 deletions
131
.github/workflows/ci.yml
vendored
131
.github/workflows/ci.yml
vendored
|
|
@ -2,27 +2,130 @@ name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: ["main"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: macos-latest
|
timeout-minutes: 10
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
os: [macos-latest, ubuntu-latest]
|
||||||
swift-version:
|
swift-version:
|
||||||
- ^6
|
- 6.1.0
|
||||||
|
|
||||||
name: Build and Test (Swift ${{ matrix.swift-version }})
|
runs-on: ${{ matrix.os }}
|
||||||
|
name: Test (${{ matrix.os }}, Swift ${{ matrix.swift-version }})
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: swift-actions/setup-swift@v2
|
|
||||||
with:
|
- name: Prepare Linux apt dependencies
|
||||||
swift-version: ${{ matrix.swift-version }}
|
if: matrix.os == 'ubuntu-latest'
|
||||||
- name: Build
|
run: |
|
||||||
run: swift build -v
|
sudo -n apt-get update
|
||||||
- name: Run tests
|
sudo -n apt-get install -y --no-install-recommends libcurl4-openssl-dev
|
||||||
run: swift test -v
|
|
||||||
|
- name: Setup Swift on Linux
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
uses: vapor/swiftly-action@v0.2
|
||||||
|
with:
|
||||||
|
toolchain: ${{ matrix.swift-version }}
|
||||||
|
- name: Setup Swift on macOS
|
||||||
|
if: matrix.os == 'macos-latest'
|
||||||
|
uses: swift-actions/setup-swift@v2
|
||||||
|
with:
|
||||||
|
swift-version: ${{ matrix.swift-version }}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: swift build -v
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: swift test -v
|
||||||
|
|
||||||
|
conformance:
|
||||||
|
timeout-minutes: 10
|
||||||
|
runs-on: macos-latest
|
||||||
|
name: MCP Conformance Tests
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Swift
|
||||||
|
uses: swift-actions/setup-swift@v2
|
||||||
|
with:
|
||||||
|
swift-version: 6.1.0
|
||||||
|
|
||||||
|
- name: Build Swift executables
|
||||||
|
run: |
|
||||||
|
swift build --product mcp-everything-client
|
||||||
|
swift build --product mcp-everything-server
|
||||||
|
|
||||||
|
- name: Run client conformance tests
|
||||||
|
uses: modelcontextprotocol/conformance@v0.1.15
|
||||||
|
with:
|
||||||
|
mode: client
|
||||||
|
command: '.build/debug/mcp-everything-client'
|
||||||
|
suite: 'core'
|
||||||
|
expected-failures: './conformance-baseline.yml'
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Start server for testing
|
||||||
|
run: |
|
||||||
|
.build/debug/mcp-everything-server &
|
||||||
|
echo "SERVER_PID=$!" >> $GITHUB_ENV
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
- name: Run server conformance tests
|
||||||
|
uses: modelcontextprotocol/conformance@v0.1.15
|
||||||
|
with:
|
||||||
|
mode: server
|
||||||
|
url: 'http://localhost:3001/mcp'
|
||||||
|
suite: 'core'
|
||||||
|
expected-failures: './conformance-baseline.yml'
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Cleanup server
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -n "$SERVER_PID" ]; then
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
documentation:
|
||||||
|
name: Documentation
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: swift-actions/setup-swift@v2
|
||||||
|
with:
|
||||||
|
swift-version: "6.1.0"
|
||||||
|
- name: Build Documentation
|
||||||
|
run: swift package generate-documentation --target MCP --warnings-as-errors
|
||||||
|
|
||||||
|
static-linux-sdk-build:
|
||||||
|
name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }})
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
swift-version:
|
||||||
|
- 6.1.2
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
container: swift:${{ matrix.swift-version }}-noble
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install static SDK and build
|
||||||
|
env:
|
||||||
|
BUILD_FLAGS: ""
|
||||||
|
run: |
|
||||||
|
which curl || (apt -q update && apt -yq install curl)
|
||||||
|
curl -s --retry 3 https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/install-and-build-with-sdk.sh | \
|
||||||
|
bash -s -- --static --flags="$BUILD_FLAGS" ${{ matrix.swift-version }}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ DerivedData/
|
||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
.index-build/
|
||||||
4
.spi.yml
Normal file
4
.spi.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
version: 1
|
||||||
|
builder:
|
||||||
|
configs:
|
||||||
|
- documentation_targets: [MCP]
|
||||||
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"recommendations": ["swiftlang.swift-vscode"]
|
"recommendations": ["swiftlang.swift-vscode", "esbenp.prettier-vscode"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
|
|
@ -4,5 +4,12 @@
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit"
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"[github-actions-workflow]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[swift]": {
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.tabSize": 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
LICENSE
59
LICENSE
|
|
@ -1,3 +1,11 @@
|
||||||
|
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
|
||||||
|
|
||||||
|
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
|
||||||
|
|
||||||
|
No rights beyond those granted by the applicable original license are conveyed for such contributions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Apache License
|
Apache License
|
||||||
Version 2.0, January 2004
|
Version 2.0, January 2004
|
||||||
http://www.apache.org/licenses/
|
http://www.apache.org/licenses/
|
||||||
|
|
@ -48,9 +56,9 @@
|
||||||
"Contribution" shall mean any work of authorship, including
|
"Contribution" shall mean any work of authorship, including
|
||||||
the original version of the Work and any modifications or additions
|
the original version of the Work and any modifications or additions
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
submitted to the Licensor for inclusion in the Work by the copyright
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
of the copyright owner. For the purposes of this definition, "submitted"
|
||||||
means any form of electronic, verbal, or written communication sent
|
means any form of electronic, verbal, or written communication sent
|
||||||
to the Licensor or its representatives, including but not limited to
|
to the Licensor or its representatives, including but not limited to
|
||||||
communication on electronic mailing lists, source code control systems,
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
|
@ -175,27 +183,34 @@
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
---
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
MIT License
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2025 Loopwork Limited
|
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
you may not use this file except in compliance with the License.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
You may obtain a copy of the License at
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
See the License for the specific language governing permissions and
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
limitations under the License.
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||||
|
|
||||||
|
Documentation in this project (excluding specifications) is licensed under
|
||||||
|
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
|
||||||
|
the full license text.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,51 @@
|
||||||
{
|
{
|
||||||
"originHash" : "cce36cb33302c2f1c28458e19b8439f736fc28106e4c6ea95d7992c74594c242",
|
"originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "eventsource",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/mattt/eventsource.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e83f076811f32757305b8bf69ac92d05626ffdd7",
|
||||||
|
"version" : "1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||||
|
"version" : "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-docc-plugin",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-docc-plugin",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "e977f65879f82b375a044c8837597f690c067da6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-docc-symbolkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-docc-symbolkit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-log",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
@ -10,6 +55,15 @@
|
||||||
"version" : "1.6.2"
|
"version" : "1.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5e72fc102906ebe75a3487595a653e6f43725552",
|
||||||
|
"version" : "2.94.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-system",
|
"identity" : "swift-system",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// swift-tools-version:6.0
|
// swift-tools-version:6.1
|
||||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
@ -6,34 +6,75 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "mcp-swift-sdk",
|
name: "mcp-swift-sdk",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v14),
|
.macOS("13.0"),
|
||||||
.iOS(.v17),
|
.macCatalyst("16.0"),
|
||||||
|
.iOS("16.0"),
|
||||||
|
.watchOS("9.0"),
|
||||||
|
.tvOS("16.0"),
|
||||||
|
.visionOS("1.0"),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
name: "MCP",
|
name: "MCP",
|
||||||
targets: ["MCP"])
|
targets: ["MCP"]),
|
||||||
|
.executable(
|
||||||
|
name: "mcp-everything-server",
|
||||||
|
targets: ["MCPConformanceServer"]),
|
||||||
|
.executable(
|
||||||
|
name: "mcp-everything-client",
|
||||||
|
targets: ["MCPConformanceClient"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/swiftlang/swift-docc-plugin", branch: "main"),
|
||||||
.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
|
||||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
|
||||||
|
.package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
|
||||||
.target(
|
.target(
|
||||||
name: "MCP",
|
name: "MCP",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "SystemPackage", package: "swift-system"),
|
.product(name: "SystemPackage", package: "swift-system"),
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
]),
|
.product(
|
||||||
|
name: "EventSource", package: "eventsource",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS, .macCatalyst])),
|
||||||
|
],
|
||||||
|
swiftSettings: [
|
||||||
|
.enableUpcomingFeature("StrictConcurrency")
|
||||||
|
]
|
||||||
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "MCPTests",
|
name: "MCPTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"MCP",
|
"MCP",
|
||||||
.product(name: "SystemPackage", package: "swift-system"),
|
.product(name: "SystemPackage", package: "swift-system"),
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
]),
|
.product(
|
||||||
|
name: "EventSource", package: "eventsource",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS, .macCatalyst])),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "MCPConformanceServer",
|
||||||
|
dependencies: [
|
||||||
|
"MCP",
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
|
.product(name: "NIOPosix", package: "swift-nio"),
|
||||||
|
.product(name: "NIOHTTP1", package: "swift-nio"),
|
||||||
|
],
|
||||||
|
path: "Sources/MCPConformance/Server"
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "MCPConformanceClient",
|
||||||
|
dependencies: [
|
||||||
|
"MCP",
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
],
|
||||||
|
path: "Sources/MCPConformance/Client"
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
49
Package@swift-6.0.swift
Normal file
49
Package@swift-6.0.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// swift-tools-version:6.0
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
// Base dependencies needed on all platforms
|
||||||
|
var dependencies: [Package.Dependency] = [
|
||||||
|
.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
|
||||||
|
.package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Target dependencies needed on all platforms
|
||||||
|
var targetDependencies: [Target.Dependency] = [
|
||||||
|
.product(name: "SystemPackage", package: "swift-system"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(
|
||||||
|
name: "EventSource", package: "eventsource",
|
||||||
|
condition: .when(platforms: [.macOS, .iOS, .tvOS, .visionOS, .watchOS])),
|
||||||
|
]
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "mcp-swift-sdk",
|
||||||
|
platforms: [
|
||||||
|
.macOS("13.0"),
|
||||||
|
.macCatalyst("16.0"),
|
||||||
|
.iOS("16.0"),
|
||||||
|
.watchOS("9.0"),
|
||||||
|
.tvOS("16.0"),
|
||||||
|
.visionOS("1.0"),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "MCP",
|
||||||
|
targets: ["MCP"])
|
||||||
|
],
|
||||||
|
dependencies: dependencies,
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "MCP",
|
||||||
|
dependencies: targetDependencies),
|
||||||
|
.testTarget(
|
||||||
|
name: "MCPTests",
|
||||||
|
dependencies: ["MCP"] + targetDependencies),
|
||||||
|
]
|
||||||
|
)
|
||||||
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
Thank you for helping keep the Model Context Protocol and its ecosystem secure.
|
||||||
|
|
||||||
|
## Reporting Security Issues
|
||||||
|
|
||||||
|
If you discover a security vulnerability in this repository, please report it through
|
||||||
|
the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
|
||||||
|
for this repository.
|
||||||
|
|
||||||
|
Please **do not** report security vulnerabilities through public GitHub issues, discussions,
|
||||||
|
or pull requests.
|
||||||
|
|
||||||
|
## What to Include
|
||||||
|
|
||||||
|
To help us triage and respond quickly, please include:
|
||||||
|
|
||||||
|
- A description of the vulnerability
|
||||||
|
- Steps to reproduce the issue
|
||||||
|
- The potential impact
|
||||||
|
- Any suggested fixes (optional)
|
||||||
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - OAuthAuthorizationCodeFlowing Protocol
|
||||||
|
|
||||||
|
/// Internal protocol for driving the OAuth 2.1 authorization code flow.
|
||||||
|
protocol OAuthAuthorizationCodeFlowing: Sendable {
|
||||||
|
func buildURL(
|
||||||
|
authorizationEndpoint: URL,
|
||||||
|
resource: URL,
|
||||||
|
redirectURI: URL,
|
||||||
|
clientID: String,
|
||||||
|
codeChallenge: String,
|
||||||
|
scopes: Set<String>?,
|
||||||
|
state: String,
|
||||||
|
scopeSerializer: any OAuthScopeSelecting
|
||||||
|
) throws -> URL
|
||||||
|
|
||||||
|
func perform(
|
||||||
|
authorizationURL: URL,
|
||||||
|
redirectURI: URL,
|
||||||
|
state: String,
|
||||||
|
delegate: (any OAuthAuthorizationDelegate)?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - No-Redirect Session Delegate
|
||||||
|
|
||||||
|
final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
|
||||||
|
@unchecked Sendable
|
||||||
|
{
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Authorization Code Flow
|
||||||
|
|
||||||
|
/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
|
||||||
|
///
|
||||||
|
/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
|
||||||
|
/// and extracts the authorization code from the callback redirect URL.
|
||||||
|
public struct OAuthAuthorizationCodeFlow: Sendable {
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Builds the authorization request URL.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - authorizationEndpoint: The AS authorization endpoint.
|
||||||
|
/// - resource: The RFC 8707 resource indicator.
|
||||||
|
/// - redirectURI: The redirect URI registered for this client.
|
||||||
|
/// - clientID: The OAuth client identifier.
|
||||||
|
/// - codeChallenge: The PKCE S256 code challenge.
|
||||||
|
/// - scopes: Optional scope set to request.
|
||||||
|
/// - state: The CSRF state nonce.
|
||||||
|
/// - scopeSerializer: Serializes the scope set to a space-separated string.
|
||||||
|
/// - Returns: The full authorization request URL with all query parameters.
|
||||||
|
public func buildURL(
|
||||||
|
authorizationEndpoint: URL,
|
||||||
|
resource: URL,
|
||||||
|
redirectURI: URL,
|
||||||
|
clientID: String,
|
||||||
|
codeChallenge: String,
|
||||||
|
scopes: Set<String>?,
|
||||||
|
state: String,
|
||||||
|
scopeSerializer: any OAuthScopeSelecting
|
||||||
|
) throws -> URL {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: authorizationEndpoint,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else {
|
||||||
|
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem] = [
|
||||||
|
.init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
|
||||||
|
.init(name: OAuthParameterName.clientID, value: clientID),
|
||||||
|
.init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
|
||||||
|
.init(name: OAuthParameterName.state, value: state),
|
||||||
|
.init(name: OAuthParameterName.resource, value: resource.absoluteString),
|
||||||
|
.init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
|
||||||
|
.init(
|
||||||
|
name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
|
||||||
|
]
|
||||||
|
|
||||||
|
if let scope = scopes.flatMap(scopeSerializer.serialize) {
|
||||||
|
queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
|
||||||
|
}
|
||||||
|
|
||||||
|
components.queryItems = queryItems
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the interactive authorization redirect and returns the authorization code.
|
||||||
|
///
|
||||||
|
/// When a delegate is provided, presents the authorization URL and awaits the redirect.
|
||||||
|
/// Without a delegate, sends a GET request and captures the redirect `Location` header.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - authorizationURL: The full authorization request URL.
|
||||||
|
/// - redirectURI: The expected redirect URI base for validation.
|
||||||
|
/// - state: The CSRF state nonce to verify in the redirect.
|
||||||
|
/// - delegate: Optional user-facing delegate for browser-based flows.
|
||||||
|
/// - session: The `URLSession` used for the no-redirect path.
|
||||||
|
/// - Returns: The extracted authorization code.
|
||||||
|
public func perform(
|
||||||
|
authorizationURL: URL,
|
||||||
|
redirectURI: URL,
|
||||||
|
state: String,
|
||||||
|
delegate: (any OAuthAuthorizationDelegate)?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> String {
|
||||||
|
if let delegate {
|
||||||
|
let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
|
||||||
|
return try extractCode(
|
||||||
|
from: redirectURL,
|
||||||
|
expectedRedirectURI: redirectURI,
|
||||||
|
expectedState: state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: authorizationURL)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(
|
||||||
|
"text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
|
||||||
|
|
||||||
|
let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
|
||||||
|
let noRedirectSession = URLSession(
|
||||||
|
configuration: session.configuration,
|
||||||
|
delegate: noRedirectDelegate,
|
||||||
|
delegateQueue: nil
|
||||||
|
)
|
||||||
|
defer { noRedirectSession.invalidateAndCancel() }
|
||||||
|
|
||||||
|
let (_, response) = try await noRedirectSession.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (300..<400).contains(httpResponse.statusCode) else {
|
||||||
|
if httpResponse.statusCode >= 400 {
|
||||||
|
throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
|
||||||
|
!location.isEmpty,
|
||||||
|
let redirectURL = URL(string: location)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
return try extractCode(
|
||||||
|
from: redirectURL,
|
||||||
|
expectedRedirectURI: redirectURI,
|
||||||
|
expectedState: state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts and validates the authorization code from the redirect URL.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - redirectURL: The redirect URL received from the authorization server.
|
||||||
|
/// - expectedRedirectURI: The redirect URI used in the authorization request.
|
||||||
|
/// - expectedState: The CSRF state nonce sent in the authorization request.
|
||||||
|
/// - Returns: The authorization code.
|
||||||
|
public func extractCode(
|
||||||
|
from redirectURL: URL,
|
||||||
|
expectedRedirectURI: URL,
|
||||||
|
expectedState: String
|
||||||
|
) throws -> String {
|
||||||
|
guard
|
||||||
|
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
|
||||||
|
let expectedComponents = URLComponents(
|
||||||
|
url: expectedRedirectURI, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||||
|
expected: expectedRedirectURI.absoluteString,
|
||||||
|
actual: redirectURL.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||||
|
expected: expectedRedirectURI.absoluteString,
|
||||||
|
actual: redirectURL.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let state = redirectComponents.queryItems?.first(where: {
|
||||||
|
$0.name == OAuthParameterName.state
|
||||||
|
})?.value,
|
||||||
|
!state.isEmpty
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseMissingState
|
||||||
|
}
|
||||||
|
|
||||||
|
guard state == expectedState else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseStateMismatch(
|
||||||
|
expected: expectedState,
|
||||||
|
actual: state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let code = redirectComponents.queryItems?.first(where: {
|
||||||
|
$0.name == OAuthParameterName.code
|
||||||
|
})?.value,
|
||||||
|
!code.isEmpty
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.authorizationResponseMissingCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func normalizedRedirectBase(_ components: URLComponents) -> String {
|
||||||
|
let scheme = components.scheme?.lowercased() ?? ""
|
||||||
|
let host = components.host?.lowercased() ?? ""
|
||||||
|
let port: Int
|
||||||
|
if let explicitPort = components.port {
|
||||||
|
port = explicitPort
|
||||||
|
} else if scheme == OAuthURLScheme.https {
|
||||||
|
port = OAuthDefaultPort.https
|
||||||
|
} else if scheme == OAuthURLScheme.http {
|
||||||
|
port = OAuthDefaultPort.http
|
||||||
|
} else {
|
||||||
|
port = -1
|
||||||
|
}
|
||||||
|
let path = components.path.isEmpty ? "/" : components.path
|
||||||
|
return "\(scheme)://\(host):\(port)\(path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}
|
||||||
873
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
873
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
|
|
@ -0,0 +1,873 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(CryptoKit)
|
||||||
|
import CryptoKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - HTTPClientAuthorizer Protocol
|
||||||
|
|
||||||
|
/// Abstraction used by ``HTTPClientTransport`` to handle OAuth authorization challenges.
|
||||||
|
///
|
||||||
|
/// Implement this protocol to provide custom token acquisition strategies,
|
||||||
|
/// or use the built-in ``OAuthAuthorizer`` for a full OAuth 2.1 implementation.
|
||||||
|
///
|
||||||
|
/// ``HTTPClientTransport`` calls these methods automatically when it receives
|
||||||
|
/// `401 Unauthorized` or `403 Forbidden` responses from the server.
|
||||||
|
public protocol HTTPClientAuthorizer: AnyObject, Sendable {
|
||||||
|
/// The maximum number of authorization retries permitted for a single request.
|
||||||
|
///
|
||||||
|
/// ``HTTPClientTransport`` will not call ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)``
|
||||||
|
/// more than this many times for a single outgoing request.
|
||||||
|
var maxAuthorizationAttempts: Int { get }
|
||||||
|
|
||||||
|
/// Validates that the MCP endpoint URL satisfies the security requirements for OAuth.
|
||||||
|
///
|
||||||
|
/// Called once before the first request is sent. Throw ``OAuthAuthorizationError/insecureOAuthEndpoint(context:url:)``
|
||||||
|
/// if the URL does not meet the requirements (e.g., non-HTTPS non-loopback).
|
||||||
|
/// - Parameter endpoint: The MCP endpoint URL to validate.
|
||||||
|
func validateEndpointSecurity(for endpoint: URL) throws
|
||||||
|
|
||||||
|
/// Returns the `Authorization` header value to attach to the next request, if a valid token is available.
|
||||||
|
///
|
||||||
|
/// - Parameter endpoint: The MCP endpoint being requested.
|
||||||
|
/// - Returns: A `"Bearer <token>"` string, or `nil` if no valid token is cached.
|
||||||
|
func authorizationHeader(for endpoint: URL) -> String?
|
||||||
|
|
||||||
|
/// Handles an authorization challenge received from the server and attempts to acquire a new token.
|
||||||
|
///
|
||||||
|
/// Called by ``HTTPClientTransport`` when a `401` or `403` response is received.
|
||||||
|
/// The implementation should attempt to obtain a valid access token and store it
|
||||||
|
/// so that a subsequent call to ``authorizationHeader(for:)`` returns the new value.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - statusCode: The HTTP status code (401 or 403).
|
||||||
|
/// - headers: All response headers from the challenge response.
|
||||||
|
/// - endpoint: The MCP endpoint that returned the challenge.
|
||||||
|
/// - operationKey: An optional identifier for the MCP operation (e.g., the JSON-RPC method),
|
||||||
|
/// used to track step-up attempts per operation.
|
||||||
|
/// - session: The `URLSession` to use for discovery and token requests.
|
||||||
|
/// - Returns: `true` if a new token was acquired and the original request should be retried;
|
||||||
|
/// `false` if the challenge cannot be handled.
|
||||||
|
func handleChallenge(
|
||||||
|
statusCode: Int,
|
||||||
|
headers: [String: String],
|
||||||
|
endpoint: URL,
|
||||||
|
operationKey: String?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> Bool
|
||||||
|
|
||||||
|
/// Proactively refreshes the access token if it is close to expiry.
|
||||||
|
///
|
||||||
|
/// Called by ``HTTPClientTransport`` before sending each request and before opening
|
||||||
|
/// an SSE stream, allowing the token to be silently renewed without a 401 round-trip.
|
||||||
|
/// Implementations should swallow refresh errors — if refresh fails, the normal
|
||||||
|
/// ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)`` path recovers.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: The MCP endpoint about to be contacted.
|
||||||
|
/// - session: The `URLSession` to use for token refresh requests.
|
||||||
|
func prepareAuthorization(for endpoint: URL, session: URLSession) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTTPClientAuthorizer {
|
||||||
|
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuthAuthorizer
|
||||||
|
|
||||||
|
/// Full OAuth 2.1 implementation of ``HTTPClientAuthorizer``.
|
||||||
|
///
|
||||||
|
/// `OAuthAuthorizer` orchestrates the complete MCP authorization flow on behalf of an HTTP client:
|
||||||
|
///
|
||||||
|
/// 1. **Protected Resource Metadata discovery** (RFC 9728) — fetches
|
||||||
|
/// `/.well-known/oauth-protected-resource` to locate the authorization server.
|
||||||
|
/// 2. **Authorization Server Metadata discovery** (RFC 8414 / OIDC Discovery 1.0) — fetches
|
||||||
|
/// `/.well-known/oauth-authorization-server` or `/.well-known/openid-configuration`.
|
||||||
|
/// 3. **Dynamic Client Registration** (RFC 7591) — registers the client if no credentials are
|
||||||
|
/// pre-configured and the AS advertises a registration endpoint.
|
||||||
|
/// 4. **Token acquisition** — performs the configured grant flow (`authorization_code` with PKCE,
|
||||||
|
/// or `client_credentials`), binding tokens to the resource indicator (RFC 8707).
|
||||||
|
/// 5. **Token refresh** — attempts a `refresh_token` grant before a full re-authorization.
|
||||||
|
/// 6. **Scope step-up** — handles `403 insufficient_scope` challenges by re-requesting with
|
||||||
|
/// the union of existing and required scopes.
|
||||||
|
///
|
||||||
|
/// Pass an instance to `HTTPClientTransport(authorizer:)` to enable automatic authorization:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let config = OAuthConfiguration(
|
||||||
|
/// grantType: .clientCredentials,
|
||||||
|
/// authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
|
||||||
|
/// )
|
||||||
|
/// let authorizer = OAuthAuthorizer(configuration: config)
|
||||||
|
/// let transport = HTTPClientTransport(endpoint: serverURL, authorizer: authorizer)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// - Important: This type is `@unchecked Sendable`. All mutable state is accessed
|
||||||
|
/// exclusively through the `HTTPClientTransport` actor, which serializes every call.
|
||||||
|
/// Do **not** share a single `OAuthAuthorizer` instance across multiple transports —
|
||||||
|
/// doing so would violate the isolation contract and risk concurrent mutation.
|
||||||
|
public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Mutable State
|
||||||
|
|
||||||
|
private var configuration: OAuthConfiguration
|
||||||
|
private let tokenStorage: TokenStorage
|
||||||
|
private var selectedAuthorizationServer: URL?
|
||||||
|
private var protectedResourceMetadata: OAuthProtectedResourceMetadata?
|
||||||
|
private var authorizationServerMetadata: OAuthAuthorizationServerMetadata?
|
||||||
|
private var cachedProtectedResourceMetadataURL: URL?
|
||||||
|
private var stepUpAttempts: [String: Int] = [:]
|
||||||
|
private var clientRegistrationAttempted = false
|
||||||
|
private var clientSecretExpiresAt: Date?
|
||||||
|
|
||||||
|
// MARK: - Composable Dependencies
|
||||||
|
|
||||||
|
private let scopeSelector: any OAuthScopeSelecting
|
||||||
|
private let challengeParser: any OAuthWWWAuthenticateParsing
|
||||||
|
private let urlValidator: any OAuthURLValidating
|
||||||
|
private let discoveryClient: any OAuthDiscoveryFetching
|
||||||
|
private let tokenEndpointClient: any OAuthTokenRequesting
|
||||||
|
private let clientRegistrar: any OAuthClientRegistering
|
||||||
|
private let authCodeFlow: any OAuthAuthorizationCodeFlowing
|
||||||
|
|
||||||
|
/// Creates an `OAuthAuthorizer` with the given configuration and optional injectable dependencies.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - configuration: OAuth 2.1 configuration controlling the grant type, authentication method,
|
||||||
|
/// endpoint discovery overrides, and retry policy.
|
||||||
|
/// - tokenStorage: Stores acquired access tokens. Defaults to ``InMemoryTokenStorage``,
|
||||||
|
/// which loses tokens when the process exits. Supply a Keychain-backed implementation
|
||||||
|
/// to persist tokens across sessions.
|
||||||
|
/// - scopeSelector: Strategy for selecting OAuth scopes from challenge and metadata hints.
|
||||||
|
/// Defaults to ``DefaultOAuthScopeSelector``.
|
||||||
|
/// - challengeParser: Parses `WWW-Authenticate: Bearer` challenge headers.
|
||||||
|
/// Defaults to ``DefaultOAuthWWWAuthenticateParser``.
|
||||||
|
/// - metadataDiscovery: Constructs well-known discovery URLs and validates resource URI matching.
|
||||||
|
/// Defaults to ``DefaultOAuthMetadataDiscovery``.
|
||||||
|
public convenience init(
|
||||||
|
configuration: OAuthConfiguration,
|
||||||
|
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
||||||
|
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
||||||
|
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
||||||
|
metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
|
||||||
|
) {
|
||||||
|
let urlValidator = OAuthURLValidator(
|
||||||
|
allowLoopbackHTTPForAuthorizationServer:
|
||||||
|
configuration.allowLoopbackHTTPAuthorizationServerEndpoints
|
||||||
|
)
|
||||||
|
self.init(
|
||||||
|
configuration: configuration,
|
||||||
|
tokenStorage: tokenStorage,
|
||||||
|
scopeSelector: scopeSelector,
|
||||||
|
challengeParser: challengeParser,
|
||||||
|
urlValidator: urlValidator,
|
||||||
|
discoveryClient: OAuthDiscoveryClient(
|
||||||
|
metadataDiscovery: metadataDiscovery,
|
||||||
|
urlValidator: urlValidator
|
||||||
|
),
|
||||||
|
tokenEndpointClient: OAuthTokenEndpointClient(urlValidator: urlValidator),
|
||||||
|
clientRegistrar: OAuthClientRegistrar(urlValidator: urlValidator),
|
||||||
|
authCodeFlow: OAuthAuthorizationCodeFlow()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
configuration: OAuthConfiguration,
|
||||||
|
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
||||||
|
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
||||||
|
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
||||||
|
urlValidator: any OAuthURLValidating,
|
||||||
|
discoveryClient: any OAuthDiscoveryFetching,
|
||||||
|
tokenEndpointClient: any OAuthTokenRequesting,
|
||||||
|
clientRegistrar: any OAuthClientRegistering,
|
||||||
|
authCodeFlow: any OAuthAuthorizationCodeFlowing
|
||||||
|
) {
|
||||||
|
self.configuration = configuration
|
||||||
|
self.tokenStorage = tokenStorage
|
||||||
|
self.scopeSelector = scopeSelector
|
||||||
|
self.challengeParser = challengeParser
|
||||||
|
self.urlValidator = urlValidator
|
||||||
|
self.discoveryClient = discoveryClient
|
||||||
|
self.tokenEndpointClient = tokenEndpointClient
|
||||||
|
self.clientRegistrar = clientRegistrar
|
||||||
|
self.authCodeFlow = authCodeFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTPClientAuthorizer
|
||||||
|
|
||||||
|
public var maxAuthorizationAttempts: Int {
|
||||||
|
configuration.retryPolicy.maxAuthorizationAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validateEndpointSecurity(for endpoint: URL) throws {
|
||||||
|
try urlValidator.validateHTTPSOrLoopback(endpoint, context: "MCP endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authorizationHeader(for endpoint: URL) -> String? {
|
||||||
|
guard let accessToken = tokenStorage.load() else { return nil }
|
||||||
|
if let tokenAuthorizationServer = accessToken.authorizationServer,
|
||||||
|
let selectedAuthorizationServer,
|
||||||
|
!authorizationServersMatch(tokenAuthorizationServer, selectedAuthorizationServer)
|
||||||
|
{
|
||||||
|
tokenStorage.clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if accessToken.isExpired() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "\(OAuthTokenType.bearer) \(accessToken.value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handleChallenge(
|
||||||
|
statusCode: Int,
|
||||||
|
headers: [String: String],
|
||||||
|
endpoint: URL,
|
||||||
|
operationKey: String? = nil,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> Bool {
|
||||||
|
try validateEndpointSecurity(for: endpoint)
|
||||||
|
let challenge = challengeParser.parseBearer(from: headers)
|
||||||
|
|
||||||
|
switch statusCode {
|
||||||
|
case 401:
|
||||||
|
if let refreshToken = tokenStorage.load()?.refreshToken {
|
||||||
|
tokenStorage.clear()
|
||||||
|
let metadata = try await discoverProtectedResourceMetadata(
|
||||||
|
endpoint: endpoint,
|
||||||
|
challenge: challenge,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||||
|
metadata: metadata,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
let resource = try canonicalResource(for: endpoint)
|
||||||
|
let requestedScopes = scopeSelector.selectScopes(
|
||||||
|
challengeScope: challenge?.scope,
|
||||||
|
scopesSupported: metadata.scopesSupported
|
||||||
|
)
|
||||||
|
if try await refreshAccessToken(
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
resource: resource,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
asMetadata: asMetadata,
|
||||||
|
session: session
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokenStorage.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = try await discoverProtectedResourceMetadata(
|
||||||
|
endpoint: endpoint,
|
||||||
|
challenge: challenge,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
let requestedScopes = scopeSelector.selectScopes(
|
||||||
|
challengeScope: challenge?.scope,
|
||||||
|
scopesSupported: metadata.scopesSupported
|
||||||
|
)
|
||||||
|
|
||||||
|
let providerContext = try await makeAccessTokenProviderContext(
|
||||||
|
statusCode: statusCode,
|
||||||
|
endpoint: endpoint,
|
||||||
|
challenge: challenge,
|
||||||
|
metadata: metadata,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
if let externalToken = try await fetchAccessTokenFromProvider(
|
||||||
|
context: providerContext,
|
||||||
|
session: session
|
||||||
|
) {
|
||||||
|
storeExternalAccessToken(
|
||||||
|
externalToken,
|
||||||
|
requestedScopes: providerContext.requestedScopes,
|
||||||
|
authorizationServer: providerContext.authorizationServer
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try await acquireToken(
|
||||||
|
endpoint: endpoint,
|
||||||
|
metadata: metadata,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
guard challenge?.error?.lowercased() == "insufficient_scope" else { return false }
|
||||||
|
|
||||||
|
let metadata = try await discoverProtectedResourceMetadata(
|
||||||
|
endpoint: endpoint,
|
||||||
|
challenge: challenge,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
let requiredScopes =
|
||||||
|
scopeSelector.selectScopes(
|
||||||
|
challengeScope: challenge?.scope,
|
||||||
|
scopesSupported: metadata.scopesSupported
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
let existingScopes = tokenStorage.load()?.scopes ?? []
|
||||||
|
let upgradedScopes = existingScopes.union(requiredScopes)
|
||||||
|
let resourceKey = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||||
|
from: endpoint
|
||||||
|
).absoluteString
|
||||||
|
let operationAttemptKey = normalizedOperationKey(operationKey)
|
||||||
|
let attemptKey =
|
||||||
|
"\(resourceKey)|\(operationAttemptKey)|\(upgradedScopes.sorted().joined(separator: " "))"
|
||||||
|
let attempts = stepUpAttempts[attemptKey, default: 0]
|
||||||
|
guard attempts < configuration.retryPolicy.maxScopeUpgradeAttempts else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stepUpAttempts[attemptKey] = attempts + 1
|
||||||
|
|
||||||
|
let providerRequestedScopes = upgradedScopes.isEmpty ? nil : upgradedScopes
|
||||||
|
let providerContext = try await makeAccessTokenProviderContext(
|
||||||
|
statusCode: statusCode,
|
||||||
|
endpoint: endpoint,
|
||||||
|
challenge: challenge,
|
||||||
|
metadata: metadata,
|
||||||
|
requestedScopes: providerRequestedScopes,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
if let externalToken = try await fetchAccessTokenFromProvider(
|
||||||
|
context: providerContext,
|
||||||
|
session: session
|
||||||
|
) {
|
||||||
|
storeExternalAccessToken(
|
||||||
|
externalToken,
|
||||||
|
requestedScopes: providerContext.requestedScopes,
|
||||||
|
authorizationServer: providerContext.authorizationServer
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try await acquireToken(
|
||||||
|
endpoint: endpoint,
|
||||||
|
metadata: metadata,
|
||||||
|
requestedScopes: upgradedScopes,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
|
||||||
|
guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
|
||||||
|
guard let token = tokenStorage.load() else { return }
|
||||||
|
guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let refreshToken = token.refreshToken else { return }
|
||||||
|
guard let asMeta = authorizationServerMetadata, asMeta.tokenEndpoint != nil else { return }
|
||||||
|
|
||||||
|
let resource: URL
|
||||||
|
do {
|
||||||
|
resource = try canonicalResource(for: endpoint)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestedScopes = token.scopes.isEmpty ? nil : token.scopes
|
||||||
|
_ = try? await refreshAccessToken(
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
resource: resource,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
asMetadata: asMeta,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Discovery
|
||||||
|
|
||||||
|
private func discoverProtectedResourceMetadata(
|
||||||
|
endpoint: URL,
|
||||||
|
challenge: OAuthBearerChallenge?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> OAuthProtectedResourceMetadata {
|
||||||
|
if let protectedResourceMetadata {
|
||||||
|
let incomingURL = challenge?.resourceMetadataURL
|
||||||
|
if let incomingURL, incomingURL != cachedProtectedResourceMetadataURL {
|
||||||
|
self.protectedResourceMetadata = nil
|
||||||
|
self.authorizationServerMetadata = nil
|
||||||
|
self.selectedAuthorizationServer = nil
|
||||||
|
self.cachedProtectedResourceMetadataURL = nil
|
||||||
|
} else {
|
||||||
|
return protectedResourceMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates: [URL] = []
|
||||||
|
|
||||||
|
if let challengeURL = challenge?.resourceMetadataURL {
|
||||||
|
try urlValidator.validateHTTPSOrLoopback(
|
||||||
|
challengeURL, context: "Protected resource metadata URL")
|
||||||
|
if let host = URLComponents(url: challengeURL, resolvingAgainstBaseURL: false)?.host?
|
||||||
|
.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||||
|
{
|
||||||
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||||
|
context: "Protected resource metadata URL",
|
||||||
|
url: challengeURL.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
candidates.append(challengeURL)
|
||||||
|
}
|
||||||
|
if let configuredURL = configuration.endpointOverrides.protectedResourceMetadataURL,
|
||||||
|
!candidates.contains(configuredURL)
|
||||||
|
{
|
||||||
|
try urlValidator.validateHTTPSOrLoopback(
|
||||||
|
configuredURL,
|
||||||
|
context: "Configured protected resource metadata URL"
|
||||||
|
)
|
||||||
|
candidates.append(configuredURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
for fallback in discoveryClient.metadataDiscovery.protectedResourceMetadataURLs(
|
||||||
|
for: endpoint)
|
||||||
|
where !candidates.contains(fallback) {
|
||||||
|
candidates.append(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackIssuer = try? discoveryClient.metadataDiscovery
|
||||||
|
.authorizationServerFallbackIssuer(from: endpoint)
|
||||||
|
let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
||||||
|
candidates: candidates, fallbackIssuer: fallbackIssuer, session: session)
|
||||||
|
try validateProtectedResource(metadata: metadata, endpoint: endpoint)
|
||||||
|
|
||||||
|
self.protectedResourceMetadata = metadata
|
||||||
|
self.cachedProtectedResourceMetadataURL = candidates.first
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateProtectedResource(
|
||||||
|
metadata: OAuthProtectedResourceMetadata, endpoint: URL
|
||||||
|
) throws {
|
||||||
|
guard let resource = metadata.resource?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!resource.isEmpty
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let resourceURL = URL(string: resource) else {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI(
|
||||||
|
"Protected resource metadata contains an invalid resource URI: \(resource)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: endpoint)
|
||||||
|
let actual = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: resourceURL)
|
||||||
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||||
|
resource: actual, endpoint: expected)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||||
|
expected: expected.absoluteString,
|
||||||
|
actual: actual.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAuthorizationServerMetadata(
|
||||||
|
metadata: OAuthProtectedResourceMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> OAuthAuthorizationServerMetadata {
|
||||||
|
if let cached = authorizationServerMetadata {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates: [URL]
|
||||||
|
if let override = configuration.endpointOverrides.authorizationServerURL {
|
||||||
|
try urlValidator.validateAuthorizationServer(
|
||||||
|
override, context: "Authorization server issuer")
|
||||||
|
candidates = [override]
|
||||||
|
} else if let selected = selectedAuthorizationServer {
|
||||||
|
candidates = [selected]
|
||||||
|
} else {
|
||||||
|
guard !metadata.authorizationServers.isEmpty else {
|
||||||
|
throw OAuthAuthorizationError.missingAuthorizationServer
|
||||||
|
}
|
||||||
|
candidates = metadata.authorizationServers
|
||||||
|
}
|
||||||
|
|
||||||
|
let (server, asMetadata) = try await discoveryClient.fetchAuthorizationServerMetadata(
|
||||||
|
candidates: candidates, session: session)
|
||||||
|
self.selectedAuthorizationServer = server
|
||||||
|
self.authorizationServerMetadata = asMetadata
|
||||||
|
return asMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Acquisition
|
||||||
|
|
||||||
|
private func acquireToken(
|
||||||
|
endpoint: URL,
|
||||||
|
metadata: OAuthProtectedResourceMetadata,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws {
|
||||||
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||||
|
metadata: metadata, session: session)
|
||||||
|
try await maybeRegisterClient(asMetadata: asMetadata, session: session)
|
||||||
|
let resource = try canonicalResource(for: endpoint)
|
||||||
|
|
||||||
|
switch configuration.grantType {
|
||||||
|
case .clientCredentials:
|
||||||
|
try await acquireTokenViaClientCredentials(
|
||||||
|
resource: resource,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
asMetadata: asMetadata,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
case .authorizationCode:
|
||||||
|
try await acquireTokenViaAuthorizationCode(
|
||||||
|
resource: resource,
|
||||||
|
requestedScopes: requestedScopes,
|
||||||
|
asMetadata: asMetadata,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquireTokenViaClientCredentials(
|
||||||
|
resource: URL,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws {
|
||||||
|
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||||
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||||
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.clientCredentials
|
||||||
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||||
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||||
|
bodyParameters[OAuthParameterName.scope] = scope
|
||||||
|
}
|
||||||
|
let decoded = try await tokenEndpointClient.request(
|
||||||
|
parameters: &bodyParameters,
|
||||||
|
endpoint: tokenEndpoint,
|
||||||
|
authentication: configuration.authentication,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquireTokenViaAuthorizationCode(
|
||||||
|
resource: URL,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws {
|
||||||
|
guard let authorizationEndpoint = asMetadata.authorizationEndpoint else {
|
||||||
|
throw OAuthAuthorizationError.tokenEndpointMissing
|
||||||
|
}
|
||||||
|
try urlValidator.validateAuthorizationServer(
|
||||||
|
authorizationEndpoint, context: "Authorization endpoint")
|
||||||
|
if let host = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false)?
|
||||||
|
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||||
|
{
|
||||||
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||||
|
context: "Authorization endpoint",
|
||||||
|
url: authorizationEndpoint.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try urlValidator.validateRedirectURI(configuration.authorizationRedirectURI)
|
||||||
|
try PKCE.checkSupport(in: asMetadata)
|
||||||
|
|
||||||
|
let verifier = PKCE.makeVerifier()
|
||||||
|
let challenge = try PKCE.makeChallenge(from: verifier)
|
||||||
|
let state = UUID().uuidString
|
||||||
|
|
||||||
|
let authorizationURL = try authCodeFlow.buildURL(
|
||||||
|
authorizationEndpoint: authorizationEndpoint,
|
||||||
|
resource: resource,
|
||||||
|
redirectURI: configuration.authorizationRedirectURI,
|
||||||
|
clientID: configuration.authentication.clientID,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
scopes: requestedScopes,
|
||||||
|
state: state,
|
||||||
|
scopeSerializer: scopeSelector
|
||||||
|
)
|
||||||
|
|
||||||
|
let authorizationCode = try await authCodeFlow.perform(
|
||||||
|
authorizationURL: authorizationURL,
|
||||||
|
redirectURI: configuration.authorizationRedirectURI,
|
||||||
|
state: state,
|
||||||
|
delegate: configuration.authorizationDelegate,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
|
||||||
|
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||||
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||||
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.authorizationCode
|
||||||
|
bodyParameters[OAuthParameterName.code] = authorizationCode
|
||||||
|
bodyParameters[OAuthParameterName.codeVerifier] = verifier
|
||||||
|
bodyParameters[OAuthParameterName.redirectURI] =
|
||||||
|
configuration.authorizationRedirectURI.absoluteString
|
||||||
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||||
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||||
|
bodyParameters[OAuthParameterName.scope] = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try await tokenEndpointClient.request(
|
||||||
|
parameters: &bodyParameters,
|
||||||
|
endpoint: tokenEndpoint,
|
||||||
|
authentication: configuration.authentication,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Refresh
|
||||||
|
|
||||||
|
private func refreshAccessToken(
|
||||||
|
refreshToken: String,
|
||||||
|
resource: URL,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> Bool {
|
||||||
|
let tokenEndpoint: URL
|
||||||
|
do {
|
||||||
|
tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||||
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.refreshToken
|
||||||
|
bodyParameters[OAuthParameterName.refreshToken] = refreshToken
|
||||||
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||||
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||||
|
bodyParameters[OAuthParameterName.scope] = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try await tokenEndpointClient.request(
|
||||||
|
parameters: &bodyParameters,
|
||||||
|
endpoint: tokenEndpoint,
|
||||||
|
authentication: configuration.authentication,
|
||||||
|
session: session
|
||||||
|
)
|
||||||
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||||
|
return true
|
||||||
|
} catch let error as OAuthAuthorizationError {
|
||||||
|
if case .tokenRequestFailed(let statusCode, _) = error,
|
||||||
|
(400..<500).contains(statusCode)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Registration
|
||||||
|
|
||||||
|
private func maybeRegisterClient(
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws {
|
||||||
|
if let expiry = clientSecretExpiresAt, Date() >= expiry {
|
||||||
|
clientSecretExpiresAt = nil
|
||||||
|
clientRegistrationAttempted = false
|
||||||
|
configuration.authentication = .none(clientID: configuration.authentication.clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !clientRegistrationAttempted else { return }
|
||||||
|
guard case .none = configuration.authentication else { return }
|
||||||
|
|
||||||
|
clientRegistrationAttempted = true
|
||||||
|
|
||||||
|
if let (registration, updatedAuth) = try await clientRegistrar.register(
|
||||||
|
configuration: configuration,
|
||||||
|
asMetadata: asMetadata,
|
||||||
|
session: session
|
||||||
|
) {
|
||||||
|
configuration.authentication = updatedAuth
|
||||||
|
if let expiresAt = registration.clientSecretExpiresAt, expiresAt > 0 {
|
||||||
|
clientSecretExpiresAt = Date(timeIntervalSince1970: Double(expiresAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Helpers
|
||||||
|
|
||||||
|
private func storeTokenResponse(
|
||||||
|
_ decoded: OAuthTokenResponse,
|
||||||
|
requestedScopes: Set<String>?
|
||||||
|
) {
|
||||||
|
let scopeSet: Set<String>
|
||||||
|
if let scope = decoded.scope {
|
||||||
|
scopeSet = scopeSelector.parseScopeString(scope)
|
||||||
|
} else {
|
||||||
|
scopeSet = requestedScopes ?? []
|
||||||
|
}
|
||||||
|
let expiresAt = decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) }
|
||||||
|
tokenStorage.save(OAuthAccessToken(
|
||||||
|
value: decoded.accessToken,
|
||||||
|
tokenType: OAuthTokenType.bearer,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
scopes: scopeSet,
|
||||||
|
authorizationServer: selectedAuthorizationServer,
|
||||||
|
refreshToken: decoded.refreshToken,
|
||||||
|
clientID: nonEmptyClientID()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured `client_id` or `nil` if the authorizer has not yet been assigned one.
|
||||||
|
private func nonEmptyClientID() -> String? {
|
||||||
|
let id = configuration.authentication.clientID
|
||||||
|
return id.isEmpty ? nil : id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveTokenEndpoint(
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata
|
||||||
|
) throws -> URL {
|
||||||
|
if let configuredEndpoint = configuration.endpointOverrides.tokenEndpoint {
|
||||||
|
try urlValidator.validateAuthorizationServer(
|
||||||
|
configuredEndpoint, context: "Configured token endpoint")
|
||||||
|
return configuredEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let tokenEndpoint = asMetadata.tokenEndpoint else {
|
||||||
|
throw OAuthAuthorizationError.tokenEndpointMissing
|
||||||
|
}
|
||||||
|
try urlValidator.validateAuthorizationServer(tokenEndpoint, context: "Token endpoint")
|
||||||
|
if let host = URLComponents(url: tokenEndpoint, resolvingAgainstBaseURL: false)?.host?
|
||||||
|
.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||||
|
{
|
||||||
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||||
|
context: "Token endpoint",
|
||||||
|
url: tokenEndpoint.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return tokenEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canonicalResource(for endpoint: URL) throws -> URL {
|
||||||
|
let endpointCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||||
|
from: endpoint)
|
||||||
|
|
||||||
|
if let configuredResource = configuration.endpointOverrides.resource {
|
||||||
|
let configuredCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||||
|
from: configuredResource)
|
||||||
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||||
|
resource: configuredCanonical, endpoint: endpointCanonical)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||||
|
expected: endpointCanonical.absoluteString,
|
||||||
|
actual: configuredCanonical.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return configuredCanonical
|
||||||
|
}
|
||||||
|
|
||||||
|
if let prmResourceString = protectedResourceMetadata?.resource,
|
||||||
|
let prmResourceURL = URL(string: prmResourceString)
|
||||||
|
{
|
||||||
|
let prmCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||||
|
from: prmResourceURL)
|
||||||
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||||
|
resource: prmCanonical, endpoint: endpointCanonical)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||||
|
expected: endpointCanonical.absoluteString,
|
||||||
|
actual: prmCanonical.absoluteString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return prmCanonical
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpointCanonical
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authorizationServersMatch(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||||
|
normalizedAuthorizationServer(lhs) == normalizedAuthorizationServer(rhs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedAuthorizationServer(_ url: URL) -> URL? {
|
||||||
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
scheme == OAuthURLScheme.http || scheme == OAuthURLScheme.https
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
components.scheme = scheme
|
||||||
|
components.host = host
|
||||||
|
components.query = nil
|
||||||
|
components.fragment = nil
|
||||||
|
if components.path == "/" { components.path = "" }
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - External Token Provider
|
||||||
|
|
||||||
|
private func fetchAccessTokenFromProvider(
|
||||||
|
context: OAuthConfiguration.AccessTokenProviderContext,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> String? {
|
||||||
|
guard let provider = configuration.accessTokenProvider else { return nil }
|
||||||
|
guard let token = try await provider(context, session), !token.isEmpty else { return nil }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storeExternalAccessToken(
|
||||||
|
_ token: String,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
authorizationServer: URL?
|
||||||
|
) {
|
||||||
|
tokenStorage.save(OAuthAccessToken(
|
||||||
|
value: token,
|
||||||
|
tokenType: OAuthTokenType.bearer,
|
||||||
|
expiresAt: nil,
|
||||||
|
scopes: requestedScopes ?? [],
|
||||||
|
authorizationServer: authorizationServer,
|
||||||
|
refreshToken: nil,
|
||||||
|
clientID: nonEmptyClientID()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAccessTokenProviderContext(
|
||||||
|
statusCode: Int,
|
||||||
|
endpoint: URL,
|
||||||
|
challenge: OAuthBearerChallenge?,
|
||||||
|
metadata: OAuthProtectedResourceMetadata,
|
||||||
|
requestedScopes: Set<String>?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> OAuthConfiguration.AccessTokenProviderContext {
|
||||||
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||||
|
metadata: metadata, session: session)
|
||||||
|
let resource = try canonicalResource(for: endpoint)
|
||||||
|
let authorizationServer = configuration.endpointOverrides.authorizationServerURL
|
||||||
|
?? selectedAuthorizationServer
|
||||||
|
?? metadata.authorizationServers.first
|
||||||
|
let tokenEndpoint = configuration.endpointOverrides.tokenEndpoint ?? asMetadata.tokenEndpoint
|
||||||
|
|
||||||
|
return OAuthConfiguration.AccessTokenProviderContext(
|
||||||
|
statusCode: statusCode,
|
||||||
|
endpoint: endpoint,
|
||||||
|
resource: resource,
|
||||||
|
authorizationServer: authorizationServer,
|
||||||
|
authorizationEndpoint: asMetadata.authorizationEndpoint,
|
||||||
|
tokenEndpoint: tokenEndpoint,
|
||||||
|
registrationEndpoint: asMetadata.registrationEndpoint,
|
||||||
|
challengedScope: challenge?.scope,
|
||||||
|
scopesSupported: metadata.scopesSupported,
|
||||||
|
requestedScopes: requestedScopes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedOperationKey(_ operationKey: String?) -> String {
|
||||||
|
guard let operationKey else { return "<unknown>" }
|
||||||
|
let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return normalized.isEmpty ? "<unknown>" : normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Internal protocol for OAuth dynamic client registration.
|
||||||
|
protocol OAuthClientRegistering: Sendable {
|
||||||
|
func register(
|
||||||
|
configuration: OAuthConfiguration,
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> (
|
||||||
|
response: OAuthClientRegistrationResponse,
|
||||||
|
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateless OAuth dynamic client registration logic.
|
||||||
|
///
|
||||||
|
/// Handles Client ID Metadata Document (CIMD) detection and RFC 7591 dynamic registration.
|
||||||
|
/// State tracking (`clientRegistrationAttempted`, `clientSecretExpiresAt`) is the caller's responsibility.
|
||||||
|
struct OAuthClientRegistrar: Sendable {
|
||||||
|
let urlValidator: OAuthURLValidator
|
||||||
|
|
||||||
|
init(urlValidator: OAuthURLValidator) {
|
||||||
|
self.urlValidator = urlValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to register the client, if applicable.
|
||||||
|
///
|
||||||
|
/// Returns `nil` if registration is not needed:
|
||||||
|
/// - Credentials are already configured (not `.none`)
|
||||||
|
/// - CIMD is in use and the server supports it (pre-registered)
|
||||||
|
/// - No registration endpoint is available and no CIMD mismatch error
|
||||||
|
///
|
||||||
|
/// Throws if registration was attempted but failed (4xx, 5xx, or unexpected response).
|
||||||
|
func register(
|
||||||
|
configuration: OAuthConfiguration,
|
||||||
|
asMetadata: OAuthAuthorizationServerMetadata,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> (
|
||||||
|
response: OAuthClientRegistrationResponse,
|
||||||
|
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||||
|
)? {
|
||||||
|
guard case .none(let clientID) = configuration.authentication else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasClientIDMetadataDocument = isHTTPSURLWithPath(clientID)
|
||||||
|
let supportsClientIDMetadataDocument = asMetadata.clientIDMetadataDocumentSupported == true
|
||||||
|
|
||||||
|
if supportsClientIDMetadataDocument,
|
||||||
|
clientIDLooksLikeURL(clientID),
|
||||||
|
!hasClientIDMetadataDocument
|
||||||
|
{
|
||||||
|
throw OAuthAuthorizationError.invalidClientIDMetadataURL(clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasClientIDMetadataDocument, supportsClientIDMetadataDocument {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let registrationEndpoint = asMetadata.registrationEndpoint else {
|
||||||
|
if hasClientIDMetadataDocument && !supportsClientIDMetadataDocument {
|
||||||
|
throw OAuthAuthorizationError.cimdNotSupported(clientID: clientID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
try urlValidator.validateAuthorizationServer(
|
||||||
|
registrationEndpoint, context: "Client registration endpoint")
|
||||||
|
|
||||||
|
var request = URLRequest(url: registrationEndpoint)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||||
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||||
|
|
||||||
|
let grantTypes: [String]
|
||||||
|
let responseTypes: [String]
|
||||||
|
switch configuration.grantType {
|
||||||
|
case .authorizationCode:
|
||||||
|
grantTypes = [OAuthGrantTypeValue.authorizationCode, OAuthGrantTypeValue.refreshToken]
|
||||||
|
responseTypes = [OAuthParameterName.code]
|
||||||
|
case .clientCredentials:
|
||||||
|
grantTypes = [OAuthGrantTypeValue.clientCredentials]
|
||||||
|
responseTypes = []
|
||||||
|
}
|
||||||
|
|
||||||
|
var registrationPayload: [String: Any] = [
|
||||||
|
"client_name": configuration.clientName,
|
||||||
|
"grant_types": grantTypes,
|
||||||
|
"token_endpoint_auth_method": configuration.authentication.methodName,
|
||||||
|
]
|
||||||
|
if !responseTypes.isEmpty {
|
||||||
|
registrationPayload["response_types"] = responseTypes
|
||||||
|
}
|
||||||
|
if configuration.grantType == .authorizationCode {
|
||||||
|
registrationPayload["redirect_uris"] = [
|
||||||
|
configuration.authorizationRedirectURI.absoluteString
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpBody = try JSONSerialization.data(withJSONObject: registrationPayload)
|
||||||
|
|
||||||
|
request.httpBody = httpBody
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
if let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(400..<500).contains(httpResponse.statusCode)
|
||||||
|
{
|
||||||
|
let oauthError =
|
||||||
|
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||||
|
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||||
|
statusCode: httpResponse.statusCode,
|
||||||
|
oauthError: oauthError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(httpResponse.statusCode)
|
||||||
|
else {
|
||||||
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let oauthError =
|
||||||
|
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||||
|
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||||
|
statusCode: statusCode,
|
||||||
|
oauthError: oauthError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registration = try JSONDecoder().decode(OAuthClientRegistrationResponse.self, from: data)
|
||||||
|
|
||||||
|
let updatedAuth = OAuthClientRegistrar.updatedAuthentication(
|
||||||
|
from: registration, current: configuration.authentication)
|
||||||
|
return (response: registration, updatedAuthentication: updatedAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the updated token endpoint authentication from a registration response.
|
||||||
|
///
|
||||||
|
/// Updates the client ID (and secret, if issued) while preserving the authentication method.
|
||||||
|
static func updatedAuthentication(
|
||||||
|
from registration: OAuthClientRegistrationResponse,
|
||||||
|
current: OAuthConfiguration.TokenEndpointAuthentication
|
||||||
|
) -> OAuthConfiguration.TokenEndpointAuthentication {
|
||||||
|
switch current {
|
||||||
|
case .none:
|
||||||
|
return .none(clientID: registration.clientID)
|
||||||
|
case .clientSecretBasic(_, let currentSecret):
|
||||||
|
return .clientSecretBasic(
|
||||||
|
clientID: registration.clientID,
|
||||||
|
clientSecret: registration.clientSecret ?? currentSecret
|
||||||
|
)
|
||||||
|
case .clientSecretPost(_, let currentSecret):
|
||||||
|
return .clientSecretPost(
|
||||||
|
clientID: registration.clientID,
|
||||||
|
clientSecret: registration.clientSecret ?? currentSecret
|
||||||
|
)
|
||||||
|
case .privateKeyJWT(_, let factory):
|
||||||
|
return .privateKeyJWT(clientID: registration.clientID, assertionFactory: factory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CIMD Helpers
|
||||||
|
|
||||||
|
private func isHTTPSURLWithPath(_ value: String) -> Bool {
|
||||||
|
guard let url = URL(string: value),
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return components.scheme?.lowercased() == OAuthURLScheme.https
|
||||||
|
&& !components.path.isEmpty
|
||||||
|
&& components.path != "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clientIDLooksLikeURL(_ value: String) -> Bool {
|
||||||
|
guard let url = URL(string: value),
|
||||||
|
let scheme = url.scheme,
|
||||||
|
!scheme.isEmpty
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthClientRegistrar: OAuthClientRegistering {}
|
||||||
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(CryptoKit)
|
||||||
|
import CryptoKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Configuration for OAuth 2.1 authorization used by ``HTTPClientTransport``.
|
||||||
|
///
|
||||||
|
/// Authorization is optional and disabled by default. Configure this type and pass it
|
||||||
|
/// to `HTTPClientTransport(oauth:)` to enable automatic Bearer token acquisition for HTTP
|
||||||
|
/// transports.
|
||||||
|
///
|
||||||
|
/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
|
||||||
|
/// (machine-to-machine) grant types via the ``grantType`` property.
|
||||||
|
public struct OAuthConfiguration: Sendable {
|
||||||
|
/// The OAuth 2.1 grant type to use for token acquisition.
|
||||||
|
public enum GrantType: Sendable {
|
||||||
|
/// OAuth 2.1 authorization_code flow with PKCE.
|
||||||
|
case authorizationCode
|
||||||
|
|
||||||
|
/// OAuth 2.1 client_credentials flow.
|
||||||
|
case clientCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the client authenticates to the OAuth token endpoint.
|
||||||
|
public enum TokenEndpointAuthentication: Sendable, Equatable {
|
||||||
|
/// `client_secret_basic` authentication using the Authorization header.
|
||||||
|
case clientSecretBasic(clientID: String, clientSecret: String)
|
||||||
|
|
||||||
|
/// `client_secret_post` authentication using form parameters.
|
||||||
|
case clientSecretPost(clientID: String, clientSecret: String)
|
||||||
|
|
||||||
|
/// Public client authentication (`token_endpoint_auth_method=none`).
|
||||||
|
case none(clientID: String)
|
||||||
|
|
||||||
|
/// `private_key_jwt` authentication.
|
||||||
|
///
|
||||||
|
/// The built-in ``OAuthConfiguration/makePrivateKeyJWTAssertion(clientID:tokenEndpoint:privateKeyPEM:signingAlgorithm:audience:issuedAt:expiresIn:)``
|
||||||
|
/// helper generates ES256 (P-256 ECDSA) assertions only. To use other algorithms
|
||||||
|
/// (e.g., RS256, ES384), provide a custom ``OAuthConfiguration/JWTAssertionFactory`` closure.
|
||||||
|
case privateKeyJWT(clientID: String, assertionFactory: JWTAssertionFactory)
|
||||||
|
|
||||||
|
var clientID: String {
|
||||||
|
switch self {
|
||||||
|
case .clientSecretBasic(let clientID, _), .clientSecretPost(let clientID, _),
|
||||||
|
.none(let clientID), .privateKeyJWT(let clientID, _):
|
||||||
|
return clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The token endpoint auth method name per RFC 7591.
|
||||||
|
var methodName: String {
|
||||||
|
switch self {
|
||||||
|
case .clientSecretBasic: return OAuthTokenEndpointAuthMethod.clientSecretBasic
|
||||||
|
case .clientSecretPost: return OAuthTokenEndpointAuthMethod.clientSecretPost
|
||||||
|
case .none: return OAuthTokenEndpointAuthMethod.none
|
||||||
|
case .privateKeyJWT: return OAuthTokenEndpointAuthMethod.privateKeyJWT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(
|
||||||
|
to request: inout URLRequest,
|
||||||
|
bodyParameters: inout [String: String],
|
||||||
|
tokenEndpoint: URL
|
||||||
|
) async throws {
|
||||||
|
switch self {
|
||||||
|
case .clientSecretBasic(let clientID, let clientSecret):
|
||||||
|
let basic = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
|
||||||
|
request.setValue("Basic \(basic)", forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||||
|
case .clientSecretPost(let clientID, let clientSecret):
|
||||||
|
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||||
|
bodyParameters[OAuthParameterName.clientSecret] = clientSecret
|
||||||
|
case .none(let clientID):
|
||||||
|
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||||
|
case .privateKeyJWT(let clientID, let assertionFactory):
|
||||||
|
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||||
|
bodyParameters[OAuthParameterName.clientAssertionType] =
|
||||||
|
OAuthClientAssertionType.jwtBearer
|
||||||
|
bodyParameters[OAuthParameterName.clientAssertion] =
|
||||||
|
try await assertionFactory(tokenEndpoint, clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closure used to generate a `private_key_jwt` assertion.
|
||||||
|
public typealias JWTAssertionFactory = @Sendable (_ tokenEndpoint: URL, _ clientID: String)
|
||||||
|
async throws -> String
|
||||||
|
|
||||||
|
/// Supported signing algorithms for SDK-generated `private_key_jwt` assertions.
|
||||||
|
public enum PrivateKeyJWTSigningAlgorithm: String, Sendable {
|
||||||
|
case ES256
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors thrown while creating SDK-generated `private_key_jwt` assertions.
|
||||||
|
public enum PrivateKeyJWTAssertionError: LocalizedError, Sendable {
|
||||||
|
case invalidLifetime(TimeInterval)
|
||||||
|
case cryptographyUnavailable
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidLifetime(let lifetime):
|
||||||
|
return "private_key_jwt assertion lifetime must be greater than zero seconds, got \(lifetime)"
|
||||||
|
case .cryptographyUnavailable:
|
||||||
|
return "private_key_jwt assertion signing requires CryptoKit support"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a signed `private_key_jwt` client assertion (RFC 7523).
|
||||||
|
///
|
||||||
|
/// Use this helper to build a `JWTAssertionFactory` closure when configuring
|
||||||
|
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``:
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// let factory: OAuthConfiguration.JWTAssertionFactory = { tokenEndpoint, clientID in
|
||||||
|
/// try OAuthConfiguration.makePrivateKeyJWTAssertion(
|
||||||
|
/// clientID: clientID,
|
||||||
|
/// tokenEndpoint: tokenEndpoint,
|
||||||
|
/// privateKeyPEM: myPEMString
|
||||||
|
/// )
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The assertion is a compact-serialized JWS signed with the specified private key.
|
||||||
|
/// Only ES256 (P-256 ECDSA) is supported by this helper; the algorithm requires CryptoKit.
|
||||||
|
/// To use other algorithms, supply a custom ``JWTAssertionFactory`` closure directly to
|
||||||
|
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - clientID: The OAuth client identifier, used as both `iss` and `sub` claims.
|
||||||
|
/// - tokenEndpoint: Token endpoint URL, used as the default `aud` claim.
|
||||||
|
/// - privateKeyPEM: PEM-encoded EC private key for signing.
|
||||||
|
/// - signingAlgorithm: The JWS signing algorithm. Currently only `.ES256` is supported.
|
||||||
|
/// - audience: Explicit `aud` claim override. Defaults to `tokenEndpoint.absoluteString`.
|
||||||
|
/// - issuedAt: The `iat` claim. Defaults to `Date()`.
|
||||||
|
/// - expiresIn: Lifetime of the assertion in seconds. Defaults to 300 (5 minutes). Must be > 0.
|
||||||
|
/// - Returns: A compact-serialized JWT string (`header.payload.signature`).
|
||||||
|
/// - Throws: ``PrivateKeyJWTAssertionError/invalidLifetime(_:)`` if `expiresIn` ≤ 0,
|
||||||
|
/// or ``PrivateKeyJWTAssertionError/cryptographyUnavailable`` on platforms without CryptoKit.
|
||||||
|
public static func makePrivateKeyJWTAssertion(
|
||||||
|
clientID: String,
|
||||||
|
tokenEndpoint: URL,
|
||||||
|
privateKeyPEM: String,
|
||||||
|
signingAlgorithm: PrivateKeyJWTSigningAlgorithm = .ES256,
|
||||||
|
audience: String? = nil,
|
||||||
|
issuedAt: Date = Date(),
|
||||||
|
expiresIn: TimeInterval = 300
|
||||||
|
) throws -> String {
|
||||||
|
guard expiresIn > 0 else {
|
||||||
|
throw PrivateKeyJWTAssertionError.invalidLifetime(expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = try JSONSerialization.data(withJSONObject: [
|
||||||
|
JWTClaimName.algorithm: signingAlgorithm.rawValue,
|
||||||
|
JWTClaimName.type: JWTClaimName.typeValue,
|
||||||
|
])
|
||||||
|
|
||||||
|
let issuedAtUnix = Int(issuedAt.timeIntervalSince1970)
|
||||||
|
let lifetimeSeconds = max(1, Int(expiresIn.rounded(.down)))
|
||||||
|
let payload = try JSONSerialization.data(withJSONObject: [
|
||||||
|
JWTClaimName.issuer: clientID,
|
||||||
|
JWTClaimName.subject: clientID,
|
||||||
|
JWTClaimName.audience: audience ?? tokenEndpoint.absoluteString,
|
||||||
|
JWTClaimName.issuedAt: issuedAtUnix,
|
||||||
|
JWTClaimName.expiration: issuedAtUnix + lifetimeSeconds,
|
||||||
|
JWTClaimName.jwtID: UUID().uuidString,
|
||||||
|
])
|
||||||
|
|
||||||
|
let signingInput = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())"
|
||||||
|
|
||||||
|
#if canImport(CryptoKit)
|
||||||
|
switch signingAlgorithm {
|
||||||
|
case .ES256:
|
||||||
|
let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPEM)
|
||||||
|
let signature = try privateKey.signature(for: Data(signingInput.utf8)).rawRepresentation
|
||||||
|
return "\(signingInput).\(signature.base64URLEncodedString())"
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
throw PrivateKeyJWTAssertionError.cryptographyUnavailable
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultAuthorizationRedirectURI() -> URL {
|
||||||
|
let port = Int.random(in: 49152...65535)
|
||||||
|
return URL(string: "http://\(OAuthLoopbackHost.ipv4):\(port)/callback")!
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Retry Policy
|
||||||
|
|
||||||
|
/// Controls retry behavior for authorization challenges.
|
||||||
|
public struct RetryPolicy: Sendable {
|
||||||
|
/// Maximum number of authentication retries for a single MCP request.
|
||||||
|
public let maxAuthorizationAttempts: Int
|
||||||
|
|
||||||
|
/// Maximum number of scope step-up attempts for a resource and operation.
|
||||||
|
public let maxScopeUpgradeAttempts: Int
|
||||||
|
|
||||||
|
/// Creates a retry policy.
|
||||||
|
///
|
||||||
|
/// Both values are clamped to a minimum of `1` to prevent infinite loops
|
||||||
|
/// or zero-retry configurations.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - maxAuthorizationAttempts: Maximum authorization retries per request. Defaults to 3.
|
||||||
|
/// - maxScopeUpgradeAttempts: Maximum scope step-up attempts per resource+operation. Defaults to 2.
|
||||||
|
public init(
|
||||||
|
maxAuthorizationAttempts: Int = 3,
|
||||||
|
maxScopeUpgradeAttempts: Int = 2
|
||||||
|
) {
|
||||||
|
self.maxAuthorizationAttempts = max(1, maxAuthorizationAttempts)
|
||||||
|
self.maxScopeUpgradeAttempts = max(1, maxScopeUpgradeAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = RetryPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Endpoint Overrides
|
||||||
|
|
||||||
|
/// Optional endpoint overrides for discovery.
|
||||||
|
public struct EndpointOverrides: Sendable {
|
||||||
|
/// Optional override for the protected resource metadata URL.
|
||||||
|
public let protectedResourceMetadataURL: URL?
|
||||||
|
|
||||||
|
/// Optional override for the authorization server issuer URL.
|
||||||
|
public let authorizationServerURL: URL?
|
||||||
|
|
||||||
|
/// Optional override for the token endpoint.
|
||||||
|
public let tokenEndpoint: URL?
|
||||||
|
|
||||||
|
/// Optional override for the resource indicator used in token requests.
|
||||||
|
public let resource: URL?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
protectedResourceMetadataURL: URL? = nil,
|
||||||
|
authorizationServerURL: URL? = nil,
|
||||||
|
tokenEndpoint: URL? = nil,
|
||||||
|
resource: URL? = nil
|
||||||
|
) {
|
||||||
|
self.protectedResourceMetadataURL = protectedResourceMetadataURL
|
||||||
|
self.authorizationServerURL = authorizationServerURL
|
||||||
|
self.tokenEndpoint = tokenEndpoint
|
||||||
|
self.resource = resource
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let none = EndpointOverrides()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Access Token Provider
|
||||||
|
|
||||||
|
/// Context supplied to ``AccessTokenProvider`` after SDK discovery is complete.
|
||||||
|
public struct AccessTokenProviderContext: Sendable {
|
||||||
|
/// HTTP status code that triggered authorization handling (typically 401 or 403).
|
||||||
|
public let statusCode: Int
|
||||||
|
/// Target MCP endpoint URL.
|
||||||
|
public let endpoint: URL
|
||||||
|
/// Canonical RFC8707 resource URI for token audience binding.
|
||||||
|
public let resource: URL
|
||||||
|
/// Selected authorization server issuer URL, if discovered.
|
||||||
|
public let authorizationServer: URL?
|
||||||
|
/// Authorization endpoint from AS metadata, if available.
|
||||||
|
public let authorizationEndpoint: URL?
|
||||||
|
/// Token endpoint from AS metadata (or configuration override), if available.
|
||||||
|
public let tokenEndpoint: URL?
|
||||||
|
/// Dynamic registration endpoint from AS metadata, if available.
|
||||||
|
public let registrationEndpoint: URL?
|
||||||
|
/// `scope` value from the latest challenge header, when present.
|
||||||
|
public let challengedScope: String?
|
||||||
|
/// `scopes_supported` from protected resource metadata, when present.
|
||||||
|
public let scopesSupported: [String]?
|
||||||
|
/// Scope set selected by SDK for the pending authorization attempt.
|
||||||
|
public let requestedScopes: Set<String>?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
statusCode: Int,
|
||||||
|
endpoint: URL,
|
||||||
|
resource: URL,
|
||||||
|
authorizationServer: URL?,
|
||||||
|
authorizationEndpoint: URL?,
|
||||||
|
tokenEndpoint: URL?,
|
||||||
|
registrationEndpoint: URL?,
|
||||||
|
challengedScope: String?,
|
||||||
|
scopesSupported: [String]?,
|
||||||
|
requestedScopes: Set<String>?
|
||||||
|
) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.resource = resource
|
||||||
|
self.authorizationServer = authorizationServer
|
||||||
|
self.authorizationEndpoint = authorizationEndpoint
|
||||||
|
self.tokenEndpoint = tokenEndpoint
|
||||||
|
self.registrationEndpoint = registrationEndpoint
|
||||||
|
self.challengedScope = challengedScope
|
||||||
|
self.scopesSupported = scopesSupported
|
||||||
|
self.requestedScopes = requestedScopes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional provider for externally acquired access tokens.
|
||||||
|
public typealias AccessTokenProvider = @Sendable (
|
||||||
|
_ context: AccessTokenProviderContext,
|
||||||
|
_ session: URLSession
|
||||||
|
) async throws -> String?
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
/// The grant type used for token acquisition.
|
||||||
|
public let grantType: GrantType
|
||||||
|
|
||||||
|
/// The configured token endpoint authentication method.
|
||||||
|
public var authentication: TokenEndpointAuthentication
|
||||||
|
|
||||||
|
/// Controls retry behavior for authorization challenges.
|
||||||
|
public let retryPolicy: RetryPolicy
|
||||||
|
|
||||||
|
/// Optional endpoint overrides for discovery.
|
||||||
|
public let endpointOverrides: EndpointOverrides
|
||||||
|
|
||||||
|
/// Package-scoped compatibility option for local environments.
|
||||||
|
package var allowLoopbackHTTPAuthorizationServerEndpoints: Bool
|
||||||
|
|
||||||
|
/// Redirect URI used for authorization requests.
|
||||||
|
public let authorizationRedirectURI: URL
|
||||||
|
|
||||||
|
/// Additional form fields to include in token requests.
|
||||||
|
public let additionalTokenRequestParameters: [String: String]
|
||||||
|
|
||||||
|
/// The `client_name` sent during dynamic client registration (RFC 7591).
|
||||||
|
/// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
|
||||||
|
public let clientName: String
|
||||||
|
|
||||||
|
/// Optional provider for externally acquired access tokens.
|
||||||
|
public let accessTokenProvider: AccessTokenProvider?
|
||||||
|
|
||||||
|
/// Optional delegate for browser-based authorization code flows.
|
||||||
|
public let authorizationDelegate: (any OAuthAuthorizationDelegate)?
|
||||||
|
|
||||||
|
/// How many seconds before token expiry ``OAuthAuthorizer`` proactively refreshes a token
|
||||||
|
/// when `prepareAuthorization(for:session:)` is called.
|
||||||
|
///
|
||||||
|
/// Set to `0` to disable proactive refresh. Defaults to 60 seconds.
|
||||||
|
/// Must be greater than ``OAuthTokenExpirySkew/defaultSeconds`` (30 s) to have any effect,
|
||||||
|
/// since tokens within the default skew window are already treated as expired.
|
||||||
|
public let proactiveRefreshWindowSeconds: TimeInterval
|
||||||
|
|
||||||
|
/// Creates an OAuth configuration.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - grantType: The OAuth 2.1 grant type. Defaults to `.clientCredentials`.
|
||||||
|
/// - authentication: How the client authenticates to the token endpoint. **Required.**
|
||||||
|
/// - retryPolicy: Controls how many retries are allowed for authorization challenges.
|
||||||
|
/// - endpointOverrides: Optional URL overrides that bypass automatic discovery.
|
||||||
|
/// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
|
||||||
|
/// Defaults to a random loopback URI (`http://127.0.0.1:<port>/callback`).
|
||||||
|
/// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
|
||||||
|
/// - additionalTokenRequestParameters: Extra form fields appended to every token request.
|
||||||
|
/// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
|
||||||
|
/// to supply an externally acquired token (e.g., from a system credential store).
|
||||||
|
/// - authorizationDelegate: Optional delegate that presents the authorization URL to the
|
||||||
|
/// user for interactive `authorization_code` flows.
|
||||||
|
/// - proactiveRefreshWindowSeconds: Seconds before expiry at which a token is proactively
|
||||||
|
/// refreshed. Defaults to 60. Set to 0 to disable proactive refresh.
|
||||||
|
public init(
|
||||||
|
grantType: GrantType = .clientCredentials,
|
||||||
|
authentication: TokenEndpointAuthentication,
|
||||||
|
retryPolicy: RetryPolicy = .default,
|
||||||
|
endpointOverrides: EndpointOverrides = .none,
|
||||||
|
authorizationRedirectURI: URL? = nil,
|
||||||
|
clientName: String = "mcp-swift-sdk",
|
||||||
|
additionalTokenRequestParameters: [String: String] = [:],
|
||||||
|
accessTokenProvider: AccessTokenProvider? = nil,
|
||||||
|
authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
|
||||||
|
proactiveRefreshWindowSeconds: TimeInterval = 60
|
||||||
|
) {
|
||||||
|
self.grantType = grantType
|
||||||
|
self.authentication = authentication
|
||||||
|
self.retryPolicy = retryPolicy
|
||||||
|
self.endpointOverrides = endpointOverrides
|
||||||
|
self.allowLoopbackHTTPAuthorizationServerEndpoints = false
|
||||||
|
self.authorizationRedirectURI =
|
||||||
|
authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
|
||||||
|
self.clientName = clientName
|
||||||
|
self.additionalTokenRequestParameters = additionalTokenRequestParameters
|
||||||
|
self.accessTokenProvider = accessTokenProvider
|
||||||
|
self.authorizationDelegate = authorizationDelegate
|
||||||
|
self.proactiveRefreshWindowSeconds = proactiveRefreshWindowSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthConfiguration.TokenEndpointAuthentication {
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.clientSecretBasic(let li, let ls), .clientSecretBasic(let ri, let rs)):
|
||||||
|
return li == ri && ls == rs
|
||||||
|
case (.clientSecretPost(let li, let ls), .clientSecretPost(let ri, let rs)):
|
||||||
|
return li == ri && ls == rs
|
||||||
|
case (.none(let li), .none(let ri)):
|
||||||
|
return li == ri
|
||||||
|
case (.privateKeyJWT(let li, _), .privateKeyJWT(let ri, _)):
|
||||||
|
return li == ri
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate that handles user-facing authorization steps for the authorization_code flow.
|
||||||
|
public protocol OAuthAuthorizationDelegate: Sendable {
|
||||||
|
/// Presents the authorization URL to the user and returns the redirect URL containing
|
||||||
|
/// the authorization code.
|
||||||
|
func presentAuthorizationURL(_ url: URL) async throws -> URL
|
||||||
|
}
|
||||||
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - OAuth Grant Types
|
||||||
|
|
||||||
|
enum OAuthGrantTypeValue {
|
||||||
|
static let clientCredentials = "client_credentials"
|
||||||
|
static let authorizationCode = "authorization_code"
|
||||||
|
static let refreshToken = "refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Parameter Names
|
||||||
|
|
||||||
|
enum OAuthParameterName {
|
||||||
|
static let grantType = "grant_type"
|
||||||
|
static let resource = "resource"
|
||||||
|
static let scope = "scope"
|
||||||
|
static let code = "code"
|
||||||
|
static let codeVerifier = "code_verifier"
|
||||||
|
static let codeChallenge = "code_challenge"
|
||||||
|
static let codeChallengeMethod = "code_challenge_method"
|
||||||
|
static let redirectURI = "redirect_uri"
|
||||||
|
static let responseType = "response_type"
|
||||||
|
static let clientID = "client_id"
|
||||||
|
static let clientSecret = "client_secret"
|
||||||
|
static let clientAssertion = "client_assertion"
|
||||||
|
static let clientAssertionType = "client_assertion_type"
|
||||||
|
static let refreshToken = "refresh_token"
|
||||||
|
static let state = "state"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Well-Known Paths
|
||||||
|
|
||||||
|
enum OAuthWellKnownPath {
|
||||||
|
static let protectedResource = "/.well-known/oauth-protected-resource"
|
||||||
|
static let authorizationServer = "/.well-known/oauth-authorization-server"
|
||||||
|
static let openIDConfiguration = "/.well-known/openid-configuration"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Token Type
|
||||||
|
|
||||||
|
enum OAuthTokenType {
|
||||||
|
static let bearer = "Bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Code Challenge Method
|
||||||
|
|
||||||
|
enum OAuthCodeChallengeMethod {
|
||||||
|
static let s256 = "S256"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Token Endpoint Auth Method
|
||||||
|
|
||||||
|
enum OAuthTokenEndpointAuthMethod {
|
||||||
|
static let clientSecretBasic = "client_secret_basic"
|
||||||
|
static let clientSecretPost = "client_secret_post"
|
||||||
|
static let none = "none"
|
||||||
|
static let privateKeyJWT = "private_key_jwt"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URL Scheme
|
||||||
|
|
||||||
|
enum OAuthURLScheme {
|
||||||
|
static let http = "http"
|
||||||
|
static let https = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Ports
|
||||||
|
|
||||||
|
enum OAuthDefaultPort {
|
||||||
|
static let http = 80
|
||||||
|
static let https = 443
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loopback Hosts
|
||||||
|
|
||||||
|
enum OAuthLoopbackHost {
|
||||||
|
static let localhost = "localhost"
|
||||||
|
static let ipv4 = "127.0.0.1"
|
||||||
|
static let ipv6 = "::1"
|
||||||
|
|
||||||
|
static func isLoopback(_ host: String) -> Bool {
|
||||||
|
host == localhost || host == ipv4 || host == ipv6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Expiry Skew
|
||||||
|
|
||||||
|
/// Clock skew tolerance applied when checking token expiry.
|
||||||
|
///
|
||||||
|
/// ``OAuthAccessToken/isExpired(now:skewSeconds:)`` treats a token as expired
|
||||||
|
/// when `now + skewSeconds >= expiresAt`, giving the client a safety margin to
|
||||||
|
/// refresh tokens before they actually expire on the server.
|
||||||
|
public enum OAuthTokenExpirySkew {
|
||||||
|
/// Default clock skew buffer: 30 seconds.
|
||||||
|
public static let defaultSeconds: TimeInterval = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JWT Claim Names
|
||||||
|
|
||||||
|
enum JWTClaimName {
|
||||||
|
static let algorithm = "alg"
|
||||||
|
static let type = "typ"
|
||||||
|
static let typeValue = "JWT"
|
||||||
|
static let issuer = "iss"
|
||||||
|
static let subject = "sub"
|
||||||
|
static let audience = "aud"
|
||||||
|
static let issuedAt = "iat"
|
||||||
|
static let expiration = "exp"
|
||||||
|
static let jwtID = "jti"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Assertion Type
|
||||||
|
|
||||||
|
enum OAuthClientAssertionType {
|
||||||
|
static let jwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTTPHeaderName Extensions
|
||||||
|
|
||||||
|
extension HTTPHeaderName {
|
||||||
|
static let location = "Location"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ContentType Extensions
|
||||||
|
|
||||||
|
extension ContentType {
|
||||||
|
static let formURLEncoded = "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Base64URL Extension
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func base64URLEncodedString() -> String {
|
||||||
|
base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Scope Selection Protocol
|
||||||
|
|
||||||
|
/// Determines which OAuth scopes to request during token acquisition.
|
||||||
|
///
|
||||||
|
/// ``OAuthAuthorizer`` uses this protocol to translate raw scope strings from
|
||||||
|
/// `WWW-Authenticate` challenges and Protected Resource Metadata into the
|
||||||
|
/// scope set passed to the token endpoint.
|
||||||
|
///
|
||||||
|
/// Override the default ``DefaultOAuthScopeSelector`` to apply custom scope
|
||||||
|
/// filtering or transformation logic.
|
||||||
|
public protocol OAuthScopeSelecting {
|
||||||
|
/// Selects the scope set to request for a token.
|
||||||
|
///
|
||||||
|
/// Priority order (highest first):
|
||||||
|
/// 1. `challengeScope` — the `scope` parameter from the `WWW-Authenticate` header.
|
||||||
|
/// 2. `scopesSupported` — the `scopes_supported` array from Protected Resource Metadata.
|
||||||
|
/// 3. `nil` — no scope restriction.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - challengeScope: Space-separated scope string from the Bearer challenge, or `nil`.
|
||||||
|
/// - scopesSupported: Array of supported scopes from the resource metadata, or `nil`.
|
||||||
|
/// - Returns: The set of scopes to request, or `nil` to omit the `scope` parameter entirely.
|
||||||
|
func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>?
|
||||||
|
|
||||||
|
/// Parses a space-separated OAuth scope string into individual scope tokens.
|
||||||
|
/// - Parameter scope: A scope string such as `"read write"`.
|
||||||
|
/// - Returns: A set of individual scope strings with whitespace-only tokens removed.
|
||||||
|
func parseScopeString(_ scope: String) -> Set<String>
|
||||||
|
|
||||||
|
/// Serializes a set of scopes into a space-separated string suitable for the `scope` parameter.
|
||||||
|
/// - Parameter scopes: The scope set to serialize.
|
||||||
|
/// - Returns: A sorted, space-separated string, or `nil` if the set is empty.
|
||||||
|
func serialize(_ scopes: Set<String>) -> String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Scope Selector
|
||||||
|
|
||||||
|
/// Default ``OAuthScopeSelecting`` implementation.
|
||||||
|
///
|
||||||
|
/// Selects scopes in priority order: challenge scope > `scopes_supported` > `nil`.
|
||||||
|
/// Serializes scopes sorted alphabetically to produce deterministic `scope` parameters.
|
||||||
|
public struct DefaultOAuthScopeSelector: OAuthScopeSelecting {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>? {
|
||||||
|
if let challengeScope {
|
||||||
|
let parsed = parseScopeString(challengeScope)
|
||||||
|
return parsed.isEmpty ? nil : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if let scopesSupported {
|
||||||
|
let parsed = Set(scopesSupported.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
|
||||||
|
return parsed.isEmpty ? nil : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func parseScopeString(_ scope: String) -> Set<String> {
|
||||||
|
Set(
|
||||||
|
scope
|
||||||
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
|
.map(String.init)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func serialize(_ scopes: Set<String>) -> String? {
|
||||||
|
guard !scopes.isEmpty else { return nil }
|
||||||
|
return scopes.sorted().joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metadata Discovery Protocol
|
||||||
|
|
||||||
|
/// Builds discovery URLs and normalizes resource identifiers for OAuth metadata discovery.
|
||||||
|
///
|
||||||
|
/// ``OAuthAuthorizer`` uses this protocol to construct the candidate URL list for both
|
||||||
|
/// Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414 / OIDC)
|
||||||
|
/// discovery, and to normalize resource URIs for RFC 8707 audience binding.
|
||||||
|
///
|
||||||
|
/// Override the default ``DefaultOAuthMetadataDiscovery`` to customise discovery URL
|
||||||
|
/// construction or resource-matching logic.
|
||||||
|
public protocol OAuthMetadataDiscovering: Sendable {
|
||||||
|
/// Returns candidate URLs for Protected Resource Metadata, ordered by priority.
|
||||||
|
///
|
||||||
|
/// ``OAuthAuthorizer`` tries each URL in order and uses the first successful response.
|
||||||
|
///
|
||||||
|
/// - Parameter endpoint: The MCP endpoint URL.
|
||||||
|
/// - Returns: An ordered list of discovery URLs (typically `/.well-known/oauth-protected-resource`
|
||||||
|
/// variants).
|
||||||
|
func protectedResourceMetadataURLs(for endpoint: URL) -> [URL]
|
||||||
|
|
||||||
|
/// Returns candidate URLs for Authorization Server Metadata, ordered by priority.
|
||||||
|
///
|
||||||
|
/// Covers RFC 8414 (`/.well-known/oauth-authorization-server`) and
|
||||||
|
/// OIDC Discovery 1.0 (`/.well-known/openid-configuration`), including
|
||||||
|
/// path-inserted variants for issuers with non-root paths.
|
||||||
|
///
|
||||||
|
/// - Parameter issuer: The authorization server issuer URL.
|
||||||
|
/// - Returns: An ordered list of metadata discovery URLs.
|
||||||
|
func authorizationServerMetadataURLs(for issuer: URL) -> [URL]
|
||||||
|
|
||||||
|
/// Derives the canonical RFC 8707 resource URI from an endpoint URL.
|
||||||
|
///
|
||||||
|
/// The canonical form strips the query string, fragment, and trailing slash
|
||||||
|
/// while preserving the scheme, host, port, and path.
|
||||||
|
///
|
||||||
|
/// - Parameter endpoint: The MCP endpoint URL.
|
||||||
|
/// - Returns: The canonical resource URI.
|
||||||
|
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL does not
|
||||||
|
/// satisfy the HTTPS-or-loopback-HTTP requirement.
|
||||||
|
func canonicalResourceURI(from endpoint: URL) throws -> URL
|
||||||
|
|
||||||
|
/// Derives a fallback authorization server issuer URL from an endpoint URL.
|
||||||
|
///
|
||||||
|
/// Used when no authorization server is listed in Protected Resource Metadata.
|
||||||
|
/// Typically returns the scheme+host+port of the endpoint with an empty path.
|
||||||
|
///
|
||||||
|
/// - Parameter endpoint: The MCP endpoint URL.
|
||||||
|
/// - Returns: A candidate issuer URL.
|
||||||
|
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL is invalid.
|
||||||
|
func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL
|
||||||
|
|
||||||
|
/// Returns `true` if `resource` is a prefix of `endpoint` in the URL hierarchy.
|
||||||
|
///
|
||||||
|
/// A resource matches an endpoint when the two share the same scheme, host, and port,
|
||||||
|
/// and the endpoint path starts with the resource path.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - resource: The canonical resource URI from Protected Resource Metadata.
|
||||||
|
/// - endpoint: The canonical endpoint URI being requested.
|
||||||
|
/// - Returns: `true` if the endpoint falls within the resource's scope.
|
||||||
|
func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Default Metadata Discovery
|
||||||
|
|
||||||
|
/// Default ``OAuthMetadataDiscovering`` implementation following RFC 9728 and RFC 8414.
|
||||||
|
///
|
||||||
|
/// - Builds `/.well-known/oauth-protected-resource` URLs with and without the endpoint path suffix.
|
||||||
|
/// - Builds RFC 8414 and OIDC discovery URLs with path-inserted and path-appended variants.
|
||||||
|
/// - Canonicalises resource URIs by stripping query, fragment, and trailing slash.
|
||||||
|
/// - Matches resources using scheme/host/port equality and path prefix rules.
|
||||||
|
public struct DefaultOAuthMetadataDiscovery: OAuthMetadataDiscovering {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func protectedResourceMetadataURLs(for endpoint: URL) -> [URL] {
|
||||||
|
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
components.query = nil
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
let endpointPath = components.path
|
||||||
|
let normalizedPath = endpointPath == "/" ? "" : endpointPath
|
||||||
|
|
||||||
|
var urls: [URL] = []
|
||||||
|
|
||||||
|
var pathSpecific = components
|
||||||
|
pathSpecific.path = "\(OAuthWellKnownPath.protectedResource)\(normalizedPath)"
|
||||||
|
if let url = pathSpecific.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = components
|
||||||
|
root.path = OAuthWellKnownPath.protectedResource
|
||||||
|
if let url = root.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authorizationServerMetadataURLs(for issuer: URL) -> [URL] {
|
||||||
|
guard var components = URLComponents(url: issuer, resolvingAgainstBaseURL: false),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
components.query = nil
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
let hasPath = !path.isEmpty
|
||||||
|
|
||||||
|
var urls: [URL] = []
|
||||||
|
|
||||||
|
if hasPath {
|
||||||
|
var oauthInserted = components
|
||||||
|
oauthInserted.path = "\(OAuthWellKnownPath.authorizationServer)/\(path)"
|
||||||
|
if let url = oauthInserted.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcInserted = components
|
||||||
|
oidcInserted.path = "\(OAuthWellKnownPath.openIDConfiguration)/\(path)"
|
||||||
|
if let url = oidcInserted.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcAppended = components
|
||||||
|
oidcAppended.path = "/\(path)\(OAuthWellKnownPath.openIDConfiguration)"
|
||||||
|
if let url = oidcAppended.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var oauth = components
|
||||||
|
oauth.path = OAuthWellKnownPath.authorizationServer
|
||||||
|
if let url = oauth.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidc = components
|
||||||
|
oidc.path = OAuthWellKnownPath.openIDConfiguration
|
||||||
|
if let url = oidc.url {
|
||||||
|
urls.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
public func canonicalResourceURI(from endpoint: URL) throws -> URL {
|
||||||
|
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI(
|
||||||
|
"Resource URI must use https or loopback http"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if components.fragment != nil {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI("Resource URI must not contain a fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
components.scheme = scheme
|
||||||
|
components.host = host
|
||||||
|
components.query = nil
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
if components.path == "/" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI("Failed to normalize resource URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL {
|
||||||
|
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
let host = components.host?.lowercased(),
|
||||||
|
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||||
|
else {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI(
|
||||||
|
"Resource URI must use https or loopback http"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.scheme = scheme
|
||||||
|
components.host = host
|
||||||
|
components.path = ""
|
||||||
|
components.query = nil
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw OAuthAuthorizationError.invalidResourceURI("Failed to derive issuer URI")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
public func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool {
|
||||||
|
guard let resourceComponents = URLComponents(
|
||||||
|
url: resource,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
),
|
||||||
|
let endpointComponents = URLComponents(
|
||||||
|
url: endpoint,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let resourceScheme = resourceComponents.scheme?.lowercased()
|
||||||
|
let endpointScheme = endpointComponents.scheme?.lowercased()
|
||||||
|
let resourceHost = resourceComponents.host?.lowercased()
|
||||||
|
let endpointHost = endpointComponents.host?.lowercased()
|
||||||
|
let resourcePort = resourceComponents.port ?? Self.defaultPort(for: resourceScheme)
|
||||||
|
let endpointPort = endpointComponents.port ?? Self.defaultPort(for: endpointScheme)
|
||||||
|
|
||||||
|
guard resourceScheme == endpointScheme,
|
||||||
|
resourceHost == endpointHost,
|
||||||
|
resourcePort == endpointPort
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let resourcePath = Self.normalizedResourcePath(resourceComponents.path)
|
||||||
|
let endpointPath = Self.normalizedResourcePath(endpointComponents.path)
|
||||||
|
if resourcePath.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if endpointPath == resourcePath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return endpointPath.hasPrefix(resourcePath + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedResourcePath(_ rawPath: String) -> String {
|
||||||
|
if rawPath.isEmpty || rawPath == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if rawPath.count > 1 && rawPath.hasSuffix("/") {
|
||||||
|
return String(rawPath.dropLast())
|
||||||
|
}
|
||||||
|
return rawPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultPort(for scheme: String?) -> Int? {
|
||||||
|
switch scheme?.lowercased() {
|
||||||
|
case OAuthURLScheme.http:
|
||||||
|
return OAuthDefaultPort.http
|
||||||
|
case OAuthURLScheme.https:
|
||||||
|
return OAuthDefaultPort.https
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isSecureOAuthScheme(scheme: String, host: String) -> Bool {
|
||||||
|
if scheme == OAuthURLScheme.https {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if scheme == OAuthURLScheme.http {
|
||||||
|
return OAuthLoopbackHost.isLoopback(host)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
142
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
142
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Internal protocol for fetching OAuth discovery metadata.
|
||||||
|
protocol OAuthDiscoveryFetching: Sendable {
|
||||||
|
var metadataDiscovery: any OAuthMetadataDiscovering { get }
|
||||||
|
func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata
|
||||||
|
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateless OAuth metadata fetcher.
|
||||||
|
///
|
||||||
|
/// Fetches Protected Resource Metadata (RFC 9728) and Authorization Server Metadata
|
||||||
|
/// (RFC 8414 / OIDC Discovery 1.0) from ordered candidate URL lists.
|
||||||
|
/// Cache management is the caller's responsibility.
|
||||||
|
struct OAuthDiscoveryClient: Sendable {
|
||||||
|
let metadataDiscovery: any OAuthMetadataDiscovering
|
||||||
|
let urlValidator: OAuthURLValidator
|
||||||
|
|
||||||
|
init(
|
||||||
|
metadataDiscovery: any OAuthMetadataDiscovering,
|
||||||
|
urlValidator: OAuthURLValidator
|
||||||
|
) {
|
||||||
|
self.metadataDiscovery = metadataDiscovery
|
||||||
|
self.urlValidator = urlValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
|
||||||
|
///
|
||||||
|
/// If all candidates fail and `fallbackIssuer` is provided, returns synthetic metadata
|
||||||
|
/// using that issuer as the authorization server — for servers that do not expose a
|
||||||
|
/// PRM document at any well-known path.
|
||||||
|
func fetchProtectedResourceMetadata(
|
||||||
|
candidates: [URL],
|
||||||
|
fallbackIssuer: URL?,
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> OAuthProtectedResourceMetadata {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
for url in candidates {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(httpResponse.statusCode)
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = try decoder.decode(OAuthProtectedResourceMetadata.self, from: data)
|
||||||
|
guard !metadata.authorizationServers.isEmpty else { continue }
|
||||||
|
return metadata
|
||||||
|
} catch let error as OAuthAuthorizationError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let fallbackIssuer {
|
||||||
|
return OAuthProtectedResourceMetadata(
|
||||||
|
resource: nil,
|
||||||
|
authorizationServers: [fallbackIssuer],
|
||||||
|
scopesSupported: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches Authorization Server Metadata from candidates, preferring a response whose
|
||||||
|
/// `issuer` matches the candidate URL (RFC 8414 §3). If no candidate produces a matching
|
||||||
|
/// issuer, the first valid response is accepted and its own `issuer` is used as the
|
||||||
|
/// server identity — accommodating servers that serve metadata at one host but advertise
|
||||||
|
/// a different issuer.
|
||||||
|
func fetchAuthorizationServerMetadata(
|
||||||
|
candidates: [URL],
|
||||||
|
session: URLSession
|
||||||
|
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)?
|
||||||
|
|
||||||
|
for candidateServer in candidates {
|
||||||
|
guard (try? urlValidator.validateAuthorizationServer(
|
||||||
|
candidateServer, context: "Authorization server issuer")) != nil
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let host = URLComponents(url: candidateServer, resolvingAgainstBaseURL: false)?
|
||||||
|
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for metadataURL in metadataDiscovery.authorizationServerMetadataURLs(
|
||||||
|
for: candidateServer)
|
||||||
|
{
|
||||||
|
var request = URLRequest(url: metadataURL)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200..<300).contains(httpResponse.statusCode)
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let asMetadata = try decoder.decode(
|
||||||
|
OAuthAuthorizationServerMetadata.self, from: data)
|
||||||
|
|
||||||
|
// Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3).
|
||||||
|
let issuerMatches =
|
||||||
|
asMetadata.issuer == nil
|
||||||
|
|| asMetadata.issuer?.absoluteString.lowercased()
|
||||||
|
== candidateServer.absoluteString.lowercased()
|
||||||
|
if issuerMatches {
|
||||||
|
return (server: candidateServer, metadata: asMetadata)
|
||||||
|
}
|
||||||
|
// Keep as fallback in case no issuer-matching candidate is found.
|
||||||
|
if firstValid == nil {
|
||||||
|
let server = asMetadata.issuer ?? candidateServer
|
||||||
|
firstValid = (server: server, metadata: asMetadata)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let firstValid {
|
||||||
|
return firstValid
|
||||||
|
}
|
||||||
|
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthDiscoveryClient: OAuthDiscoveryFetching {}
|
||||||
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors thrown during OAuth 2.1 authorization by ``OAuthAuthorizer``.
|
||||||
|
///
|
||||||
|
/// These errors surface when the authorizer is unable to complete the authorization flow,
|
||||||
|
/// either due to discovery failures, token exchange problems, security policy violations,
|
||||||
|
/// or authorization code flow issues.
|
||||||
|
public enum OAuthAuthorizationError: LocalizedError {
|
||||||
|
/// No authorization server URL could be found in the Protected Resource Metadata.
|
||||||
|
case missingAuthorizationServer
|
||||||
|
|
||||||
|
/// All Protected Resource Metadata discovery candidates returned errors or invalid documents.
|
||||||
|
case metadataDiscoveryFailed
|
||||||
|
|
||||||
|
/// All Authorization Server Metadata discovery candidates (RFC 8414 / OIDC) returned errors.
|
||||||
|
case authorizationServerMetadataDiscoveryFailed
|
||||||
|
|
||||||
|
/// The authorization server metadata does not include a `token_endpoint`.
|
||||||
|
case tokenEndpointMissing
|
||||||
|
|
||||||
|
/// The token endpoint returned a non-2xx HTTP response.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - statusCode: HTTP status code from the token endpoint.
|
||||||
|
/// - oauthError: The `error` field from the OAuth error response body, if present.
|
||||||
|
case tokenRequestFailed(statusCode: Int, oauthError: String?)
|
||||||
|
|
||||||
|
/// The token response body is missing `access_token`, has an empty token, or specifies
|
||||||
|
/// a non-Bearer token type.
|
||||||
|
case tokenResponseInvalid
|
||||||
|
|
||||||
|
/// A URL that must be a valid resource identifier (RFC 8707) failed validation.
|
||||||
|
case invalidResourceURI(String)
|
||||||
|
|
||||||
|
/// The configured client ID looks like a URL but is not a valid HTTPS URL with a path,
|
||||||
|
/// which is required for Client ID Metadata Documents.
|
||||||
|
case invalidClientIDMetadataURL(String)
|
||||||
|
|
||||||
|
/// The `resource` field in the Protected Resource Metadata does not match the requested endpoint.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - expected: The canonical URI derived from the endpoint.
|
||||||
|
/// - actual: The canonical URI derived from the metadata's `resource` field.
|
||||||
|
case protectedResourceMismatch(expected: String, actual: String)
|
||||||
|
|
||||||
|
/// The authorization server does not support dynamic registration and no pre-registered
|
||||||
|
/// credentials were supplied.
|
||||||
|
case registrationInformationRequired
|
||||||
|
|
||||||
|
/// The authorization server does not support Client ID Metadata Documents and no dynamic
|
||||||
|
/// registration endpoint is available.
|
||||||
|
///
|
||||||
|
/// - Parameter clientID: The client ID URL that was provided as a CIMD URL.
|
||||||
|
case cimdNotSupported(clientID: String)
|
||||||
|
|
||||||
|
/// A URL received from a discovery response resolves to a private or reserved IP address,
|
||||||
|
/// which is blocked to prevent SSRF attacks.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - context: Human-readable label identifying which URL was blocked.
|
||||||
|
/// - url: The blocked URL string.
|
||||||
|
case privateIPAddressBlocked(context: String, url: String)
|
||||||
|
|
||||||
|
/// An endpoint URL used during the OAuth flow does not satisfy the HTTPS-or-loopback requirement.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - context: Human-readable label identifying which endpoint failed (e.g., `"Token endpoint"`).
|
||||||
|
/// - url: The offending URL string.
|
||||||
|
case insecureOAuthEndpoint(context: String, url: String)
|
||||||
|
|
||||||
|
/// An authorization server endpoint does not satisfy the HTTPS-only requirement.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - context: Human-readable label identifying which endpoint failed.
|
||||||
|
/// - url: The offending URL string.
|
||||||
|
case insecureAuthorizationServerEndpoint(context: String, url: String)
|
||||||
|
|
||||||
|
/// The redirect URI supplied for the `authorization_code` flow is not a valid HTTPS or
|
||||||
|
/// loopback HTTP URI, or it contains a fragment.
|
||||||
|
case invalidRedirectURI(String)
|
||||||
|
|
||||||
|
/// The authorization endpoint returned an HTTP error response during the authorization code flow.
|
||||||
|
///
|
||||||
|
/// - Parameter statusCode: HTTP status code from the authorization endpoint.
|
||||||
|
case authorizationRequestFailed(statusCode: Int)
|
||||||
|
|
||||||
|
/// The authorization response did not include a `Location` redirect header.
|
||||||
|
case authorizationResponseMissingRedirectLocation
|
||||||
|
|
||||||
|
/// The redirect URI in the authorization response does not match the expected redirect URI.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - expected: The redirect URI supplied in the authorization request.
|
||||||
|
/// - actual: The redirect URI received in the authorization response.
|
||||||
|
case authorizationResponseRedirectMismatch(expected: String, actual: String)
|
||||||
|
|
||||||
|
/// The authorization response redirect URL is missing the `state` parameter.
|
||||||
|
case authorizationResponseMissingState
|
||||||
|
|
||||||
|
/// The `state` in the authorization response does not match the one sent in the request.
|
||||||
|
///
|
||||||
|
/// This may indicate a CSRF attack.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - expected: The `state` value sent in the authorization request.
|
||||||
|
/// - actual: The `state` value received in the authorization response.
|
||||||
|
case authorizationResponseStateMismatch(expected: String, actual: String)
|
||||||
|
|
||||||
|
/// The authorization response redirect URL is missing the `code` parameter.
|
||||||
|
case authorizationResponseMissingCode
|
||||||
|
|
||||||
|
/// The authorization server metadata does not include `code_challenge_methods_supported`,
|
||||||
|
/// which is required for PKCE (RFC 7636).
|
||||||
|
case pkceCodeChallengeMethodsMissing
|
||||||
|
|
||||||
|
/// The authorization server does not advertise `S256` in `code_challenge_methods_supported`.
|
||||||
|
///
|
||||||
|
/// The MCP specification mandates S256; plain PKCE is not accepted.
|
||||||
|
///
|
||||||
|
/// - Parameter advertisedMethods: The methods listed in the server metadata.
|
||||||
|
case pkceS256NotSupported(advertisedMethods: [String])
|
||||||
|
|
||||||
|
/// PKCE S256 challenge generation is unavailable because CryptoKit is not present on this platform.
|
||||||
|
case pkceS256Unavailable
|
||||||
|
|
||||||
|
/// The `issuer` field in the Authorization Server Metadata does not match the URL used to
|
||||||
|
/// resolve the metadata document, as required by RFC 8414 §3.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - expected: The issuer URL derived from the discovery candidate.
|
||||||
|
/// - actual: The `issuer` field value found in the metadata document.
|
||||||
|
case authorizationServerIssuerMismatch(expected: String, actual: String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingAuthorizationServer:
|
||||||
|
return "No authorization server was found in protected resource metadata"
|
||||||
|
case .metadataDiscoveryFailed:
|
||||||
|
return "Failed to discover protected resource metadata"
|
||||||
|
case .authorizationServerMetadataDiscoveryFailed:
|
||||||
|
return "Failed to discover authorization server metadata"
|
||||||
|
case .tokenEndpointMissing:
|
||||||
|
return "Authorization server metadata is missing token_endpoint"
|
||||||
|
case .tokenRequestFailed(let statusCode, let oauthError):
|
||||||
|
if let oauthError, !oauthError.isEmpty {
|
||||||
|
return "Token request failed with status \(statusCode) (oauth_error: \(oauthError))"
|
||||||
|
}
|
||||||
|
return "Token request failed with status \(statusCode)"
|
||||||
|
case .tokenResponseInvalid:
|
||||||
|
return "Token response is invalid"
|
||||||
|
case .invalidResourceURI(let detail):
|
||||||
|
return "Invalid resource URI: \(detail)"
|
||||||
|
case .invalidClientIDMetadataURL(let value):
|
||||||
|
return
|
||||||
|
"Client ID metadata document URL must use https and include a path: \(value)"
|
||||||
|
case .protectedResourceMismatch(let expected, let actual):
|
||||||
|
return
|
||||||
|
"Protected resource metadata resource mismatch. Expected \(expected), got \(actual)"
|
||||||
|
case .registrationInformationRequired:
|
||||||
|
return
|
||||||
|
"No supported client registration mechanism was available; provide pre-registered client credentials"
|
||||||
|
case .cimdNotSupported(let clientID):
|
||||||
|
return
|
||||||
|
"Authorization server does not support Client ID Metadata Documents; configure pre-registered credentials or ensure the server advertises client_id_metadata_document_supported: \(clientID)"
|
||||||
|
case .privateIPAddressBlocked(let context, let url):
|
||||||
|
return
|
||||||
|
"\(context) resolves to a private or reserved IP address which is blocked for SSRF protection: \(url)"
|
||||||
|
case .insecureOAuthEndpoint(let context, let url):
|
||||||
|
return "\(context) must use https or loopback http: \(url)"
|
||||||
|
case .insecureAuthorizationServerEndpoint(let context, let url):
|
||||||
|
return "\(context) must use https: \(url)"
|
||||||
|
case .invalidRedirectURI(let url):
|
||||||
|
return
|
||||||
|
"Redirect URI must use https or loopback http and must not include fragments: \(url)"
|
||||||
|
case .authorizationRequestFailed(let statusCode):
|
||||||
|
return "Authorization request failed with status \(statusCode)"
|
||||||
|
case .authorizationResponseMissingRedirectLocation:
|
||||||
|
return "Authorization response is missing redirect location"
|
||||||
|
case .authorizationResponseRedirectMismatch(let expected, let actual):
|
||||||
|
return
|
||||||
|
"Authorization response redirect URI mismatch. Expected \(expected), got \(actual)"
|
||||||
|
case .authorizationResponseMissingState:
|
||||||
|
return "Authorization response is missing state"
|
||||||
|
case .authorizationResponseStateMismatch(let expected, let actual):
|
||||||
|
return "Authorization response state mismatch. Expected \(expected), got \(actual)"
|
||||||
|
case .authorizationResponseMissingCode:
|
||||||
|
return "Authorization response is missing the authorization code"
|
||||||
|
case .pkceCodeChallengeMethodsMissing:
|
||||||
|
return
|
||||||
|
"Authorization server metadata must include code_challenge_methods_supported for PKCE"
|
||||||
|
case .pkceS256NotSupported(let advertisedMethods):
|
||||||
|
let methods = advertisedMethods.joined(separator: ", ")
|
||||||
|
return
|
||||||
|
"Authorization server metadata must support PKCE S256 (advertised: \(methods))"
|
||||||
|
case .pkceS256Unavailable:
|
||||||
|
return
|
||||||
|
"PKCE S256 code challenge generation is unavailable on this platform"
|
||||||
|
case .authorizationServerIssuerMismatch(let expected, let actual):
|
||||||
|
return
|
||||||
|
"Authorization server issuer mismatch. Expected \(expected), got \(actual)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
257
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
257
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct HTTPAuthenticationChallengeError: Error {
|
||||||
|
let statusCode: Int
|
||||||
|
let headers: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed representation of a `WWW-Authenticate: Bearer` challenge header.
|
||||||
|
///
|
||||||
|
/// Servers return this challenge in `401 Unauthorized` and `403 Forbidden` responses
|
||||||
|
/// to indicate that a Bearer token is required or that the presented token lacks
|
||||||
|
/// sufficient scope.
|
||||||
|
///
|
||||||
|
/// The ``OAuthWWWAuthenticateParsing`` protocol produces instances of this type.
|
||||||
|
public struct OAuthBearerChallenge: Sendable {
|
||||||
|
/// Raw key-value parameters extracted from the `Bearer` challenge.
|
||||||
|
public let parameters: [String: String]
|
||||||
|
|
||||||
|
/// Creates a challenge from raw parsed parameters.
|
||||||
|
/// - Parameter parameters: Key-value pairs from the `WWW-Authenticate` header.
|
||||||
|
public init(parameters: [String: String]) {
|
||||||
|
self.parameters = parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `resource_metadata` parameter, parsed as a URL.
|
||||||
|
///
|
||||||
|
/// Points to the server's RFC 9728 Protected Resource Metadata document.
|
||||||
|
/// When present, ``OAuthAuthorizer`` uses this URL as the highest-priority
|
||||||
|
/// discovery candidate.
|
||||||
|
public var resourceMetadataURL: URL? {
|
||||||
|
guard let value = parameters["resource_metadata"] else { return nil }
|
||||||
|
return URL(string: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `scope` parameter from the Bearer challenge.
|
||||||
|
///
|
||||||
|
/// Specifies the scopes required or recommended for this resource.
|
||||||
|
/// ``OAuthAuthorizer`` uses this value as the highest-priority scope hint.
|
||||||
|
public var scope: String? {
|
||||||
|
parameters["scope"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `error` parameter from the Bearer challenge (e.g., `"invalid_token"`, `"insufficient_scope"`).
|
||||||
|
public var error: String? {
|
||||||
|
parameters["error"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `error_description` parameter from the Bearer challenge.
|
||||||
|
public var errorDescription: String? {
|
||||||
|
parameters["error_description"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC9728 OAuth Protected Resource metadata (client-side, decode-only).
|
||||||
|
struct OAuthProtectedResourceMetadata: Decodable, Sendable, Equatable {
|
||||||
|
let resource: String?
|
||||||
|
let authorizationServers: [URL]
|
||||||
|
let scopesSupported: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case resource
|
||||||
|
case authorizationServers = "authorization_servers"
|
||||||
|
case scopesSupported = "scopes_supported"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC8414/OIDC authorization server metadata.
|
||||||
|
struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable {
|
||||||
|
let issuer: URL?
|
||||||
|
let authorizationEndpoint: URL?
|
||||||
|
let tokenEndpoint: URL?
|
||||||
|
let registrationEndpoint: URL?
|
||||||
|
let codeChallengeMethodsSupported: [String]?
|
||||||
|
let tokenEndpointAuthMethodsSupported: [String]?
|
||||||
|
let clientIDMetadataDocumentSupported: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case issuer
|
||||||
|
case authorizationEndpoint = "authorization_endpoint"
|
||||||
|
case tokenEndpoint = "token_endpoint"
|
||||||
|
case registrationEndpoint = "registration_endpoint"
|
||||||
|
case codeChallengeMethodsSupported = "code_challenge_methods_supported"
|
||||||
|
case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
|
||||||
|
case clientIDMetadataDocumentSupported = "client_id_metadata_document_supported"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OAuthTokenResponse: Decodable, Sendable, Equatable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int?
|
||||||
|
let scope: String?
|
||||||
|
let refreshToken: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case scope
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OAuthTokenErrorResponse: Decodable {
|
||||||
|
let error: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OAuth 2.1 access token and its associated metadata.
|
||||||
|
///
|
||||||
|
/// Stored by ``TokenStorage`` and produced by ``OAuthAuthorizer`` after a successful
|
||||||
|
/// token request. Use ``isExpired(now:skewSeconds:)`` to check validity before use.
|
||||||
|
public struct OAuthAccessToken: Sendable, Codable {
|
||||||
|
/// The raw bearer token string for use in the `Authorization` header.
|
||||||
|
public let value: String
|
||||||
|
|
||||||
|
/// The token type returned by the authorization server (should be `"Bearer"`).
|
||||||
|
public let tokenType: String
|
||||||
|
|
||||||
|
/// The UTC date after which the token is considered expired, or `nil` if no expiry was specified.
|
||||||
|
public let expiresAt: Date?
|
||||||
|
|
||||||
|
/// The set of OAuth scopes granted with this token.
|
||||||
|
public let scopes: Set<String>
|
||||||
|
|
||||||
|
/// The issuer URL of the authorization server that issued this token.
|
||||||
|
///
|
||||||
|
/// Used to detect when the active authorization server changes between requests,
|
||||||
|
/// triggering a token invalidation.
|
||||||
|
public let authorizationServer: URL?
|
||||||
|
|
||||||
|
/// The refresh token, if the authorization server issued one alongside the access token.
|
||||||
|
public let refreshToken: String?
|
||||||
|
|
||||||
|
/// The OAuth `client_id` this token was issued to, if the authorizer had one at storage time.
|
||||||
|
///
|
||||||
|
/// Captures the `client_id` held by the authorizer's ``OAuthConfiguration/authentication`` when
|
||||||
|
/// the token was saved — including identifiers assigned by Dynamic Client Registration
|
||||||
|
/// (RFC 7591). `nil` when no `client_id` was configured (for example, the placeholder used
|
||||||
|
/// before registration completes).
|
||||||
|
public let clientID: String?
|
||||||
|
|
||||||
|
/// Creates a new access token record.
|
||||||
|
public init(
|
||||||
|
value: String,
|
||||||
|
tokenType: String,
|
||||||
|
expiresAt: Date?,
|
||||||
|
scopes: Set<String>,
|
||||||
|
authorizationServer: URL?,
|
||||||
|
refreshToken: String?,
|
||||||
|
clientID: String? = nil
|
||||||
|
) {
|
||||||
|
self.value = value
|
||||||
|
self.tokenType = tokenType
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.scopes = scopes
|
||||||
|
self.authorizationServer = authorizationServer
|
||||||
|
self.refreshToken = refreshToken
|
||||||
|
self.clientID = clientID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the token has expired or will expire within the skew window.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - now: The reference time to compare against. Defaults to `Date()`.
|
||||||
|
/// - skewSeconds: Clock skew buffer in seconds. Defaults to ``OAuthTokenExpirySkew/defaultSeconds`` (30 s).
|
||||||
|
/// Tokens are considered expired when `now + skewSeconds >= expiresAt`.
|
||||||
|
/// - Returns: `false` if `expiresAt` is `nil` (no expiry).
|
||||||
|
public func isExpired(now: Date = Date(), skewSeconds: TimeInterval = OAuthTokenExpirySkew.defaultSeconds) -> Bool {
|
||||||
|
guard let expiresAt else { return false }
|
||||||
|
return now.addingTimeInterval(skewSeconds) >= expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token introspection result passed from the caller's token validator to ``BearerTokenValidator``.
|
||||||
|
///
|
||||||
|
/// The validator uses this to enforce expiry and audience checks before allowing a request through.
|
||||||
|
/// Produce an instance in your ``BearerTokenValidator/TokenValidator`` closure after verifying
|
||||||
|
/// the token's signature and extracting its claims.
|
||||||
|
public struct BearerTokenInfo: Sendable, Equatable {
|
||||||
|
/// Audience values from the token (`aud` JWT claim or introspection response).
|
||||||
|
///
|
||||||
|
/// `nil` indicates an opaque token whose audience cannot be inspected;
|
||||||
|
/// ``BearerTokenValidator`` skips the audience check in that case.
|
||||||
|
public let audience: [String]?
|
||||||
|
|
||||||
|
/// Scopes granted by the token.
|
||||||
|
public let scopes: Set<String>?
|
||||||
|
|
||||||
|
/// UTC date after which the token is considered expired.
|
||||||
|
///
|
||||||
|
/// `nil` means no expiry information is available and the expiry check is skipped.
|
||||||
|
public let expiresAt: Date?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
audience: [String]? = nil,
|
||||||
|
scopes: Set<String>? = nil,
|
||||||
|
expiresAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.audience = audience
|
||||||
|
self.scopes = scopes
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OAuthClientRegistrationResponse: Decodable {
|
||||||
|
let clientID: String
|
||||||
|
let clientSecret: String?
|
||||||
|
let tokenEndpointAuthMethod: String?
|
||||||
|
/// Unix timestamp after which `clientSecret` is no longer valid, per RFC 7591 §3.2.
|
||||||
|
/// A value of `0` means the secret does not expire.
|
||||||
|
let clientSecretExpiresAt: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientID = "client_id"
|
||||||
|
case clientSecret = "client_secret"
|
||||||
|
case tokenEndpointAuthMethod = "token_endpoint_auth_method"
|
||||||
|
case clientSecretExpiresAt = "client_secret_expires_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side encodable RFC 9728 Protected Resource Metadata.
|
||||||
|
///
|
||||||
|
/// Use this type to construct the metadata document that MCP servers **MUST** serve
|
||||||
|
/// at `/.well-known/oauth-protected-resource` per the MCP authorization specification.
|
||||||
|
///
|
||||||
|
/// Pair with ``ProtectedResourceMetadataValidator`` to automatically serve this
|
||||||
|
/// document in the server's validation pipeline.
|
||||||
|
public struct OAuthProtectedResourceServerMetadata: Codable, Sendable {
|
||||||
|
/// The canonical resource identifier (RFC 8707).
|
||||||
|
public let resource: String
|
||||||
|
|
||||||
|
/// One or more authorization server URLs that protect this resource.
|
||||||
|
public let authorizationServers: [URL]
|
||||||
|
|
||||||
|
/// The scopes supported by this resource server.
|
||||||
|
public let scopesSupported: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case resource
|
||||||
|
case authorizationServers = "authorization_servers"
|
||||||
|
case scopesSupported = "scopes_supported"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
resource: String,
|
||||||
|
authorizationServers: [URL],
|
||||||
|
scopesSupported: [String]? = nil
|
||||||
|
) {
|
||||||
|
self.resource = resource
|
||||||
|
self.authorizationServers = authorizationServers
|
||||||
|
self.scopesSupported = scopesSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue