* 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:
Maksym Mova 2026-03-24 11:05:22 +02:00 • committed by GitHub
commit 6132fd4b5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 10925 additions and 199 deletions

View file

@ -12,7 +12,7 @@ permissions:
jobs: jobs:
test: test:
timeout-minutes: 5 timeout-minutes: 10
strategy: strategy:
matrix: matrix:
os: [macos-latest, ubuntu-latest] os: [macos-latest, ubuntu-latest]
@ -61,23 +61,19 @@ jobs:
with: with:
swift-version: 6.1.0 swift-version: 6.1.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build Swift executables - name: Build Swift executables
run: | run: |
swift build --product mcp-everything-client swift build --product mcp-everything-client
swift build --product mcp-everything-server swift build --product mcp-everything-server
- name: Run client conformance tests - name: Run client conformance tests
uses: modelcontextprotocol/conformance@v0.1.11 uses: modelcontextprotocol/conformance@v0.1.15
with: with:
mode: client mode: client
command: '.build/debug/mcp-everything-client' command: '.build/debug/mcp-everything-client'
suite: 'core' suite: 'core'
expected-failures: './conformance-baseline.yml' expected-failures: './conformance-baseline.yml'
node-version: '22'
- name: Start server for testing - name: Start server for testing
run: | run: |
@ -86,12 +82,13 @@ jobs:
sleep 3 sleep 3
- name: Run server conformance tests - name: Run server conformance tests
uses: modelcontextprotocol/conformance@v0.1.11 uses: modelcontextprotocol/conformance@v0.1.15
with: with:
mode: server mode: server
url: 'http://localhost:3001/mcp' url: 'http://localhost:3001/mcp'
suite: 'core' suite: 'core'
expected-failures: './conformance-baseline.yml' expected-failures: './conformance-baseline.yml'
node-version: '22'
- name: Cleanup server - name: Cleanup server
if: always() if: always()
@ -100,6 +97,18 @@ jobs:
kill $SERVER_PID 2>/dev/null || true kill $SERVER_PID 2>/dev/null || true
fi 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: static-linux-sdk-build:
name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }}) name: Linux Static SDK Build (${{ matrix.swift-version }} - ${{ matrix.os }})
strategy: strategy:

4
.spi.yml Normal file
View file

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

View file

@ -1,5 +1,5 @@
{ {
"originHash" : "2e844b5aa785005b516db94bbdf89be795d57d958f648d3bf655b75ad9ad9d68", "originHash" : "67fa9b6a0d1f334fd836cabb7af8c36110a6b88c5b49fa5a42a50125b18a056b",
"pins" : [ "pins" : [
{ {
"identity" : "eventsource", "identity" : "eventsource",
@ -28,6 +28,24 @@
"version" : "1.3.0" "version" : "1.3.0"
} }
}, },
{
"identity" : "swift-docc-plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-plugin",
"state" : {
"branch" : "main",
"revision" : "e977f65879f82b375a044c8837597f690c067da6"
}
},
{
"identity" : "swift-docc-symbolkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-symbolkit",
"state" : {
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
"version" : "1.0.0"
}
},
{ {
"identity" : "swift-log", "identity" : "swift-log",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View file

@ -26,6 +26,7 @@ let package = Package(
targets: ["MCPConformanceClient"]) targets: ["MCPConformanceClient"])
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/swiftlang/swift-docc-plugin", branch: "main"),
.package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
.package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"), .package(url: "https://github.com/mattt/eventsource.git", from: "1.1.0"),

198
README.md
View file

@ -43,6 +43,15 @@ of the MCP specification.
- [Initialize Hook](#initialize-hook) - [Initialize Hook](#initialize-hook)
- [Graceful Shutdown](#graceful-shutdown) - [Graceful Shutdown](#graceful-shutdown)
- [Transports](#transports) - [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) - [Platform Availability](#platform-availability)
- [Debugging and Logging](#debugging-and-logging) - [Debugging and Logging](#debugging-and-logging)
- [Additional Resources](#additional-resources) - [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 ## Platform Availability
The Swift SDK has the following platform requirements: The Swift SDK has the following platform requirements:

View file

@ -0,0 +1,253 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - OAuthAuthorizationCodeFlowing Protocol
/// Internal protocol for driving the OAuth 2.1 authorization code flow.
protocol OAuthAuthorizationCodeFlowing: Sendable {
func buildURL(
authorizationEndpoint: URL,
resource: URL,
redirectURI: URL,
clientID: String,
codeChallenge: String,
scopes: Set<String>?,
state: String,
scopeSerializer: any OAuthScopeSelecting
) throws -> URL
func perform(
authorizationURL: URL,
redirectURI: URL,
state: String,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String
}
// MARK: - No-Redirect Session Delegate
final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
@unchecked Sendable
{
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
// MARK: - Authorization Code Flow
/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
///
/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
/// and extracts the authorization code from the callback redirect URL.
public struct OAuthAuthorizationCodeFlow: Sendable {
public init() {}
/// Builds the authorization request URL.
///
/// - Parameters:
/// - authorizationEndpoint: The AS authorization endpoint.
/// - resource: The RFC 8707 resource indicator.
/// - redirectURI: The redirect URI registered for this client.
/// - clientID: The OAuth client identifier.
/// - codeChallenge: The PKCE S256 code challenge.
/// - scopes: Optional scope set to request.
/// - state: The CSRF state nonce.
/// - scopeSerializer: Serializes the scope set to a space-separated string.
/// - Returns: The full authorization request URL with all query parameters.
public func buildURL(
authorizationEndpoint: URL,
resource: URL,
redirectURI: URL,
clientID: String,
codeChallenge: String,
scopes: Set<String>?,
state: String,
scopeSerializer: any OAuthScopeSelecting
) throws -> URL {
guard var components = URLComponents(
url: authorizationEndpoint,
resolvingAgainstBaseURL: false
) else {
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
}
var queryItems: [URLQueryItem] = [
.init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
.init(name: OAuthParameterName.clientID, value: clientID),
.init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
.init(name: OAuthParameterName.state, value: state),
.init(name: OAuthParameterName.resource, value: resource.absoluteString),
.init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
.init(
name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
]
if let scope = scopes.flatMap(scopeSerializer.serialize) {
queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
}
components.queryItems = queryItems
guard let url = components.url else {
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
}
return url
}
/// Drives the interactive authorization redirect and returns the authorization code.
///
/// When a delegate is provided, presents the authorization URL and awaits the redirect.
/// Without a delegate, sends a GET request and captures the redirect `Location` header.
///
/// - Parameters:
/// - authorizationURL: The full authorization request URL.
/// - redirectURI: The expected redirect URI base for validation.
/// - state: The CSRF state nonce to verify in the redirect.
/// - delegate: Optional user-facing delegate for browser-based flows.
/// - session: The `URLSession` used for the no-redirect path.
/// - Returns: The extracted authorization code.
public func perform(
authorizationURL: URL,
redirectURI: URL,
state: String,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String {
if let delegate {
let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
)
}
var request = URLRequest(url: authorizationURL)
request.httpMethod = "GET"
request.setValue(
"text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
let noRedirectSession = URLSession(
configuration: session.configuration,
delegate: noRedirectDelegate,
delegateQueue: nil
)
defer { noRedirectSession.invalidateAndCancel() }
let (_, response) = try await noRedirectSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
}
guard (300..<400).contains(httpResponse.statusCode) else {
if httpResponse.statusCode >= 400 {
throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
}
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
}
guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
!location.isEmpty,
let redirectURL = URL(string: location)
else {
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
}
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
)
}
/// Extracts and validates the authorization code from the redirect URL.
///
/// - Parameters:
/// - redirectURL: The redirect URL received from the authorization server.
/// - expectedRedirectURI: The redirect URI used in the authorization request.
/// - expectedState: The CSRF state nonce sent in the authorization request.
/// - Returns: The authorization code.
public func extractCode(
from redirectURL: URL,
expectedRedirectURI: URL,
expectedState: String
) throws -> String {
guard
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
let expectedComponents = URLComponents(
url: expectedRedirectURI, resolvingAgainstBaseURL: false)
else {
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
expected: expectedRedirectURI.absoluteString,
actual: redirectURL.absoluteString
)
}
if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
expected: expectedRedirectURI.absoluteString,
actual: redirectURL.absoluteString
)
}
guard
let state = redirectComponents.queryItems?.first(where: {
$0.name == OAuthParameterName.state
})?.value,
!state.isEmpty
else {
throw OAuthAuthorizationError.authorizationResponseMissingState
}
guard state == expectedState else {
throw OAuthAuthorizationError.authorizationResponseStateMismatch(
expected: expectedState,
actual: state
)
}
guard
let code = redirectComponents.queryItems?.first(where: {
$0.name == OAuthParameterName.code
})?.value,
!code.isEmpty
else {
throw OAuthAuthorizationError.authorizationResponseMissingCode
}
return code
}
// MARK: - Private Helpers
private func normalizedRedirectBase(_ components: URLComponents) -> String {
let scheme = components.scheme?.lowercased() ?? ""
let host = components.host?.lowercased() ?? ""
let port: Int
if let explicitPort = components.port {
port = explicitPort
} else if scheme == OAuthURLScheme.https {
port = OAuthDefaultPort.https
} else if scheme == OAuthURLScheme.http {
port = OAuthDefaultPort.http
} else {
port = -1
}
let path = components.path.isEmpty ? "/" : components.path
return "\(scheme)://\(host):\(port)\(path)"
}
}
extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}

View file

@ -0,0 +1,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
}
}

View file

@ -0,0 +1,186 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Internal protocol for OAuth dynamic client registration.
protocol OAuthClientRegistering: Sendable {
func register(
configuration: OAuthConfiguration,
asMetadata: OAuthAuthorizationServerMetadata,
session: URLSession
) async throws -> (
response: OAuthClientRegistrationResponse,
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
)?
}
/// Stateless OAuth dynamic client registration logic.
///
/// Handles Client ID Metadata Document (CIMD) detection and RFC 7591 dynamic registration.
/// State tracking (`clientRegistrationAttempted`, `clientSecretExpiresAt`) is the caller's responsibility.
struct OAuthClientRegistrar: Sendable {
let urlValidator: OAuthURLValidator
init(urlValidator: OAuthURLValidator) {
self.urlValidator = urlValidator
}
/// Attempts to register the client, if applicable.
///
/// Returns `nil` if registration is not needed:
/// - Credentials are already configured (not `.none`)
/// - CIMD is in use and the server supports it (pre-registered)
/// - No registration endpoint is available and no CIMD mismatch error
///
/// Throws if registration was attempted but failed (4xx, 5xx, or unexpected response).
func register(
configuration: OAuthConfiguration,
asMetadata: OAuthAuthorizationServerMetadata,
session: URLSession
) async throws -> (
response: OAuthClientRegistrationResponse,
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
)? {
guard case .none(let clientID) = configuration.authentication else {
return nil
}
let hasClientIDMetadataDocument = isHTTPSURLWithPath(clientID)
let supportsClientIDMetadataDocument = asMetadata.clientIDMetadataDocumentSupported == true
if supportsClientIDMetadataDocument,
clientIDLooksLikeURL(clientID),
!hasClientIDMetadataDocument
{
throw OAuthAuthorizationError.invalidClientIDMetadataURL(clientID)
}
if hasClientIDMetadataDocument, supportsClientIDMetadataDocument {
return nil
}
guard let registrationEndpoint = asMetadata.registrationEndpoint else {
if hasClientIDMetadataDocument && !supportsClientIDMetadataDocument {
throw OAuthAuthorizationError.cimdNotSupported(clientID: clientID)
}
return nil
}
try urlValidator.validateAuthorizationServer(
registrationEndpoint, context: "Client registration endpoint")
var request = URLRequest(url: registrationEndpoint)
request.httpMethod = "POST"
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
let grantTypes: [String]
let responseTypes: [String]
switch configuration.grantType {
case .authorizationCode:
grantTypes = [OAuthGrantTypeValue.authorizationCode]
responseTypes = [OAuthParameterName.code]
case .clientCredentials:
grantTypes = [OAuthGrantTypeValue.clientCredentials]
responseTypes = []
}
var registrationPayload: [String: Any] = [
"client_name": configuration.clientName,
"grant_types": grantTypes,
"token_endpoint_auth_method": configuration.authentication.methodName,
]
if !responseTypes.isEmpty {
registrationPayload["response_types"] = responseTypes
}
if configuration.grantType == .authorizationCode {
registrationPayload["redirect_uris"] = [
configuration.authorizationRedirectURI.absoluteString
]
}
let httpBody = try JSONSerialization.data(withJSONObject: registrationPayload)
request.httpBody = httpBody
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
(400..<500).contains(httpResponse.statusCode)
{
let oauthError =
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
throw OAuthAuthorizationError.tokenRequestFailed(
statusCode: httpResponse.statusCode,
oauthError: oauthError
)
}
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode)
else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
let oauthError =
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
throw OAuthAuthorizationError.tokenRequestFailed(
statusCode: statusCode,
oauthError: oauthError
)
}
let registration = try JSONDecoder().decode(OAuthClientRegistrationResponse.self, from: data)
let updatedAuth = OAuthClientRegistrar.updatedAuthentication(
from: registration, current: configuration.authentication)
return (response: registration, updatedAuthentication: updatedAuth)
}
/// Derives the updated token endpoint authentication from a registration response.
///
/// Updates the client ID (and secret, if issued) while preserving the authentication method.
static func updatedAuthentication(
from registration: OAuthClientRegistrationResponse,
current: OAuthConfiguration.TokenEndpointAuthentication
) -> OAuthConfiguration.TokenEndpointAuthentication {
switch current {
case .none:
return .none(clientID: registration.clientID)
case .clientSecretBasic(_, let currentSecret):
return .clientSecretBasic(
clientID: registration.clientID,
clientSecret: registration.clientSecret ?? currentSecret
)
case .clientSecretPost(_, let currentSecret):
return .clientSecretPost(
clientID: registration.clientID,
clientSecret: registration.clientSecret ?? currentSecret
)
case .privateKeyJWT(_, let factory):
return .privateKeyJWT(clientID: registration.clientID, assertionFactory: factory)
}
}
// MARK: - CIMD Helpers
private func isHTTPSURLWithPath(_ value: String) -> Bool {
guard let url = URL(string: value),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
return false
}
return components.scheme?.lowercased() == OAuthURLScheme.https
&& !components.path.isEmpty
&& components.path != "/"
}
private func clientIDLooksLikeURL(_ value: String) -> Bool {
guard let url = URL(string: value),
let scheme = url.scheme,
!scheme.isEmpty
else {
return false
}
return true
}
}
extension OAuthClientRegistrar: OAuthClientRegistering {}

View file

@ -0,0 +1,415 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(CryptoKit)
import CryptoKit
#endif
/// Configuration for OAuth 2.1 authorization used by ``HTTPClientTransport``.
///
/// Authorization is optional and disabled by default. Configure this type and pass it
/// to `HTTPClientTransport(oauth:)` to enable automatic Bearer token acquisition for HTTP
/// transports.
///
/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
/// (machine-to-machine) grant types via the ``grantType`` property.
public struct OAuthConfiguration: Sendable {
/// The OAuth 2.1 grant type to use for token acquisition.
public enum GrantType: Sendable {
/// OAuth 2.1 authorization_code flow with PKCE.
case authorizationCode
/// OAuth 2.1 client_credentials flow.
case clientCredentials
}
/// How the client authenticates to the OAuth token endpoint.
public enum TokenEndpointAuthentication: Sendable, Equatable {
/// `client_secret_basic` authentication using the Authorization header.
case clientSecretBasic(clientID: String, clientSecret: String)
/// `client_secret_post` authentication using form parameters.
case clientSecretPost(clientID: String, clientSecret: String)
/// Public client authentication (`token_endpoint_auth_method=none`).
case none(clientID: String)
/// `private_key_jwt` authentication.
///
/// The built-in ``OAuthConfiguration/makePrivateKeyJWTAssertion(clientID:tokenEndpoint:privateKeyPEM:signingAlgorithm:audience:issuedAt:expiresIn:)``
/// helper generates ES256 (P-256 ECDSA) assertions only. To use other algorithms
/// (e.g., RS256, ES384), provide a custom ``OAuthConfiguration/JWTAssertionFactory`` closure.
case privateKeyJWT(clientID: String, assertionFactory: JWTAssertionFactory)
var clientID: String {
switch self {
case .clientSecretBasic(let clientID, _), .clientSecretPost(let clientID, _),
.none(let clientID), .privateKeyJWT(let clientID, _):
return clientID
}
}
/// The token endpoint auth method name per RFC 7591.
var methodName: String {
switch self {
case .clientSecretBasic: return OAuthTokenEndpointAuthMethod.clientSecretBasic
case .clientSecretPost: return OAuthTokenEndpointAuthMethod.clientSecretPost
case .none: return OAuthTokenEndpointAuthMethod.none
case .privateKeyJWT: return OAuthTokenEndpointAuthMethod.privateKeyJWT
}
}
func apply(
to request: inout URLRequest,
bodyParameters: inout [String: String],
tokenEndpoint: URL
) async throws {
switch self {
case .clientSecretBasic(let clientID, let clientSecret):
let basic = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
request.setValue("Basic \(basic)", forHTTPHeaderField: HTTPHeaderName.authorization)
case .clientSecretPost(let clientID, let clientSecret):
bodyParameters[OAuthParameterName.clientID] = clientID
bodyParameters[OAuthParameterName.clientSecret] = clientSecret
case .none(let clientID):
bodyParameters[OAuthParameterName.clientID] = clientID
case .privateKeyJWT(let clientID, let assertionFactory):
bodyParameters[OAuthParameterName.clientID] = clientID
bodyParameters[OAuthParameterName.clientAssertionType] =
OAuthClientAssertionType.jwtBearer
bodyParameters[OAuthParameterName.clientAssertion] =
try await assertionFactory(tokenEndpoint, clientID)
}
}
}
/// Closure used to generate a `private_key_jwt` assertion.
public typealias JWTAssertionFactory = @Sendable (_ tokenEndpoint: URL, _ clientID: String)
async throws -> String
/// Supported signing algorithms for SDK-generated `private_key_jwt` assertions.
public enum PrivateKeyJWTSigningAlgorithm: String, Sendable {
case ES256
}
/// Errors thrown while creating SDK-generated `private_key_jwt` assertions.
public enum PrivateKeyJWTAssertionError: LocalizedError, Sendable {
case invalidLifetime(TimeInterval)
case cryptographyUnavailable
public var errorDescription: String? {
switch self {
case .invalidLifetime(let lifetime):
return "private_key_jwt assertion lifetime must be greater than zero seconds, got \(lifetime)"
case .cryptographyUnavailable:
return "private_key_jwt assertion signing requires CryptoKit support"
}
}
}
/// Creates a signed `private_key_jwt` client assertion (RFC 7523).
///
/// Use this helper to build a `JWTAssertionFactory` closure when configuring
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``:
///
/// ```swift
/// let factory: OAuthConfiguration.JWTAssertionFactory = { tokenEndpoint, clientID in
/// try OAuthConfiguration.makePrivateKeyJWTAssertion(
/// clientID: clientID,
/// tokenEndpoint: tokenEndpoint,
/// privateKeyPEM: myPEMString
/// )
/// }
/// ```
///
/// The assertion is a compact-serialized JWS signed with the specified private key.
/// Only ES256 (P-256 ECDSA) is supported by this helper; the algorithm requires CryptoKit.
/// To use other algorithms, supply a custom ``JWTAssertionFactory`` closure directly to
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``.
///
/// - Parameters:
/// - clientID: The OAuth client identifier, used as both `iss` and `sub` claims.
/// - tokenEndpoint: Token endpoint URL, used as the default `aud` claim.
/// - privateKeyPEM: PEM-encoded EC private key for signing.
/// - signingAlgorithm: The JWS signing algorithm. Currently only `.ES256` is supported.
/// - audience: Explicit `aud` claim override. Defaults to `tokenEndpoint.absoluteString`.
/// - issuedAt: The `iat` claim. Defaults to `Date()`.
/// - expiresIn: Lifetime of the assertion in seconds. Defaults to 300 (5 minutes). Must be > 0.
/// - Returns: A compact-serialized JWT string (`header.payload.signature`).
/// - Throws: ``PrivateKeyJWTAssertionError/invalidLifetime(_:)`` if `expiresIn` ≤ 0,
/// or ``PrivateKeyJWTAssertionError/cryptographyUnavailable`` on platforms without CryptoKit.
public static func makePrivateKeyJWTAssertion(
clientID: String,
tokenEndpoint: URL,
privateKeyPEM: String,
signingAlgorithm: PrivateKeyJWTSigningAlgorithm = .ES256,
audience: String? = nil,
issuedAt: Date = Date(),
expiresIn: TimeInterval = 300
) throws -> String {
guard expiresIn > 0 else {
throw PrivateKeyJWTAssertionError.invalidLifetime(expiresIn)
}
let header = try JSONSerialization.data(withJSONObject: [
JWTClaimName.algorithm: signingAlgorithm.rawValue,
JWTClaimName.type: JWTClaimName.typeValue,
])
let issuedAtUnix = Int(issuedAt.timeIntervalSince1970)
let lifetimeSeconds = max(1, Int(expiresIn.rounded(.down)))
let payload = try JSONSerialization.data(withJSONObject: [
JWTClaimName.issuer: clientID,
JWTClaimName.subject: clientID,
JWTClaimName.audience: audience ?? tokenEndpoint.absoluteString,
JWTClaimName.issuedAt: issuedAtUnix,
JWTClaimName.expiration: issuedAtUnix + lifetimeSeconds,
JWTClaimName.jwtID: UUID().uuidString,
])
let signingInput = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())"
#if canImport(CryptoKit)
switch signingAlgorithm {
case .ES256:
let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPEM)
let signature = try privateKey.signature(for: Data(signingInput.utf8)).rawRepresentation
return "\(signingInput).\(signature.base64URLEncodedString())"
}
#else
throw PrivateKeyJWTAssertionError.cryptographyUnavailable
#endif
}
static func defaultAuthorizationRedirectURI() -> URL {
let port = Int.random(in: 49152...65535)
return URL(string: "http://\(OAuthLoopbackHost.ipv4):\(port)/callback")!
}
// MARK: - Retry Policy
/// Controls retry behavior for authorization challenges.
public struct RetryPolicy: Sendable {
/// Maximum number of authentication retries for a single MCP request.
public let maxAuthorizationAttempts: Int
/// Maximum number of scope step-up attempts for a resource and operation.
public let maxScopeUpgradeAttempts: Int
/// Creates a retry policy.
///
/// Both values are clamped to a minimum of `1` to prevent infinite loops
/// or zero-retry configurations.
///
/// - Parameters:
/// - maxAuthorizationAttempts: Maximum authorization retries per request. Defaults to 3.
/// - maxScopeUpgradeAttempts: Maximum scope step-up attempts per resource+operation. Defaults to 2.
public init(
maxAuthorizationAttempts: Int = 3,
maxScopeUpgradeAttempts: Int = 2
) {
self.maxAuthorizationAttempts = max(1, maxAuthorizationAttempts)
self.maxScopeUpgradeAttempts = max(1, maxScopeUpgradeAttempts)
}
public static let `default` = RetryPolicy()
}
// MARK: - Endpoint Overrides
/// Optional endpoint overrides for discovery.
public struct EndpointOverrides: Sendable {
/// Optional override for the protected resource metadata URL.
public let protectedResourceMetadataURL: URL?
/// Optional override for the authorization server issuer URL.
public let authorizationServerURL: URL?
/// Optional override for the token endpoint.
public let tokenEndpoint: URL?
/// Optional override for the resource indicator used in token requests.
public let resource: URL?
public init(
protectedResourceMetadataURL: URL? = nil,
authorizationServerURL: URL? = nil,
tokenEndpoint: URL? = nil,
resource: URL? = nil
) {
self.protectedResourceMetadataURL = protectedResourceMetadataURL
self.authorizationServerURL = authorizationServerURL
self.tokenEndpoint = tokenEndpoint
self.resource = resource
}
public static let none = EndpointOverrides()
}
// MARK: - Access Token Provider
/// Context supplied to ``AccessTokenProvider`` after SDK discovery is complete.
public struct AccessTokenProviderContext: Sendable {
/// HTTP status code that triggered authorization handling (typically 401 or 403).
public let statusCode: Int
/// Target MCP endpoint URL.
public let endpoint: URL
/// Canonical RFC8707 resource URI for token audience binding.
public let resource: URL
/// Selected authorization server issuer URL, if discovered.
public let authorizationServer: URL?
/// Authorization endpoint from AS metadata, if available.
public let authorizationEndpoint: URL?
/// Token endpoint from AS metadata (or configuration override), if available.
public let tokenEndpoint: URL?
/// Dynamic registration endpoint from AS metadata, if available.
public let registrationEndpoint: URL?
/// `scope` value from the latest challenge header, when present.
public let challengedScope: String?
/// `scopes_supported` from protected resource metadata, when present.
public let scopesSupported: [String]?
/// Scope set selected by SDK for the pending authorization attempt.
public let requestedScopes: Set<String>?
public init(
statusCode: Int,
endpoint: URL,
resource: URL,
authorizationServer: URL?,
authorizationEndpoint: URL?,
tokenEndpoint: URL?,
registrationEndpoint: URL?,
challengedScope: String?,
scopesSupported: [String]?,
requestedScopes: Set<String>?
) {
self.statusCode = statusCode
self.endpoint = endpoint
self.resource = resource
self.authorizationServer = authorizationServer
self.authorizationEndpoint = authorizationEndpoint
self.tokenEndpoint = tokenEndpoint
self.registrationEndpoint = registrationEndpoint
self.challengedScope = challengedScope
self.scopesSupported = scopesSupported
self.requestedScopes = requestedScopes
}
}
/// Optional provider for externally acquired access tokens.
public typealias AccessTokenProvider = @Sendable (
_ context: AccessTokenProviderContext,
_ session: URLSession
) async throws -> String?
// MARK: - Properties
/// The grant type used for token acquisition.
public let grantType: GrantType
/// The configured token endpoint authentication method.
public var authentication: TokenEndpointAuthentication
/// Controls retry behavior for authorization challenges.
public let retryPolicy: RetryPolicy
/// Optional endpoint overrides for discovery.
public let endpointOverrides: EndpointOverrides
/// Package-scoped compatibility option for local environments.
package var allowLoopbackHTTPAuthorizationServerEndpoints: Bool
/// Redirect URI used for authorization requests.
public let authorizationRedirectURI: URL
/// Additional form fields to include in token requests.
public let additionalTokenRequestParameters: [String: String]
/// The `client_name` sent during dynamic client registration (RFC 7591).
/// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
public let clientName: String
/// Optional provider for externally acquired access tokens.
public let accessTokenProvider: AccessTokenProvider?
/// Optional delegate for browser-based authorization code flows.
public let authorizationDelegate: (any OAuthAuthorizationDelegate)?
/// How many seconds before token expiry ``OAuthAuthorizer`` proactively refreshes a token
/// when `prepareAuthorization(for:session:)` is called.
///
/// Set to `0` to disable proactive refresh. Defaults to 60 seconds.
/// Must be greater than ``OAuthTokenExpirySkew/defaultSeconds`` (30 s) to have any effect,
/// since tokens within the default skew window are already treated as expired.
public let proactiveRefreshWindowSeconds: TimeInterval
/// Creates an OAuth configuration.
///
/// - Parameters:
/// - grantType: The OAuth 2.1 grant type. Defaults to `.clientCredentials`.
/// - authentication: How the client authenticates to the token endpoint. **Required.**
/// - retryPolicy: Controls how many retries are allowed for authorization challenges.
/// - endpointOverrides: Optional URL overrides that bypass automatic discovery.
/// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
/// Defaults to a random loopback URI (`http://127.0.0.1:<port>/callback`).
/// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
/// - additionalTokenRequestParameters: Extra form fields appended to every token request.
/// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
/// to supply an externally acquired token (e.g., from a system credential store).
/// - authorizationDelegate: Optional delegate that presents the authorization URL to the
/// user for interactive `authorization_code` flows.
/// - proactiveRefreshWindowSeconds: Seconds before expiry at which a token is proactively
/// refreshed. Defaults to 60. Set to 0 to disable proactive refresh.
public init(
grantType: GrantType = .clientCredentials,
authentication: TokenEndpointAuthentication,
retryPolicy: RetryPolicy = .default,
endpointOverrides: EndpointOverrides = .none,
authorizationRedirectURI: URL? = nil,
clientName: String = "mcp-swift-sdk",
additionalTokenRequestParameters: [String: String] = [:],
accessTokenProvider: AccessTokenProvider? = nil,
authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
proactiveRefreshWindowSeconds: TimeInterval = 60
) {
self.grantType = grantType
self.authentication = authentication
self.retryPolicy = retryPolicy
self.endpointOverrides = endpointOverrides
self.allowLoopbackHTTPAuthorizationServerEndpoints = false
self.authorizationRedirectURI =
authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
self.clientName = clientName
self.additionalTokenRequestParameters = additionalTokenRequestParameters
self.accessTokenProvider = accessTokenProvider
self.authorizationDelegate = authorizationDelegate
self.proactiveRefreshWindowSeconds = proactiveRefreshWindowSeconds
}
}
extension OAuthConfiguration.TokenEndpointAuthentication {
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.clientSecretBasic(let li, let ls), .clientSecretBasic(let ri, let rs)):
return li == ri && ls == rs
case (.clientSecretPost(let li, let ls), .clientSecretPost(let ri, let rs)):
return li == ri && ls == rs
case (.none(let li), .none(let ri)):
return li == ri
case (.privateKeyJWT(let li, _), .privateKeyJWT(let ri, _)):
return li == ri
default:
return false
}
}
}
/// Delegate that handles user-facing authorization steps for the authorization_code flow.
public protocol OAuthAuthorizationDelegate: Sendable {
/// Presents the authorization URL to the user and returns the redirect URL containing
/// the authorization code.
func presentAuthorizationURL(_ url: URL) async throws -> URL
}

View file

@ -0,0 +1,139 @@
import Foundation
// MARK: - OAuth Grant Types
enum OAuthGrantTypeValue {
static let clientCredentials = "client_credentials"
static let authorizationCode = "authorization_code"
static let refreshToken = "refresh_token"
}
// MARK: - OAuth Parameter Names
enum OAuthParameterName {
static let grantType = "grant_type"
static let resource = "resource"
static let scope = "scope"
static let code = "code"
static let codeVerifier = "code_verifier"
static let codeChallenge = "code_challenge"
static let codeChallengeMethod = "code_challenge_method"
static let redirectURI = "redirect_uri"
static let responseType = "response_type"
static let clientID = "client_id"
static let clientSecret = "client_secret"
static let clientAssertion = "client_assertion"
static let clientAssertionType = "client_assertion_type"
static let refreshToken = "refresh_token"
static let state = "state"
}
// MARK: - OAuth Well-Known Paths
enum OAuthWellKnownPath {
static let protectedResource = "/.well-known/oauth-protected-resource"
static let authorizationServer = "/.well-known/oauth-authorization-server"
static let openIDConfiguration = "/.well-known/openid-configuration"
}
// MARK: - OAuth Token Type
enum OAuthTokenType {
static let bearer = "Bearer"
}
// MARK: - OAuth Code Challenge Method
enum OAuthCodeChallengeMethod {
static let s256 = "S256"
}
// MARK: - OAuth Token Endpoint Auth Method
enum OAuthTokenEndpointAuthMethod {
static let clientSecretBasic = "client_secret_basic"
static let clientSecretPost = "client_secret_post"
static let none = "none"
static let privateKeyJWT = "private_key_jwt"
}
// MARK: - URL Scheme
enum OAuthURLScheme {
static let http = "http"
static let https = "https"
}
// MARK: - Default Ports
enum OAuthDefaultPort {
static let http = 80
static let https = 443
}
// MARK: - Loopback Hosts
enum OAuthLoopbackHost {
static let localhost = "localhost"
static let ipv4 = "127.0.0.1"
static let ipv6 = "::1"
static func isLoopback(_ host: String) -> Bool {
host == localhost || host == ipv4 || host == ipv6
}
}
// MARK: - Token Expiry Skew
/// Clock skew tolerance applied when checking token expiry.
///
/// ``OAuthAccessToken/isExpired(now:skewSeconds:)`` treats a token as expired
/// when `now + skewSeconds >= expiresAt`, giving the client a safety margin to
/// refresh tokens before they actually expire on the server.
public enum OAuthTokenExpirySkew {
/// Default clock skew buffer: 30 seconds.
public static let defaultSeconds: TimeInterval = 30
}
// MARK: - JWT Claim Names
enum JWTClaimName {
static let algorithm = "alg"
static let type = "typ"
static let typeValue = "JWT"
static let issuer = "iss"
static let subject = "sub"
static let audience = "aud"
static let issuedAt = "iat"
static let expiration = "exp"
static let jwtID = "jti"
}
// MARK: - Client Assertion Type
enum OAuthClientAssertionType {
static let jwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
}
// MARK: - HTTPHeaderName Extensions
extension HTTPHeaderName {
static let location = "Location"
}
// MARK: - ContentType Extensions
extension ContentType {
static let formURLEncoded = "application/x-www-form-urlencoded"
}
// MARK: - Data Base64URL Extension
extension Data {
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View file

@ -0,0 +1,361 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Scope Selection Protocol
/// Determines which OAuth scopes to request during token acquisition.
///
/// ``OAuthAuthorizer`` uses this protocol to translate raw scope strings from
/// `WWW-Authenticate` challenges and Protected Resource Metadata into the
/// scope set passed to the token endpoint.
///
/// Override the default ``DefaultOAuthScopeSelector`` to apply custom scope
/// filtering or transformation logic.
public protocol OAuthScopeSelecting {
/// Selects the scope set to request for a token.
///
/// Priority order (highest first):
/// 1. `challengeScope` — the `scope` parameter from the `WWW-Authenticate` header.
/// 2. `scopesSupported` — the `scopes_supported` array from Protected Resource Metadata.
/// 3. `nil` — no scope restriction.
///
/// - Parameters:
/// - challengeScope: Space-separated scope string from the Bearer challenge, or `nil`.
/// - scopesSupported: Array of supported scopes from the resource metadata, or `nil`.
/// - Returns: The set of scopes to request, or `nil` to omit the `scope` parameter entirely.
func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>?
/// Parses a space-separated OAuth scope string into individual scope tokens.
/// - Parameter scope: A scope string such as `"read write"`.
/// - Returns: A set of individual scope strings with whitespace-only tokens removed.
func parseScopeString(_ scope: String) -> Set<String>
/// Serializes a set of scopes into a space-separated string suitable for the `scope` parameter.
/// - Parameter scopes: The scope set to serialize.
/// - Returns: A sorted, space-separated string, or `nil` if the set is empty.
func serialize(_ scopes: Set<String>) -> String?
}
// MARK: - Default Scope Selector
/// Default ``OAuthScopeSelecting`` implementation.
///
/// Selects scopes in priority order: challenge scope > `scopes_supported` > `nil`.
/// Serializes scopes sorted alphabetically to produce deterministic `scope` parameters.
public struct DefaultOAuthScopeSelector: OAuthScopeSelecting {
public init() {}
public func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>? {
if let challengeScope {
let parsed = parseScopeString(challengeScope)
return parsed.isEmpty ? nil : parsed
}
if let scopesSupported {
let parsed = Set(scopesSupported.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
return parsed.isEmpty ? nil : parsed
}
return nil
}
public func parseScopeString(_ scope: String) -> Set<String> {
Set(
scope
.split(whereSeparator: { $0.isWhitespace })
.map(String.init)
.filter { !$0.isEmpty }
)
}
public func serialize(_ scopes: Set<String>) -> String? {
guard !scopes.isEmpty else { return nil }
return scopes.sorted().joined(separator: " ")
}
}
// MARK: - Metadata Discovery Protocol
/// Builds discovery URLs and normalizes resource identifiers for OAuth metadata discovery.
///
/// ``OAuthAuthorizer`` uses this protocol to construct the candidate URL list for both
/// Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414 / OIDC)
/// discovery, and to normalize resource URIs for RFC 8707 audience binding.
///
/// Override the default ``DefaultOAuthMetadataDiscovery`` to customise discovery URL
/// construction or resource-matching logic.
public protocol OAuthMetadataDiscovering: Sendable {
/// Returns candidate URLs for Protected Resource Metadata, ordered by priority.
///
/// ``OAuthAuthorizer`` tries each URL in order and uses the first successful response.
///
/// - Parameter endpoint: The MCP endpoint URL.
/// - Returns: An ordered list of discovery URLs (typically `/.well-known/oauth-protected-resource`
/// variants).
func protectedResourceMetadataURLs(for endpoint: URL) -> [URL]
/// Returns candidate URLs for Authorization Server Metadata, ordered by priority.
///
/// Covers RFC 8414 (`/.well-known/oauth-authorization-server`) and
/// OIDC Discovery 1.0 (`/.well-known/openid-configuration`), including
/// path-inserted variants for issuers with non-root paths.
///
/// - Parameter issuer: The authorization server issuer URL.
/// - Returns: An ordered list of metadata discovery URLs.
func authorizationServerMetadataURLs(for issuer: URL) -> [URL]
/// Derives the canonical RFC 8707 resource URI from an endpoint URL.
///
/// The canonical form strips the query string, fragment, and trailing slash
/// while preserving the scheme, host, port, and path.
///
/// - Parameter endpoint: The MCP endpoint URL.
/// - Returns: The canonical resource URI.
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL does not
/// satisfy the HTTPS-or-loopback-HTTP requirement.
func canonicalResourceURI(from endpoint: URL) throws -> URL
/// Derives a fallback authorization server issuer URL from an endpoint URL.
///
/// Used when no authorization server is listed in Protected Resource Metadata.
/// Typically returns the scheme+host+port of the endpoint with an empty path.
///
/// - Parameter endpoint: The MCP endpoint URL.
/// - Returns: A candidate issuer URL.
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL is invalid.
func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL
/// Returns `true` if `resource` is a prefix of `endpoint` in the URL hierarchy.
///
/// A resource matches an endpoint when the two share the same scheme, host, and port,
/// and the endpoint path starts with the resource path.
///
/// - Parameters:
/// - resource: The canonical resource URI from Protected Resource Metadata.
/// - endpoint: The canonical endpoint URI being requested.
/// - Returns: `true` if the endpoint falls within the resource's scope.
func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool
}
// MARK: - Default Metadata Discovery
/// Default ``OAuthMetadataDiscovering`` implementation following RFC 9728 and RFC 8414.
///
/// - Builds `/.well-known/oauth-protected-resource` URLs with and without the endpoint path suffix.
/// - Builds RFC 8414 and OIDC discovery URLs with path-inserted and path-appended variants.
/// - Canonicalises resource URIs by stripping query, fragment, and trailing slash.
/// - Matches resources using scheme/host/port equality and path prefix rules.
public struct DefaultOAuthMetadataDiscovery: OAuthMetadataDiscovering {
public init() {}
public func protectedResourceMetadataURLs(for endpoint: URL) -> [URL] {
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
Self.isSecureOAuthScheme(scheme: scheme, host: host)
else {
return []
}
components.query = nil
components.fragment = nil
let endpointPath = components.path
let normalizedPath = endpointPath == "/" ? "" : endpointPath
var urls: [URL] = []
var pathSpecific = components
pathSpecific.path = "\(OAuthWellKnownPath.protectedResource)\(normalizedPath)"
if let url = pathSpecific.url {
urls.append(url)
}
var root = components
root.path = OAuthWellKnownPath.protectedResource
if let url = root.url {
urls.append(url)
}
return urls
}
public func authorizationServerMetadataURLs(for issuer: URL) -> [URL] {
guard var components = URLComponents(url: issuer, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
Self.isSecureOAuthScheme(scheme: scheme, host: host)
else {
return []
}
components.query = nil
components.fragment = nil
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let hasPath = !path.isEmpty
var urls: [URL] = []
if hasPath {
var oauthInserted = components
oauthInserted.path = "\(OAuthWellKnownPath.authorizationServer)/\(path)"
if let url = oauthInserted.url {
urls.append(url)
}
var oidcInserted = components
oidcInserted.path = "\(OAuthWellKnownPath.openIDConfiguration)/\(path)"
if let url = oidcInserted.url {
urls.append(url)
}
var oidcAppended = components
oidcAppended.path = "/\(path)\(OAuthWellKnownPath.openIDConfiguration)"
if let url = oidcAppended.url {
urls.append(url)
}
} else {
var oauth = components
oauth.path = OAuthWellKnownPath.authorizationServer
if let url = oauth.url {
urls.append(url)
}
var oidc = components
oidc.path = OAuthWellKnownPath.openIDConfiguration
if let url = oidc.url {
urls.append(url)
}
}
return urls
}
public func canonicalResourceURI(from endpoint: URL) throws -> URL {
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
Self.isSecureOAuthScheme(scheme: scheme, host: host)
else {
throw OAuthAuthorizationError.invalidResourceURI(
"Resource URI must use https or loopback http"
)
}
if components.fragment != nil {
throw OAuthAuthorizationError.invalidResourceURI("Resource URI must not contain a fragment")
}
components.scheme = scheme
components.host = host
components.query = nil
components.fragment = nil
if components.path == "/" {
components.path = ""
}
guard let url = components.url else {
throw OAuthAuthorizationError.invalidResourceURI("Failed to normalize resource URI")
}
return url
}
public func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL {
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
Self.isSecureOAuthScheme(scheme: scheme, host: host)
else {
throw OAuthAuthorizationError.invalidResourceURI(
"Resource URI must use https or loopback http"
)
}
components.scheme = scheme
components.host = host
components.path = ""
components.query = nil
components.fragment = nil
guard let url = components.url else {
throw OAuthAuthorizationError.invalidResourceURI("Failed to derive issuer URI")
}
return url
}
public func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool {
guard let resourceComponents = URLComponents(
url: resource,
resolvingAgainstBaseURL: false
),
let endpointComponents = URLComponents(
url: endpoint,
resolvingAgainstBaseURL: false
)
else {
return false
}
let resourceScheme = resourceComponents.scheme?.lowercased()
let endpointScheme = endpointComponents.scheme?.lowercased()
let resourceHost = resourceComponents.host?.lowercased()
let endpointHost = endpointComponents.host?.lowercased()
let resourcePort = resourceComponents.port ?? Self.defaultPort(for: resourceScheme)
let endpointPort = endpointComponents.port ?? Self.defaultPort(for: endpointScheme)
guard resourceScheme == endpointScheme,
resourceHost == endpointHost,
resourcePort == endpointPort
else {
return false
}
let resourcePath = Self.normalizedResourcePath(resourceComponents.path)
let endpointPath = Self.normalizedResourcePath(endpointComponents.path)
if resourcePath.isEmpty {
return true
}
if endpointPath == resourcePath {
return true
}
return endpointPath.hasPrefix(resourcePath + "/")
}
private static func normalizedResourcePath(_ rawPath: String) -> String {
if rawPath.isEmpty || rawPath == "/" {
return ""
}
if rawPath.count > 1 && rawPath.hasSuffix("/") {
return String(rawPath.dropLast())
}
return rawPath
}
private static func defaultPort(for scheme: String?) -> Int? {
switch scheme?.lowercased() {
case OAuthURLScheme.http:
return OAuthDefaultPort.http
case OAuthURLScheme.https:
return OAuthDefaultPort.https
default:
return nil
}
}
static func isSecureOAuthScheme(scheme: String, host: String) -> Bool {
if scheme == OAuthURLScheme.https {
return true
}
if scheme == OAuthURLScheme.http {
return OAuthLoopbackHost.isLoopback(host)
}
return false
}
}

View file

@ -0,0 +1,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 {}

View file

@ -0,0 +1,203 @@
import Foundation
/// Errors thrown during OAuth 2.1 authorization by ``OAuthAuthorizer``.
///
/// These errors surface when the authorizer is unable to complete the authorization flow,
/// either due to discovery failures, token exchange problems, security policy violations,
/// or authorization code flow issues.
public enum OAuthAuthorizationError: LocalizedError {
/// No authorization server URL could be found in the Protected Resource Metadata.
case missingAuthorizationServer
/// All Protected Resource Metadata discovery candidates returned errors or invalid documents.
case metadataDiscoveryFailed
/// All Authorization Server Metadata discovery candidates (RFC 8414 / OIDC) returned errors.
case authorizationServerMetadataDiscoveryFailed
/// The authorization server metadata does not include a `token_endpoint`.
case tokenEndpointMissing
/// The token endpoint returned a non-2xx HTTP response.
///
/// - Parameters:
/// - statusCode: HTTP status code from the token endpoint.
/// - oauthError: The `error` field from the OAuth error response body, if present.
case tokenRequestFailed(statusCode: Int, oauthError: String?)
/// The token response body is missing `access_token`, has an empty token, or specifies
/// a non-Bearer token type.
case tokenResponseInvalid
/// A URL that must be a valid resource identifier (RFC 8707) failed validation.
case invalidResourceURI(String)
/// The configured client ID looks like a URL but is not a valid HTTPS URL with a path,
/// which is required for Client ID Metadata Documents.
case invalidClientIDMetadataURL(String)
/// The `resource` field in the Protected Resource Metadata does not match the requested endpoint.
///
/// - Parameters:
/// - expected: The canonical URI derived from the endpoint.
/// - actual: The canonical URI derived from the metadata's `resource` field.
case protectedResourceMismatch(expected: String, actual: String)
/// The authorization server does not support dynamic registration and no pre-registered
/// credentials were supplied.
case registrationInformationRequired
/// The authorization server does not support Client ID Metadata Documents and no dynamic
/// registration endpoint is available.
///
/// - Parameter clientID: The client ID URL that was provided as a CIMD URL.
case cimdNotSupported(clientID: String)
/// A URL received from a discovery response resolves to a private or reserved IP address,
/// which is blocked to prevent SSRF attacks.
///
/// - Parameters:
/// - context: Human-readable label identifying which URL was blocked.
/// - url: The blocked URL string.
case privateIPAddressBlocked(context: String, url: String)
/// An endpoint URL used during the OAuth flow does not satisfy the HTTPS-or-loopback requirement.
///
/// - Parameters:
/// - context: Human-readable label identifying which endpoint failed (e.g., `"Token endpoint"`).
/// - url: The offending URL string.
case insecureOAuthEndpoint(context: String, url: String)
/// An authorization server endpoint does not satisfy the HTTPS-only requirement.
///
/// - Parameters:
/// - context: Human-readable label identifying which endpoint failed.
/// - url: The offending URL string.
case insecureAuthorizationServerEndpoint(context: String, url: String)
/// The redirect URI supplied for the `authorization_code` flow is not a valid HTTPS or
/// loopback HTTP URI, or it contains a fragment.
case invalidRedirectURI(String)
/// The authorization endpoint returned an HTTP error response during the authorization code flow.
///
/// - Parameter statusCode: HTTP status code from the authorization endpoint.
case authorizationRequestFailed(statusCode: Int)
/// The authorization response did not include a `Location` redirect header.
case authorizationResponseMissingRedirectLocation
/// The redirect URI in the authorization response does not match the expected redirect URI.
///
/// - Parameters:
/// - expected: The redirect URI supplied in the authorization request.
/// - actual: The redirect URI received in the authorization response.
case authorizationResponseRedirectMismatch(expected: String, actual: String)
/// The authorization response redirect URL is missing the `state` parameter.
case authorizationResponseMissingState
/// The `state` in the authorization response does not match the one sent in the request.
///
/// This may indicate a CSRF attack.
///
/// - Parameters:
/// - expected: The `state` value sent in the authorization request.
/// - actual: The `state` value received in the authorization response.
case authorizationResponseStateMismatch(expected: String, actual: String)
/// The authorization response redirect URL is missing the `code` parameter.
case authorizationResponseMissingCode
/// The authorization server metadata does not include `code_challenge_methods_supported`,
/// which is required for PKCE (RFC 7636).
case pkceCodeChallengeMethodsMissing
/// The authorization server does not advertise `S256` in `code_challenge_methods_supported`.
///
/// The MCP specification mandates S256; plain PKCE is not accepted.
///
/// - Parameter advertisedMethods: The methods listed in the server metadata.
case pkceS256NotSupported(advertisedMethods: [String])
/// PKCE S256 challenge generation is unavailable because CryptoKit is not present on this platform.
case pkceS256Unavailable
/// The `issuer` field in the Authorization Server Metadata does not match the URL used to
/// resolve the metadata document, as required by RFC 8414 §3.
///
/// - Parameters:
/// - expected: The issuer URL derived from the discovery candidate.
/// - actual: The `issuer` field value found in the metadata document.
case authorizationServerIssuerMismatch(expected: String, actual: String)
public var errorDescription: String? {
switch self {
case .missingAuthorizationServer:
return "No authorization server was found in protected resource metadata"
case .metadataDiscoveryFailed:
return "Failed to discover protected resource metadata"
case .authorizationServerMetadataDiscoveryFailed:
return "Failed to discover authorization server metadata"
case .tokenEndpointMissing:
return "Authorization server metadata is missing token_endpoint"
case .tokenRequestFailed(let statusCode, let oauthError):
if let oauthError, !oauthError.isEmpty {
return "Token request failed with status \(statusCode) (oauth_error: \(oauthError))"
}
return "Token request failed with status \(statusCode)"
case .tokenResponseInvalid:
return "Token response is invalid"
case .invalidResourceURI(let detail):
return "Invalid resource URI: \(detail)"
case .invalidClientIDMetadataURL(let value):
return
"Client ID metadata document URL must use https and include a path: \(value)"
case .protectedResourceMismatch(let expected, let actual):
return
"Protected resource metadata resource mismatch. Expected \(expected), got \(actual)"
case .registrationInformationRequired:
return
"No supported client registration mechanism was available; provide pre-registered client credentials"
case .cimdNotSupported(let clientID):
return
"Authorization server does not support Client ID Metadata Documents; configure pre-registered credentials or ensure the server advertises client_id_metadata_document_supported: \(clientID)"
case .privateIPAddressBlocked(let context, let url):
return
"\(context) resolves to a private or reserved IP address which is blocked for SSRF protection: \(url)"
case .insecureOAuthEndpoint(let context, let url):
return "\(context) must use https or loopback http: \(url)"
case .insecureAuthorizationServerEndpoint(let context, let url):
return "\(context) must use https: \(url)"
case .invalidRedirectURI(let url):
return
"Redirect URI must use https or loopback http and must not include fragments: \(url)"
case .authorizationRequestFailed(let statusCode):
return "Authorization request failed with status \(statusCode)"
case .authorizationResponseMissingRedirectLocation:
return "Authorization response is missing redirect location"
case .authorizationResponseRedirectMismatch(let expected, let actual):
return
"Authorization response redirect URI mismatch. Expected \(expected), got \(actual)"
case .authorizationResponseMissingState:
return "Authorization response is missing state"
case .authorizationResponseStateMismatch(let expected, let actual):
return "Authorization response state mismatch. Expected \(expected), got \(actual)"
case .authorizationResponseMissingCode:
return "Authorization response is missing the authorization code"
case .pkceCodeChallengeMethodsMissing:
return
"Authorization server metadata must include code_challenge_methods_supported for PKCE"
case .pkceS256NotSupported(let advertisedMethods):
let methods = advertisedMethods.joined(separator: ", ")
return
"Authorization server metadata must support PKCE S256 (advertised: \(methods))"
case .pkceS256Unavailable:
return
"PKCE S256 code challenge generation is unavailable on this platform"
case .authorizationServerIssuerMismatch(let expected, let actual):
return
"Authorization server issuer mismatch. Expected \(expected), got \(actual)"
}
}
}

View file

@ -0,0 +1,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
}
}

View file

@ -0,0 +1,90 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Internal protocol for making OAuth token endpoint requests.
protocol OAuthTokenRequesting: Sendable {
func request(
parameters: inout [String: String],
endpoint: URL,
authentication: OAuthConfiguration.TokenEndpointAuthentication,
session: URLSession
) async throws -> OAuthTokenResponse
}
/// Stateless OAuth token endpoint HTTP client.
///
/// Handles the low-level HTTP mechanics of making token requests.
struct OAuthTokenEndpointClient: Sendable {
let urlValidator: OAuthURLValidator
init(urlValidator: OAuthURLValidator) {
self.urlValidator = urlValidator
}
/// Makes a token request to the given endpoint.
func request(
parameters: inout [String: String],
endpoint: URL,
authentication: OAuthConfiguration.TokenEndpointAuthentication,
session: URLSession
) async throws -> OAuthTokenResponse {
var urlRequest = URLRequest(url: endpoint)
urlRequest.httpMethod = "POST"
urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
try await authentication.apply(
to: &urlRequest,
bodyParameters: &parameters,
tokenEndpoint: endpoint
)
urlRequest.httpBody = encodeForm(parameters)
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
}
guard (200..<300).contains(httpResponse.statusCode) else {
let oauthError =
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
throw OAuthAuthorizationError.tokenRequestFailed(
statusCode: httpResponse.statusCode,
oauthError: oauthError
)
}
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
guard !decoded.accessToken.isEmpty else {
throw OAuthAuthorizationError.tokenResponseInvalid
}
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
guard !tokenType.isEmpty,
tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
else {
throw OAuthAuthorizationError.tokenResponseInvalid
}
return decoded
}
private func encodeForm(_ params: [String: String]) -> Data {
let body = params
.sorted { $0.key < $1.key }
.map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
.joined(separator: "&")
return Data(body.utf8)
}
private func percentEncode(_ string: String) -> String {
let allowed = CharacterSet(
charactersIn:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
}
}
extension OAuthTokenEndpointClient: OAuthTokenRequesting {}

View file

@ -0,0 +1,135 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Internal protocol for URL security validation in OAuth flows.
protocol OAuthURLValidating: Sendable {
func validateHTTPSOrLoopback(_ url: URL, context: String) throws
func validateAuthorizationServer(_ url: URL, context: String) throws
func validateRedirectURI(_ url: URL) throws
func isPrivateIPHost(_ host: String) -> Bool
}
/// URL security rules for OAuth endpoints.
///
/// Validates that URLs used in the OAuth flow satisfy HTTPS requirements and SSRF protections.
/// Configured once and shared across the discovery, token, and registration components.
public struct OAuthURLValidator: Sendable {
/// When `true`, loopback HTTP URLs are accepted for authorization server endpoints.
///
/// This is a package-level compatibility option for local test environments.
public let allowLoopbackHTTPForAuthorizationServer: Bool
public init(allowLoopbackHTTPForAuthorizationServer: Bool = false) {
self.allowLoopbackHTTPForAuthorizationServer = allowLoopbackHTTPForAuthorizationServer
}
/// Validates that the URL uses HTTPS or loopback HTTP, and has no fragment.
///
/// Used for MCP endpoints and protected resource metadata URLs.
public func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
!host.isEmpty,
components.fragment == nil
else {
throw OAuthAuthorizationError.invalidResourceURI(
"Invalid \(context): \(url.absoluteString)"
)
}
guard scheme == OAuthURLScheme.https
|| (scheme == OAuthURLScheme.http && OAuthLoopbackHost.isLoopback(host))
else {
throw OAuthAuthorizationError.insecureOAuthEndpoint(
context: context,
url: url.absoluteString
)
}
}
/// Validates that the URL is an HTTPS authorization server endpoint.
///
/// Loopback HTTP is permitted when `allowLoopbackHTTPForAuthorizationServer` is `true`.
public func validateAuthorizationServer(_ url: URL, context: String) throws {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased(),
!host.isEmpty,
components.fragment == nil
else {
throw OAuthAuthorizationError.invalidResourceURI(
"Invalid \(context): \(url.absoluteString)"
)
}
if allowLoopbackHTTPForAuthorizationServer,
scheme == OAuthURLScheme.http,
OAuthLoopbackHost.isLoopback(host)
{
return
}
guard scheme == OAuthURLScheme.https else {
throw OAuthAuthorizationError.insecureAuthorizationServerEndpoint(
context: context,
url: url.absoluteString
)
}
}
/// Validates the redirect URI for authorization_code flows.
///
/// Accepts HTTPS or loopback HTTP; rejects any URI containing a fragment.
public func validateRedirectURI(_ url: URL) throws {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
components.fragment == nil
else {
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
}
if scheme == OAuthURLScheme.https { return }
if scheme == OAuthURLScheme.http,
let host = components.host?.lowercased(),
OAuthLoopbackHost.isLoopback(host)
{
return
}
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
}
/// Returns `true` if `host` is a literal IPv4 or IPv6 address in a private or reserved range.
///
/// Blocked ranges:
/// - IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`
/// - IPv6: `fc00::/7` (ULA), `fe80::/10` (link-local)
///
/// **Limitation**: only literal IP addresses are checked. DNS rebinding is not prevented here.
public func isPrivateIPHost(_ host: String) -> Bool {
let octets = host.split(separator: ".").compactMap { UInt8($0) }
if octets.count == 4 && !host.contains(":") {
let (a, b) = (octets[0], octets[1])
return a == 10 // 10.0.0.0/8
|| (a == 172 && (16...31).contains(b)) // 172.16.0.0/12
|| (a == 192 && b == 168) // 192.168.0.0/16
|| (a == 169 && b == 254) // 169.254.0.0/16 (link-local / cloud metadata)
|| (a == 100 && (64...127).contains(b)) // 100.64.0.0/10 (CGNAT)
}
let lower = host.lowercased()
if lower.hasPrefix("fc") || lower.hasPrefix("fd") { return true } // fc00::/7 (ULA)
if lower.hasPrefix("fe") && lower.count > 2 {
let idx = lower.index(lower.startIndex, offsetBy: 2)
if "89ab".contains(lower[idx]) { return true } // fe80::/10 (link-local)
}
return false
}
}
extension OAuthURLValidator: OAuthURLValidating {}

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

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

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

View file

@ -76,6 +76,9 @@ public actor HTTPClientTransport: Transport {
/// Closure to modify requests before they are sent /// Closure to modify requests before they are sent
private let requestModifier: (URLRequest) -> URLRequest private let requestModifier: (URLRequest) -> URLRequest
/// Optional OAuth 2.1 authorizer.
private let authorizer: (any HTTPClientAuthorizer)?
private var isConnected = false private var isConnected = false
private let messageStream: AsyncThrowingStream<Data, Swift.Error> private let messageStream: AsyncThrowingStream<Data, Swift.Error>
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation 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) /// - streaming: Whether to enable SSE streaming mode (default: true)
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds) /// - 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") /// - 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) /// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
/// - logger: Optional logger instance for transport events /// - logger: Optional logger instance for transport events
public init( public init(
@ -109,6 +113,7 @@ public actor HTTPClientTransport: Transport {
streaming: Bool = true, streaming: Bool = true,
sseInitializationTimeout: TimeInterval = 10, sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest, protocolVersion: String = Version.latest,
authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 }, requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil logger: Logger? = nil
) { ) {
@ -118,6 +123,7 @@ public actor HTTPClientTransport: Transport {
streaming: streaming, streaming: streaming,
sseInitializationTimeout: sseInitializationTimeout, sseInitializationTimeout: sseInitializationTimeout,
protocolVersion: protocolVersion, protocolVersion: protocolVersion,
authorizer: authorizer,
requestModifier: requestModifier, requestModifier: requestModifier,
logger: logger logger: logger
) )
@ -129,6 +135,7 @@ public actor HTTPClientTransport: Transport {
streaming: Bool = false, streaming: Bool = false,
sseInitializationTimeout: TimeInterval = 10, sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest, protocolVersion: String = Version.latest,
authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 }, requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil logger: Logger? = nil
) { ) {
@ -138,6 +145,7 @@ public actor HTTPClientTransport: Transport {
self.sseInitializationTimeout = sseInitializationTimeout self.sseInitializationTimeout = sseInitializationTimeout
self.protocolVersion = protocolVersion self.protocolVersion = protocolVersion
self.requestModifier = requestModifier self.requestModifier = requestModifier
self.authorizer = authorizer
// Create message stream // Create message stream
var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation! var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation!
@ -157,7 +165,6 @@ public actor HTTPClientTransport: Transport {
self.initialSessionIDSignalTask = Task { self.initialSessionIDSignalTask = Task {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
self.initialSessionIDContinuation = continuation self.initialSessionIDContinuation = continuation
// This task will suspend here until continuation.resume() is called
} }
} }
} }
@ -166,7 +173,7 @@ public actor HTTPClientTransport: Transport {
private func triggerInitialSessionIDSignal() { private func triggerInitialSessionIDSignal() {
if let continuation = self.initialSessionIDContinuation { if let continuation = self.initialSessionIDContinuation {
continuation.resume() continuation.resume()
self.initialSessionIDContinuation = nil // Consume the continuation self.initialSessionIDContinuation = nil
logger.debug("✓ Initial session ID signal triggered for SSE task") logger.debug("✓ Initial session ID signal triggered for SSE task")
} else { } else {
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting") 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 /// 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 { public func connect() async throws {
guard !isConnected else { return } guard !isConnected else { return }
isConnected = true isConnected = true
// Setup initial session ID signal
setupInitialSessionIDSignal() setupInitialSessionIDSignal()
if streaming { if streaming {
// Start listening to server events
streamingTask = Task { await startListeningForServerEvents() } streamingTask = Task { await startListeningForServerEvents() }
} }
@ -194,27 +195,18 @@ public actor HTTPClientTransport: Transport {
} }
/// Disconnects from the 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 { public func disconnect() async {
guard isConnected else { return } guard isConnected else { return }
isConnected = false isConnected = false
// Cancel streaming task if active
streamingTask?.cancel() streamingTask?.cancel()
streamingTask = nil streamingTask = nil
// Cancel any in-progress requests
session.invalidateAndCancel() session.invalidateAndCancel()
// Clean up message stream
messageContinuation.finish() messageContinuation.finish()
// Cancel the initial session ID signal task if active
initialSessionIDSignalTask?.cancel() initialSessionIDSignalTask?.cancel()
initialSessionIDSignalTask = nil initialSessionIDSignalTask = nil
// Resume the continuation if it's still pending to avoid leaks
initialSessionIDContinuation?.resume() initialSessionIDContinuation?.resume()
initialSessionIDContinuation = nil initialSessionIDContinuation = nil
@ -222,77 +214,108 @@ public actor HTTPClientTransport: Transport {
} }
/// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests. /// 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) { public func updateNegotiatedProtocolVersion(_ version: String) {
self.protocolVersion = version self.protocolVersion = version
} }
/// Sends data through an HTTP POST request /// 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 { public func send(_ data: Data) async throws {
guard isConnected else { guard isConnected else {
throw MCPError.internalError("Transport not connected") throw MCPError.internalError("Transport not connected")
} }
var request = URLRequest(url: endpoint) if let authorizer {
request.httpMethod = "POST" do {
request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") try authorizer.validateEndpointSecurity(for: endpoint)
request.addValue("application/json", forHTTPHeaderField: "Content-Type") } catch {
request.httpBody = data throw MCPError.internalError(
"Authorization flow failed: \(error.localizedDescription)"
// Add protocol version header (required by MCP specification 2025-11-25) )
if let protocolVersion = protocolVersion { }
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
} }
// Add session ID if available if let authorizer {
if let sessionID = sessionID { try? await authorizer.prepareAuthorization(for: endpoint, session: session)
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
} }
// Apply request modifier var attempts = 0
request = requestModifier(request) let operationKey = jsonRPCOperationKey(from: data)
#if os(Linux) while true {
// Linux implementation using data(for:) instead of bytes(for:) var request = URLRequest(url: endpoint)
let (responseData, response) = try await session.data(for: request) request.httpMethod = "POST"
try await processResponse(response: response, data: responseData) request.addValue(
#else "\(ContentType.json), \(ContentType.sse)",
// macOS and other platforms with bytes(for:) support forHTTPHeaderField: HTTPHeaderName.accept
let (responseStream, response) = try await session.bytes(for: request) )
try await processResponse(response: response, stream: responseStream) request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
#endif request.httpBody = data
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
}
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
}
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
}
request = requestModifier(request)
do {
#if os(Linux)
let (responseData, response) = try await session.data(for: request)
try await processResponse(response: response, data: responseData)
#else
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) #if os(Linux)
// Process response with data payload (Linux)
private func processResponse(response: URLResponse, data: Data) async throws { private func processResponse(response: URLResponse, data: Data) async throws {
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response") throw MCPError.internalError("Invalid HTTP response")
} }
// Process the response based on content type and status code let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil) let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID self.sessionID = newSessionID
if wasSessionIDNil { if wasSessionIDNil {
// Trigger signal on first session ID
triggerInitialSessionIDSignal() triggerInitialSessionIDSignal()
} }
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"]) logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -301,11 +324,10 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType) try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return } guard case 200..<300 = httpResponse.statusCode else { return }
// For JSON responses, yield the data if contentType.contains(ContentType.sse) {
if contentType.contains("text/event-stream") {
logger.warning("SSE responses aren't fully supported on Linux") logger.warning("SSE responses aren't fully supported on Linux")
messageContinuation.yield(data) messageContinuation.yield(data)
} else if contentType.contains("application/json") { } else if contentType.contains(ContentType.json) {
logger.trace("Received JSON response", metadata: ["size": "\(data.count)"]) logger.trace("Received JSON response", metadata: ["size": "\(data.count)"])
messageContinuation.yield(data) messageContinuation.yield(data)
} else { } else {
@ -313,7 +335,6 @@ public actor HTTPClientTransport: Transport {
} }
} }
#else #else
// Process response with byte stream (macOS, iOS, etc.)
private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes) private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes)
async throws async throws
{ {
@ -321,15 +342,12 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Invalid HTTP response") throw MCPError.internalError("Invalid HTTP response")
} }
// Process the response based on content type and status code let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
// Extract session ID if present if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil) let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID self.sessionID = newSessionID
if wasSessionIDNil { if wasSessionIDNil {
// Trigger signal on first session ID
triggerInitialSessionIDSignal() triggerInitialSessionIDSignal()
} }
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"]) logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -338,20 +356,15 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType) try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return } guard case 200..<300 = httpResponse.statusCode else { return }
if contentType.contains("text/event-stream") { if contentType.contains(ContentType.sse) {
// For SSE, processing happens via the stream
logger.trace("Received SSE response, processing in streaming task") logger.trace("Received SSE response, processing in streaming task")
let hadData = try await self.processSSE(stream) 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 { if !hadData {
logger.debug("POST SSE stream closed without data, triggering GET reconnection") logger.debug("POST SSE stream closed without data, triggering GET reconnection")
self.activeGETSessionTask?.cancel() self.activeGETSessionTask?.cancel()
} }
} else if contentType.contains("application/json") { } else if contentType.contains(ContentType.json) {
// For JSON responses, collect and deliver the data
var buffer = Data() var buffer = Data()
for try await byte in stream { for try await byte in stream {
buffer.append(byte) buffer.append(byte)
@ -364,25 +377,27 @@ public actor HTTPClientTransport: Transport {
} }
#endif #endif
// Common HTTP response handling for all platforms
private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws { private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws {
// Handle status codes according to HTTP semantics
switch response.statusCode { switch response.statusCode {
case 200..<300: case 200..<300:
// Success range - these are handled by the platform-specific code
return return
case 400: case 400:
throw MCPError.internalError("Bad request") throw MCPError.internalError("Bad request")
case 401: case 401:
throw MCPError.internalError("Authentication required") throw HTTPAuthenticationChallengeError(
statusCode: response.statusCode,
headers: responseHeaders(from: response)
)
case 403: case 403:
throw MCPError.internalError("Access forbidden") throw HTTPAuthenticationChallengeError(
statusCode: response.statusCode,
headers: responseHeaders(from: response)
)
case 404: case 404:
// If we get a 404 with a session ID, it means our session is invalid
if sessionID != nil { if sessionID != nil {
logger.warning("Session has expired") logger.warning("Session has expired")
sessionID = nil sessionID = nil
@ -391,8 +406,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Endpoint not found") throw MCPError.internalError("Endpoint not found")
case 405: 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 { if streaming {
self.streamingTask?.cancel() self.streamingTask?.cancel()
throw MCPError.internalError("Server does not support streaming") throw MCPError.internalError("Server does not support streaming")
@ -406,7 +419,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Too many requests") throw MCPError.internalError("Too many requests")
case 500..<600: case 500..<600:
// Server error range
throw MCPError.internalError("Server error: \(response.statusCode)") throw MCPError.internalError("Server error: \(response.statusCode)")
default: 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 /// 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> { public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return messageStream return messageStream
} }
// MARK: - SSE // 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 { private func startListeningForServerEvents() async {
#if os(Linux) #if os(Linux)
// SSE is not fully supported on Linux
if streaming { if streaming {
logger.warning( logger.warning(
"SSE streaming was requested but is not fully supported on Linux. SSE connection will not be attempted." "SSE streaming was requested but is not fully supported on Linux. SSE connection will not be attempted."
) )
} }
#else #else
// This is the original code for platforms that support SSE
guard isConnected else { return } guard isConnected else { return }
// Wait for session ID to be available before opening SSE stream
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask { if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...") 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") logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
} }
// Retry loop for connection drops
var isFirstAttempt = true var isFirstAttempt = true
var attemptCount = 0 var attemptCount = 0
@ -501,7 +524,6 @@ public actor HTTPClientTransport: Transport {
]) ])
do { do {
// Wait for retry interval before reconnecting (except first attempt)
if !isFirstAttempt { if !isFirstAttempt {
let delayMs = self.retryInterval let delayMs = self.retryInterval
logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"]) logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
@ -514,8 +536,6 @@ public actor HTTPClientTransport: Transport {
try await self.connectToEventStream() 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: [ logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
"attempt": "\(attemptCount)", "attempt": "\(attemptCount)",
"willRetryAfter": "\(self.retryInterval)ms" "willRetryAfter": "\(self.retryInterval)ms"
@ -523,7 +543,6 @@ public actor HTTPClientTransport: Transport {
} catch { } catch {
if !Task.isCancelled { if !Task.isCancelled {
logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)") logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
// Error case - will also use retry interval on next iteration
} else { } else {
logger.debug("⏹️ SSE task cancelled") logger.debug("⏹️ SSE task cancelled")
} }
@ -545,50 +564,56 @@ public actor HTTPClientTransport: Transport {
} }
#if !os(Linux) #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 { private func connectToEventStream() async throws {
guard isConnected else { guard isConnected else {
logger.debug("⚠️ Skipping connectToEventStream - transport not connected") logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
return 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") logger.debug("🔌 Preparing SSE connection request")
var request = URLRequest(url: endpoint) var request = URLRequest(url: endpoint)
request.httpMethod = "GET" request.httpMethod = "GET"
request.addValue("text/event-stream", forHTTPHeaderField: "Accept") request.addValue(ContentType.sse, forHTTPHeaderField: HTTPHeaderName.accept)
request.addValue("no-cache", forHTTPHeaderField: "Cache-Control") request.addValue("no-cache", forHTTPHeaderField: HTTPHeaderName.cacheControl)
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion { 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 { 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 { 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)"]) logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
} else { } else {
logger.info("→ Connecting to SSE stream (no last event ID to resume from)") 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) request = requestModifier(request)
logger.debug("Starting SSE connection") logger.debug("Starting SSE connection")
// Create URLSession task for SSE
let (stream, response) = try await session.bytes(for: request) let (stream, response) = try await session.bytes(for: request)
self.activeGETSessionTask = stream.task self.activeGETSessionTask = stream.task
@ -596,24 +621,17 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Invalid HTTP response") throw MCPError.internalError("Invalid HTTP response")
} }
// Check response status
guard httpResponse.statusCode == 200 else { 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 { if httpResponse.statusCode == 405 {
self.streamingTask?.cancel() self.streamingTask?.cancel()
} }
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)") throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
} }
// Extract session ID if present if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
let wasSessionIDNil = (self.sessionID == nil) let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID self.sessionID = newSessionID
if wasSessionIDNil { 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() triggerInitialSessionIDSignal()
} }
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"]) logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -623,14 +641,6 @@ public actor HTTPClientTransport: Transport {
try await self.processSSE(stream) 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 @discardableResult
private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool { private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
logger.debug("📥 Starting SSE event processing") logger.debug("📥 Starting SSE event processing")
@ -640,7 +650,6 @@ public actor HTTPClientTransport: Transport {
for try await event in stream.events { for try await event in stream.events {
eventCount += 1 eventCount += 1
// Check if task has been cancelled
if Task.isCancelled { if Task.isCancelled {
logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"]) logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
break break
@ -654,19 +663,16 @@ public actor HTTPClientTransport: Transport {
] ]
) )
// Store event ID for resumability support
if let eventID = event.id, !eventID.isEmpty { if let eventID = event.id, !eventID.isEmpty {
self.lastEventID = eventID self.lastEventID = eventID
logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"]) logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
} }
// Store retry interval if provided by server
if let retry = event.retry { if let retry = event.retry {
self.retryInterval = retry self.retryInterval = retry
logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(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) { if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
hadDataEvent = true hadDataEvent = true
messageContinuation.yield(data) messageContinuation.yield(data)

Some files were not shown because too many files have changed in this diff Show more