Compare commits

...
Author SHA1 Message Date
yehorsobko
a0ae212ebf
Improvements and fixes (#223)
Some checks failed
CI / Test (macos-latest, Swift 6.1.0) (push) Has been cancelled
CI / Test (ubuntu-latest, Swift 6.1.0) (push) Has been cancelled
CI / MCP Conformance Tests (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / Linux Static SDK Build (6.1.2 - ubuntu-latest) (push) Has been cancelled
* feat: added http transport context to the task local execution

* fix: added refresh_token to the grantTypes for DCR

* chore: updated documentation
2026-04-29 14:34:21 +03:00
yehorsobko
cb6a62f7c2
feat: auth improvements (#217)
* feat: improvement to oauth resources resolution

* feat: improvements to refreshToken procedure and updates to unit tests

* feat: improvements to documentation of clien_id
2026-04-17 17:34:59 +03:00
Maksym Mova
6132fd4b5b
V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206)

Co-authored-by: wti <wti@users.noreply.github.com>

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

* feat(auth): updated errors and tests in OAuthClientRegistrar

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

* chore: increased timeout for test from 5 to 10m

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-03-24 11:05:22 +02:00
yehorsobko
973b46b8f7
feat: 2025-11-25 review fixes (#201)
* fix: review fixes of the latest changes

* fix: updated unit tests

* chore: more conformance compliance improvements

* chore: reverted protocol changes

* chore: more compliance improvements

* chore: more conformance compliance improvements

* chore: improvements to the backcompat and updated to Tool unit tests

* chore: ci update
2026-03-03 17:33:28 +02:00
Maksym Mova
6112a3995a
V0.11.x (#199)
* Support 2025-06-18 Spec (#167)

* Support OpenAI _meta for Tool

* Update init

* Add title and outputSchema

* Concurreny issue

* General fields

* Add Generate Fields to Server elements

* 4 tab spacing

* Update Version

* Elicitation

* Format

* More tests and inits

* Elicitation in README

* Fix orphaned test

* Add title to more structs

* Public init

* Fix meta validation and make tests more generic

* Format and make Result properties public again

* Revert more visibility changes

* feat: 2025-11-25 phase 1, includes Version, Icon, Progress (#181)

* chore: fixed unit tests

* feat: added icon and progess base structs

* feat: added progress notification and meta to callTool

* feat: added tests

* feat: simplified RequestMeta encoding/decoding

* tests: added unit test to track progress

* chore: rebased v0.11.x

* feat: implemented cancellation

* feat: added overloaded callTool method

* fix: encoding Response _meta fix

* chore: added old version to the versions list

* feat: 2025-11-25 phase 2; added Logging and Completions (#196)

* feat: added Logging and Completions

* feat: updated EmbeddedResource for the latest spec

* chore: changed encoding of meta in Prompts to encodeIfPresent

* feat: compliance tests + http server transport (#197)

* feat: http server transport

* feat: added http server transport

* feat: removed unused files

* feat: added conformance tests

* feat: improved conformace testing

* chore: removed redundant files

* feat/2025-11-25: sampling, elicitation, roots (#198)

* feat: Roots implementation

* feat: added unit testing

* feat: improved unit tests and fixed NetworkTransport crash

* feat: implemented sampling and elicitation

* feat: improved sampling tests

* feat: improved elicitation tests

* feat: improved confirmance tests

* feat: improved Prompts unit tests

* feat: removed test code

* feat: updated conformance tests

* chore: removed redundant commas

* chore: removed 6.0.3 swift version from ci

* chore: improved unit tests a little bit

* chore: update README.md

* chore: add server progress tracking example to README.md

* chore: updated README.md

* chore: updated transports table formatting

* chore: updated MockNetworkConnection

---------

Co-authored-by: Austin Evans <austinjevans@me.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-02-19 13:09:31 +02:00
Den Delimarsky
6fa1a97b41
Add SECURITY.md with GitHub Security Advisories guidance (#200) 2026-02-18 23:09:41 +02:00
adam jones
8eef5fda00
chore: update licensing to Apache 2.0 for new contributions (#177) 2026-01-28 00:03:51 +02:00
Semen Kudriavtsev
c0407a0b52
Add Mac Catalyst to target dependencies for EventSource. (#163) 2025-09-23 13:54:32 -07:00
Yifan Liu
fb6fffbecd
fix: allow description field to be absent from Tool (#159)
* fix: allow description field to be absent from `Tool`
* description field is not a requried field of Tool

* Apply suggestions from code review

* Update Tests/MCPTests/ToolTests.swift

---------

Co-authored-by: Mattt <mattt@me.com>
2025-09-07 04:30:19 -07:00
Mattt
ac90e679cd
Make Tool.description optional (#160)
* Make Tool.description optional

* Make description parameter in Tool initializer optional
2025-09-07 04:24:38 -07:00
Alexey Ivashko
78d187716e
Adopt strict concurrency for project (#157)
* Adopt strict concurrency for project

* Fix one warning related to that

* Add version-specific manifest for Swift 6.0

---------

Co-authored-by: Alexey Ivashko <a_ivashko@apple.com>
Co-authored-by: Mattt Zmuda <mattt@me.com>
2025-09-07 04:21:15 -07:00
82 changed files with 23747 additions and 1076 deletions

View file

@ -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
View file

@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [MCP]

View file

@ -7,5 +7,9 @@
},
"[github-actions-workflow]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 4
}
}

197
LICENSE
View file

@ -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.

View file

@ -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",

View file

@ -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
View 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),
]
)

882
README.md

File diff suppressed because it is too large Load diff

21
SECURITY.md Normal file
View 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)

View 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 {}

View 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
}
}

View 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 {}

View 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
}

View 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: "")
}
}

View 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
}
}

View 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 {}

View 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)"
}
}
}

View 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
}
}

View file

@ -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: &parameters,
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 {}

View 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