Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ae212ebf |
||
|
|
cb6a62f7c2 |
||
|
|
6132fd4b5b |
||
|
|
973b46b8f7 |
||
|
|
6112a3995a |
||
|
|
6fa1a97b41 |
||
|
|
8eef5fda00 |
||
|
|
c0407a0b52 |
||
|
|
fb6fffbecd |
||
|
|
ac90e679cd |
||
|
|
78d187716e |
82 changed files with 23747 additions and 1076 deletions
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
|
|
@ -12,12 +12,11 @@ permissions:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
swift-version:
|
||||
- 6.0.3
|
||||
- 6.1.0
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -26,6 +25,12 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare Linux apt dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo -n apt-get update
|
||||
sudo -n apt-get install -y --no-install-recommends libcurl4-openssl-dev
|
||||
|
||||
- name: Setup Swift on Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: vapor/swiftly-action@v0.2
|
||||
|
|
@ -43,6 +48,67 @@ jobs:
|
|||
- 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:
|
||||
|
|
|
|||
4
.spi.yml
Normal file
4
.spi.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [MCP]
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -7,5 +7,9 @@
|
|||
},
|
||||
"[github-actions-workflow]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 4
|
||||
}
|
||||
}
|
||||
|
|
|
|||
197
LICENSE
197
LICENSE
|
|
@ -1,5 +1,194 @@
|
|||
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
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on behalf
|
||||
of the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
|
|
@ -17,3 +206,11 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
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,15 +1,51 @@
|
|||
{
|
||||
"originHash" : "08de61941b7919a65e36c0e34f8c1c41995469b86a39122158b75b4a68c4527d",
|
||||
"originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/loopwork-ai/eventsource.git",
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -19,6 +55,15 @@
|
|||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -1,22 +1,8 @@
|
|||
// 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.
|
||||
|
||||
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: [
|
||||
|
|
@ -31,17 +17,64 @@ let package = Package(
|
|||
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||
.library(
|
||||
name: "MCP",
|
||||
targets: ["MCP"])
|
||||
targets: ["MCP"]),
|
||||
.executable(
|
||||
name: "mcp-everything-server",
|
||||
targets: ["MCPConformanceServer"]),
|
||||
.executable(
|
||||
name: "mcp-everything-client",
|
||||
targets: ["MCPConformanceClient"])
|
||||
],
|
||||
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-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"),
|
||||
],
|
||||
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),
|
||||
dependencies: [
|
||||
.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, .macCatalyst])),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "MCPTests",
|
||||
dependencies: ["MCP"] + targetDependencies),
|
||||
dependencies: [
|
||||
"MCP",
|
||||
.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, .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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for making OAuth token endpoint requests.
|
||||
protocol OAuthTokenRequesting: Sendable {
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse
|
||||
}
|
||||
|
||||
/// Stateless OAuth token endpoint HTTP client.
|
||||
///
|
||||
/// Handles the low-level HTTP mechanics of making token requests.
|
||||
struct OAuthTokenEndpointClient: Sendable {
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(urlValidator: OAuthURLValidator) {
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Makes a token request to the given endpoint.
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse {
|
||||
var urlRequest = URLRequest(url: endpoint)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
try await authentication.apply(
|
||||
to: &urlRequest,
|
||||
bodyParameters: ¶meters,
|
||||
tokenEndpoint: endpoint
|
||||
)
|
||||
urlRequest.httpBody = encodeForm(parameters)
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: httpResponse.statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||||
guard !decoded.accessToken.isEmpty else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !tokenType.isEmpty,
|
||||
tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
|
||||
else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func encodeForm(_ params: [String: String]) -> Data {
|
||||
let body = params
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
|
||||
.joined(separator: "&")
|
||||
return Data(body.utf8)
|
||||
}
|
||||
|
||||
private func percentEncode(_ string: String) -> String {
|
||||
let allowed = CharacterSet(
|
||||
charactersIn:
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthTokenEndpointClient: OAuthTokenRequesting {}
|
||||
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for URL security validation in OAuth flows.
|
||||
protocol OAuthURLValidating: Sendable {
|
||||
func validateHTTPSOrLoopback(_ url: URL, context: String) throws
|
||||
func validateAuthorizationServer(_ url: URL, context: String) throws
|
||||
func validateRedirectURI(_ url: URL) throws
|
||||
func isPrivateIPHost(_ host: String) -> Bool
|
||||
}
|
||||
|
||||
/// URL security rules for OAuth endpoints.
|
||||
///
|
||||
/// Validates that URLs used in the OAuth flow satisfy HTTPS requirements and SSRF protections.
|
||||
/// Configured once and shared across the discovery, token, and registration components.
|
||||
public struct OAuthURLValidator: Sendable {
|
||||
|
||||
/// When `true`, loopback HTTP URLs are accepted for authorization server endpoints.
|
||||
///
|
||||
/// This is a package-level compatibility option for local test environments.
|
||||
public let allowLoopbackHTTPForAuthorizationServer: Bool
|
||||
|
||||
public init(allowLoopbackHTTPForAuthorizationServer: Bool = false) {
|
||||
self.allowLoopbackHTTPForAuthorizationServer = allowLoopbackHTTPForAuthorizationServer
|
||||
}
|
||||
|
||||
/// Validates that the URL uses HTTPS or loopback HTTP, and has no fragment.
|
||||
///
|
||||
/// Used for MCP endpoints and protected resource metadata URLs.
|
||||
public func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https
|
||||
|| (scheme == OAuthURLScheme.http && OAuthLoopbackHost.isLoopback(host))
|
||||
else {
|
||||
throw OAuthAuthorizationError.insecureOAuthEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the URL is an HTTPS authorization server endpoint.
|
||||
///
|
||||
/// Loopback HTTP is permitted when `allowLoopbackHTTPForAuthorizationServer` is `true`.
|
||||
public func validateAuthorizationServer(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
if allowLoopbackHTTPForAuthorizationServer,
|
||||
scheme == OAuthURLScheme.http,
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https else {
|
||||
throw OAuthAuthorizationError.insecureAuthorizationServerEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the redirect URI for authorization_code flows.
|
||||
///
|
||||
/// Accepts HTTPS or loopback HTTP; rejects any URI containing a fragment.
|
||||
public func validateRedirectURI(_ url: URL) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
if scheme == OAuthURLScheme.https { return }
|
||||
|
||||
if scheme == OAuthURLScheme.http,
|
||||
let host = components.host?.lowercased(),
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
/// Returns `true` if `host` is a literal IPv4 or IPv6 address in a private or reserved range.
|
||||
///
|
||||
/// Blocked ranges:
|
||||
/// - IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`
|
||||
/// - IPv6: `fc00::/7` (ULA), `fe80::/10` (link-local)
|
||||
///
|
||||
/// **Limitation**: only literal IP addresses are checked. DNS rebinding is not prevented here.
|
||||
public func isPrivateIPHost(_ host: String) -> Bool {
|
||||
let octets = host.split(separator: ".").compactMap { UInt8($0) }
|
||||
if octets.count == 4 && !host.contains(":") {
|
||||
let (a, b) = (octets[0], octets[1])
|
||||
return a == 10 // 10.0.0.0/8
|
||||
|| (a == 172 && (16...31).contains(b)) // 172.16.0.0/12
|
||||
|| (a == 192 && b == 168) // 192.168.0.0/16
|
||||
|| (a == 169 && b == 254) // 169.254.0.0/16 (link-local / cloud metadata)
|
||||
|| (a == 100 && (64...127).contains(b)) // 100.64.0.0/10 (CGNAT)
|
||||
}
|
||||
let lower = host.lowercased()
|
||||
if lower.hasPrefix("fc") || lower.hasPrefix("fd") { return true } // fc00::/7 (ULA)
|
||||
if lower.hasPrefix("fe") && lower.count > 2 {
|
||||
let idx = lower.index(lower.startIndex, offsetBy: 2)
|
||||
if "89ab".contains(lower[idx]) { return true } // fe80::/10 (link-local)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthURLValidator: OAuthURLValidating {}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue