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

4
.spi.yml Normal file
View file

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

View file

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

View file

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

198
README.md
View file

@ -43,6 +43,15 @@ of the MCP specification.
- [Initialize Hook](#initialize-hook)
- [Graceful Shutdown](#graceful-shutdown)
- [Transports](#transports)
- [Authentication](#authentication)
- [Client: Client Credentials Flow](#client-client-credentials-flow)
- [Client: Authorization Code Flow](#client-authorization-code-flow)
- [Client: Custom Token Provider](#client-custom-token-provider)
- [Client: Custom Token Storage](#client-custom-token-storage)
- [Client: private\_key\_jwt Authentication](#client-private_key_jwt-authentication)
- [Client: Endpoint Overrides](#client-endpoint-overrides)
- [Server: Serving Protected Resource Metadata](#server-serving-protected-resource-metadata)
- [Server: Validating Bearer Tokens](#server-validating-bearer-tokens)
- [Platform Availability](#platform-availability)
- [Debugging and Logging](#debugging-and-logging)
- [Additional Resources](#additional-resources)
@ -1341,6 +1350,195 @@ public actor MyCustomTransport: Transport {
}
```
## Authentication
`HTTPClientTransport` supports OAuth 2.1 Bearer token authorization per the
[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
When a server returns `401 Unauthorized` or `403 Forbidden`, the transport automatically:
1. Discovers Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource`
2. Discovers Authorization Server Metadata (RFC 8414 / OIDC Discovery 1.0)
3. Registers the client dynamically (RFC 7591) if needed
4. Acquires a Bearer token using the configured grant flow (PKCE enforced)
5. Retries the original request with the token attached
Authorization is opt-in and disabled by default.
Pass an `OAuthAuthorizer` to `HTTPClientTransport(authorizer:)` to enable it.
### Client: Client Credentials Flow
Machine-to-machine authentication using a pre-shared client secret:
```swift
let config = OAuthConfiguration(
grantType: .clientCredentials,
authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
)
let authorizer = OAuthAuthorizer(configuration: config)
let transport = HTTPClientTransport(
endpoint: URL(string: "https://api.example.com/mcp")!,
authorizer: authorizer
)
let client = Client(name: "MyClient", version: "1.0.0")
try await client.connect(transport: transport)
```
### Client: Authorization Code Flow
Interactive, browser-based authentication with PKCE.
Implement `OAuthAuthorizationDelegate` to open the authorization URL and capture the redirect:
```swift
struct MyAuthDelegate: OAuthAuthorizationDelegate {
func presentAuthorizationURL(_ url: URL) async throws -> URL {
// Open the URL in a browser/webview and wait for the callback redirect URI.
// The returned URL must include the authorization code and state parameters.
return try await openBrowserAndWaitForCallback(url)
}
}
let config = OAuthConfiguration(
grantType: .authorizationCode,
authentication: .none(clientID: "my-app"),
authorizationDelegate: MyAuthDelegate()
)
let authorizer = OAuthAuthorizer(configuration: config)
let transport = HTTPClientTransport(
endpoint: URL(string: "https://api.example.com/mcp")!,
authorizer: authorizer
)
```
### Client: Custom Token Provider
Supply an externally acquired token (e.g., from a system credential store) via `accessTokenProvider`.
The SDK calls this closure after discovery completes. Return `nil` to fall back to the configured grant flow:
```swift
let config = OAuthConfiguration(
grantType: .clientCredentials,
authentication: .none(clientID: "my-app"),
accessTokenProvider: { context, session in
// context contains the discovered resource URI, token endpoint, scopes, etc.
return try await KeychainTokenStore.shared.loadToken(for: context.resource)
}
)
```
### Client: Custom Token Storage
By default, tokens are stored in memory and lost when the process exits.
To persist tokens across sessions, implement `TokenStorage` and pass it to `OAuthAuthorizer`:
```swift
final class KeychainTokenStorage: TokenStorage {
func save(_ token: OAuthAccessToken) {
// Encode and store token.value in the system Keychain
}
func load() -> OAuthAccessToken? {
// Load and decode token from the Keychain
return nil
}
func clear() {
// Delete from the Keychain
}
}
let authorizer = OAuthAuthorizer(
configuration: config,
tokenStorage: KeychainTokenStorage()
)
```
### Client: `private_key_jwt` Authentication
Authenticate to the token endpoint using an asymmetric key (RFC 7523).
The SDK provides a built-in ES256 helper for P-256 keys:
```swift
let config = OAuthConfiguration(
grantType: .clientCredentials,
authentication: .privateKeyJWT(
clientID: "my-app",
assertionFactory: { tokenEndpoint, clientID in
try OAuthConfiguration.makePrivateKeyJWTAssertion(
clientID: clientID,
tokenEndpoint: tokenEndpoint,
privateKeyPEM: myEC256PrivateKeyPEM // PEM-encoded P-256 private key
)
}
)
)
```
### Client: Endpoint Overrides
Skip automatic discovery by providing explicit endpoint URLs.
Useful when the server does not publish well-known metadata documents:
```swift
let config = OAuthConfiguration(
grantType: .clientCredentials,
authentication: .clientSecretBasic(clientID: "app", clientSecret: "secret"),
endpointOverrides: OAuthConfiguration.EndpointOverrides(
tokenEndpoint: URL(string: "https://auth.example.com/oauth/token")!
)
)
```
### Server: Serving Protected Resource Metadata
Per the MCP authorization specification, servers **MUST** serve Protected Resource Metadata
at `/.well-known/oauth-protected-resource` so clients can discover authorization server endpoints.
Use `ProtectedResourceMetadataValidator` as the first validator in your pipeline so that
unauthenticated discovery requests are handled before the bearer token check:
```swift
let metadata = OAuthProtectedResourceServerMetadata(
resource: "https://api.example.com",
authorizationServers: [URL(string: "https://auth.example.com")!],
scopesSupported: ["read", "write"]
)
let metadataValidator = ProtectedResourceMetadataValidator(metadata: metadata)
```
### Server: Validating Bearer Tokens
Use `BearerTokenValidator` to authenticate incoming requests.
Your `tokenValidator` closure **MUST** verify the token's `aud` claim to prevent
token substitution attacks where a token intended for another resource is replayed against your server:
```swift
let resourceIdentifier = URL(string: "https://api.example.com")!
let bearerValidator = BearerTokenValidator(
resourceMetadataURL: URL(string: "https://api.example.com/.well-known/oauth-protected-resource")!,
resourceIdentifier: resourceIdentifier,
tokenValidator: { token, request, context in
guard let claims = try? verifyAndDecodeJWT(token) else {
return .invalidToken(errorDescription: "Token verification failed")
}
// Pass audience and expiry to BearerTokenInfo; the SDK validates the
// audience claim against resourceIdentifier automatically.
return .valid(BearerTokenInfo(
audience: claims.audience,
expiresAt: claims.expiresAt
))
}
)
let pipeline = StandardValidationPipeline(validators: [
metadataValidator, // serves /.well-known/oauth-protected-resource unauthenticated
bearerValidator, // validates Bearer tokens on all other requests
AcceptHeaderValidator(mode: .sseRequired),
ContentTypeValidator(),
SessionValidator(),
])
```
## Platform Availability
The Swift SDK has the following platform requirements:

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
private let requestModifier: (URLRequest) -> URLRequest
/// Optional OAuth 2.1 authorizer.
private let authorizer: (any HTTPClientAuthorizer)?
private var isConnected = false
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
@ -101,6 +104,7 @@ public actor HTTPClientTransport: Transport {
/// - streaming: Whether to enable SSE streaming mode (default: true)
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
/// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
/// - authorizer: Optional ``HTTPClientAuthorizer`` for automatic Bearer token acquisition and retries.
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
/// - logger: Optional logger instance for transport events
public init(
@ -109,6 +113,7 @@ public actor HTTPClientTransport: Transport {
streaming: Bool = true,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -118,6 +123,7 @@ public actor HTTPClientTransport: Transport {
streaming: streaming,
sseInitializationTimeout: sseInitializationTimeout,
protocolVersion: protocolVersion,
authorizer: authorizer,
requestModifier: requestModifier,
logger: logger
)
@ -129,6 +135,7 @@ public actor HTTPClientTransport: Transport {
streaming: Bool = false,
sseInitializationTimeout: TimeInterval = 10,
protocolVersion: String = Version.latest,
authorizer: (any HTTPClientAuthorizer)? = nil,
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
logger: Logger? = nil
) {
@ -138,6 +145,7 @@ public actor HTTPClientTransport: Transport {
self.sseInitializationTimeout = sseInitializationTimeout
self.protocolVersion = protocolVersion
self.requestModifier = requestModifier
self.authorizer = authorizer
// Create message stream
var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation!
@ -157,7 +165,6 @@ public actor HTTPClientTransport: Transport {
self.initialSessionIDSignalTask = Task {
await withCheckedContinuation { continuation in
self.initialSessionIDContinuation = continuation
// This task will suspend here until continuation.resume() is called
}
}
}
@ -166,7 +173,7 @@ public actor HTTPClientTransport: Transport {
private func triggerInitialSessionIDSignal() {
if let continuation = self.initialSessionIDContinuation {
continuation.resume()
self.initialSessionIDContinuation = nil // Consume the continuation
self.initialSessionIDContinuation = nil
logger.debug("✓ Initial session ID signal triggered for SSE task")
} else {
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
@ -174,19 +181,13 @@ public actor HTTPClientTransport: Transport {
}
/// Establishes connection with the transport
///
/// This prepares the transport for communication and sets up SSE streaming
/// if streaming mode is enabled. The actual HTTP connection happens with the
/// first message sent.
public func connect() async throws {
guard !isConnected else { return }
isConnected = true
// Setup initial session ID signal
setupInitialSessionIDSignal()
if streaming {
// Start listening to server events
streamingTask = Task { await startListeningForServerEvents() }
}
@ -194,27 +195,18 @@ public actor HTTPClientTransport: Transport {
}
/// Disconnects from the transport
///
/// This terminates any active connections, cancels the streaming task,
/// and releases any resources being used by the transport.
public func disconnect() async {
guard isConnected else { return }
isConnected = false
// Cancel streaming task if active
streamingTask?.cancel()
streamingTask = nil
// Cancel any in-progress requests
session.invalidateAndCancel()
// Clean up message stream
messageContinuation.finish()
// Cancel the initial session ID signal task if active
initialSessionIDSignalTask?.cancel()
initialSessionIDSignalTask = nil
// Resume the continuation if it's still pending to avoid leaks
initialSessionIDContinuation?.resume()
initialSessionIDContinuation = nil
@ -222,77 +214,108 @@ public actor HTTPClientTransport: Transport {
}
/// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests.
///
/// Call this after lifecycle negotiation if the server responds with a different version.
/// - Parameter version: The negotiated protocol version.
public func updateNegotiatedProtocolVersion(_ version: String) {
self.protocolVersion = version
}
/// Sends data through an HTTP POST request
///
/// This sends a JSON-RPC message to the server via HTTP POST and processes
/// the response according to the MCP Streamable HTTP specification. It handles:
///
/// - Adding appropriate Accept headers for both JSON and SSE
/// - Including the MCP-Protocol-Version header as required by the specification
/// - Including the session ID in requests if one has been established
/// - Processing different response types (JSON vs SSE)
/// - Handling HTTP error codes according to the specification
///
/// - Parameter data: The JSON-RPC message to send
/// - Throws: MCPError for transport failures or server errors
public func send(_ data: Data) async throws {
guard isConnected else {
throw MCPError.internalError("Transport not connected")
}
if let authorizer {
do {
try authorizer.validateEndpointSecurity(for: endpoint)
} catch {
throw MCPError.internalError(
"Authorization flow failed: \(error.localizedDescription)"
)
}
}
if let authorizer {
try? await authorizer.prepareAuthorization(for: endpoint, session: session)
}
var attempts = 0
let operationKey = jsonRPCOperationKey(from: data)
while true {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue(
"\(ContentType.json), \(ContentType.sse)",
forHTTPHeaderField: HTTPHeaderName.accept
)
request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
request.httpBody = data
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
}
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
}
// Apply request modifier
request = requestModifier(request)
do {
#if os(Linux)
// Linux implementation using data(for:) instead of bytes(for:)
let (responseData, response) = try await session.data(for: request)
try await processResponse(response: response, data: responseData)
#else
// macOS and other platforms with bytes(for:) support
let (responseStream, response) = try await session.bytes(for: request)
try await processResponse(response: response, stream: responseStream)
#endif
return
} catch let authError as HTTPAuthenticationChallengeError {
guard let authorizer else {
throw mapAuthenticationChallengeError(authError)
}
let handled: Bool
do {
handled = try await authorizer.handleChallenge(
statusCode: authError.statusCode,
headers: authError.headers,
endpoint: endpoint,
operationKey: operationKey,
session: session
)
} catch {
throw MCPError.internalError(
"Authorization flow failed: \(error.localizedDescription)")
}
attempts += 1
if handled, attempts < authorizer.maxAuthorizationAttempts {
continue
}
throw mapAuthenticationChallengeError(authError)
}
}
}
#if os(Linux)
// Process response with data payload (Linux)
private func processResponse(response: URLResponse, data: Data) async throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw MCPError.internalError("Invalid HTTP response")
}
// Process the response based on content type and status code
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
// Trigger signal on first session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -301,11 +324,10 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return }
// For JSON responses, yield the data
if contentType.contains("text/event-stream") {
if contentType.contains(ContentType.sse) {
logger.warning("SSE responses aren't fully supported on Linux")
messageContinuation.yield(data)
} else if contentType.contains("application/json") {
} else if contentType.contains(ContentType.json) {
logger.trace("Received JSON response", metadata: ["size": "\(data.count)"])
messageContinuation.yield(data)
} else {
@ -313,7 +335,6 @@ public actor HTTPClientTransport: Transport {
}
}
#else
// Process response with byte stream (macOS, iOS, etc.)
private func processResponse(response: URLResponse, stream: URLSession.AsyncBytes)
async throws
{
@ -321,15 +342,12 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Invalid HTTP response")
}
// Process the response based on content type and status code
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
let contentType = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.contentType) ?? ""
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
// Trigger signal on first session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -338,20 +356,15 @@ public actor HTTPClientTransport: Transport {
try processHTTPResponse(httpResponse, contentType: contentType)
guard case 200..<300 = httpResponse.statusCode else { return }
if contentType.contains("text/event-stream") {
// For SSE, processing happens via the stream
if contentType.contains(ContentType.sse) {
logger.trace("Received SSE response, processing in streaming task")
let hadData = try await self.processSSE(stream)
// If the POST SSE stream closed without delivering a JSON-RPC response,
// trigger GET reconnection so the server can deliver it there.
// This implements standard SSE reconnection behavior per the spec.
if !hadData {
logger.debug("POST SSE stream closed without data, triggering GET reconnection")
self.activeGETSessionTask?.cancel()
}
} else if contentType.contains("application/json") {
// For JSON responses, collect and deliver the data
} else if contentType.contains(ContentType.json) {
var buffer = Data()
for try await byte in stream {
buffer.append(byte)
@ -364,25 +377,27 @@ public actor HTTPClientTransport: Transport {
}
#endif
// Common HTTP response handling for all platforms
private func processHTTPResponse(_ response: HTTPURLResponse, contentType: String) throws {
// Handle status codes according to HTTP semantics
switch response.statusCode {
case 200..<300:
// Success range - these are handled by the platform-specific code
return
case 400:
throw MCPError.internalError("Bad request")
case 401:
throw MCPError.internalError("Authentication required")
throw HTTPAuthenticationChallengeError(
statusCode: response.statusCode,
headers: responseHeaders(from: response)
)
case 403:
throw MCPError.internalError("Access forbidden")
throw HTTPAuthenticationChallengeError(
statusCode: response.statusCode,
headers: responseHeaders(from: response)
)
case 404:
// If we get a 404 with a session ID, it means our session is invalid
if sessionID != nil {
logger.warning("Session has expired")
sessionID = nil
@ -391,8 +406,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Endpoint not found")
case 405:
// If we get a 405, it means the server does not support the requested method
// If streaming was requested, we should cancel the streaming task
if streaming {
self.streamingTask?.cancel()
throw MCPError.internalError("Server does not support streaming")
@ -406,7 +419,6 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Too many requests")
case 500..<600:
// Server error range
throw MCPError.internalError("Server error: \(response.statusCode)")
default:
@ -415,43 +427,55 @@ public actor HTTPClientTransport: Transport {
}
}
private func mapAuthenticationChallengeError(_ error: HTTPAuthenticationChallengeError) -> MCPError {
switch error.statusCode {
case 401:
return MCPError.internalError("Authentication required")
case 403:
return MCPError.internalError("Access forbidden")
default:
return MCPError.internalError("HTTP authorization error: \(error.statusCode)")
}
}
private func responseHeaders(from response: HTTPURLResponse) -> [String: String] {
var headers: [String: String] = [:]
for (key, value) in response.allHeaderFields {
guard let key = key as? String, let value = value as? String else { continue }
headers[key] = value
}
return headers
}
private func jsonRPCOperationKey(from data: Data) -> String? {
guard
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let method = jsonObject["method"] as? String
else {
return nil
}
let normalized = method.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty ? nil : normalized
}
/// Receives data in an async sequence
///
/// This returns an AsyncThrowingStream that emits Data objects representing
/// each JSON-RPC message received from the server. This includes:
///
/// - Direct responses to client requests
/// - Server-initiated messages delivered via SSE streams
///
/// - Returns: An AsyncThrowingStream of Data objects
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return messageStream
}
// MARK: - SSE
/// Starts listening for server events using SSE
///
/// This establishes a long-lived HTTP connection using Server-Sent Events (SSE)
/// to enable server-to-client push messaging. It handles:
///
/// - Waiting for session ID if needed
/// - Opening the SSE connection
/// - Automatic reconnection on connection drops
/// - Processing received events
private func startListeningForServerEvents() async {
#if os(Linux)
// SSE is not fully supported on Linux
if streaming {
logger.warning(
"SSE streaming was requested but is not fully supported on Linux. SSE connection will not be attempted."
)
}
#else
// This is the original code for platforms that support SSE
guard isConnected else { return }
// Wait for session ID to be available before opening SSE stream
if self.sessionID == nil, let signalTask = self.initialSessionIDSignalTask {
logger.debug("⏳ Waiting for session ID to be set (timeout: \(self.sseInitializationTimeout)s)...")
@ -484,7 +508,6 @@ public actor HTTPClientTransport: Transport {
logger.debug("✓ Session ID already available, proceeding with SSE connection immediately")
}
// Retry loop for connection drops
var isFirstAttempt = true
var attemptCount = 0
@ -501,7 +524,6 @@ public actor HTTPClientTransport: Transport {
])
do {
// Wait for retry interval before reconnecting (except first attempt)
if !isFirstAttempt {
let delayMs = self.retryInterval
logger.debug("⏳ Waiting before SSE reconnection", metadata: ["retryMs": "\(delayMs)"])
@ -514,8 +536,6 @@ public actor HTTPClientTransport: Transport {
try await self.connectToEventStream()
// If connectToEventStream() returns without error,
// it means the stream closed gracefully - reconnect with retry interval
logger.info("🔌 SSE stream closed gracefully, will reconnect", metadata: [
"attempt": "\(attemptCount)",
"willRetryAfter": "\(self.retryInterval)ms"
@ -523,7 +543,6 @@ public actor HTTPClientTransport: Transport {
} catch {
if !Task.isCancelled {
logger.error("❌ SSE connection error (attempt #\(attemptCount)): \(error)")
// Error case - will also use retry interval on next iteration
} else {
logger.debug("⏹️ SSE task cancelled")
}
@ -545,50 +564,56 @@ public actor HTTPClientTransport: Transport {
}
#if !os(Linux)
/// Establishes an SSE connection to the server
///
/// This initiates a GET request to the server endpoint with appropriate
/// headers to establish an SSE stream according to the MCP specification.
/// Supports stream resumability via Last-Event-ID header.
///
/// - Throws: MCPError for connection failures or server errors
private func connectToEventStream() async throws {
guard isConnected else {
logger.debug("⚠️ Skipping connectToEventStream - transport not connected")
return
}
if let authorizer {
do {
try authorizer.validateEndpointSecurity(for: endpoint)
} catch {
throw MCPError.internalError(
"Authorization flow failed: \(error.localizedDescription)"
)
}
}
if let authorizer {
try? await authorizer.prepareAuthorization(for: endpoint, session: session)
}
logger.debug("🔌 Preparing SSE connection request")
var request = URLRequest(url: endpoint)
request.httpMethod = "GET"
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
request.addValue("no-cache", forHTTPHeaderField: "Cache-Control")
request.addValue(ContentType.sse, forHTTPHeaderField: HTTPHeaderName.accept)
request.addValue("no-cache", forHTTPHeaderField: HTTPHeaderName.cacheControl)
// Add protocol version header (required by MCP specification 2025-11-25)
if let protocolVersion = protocolVersion {
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
}
// Add session ID if available
if let sessionID = sessionID {
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
}
// Add last event ID for resumability (if available)
if let lastEventID = lastEventID {
request.addValue(lastEventID, forHTTPHeaderField: "Last-Event-ID")
request.addValue(lastEventID, forHTTPHeaderField: HTTPHeaderName.lastEventID)
logger.info("→ Resuming SSE stream with Last-Event-ID", metadata: ["lastEventID": "\(lastEventID)"])
} else {
logger.info("→ Connecting to SSE stream (no last event ID to resume from)")
}
// Apply request modifier
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
}
request = requestModifier(request)
logger.debug("Starting SSE connection")
// Create URLSession task for SSE
let (stream, response) = try await session.bytes(for: request)
self.activeGETSessionTask = stream.task
@ -596,24 +621,17 @@ public actor HTTPClientTransport: Transport {
throw MCPError.internalError("Invalid HTTP response")
}
// Check response status
guard httpResponse.statusCode == 200 else {
// If the server returns 405 Method Not Allowed,
// it indicates that the server doesn't support SSE streaming.
// We should cancel the task instead of retrying the connection.
if httpResponse.statusCode == 405 {
self.streamingTask?.cancel()
}
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
}
// Extract session ID if present
if let newSessionID = httpResponse.value(forHTTPHeaderField: "MCP-Session-Id") {
if let newSessionID = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.sessionID) {
let wasSessionIDNil = (self.sessionID == nil)
self.sessionID = newSessionID
if wasSessionIDNil {
// Trigger signal on first session ID, though this is unlikely to happen here
// as GET usually follows a POST that would have already set the session ID
triggerInitialSessionIDSignal()
}
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
@ -623,14 +641,6 @@ public actor HTTPClientTransport: Transport {
try await self.processSSE(stream)
}
/// Processes an SSE byte stream, extracting events and delivering them.
///
/// This method processes Server-Sent Events according to the MCP specification,
/// including support for event IDs for resumability.
///
/// - Parameter stream: The URLSession.AsyncBytes stream to process
/// - Returns: `true` if any data events were received, `false` otherwise.
/// - Throws: Error for stream processing failures
@discardableResult
private func processSSE(_ stream: URLSession.AsyncBytes) async throws -> Bool {
logger.debug("📥 Starting SSE event processing")
@ -640,7 +650,6 @@ public actor HTTPClientTransport: Transport {
for try await event in stream.events {
eventCount += 1
// Check if task has been cancelled
if Task.isCancelled {
logger.debug("⏹️ SSE processing cancelled", metadata: ["eventsProcessed": "\(eventCount)"])
break
@ -654,19 +663,16 @@ public actor HTTPClientTransport: Transport {
]
)
// Store event ID for resumability support
if let eventID = event.id, !eventID.isEmpty {
self.lastEventID = eventID
logger.debug("Stored event ID for resumability", metadata: ["eventID": "\(eventID)"])
}
// Store retry interval if provided by server
if let retry = event.retry {
self.retryInterval = retry
logger.debug("SSE retry interval updated", metadata: ["retryMs": "\(retry)"])
}
// Convert the event data to Data and yield it to the message stream
if !event.data.isEmpty, let data = event.data.data(using: .utf8) {
hadDataEvent = true
messageContinuation.yield(data)

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