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>
This commit is contained in:
parent
973b46b8f7
commit
6132fd4b5b
39 changed files with 10925 additions and 199 deletions
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
|
|
@ -12,7 +12,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
|
|
@ -61,23 +61,19 @@ jobs:
|
|||
with:
|
||||
swift-version: 6.1.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- 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.11
|
||||
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: |
|
||||
|
|
@ -86,12 +82,13 @@ jobs:
|
|||
sleep 3
|
||||
|
||||
- name: Run server conformance tests
|
||||
uses: modelcontextprotocol/conformance@v0.1.11
|
||||
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()
|
||||
|
|
@ -100,6 +97,18 @@ jobs:
|
|||
kill $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
documentation:
|
||||
name: Documentation
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: swift-actions/setup-swift@v2
|
||||
with:
|
||||
swift-version: "6.1.0"
|
||||
- name: Build Documentation
|
||||
run: swift package generate-documentation --target MCP --warnings-as-errors
|
||||
|
||||
static-linux-sdk-build:
|
||||
name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }})
|
||||
strategy:
|
||||
|
|
|
|||
4
.spi.yml
Normal file
4
.spi.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [MCP]
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "2e844b5aa785005b516db94bbdf89be795d57d958f648d3bf655b75ad9ad9d68",
|
||||
"originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
|
|
@ -28,6 +28,24 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ let package = Package(
|
|||
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"),
|
||||
|
|
|
|||
198
README.md
198
README.md
|
|
@ -43,6 +43,15 @@ of the MCP specification.
|
|||
- [Initialize Hook](#initialize-hook)
|
||||
- [Graceful Shutdown](#graceful-shutdown)
|
||||
- [Transports](#transports)
|
||||
- [Authentication](#authentication)
|
||||
- [Client: Client Credentials Flow](#client-client-credentials-flow)
|
||||
- [Client: Authorization Code Flow](#client-authorization-code-flow)
|
||||
- [Client: Custom Token Provider](#client-custom-token-provider)
|
||||
- [Client: Custom Token Storage](#client-custom-token-storage)
|
||||
- [Client: private\_key\_jwt Authentication](#client-private_key_jwt-authentication)
|
||||
- [Client: Endpoint Overrides](#client-endpoint-overrides)
|
||||
- [Server: Serving Protected Resource Metadata](#server-serving-protected-resource-metadata)
|
||||
- [Server: Validating Bearer Tokens](#server-validating-bearer-tokens)
|
||||
- [Platform Availability](#platform-availability)
|
||||
- [Debugging and Logging](#debugging-and-logging)
|
||||
- [Additional Resources](#additional-resources)
|
||||
|
|
@ -1341,6 +1350,195 @@ public actor MyCustomTransport: Transport {
|
|||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
`HTTPClientTransport` supports OAuth 2.1 Bearer token authorization per the
|
||||
[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
|
||||
When a server returns `401 Unauthorized` or `403 Forbidden`, the transport automatically:
|
||||
|
||||
1. Discovers Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
|
||||
2. Discovers Authorization Server Metadata (RFC 8414 / OIDC Discovery 1.0)
|
||||
3. Registers the client dynamically (RFC 7591) if needed
|
||||
4. Acquires a Bearer token using the configured grant flow (PKCE enforced)
|
||||
5. Retries the original request with the token attached
|
||||
|
||||
Authorization is opt-in and disabled by default.
|
||||
Pass an `OAuthAuthorizer` to `HTTPClientTransport(authorizer:)` to enable it.
|
||||
|
||||
### Client: Client Credentials Flow
|
||||
|
||||
Machine-to-machine authentication using a pre-shared client secret:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
|
||||
)
|
||||
let authorizer = OAuthAuthorizer(configuration: config)
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: URL(string: "https://api.example.com/mcp")!,
|
||||
authorizer: authorizer
|
||||
)
|
||||
let client = Client(name: "MyClient", version: "1.0.0")
|
||||
try await client.connect(transport: transport)
|
||||
```
|
||||
|
||||
### Client: Authorization Code Flow
|
||||
|
||||
Interactive, browser-based authentication with PKCE.
|
||||
Implement `OAuthAuthorizationDelegate` to open the authorization URL and capture the redirect:
|
||||
|
||||
```swift
|
||||
struct MyAuthDelegate: OAuthAuthorizationDelegate {
|
||||
func presentAuthorizationURL(_ url: URL) async throws -> URL {
|
||||
// Open the URL in a browser/webview and wait for the callback redirect URI.
|
||||
// The returned URL must include the authorization code and state parameters.
|
||||
return try await openBrowserAndWaitForCallback(url)
|
||||
}
|
||||
}
|
||||
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .authorizationCode,
|
||||
authentication: .none(clientID: "my-app"),
|
||||
authorizationDelegate: MyAuthDelegate()
|
||||
)
|
||||
let authorizer = OAuthAuthorizer(configuration: config)
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: URL(string: "https://api.example.com/mcp")!,
|
||||
authorizer: authorizer
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Custom Token Provider
|
||||
|
||||
Supply an externally acquired token (e.g., from a system credential store) via `accessTokenProvider`.
|
||||
The SDK calls this closure after discovery completes. Return `nil` to fall back to the configured grant flow:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .none(clientID: "my-app"),
|
||||
accessTokenProvider: { context, session in
|
||||
// context contains the discovered resource URI, token endpoint, scopes, etc.
|
||||
return try await KeychainTokenStore.shared.loadToken(for: context.resource)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Custom Token Storage
|
||||
|
||||
By default, tokens are stored in memory and lost when the process exits.
|
||||
To persist tokens across sessions, implement `TokenStorage` and pass it to `OAuthAuthorizer`:
|
||||
|
||||
```swift
|
||||
final class KeychainTokenStorage: TokenStorage {
|
||||
func save(_ token: OAuthAccessToken) {
|
||||
// Encode and store token.value in the system Keychain
|
||||
}
|
||||
|
||||
func load() -> OAuthAccessToken? {
|
||||
// Load and decode token from the Keychain
|
||||
return nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
// Delete from the Keychain
|
||||
}
|
||||
}
|
||||
|
||||
let authorizer = OAuthAuthorizer(
|
||||
configuration: config,
|
||||
tokenStorage: KeychainTokenStorage()
|
||||
)
|
||||
```
|
||||
|
||||
### Client: `private_key_jwt` Authentication
|
||||
|
||||
Authenticate to the token endpoint using an asymmetric key (RFC 7523).
|
||||
The SDK provides a built-in ES256 helper for P-256 keys:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .privateKeyJWT(
|
||||
clientID: "my-app",
|
||||
assertionFactory: { tokenEndpoint, clientID in
|
||||
try OAuthConfiguration.makePrivateKeyJWTAssertion(
|
||||
clientID: clientID,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
privateKeyPEM: myEC256PrivateKeyPEM // PEM-encoded P-256 private key
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Client: Endpoint Overrides
|
||||
|
||||
Skip automatic discovery by providing explicit endpoint URLs.
|
||||
Useful when the server does not publish well-known metadata documents:
|
||||
|
||||
```swift
|
||||
let config = OAuthConfiguration(
|
||||
grantType: .clientCredentials,
|
||||
authentication: .clientSecretBasic(clientID: "app", clientSecret: "secret"),
|
||||
endpointOverrides: OAuthConfiguration.EndpointOverrides(
|
||||
tokenEndpoint: URL(string: "https://auth.example.com/oauth/token")!
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Server: Serving Protected Resource Metadata
|
||||
|
||||
Per the MCP authorization specification, servers **MUST** serve Protected Resource Metadata
|
||||
at `/.well-known/oauth-protected-resource` so clients can discover authorization server endpoints.
|
||||
|
||||
Use `ProtectedResourceMetadataValidator` as the first validator in your pipeline so that
|
||||
unauthenticated discovery requests are handled before the bearer token check:
|
||||
|
||||
```swift
|
||||
let metadata = OAuthProtectedResourceServerMetadata(
|
||||
resource: "https://api.example.com",
|
||||
authorizationServers: [URL(string: "https://auth.example.com")!],
|
||||
scopesSupported: ["read", "write"]
|
||||
)
|
||||
let metadataValidator = ProtectedResourceMetadataValidator(metadata: metadata)
|
||||
```
|
||||
|
||||
### Server: Validating Bearer Tokens
|
||||
|
||||
Use `BearerTokenValidator` to authenticate incoming requests.
|
||||
Your `tokenValidator` closure **MUST** verify the token's `aud` claim to prevent
|
||||
token substitution attacks where a token intended for another resource is replayed against your server:
|
||||
|
||||
```swift
|
||||
let resourceIdentifier = URL(string: "https://api.example.com")!
|
||||
|
||||
let bearerValidator = BearerTokenValidator(
|
||||
resourceMetadataURL: URL(string: "https://api.example.com/.well-known/oauth-protected-resource")!,
|
||||
resourceIdentifier: resourceIdentifier,
|
||||
tokenValidator: { token, request, context in
|
||||
guard let claims = try? verifyAndDecodeJWT(token) else {
|
||||
return .invalidToken(errorDescription: "Token verification failed")
|
||||
}
|
||||
// Pass audience and expiry to BearerTokenInfo; the SDK validates the
|
||||
// audience claim against resourceIdentifier automatically.
|
||||
return .valid(BearerTokenInfo(
|
||||
audience: claims.audience,
|
||||
expiresAt: claims.expiresAt
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
let pipeline = StandardValidationPipeline(validators: [
|
||||
metadataValidator, // serves /.well-known/oauth-protected-resource unauthenticated
|
||||
bearerValidator, // validates Bearer tokens on all other requests
|
||||
AcceptHeaderValidator(mode: .sseRequired),
|
||||
ContentTypeValidator(),
|
||||
SessionValidator(),
|
||||
])
|
||||
```
|
||||
|
||||
## Platform Availability
|
||||
|
||||
The Swift SDK has the following platform requirements:
|
||||
|
|
|
|||
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - OAuthAuthorizationCodeFlowing Protocol
|
||||
|
||||
/// Internal protocol for driving the OAuth 2.1 authorization code flow.
|
||||
protocol OAuthAuthorizationCodeFlowing: Sendable {
|
||||
func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL
|
||||
|
||||
func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
// MARK: - No-Redirect Session Delegate
|
||||
|
||||
final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
|
||||
@unchecked Sendable
|
||||
{
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Authorization Code Flow
|
||||
|
||||
/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
|
||||
///
|
||||
/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
|
||||
/// and extracts the authorization code from the callback redirect URL.
|
||||
public struct OAuthAuthorizationCodeFlow: Sendable {
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Builds the authorization request URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationEndpoint: The AS authorization endpoint.
|
||||
/// - resource: The RFC 8707 resource indicator.
|
||||
/// - redirectURI: The redirect URI registered for this client.
|
||||
/// - clientID: The OAuth client identifier.
|
||||
/// - codeChallenge: The PKCE S256 code challenge.
|
||||
/// - scopes: Optional scope set to request.
|
||||
/// - state: The CSRF state nonce.
|
||||
/// - scopeSerializer: Serializes the scope set to a space-separated string.
|
||||
/// - Returns: The full authorization request URL with all query parameters.
|
||||
public func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL {
|
||||
guard var components = URLComponents(
|
||||
url: authorizationEndpoint,
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] = [
|
||||
.init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
|
||||
.init(name: OAuthParameterName.clientID, value: clientID),
|
||||
.init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
|
||||
.init(name: OAuthParameterName.state, value: state),
|
||||
.init(name: OAuthParameterName.resource, value: resource.absoluteString),
|
||||
.init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
|
||||
.init(
|
||||
name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
|
||||
]
|
||||
|
||||
if let scope = scopes.flatMap(scopeSerializer.serialize) {
|
||||
queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
|
||||
}
|
||||
|
||||
components.queryItems = queryItems
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Drives the interactive authorization redirect and returns the authorization code.
|
||||
///
|
||||
/// When a delegate is provided, presents the authorization URL and awaits the redirect.
|
||||
/// Without a delegate, sends a GET request and captures the redirect `Location` header.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationURL: The full authorization request URL.
|
||||
/// - redirectURI: The expected redirect URI base for validation.
|
||||
/// - state: The CSRF state nonce to verify in the redirect.
|
||||
/// - delegate: Optional user-facing delegate for browser-based flows.
|
||||
/// - session: The `URLSession` used for the no-redirect path.
|
||||
/// - Returns: The extracted authorization code.
|
||||
public func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String {
|
||||
if let delegate {
|
||||
let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: authorizationURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(
|
||||
"text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
|
||||
let noRedirectSession = URLSession(
|
||||
configuration: session.configuration,
|
||||
delegate: noRedirectDelegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
defer { noRedirectSession.invalidateAndCancel() }
|
||||
|
||||
let (_, response) = try await noRedirectSession.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard (300..<400).contains(httpResponse.statusCode) else {
|
||||
if httpResponse.statusCode >= 400 {
|
||||
throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
|
||||
!location.isEmpty,
|
||||
let redirectURL = URL(string: location)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts and validates the authorization code from the redirect URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - redirectURL: The redirect URL received from the authorization server.
|
||||
/// - expectedRedirectURI: The redirect URI used in the authorization request.
|
||||
/// - expectedState: The CSRF state nonce sent in the authorization request.
|
||||
/// - Returns: The authorization code.
|
||||
public func extractCode(
|
||||
from redirectURL: URL,
|
||||
expectedRedirectURI: URL,
|
||||
expectedState: String
|
||||
) throws -> String {
|
||||
guard
|
||||
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
|
||||
let expectedComponents = URLComponents(
|
||||
url: expectedRedirectURI, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let state = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.state
|
||||
})?.value,
|
||||
!state.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingState
|
||||
}
|
||||
|
||||
guard state == expectedState else {
|
||||
throw OAuthAuthorizationError.authorizationResponseStateMismatch(
|
||||
expected: expectedState,
|
||||
actual: state
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let code = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.code
|
||||
})?.value,
|
||||
!code.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingCode
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func normalizedRedirectBase(_ components: URLComponents) -> String {
|
||||
let scheme = components.scheme?.lowercased() ?? ""
|
||||
let host = components.host?.lowercased() ?? ""
|
||||
let port: Int
|
||||
if let explicitPort = components.port {
|
||||
port = explicitPort
|
||||
} else if scheme == OAuthURLScheme.https {
|
||||
port = OAuthDefaultPort.https
|
||||
} else if scheme == OAuthURLScheme.http {
|
||||
port = OAuthDefaultPort.http
|
||||
} else {
|
||||
port = -1
|
||||
}
|
||||
let path = components.path.isEmpty ? "/" : components.path
|
||||
return "\(scheme)://\(host):\(port)\(path)"
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}
|
||||
865
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
865
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
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() {
|
||||
tokenStorage.clear()
|
||||
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() 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 metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
||||
candidates: candidates, 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
|
||||
))
|
||||
}
|
||||
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
private func makeAccessTokenProviderContext(
|
||||
statusCode: Int,
|
||||
endpoint: URL,
|
||||
challenge: OAuthBearerChallenge?,
|
||||
metadata: OAuthProtectedResourceMetadata,
|
||||
requestedScopes: Set<String>?,
|
||||
session: URLSession
|
||||
) async throws -> OAuthConfiguration.AccessTokenProviderContext {
|
||||
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||
metadata: metadata, session: session)
|
||||
let resource = try canonicalResource(for: endpoint)
|
||||
let authorizationServer = configuration.endpointOverrides.authorizationServerURL
|
||||
?? selectedAuthorizationServer
|
||||
?? metadata.authorizationServers.first
|
||||
let tokenEndpoint = configuration.endpointOverrides.tokenEndpoint ?? asMetadata.tokenEndpoint
|
||||
|
||||
return OAuthConfiguration.AccessTokenProviderContext(
|
||||
statusCode: statusCode,
|
||||
endpoint: endpoint,
|
||||
resource: resource,
|
||||
authorizationServer: authorizationServer,
|
||||
authorizationEndpoint: asMetadata.authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
registrationEndpoint: asMetadata.registrationEndpoint,
|
||||
challengedScope: challenge?.scope,
|
||||
scopesSupported: metadata.scopesSupported,
|
||||
requestedScopes: requestedScopes
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedOperationKey(_ operationKey: String?) -> String {
|
||||
guard let operationKey else { return "<unknown>" }
|
||||
let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty ? "<unknown>" : normalized
|
||||
}
|
||||
}
|
||||
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for OAuth dynamic client registration.
|
||||
protocol OAuthClientRegistering: Sendable {
|
||||
func register(
|
||||
configuration: OAuthConfiguration,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws -> (
|
||||
response: OAuthClientRegistrationResponse,
|
||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)?
|
||||
}
|
||||
|
||||
/// Stateless OAuth dynamic client registration logic.
|
||||
///
|
||||
/// Handles Client ID Metadata Document (CIMD) detection and RFC 7591 dynamic registration.
|
||||
/// State tracking (`clientRegistrationAttempted`, `clientSecretExpiresAt`) is the caller's responsibility.
|
||||
struct OAuthClientRegistrar: Sendable {
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(urlValidator: OAuthURLValidator) {
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Attempts to register the client, if applicable.
|
||||
///
|
||||
/// Returns `nil` if registration is not needed:
|
||||
/// - Credentials are already configured (not `.none`)
|
||||
/// - CIMD is in use and the server supports it (pre-registered)
|
||||
/// - No registration endpoint is available and no CIMD mismatch error
|
||||
///
|
||||
/// Throws if registration was attempted but failed (4xx, 5xx, or unexpected response).
|
||||
func register(
|
||||
configuration: OAuthConfiguration,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws -> (
|
||||
response: OAuthClientRegistrationResponse,
|
||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)? {
|
||||
guard case .none(let clientID) = configuration.authentication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hasClientIDMetadataDocument = isHTTPSURLWithPath(clientID)
|
||||
let supportsClientIDMetadataDocument = asMetadata.clientIDMetadataDocumentSupported == true
|
||||
|
||||
if supportsClientIDMetadataDocument,
|
||||
clientIDLooksLikeURL(clientID),
|
||||
!hasClientIDMetadataDocument
|
||||
{
|
||||
throw OAuthAuthorizationError.invalidClientIDMetadataURL(clientID)
|
||||
}
|
||||
|
||||
if hasClientIDMetadataDocument, supportsClientIDMetadataDocument {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let registrationEndpoint = asMetadata.registrationEndpoint else {
|
||||
if hasClientIDMetadataDocument && !supportsClientIDMetadataDocument {
|
||||
throw OAuthAuthorizationError.cimdNotSupported(clientID: clientID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
try urlValidator.validateAuthorizationServer(
|
||||
registrationEndpoint, context: "Client registration endpoint")
|
||||
|
||||
var request = URLRequest(url: registrationEndpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
let grantTypes: [String]
|
||||
let responseTypes: [String]
|
||||
switch configuration.grantType {
|
||||
case .authorizationCode:
|
||||
grantTypes = [OAuthGrantTypeValue.authorizationCode]
|
||||
responseTypes = [OAuthParameterName.code]
|
||||
case .clientCredentials:
|
||||
grantTypes = [OAuthGrantTypeValue.clientCredentials]
|
||||
responseTypes = []
|
||||
}
|
||||
|
||||
var registrationPayload: [String: Any] = [
|
||||
"client_name": configuration.clientName,
|
||||
"grant_types": grantTypes,
|
||||
"token_endpoint_auth_method": configuration.authentication.methodName,
|
||||
]
|
||||
if !responseTypes.isEmpty {
|
||||
registrationPayload["response_types"] = responseTypes
|
||||
}
|
||||
if configuration.grantType == .authorizationCode {
|
||||
registrationPayload["redirect_uris"] = [
|
||||
configuration.authorizationRedirectURI.absoluteString
|
||||
]
|
||||
}
|
||||
|
||||
let httpBody = try JSONSerialization.data(withJSONObject: registrationPayload)
|
||||
|
||||
request.httpBody = httpBody
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
(400..<500).contains(httpResponse.statusCode)
|
||||
{
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: httpResponse.statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
|
||||
let registration = try JSONDecoder().decode(OAuthClientRegistrationResponse.self, from: data)
|
||||
|
||||
let updatedAuth = OAuthClientRegistrar.updatedAuthentication(
|
||||
from: registration, current: configuration.authentication)
|
||||
return (response: registration, updatedAuthentication: updatedAuth)
|
||||
}
|
||||
|
||||
/// Derives the updated token endpoint authentication from a registration response.
|
||||
///
|
||||
/// Updates the client ID (and secret, if issued) while preserving the authentication method.
|
||||
static func updatedAuthentication(
|
||||
from registration: OAuthClientRegistrationResponse,
|
||||
current: OAuthConfiguration.TokenEndpointAuthentication
|
||||
) -> OAuthConfiguration.TokenEndpointAuthentication {
|
||||
switch current {
|
||||
case .none:
|
||||
return .none(clientID: registration.clientID)
|
||||
case .clientSecretBasic(_, let currentSecret):
|
||||
return .clientSecretBasic(
|
||||
clientID: registration.clientID,
|
||||
clientSecret: registration.clientSecret ?? currentSecret
|
||||
)
|
||||
case .clientSecretPost(_, let currentSecret):
|
||||
return .clientSecretPost(
|
||||
clientID: registration.clientID,
|
||||
clientSecret: registration.clientSecret ?? currentSecret
|
||||
)
|
||||
case .privateKeyJWT(_, let factory):
|
||||
return .privateKeyJWT(clientID: registration.clientID, assertionFactory: factory)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CIMD Helpers
|
||||
|
||||
private func isHTTPSURLWithPath(_ value: String) -> Bool {
|
||||
guard let url = URL(string: value),
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == OAuthURLScheme.https
|
||||
&& !components.path.isEmpty
|
||||
&& components.path != "/"
|
||||
}
|
||||
|
||||
private func clientIDLooksLikeURL(_ value: String) -> Bool {
|
||||
guard let url = URL(string: value),
|
||||
let scheme = url.scheme,
|
||||
!scheme.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthClientRegistrar: OAuthClientRegistering {}
|
||||
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
/// Configuration for OAuth 2.1 authorization used by ``HTTPClientTransport``.
|
||||
///
|
||||
/// Authorization is optional and disabled by default. Configure this type and pass it
|
||||
/// to `HTTPClientTransport(oauth:)` to enable automatic Bearer token acquisition for HTTP
|
||||
/// transports.
|
||||
///
|
||||
/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
|
||||
/// (machine-to-machine) grant types via the ``grantType`` property.
|
||||
public struct OAuthConfiguration: Sendable {
|
||||
/// The OAuth 2.1 grant type to use for token acquisition.
|
||||
public enum GrantType: Sendable {
|
||||
/// OAuth 2.1 authorization_code flow with PKCE.
|
||||
case authorizationCode
|
||||
|
||||
/// OAuth 2.1 client_credentials flow.
|
||||
case clientCredentials
|
||||
}
|
||||
|
||||
/// How the client authenticates to the OAuth token endpoint.
|
||||
public enum TokenEndpointAuthentication: Sendable, Equatable {
|
||||
/// `client_secret_basic` authentication using the Authorization header.
|
||||
case clientSecretBasic(clientID: String, clientSecret: String)
|
||||
|
||||
/// `client_secret_post` authentication using form parameters.
|
||||
case clientSecretPost(clientID: String, clientSecret: String)
|
||||
|
||||
/// Public client authentication (`token_endpoint_auth_method=none`).
|
||||
case none(clientID: String)
|
||||
|
||||
/// `private_key_jwt` authentication.
|
||||
///
|
||||
/// The built-in ``OAuthConfiguration/makePrivateKeyJWTAssertion(clientID:tokenEndpoint:privateKeyPEM:signingAlgorithm:audience:issuedAt:expiresIn:)``
|
||||
/// helper generates ES256 (P-256 ECDSA) assertions only. To use other algorithms
|
||||
/// (e.g., RS256, ES384), provide a custom ``OAuthConfiguration/JWTAssertionFactory`` closure.
|
||||
case privateKeyJWT(clientID: String, assertionFactory: JWTAssertionFactory)
|
||||
|
||||
var clientID: String {
|
||||
switch self {
|
||||
case .clientSecretBasic(let clientID, _), .clientSecretPost(let clientID, _),
|
||||
.none(let clientID), .privateKeyJWT(let clientID, _):
|
||||
return clientID
|
||||
}
|
||||
}
|
||||
|
||||
/// The token endpoint auth method name per RFC 7591.
|
||||
var methodName: String {
|
||||
switch self {
|
||||
case .clientSecretBasic: return OAuthTokenEndpointAuthMethod.clientSecretBasic
|
||||
case .clientSecretPost: return OAuthTokenEndpointAuthMethod.clientSecretPost
|
||||
case .none: return OAuthTokenEndpointAuthMethod.none
|
||||
case .privateKeyJWT: return OAuthTokenEndpointAuthMethod.privateKeyJWT
|
||||
}
|
||||
}
|
||||
|
||||
func apply(
|
||||
to request: inout URLRequest,
|
||||
bodyParameters: inout [String: String],
|
||||
tokenEndpoint: URL
|
||||
) async throws {
|
||||
switch self {
|
||||
case .clientSecretBasic(let clientID, let clientSecret):
|
||||
let basic = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
|
||||
request.setValue("Basic \(basic)", forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||
case .clientSecretPost(let clientID, let clientSecret):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
bodyParameters[OAuthParameterName.clientSecret] = clientSecret
|
||||
case .none(let clientID):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
case .privateKeyJWT(let clientID, let assertionFactory):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
bodyParameters[OAuthParameterName.clientAssertionType] =
|
||||
OAuthClientAssertionType.jwtBearer
|
||||
bodyParameters[OAuthParameterName.clientAssertion] =
|
||||
try await assertionFactory(tokenEndpoint, clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closure used to generate a `private_key_jwt` assertion.
|
||||
public typealias JWTAssertionFactory = @Sendable (_ tokenEndpoint: URL, _ clientID: String)
|
||||
async throws -> String
|
||||
|
||||
/// Supported signing algorithms for SDK-generated `private_key_jwt` assertions.
|
||||
public enum PrivateKeyJWTSigningAlgorithm: String, Sendable {
|
||||
case ES256
|
||||
}
|
||||
|
||||
/// Errors thrown while creating SDK-generated `private_key_jwt` assertions.
|
||||
public enum PrivateKeyJWTAssertionError: LocalizedError, Sendable {
|
||||
case invalidLifetime(TimeInterval)
|
||||
case cryptographyUnavailable
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidLifetime(let lifetime):
|
||||
return "private_key_jwt assertion lifetime must be greater than zero seconds, got \(lifetime)"
|
||||
case .cryptographyUnavailable:
|
||||
return "private_key_jwt assertion signing requires CryptoKit support"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a signed `private_key_jwt` client assertion (RFC 7523).
|
||||
///
|
||||
/// Use this helper to build a `JWTAssertionFactory` closure when configuring
|
||||
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``:
|
||||
///
|
||||
/// ```swift
|
||||
/// let factory: OAuthConfiguration.JWTAssertionFactory = { tokenEndpoint, clientID in
|
||||
/// try OAuthConfiguration.makePrivateKeyJWTAssertion(
|
||||
/// clientID: clientID,
|
||||
/// tokenEndpoint: tokenEndpoint,
|
||||
/// privateKeyPEM: myPEMString
|
||||
/// )
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The assertion is a compact-serialized JWS signed with the specified private key.
|
||||
/// Only ES256 (P-256 ECDSA) is supported by this helper; the algorithm requires CryptoKit.
|
||||
/// To use other algorithms, supply a custom ``JWTAssertionFactory`` closure directly to
|
||||
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - clientID: The OAuth client identifier, used as both `iss` and `sub` claims.
|
||||
/// - tokenEndpoint: Token endpoint URL, used as the default `aud` claim.
|
||||
/// - privateKeyPEM: PEM-encoded EC private key for signing.
|
||||
/// - signingAlgorithm: The JWS signing algorithm. Currently only `.ES256` is supported.
|
||||
/// - audience: Explicit `aud` claim override. Defaults to `tokenEndpoint.absoluteString`.
|
||||
/// - issuedAt: The `iat` claim. Defaults to `Date()`.
|
||||
/// - expiresIn: Lifetime of the assertion in seconds. Defaults to 300 (5 minutes). Must be > 0.
|
||||
/// - Returns: A compact-serialized JWT string (`header.payload.signature`).
|
||||
/// - Throws: ``PrivateKeyJWTAssertionError/invalidLifetime(_:)`` if `expiresIn` ≤ 0,
|
||||
/// or ``PrivateKeyJWTAssertionError/cryptographyUnavailable`` on platforms without CryptoKit.
|
||||
public static func makePrivateKeyJWTAssertion(
|
||||
clientID: String,
|
||||
tokenEndpoint: URL,
|
||||
privateKeyPEM: String,
|
||||
signingAlgorithm: PrivateKeyJWTSigningAlgorithm = .ES256,
|
||||
audience: String? = nil,
|
||||
issuedAt: Date = Date(),
|
||||
expiresIn: TimeInterval = 300
|
||||
) throws -> String {
|
||||
guard expiresIn > 0 else {
|
||||
throw PrivateKeyJWTAssertionError.invalidLifetime(expiresIn)
|
||||
}
|
||||
|
||||
let header = try JSONSerialization.data(withJSONObject: [
|
||||
JWTClaimName.algorithm: signingAlgorithm.rawValue,
|
||||
JWTClaimName.type: JWTClaimName.typeValue,
|
||||
])
|
||||
|
||||
let issuedAtUnix = Int(issuedAt.timeIntervalSince1970)
|
||||
let lifetimeSeconds = max(1, Int(expiresIn.rounded(.down)))
|
||||
let payload = try JSONSerialization.data(withJSONObject: [
|
||||
JWTClaimName.issuer: clientID,
|
||||
JWTClaimName.subject: clientID,
|
||||
JWTClaimName.audience: audience ?? tokenEndpoint.absoluteString,
|
||||
JWTClaimName.issuedAt: issuedAtUnix,
|
||||
JWTClaimName.expiration: issuedAtUnix + lifetimeSeconds,
|
||||
JWTClaimName.jwtID: UUID().uuidString,
|
||||
])
|
||||
|
||||
let signingInput = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())"
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
switch signingAlgorithm {
|
||||
case .ES256:
|
||||
let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPEM)
|
||||
let signature = try privateKey.signature(for: Data(signingInput.utf8)).rawRepresentation
|
||||
return "\(signingInput).\(signature.base64URLEncodedString())"
|
||||
}
|
||||
#else
|
||||
throw PrivateKeyJWTAssertionError.cryptographyUnavailable
|
||||
#endif
|
||||
}
|
||||
|
||||
static func defaultAuthorizationRedirectURI() -> URL {
|
||||
let port = Int.random(in: 49152...65535)
|
||||
return URL(string: "http://\(OAuthLoopbackHost.ipv4):\(port)/callback")!
|
||||
}
|
||||
|
||||
// MARK: - Retry Policy
|
||||
|
||||
/// Controls retry behavior for authorization challenges.
|
||||
public struct RetryPolicy: Sendable {
|
||||
/// Maximum number of authentication retries for a single MCP request.
|
||||
public let maxAuthorizationAttempts: Int
|
||||
|
||||
/// Maximum number of scope step-up attempts for a resource and operation.
|
||||
public let maxScopeUpgradeAttempts: Int
|
||||
|
||||
/// Creates a retry policy.
|
||||
///
|
||||
/// Both values are clamped to a minimum of `1` to prevent infinite loops
|
||||
/// or zero-retry configurations.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - maxAuthorizationAttempts: Maximum authorization retries per request. Defaults to 3.
|
||||
/// - maxScopeUpgradeAttempts: Maximum scope step-up attempts per resource+operation. Defaults to 2.
|
||||
public init(
|
||||
maxAuthorizationAttempts: Int = 3,
|
||||
maxScopeUpgradeAttempts: Int = 2
|
||||
) {
|
||||
self.maxAuthorizationAttempts = max(1, maxAuthorizationAttempts)
|
||||
self.maxScopeUpgradeAttempts = max(1, maxScopeUpgradeAttempts)
|
||||
}
|
||||
|
||||
public static let `default` = RetryPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Endpoint Overrides
|
||||
|
||||
/// Optional endpoint overrides for discovery.
|
||||
public struct EndpointOverrides: Sendable {
|
||||
/// Optional override for the protected resource metadata URL.
|
||||
public let protectedResourceMetadataURL: URL?
|
||||
|
||||
/// Optional override for the authorization server issuer URL.
|
||||
public let authorizationServerURL: URL?
|
||||
|
||||
/// Optional override for the token endpoint.
|
||||
public let tokenEndpoint: URL?
|
||||
|
||||
/// Optional override for the resource indicator used in token requests.
|
||||
public let resource: URL?
|
||||
|
||||
public init(
|
||||
protectedResourceMetadataURL: URL? = nil,
|
||||
authorizationServerURL: URL? = nil,
|
||||
tokenEndpoint: URL? = nil,
|
||||
resource: URL? = nil
|
||||
) {
|
||||
self.protectedResourceMetadataURL = protectedResourceMetadataURL
|
||||
self.authorizationServerURL = authorizationServerURL
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.resource = resource
|
||||
}
|
||||
|
||||
public static let none = EndpointOverrides()
|
||||
}
|
||||
|
||||
// MARK: - Access Token Provider
|
||||
|
||||
/// Context supplied to ``AccessTokenProvider`` after SDK discovery is complete.
|
||||
public struct AccessTokenProviderContext: Sendable {
|
||||
/// HTTP status code that triggered authorization handling (typically 401 or 403).
|
||||
public let statusCode: Int
|
||||
/// Target MCP endpoint URL.
|
||||
public let endpoint: URL
|
||||
/// Canonical RFC8707 resource URI for token audience binding.
|
||||
public let resource: URL
|
||||
/// Selected authorization server issuer URL, if discovered.
|
||||
public let authorizationServer: URL?
|
||||
/// Authorization endpoint from AS metadata, if available.
|
||||
public let authorizationEndpoint: URL?
|
||||
/// Token endpoint from AS metadata (or configuration override), if available.
|
||||
public let tokenEndpoint: URL?
|
||||
/// Dynamic registration endpoint from AS metadata, if available.
|
||||
public let registrationEndpoint: URL?
|
||||
/// `scope` value from the latest challenge header, when present.
|
||||
public let challengedScope: String?
|
||||
/// `scopes_supported` from protected resource metadata, when present.
|
||||
public let scopesSupported: [String]?
|
||||
/// Scope set selected by SDK for the pending authorization attempt.
|
||||
public let requestedScopes: Set<String>?
|
||||
|
||||
public init(
|
||||
statusCode: Int,
|
||||
endpoint: URL,
|
||||
resource: URL,
|
||||
authorizationServer: URL?,
|
||||
authorizationEndpoint: URL?,
|
||||
tokenEndpoint: URL?,
|
||||
registrationEndpoint: URL?,
|
||||
challengedScope: String?,
|
||||
scopesSupported: [String]?,
|
||||
requestedScopes: Set<String>?
|
||||
) {
|
||||
self.statusCode = statusCode
|
||||
self.endpoint = endpoint
|
||||
self.resource = resource
|
||||
self.authorizationServer = authorizationServer
|
||||
self.authorizationEndpoint = authorizationEndpoint
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.registrationEndpoint = registrationEndpoint
|
||||
self.challengedScope = challengedScope
|
||||
self.scopesSupported = scopesSupported
|
||||
self.requestedScopes = requestedScopes
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional provider for externally acquired access tokens.
|
||||
public typealias AccessTokenProvider = @Sendable (
|
||||
_ context: AccessTokenProviderContext,
|
||||
_ session: URLSession
|
||||
) async throws -> String?
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The grant type used for token acquisition.
|
||||
public let grantType: GrantType
|
||||
|
||||
/// The configured token endpoint authentication method.
|
||||
public var authentication: TokenEndpointAuthentication
|
||||
|
||||
/// Controls retry behavior for authorization challenges.
|
||||
public let retryPolicy: RetryPolicy
|
||||
|
||||
/// Optional endpoint overrides for discovery.
|
||||
public let endpointOverrides: EndpointOverrides
|
||||
|
||||
/// Package-scoped compatibility option for local environments.
|
||||
package var allowLoopbackHTTPAuthorizationServerEndpoints: Bool
|
||||
|
||||
/// Redirect URI used for authorization requests.
|
||||
public let authorizationRedirectURI: URL
|
||||
|
||||
/// Additional form fields to include in token requests.
|
||||
public let additionalTokenRequestParameters: [String: String]
|
||||
|
||||
/// The `client_name` sent during dynamic client registration (RFC 7591).
|
||||
/// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
|
||||
public let clientName: String
|
||||
|
||||
/// Optional provider for externally acquired access tokens.
|
||||
public let accessTokenProvider: AccessTokenProvider?
|
||||
|
||||
/// Optional delegate for browser-based authorization code flows.
|
||||
public let authorizationDelegate: (any OAuthAuthorizationDelegate)?
|
||||
|
||||
/// How many seconds before token expiry ``OAuthAuthorizer`` proactively refreshes a token
|
||||
/// when `prepareAuthorization(for:session:)` is called.
|
||||
///
|
||||
/// Set to `0` to disable proactive refresh. Defaults to 60 seconds.
|
||||
/// Must be greater than ``OAuthTokenExpirySkew/defaultSeconds`` (30 s) to have any effect,
|
||||
/// since tokens within the default skew window are already treated as expired.
|
||||
public let proactiveRefreshWindowSeconds: TimeInterval
|
||||
|
||||
/// Creates an OAuth configuration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - grantType: The OAuth 2.1 grant type. Defaults to `.clientCredentials`.
|
||||
/// - authentication: How the client authenticates to the token endpoint. **Required.**
|
||||
/// - retryPolicy: Controls how many retries are allowed for authorization challenges.
|
||||
/// - endpointOverrides: Optional URL overrides that bypass automatic discovery.
|
||||
/// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
|
||||
/// Defaults to a random loopback URI (`http://127.0.0.1:<port>/callback`).
|
||||
/// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
|
||||
/// - additionalTokenRequestParameters: Extra form fields appended to every token request.
|
||||
/// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
|
||||
/// to supply an externally acquired token (e.g., from a system credential store).
|
||||
/// - authorizationDelegate: Optional delegate that presents the authorization URL to the
|
||||
/// user for interactive `authorization_code` flows.
|
||||
/// - proactiveRefreshWindowSeconds: Seconds before expiry at which a token is proactively
|
||||
/// refreshed. Defaults to 60. Set to 0 to disable proactive refresh.
|
||||
public init(
|
||||
grantType: GrantType = .clientCredentials,
|
||||
authentication: TokenEndpointAuthentication,
|
||||
retryPolicy: RetryPolicy = .default,
|
||||
endpointOverrides: EndpointOverrides = .none,
|
||||
authorizationRedirectURI: URL? = nil,
|
||||
clientName: String = "mcp-swift-sdk",
|
||||
additionalTokenRequestParameters: [String: String] = [:],
|
||||
accessTokenProvider: AccessTokenProvider? = nil,
|
||||
authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
|
||||
proactiveRefreshWindowSeconds: TimeInterval = 60
|
||||
) {
|
||||
self.grantType = grantType
|
||||
self.authentication = authentication
|
||||
self.retryPolicy = retryPolicy
|
||||
self.endpointOverrides = endpointOverrides
|
||||
self.allowLoopbackHTTPAuthorizationServerEndpoints = false
|
||||
self.authorizationRedirectURI =
|
||||
authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
|
||||
self.clientName = clientName
|
||||
self.additionalTokenRequestParameters = additionalTokenRequestParameters
|
||||
self.accessTokenProvider = accessTokenProvider
|
||||
self.authorizationDelegate = authorizationDelegate
|
||||
self.proactiveRefreshWindowSeconds = proactiveRefreshWindowSeconds
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthConfiguration.TokenEndpointAuthentication {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.clientSecretBasic(let li, let ls), .clientSecretBasic(let ri, let rs)):
|
||||
return li == ri && ls == rs
|
||||
case (.clientSecretPost(let li, let ls), .clientSecretPost(let ri, let rs)):
|
||||
return li == ri && ls == rs
|
||||
case (.none(let li), .none(let ri)):
|
||||
return li == ri
|
||||
case (.privateKeyJWT(let li, _), .privateKeyJWT(let ri, _)):
|
||||
return li == ri
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate that handles user-facing authorization steps for the authorization_code flow.
|
||||
public protocol OAuthAuthorizationDelegate: Sendable {
|
||||
/// Presents the authorization URL to the user and returns the redirect URL containing
|
||||
/// the authorization code.
|
||||
func presentAuthorizationURL(_ url: URL) async throws -> URL
|
||||
}
|
||||
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - OAuth Grant Types
|
||||
|
||||
enum OAuthGrantTypeValue {
|
||||
static let clientCredentials = "client_credentials"
|
||||
static let authorizationCode = "authorization_code"
|
||||
static let refreshToken = "refresh_token"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Parameter Names
|
||||
|
||||
enum OAuthParameterName {
|
||||
static let grantType = "grant_type"
|
||||
static let resource = "resource"
|
||||
static let scope = "scope"
|
||||
static let code = "code"
|
||||
static let codeVerifier = "code_verifier"
|
||||
static let codeChallenge = "code_challenge"
|
||||
static let codeChallengeMethod = "code_challenge_method"
|
||||
static let redirectURI = "redirect_uri"
|
||||
static let responseType = "response_type"
|
||||
static let clientID = "client_id"
|
||||
static let clientSecret = "client_secret"
|
||||
static let clientAssertion = "client_assertion"
|
||||
static let clientAssertionType = "client_assertion_type"
|
||||
static let refreshToken = "refresh_token"
|
||||
static let state = "state"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Well-Known Paths
|
||||
|
||||
enum OAuthWellKnownPath {
|
||||
static let protectedResource = "/.well-known/oauth-protected-resource"
|
||||
static let authorizationServer = "/.well-known/oauth-authorization-server"
|
||||
static let openIDConfiguration = "/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Token Type
|
||||
|
||||
enum OAuthTokenType {
|
||||
static let bearer = "Bearer"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Code Challenge Method
|
||||
|
||||
enum OAuthCodeChallengeMethod {
|
||||
static let s256 = "S256"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Token Endpoint Auth Method
|
||||
|
||||
enum OAuthTokenEndpointAuthMethod {
|
||||
static let clientSecretBasic = "client_secret_basic"
|
||||
static let clientSecretPost = "client_secret_post"
|
||||
static let none = "none"
|
||||
static let privateKeyJWT = "private_key_jwt"
|
||||
}
|
||||
|
||||
// MARK: - URL Scheme
|
||||
|
||||
enum OAuthURLScheme {
|
||||
static let http = "http"
|
||||
static let https = "https"
|
||||
}
|
||||
|
||||
// MARK: - Default Ports
|
||||
|
||||
enum OAuthDefaultPort {
|
||||
static let http = 80
|
||||
static let https = 443
|
||||
}
|
||||
|
||||
// MARK: - Loopback Hosts
|
||||
|
||||
enum OAuthLoopbackHost {
|
||||
static let localhost = "localhost"
|
||||
static let ipv4 = "127.0.0.1"
|
||||
static let ipv6 = "::1"
|
||||
|
||||
static func isLoopback(_ host: String) -> Bool {
|
||||
host == localhost || host == ipv4 || host == ipv6
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token Expiry Skew
|
||||
|
||||
/// Clock skew tolerance applied when checking token expiry.
|
||||
///
|
||||
/// ``OAuthAccessToken/isExpired(now:skewSeconds:)`` treats a token as expired
|
||||
/// when `now + skewSeconds >= expiresAt`, giving the client a safety margin to
|
||||
/// refresh tokens before they actually expire on the server.
|
||||
public enum OAuthTokenExpirySkew {
|
||||
/// Default clock skew buffer: 30 seconds.
|
||||
public static let defaultSeconds: TimeInterval = 30
|
||||
}
|
||||
|
||||
// MARK: - JWT Claim Names
|
||||
|
||||
enum JWTClaimName {
|
||||
static let algorithm = "alg"
|
||||
static let type = "typ"
|
||||
static let typeValue = "JWT"
|
||||
static let issuer = "iss"
|
||||
static let subject = "sub"
|
||||
static let audience = "aud"
|
||||
static let issuedAt = "iat"
|
||||
static let expiration = "exp"
|
||||
static let jwtID = "jti"
|
||||
}
|
||||
|
||||
// MARK: - Client Assertion Type
|
||||
|
||||
enum OAuthClientAssertionType {
|
||||
static let jwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
}
|
||||
|
||||
// MARK: - HTTPHeaderName Extensions
|
||||
|
||||
extension HTTPHeaderName {
|
||||
static let location = "Location"
|
||||
}
|
||||
|
||||
// MARK: - ContentType Extensions
|
||||
|
||||
extension ContentType {
|
||||
static let formURLEncoded = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
// MARK: - Data Base64URL Extension
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - Scope Selection Protocol
|
||||
|
||||
/// Determines which OAuth scopes to request during token acquisition.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to translate raw scope strings from
|
||||
/// `WWW-Authenticate` challenges and Protected Resource Metadata into the
|
||||
/// scope set passed to the token endpoint.
|
||||
///
|
||||
/// Override the default ``DefaultOAuthScopeSelector`` to apply custom scope
|
||||
/// filtering or transformation logic.
|
||||
public protocol OAuthScopeSelecting {
|
||||
/// Selects the scope set to request for a token.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. `challengeScope` — the `scope` parameter from the `WWW-Authenticate` header.
|
||||
/// 2. `scopesSupported` — the `scopes_supported` array from Protected Resource Metadata.
|
||||
/// 3. `nil` — no scope restriction.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - challengeScope: Space-separated scope string from the Bearer challenge, or `nil`.
|
||||
/// - scopesSupported: Array of supported scopes from the resource metadata, or `nil`.
|
||||
/// - Returns: The set of scopes to request, or `nil` to omit the `scope` parameter entirely.
|
||||
func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>?
|
||||
|
||||
/// Parses a space-separated OAuth scope string into individual scope tokens.
|
||||
/// - Parameter scope: A scope string such as `"read write"`.
|
||||
/// - Returns: A set of individual scope strings with whitespace-only tokens removed.
|
||||
func parseScopeString(_ scope: String) -> Set<String>
|
||||
|
||||
/// Serializes a set of scopes into a space-separated string suitable for the `scope` parameter.
|
||||
/// - Parameter scopes: The scope set to serialize.
|
||||
/// - Returns: A sorted, space-separated string, or `nil` if the set is empty.
|
||||
func serialize(_ scopes: Set<String>) -> String?
|
||||
}
|
||||
|
||||
// MARK: - Default Scope Selector
|
||||
|
||||
/// Default ``OAuthScopeSelecting`` implementation.
|
||||
///
|
||||
/// Selects scopes in priority order: challenge scope > `scopes_supported` > `nil`.
|
||||
/// Serializes scopes sorted alphabetically to produce deterministic `scope` parameters.
|
||||
public struct DefaultOAuthScopeSelector: OAuthScopeSelecting {
|
||||
public init() {}
|
||||
|
||||
public func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>? {
|
||||
if let challengeScope {
|
||||
let parsed = parseScopeString(challengeScope)
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
if let scopesSupported {
|
||||
let parsed = Set(scopesSupported.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func parseScopeString(_ scope: String) -> Set<String> {
|
||||
Set(
|
||||
scope
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
)
|
||||
}
|
||||
|
||||
public func serialize(_ scopes: Set<String>) -> String? {
|
||||
guard !scopes.isEmpty else { return nil }
|
||||
return scopes.sorted().joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Discovery Protocol
|
||||
|
||||
/// Builds discovery URLs and normalizes resource identifiers for OAuth metadata discovery.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to construct the candidate URL list for both
|
||||
/// Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414 / OIDC)
|
||||
/// discovery, and to normalize resource URIs for RFC 8707 audience binding.
|
||||
///
|
||||
/// Override the default ``DefaultOAuthMetadataDiscovery`` to customise discovery URL
|
||||
/// construction or resource-matching logic.
|
||||
public protocol OAuthMetadataDiscovering: Sendable {
|
||||
/// Returns candidate URLs for Protected Resource Metadata, ordered by priority.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` tries each URL in order and uses the first successful response.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: An ordered list of discovery URLs (typically `/.well-known/oauth-protected-resource`
|
||||
/// variants).
|
||||
func protectedResourceMetadataURLs(for endpoint: URL) -> [URL]
|
||||
|
||||
/// Returns candidate URLs for Authorization Server Metadata, ordered by priority.
|
||||
///
|
||||
/// Covers RFC 8414 (`/.well-known/oauth-authorization-server`) and
|
||||
/// OIDC Discovery 1.0 (`/.well-known/openid-configuration`), including
|
||||
/// path-inserted variants for issuers with non-root paths.
|
||||
///
|
||||
/// - Parameter issuer: The authorization server issuer URL.
|
||||
/// - Returns: An ordered list of metadata discovery URLs.
|
||||
func authorizationServerMetadataURLs(for issuer: URL) -> [URL]
|
||||
|
||||
/// Derives the canonical RFC 8707 resource URI from an endpoint URL.
|
||||
///
|
||||
/// The canonical form strips the query string, fragment, and trailing slash
|
||||
/// while preserving the scheme, host, port, and path.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: The canonical resource URI.
|
||||
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL does not
|
||||
/// satisfy the HTTPS-or-loopback-HTTP requirement.
|
||||
func canonicalResourceURI(from endpoint: URL) throws -> URL
|
||||
|
||||
/// Derives a fallback authorization server issuer URL from an endpoint URL.
|
||||
///
|
||||
/// Used when no authorization server is listed in Protected Resource Metadata.
|
||||
/// Typically returns the scheme+host+port of the endpoint with an empty path.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: A candidate issuer URL.
|
||||
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL is invalid.
|
||||
func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL
|
||||
|
||||
/// Returns `true` if `resource` is a prefix of `endpoint` in the URL hierarchy.
|
||||
///
|
||||
/// A resource matches an endpoint when the two share the same scheme, host, and port,
|
||||
/// and the endpoint path starts with the resource path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - resource: The canonical resource URI from Protected Resource Metadata.
|
||||
/// - endpoint: The canonical endpoint URI being requested.
|
||||
/// - Returns: `true` if the endpoint falls within the resource's scope.
|
||||
func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool
|
||||
}
|
||||
|
||||
// MARK: - Default Metadata Discovery
|
||||
|
||||
/// Default ``OAuthMetadataDiscovering`` implementation following RFC 9728 and RFC 8414.
|
||||
///
|
||||
/// - Builds `/.well-known/oauth-protected-resource` URLs with and without the endpoint path suffix.
|
||||
/// - Builds RFC 8414 and OIDC discovery URLs with path-inserted and path-appended variants.
|
||||
/// - Canonicalises resource URIs by stripping query, fragment, and trailing slash.
|
||||
/// - Matches resources using scheme/host/port equality and path prefix rules.
|
||||
public struct DefaultOAuthMetadataDiscovery: OAuthMetadataDiscovering {
|
||||
public init() {}
|
||||
|
||||
public func protectedResourceMetadataURLs(for endpoint: URL) -> [URL] {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
let endpointPath = components.path
|
||||
let normalizedPath = endpointPath == "/" ? "" : endpointPath
|
||||
|
||||
var urls: [URL] = []
|
||||
|
||||
var pathSpecific = components
|
||||
pathSpecific.path = "\(OAuthWellKnownPath.protectedResource)\(normalizedPath)"
|
||||
if let url = pathSpecific.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var root = components
|
||||
root.path = OAuthWellKnownPath.protectedResource
|
||||
if let url = root.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
public func authorizationServerMetadataURLs(for issuer: URL) -> [URL] {
|
||||
guard var components = URLComponents(url: issuer, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let hasPath = !path.isEmpty
|
||||
|
||||
var urls: [URL] = []
|
||||
|
||||
if hasPath {
|
||||
var oauthInserted = components
|
||||
oauthInserted.path = "\(OAuthWellKnownPath.authorizationServer)/\(path)"
|
||||
if let url = oauthInserted.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidcInserted = components
|
||||
oidcInserted.path = "\(OAuthWellKnownPath.openIDConfiguration)/\(path)"
|
||||
if let url = oidcInserted.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidcAppended = components
|
||||
oidcAppended.path = "/\(path)\(OAuthWellKnownPath.openIDConfiguration)"
|
||||
if let url = oidcAppended.url {
|
||||
urls.append(url)
|
||||
}
|
||||
} else {
|
||||
var oauth = components
|
||||
oauth.path = OAuthWellKnownPath.authorizationServer
|
||||
if let url = oauth.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidc = components
|
||||
oidc.path = OAuthWellKnownPath.openIDConfiguration
|
||||
if let url = oidc.url {
|
||||
urls.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
public func canonicalResourceURI(from endpoint: URL) throws -> URL {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Resource URI must use https or loopback http"
|
||||
)
|
||||
}
|
||||
|
||||
if components.fragment != nil {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Resource URI must not contain a fragment")
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
if components.path == "/" {
|
||||
components.path = ""
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Failed to normalize resource URI")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Resource URI must use https or loopback http"
|
||||
)
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.path = ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Failed to derive issuer URI")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
public func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool {
|
||||
guard let resourceComponents = URLComponents(
|
||||
url: resource,
|
||||
resolvingAgainstBaseURL: false
|
||||
),
|
||||
let endpointComponents = URLComponents(
|
||||
url: endpoint,
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let resourceScheme = resourceComponents.scheme?.lowercased()
|
||||
let endpointScheme = endpointComponents.scheme?.lowercased()
|
||||
let resourceHost = resourceComponents.host?.lowercased()
|
||||
let endpointHost = endpointComponents.host?.lowercased()
|
||||
let resourcePort = resourceComponents.port ?? Self.defaultPort(for: resourceScheme)
|
||||
let endpointPort = endpointComponents.port ?? Self.defaultPort(for: endpointScheme)
|
||||
|
||||
guard resourceScheme == endpointScheme,
|
||||
resourceHost == endpointHost,
|
||||
resourcePort == endpointPort
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let resourcePath = Self.normalizedResourcePath(resourceComponents.path)
|
||||
let endpointPath = Self.normalizedResourcePath(endpointComponents.path)
|
||||
if resourcePath.isEmpty {
|
||||
return true
|
||||
}
|
||||
if endpointPath == resourcePath {
|
||||
return true
|
||||
}
|
||||
return endpointPath.hasPrefix(resourcePath + "/")
|
||||
}
|
||||
|
||||
private static func normalizedResourcePath(_ rawPath: String) -> String {
|
||||
if rawPath.isEmpty || rawPath == "/" {
|
||||
return ""
|
||||
}
|
||||
if rawPath.count > 1 && rawPath.hasSuffix("/") {
|
||||
return String(rawPath.dropLast())
|
||||
}
|
||||
return rawPath
|
||||
}
|
||||
|
||||
private static func defaultPort(for scheme: String?) -> Int? {
|
||||
switch scheme?.lowercased() {
|
||||
case OAuthURLScheme.http:
|
||||
return OAuthDefaultPort.http
|
||||
case OAuthURLScheme.https:
|
||||
return OAuthDefaultPort.https
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func isSecureOAuthScheme(scheme: String, host: String) -> Bool {
|
||||
if scheme == OAuthURLScheme.https {
|
||||
return true
|
||||
}
|
||||
if scheme == OAuthURLScheme.http {
|
||||
return OAuthLoopbackHost.isLoopback(host)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
118
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
118
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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], 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.
|
||||
func fetchProtectedResourceMetadata(
|
||||
candidates: [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
|
||||
}
|
||||
}
|
||||
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
||||
}
|
||||
|
||||
/// Fetches Authorization Server Metadata from the first candidate that returns a valid response.
|
||||
func fetchAuthorizationServerMetadata(
|
||||
candidates: [URL],
|
||||
session: URLSession
|
||||
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
||||
let decoder = JSONDecoder()
|
||||
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)
|
||||
|
||||
// RFC 8414 §3: issuer field must match the candidate server URL.
|
||||
// Absent issuer is tolerated (some servers omit it).
|
||||
if let metadataIssuer = asMetadata.issuer {
|
||||
guard metadataIssuer.absoluteString.lowercased()
|
||||
== candidateServer.absoluteString.lowercased()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return (server: candidateServer, metadata: asMetadata)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthDiscoveryClient: OAuthDiscoveryFetching {}
|
||||
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import Foundation
|
||||
|
||||
/// Errors thrown during OAuth 2.1 authorization by ``OAuthAuthorizer``.
|
||||
///
|
||||
/// These errors surface when the authorizer is unable to complete the authorization flow,
|
||||
/// either due to discovery failures, token exchange problems, security policy violations,
|
||||
/// or authorization code flow issues.
|
||||
public enum OAuthAuthorizationError: LocalizedError {
|
||||
/// No authorization server URL could be found in the Protected Resource Metadata.
|
||||
case missingAuthorizationServer
|
||||
|
||||
/// All Protected Resource Metadata discovery candidates returned errors or invalid documents.
|
||||
case metadataDiscoveryFailed
|
||||
|
||||
/// All Authorization Server Metadata discovery candidates (RFC 8414 / OIDC) returned errors.
|
||||
case authorizationServerMetadataDiscoveryFailed
|
||||
|
||||
/// The authorization server metadata does not include a `token_endpoint`.
|
||||
case tokenEndpointMissing
|
||||
|
||||
/// The token endpoint returned a non-2xx HTTP response.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - statusCode: HTTP status code from the token endpoint.
|
||||
/// - oauthError: The `error` field from the OAuth error response body, if present.
|
||||
case tokenRequestFailed(statusCode: Int, oauthError: String?)
|
||||
|
||||
/// The token response body is missing `access_token`, has an empty token, or specifies
|
||||
/// a non-Bearer token type.
|
||||
case tokenResponseInvalid
|
||||
|
||||
/// A URL that must be a valid resource identifier (RFC 8707) failed validation.
|
||||
case invalidResourceURI(String)
|
||||
|
||||
/// The configured client ID looks like a URL but is not a valid HTTPS URL with a path,
|
||||
/// which is required for Client ID Metadata Documents.
|
||||
case invalidClientIDMetadataURL(String)
|
||||
|
||||
/// The `resource` field in the Protected Resource Metadata does not match the requested endpoint.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The canonical URI derived from the endpoint.
|
||||
/// - actual: The canonical URI derived from the metadata's `resource` field.
|
||||
case protectedResourceMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization server does not support dynamic registration and no pre-registered
|
||||
/// credentials were supplied.
|
||||
case registrationInformationRequired
|
||||
|
||||
/// The authorization server does not support Client ID Metadata Documents and no dynamic
|
||||
/// registration endpoint is available.
|
||||
///
|
||||
/// - Parameter clientID: The client ID URL that was provided as a CIMD URL.
|
||||
case cimdNotSupported(clientID: String)
|
||||
|
||||
/// A URL received from a discovery response resolves to a private or reserved IP address,
|
||||
/// which is blocked to prevent SSRF attacks.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which URL was blocked.
|
||||
/// - url: The blocked URL string.
|
||||
case privateIPAddressBlocked(context: String, url: String)
|
||||
|
||||
/// An endpoint URL used during the OAuth flow does not satisfy the HTTPS-or-loopback requirement.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which endpoint failed (e.g., `"Token endpoint"`).
|
||||
/// - url: The offending URL string.
|
||||
case insecureOAuthEndpoint(context: String, url: String)
|
||||
|
||||
/// An authorization server endpoint does not satisfy the HTTPS-only requirement.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which endpoint failed.
|
||||
/// - url: The offending URL string.
|
||||
case insecureAuthorizationServerEndpoint(context: String, url: String)
|
||||
|
||||
/// The redirect URI supplied for the `authorization_code` flow is not a valid HTTPS or
|
||||
/// loopback HTTP URI, or it contains a fragment.
|
||||
case invalidRedirectURI(String)
|
||||
|
||||
/// The authorization endpoint returned an HTTP error response during the authorization code flow.
|
||||
///
|
||||
/// - Parameter statusCode: HTTP status code from the authorization endpoint.
|
||||
case authorizationRequestFailed(statusCode: Int)
|
||||
|
||||
/// The authorization response did not include a `Location` redirect header.
|
||||
case authorizationResponseMissingRedirectLocation
|
||||
|
||||
/// The redirect URI in the authorization response does not match the expected redirect URI.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The redirect URI supplied in the authorization request.
|
||||
/// - actual: The redirect URI received in the authorization response.
|
||||
case authorizationResponseRedirectMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization response redirect URL is missing the `state` parameter.
|
||||
case authorizationResponseMissingState
|
||||
|
||||
/// The `state` in the authorization response does not match the one sent in the request.
|
||||
///
|
||||
/// This may indicate a CSRF attack.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The `state` value sent in the authorization request.
|
||||
/// - actual: The `state` value received in the authorization response.
|
||||
case authorizationResponseStateMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization response redirect URL is missing the `code` parameter.
|
||||
case authorizationResponseMissingCode
|
||||
|
||||
/// The authorization server metadata does not include `code_challenge_methods_supported`,
|
||||
/// which is required for PKCE (RFC 7636).
|
||||
case pkceCodeChallengeMethodsMissing
|
||||
|
||||
/// The authorization server does not advertise `S256` in `code_challenge_methods_supported`.
|
||||
///
|
||||
/// The MCP specification mandates S256; plain PKCE is not accepted.
|
||||
///
|
||||
/// - Parameter advertisedMethods: The methods listed in the server metadata.
|
||||
case pkceS256NotSupported(advertisedMethods: [String])
|
||||
|
||||
/// PKCE S256 challenge generation is unavailable because CryptoKit is not present on this platform.
|
||||
case pkceS256Unavailable
|
||||
|
||||
/// The `issuer` field in the Authorization Server Metadata does not match the URL used to
|
||||
/// resolve the metadata document, as required by RFC 8414 §3.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The issuer URL derived from the discovery candidate.
|
||||
/// - actual: The `issuer` field value found in the metadata document.
|
||||
case authorizationServerIssuerMismatch(expected: String, actual: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingAuthorizationServer:
|
||||
return "No authorization server was found in protected resource metadata"
|
||||
case .metadataDiscoveryFailed:
|
||||
return "Failed to discover protected resource metadata"
|
||||
case .authorizationServerMetadataDiscoveryFailed:
|
||||
return "Failed to discover authorization server metadata"
|
||||
case .tokenEndpointMissing:
|
||||
return "Authorization server metadata is missing token_endpoint"
|
||||
case .tokenRequestFailed(let statusCode, let oauthError):
|
||||
if let oauthError, !oauthError.isEmpty {
|
||||
return "Token request failed with status \(statusCode) (oauth_error: \(oauthError))"
|
||||
}
|
||||
return "Token request failed with status \(statusCode)"
|
||||
case .tokenResponseInvalid:
|
||||
return "Token response is invalid"
|
||||
case .invalidResourceURI(let detail):
|
||||
return "Invalid resource URI: \(detail)"
|
||||
case .invalidClientIDMetadataURL(let value):
|
||||
return
|
||||
"Client ID metadata document URL must use https and include a path: \(value)"
|
||||
case .protectedResourceMismatch(let expected, let actual):
|
||||
return
|
||||
"Protected resource metadata resource mismatch. Expected \(expected), got \(actual)"
|
||||
case .registrationInformationRequired:
|
||||
return
|
||||
"No supported client registration mechanism was available; provide pre-registered client credentials"
|
||||
case .cimdNotSupported(let clientID):
|
||||
return
|
||||
"Authorization server does not support Client ID Metadata Documents; configure pre-registered credentials or ensure the server advertises client_id_metadata_document_supported: \(clientID)"
|
||||
case .privateIPAddressBlocked(let context, let url):
|
||||
return
|
||||
"\(context) resolves to a private or reserved IP address which is blocked for SSRF protection: \(url)"
|
||||
case .insecureOAuthEndpoint(let context, let url):
|
||||
return "\(context) must use https or loopback http: \(url)"
|
||||
case .insecureAuthorizationServerEndpoint(let context, let url):
|
||||
return "\(context) must use https: \(url)"
|
||||
case .invalidRedirectURI(let url):
|
||||
return
|
||||
"Redirect URI must use https or loopback http and must not include fragments: \(url)"
|
||||
case .authorizationRequestFailed(let statusCode):
|
||||
return "Authorization request failed with status \(statusCode)"
|
||||
case .authorizationResponseMissingRedirectLocation:
|
||||
return "Authorization response is missing redirect location"
|
||||
case .authorizationResponseRedirectMismatch(let expected, let actual):
|
||||
return
|
||||
"Authorization response redirect URI mismatch. Expected \(expected), got \(actual)"
|
||||
case .authorizationResponseMissingState:
|
||||
return "Authorization response is missing state"
|
||||
case .authorizationResponseStateMismatch(let expected, let actual):
|
||||
return "Authorization response state mismatch. Expected \(expected), got \(actual)"
|
||||
case .authorizationResponseMissingCode:
|
||||
return "Authorization response is missing the authorization code"
|
||||
case .pkceCodeChallengeMethodsMissing:
|
||||
return
|
||||
"Authorization server metadata must include code_challenge_methods_supported for PKCE"
|
||||
case .pkceS256NotSupported(let advertisedMethods):
|
||||
let methods = advertisedMethods.joined(separator: ", ")
|
||||
return
|
||||
"Authorization server metadata must support PKCE S256 (advertised: \(methods))"
|
||||
case .pkceS256Unavailable:
|
||||
return
|
||||
"PKCE S256 code challenge generation is unavailable on this platform"
|
||||
case .authorizationServerIssuerMismatch(let expected, let actual):
|
||||
return
|
||||
"Authorization server issuer mismatch. Expected \(expected), got \(actual)"
|
||||
}
|
||||
}
|
||||
}
|
||||
247
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
247
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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 {
|
||||
/// 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?
|
||||
|
||||
/// Creates a new access token record.
|
||||
public init(
|
||||
value: String,
|
||||
tokenType: String,
|
||||
expiresAt: Date?,
|
||||
scopes: Set<String>,
|
||||
authorizationServer: URL?,
|
||||
refreshToken: String?
|
||||
) {
|
||||
self.value = value
|
||||
self.tokenType = tokenType
|
||||
self.expiresAt = expiresAt
|
||||
self.scopes = scopes
|
||||
self.authorizationServer = authorizationServer
|
||||
self.refreshToken = refreshToken
|
||||
}
|
||||
|
||||
/// Returns `true` if the token has expired or will expire within the skew window.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - now: The reference time to compare against. Defaults to `Date()`.
|
||||
/// - skewSeconds: Clock skew buffer in seconds. Defaults to ``OAuthTokenExpirySkew/defaultSeconds`` (30 s).
|
||||
/// Tokens are considered expired when `now + skewSeconds >= expiresAt`.
|
||||
/// - Returns: `false` if `expiresAt` is `nil` (no expiry).
|
||||
public func isExpired(now: Date = Date(), skewSeconds: TimeInterval = OAuthTokenExpirySkew.defaultSeconds) -> Bool {
|
||||
guard let expiresAt else { return false }
|
||||
return now.addingTimeInterval(skewSeconds) >= expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Token introspection result passed from the caller's token validator to ``BearerTokenValidator``.
|
||||
///
|
||||
/// The validator uses this to enforce expiry and audience checks before allowing a request through.
|
||||
/// Produce an instance in your ``BearerTokenValidator/TokenValidator`` closure after verifying
|
||||
/// the token's signature and extracting its claims.
|
||||
public struct BearerTokenInfo: Sendable, Equatable {
|
||||
/// Audience values from the token (`aud` JWT claim or introspection response).
|
||||
///
|
||||
/// `nil` indicates an opaque token whose audience cannot be inspected;
|
||||
/// ``BearerTokenValidator`` skips the audience check in that case.
|
||||
public let audience: [String]?
|
||||
|
||||
/// Scopes granted by the token.
|
||||
public let scopes: Set<String>?
|
||||
|
||||
/// UTC date after which the token is considered expired.
|
||||
///
|
||||
/// `nil` means no expiry information is available and the expiry check is skipped.
|
||||
public let expiresAt: Date?
|
||||
|
||||
public init(
|
||||
audience: [String]? = nil,
|
||||
scopes: Set<String>? = nil,
|
||||
expiresAt: Date? = nil
|
||||
) {
|
||||
self.audience = audience
|
||||
self.scopes = scopes
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
struct OAuthClientRegistrationResponse: Decodable {
|
||||
let clientID: String
|
||||
let clientSecret: String?
|
||||
let tokenEndpointAuthMethod: String?
|
||||
/// Unix timestamp after which `clientSecret` is no longer valid, per RFC 7591 §3.2.
|
||||
/// A value of `0` means the secret does not expire.
|
||||
let clientSecretExpiresAt: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case clientID = "client_id"
|
||||
case clientSecret = "client_secret"
|
||||
case tokenEndpointAuthMethod = "token_endpoint_auth_method"
|
||||
case clientSecretExpiresAt = "client_secret_expires_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side encodable RFC 9728 Protected Resource Metadata.
|
||||
///
|
||||
/// Use this type to construct the metadata document that MCP servers **MUST** serve
|
||||
/// at `/.well-known/oauth-protected-resource` per the MCP authorization specification.
|
||||
///
|
||||
/// Pair with ``ProtectedResourceMetadataValidator`` to automatically serve this
|
||||
/// document in the server's validation pipeline.
|
||||
public struct OAuthProtectedResourceServerMetadata: Codable, Sendable {
|
||||
/// The canonical resource identifier (RFC 8707).
|
||||
public let resource: String
|
||||
|
||||
/// One or more authorization server URLs that protect this resource.
|
||||
public let authorizationServers: [URL]
|
||||
|
||||
/// The scopes supported by this resource server.
|
||||
public let scopesSupported: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case resource
|
||||
case authorizationServers = "authorization_servers"
|
||||
case scopesSupported = "scopes_supported"
|
||||
}
|
||||
|
||||
public init(
|
||||
resource: String,
|
||||
authorizationServers: [URL],
|
||||
scopesSupported: [String]? = nil
|
||||
) {
|
||||
self.resource = resource
|
||||
self.authorizationServers = authorizationServers
|
||||
self.scopesSupported = scopesSupported
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for making OAuth token endpoint requests.
|
||||
protocol OAuthTokenRequesting: Sendable {
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse
|
||||
}
|
||||
|
||||
/// Stateless OAuth token endpoint HTTP client.
|
||||
///
|
||||
/// Handles the low-level HTTP mechanics of making token requests.
|
||||
struct OAuthTokenEndpointClient: Sendable {
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(urlValidator: OAuthURLValidator) {
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Makes a token request to the given endpoint.
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse {
|
||||
var urlRequest = URLRequest(url: endpoint)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
try await authentication.apply(
|
||||
to: &urlRequest,
|
||||
bodyParameters: ¶meters,
|
||||
tokenEndpoint: endpoint
|
||||
)
|
||||
urlRequest.httpBody = encodeForm(parameters)
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: httpResponse.statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||||
guard !decoded.accessToken.isEmpty else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !tokenType.isEmpty,
|
||||
tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
|
||||
else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func encodeForm(_ params: [String: String]) -> Data {
|
||||
let body = params
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
|
||||
.joined(separator: "&")
|
||||
return Data(body.utf8)
|
||||
}
|
||||
|
||||
private func percentEncode(_ string: String) -> String {
|
||||
let allowed = CharacterSet(
|
||||
charactersIn:
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthTokenEndpointClient: OAuthTokenRequesting {}
|
||||
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for URL security validation in OAuth flows.
|
||||
protocol OAuthURLValidating: Sendable {
|
||||
func validateHTTPSOrLoopback(_ url: URL, context: String) throws
|
||||
func validateAuthorizationServer(_ url: URL, context: String) throws
|
||||
func validateRedirectURI(_ url: URL) throws
|
||||
func isPrivateIPHost(_ host: String) -> Bool
|
||||
}
|
||||
|
||||
/// URL security rules for OAuth endpoints.
|
||||
///
|
||||
/// Validates that URLs used in the OAuth flow satisfy HTTPS requirements and SSRF protections.
|
||||
/// Configured once and shared across the discovery, token, and registration components.
|
||||
public struct OAuthURLValidator: Sendable {
|
||||
|
||||
/// When `true`, loopback HTTP URLs are accepted for authorization server endpoints.
|
||||
///
|
||||
/// This is a package-level compatibility option for local test environments.
|
||||
public let allowLoopbackHTTPForAuthorizationServer: Bool
|
||||
|
||||
public init(allowLoopbackHTTPForAuthorizationServer: Bool = false) {
|
||||
self.allowLoopbackHTTPForAuthorizationServer = allowLoopbackHTTPForAuthorizationServer
|
||||
}
|
||||
|
||||
/// Validates that the URL uses HTTPS or loopback HTTP, and has no fragment.
|
||||
///
|
||||
/// Used for MCP endpoints and protected resource metadata URLs.
|
||||
public func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https
|
||||
|| (scheme == OAuthURLScheme.http && OAuthLoopbackHost.isLoopback(host))
|
||||
else {
|
||||
throw OAuthAuthorizationError.insecureOAuthEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the URL is an HTTPS authorization server endpoint.
|
||||
///
|
||||
/// Loopback HTTP is permitted when `allowLoopbackHTTPForAuthorizationServer` is `true`.
|
||||
public func validateAuthorizationServer(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
if allowLoopbackHTTPForAuthorizationServer,
|
||||
scheme == OAuthURLScheme.http,
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https else {
|
||||
throw OAuthAuthorizationError.insecureAuthorizationServerEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the redirect URI for authorization_code flows.
|
||||
///
|
||||
/// Accepts HTTPS or loopback HTTP; rejects any URI containing a fragment.
|
||||
public func validateRedirectURI(_ url: URL) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
if scheme == OAuthURLScheme.https { return }
|
||||
|
||||
if scheme == OAuthURLScheme.http,
|
||||
let host = components.host?.lowercased(),
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
/// Returns `true` if `host` is a literal IPv4 or IPv6 address in a private or reserved range.
|
||||
///
|
||||
/// Blocked ranges:
|
||||
/// - IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`
|
||||
/// - IPv6: `fc00::/7` (ULA), `fe80::/10` (link-local)
|
||||
///
|
||||
/// **Limitation**: only literal IP addresses are checked. DNS rebinding is not prevented here.
|
||||
public func isPrivateIPHost(_ host: String) -> Bool {
|
||||
let octets = host.split(separator: ".").compactMap { UInt8($0) }
|
||||
if octets.count == 4 && !host.contains(":") {
|
||||
let (a, b) = (octets[0], octets[1])
|
||||
return a == 10 // 10.0.0.0/8
|
||||
|| (a == 172 && (16...31).contains(b)) // 172.16.0.0/12
|
||||
|| (a == 192 && b == 168) // 192.168.0.0/16
|
||||
|| (a == 169 && b == 254) // 169.254.0.0/16 (link-local / cloud metadata)
|
||||
|| (a == 100 && (64...127).contains(b)) // 100.64.0.0/10 (CGNAT)
|
||||
}
|
||||
let lower = host.lowercased()
|
||||
if lower.hasPrefix("fc") || lower.hasPrefix("fd") { return true } // fc00::/7 (ULA)
|
||||
if lower.hasPrefix("fe") && lower.count > 2 {
|
||||
let idx = lower.index(lower.startIndex, offsetBy: 2)
|
||||
if "89ab".contains(lower[idx]) { return true } // fe80::/10 (link-local)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthURLValidator: OAuthURLValidating {}
|
||||
189
Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
Normal file
189
Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - WWW-Authenticate Parsing Protocol
|
||||
|
||||
/// Parses the `WWW-Authenticate` response header to extract Bearer challenge parameters.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to inspect 401 and 403 responses from the server.
|
||||
/// Override the default ``DefaultOAuthWWWAuthenticateParser`` to handle custom challenge formats.
|
||||
public protocol OAuthWWWAuthenticateParsing {
|
||||
/// Parses the `WWW-Authenticate` header from a response header dictionary.
|
||||
///
|
||||
/// - Parameter headers: The full set of HTTP response headers (case-insensitive lookup is expected).
|
||||
/// - Returns: An ``OAuthBearerChallenge`` if a `Bearer` scheme is found, or `nil` otherwise.
|
||||
func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge?
|
||||
}
|
||||
|
||||
// MARK: - Default Implementation
|
||||
|
||||
/// Default ``OAuthWWWAuthenticateParsing`` implementation.
|
||||
///
|
||||
/// Parses `WWW-Authenticate` headers following RFC 6750 §3, handling:
|
||||
/// - Multiple challenge schemes in a single header value (e.g., `Bearer …, Basic …`)
|
||||
/// - Quoted-string parameter values with backslash escaping
|
||||
/// - Case-insensitive scheme and parameter key matching
|
||||
public struct DefaultOAuthWWWAuthenticateParser: OAuthWWWAuthenticateParsing {
|
||||
public init() {}
|
||||
private let tokenCharacters = CharacterSet(
|
||||
charactersIn: "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
|
||||
public func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge? {
|
||||
guard let value = headers.first(where: {
|
||||
$0.key.caseInsensitiveCompare(HTTPHeaderName.wwwAuthenticate) == .orderedSame
|
||||
})?.value else {
|
||||
return nil
|
||||
}
|
||||
return parseBearerHeader(value)
|
||||
}
|
||||
|
||||
private func parseBearerHeader(_ header: String) -> OAuthBearerChallenge? {
|
||||
let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let parametersPart = extractBearerParameters(from: trimmed) else { return nil }
|
||||
|
||||
if parametersPart.isEmpty {
|
||||
return OAuthBearerChallenge(parameters: [:])
|
||||
}
|
||||
|
||||
let components = splitParameters(parametersPart)
|
||||
var parameters: [String: String] = [:]
|
||||
|
||||
for component in components {
|
||||
let pair = component.split(separator: "=", maxSplits: 1)
|
||||
guard pair.count == 2 else { continue }
|
||||
let key = pair[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
var value = pair[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
|
||||
value.removeFirst()
|
||||
value.removeLast()
|
||||
value = value.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
}
|
||||
parameters[key] = value
|
||||
}
|
||||
|
||||
return OAuthBearerChallenge(parameters: parameters)
|
||||
}
|
||||
|
||||
private func extractBearerParameters(from header: String) -> String? {
|
||||
let segments = splitParameters(header)
|
||||
|
||||
for index in segments.indices {
|
||||
let segment = segments[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isBearerChallengeStart(segment) else { continue }
|
||||
|
||||
var parameters: [String] = []
|
||||
let initial = stripBearerScheme(from: segment)
|
||||
if !initial.isEmpty {
|
||||
parameters.append(initial)
|
||||
}
|
||||
|
||||
var nextIndex = segments.index(after: index)
|
||||
while nextIndex < segments.endIndex {
|
||||
let next = segments[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if next.isEmpty {
|
||||
nextIndex = segments.index(after: nextIndex)
|
||||
continue
|
||||
}
|
||||
if startsNewChallenge(next) {
|
||||
break
|
||||
}
|
||||
parameters.append(next)
|
||||
nextIndex = segments.index(after: nextIndex)
|
||||
}
|
||||
|
||||
return parameters.joined(separator: ",")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isBearerChallengeStart(_ segment: String) -> Bool {
|
||||
guard segment.count >= OAuthTokenType.bearer.count else { return false }
|
||||
let schemeEnd = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
|
||||
let scheme = segment[..<schemeEnd]
|
||||
guard String(scheme).caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame else {
|
||||
return false
|
||||
}
|
||||
|
||||
if schemeEnd == segment.endIndex {
|
||||
return true
|
||||
}
|
||||
return segment[schemeEnd].isWhitespace
|
||||
}
|
||||
|
||||
private func stripBearerScheme(from segment: String) -> String {
|
||||
guard segment.count > OAuthTokenType.bearer.count else { return "" }
|
||||
let index = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
|
||||
return String(segment[index...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func startsNewChallenge(_ segment: String) -> Bool {
|
||||
if isBearerChallengeStart(segment) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let equalsIndex = segment.firstIndex(of: "=") {
|
||||
let parameterName = String(segment[..<equalsIndex]).trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
if !parameterName.isEmpty
|
||||
&& !parameterName.contains(where: { $0.isWhitespace })
|
||||
&& isToken(parameterName)
|
||||
{
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
guard let whitespaceIndex = segment.firstIndex(where: \.isWhitespace) else {
|
||||
// No whitespace, no '=': bare token. Treat as new auth scheme if it's a valid token.
|
||||
return isToken(segment)
|
||||
}
|
||||
|
||||
let scheme = segment[..<whitespaceIndex]
|
||||
return isToken(String(scheme))
|
||||
}
|
||||
|
||||
private func isToken(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else { return false }
|
||||
return value.rangeOfCharacter(from: tokenCharacters.inverted) == nil
|
||||
}
|
||||
|
||||
private func splitParameters(_ value: String) -> [String] {
|
||||
var components: [String] = []
|
||||
var current = ""
|
||||
var inQuotes = false
|
||||
var escaping = false
|
||||
|
||||
for character in value {
|
||||
if escaping {
|
||||
current.append(character)
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if character == "\\" {
|
||||
current.append(character)
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if character == "\"" {
|
||||
inQuotes.toggle()
|
||||
current.append(character)
|
||||
continue
|
||||
}
|
||||
if character == "," && !inQuotes {
|
||||
components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
current.append(character)
|
||||
}
|
||||
|
||||
if !current.isEmpty {
|
||||
components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
return components.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
62
Sources/MCP/Base/Authorization/PKCE.swift
Normal file
62
Sources/MCP/Base/Authorization/PKCE.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
/// Pure PKCE (RFC 7636) helpers required by the authorization_code flow.
|
||||
public enum PKCE {
|
||||
|
||||
/// Generates a cryptographically random PKCE code verifier.
|
||||
///
|
||||
/// - Parameter length: Number of characters in the verifier. Defaults to 64.
|
||||
/// RFC 7636 requires 43–128 characters.
|
||||
/// - Returns: A URL-safe random string suitable for use as a PKCE code verifier.
|
||||
public static func makeVerifier(length: Int = 64) -> String {
|
||||
let charset = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||
// 66 characters; 256 % 66 == 52, so reject bytes > 252 to eliminate modulo bias.
|
||||
let limit = UInt8(255 - (255 % charset.count)) // 252
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
var result = ""
|
||||
result.reserveCapacity(length)
|
||||
while result.count < length {
|
||||
let byte = UInt8.random(in: 0...255, using: &rng)
|
||||
if byte <= limit {
|
||||
result.append(charset[Int(byte % UInt8(charset.count))])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Derives the PKCE S256 code challenge from a verifier.
|
||||
///
|
||||
/// - Parameter verifier: A code verifier produced by ``makeVerifier(length:)``.
|
||||
/// - Returns: The base64url-encoded SHA-256 hash of the verifier.
|
||||
/// - Throws: ``OAuthAuthorizationError/pkceS256Unavailable`` on platforms without CryptoKit.
|
||||
public static func makeChallenge(from verifier: String) throws -> String {
|
||||
#if canImport(CryptoKit)
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
return Data(hash).base64URLEncodedString()
|
||||
#else
|
||||
throw OAuthAuthorizationError.pkceS256Unavailable
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Verifies that the authorization server metadata advertises S256 PKCE support.
|
||||
///
|
||||
/// - Parameter metadata: Authorization server metadata to inspect.
|
||||
/// - Throws: ``OAuthAuthorizationError/pkceCodeChallengeMethodsMissing`` if
|
||||
/// `code_challenge_methods_supported` is absent or empty, or
|
||||
/// ``OAuthAuthorizationError/pkceS256NotSupported(advertisedMethods:)`` if S256 is not listed.
|
||||
static func checkSupport(in metadata: OAuthAuthorizationServerMetadata) throws {
|
||||
guard let methods = metadata.codeChallengeMethodsSupported, !methods.isEmpty else {
|
||||
throw OAuthAuthorizationError.pkceCodeChallengeMethodsMissing
|
||||
}
|
||||
let supportsS256 = methods.contains {
|
||||
$0.caseInsensitiveCompare(OAuthCodeChallengeMethod.s256) == .orderedSame
|
||||
}
|
||||
guard supportsS256 else {
|
||||
throw OAuthAuthorizationError.pkceS256NotSupported(advertisedMethods: methods)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Sources/MCP/Base/Authorization/TokenStorage.swift
Normal file
37
Sources/MCP/Base/Authorization/TokenStorage.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Token Storage Protocol
|
||||
|
||||
/// Abstraction for persisting OAuth access tokens.
|
||||
///
|
||||
/// Implement this protocol to provide custom token storage (e.g., Keychain-backed).
|
||||
/// The default ``InMemoryTokenStorage`` stores tokens in memory only.
|
||||
public protocol TokenStorage: AnyObject, Sendable {
|
||||
func save(_ token: OAuthAccessToken)
|
||||
func load() -> OAuthAccessToken?
|
||||
func clear()
|
||||
}
|
||||
|
||||
// MARK: - In-Memory Implementation
|
||||
|
||||
/// Default ``TokenStorage`` that stores the access token in memory only.
|
||||
///
|
||||
/// The token is lost when the process exits. For persistent storage
|
||||
/// (e.g., system Keychain), implement ``TokenStorage`` directly.
|
||||
public final class InMemoryTokenStorage: TokenStorage, @unchecked Sendable {
|
||||
private var token: OAuthAccessToken?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func save(_ token: OAuthAccessToken) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
public func load() -> OAuthAccessToken? {
|
||||
token
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
token = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,9 @@ public actor HTTPClientTransport: Transport {
|
|||
/// Closure to modify requests before they are sent
|
||||
private let requestModifier: (URLRequest) -> URLRequest
|
||||
|
||||
/// Optional OAuth 2.1 authorizer.
|
||||
private let authorizer: (any HTTPClientAuthorizer)?
|
||||
|
||||
private var isConnected = false
|
||||
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
|
||||
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
|
||||
|
|
@ -101,6 +104,7 @@ public actor HTTPClientTransport: Transport {
|
|||
/// - streaming: Whether to enable SSE streaming mode (default: true)
|
||||
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
|
||||
/// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
|
||||
/// - authorizer: Optional ``HTTPClientAuthorizer`` for automatic Bearer token acquisition and retries.
|
||||
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
|
||||
/// - logger: Optional logger instance for transport events
|
||||
public init(
|
||||
|
|
@ -109,6 +113,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: Bool = true,
|
||||
sseInitializationTimeout: TimeInterval = 10,
|
||||
protocolVersion: String = Version.latest,
|
||||
authorizer: (any HTTPClientAuthorizer)? = nil,
|
||||
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
|
|
@ -118,6 +123,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: streaming,
|
||||
sseInitializationTimeout: sseInitializationTimeout,
|
||||
protocolVersion: protocolVersion,
|
||||
authorizer: authorizer,
|
||||
requestModifier: requestModifier,
|
||||
logger: logger
|
||||
)
|
||||
|
|
@ -129,6 +135,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: Bool = false,
|
||||
sseInitializationTimeout: TimeInterval = 10,
|
||||
protocolVersion: String = Version.latest,
|
||||
authorizer: (any HTTPClientAuthorizer)? = nil,
|
||||
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
|
|
@ -138,6 +145,7 @@ public actor HTTPClientTransport: Transport {
|
|||
self.sseInitializationTimeout = sseInitializationTimeout
|
||||
self.protocolVersion = protocolVersion
|
||||
self.requestModifier = requestModifier
|
||||
self.authorizer = authorizer
|
||||
|
||||
// Create message stream
|
||||
var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation!
|
||||
|
|
@ -157,7 +165,6 @@ public actor HTTPClientTransport: Transport {
|
|||
self.initialSessionIDSignalTask = Task {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.initialSessionIDContinuation = continuation
|
||||
// This task will suspend here until continuation.resume() is called
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +173,7 @@ public actor HTTPClientTransport: Transport {
|
|||
private func triggerInitialSessionIDSignal() {
|
||||
if let continuation = self.initialSessionIDContinuation {
|
||||
continuation.resume()
|
||||
self.initialSessionIDContinuation = nil // Consume the continuation
|
||||
self.initialSessionIDContinuation = nil
|
||||
logger.debug("✓ Initial session ID signal triggered for SSE task")
|
||||
} else {
|
||||
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
|
||||
|
|
@ -174,19 +181,13 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Establishes connection with the transport
|
||||
///
|
||||
/// This prepares the transport for communication and sets up SSE streaming
|
||||
/// if streaming mode is enabled. The actual HTTP connection happens with the
|
||||
/// first message sent.
|
||||
public func connect() async throws {
|
||||
guard !isConnected else { return }
|
||||
isConnected = true
|
||||
|
||||
// Setup initial session ID signal
|
||||
setupInitialSessionIDSignal()
|
||||
|
||||
if streaming {
|
||||
// Start listening to server events
|
||||
streamingTask = Task { await startListeningForServerEvents() }
|
||||
}
|
||||
|
||||
|
|
@ -194,27 +195,18 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Disconnects from the transport
|
||||
///
|
||||
/// This terminates any active connections, cancels the streaming task,
|
||||
/// and releases any resources being used by the transport.
|
||||
public func disconnect() async {
|
||||
guard isConnected else { return }
|
||||
isConnected = false
|
||||
|
||||
// Cancel streaming task if active
|
||||
streamingTask?.cancel()
|
||||
streamingTask = nil
|
||||
|
||||
// Cancel any in-progress requests
|
||||
session.invalidateAndCancel()
|
||||
|
||||
// Clean up message stream
|
||||
messageContinuation.finish()
|
||||
|
||||
// Cancel the initial session ID signal task if active
|
||||
initialSessionIDSignalTask?.cancel()
|
||||
initialSessionIDSignalTask = nil
|
||||
// Resume the continuation if it's still pending to avoid leaks
|
||||
initialSessionIDContinuation?.resume()
|
||||
initialSessionIDContinuation = nil
|
||||
|
||||
|
|
@ -222,77 +214,108 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests.
|
||||
///
|
||||
/// Call this after lifecycle negotiation if the server responds with a different version.
|
||||
/// - Parameter version: The negotiated protocol version.
|
||||
public func updateNegotiatedProtocolVersion(_ version: String) {
|
||||
self.protocolVersion = version
|
||||
}
|
||||
|
||||
/// Sends data through an HTTP POST request
|
||||
///
|
||||
/// This sends a JSON-RPC message to the server via HTTP POST and processes
|
||||
/// the response according to the MCP Streamable HTTP specification. It handles:
|
||||
///
|
||||
/// - Adding appropriate Accept headers for both JSON and SSE
|
||||
/// - Including the MCP-Protocol-Version header as required by the specification
|
||||
/// - Including the session ID in requests if one has been established
|
||||
/// - Processing different response types (JSON vs SSE)
|
||||
/// - Handling HTTP error codes according to the specification
|
||||
///
|
||||
/// - Parameter data: The JSON-RPC message to send
|
||||
/// - Throws: MCPError for transport failures or server errors
|
||||
public func send(_ data: Data) async throws {
|
||||
guard isConnected else {
|
||||
throw MCPError.internalError("Transport not connected")
|
||||
}
|
||||
|
||||
if let authorizer {
|
||||
do {
|
||||
try authorizer.validateEndpointSecurity(for: endpoint)
|
||||
} catch {
|
||||
throw MCPError.internalError(
|
||||
"Authorization flow failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let authorizer {
|
||||
try? await authorizer.prepareAuthorization(for: endpoint, session: session)
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
let operationKey = jsonRPCOperationKey(from: data)
|
||||
|
||||
while true {
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue(
|
||||
"\(ContentType.json), \(ContentType.sse)",
|
||||
forHTTPHeaderField: HTTPHeaderName.accept
|
||||
)
|
||||
request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
request.httpBody = data
|
||||
|
||||
// Add protocol version header (required by MCP specification 2025-11-25)
|
||||
if let protocolVersion = protocolVersion {
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
|
||||
}
|
||||
|
||||
// Add session ID if available
|
||||
if let sessionID = sessionID {
|
||||
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
|
||||
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
|
||||
}
|
||||
|
||||
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
|
||||
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||
}
|
||||
|
||||
// Apply request modifier
|
||||
request = requestModifier(request)
|
||||
|
||||
do {
|
||||
#if os(Linux)
|
||||
// Linux implementation using data(for:) instead of bytes(for:)
|
||||
let (responseData, response) = try await session.data(for: request)
|
||||
try await processResponse(response: response, data: responseData)
|
||||
#else
|
||||
// macOS and other platforms with bytes(for:) support
|
||||
let (responseStream, response) = try await session.bytes(for: request)
|
||||
try await processResponse(response: response, stream: responseStream)
|
||||
#endif
|
||||
return
|
||||
} catch let authError as HTTPAuthenticationChallengeError {
|
||||
guard let authorizer else {
|
||||
throw mapAuthenticationChallengeError(authError)
|
||||
}
|
||||
|
||||
let handled: Bool
|
||||
do {
|
||||
handled = try await authorizer.handleChallenge(
|
||||
statusCode: authError.statusCode,
|
||||
headers: authError.headers,
|
||||
endpoint: endpoint,
|
||||
operationKey: operationKey,
|
||||
session: session
|
||||
)
|
||||
} catch {
|
||||
throw MCPError.internalError(
|
||||
"Authorization flow failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
|
||||
if handled, attempts < authorizer.maxAuthorizationAttempts {
|
||||
continue
|
||||
}
|
||||
|
||||
throw mapAuthenticationChallengeError(authError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
// Process response with data payload (Linux)
|
||||
private func processResponse(response: URLResponse, data: Data) async throws {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw MCPError.internalError("Invalid HTTP response")
|
||||
}
|
||||
|
||||
// Process the response based on content type and status code
|
||||
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
|
||||
let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
|
||||
|
||||
// Extract session ID if present
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
|
||||
let wasSessionIDNil = (self.sessionID == nil)
|
||||
self.sessionID = newSessionID
|
||||
if wasSessionIDNil {
|
||||
// Trigger signal on first session ID
|
||||
triggerInitialSessionIDSignal()
|
||||
}
|
||||
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
|
||||
|
|
@ -301,11 +324,10 @@ public actor HTTPClientTransport: Transport {
|
|||
try processHTTPResponse(httpResponse, contentType: contentType)
|
||||
guard case 200..<300 = httpResponse.statusCode else { return }
|
||||
|
||||
// For JSON responses, yield the data
|
||||
if contentType.contains("text/event-stream") {
|
||||
if contentType.contains(ContentType.sse) {
|
||||
logger.warning("SSE responses aren't fully supported on Linux")
|
||||
messageContinuation.yield(data)
|
||||
} else if contentType.contains("application/json") {
|
||||
} else if contentType.contains(ContentType.json) {
|
||||
logger.trace("Received JSON response", metadata: ["size": "\(data.count)"])
|
||||
messageContinuation.yield(data)
|
||||
} else {
|
||||
|
|
@ -313,7 +335,6 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
}
|
||||
#else
|
||||
// Process response with byte stream (macOS, iOS, etc.)
|
||||
private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes)
|
||||
async throws
|
||||
{
|
||||
|
|
@ -321,15 +342,12 @@ public actor HTTPClientTransport: Transport {
|
|||
throw MCPError.internalError("Invalid HTTP response")
|
||||
}
|
||||
|
||||
// Process the response based on content type and status code
|
||||
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
|
||||
let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
|
||||
|
||||
// Extract session ID if present
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
|
||||
let wasSessionIDNil = (self.sessionID == nil)
|
||||
self.sessionID = newSessionID
|
||||
if wasSessionIDNil {
|
||||
// Trigger signal on first session ID
|
||||
triggerInitialSessionIDSignal()
|
||||
}
|
||||
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
|
||||
|
|
@ -338,20 +356,15 @@ public actor HTTPClientTransport: Transport {
|
|||
try processHTTPResponse(httpResponse, contentType: contentType)
|
||||
guard case 200..<300 = httpResponse.statusCode else { return }
|
||||
|
||||
if contentType.contains("text/event-stream") {
|
||||
// For SSE, processing happens via the stream
|
||||
if contentType.contains(ContentType.sse) {
|
||||
logger.trace("Received SSE response, processing in streaming task")
|
||||
let hadData = try await self.processSSE(stream)
|
||||
|
||||
// If the POST SSE stream closed without delivering a JSON-RPC response,
|
||||
// trigger GET reconnection so the server can deliver it there.
|
||||
// This implements standard SSE reconnection behavior per the spec.
|
||||
if !hadData {
|
||||
logger.debug("POST SSE stream closed without data, triggering GET reconnection")
|
||||
self.activeGETSessionTask?.cancel()
|
||||
}
|
||||
} else if contentType.contains("application/json") {
|
||||
// For JSON responses, collect and deliver the data
|
||||
} else if contentType.contains(ContentType.json) {
|
||||
var buffer = Data()
|
||||
for try await byte in stream {
|
||||
buffer.append(byte)
|
||||
|
|
@ -364,25 +377,27 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
#endif
|
||||
|
||||
// Common HTTP response handling for all platforms
|
||||
private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws {
|
||||
// Handle status codes according to HTTP semantics
|
||||
switch response.statusCode {
|
||||
case 200..<300:
|
||||
// Success range - these are handled by the platform-specific code
|
||||
return
|
||||
|
||||
case 400:
|
||||
throw MCPError.internalError("Bad request")
|
||||
|
||||
case 401:
|
||||
throw MCPError.internalError("Authentication required")
|
||||
throw HTTPAuthenticationChallengeError(
|
||||
statusCode: response.statusCode,
|
||||
headers: responseHeaders(from: response)
|
||||
)
|
||||
|
||||
case 403:
|
||||
throw MCPError.internalError("Access forbidden")
|
||||
throw HTTPAuthenticationChallengeError(
|
||||
statusCode: response.statusCode,
|
||||
headers: responseHeaders(from: response)
|
||||
)
|
||||
|
||||
case 404:
|
||||
// If we get a 404 with a session ID, it means our session is invalid
|
||||
if sessionID != nil {
|
||||
logger.warning("Session has expired")
|
||||
sessionID = nil
|
||||
|
|
@ -391,8 +406,6 @@ public actor HTTPClientTransport: Transport {
|
|||
throw MCPError.internalError("Endpoint not found")
|
||||
|
||||
case 405:
|
||||
// If we get a 405, it means the server does not support the requested method
|
||||
// If streaming was requested, we should cancel the streaming task
|
||||
if streaming {
|
||||
self.streamingTask?.cancel()
|
||||
throw MCPError.internalError("Server does not support streaming")
|
||||
|
|
@ -406,7 +419,6 @@ public actor HTTPClientTransport: Transport {
|
|||
throw MCPError.internalError("Too many requests")
|
||||
|
||||
case 500..<600:
|
||||
// Server error range
|
||||
throw MCPError.internalError("Server error: \(response.statusCode)")
|
||||
|
||||
default:
|
||||
|
|
@ -415,43 +427,55 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
}
|
||||
|
||||
private func mapAuthenticationChallengeError(_ error: HTTPAuthenticationChallengeError) -> MCPError {
|
||||
switch error.statusCode {
|
||||
case 401:
|
||||
return MCPError.internalError("Authentication required")
|
||||
case 403:
|
||||
return MCPError.internalError("Access forbidden")
|
||||
default:
|
||||
return MCPError.internalError("HTTP authorization error: \(error.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
private func responseHeaders(from response: HTTPURLResponse) -> [String: String] {
|
||||
var headers: [String: String] = [:]
|
||||
for (key, value) in response.allHeaderFields {
|
||||
guard let key = key as? String, let value = value as? String else { continue }
|
||||
headers[key] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
private func jsonRPCOperationKey(from data: Data) -> String? {
|
||||
guard
|
||||
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let method = jsonObject["method"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = method.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
/// Receives data in an async sequence
|
||||
///
|
||||
/// This returns an AsyncThrowingStream that emits Data objects representing
|
||||
/// each JSON-RPC message received from the server. This includes:
|
||||
///
|
||||
/// - Direct responses to client requests
|
||||
/// - Server-initiated messages delivered via SSE streams
|
||||
///
|
||||
/// - Returns: An AsyncThrowingStream of Data objects
|
||||
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
|
||||
return messageStream
|
||||
}
|
||||
|
||||
// MARK: - SSE
|
||||
|
||||
/// Starts listening for server events using SSE
|
||||
///
|
||||
/// This establishes a long-lived HTTP connection using Server-Sent Events (SSE)
|
||||
/// to enable server-to-client push messaging. It handles:
|
||||
///
|
||||
/// - Waiting for session ID if needed
|
||||
/// - Opening the SSE connection
|
||||
/// - Automatic reconnection on connection drops
|
||||
/// - Processing received events
|
||||
private func startListeningForServerEvents() async {
|
||||
#if os(Linux)
|
||||
// SSE is not fully supported on Linux
|
||||
if streaming {
|
||||
logger.warning(
|
||||
"SSE streaming was requested but is not fully supported on Linux. SSE connection will not be attempted."
|
||||
)
|
||||
}
|
||||
#else
|
||||
// This is the original code for platforms that support SSE
|
||||
guard isConnected else { return }
|
||||
|
||||
// Wait for session ID to be available before opening SSE stream
|
||||
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
|
||||
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
|
||||
|
||||
|
|
@ -484,7 +508,6 @@ public actor HTTPClientTransport: Transport {
|
|||
logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
|
||||
}
|
||||
|
||||
// Retry loop for connection drops
|
||||
var isFirstAttempt = true
|
||||
var attemptCount = 0
|
||||
|
||||
|
|
@ -501,7 +524,6 @@ public actor HTTPClientTransport: Transport {
|
|||
])
|
||||
|
||||
do {
|
||||
// Wait for retry interval before reconnecting (except first attempt)
|
||||
if !isFirstAttempt {
|
||||
let delayMs = self.retryInterval
|
||||
logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
|
||||
|
|
@ -514,8 +536,6 @@ public actor HTTPClientTransport: Transport {
|
|||
|
||||
try await self.connectToEventStream()
|
||||
|
||||
// If connectToEventStream() returns without error,
|
||||
// it means the stream closed gracefully - reconnect with retry interval
|
||||
logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
|
||||
"attempt": "\(attemptCount)",
|
||||
"willRetryAfter": "\(self.retryInterval)ms"
|
||||
|
|
@ -523,7 +543,6 @@ public actor HTTPClientTransport: Transport {
|
|||
} catch {
|
||||
if !Task.isCancelled {
|
||||
logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
|
||||
// Error case - will also use retry interval on next iteration
|
||||
} else {
|
||||
logger.debug("⏹️ SSE task cancelled")
|
||||
}
|
||||
|
|
@ -545,50 +564,56 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
#if !os(Linux)
|
||||
/// Establishes an SSE connection to the server
|
||||
///
|
||||
/// This initiates a GET request to the server endpoint with appropriate
|
||||
/// headers to establish an SSE stream according to the MCP specification.
|
||||
/// Supports stream resumability via Last-Event-ID header.
|
||||
///
|
||||
/// - Throws: MCPError for connection failures or server errors
|
||||
private func connectToEventStream() async throws {
|
||||
guard isConnected else {
|
||||
logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
|
||||
return
|
||||
}
|
||||
|
||||
if let authorizer {
|
||||
do {
|
||||
try authorizer.validateEndpointSecurity(for: endpoint)
|
||||
} catch {
|
||||
throw MCPError.internalError(
|
||||
"Authorization flow failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let authorizer {
|
||||
try? await authorizer.prepareAuthorization(for: endpoint, session: session)
|
||||
}
|
||||
|
||||
logger.debug("🔌 Preparing SSE connection request")
|
||||
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.httpMethod = "GET"
|
||||
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.addValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||
request.addValue(ContentType.sse, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
request.addValue("no-cache", forHTTPHeaderField: HTTPHeaderName.cacheControl)
|
||||
|
||||
// Add protocol version header (required by MCP specification 2025-11-25)
|
||||
if let protocolVersion = protocolVersion {
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
|
||||
}
|
||||
|
||||
// Add session ID if available
|
||||
if let sessionID = sessionID {
|
||||
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
|
||||
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
|
||||
}
|
||||
|
||||
// Add last event ID for resumability (if available)
|
||||
if let lastEventID = lastEventID {
|
||||
request.addValue(lastEventID, forHTTPHeaderField: "Last-Event-ID")
|
||||
request.addValue(lastEventID, forHTTPHeaderField: HTTPHeaderName.lastEventID)
|
||||
logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
|
||||
} else {
|
||||
logger.info("→ Connecting to SSE stream (no last event ID to resume from)")
|
||||
}
|
||||
|
||||
// Apply request modifier
|
||||
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
|
||||
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||
}
|
||||
|
||||
request = requestModifier(request)
|
||||
|
||||
logger.debug("Starting SSE connection")
|
||||
|
||||
// Create URLSession task for SSE
|
||||
let (stream, response) = try await session.bytes(for: request)
|
||||
self.activeGETSessionTask = stream.task
|
||||
|
||||
|
|
@ -596,24 +621,17 @@ public actor HTTPClientTransport: Transport {
|
|||
throw MCPError.internalError("Invalid HTTP response")
|
||||
}
|
||||
|
||||
// Check response status
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
// If the server returns 405 Method Not Allowed,
|
||||
// it indicates that the server doesn't support SSE streaming.
|
||||
// We should cancel the task instead of retrying the connection.
|
||||
if httpResponse.statusCode == 405 {
|
||||
self.streamingTask?.cancel()
|
||||
}
|
||||
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Extract session ID if present
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
|
||||
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
|
||||
let wasSessionIDNil = (self.sessionID == nil)
|
||||
self.sessionID = newSessionID
|
||||
if wasSessionIDNil {
|
||||
// Trigger signal on first session ID, though this is unlikely to happen here
|
||||
// as GET usually follows a POST that would have already set the session ID
|
||||
triggerInitialSessionIDSignal()
|
||||
}
|
||||
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
|
||||
|
|
@ -623,14 +641,6 @@ public actor HTTPClientTransport: Transport {
|
|||
try await self.processSSE(stream)
|
||||
}
|
||||
|
||||
/// Processes an SSE byte stream, extracting events and delivering them.
|
||||
///
|
||||
/// This method processes Server-Sent Events according to the MCP specification,
|
||||
/// including support for event IDs for resumability.
|
||||
///
|
||||
/// - Parameter stream: The URLSession.AsyncBytes stream to process
|
||||
/// - Returns: `true` if any data events were received, `false` otherwise.
|
||||
/// - Throws: Error for stream processing failures
|
||||
@discardableResult
|
||||
private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
|
||||
logger.debug("📥 Starting SSE event processing")
|
||||
|
|
@ -640,7 +650,6 @@ public actor HTTPClientTransport: Transport {
|
|||
for try await event in stream.events {
|
||||
eventCount += 1
|
||||
|
||||
// Check if task has been cancelled
|
||||
if Task.isCancelled {
|
||||
logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
|
||||
break
|
||||
|
|
@ -654,19 +663,16 @@ public actor HTTPClientTransport: Transport {
|
|||
]
|
||||
)
|
||||
|
||||
// Store event ID for resumability support
|
||||
if let eventID = event.id, !eventID.isEmpty {
|
||||
self.lastEventID = eventID
|
||||
logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
|
||||
}
|
||||
|
||||
// Store retry interval if provided by server
|
||||
if let retry = event.retry {
|
||||
self.retryInterval = retry
|
||||
logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(retry)"])
|
||||
}
|
||||
|
||||
// Convert the event data to Data and yield it to the message stream
|
||||
if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
|
||||
hadDataEvent = true
|
||||
messageContinuation.yield(data)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue