V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206) Co-authored-by: wti <wti@users.noreply.github.com> * feat/auth: Authentication implementation (#205) * feat: added authentication * feat(auth): improvement to error handling * feat(auth): updated errors and tests in OAuthClientRegistrar * feat(auth): updated ci conformance package version * feat(auth): bumped node version * feat(auth): updated ci conformance step * feat(auth): improvement to Linux support * chore: added docc plugin * chore: added spi yml * chore: added documentation step to ci * chore: increased timeout for test from 5 to 10m --------- Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com> Co-authored-by: wti <wti@users.noreply.github.com> Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
This commit is contained in:
parent
973b46b8f7
commit
6132fd4b5b
18 changed files with 10925 additions and 199 deletions
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - OAuthAuthorizationCodeFlowing Protocol
|
||||
|
||||
/// Internal protocol for driving the OAuth 2.1 authorization code flow.
|
||||
protocol OAuthAuthorizationCodeFlowing: Sendable {
|
||||
func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL
|
||||
|
||||
func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
// MARK: - No-Redirect Session Delegate
|
||||
|
||||
final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
|
||||
@unchecked Sendable
|
||||
{
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Authorization Code Flow
|
||||
|
||||
/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
|
||||
///
|
||||
/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
|
||||
/// and extracts the authorization code from the callback redirect URL.
|
||||
public struct OAuthAuthorizationCodeFlow: Sendable {
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Builds the authorization request URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationEndpoint: The AS authorization endpoint.
|
||||
/// - resource: The RFC 8707 resource indicator.
|
||||
/// - redirectURI: The redirect URI registered for this client.
|
||||
/// - clientID: The OAuth client identifier.
|
||||
/// - codeChallenge: The PKCE S256 code challenge.
|
||||
/// - scopes: Optional scope set to request.
|
||||
/// - state: The CSRF state nonce.
|
||||
/// - scopeSerializer: Serializes the scope set to a space-separated string.
|
||||
/// - Returns: The full authorization request URL with all query parameters.
|
||||
public func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL {
|
||||
guard var components = URLComponents(
|
||||
url: authorizationEndpoint,
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] = [
|
||||
.init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
|
||||
.init(name: OAuthParameterName.clientID, value: clientID),
|
||||
.init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
|
||||
.init(name: OAuthParameterName.state, value: state),
|
||||
.init(name: OAuthParameterName.resource, value: resource.absoluteString),
|
||||
.init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
|
||||
.init(
|
||||
name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
|
||||
]
|
||||
|
||||
if let scope = scopes.flatMap(scopeSerializer.serialize) {
|
||||
queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
|
||||
}
|
||||
|
||||
components.queryItems = queryItems
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Drives the interactive authorization redirect and returns the authorization code.
|
||||
///
|
||||
/// When a delegate is provided, presents the authorization URL and awaits the redirect.
|
||||
/// Without a delegate, sends a GET request and captures the redirect `Location` header.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationURL: The full authorization request URL.
|
||||
/// - redirectURI: The expected redirect URI base for validation.
|
||||
/// - state: The CSRF state nonce to verify in the redirect.
|
||||
/// - delegate: Optional user-facing delegate for browser-based flows.
|
||||
/// - session: The `URLSession` used for the no-redirect path.
|
||||
/// - Returns: The extracted authorization code.
|
||||
public func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String {
|
||||
if let delegate {
|
||||
let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: authorizationURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(
|
||||
"text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
|
||||
let noRedirectSession = URLSession(
|
||||
configuration: session.configuration,
|
||||
delegate: noRedirectDelegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
defer { noRedirectSession.invalidateAndCancel() }
|
||||
|
||||
let (_, response) = try await noRedirectSession.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard (300..<400).contains(httpResponse.statusCode) else {
|
||||
if httpResponse.statusCode >= 400 {
|
||||
throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
|
||||
!location.isEmpty,
|
||||
let redirectURL = URL(string: location)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts and validates the authorization code from the redirect URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - redirectURL: The redirect URL received from the authorization server.
|
||||
/// - expectedRedirectURI: The redirect URI used in the authorization request.
|
||||
/// - expectedState: The CSRF state nonce sent in the authorization request.
|
||||
/// - Returns: The authorization code.
|
||||
public func extractCode(
|
||||
from redirectURL: URL,
|
||||
expectedRedirectURI: URL,
|
||||
expectedState: String
|
||||
) throws -> String {
|
||||
guard
|
||||
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
|
||||
let expectedComponents = URLComponents(
|
||||
url: expectedRedirectURI, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let state = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.state
|
||||
})?.value,
|
||||
!state.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingState
|
||||
}
|
||||
|
||||
guard state == expectedState else {
|
||||
throw OAuthAuthorizationError.authorizationResponseStateMismatch(
|
||||
expected: expectedState,
|
||||
actual: state
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let code = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.code
|
||||
})?.value,
|
||||
!code.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingCode
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func normalizedRedirectBase(_ components: URLComponents) -> String {
|
||||
let scheme = components.scheme?.lowercased() ?? ""
|
||||
let host = components.host?.lowercased() ?? ""
|
||||
let port: Int
|
||||
if let explicitPort = components.port {
|
||||
port = explicitPort
|
||||
} else if scheme == OAuthURLScheme.https {
|
||||
port = OAuthDefaultPort.https
|
||||
} else if scheme == OAuthURLScheme.http {
|
||||
port = OAuthDefaultPort.http
|
||||
} else {
|
||||
port = -1
|
||||
}
|
||||
let path = components.path.isEmpty ? "/" : components.path
|
||||
return "\(scheme)://\(host):\(port)\(path)"
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}
|
||||
865
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
865
Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
// MARK: - HTTPClientAuthorizer Protocol
|
||||
|
||||
/// Abstraction used by ``HTTPClientTransport`` to handle OAuth authorization challenges.
|
||||
///
|
||||
/// Implement this protocol to provide custom token acquisition strategies,
|
||||
/// or use the built-in ``OAuthAuthorizer`` for a full OAuth 2.1 implementation.
|
||||
///
|
||||
/// ``HTTPClientTransport`` calls these methods automatically when it receives
|
||||
/// `401 Unauthorized` or `403 Forbidden` responses from the server.
|
||||
public protocol HTTPClientAuthorizer: AnyObject, Sendable {
|
||||
/// The maximum number of authorization retries permitted for a single request.
|
||||
///
|
||||
/// ``HTTPClientTransport`` will not call ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)``
|
||||
/// more than this many times for a single outgoing request.
|
||||
var maxAuthorizationAttempts: Int { get }
|
||||
|
||||
/// Validates that the MCP endpoint URL satisfies the security requirements for OAuth.
|
||||
///
|
||||
/// Called once before the first request is sent. Throw ``OAuthAuthorizationError/insecureOAuthEndpoint(context:url:)``
|
||||
/// if the URL does not meet the requirements (e.g., non-HTTPS non-loopback).
|
||||
/// - Parameter endpoint: The MCP endpoint URL to validate.
|
||||
func validateEndpointSecurity(for endpoint: URL) throws
|
||||
|
||||
/// Returns the `Authorization` header value to attach to the next request, if a valid token is available.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint being requested.
|
||||
/// - Returns: A `"Bearer <token>"` string, or `nil` if no valid token is cached.
|
||||
func authorizationHeader(for endpoint: URL) -> String?
|
||||
|
||||
/// Handles an authorization challenge received from the server and attempts to acquire a new token.
|
||||
///
|
||||
/// Called by ``HTTPClientTransport`` when a `401` or `403` response is received.
|
||||
/// The implementation should attempt to obtain a valid access token and store it
|
||||
/// so that a subsequent call to ``authorizationHeader(for:)`` returns the new value.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - statusCode: The HTTP status code (401 or 403).
|
||||
/// - headers: All response headers from the challenge response.
|
||||
/// - endpoint: The MCP endpoint that returned the challenge.
|
||||
/// - operationKey: An optional identifier for the MCP operation (e.g., the JSON-RPC method),
|
||||
/// used to track step-up attempts per operation.
|
||||
/// - session: The `URLSession` to use for discovery and token requests.
|
||||
/// - Returns: `true` if a new token was acquired and the original request should be retried;
|
||||
/// `false` if the challenge cannot be handled.
|
||||
func handleChallenge(
|
||||
statusCode: Int,
|
||||
headers: [String: String],
|
||||
endpoint: URL,
|
||||
operationKey: String?,
|
||||
session: URLSession
|
||||
) async throws -> Bool
|
||||
|
||||
/// Proactively refreshes the access token if it is close to expiry.
|
||||
///
|
||||
/// Called by ``HTTPClientTransport`` before sending each request and before opening
|
||||
/// an SSE stream, allowing the token to be silently renewed without a 401 round-trip.
|
||||
/// Implementations should swallow refresh errors — if refresh fails, the normal
|
||||
/// ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)`` path recovers.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - endpoint: The MCP endpoint about to be contacted.
|
||||
/// - session: The `URLSession` to use for token refresh requests.
|
||||
func prepareAuthorization(for endpoint: URL, session: URLSession) async throws
|
||||
}
|
||||
|
||||
extension HTTPClientAuthorizer {
|
||||
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - OAuthAuthorizer
|
||||
|
||||
/// Full OAuth 2.1 implementation of ``HTTPClientAuthorizer``.
|
||||
///
|
||||
/// `OAuthAuthorizer` orchestrates the complete MCP authorization flow on behalf of an HTTP client:
|
||||
///
|
||||
/// 1. **Protected Resource Metadata discovery** (RFC 9728) — fetches
|
||||
/// `/.well-known/oauth-protected-resource` to locate the authorization server.
|
||||
/// 2. **Authorization Server Metadata discovery** (RFC 8414 / OIDC Discovery 1.0) — fetches
|
||||
/// `/.well-known/oauth-authorization-server` or `/.well-known/openid-configuration`.
|
||||
/// 3. **Dynamic Client Registration** (RFC 7591) — registers the client if no credentials are
|
||||
/// pre-configured and the AS advertises a registration endpoint.
|
||||
/// 4. **Token acquisition** — performs the configured grant flow (`authorization_code` with PKCE,
|
||||
/// or `client_credentials`), binding tokens to the resource indicator (RFC 8707).
|
||||
/// 5. **Token refresh** — attempts a `refresh_token` grant before a full re-authorization.
|
||||
/// 6. **Scope step-up** — handles `403 insufficient_scope` challenges by re-requesting with
|
||||
/// the union of existing and required scopes.
|
||||
///
|
||||
/// Pass an instance to `HTTPClientTransport(authorizer:)` to enable automatic authorization:
|
||||
///
|
||||
/// ```swift
|
||||
/// let config = OAuthConfiguration(
|
||||
/// grantType: .clientCredentials,
|
||||
/// authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
|
||||
/// )
|
||||
/// let authorizer = OAuthAuthorizer(configuration: config)
|
||||
/// let transport = HTTPClientTransport(endpoint: serverURL, authorizer: authorizer)
|
||||
/// ```
|
||||
///
|
||||
/// - Important: This type is `@unchecked Sendable`. All mutable state is accessed
|
||||
/// exclusively through the `HTTPClientTransport` actor, which serializes every call.
|
||||
/// Do **not** share a single `OAuthAuthorizer` instance across multiple transports —
|
||||
/// doing so would violate the isolation contract and risk concurrent mutation.
|
||||
public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
||||
|
||||
// MARK: - Mutable State
|
||||
|
||||
private var configuration: OAuthConfiguration
|
||||
private let tokenStorage: TokenStorage
|
||||
private var selectedAuthorizationServer: URL?
|
||||
private var protectedResourceMetadata: OAuthProtectedResourceMetadata?
|
||||
private var authorizationServerMetadata: OAuthAuthorizationServerMetadata?
|
||||
private var cachedProtectedResourceMetadataURL: URL?
|
||||
private var stepUpAttempts: [String: Int] = [:]
|
||||
private var clientRegistrationAttempted = false
|
||||
private var clientSecretExpiresAt: Date?
|
||||
|
||||
// MARK: - Composable Dependencies
|
||||
|
||||
private let scopeSelector: any OAuthScopeSelecting
|
||||
private let challengeParser: any OAuthWWWAuthenticateParsing
|
||||
private let urlValidator: any OAuthURLValidating
|
||||
private let discoveryClient: any OAuthDiscoveryFetching
|
||||
private let tokenEndpointClient: any OAuthTokenRequesting
|
||||
private let clientRegistrar: any OAuthClientRegistering
|
||||
private let authCodeFlow: any OAuthAuthorizationCodeFlowing
|
||||
|
||||
/// Creates an `OAuthAuthorizer` with the given configuration and optional injectable dependencies.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - configuration: OAuth 2.1 configuration controlling the grant type, authentication method,
|
||||
/// endpoint discovery overrides, and retry policy.
|
||||
/// - tokenStorage: Stores acquired access tokens. Defaults to ``InMemoryTokenStorage``,
|
||||
/// which loses tokens when the process exits. Supply a Keychain-backed implementation
|
||||
/// to persist tokens across sessions.
|
||||
/// - scopeSelector: Strategy for selecting OAuth scopes from challenge and metadata hints.
|
||||
/// Defaults to ``DefaultOAuthScopeSelector``.
|
||||
/// - challengeParser: Parses `WWW-Authenticate: Bearer` challenge headers.
|
||||
/// Defaults to ``DefaultOAuthWWWAuthenticateParser``.
|
||||
/// - metadataDiscovery: Constructs well-known discovery URLs and validates resource URI matching.
|
||||
/// Defaults to ``DefaultOAuthMetadataDiscovery``.
|
||||
public convenience init(
|
||||
configuration: OAuthConfiguration,
|
||||
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
||||
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
||||
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
||||
metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
|
||||
) {
|
||||
let urlValidator = OAuthURLValidator(
|
||||
allowLoopbackHTTPForAuthorizationServer:
|
||||
configuration.allowLoopbackHTTPAuthorizationServerEndpoints
|
||||
)
|
||||
self.init(
|
||||
configuration: configuration,
|
||||
tokenStorage: tokenStorage,
|
||||
scopeSelector: scopeSelector,
|
||||
challengeParser: challengeParser,
|
||||
urlValidator: urlValidator,
|
||||
discoveryClient: OAuthDiscoveryClient(
|
||||
metadataDiscovery: metadataDiscovery,
|
||||
urlValidator: urlValidator
|
||||
),
|
||||
tokenEndpointClient: OAuthTokenEndpointClient(urlValidator: urlValidator),
|
||||
clientRegistrar: OAuthClientRegistrar(urlValidator: urlValidator),
|
||||
authCodeFlow: OAuthAuthorizationCodeFlow()
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
configuration: OAuthConfiguration,
|
||||
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
||||
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
||||
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
||||
urlValidator: any OAuthURLValidating,
|
||||
discoveryClient: any OAuthDiscoveryFetching,
|
||||
tokenEndpointClient: any OAuthTokenRequesting,
|
||||
clientRegistrar: any OAuthClientRegistering,
|
||||
authCodeFlow: any OAuthAuthorizationCodeFlowing
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.tokenStorage = tokenStorage
|
||||
self.scopeSelector = scopeSelector
|
||||
self.challengeParser = challengeParser
|
||||
self.urlValidator = urlValidator
|
||||
self.discoveryClient = discoveryClient
|
||||
self.tokenEndpointClient = tokenEndpointClient
|
||||
self.clientRegistrar = clientRegistrar
|
||||
self.authCodeFlow = authCodeFlow
|
||||
}
|
||||
|
||||
// MARK: - HTTPClientAuthorizer
|
||||
|
||||
public var maxAuthorizationAttempts: Int {
|
||||
configuration.retryPolicy.maxAuthorizationAttempts
|
||||
}
|
||||
|
||||
public func validateEndpointSecurity(for endpoint: URL) throws {
|
||||
try urlValidator.validateHTTPSOrLoopback(endpoint, context: "MCP endpoint")
|
||||
}
|
||||
|
||||
public func authorizationHeader(for endpoint: URL) -> String? {
|
||||
guard let accessToken = tokenStorage.load() else { return nil }
|
||||
if let tokenAuthorizationServer = accessToken.authorizationServer,
|
||||
let selectedAuthorizationServer,
|
||||
!authorizationServersMatch(tokenAuthorizationServer, selectedAuthorizationServer)
|
||||
{
|
||||
tokenStorage.clear()
|
||||
return nil
|
||||
}
|
||||
if accessToken.isExpired() {
|
||||
tokenStorage.clear()
|
||||
return nil
|
||||
}
|
||||
return "\(OAuthTokenType.bearer) \(accessToken.value)"
|
||||
}
|
||||
|
||||
public func handleChallenge(
|
||||
statusCode: Int,
|
||||
headers: [String: String],
|
||||
endpoint: URL,
|
||||
operationKey: String? = nil,
|
||||
session: URLSession
|
||||
) async throws -> Bool {
|
||||
try validateEndpointSecurity(for: endpoint)
|
||||
let challenge = challengeParser.parseBearer(from: headers)
|
||||
|
||||
switch statusCode {
|
||||
case 401:
|
||||
if let refreshToken = tokenStorage.load()?.refreshToken {
|
||||
tokenStorage.clear()
|
||||
let metadata = try await discoverProtectedResourceMetadata(
|
||||
endpoint: endpoint,
|
||||
challenge: challenge,
|
||||
session: session
|
||||
)
|
||||
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||
metadata: metadata,
|
||||
session: session
|
||||
)
|
||||
let resource = try canonicalResource(for: endpoint)
|
||||
let requestedScopes = scopeSelector.selectScopes(
|
||||
challengeScope: challenge?.scope,
|
||||
scopesSupported: metadata.scopesSupported
|
||||
)
|
||||
if try await refreshAccessToken(
|
||||
refreshToken: refreshToken,
|
||||
resource: resource,
|
||||
requestedScopes: requestedScopes,
|
||||
asMetadata: asMetadata,
|
||||
session: session
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
tokenStorage.clear()
|
||||
}
|
||||
|
||||
let metadata = try await discoverProtectedResourceMetadata(
|
||||
endpoint: endpoint,
|
||||
challenge: challenge,
|
||||
session: session
|
||||
)
|
||||
let requestedScopes = scopeSelector.selectScopes(
|
||||
challengeScope: challenge?.scope,
|
||||
scopesSupported: metadata.scopesSupported
|
||||
)
|
||||
|
||||
let providerContext = try await makeAccessTokenProviderContext(
|
||||
statusCode: statusCode,
|
||||
endpoint: endpoint,
|
||||
challenge: challenge,
|
||||
metadata: metadata,
|
||||
requestedScopes: requestedScopes,
|
||||
session: session
|
||||
)
|
||||
if let externalToken = try await fetchAccessTokenFromProvider(
|
||||
context: providerContext,
|
||||
session: session
|
||||
) {
|
||||
storeExternalAccessToken(
|
||||
externalToken,
|
||||
requestedScopes: providerContext.requestedScopes,
|
||||
authorizationServer: providerContext.authorizationServer
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
try await acquireToken(
|
||||
endpoint: endpoint,
|
||||
metadata: metadata,
|
||||
requestedScopes: requestedScopes,
|
||||
session: session
|
||||
)
|
||||
return true
|
||||
|
||||
case 403:
|
||||
guard challenge?.error?.lowercased() == "insufficient_scope" else { return false }
|
||||
|
||||
let metadata = try await discoverProtectedResourceMetadata(
|
||||
endpoint: endpoint,
|
||||
challenge: challenge,
|
||||
session: session
|
||||
)
|
||||
let requiredScopes =
|
||||
scopeSelector.selectScopes(
|
||||
challengeScope: challenge?.scope,
|
||||
scopesSupported: metadata.scopesSupported
|
||||
) ?? []
|
||||
|
||||
let existingScopes = tokenStorage.load()?.scopes ?? []
|
||||
let upgradedScopes = existingScopes.union(requiredScopes)
|
||||
let resourceKey = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||
from: endpoint
|
||||
).absoluteString
|
||||
let operationAttemptKey = normalizedOperationKey(operationKey)
|
||||
let attemptKey =
|
||||
"\(resourceKey)|\(operationAttemptKey)|\(upgradedScopes.sorted().joined(separator: " "))"
|
||||
let attempts = stepUpAttempts[attemptKey, default: 0]
|
||||
guard attempts < configuration.retryPolicy.maxScopeUpgradeAttempts else {
|
||||
return false
|
||||
}
|
||||
stepUpAttempts[attemptKey] = attempts + 1
|
||||
|
||||
let providerRequestedScopes = upgradedScopes.isEmpty ? nil : upgradedScopes
|
||||
let providerContext = try await makeAccessTokenProviderContext(
|
||||
statusCode: statusCode,
|
||||
endpoint: endpoint,
|
||||
challenge: challenge,
|
||||
metadata: metadata,
|
||||
requestedScopes: providerRequestedScopes,
|
||||
session: session
|
||||
)
|
||||
if let externalToken = try await fetchAccessTokenFromProvider(
|
||||
context: providerContext,
|
||||
session: session
|
||||
) {
|
||||
storeExternalAccessToken(
|
||||
externalToken,
|
||||
requestedScopes: providerContext.requestedScopes,
|
||||
authorizationServer: providerContext.authorizationServer
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
try await acquireToken(
|
||||
endpoint: endpoint,
|
||||
metadata: metadata,
|
||||
requestedScopes: upgradedScopes,
|
||||
session: session
|
||||
)
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
|
||||
guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
|
||||
guard let token = tokenStorage.load() else { return }
|
||||
guard !token.isExpired() else { return }
|
||||
guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
|
||||
return
|
||||
}
|
||||
guard let refreshToken = token.refreshToken else { return }
|
||||
guard let asMeta = authorizationServerMetadata, asMeta.tokenEndpoint != nil else { return }
|
||||
|
||||
let resource: URL
|
||||
do {
|
||||
resource = try canonicalResource(for: endpoint)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let requestedScopes = token.scopes.isEmpty ? nil : token.scopes
|
||||
_ = try? await refreshAccessToken(
|
||||
refreshToken: refreshToken,
|
||||
resource: resource,
|
||||
requestedScopes: requestedScopes,
|
||||
asMetadata: asMeta,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Discovery
|
||||
|
||||
private func discoverProtectedResourceMetadata(
|
||||
endpoint: URL,
|
||||
challenge: OAuthBearerChallenge?,
|
||||
session: URLSession
|
||||
) async throws -> OAuthProtectedResourceMetadata {
|
||||
if let protectedResourceMetadata {
|
||||
let incomingURL = challenge?.resourceMetadataURL
|
||||
if let incomingURL, incomingURL != cachedProtectedResourceMetadataURL {
|
||||
self.protectedResourceMetadata = nil
|
||||
self.authorizationServerMetadata = nil
|
||||
self.selectedAuthorizationServer = nil
|
||||
self.cachedProtectedResourceMetadataURL = nil
|
||||
} else {
|
||||
return protectedResourceMetadata
|
||||
}
|
||||
}
|
||||
|
||||
var candidates: [URL] = []
|
||||
|
||||
if let challengeURL = challenge?.resourceMetadataURL {
|
||||
try urlValidator.validateHTTPSOrLoopback(
|
||||
challengeURL, context: "Protected resource metadata URL")
|
||||
if let host = URLComponents(url: challengeURL, resolvingAgainstBaseURL: false)?.host?
|
||||
.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||
{
|
||||
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||
context: "Protected resource metadata URL",
|
||||
url: challengeURL.absoluteString
|
||||
)
|
||||
}
|
||||
candidates.append(challengeURL)
|
||||
}
|
||||
if let configuredURL = configuration.endpointOverrides.protectedResourceMetadataURL,
|
||||
!candidates.contains(configuredURL)
|
||||
{
|
||||
try urlValidator.validateHTTPSOrLoopback(
|
||||
configuredURL,
|
||||
context: "Configured protected resource metadata URL"
|
||||
)
|
||||
candidates.append(configuredURL)
|
||||
}
|
||||
|
||||
for fallback in discoveryClient.metadataDiscovery.protectedResourceMetadataURLs(
|
||||
for: endpoint)
|
||||
where !candidates.contains(fallback) {
|
||||
candidates.append(fallback)
|
||||
}
|
||||
|
||||
let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
||||
candidates: candidates, session: session)
|
||||
try validateProtectedResource(metadata: metadata, endpoint: endpoint)
|
||||
|
||||
self.protectedResourceMetadata = metadata
|
||||
self.cachedProtectedResourceMetadataURL = candidates.first
|
||||
return metadata
|
||||
}
|
||||
|
||||
private func validateProtectedResource(
|
||||
metadata: OAuthProtectedResourceMetadata, endpoint: URL
|
||||
) throws {
|
||||
guard let resource = metadata.resource?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!resource.isEmpty
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let resourceURL = URL(string: resource) else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Protected resource metadata contains an invalid resource URI: \(resource)"
|
||||
)
|
||||
}
|
||||
|
||||
let expected = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: endpoint)
|
||||
let actual = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: resourceURL)
|
||||
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||
resource: actual, endpoint: expected)
|
||||
else {
|
||||
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||
expected: expected.absoluteString,
|
||||
actual: actual.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAuthorizationServerMetadata(
|
||||
metadata: OAuthProtectedResourceMetadata,
|
||||
session: URLSession
|
||||
) async throws -> OAuthAuthorizationServerMetadata {
|
||||
if let cached = authorizationServerMetadata {
|
||||
return cached
|
||||
}
|
||||
|
||||
let candidates: [URL]
|
||||
if let override = configuration.endpointOverrides.authorizationServerURL {
|
||||
try urlValidator.validateAuthorizationServer(
|
||||
override, context: "Authorization server issuer")
|
||||
candidates = [override]
|
||||
} else if let selected = selectedAuthorizationServer {
|
||||
candidates = [selected]
|
||||
} else {
|
||||
guard !metadata.authorizationServers.isEmpty else {
|
||||
throw OAuthAuthorizationError.missingAuthorizationServer
|
||||
}
|
||||
candidates = metadata.authorizationServers
|
||||
}
|
||||
|
||||
let (server, asMetadata) = try await discoveryClient.fetchAuthorizationServerMetadata(
|
||||
candidates: candidates, session: session)
|
||||
self.selectedAuthorizationServer = server
|
||||
self.authorizationServerMetadata = asMetadata
|
||||
return asMetadata
|
||||
}
|
||||
|
||||
// MARK: - Token Acquisition
|
||||
|
||||
private func acquireToken(
|
||||
endpoint: URL,
|
||||
metadata: OAuthProtectedResourceMetadata,
|
||||
requestedScopes: Set<String>?,
|
||||
session: URLSession
|
||||
) async throws {
|
||||
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||
metadata: metadata, session: session)
|
||||
try await maybeRegisterClient(asMetadata: asMetadata, session: session)
|
||||
let resource = try canonicalResource(for: endpoint)
|
||||
|
||||
switch configuration.grantType {
|
||||
case .clientCredentials:
|
||||
try await acquireTokenViaClientCredentials(
|
||||
resource: resource,
|
||||
requestedScopes: requestedScopes,
|
||||
asMetadata: asMetadata,
|
||||
session: session
|
||||
)
|
||||
case .authorizationCode:
|
||||
try await acquireTokenViaAuthorizationCode(
|
||||
resource: resource,
|
||||
requestedScopes: requestedScopes,
|
||||
asMetadata: asMetadata,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func acquireTokenViaClientCredentials(
|
||||
resource: URL,
|
||||
requestedScopes: Set<String>?,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws {
|
||||
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.clientCredentials
|
||||
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||
bodyParameters[OAuthParameterName.scope] = scope
|
||||
}
|
||||
let decoded = try await tokenEndpointClient.request(
|
||||
parameters: &bodyParameters,
|
||||
endpoint: tokenEndpoint,
|
||||
authentication: configuration.authentication,
|
||||
session: session
|
||||
)
|
||||
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||
}
|
||||
|
||||
private func acquireTokenViaAuthorizationCode(
|
||||
resource: URL,
|
||||
requestedScopes: Set<String>?,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws {
|
||||
guard let authorizationEndpoint = asMetadata.authorizationEndpoint else {
|
||||
throw OAuthAuthorizationError.tokenEndpointMissing
|
||||
}
|
||||
try urlValidator.validateAuthorizationServer(
|
||||
authorizationEndpoint, context: "Authorization endpoint")
|
||||
if let host = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false)?
|
||||
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||
{
|
||||
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||
context: "Authorization endpoint",
|
||||
url: authorizationEndpoint.absoluteString
|
||||
)
|
||||
}
|
||||
try urlValidator.validateRedirectURI(configuration.authorizationRedirectURI)
|
||||
try PKCE.checkSupport(in: asMetadata)
|
||||
|
||||
let verifier = PKCE.makeVerifier()
|
||||
let challenge = try PKCE.makeChallenge(from: verifier)
|
||||
let state = UUID().uuidString
|
||||
|
||||
let authorizationURL = try authCodeFlow.buildURL(
|
||||
authorizationEndpoint: authorizationEndpoint,
|
||||
resource: resource,
|
||||
redirectURI: configuration.authorizationRedirectURI,
|
||||
clientID: configuration.authentication.clientID,
|
||||
codeChallenge: challenge,
|
||||
scopes: requestedScopes,
|
||||
state: state,
|
||||
scopeSerializer: scopeSelector
|
||||
)
|
||||
|
||||
let authorizationCode = try await authCodeFlow.perform(
|
||||
authorizationURL: authorizationURL,
|
||||
redirectURI: configuration.authorizationRedirectURI,
|
||||
state: state,
|
||||
delegate: configuration.authorizationDelegate,
|
||||
session: session
|
||||
)
|
||||
|
||||
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.authorizationCode
|
||||
bodyParameters[OAuthParameterName.code] = authorizationCode
|
||||
bodyParameters[OAuthParameterName.codeVerifier] = verifier
|
||||
bodyParameters[OAuthParameterName.redirectURI] =
|
||||
configuration.authorizationRedirectURI.absoluteString
|
||||
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||
bodyParameters[OAuthParameterName.scope] = scope
|
||||
}
|
||||
|
||||
let decoded = try await tokenEndpointClient.request(
|
||||
parameters: &bodyParameters,
|
||||
endpoint: tokenEndpoint,
|
||||
authentication: configuration.authentication,
|
||||
session: session
|
||||
)
|
||||
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||
}
|
||||
|
||||
// MARK: - Token Refresh
|
||||
|
||||
private func refreshAccessToken(
|
||||
refreshToken: String,
|
||||
resource: URL,
|
||||
requestedScopes: Set<String>?,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws -> Bool {
|
||||
let tokenEndpoint: URL
|
||||
do {
|
||||
tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
||||
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.refreshToken
|
||||
bodyParameters[OAuthParameterName.refreshToken] = refreshToken
|
||||
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
||||
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
||||
bodyParameters[OAuthParameterName.scope] = scope
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try await tokenEndpointClient.request(
|
||||
parameters: &bodyParameters,
|
||||
endpoint: tokenEndpoint,
|
||||
authentication: configuration.authentication,
|
||||
session: session
|
||||
)
|
||||
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
||||
return true
|
||||
} catch let error as OAuthAuthorizationError {
|
||||
if case .tokenRequestFailed(let statusCode, _) = error,
|
||||
(400..<500).contains(statusCode)
|
||||
{
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client Registration
|
||||
|
||||
private func maybeRegisterClient(
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws {
|
||||
if let expiry = clientSecretExpiresAt, Date() >= expiry {
|
||||
clientSecretExpiresAt = nil
|
||||
clientRegistrationAttempted = false
|
||||
configuration.authentication = .none(clientID: configuration.authentication.clientID)
|
||||
}
|
||||
|
||||
guard !clientRegistrationAttempted else { return }
|
||||
guard case .none = configuration.authentication else { return }
|
||||
|
||||
clientRegistrationAttempted = true
|
||||
|
||||
if let (registration, updatedAuth) = try await clientRegistrar.register(
|
||||
configuration: configuration,
|
||||
asMetadata: asMetadata,
|
||||
session: session
|
||||
) {
|
||||
configuration.authentication = updatedAuth
|
||||
if let expiresAt = registration.clientSecretExpiresAt, expiresAt > 0 {
|
||||
clientSecretExpiresAt = Date(timeIntervalSince1970: Double(expiresAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Helpers
|
||||
|
||||
private func storeTokenResponse(
|
||||
_ decoded: OAuthTokenResponse,
|
||||
requestedScopes: Set<String>?
|
||||
) {
|
||||
let scopeSet: Set<String>
|
||||
if let scope = decoded.scope {
|
||||
scopeSet = scopeSelector.parseScopeString(scope)
|
||||
} else {
|
||||
scopeSet = requestedScopes ?? []
|
||||
}
|
||||
let expiresAt = decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) }
|
||||
tokenStorage.save(OAuthAccessToken(
|
||||
value: decoded.accessToken,
|
||||
tokenType: OAuthTokenType.bearer,
|
||||
expiresAt: expiresAt,
|
||||
scopes: scopeSet,
|
||||
authorizationServer: selectedAuthorizationServer,
|
||||
refreshToken: decoded.refreshToken
|
||||
))
|
||||
}
|
||||
|
||||
private func resolveTokenEndpoint(
|
||||
asMetadata: OAuthAuthorizationServerMetadata
|
||||
) throws -> URL {
|
||||
if let configuredEndpoint = configuration.endpointOverrides.tokenEndpoint {
|
||||
try urlValidator.validateAuthorizationServer(
|
||||
configuredEndpoint, context: "Configured token endpoint")
|
||||
return configuredEndpoint
|
||||
}
|
||||
|
||||
guard let tokenEndpoint = asMetadata.tokenEndpoint else {
|
||||
throw OAuthAuthorizationError.tokenEndpointMissing
|
||||
}
|
||||
try urlValidator.validateAuthorizationServer(tokenEndpoint, context: "Token endpoint")
|
||||
if let host = URLComponents(url: tokenEndpoint, resolvingAgainstBaseURL: false)?.host?
|
||||
.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||
{
|
||||
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
||||
context: "Token endpoint",
|
||||
url: tokenEndpoint.absoluteString
|
||||
)
|
||||
}
|
||||
return tokenEndpoint
|
||||
}
|
||||
|
||||
private func canonicalResource(for endpoint: URL) throws -> URL {
|
||||
let endpointCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||
from: endpoint)
|
||||
|
||||
if let configuredResource = configuration.endpointOverrides.resource {
|
||||
let configuredCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||
from: configuredResource)
|
||||
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||
resource: configuredCanonical, endpoint: endpointCanonical)
|
||||
else {
|
||||
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||
expected: endpointCanonical.absoluteString,
|
||||
actual: configuredCanonical.absoluteString
|
||||
)
|
||||
}
|
||||
return configuredCanonical
|
||||
}
|
||||
|
||||
if let prmResourceString = protectedResourceMetadata?.resource,
|
||||
let prmResourceURL = URL(string: prmResourceString)
|
||||
{
|
||||
let prmCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
||||
from: prmResourceURL)
|
||||
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
||||
resource: prmCanonical, endpoint: endpointCanonical)
|
||||
else {
|
||||
throw OAuthAuthorizationError.protectedResourceMismatch(
|
||||
expected: endpointCanonical.absoluteString,
|
||||
actual: prmCanonical.absoluteString
|
||||
)
|
||||
}
|
||||
return prmCanonical
|
||||
}
|
||||
|
||||
return endpointCanonical
|
||||
}
|
||||
|
||||
private func authorizationServersMatch(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||
normalizedAuthorizationServer(lhs) == normalizedAuthorizationServer(rhs)
|
||||
}
|
||||
|
||||
private func normalizedAuthorizationServer(_ url: URL) -> URL? {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
scheme == OAuthURLScheme.http || scheme == OAuthURLScheme.https
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
if components.path == "/" { components.path = "" }
|
||||
return components.url
|
||||
}
|
||||
|
||||
// MARK: - External Token Provider
|
||||
|
||||
private func fetchAccessTokenFromProvider(
|
||||
context: OAuthConfiguration.AccessTokenProviderContext,
|
||||
session: URLSession
|
||||
) async throws -> String? {
|
||||
guard let provider = configuration.accessTokenProvider else { return nil }
|
||||
guard let token = try await provider(context, session), !token.isEmpty else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
private func storeExternalAccessToken(
|
||||
_ token: String,
|
||||
requestedScopes: Set<String>?,
|
||||
authorizationServer: URL?
|
||||
) {
|
||||
tokenStorage.save(OAuthAccessToken(
|
||||
value: token,
|
||||
tokenType: OAuthTokenType.bearer,
|
||||
expiresAt: nil,
|
||||
scopes: requestedScopes ?? [],
|
||||
authorizationServer: authorizationServer,
|
||||
refreshToken: nil
|
||||
))
|
||||
}
|
||||
|
||||
private func makeAccessTokenProviderContext(
|
||||
statusCode: Int,
|
||||
endpoint: URL,
|
||||
challenge: OAuthBearerChallenge?,
|
||||
metadata: OAuthProtectedResourceMetadata,
|
||||
requestedScopes: Set<String>?,
|
||||
session: URLSession
|
||||
) async throws -> OAuthConfiguration.AccessTokenProviderContext {
|
||||
let asMetadata = try await resolveAuthorizationServerMetadata(
|
||||
metadata: metadata, session: session)
|
||||
let resource = try canonicalResource(for: endpoint)
|
||||
let authorizationServer = configuration.endpointOverrides.authorizationServerURL
|
||||
?? selectedAuthorizationServer
|
||||
?? metadata.authorizationServers.first
|
||||
let tokenEndpoint = configuration.endpointOverrides.tokenEndpoint ?? asMetadata.tokenEndpoint
|
||||
|
||||
return OAuthConfiguration.AccessTokenProviderContext(
|
||||
statusCode: statusCode,
|
||||
endpoint: endpoint,
|
||||
resource: resource,
|
||||
authorizationServer: authorizationServer,
|
||||
authorizationEndpoint: asMetadata.authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
registrationEndpoint: asMetadata.registrationEndpoint,
|
||||
challengedScope: challenge?.scope,
|
||||
scopesSupported: metadata.scopesSupported,
|
||||
requestedScopes: requestedScopes
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedOperationKey(_ operationKey: String?) -> String {
|
||||
guard let operationKey else { return "<unknown>" }
|
||||
let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return normalized.isEmpty ? "<unknown>" : normalized
|
||||
}
|
||||
}
|
||||
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
186
Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for OAuth dynamic client registration.
|
||||
protocol OAuthClientRegistering: Sendable {
|
||||
func register(
|
||||
configuration: OAuthConfiguration,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws -> (
|
||||
response: OAuthClientRegistrationResponse,
|
||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)?
|
||||
}
|
||||
|
||||
/// Stateless OAuth dynamic client registration logic.
|
||||
///
|
||||
/// Handles Client ID Metadata Document (CIMD) detection and RFC 7591 dynamic registration.
|
||||
/// State tracking (`clientRegistrationAttempted`, `clientSecretExpiresAt`) is the caller's responsibility.
|
||||
struct OAuthClientRegistrar: Sendable {
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(urlValidator: OAuthURLValidator) {
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Attempts to register the client, if applicable.
|
||||
///
|
||||
/// Returns `nil` if registration is not needed:
|
||||
/// - Credentials are already configured (not `.none`)
|
||||
/// - CIMD is in use and the server supports it (pre-registered)
|
||||
/// - No registration endpoint is available and no CIMD mismatch error
|
||||
///
|
||||
/// Throws if registration was attempted but failed (4xx, 5xx, or unexpected response).
|
||||
func register(
|
||||
configuration: OAuthConfiguration,
|
||||
asMetadata: OAuthAuthorizationServerMetadata,
|
||||
session: URLSession
|
||||
) async throws -> (
|
||||
response: OAuthClientRegistrationResponse,
|
||||
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
|
||||
)? {
|
||||
guard case .none(let clientID) = configuration.authentication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let hasClientIDMetadataDocument = isHTTPSURLWithPath(clientID)
|
||||
let supportsClientIDMetadataDocument = asMetadata.clientIDMetadataDocumentSupported == true
|
||||
|
||||
if supportsClientIDMetadataDocument,
|
||||
clientIDLooksLikeURL(clientID),
|
||||
!hasClientIDMetadataDocument
|
||||
{
|
||||
throw OAuthAuthorizationError.invalidClientIDMetadataURL(clientID)
|
||||
}
|
||||
|
||||
if hasClientIDMetadataDocument, supportsClientIDMetadataDocument {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let registrationEndpoint = asMetadata.registrationEndpoint else {
|
||||
if hasClientIDMetadataDocument && !supportsClientIDMetadataDocument {
|
||||
throw OAuthAuthorizationError.cimdNotSupported(clientID: clientID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
try urlValidator.validateAuthorizationServer(
|
||||
registrationEndpoint, context: "Client registration endpoint")
|
||||
|
||||
var request = URLRequest(url: registrationEndpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
let grantTypes: [String]
|
||||
let responseTypes: [String]
|
||||
switch configuration.grantType {
|
||||
case .authorizationCode:
|
||||
grantTypes = [OAuthGrantTypeValue.authorizationCode]
|
||||
responseTypes = [OAuthParameterName.code]
|
||||
case .clientCredentials:
|
||||
grantTypes = [OAuthGrantTypeValue.clientCredentials]
|
||||
responseTypes = []
|
||||
}
|
||||
|
||||
var registrationPayload: [String: Any] = [
|
||||
"client_name": configuration.clientName,
|
||||
"grant_types": grantTypes,
|
||||
"token_endpoint_auth_method": configuration.authentication.methodName,
|
||||
]
|
||||
if !responseTypes.isEmpty {
|
||||
registrationPayload["response_types"] = responseTypes
|
||||
}
|
||||
if configuration.grantType == .authorizationCode {
|
||||
registrationPayload["redirect_uris"] = [
|
||||
configuration.authorizationRedirectURI.absoluteString
|
||||
]
|
||||
}
|
||||
|
||||
let httpBody = try JSONSerialization.data(withJSONObject: registrationPayload)
|
||||
|
||||
request.httpBody = httpBody
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
(400..<500).contains(httpResponse.statusCode)
|
||||
{
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: httpResponse.statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
|
||||
let registration = try JSONDecoder().decode(OAuthClientRegistrationResponse.self, from: data)
|
||||
|
||||
let updatedAuth = OAuthClientRegistrar.updatedAuthentication(
|
||||
from: registration, current: configuration.authentication)
|
||||
return (response: registration, updatedAuthentication: updatedAuth)
|
||||
}
|
||||
|
||||
/// Derives the updated token endpoint authentication from a registration response.
|
||||
///
|
||||
/// Updates the client ID (and secret, if issued) while preserving the authentication method.
|
||||
static func updatedAuthentication(
|
||||
from registration: OAuthClientRegistrationResponse,
|
||||
current: OAuthConfiguration.TokenEndpointAuthentication
|
||||
) -> OAuthConfiguration.TokenEndpointAuthentication {
|
||||
switch current {
|
||||
case .none:
|
||||
return .none(clientID: registration.clientID)
|
||||
case .clientSecretBasic(_, let currentSecret):
|
||||
return .clientSecretBasic(
|
||||
clientID: registration.clientID,
|
||||
clientSecret: registration.clientSecret ?? currentSecret
|
||||
)
|
||||
case .clientSecretPost(_, let currentSecret):
|
||||
return .clientSecretPost(
|
||||
clientID: registration.clientID,
|
||||
clientSecret: registration.clientSecret ?? currentSecret
|
||||
)
|
||||
case .privateKeyJWT(_, let factory):
|
||||
return .privateKeyJWT(clientID: registration.clientID, assertionFactory: factory)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CIMD Helpers
|
||||
|
||||
private func isHTTPSURLWithPath(_ value: String) -> Bool {
|
||||
guard let url = URL(string: value),
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return components.scheme?.lowercased() == OAuthURLScheme.https
|
||||
&& !components.path.isEmpty
|
||||
&& components.path != "/"
|
||||
}
|
||||
|
||||
private func clientIDLooksLikeURL(_ value: String) -> Bool {
|
||||
guard let url = URL(string: value),
|
||||
let scheme = url.scheme,
|
||||
!scheme.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthClientRegistrar: OAuthClientRegistering {}
|
||||
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
415
Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
/// Configuration for OAuth 2.1 authorization used by ``HTTPClientTransport``.
|
||||
///
|
||||
/// Authorization is optional and disabled by default. Configure this type and pass it
|
||||
/// to `HTTPClientTransport(oauth:)` to enable automatic Bearer token acquisition for HTTP
|
||||
/// transports.
|
||||
///
|
||||
/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
|
||||
/// (machine-to-machine) grant types via the ``grantType`` property.
|
||||
public struct OAuthConfiguration: Sendable {
|
||||
/// The OAuth 2.1 grant type to use for token acquisition.
|
||||
public enum GrantType: Sendable {
|
||||
/// OAuth 2.1 authorization_code flow with PKCE.
|
||||
case authorizationCode
|
||||
|
||||
/// OAuth 2.1 client_credentials flow.
|
||||
case clientCredentials
|
||||
}
|
||||
|
||||
/// How the client authenticates to the OAuth token endpoint.
|
||||
public enum TokenEndpointAuthentication: Sendable, Equatable {
|
||||
/// `client_secret_basic` authentication using the Authorization header.
|
||||
case clientSecretBasic(clientID: String, clientSecret: String)
|
||||
|
||||
/// `client_secret_post` authentication using form parameters.
|
||||
case clientSecretPost(clientID: String, clientSecret: String)
|
||||
|
||||
/// Public client authentication (`token_endpoint_auth_method=none`).
|
||||
case none(clientID: String)
|
||||
|
||||
/// `private_key_jwt` authentication.
|
||||
///
|
||||
/// The built-in ``OAuthConfiguration/makePrivateKeyJWTAssertion(clientID:tokenEndpoint:privateKeyPEM:signingAlgorithm:audience:issuedAt:expiresIn:)``
|
||||
/// helper generates ES256 (P-256 ECDSA) assertions only. To use other algorithms
|
||||
/// (e.g., RS256, ES384), provide a custom ``OAuthConfiguration/JWTAssertionFactory`` closure.
|
||||
case privateKeyJWT(clientID: String, assertionFactory: JWTAssertionFactory)
|
||||
|
||||
var clientID: String {
|
||||
switch self {
|
||||
case .clientSecretBasic(let clientID, _), .clientSecretPost(let clientID, _),
|
||||
.none(let clientID), .privateKeyJWT(let clientID, _):
|
||||
return clientID
|
||||
}
|
||||
}
|
||||
|
||||
/// The token endpoint auth method name per RFC 7591.
|
||||
var methodName: String {
|
||||
switch self {
|
||||
case .clientSecretBasic: return OAuthTokenEndpointAuthMethod.clientSecretBasic
|
||||
case .clientSecretPost: return OAuthTokenEndpointAuthMethod.clientSecretPost
|
||||
case .none: return OAuthTokenEndpointAuthMethod.none
|
||||
case .privateKeyJWT: return OAuthTokenEndpointAuthMethod.privateKeyJWT
|
||||
}
|
||||
}
|
||||
|
||||
func apply(
|
||||
to request: inout URLRequest,
|
||||
bodyParameters: inout [String: String],
|
||||
tokenEndpoint: URL
|
||||
) async throws {
|
||||
switch self {
|
||||
case .clientSecretBasic(let clientID, let clientSecret):
|
||||
let basic = Data("\(clientID):\(clientSecret)".utf8).base64EncodedString()
|
||||
request.setValue("Basic \(basic)", forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||
case .clientSecretPost(let clientID, let clientSecret):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
bodyParameters[OAuthParameterName.clientSecret] = clientSecret
|
||||
case .none(let clientID):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
case .privateKeyJWT(let clientID, let assertionFactory):
|
||||
bodyParameters[OAuthParameterName.clientID] = clientID
|
||||
bodyParameters[OAuthParameterName.clientAssertionType] =
|
||||
OAuthClientAssertionType.jwtBearer
|
||||
bodyParameters[OAuthParameterName.clientAssertion] =
|
||||
try await assertionFactory(tokenEndpoint, clientID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closure used to generate a `private_key_jwt` assertion.
|
||||
public typealias JWTAssertionFactory = @Sendable (_ tokenEndpoint: URL, _ clientID: String)
|
||||
async throws -> String
|
||||
|
||||
/// Supported signing algorithms for SDK-generated `private_key_jwt` assertions.
|
||||
public enum PrivateKeyJWTSigningAlgorithm: String, Sendable {
|
||||
case ES256
|
||||
}
|
||||
|
||||
/// Errors thrown while creating SDK-generated `private_key_jwt` assertions.
|
||||
public enum PrivateKeyJWTAssertionError: LocalizedError, Sendable {
|
||||
case invalidLifetime(TimeInterval)
|
||||
case cryptographyUnavailable
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidLifetime(let lifetime):
|
||||
return "private_key_jwt assertion lifetime must be greater than zero seconds, got \(lifetime)"
|
||||
case .cryptographyUnavailable:
|
||||
return "private_key_jwt assertion signing requires CryptoKit support"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a signed `private_key_jwt` client assertion (RFC 7523).
|
||||
///
|
||||
/// Use this helper to build a `JWTAssertionFactory` closure when configuring
|
||||
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``:
|
||||
///
|
||||
/// ```swift
|
||||
/// let factory: OAuthConfiguration.JWTAssertionFactory = { tokenEndpoint, clientID in
|
||||
/// try OAuthConfiguration.makePrivateKeyJWTAssertion(
|
||||
/// clientID: clientID,
|
||||
/// tokenEndpoint: tokenEndpoint,
|
||||
/// privateKeyPEM: myPEMString
|
||||
/// )
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The assertion is a compact-serialized JWS signed with the specified private key.
|
||||
/// Only ES256 (P-256 ECDSA) is supported by this helper; the algorithm requires CryptoKit.
|
||||
/// To use other algorithms, supply a custom ``JWTAssertionFactory`` closure directly to
|
||||
/// ``TokenEndpointAuthentication/privateKeyJWT(clientID:assertionFactory:)``.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - clientID: The OAuth client identifier, used as both `iss` and `sub` claims.
|
||||
/// - tokenEndpoint: Token endpoint URL, used as the default `aud` claim.
|
||||
/// - privateKeyPEM: PEM-encoded EC private key for signing.
|
||||
/// - signingAlgorithm: The JWS signing algorithm. Currently only `.ES256` is supported.
|
||||
/// - audience: Explicit `aud` claim override. Defaults to `tokenEndpoint.absoluteString`.
|
||||
/// - issuedAt: The `iat` claim. Defaults to `Date()`.
|
||||
/// - expiresIn: Lifetime of the assertion in seconds. Defaults to 300 (5 minutes). Must be > 0.
|
||||
/// - Returns: A compact-serialized JWT string (`header.payload.signature`).
|
||||
/// - Throws: ``PrivateKeyJWTAssertionError/invalidLifetime(_:)`` if `expiresIn` ≤ 0,
|
||||
/// or ``PrivateKeyJWTAssertionError/cryptographyUnavailable`` on platforms without CryptoKit.
|
||||
public static func makePrivateKeyJWTAssertion(
|
||||
clientID: String,
|
||||
tokenEndpoint: URL,
|
||||
privateKeyPEM: String,
|
||||
signingAlgorithm: PrivateKeyJWTSigningAlgorithm = .ES256,
|
||||
audience: String? = nil,
|
||||
issuedAt: Date = Date(),
|
||||
expiresIn: TimeInterval = 300
|
||||
) throws -> String {
|
||||
guard expiresIn > 0 else {
|
||||
throw PrivateKeyJWTAssertionError.invalidLifetime(expiresIn)
|
||||
}
|
||||
|
||||
let header = try JSONSerialization.data(withJSONObject: [
|
||||
JWTClaimName.algorithm: signingAlgorithm.rawValue,
|
||||
JWTClaimName.type: JWTClaimName.typeValue,
|
||||
])
|
||||
|
||||
let issuedAtUnix = Int(issuedAt.timeIntervalSince1970)
|
||||
let lifetimeSeconds = max(1, Int(expiresIn.rounded(.down)))
|
||||
let payload = try JSONSerialization.data(withJSONObject: [
|
||||
JWTClaimName.issuer: clientID,
|
||||
JWTClaimName.subject: clientID,
|
||||
JWTClaimName.audience: audience ?? tokenEndpoint.absoluteString,
|
||||
JWTClaimName.issuedAt: issuedAtUnix,
|
||||
JWTClaimName.expiration: issuedAtUnix + lifetimeSeconds,
|
||||
JWTClaimName.jwtID: UUID().uuidString,
|
||||
])
|
||||
|
||||
let signingInput = "\(header.base64URLEncodedString()).\(payload.base64URLEncodedString())"
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
switch signingAlgorithm {
|
||||
case .ES256:
|
||||
let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPEM)
|
||||
let signature = try privateKey.signature(for: Data(signingInput.utf8)).rawRepresentation
|
||||
return "\(signingInput).\(signature.base64URLEncodedString())"
|
||||
}
|
||||
#else
|
||||
throw PrivateKeyJWTAssertionError.cryptographyUnavailable
|
||||
#endif
|
||||
}
|
||||
|
||||
static func defaultAuthorizationRedirectURI() -> URL {
|
||||
let port = Int.random(in: 49152...65535)
|
||||
return URL(string: "http://\(OAuthLoopbackHost.ipv4):\(port)/callback")!
|
||||
}
|
||||
|
||||
// MARK: - Retry Policy
|
||||
|
||||
/// Controls retry behavior for authorization challenges.
|
||||
public struct RetryPolicy: Sendable {
|
||||
/// Maximum number of authentication retries for a single MCP request.
|
||||
public let maxAuthorizationAttempts: Int
|
||||
|
||||
/// Maximum number of scope step-up attempts for a resource and operation.
|
||||
public let maxScopeUpgradeAttempts: Int
|
||||
|
||||
/// Creates a retry policy.
|
||||
///
|
||||
/// Both values are clamped to a minimum of `1` to prevent infinite loops
|
||||
/// or zero-retry configurations.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - maxAuthorizationAttempts: Maximum authorization retries per request. Defaults to 3.
|
||||
/// - maxScopeUpgradeAttempts: Maximum scope step-up attempts per resource+operation. Defaults to 2.
|
||||
public init(
|
||||
maxAuthorizationAttempts: Int = 3,
|
||||
maxScopeUpgradeAttempts: Int = 2
|
||||
) {
|
||||
self.maxAuthorizationAttempts = max(1, maxAuthorizationAttempts)
|
||||
self.maxScopeUpgradeAttempts = max(1, maxScopeUpgradeAttempts)
|
||||
}
|
||||
|
||||
public static let `default` = RetryPolicy()
|
||||
}
|
||||
|
||||
// MARK: - Endpoint Overrides
|
||||
|
||||
/// Optional endpoint overrides for discovery.
|
||||
public struct EndpointOverrides: Sendable {
|
||||
/// Optional override for the protected resource metadata URL.
|
||||
public let protectedResourceMetadataURL: URL?
|
||||
|
||||
/// Optional override for the authorization server issuer URL.
|
||||
public let authorizationServerURL: URL?
|
||||
|
||||
/// Optional override for the token endpoint.
|
||||
public let tokenEndpoint: URL?
|
||||
|
||||
/// Optional override for the resource indicator used in token requests.
|
||||
public let resource: URL?
|
||||
|
||||
public init(
|
||||
protectedResourceMetadataURL: URL? = nil,
|
||||
authorizationServerURL: URL? = nil,
|
||||
tokenEndpoint: URL? = nil,
|
||||
resource: URL? = nil
|
||||
) {
|
||||
self.protectedResourceMetadataURL = protectedResourceMetadataURL
|
||||
self.authorizationServerURL = authorizationServerURL
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.resource = resource
|
||||
}
|
||||
|
||||
public static let none = EndpointOverrides()
|
||||
}
|
||||
|
||||
// MARK: - Access Token Provider
|
||||
|
||||
/// Context supplied to ``AccessTokenProvider`` after SDK discovery is complete.
|
||||
public struct AccessTokenProviderContext: Sendable {
|
||||
/// HTTP status code that triggered authorization handling (typically 401 or 403).
|
||||
public let statusCode: Int
|
||||
/// Target MCP endpoint URL.
|
||||
public let endpoint: URL
|
||||
/// Canonical RFC8707 resource URI for token audience binding.
|
||||
public let resource: URL
|
||||
/// Selected authorization server issuer URL, if discovered.
|
||||
public let authorizationServer: URL?
|
||||
/// Authorization endpoint from AS metadata, if available.
|
||||
public let authorizationEndpoint: URL?
|
||||
/// Token endpoint from AS metadata (or configuration override), if available.
|
||||
public let tokenEndpoint: URL?
|
||||
/// Dynamic registration endpoint from AS metadata, if available.
|
||||
public let registrationEndpoint: URL?
|
||||
/// `scope` value from the latest challenge header, when present.
|
||||
public let challengedScope: String?
|
||||
/// `scopes_supported` from protected resource metadata, when present.
|
||||
public let scopesSupported: [String]?
|
||||
/// Scope set selected by SDK for the pending authorization attempt.
|
||||
public let requestedScopes: Set<String>?
|
||||
|
||||
public init(
|
||||
statusCode: Int,
|
||||
endpoint: URL,
|
||||
resource: URL,
|
||||
authorizationServer: URL?,
|
||||
authorizationEndpoint: URL?,
|
||||
tokenEndpoint: URL?,
|
||||
registrationEndpoint: URL?,
|
||||
challengedScope: String?,
|
||||
scopesSupported: [String]?,
|
||||
requestedScopes: Set<String>?
|
||||
) {
|
||||
self.statusCode = statusCode
|
||||
self.endpoint = endpoint
|
||||
self.resource = resource
|
||||
self.authorizationServer = authorizationServer
|
||||
self.authorizationEndpoint = authorizationEndpoint
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.registrationEndpoint = registrationEndpoint
|
||||
self.challengedScope = challengedScope
|
||||
self.scopesSupported = scopesSupported
|
||||
self.requestedScopes = requestedScopes
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional provider for externally acquired access tokens.
|
||||
public typealias AccessTokenProvider = @Sendable (
|
||||
_ context: AccessTokenProviderContext,
|
||||
_ session: URLSession
|
||||
) async throws -> String?
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// The grant type used for token acquisition.
|
||||
public let grantType: GrantType
|
||||
|
||||
/// The configured token endpoint authentication method.
|
||||
public var authentication: TokenEndpointAuthentication
|
||||
|
||||
/// Controls retry behavior for authorization challenges.
|
||||
public let retryPolicy: RetryPolicy
|
||||
|
||||
/// Optional endpoint overrides for discovery.
|
||||
public let endpointOverrides: EndpointOverrides
|
||||
|
||||
/// Package-scoped compatibility option for local environments.
|
||||
package var allowLoopbackHTTPAuthorizationServerEndpoints: Bool
|
||||
|
||||
/// Redirect URI used for authorization requests.
|
||||
public let authorizationRedirectURI: URL
|
||||
|
||||
/// Additional form fields to include in token requests.
|
||||
public let additionalTokenRequestParameters: [String: String]
|
||||
|
||||
/// The `client_name` sent during dynamic client registration (RFC 7591).
|
||||
/// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
|
||||
public let clientName: String
|
||||
|
||||
/// Optional provider for externally acquired access tokens.
|
||||
public let accessTokenProvider: AccessTokenProvider?
|
||||
|
||||
/// Optional delegate for browser-based authorization code flows.
|
||||
public let authorizationDelegate: (any OAuthAuthorizationDelegate)?
|
||||
|
||||
/// How many seconds before token expiry ``OAuthAuthorizer`` proactively refreshes a token
|
||||
/// when `prepareAuthorization(for:session:)` is called.
|
||||
///
|
||||
/// Set to `0` to disable proactive refresh. Defaults to 60 seconds.
|
||||
/// Must be greater than ``OAuthTokenExpirySkew/defaultSeconds`` (30 s) to have any effect,
|
||||
/// since tokens within the default skew window are already treated as expired.
|
||||
public let proactiveRefreshWindowSeconds: TimeInterval
|
||||
|
||||
/// Creates an OAuth configuration.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - grantType: The OAuth 2.1 grant type. Defaults to `.clientCredentials`.
|
||||
/// - authentication: How the client authenticates to the token endpoint. **Required.**
|
||||
/// - retryPolicy: Controls how many retries are allowed for authorization challenges.
|
||||
/// - endpointOverrides: Optional URL overrides that bypass automatic discovery.
|
||||
/// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
|
||||
/// Defaults to a random loopback URI (`http://127.0.0.1:<port>/callback`).
|
||||
/// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
|
||||
/// - additionalTokenRequestParameters: Extra form fields appended to every token request.
|
||||
/// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
|
||||
/// to supply an externally acquired token (e.g., from a system credential store).
|
||||
/// - authorizationDelegate: Optional delegate that presents the authorization URL to the
|
||||
/// user for interactive `authorization_code` flows.
|
||||
/// - proactiveRefreshWindowSeconds: Seconds before expiry at which a token is proactively
|
||||
/// refreshed. Defaults to 60. Set to 0 to disable proactive refresh.
|
||||
public init(
|
||||
grantType: GrantType = .clientCredentials,
|
||||
authentication: TokenEndpointAuthentication,
|
||||
retryPolicy: RetryPolicy = .default,
|
||||
endpointOverrides: EndpointOverrides = .none,
|
||||
authorizationRedirectURI: URL? = nil,
|
||||
clientName: String = "mcp-swift-sdk",
|
||||
additionalTokenRequestParameters: [String: String] = [:],
|
||||
accessTokenProvider: AccessTokenProvider? = nil,
|
||||
authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
|
||||
proactiveRefreshWindowSeconds: TimeInterval = 60
|
||||
) {
|
||||
self.grantType = grantType
|
||||
self.authentication = authentication
|
||||
self.retryPolicy = retryPolicy
|
||||
self.endpointOverrides = endpointOverrides
|
||||
self.allowLoopbackHTTPAuthorizationServerEndpoints = false
|
||||
self.authorizationRedirectURI =
|
||||
authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
|
||||
self.clientName = clientName
|
||||
self.additionalTokenRequestParameters = additionalTokenRequestParameters
|
||||
self.accessTokenProvider = accessTokenProvider
|
||||
self.authorizationDelegate = authorizationDelegate
|
||||
self.proactiveRefreshWindowSeconds = proactiveRefreshWindowSeconds
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthConfiguration.TokenEndpointAuthentication {
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.clientSecretBasic(let li, let ls), .clientSecretBasic(let ri, let rs)):
|
||||
return li == ri && ls == rs
|
||||
case (.clientSecretPost(let li, let ls), .clientSecretPost(let ri, let rs)):
|
||||
return li == ri && ls == rs
|
||||
case (.none(let li), .none(let ri)):
|
||||
return li == ri
|
||||
case (.privateKeyJWT(let li, _), .privateKeyJWT(let ri, _)):
|
||||
return li == ri
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate that handles user-facing authorization steps for the authorization_code flow.
|
||||
public protocol OAuthAuthorizationDelegate: Sendable {
|
||||
/// Presents the authorization URL to the user and returns the redirect URL containing
|
||||
/// the authorization code.
|
||||
func presentAuthorizationURL(_ url: URL) async throws -> URL
|
||||
}
|
||||
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
139
Sources/MCP/Base/Authorization/OAuthConstants.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - OAuth Grant Types
|
||||
|
||||
enum OAuthGrantTypeValue {
|
||||
static let clientCredentials = "client_credentials"
|
||||
static let authorizationCode = "authorization_code"
|
||||
static let refreshToken = "refresh_token"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Parameter Names
|
||||
|
||||
enum OAuthParameterName {
|
||||
static let grantType = "grant_type"
|
||||
static let resource = "resource"
|
||||
static let scope = "scope"
|
||||
static let code = "code"
|
||||
static let codeVerifier = "code_verifier"
|
||||
static let codeChallenge = "code_challenge"
|
||||
static let codeChallengeMethod = "code_challenge_method"
|
||||
static let redirectURI = "redirect_uri"
|
||||
static let responseType = "response_type"
|
||||
static let clientID = "client_id"
|
||||
static let clientSecret = "client_secret"
|
||||
static let clientAssertion = "client_assertion"
|
||||
static let clientAssertionType = "client_assertion_type"
|
||||
static let refreshToken = "refresh_token"
|
||||
static let state = "state"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Well-Known Paths
|
||||
|
||||
enum OAuthWellKnownPath {
|
||||
static let protectedResource = "/.well-known/oauth-protected-resource"
|
||||
static let authorizationServer = "/.well-known/oauth-authorization-server"
|
||||
static let openIDConfiguration = "/.well-known/openid-configuration"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Token Type
|
||||
|
||||
enum OAuthTokenType {
|
||||
static let bearer = "Bearer"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Code Challenge Method
|
||||
|
||||
enum OAuthCodeChallengeMethod {
|
||||
static let s256 = "S256"
|
||||
}
|
||||
|
||||
// MARK: - OAuth Token Endpoint Auth Method
|
||||
|
||||
enum OAuthTokenEndpointAuthMethod {
|
||||
static let clientSecretBasic = "client_secret_basic"
|
||||
static let clientSecretPost = "client_secret_post"
|
||||
static let none = "none"
|
||||
static let privateKeyJWT = "private_key_jwt"
|
||||
}
|
||||
|
||||
// MARK: - URL Scheme
|
||||
|
||||
enum OAuthURLScheme {
|
||||
static let http = "http"
|
||||
static let https = "https"
|
||||
}
|
||||
|
||||
// MARK: - Default Ports
|
||||
|
||||
enum OAuthDefaultPort {
|
||||
static let http = 80
|
||||
static let https = 443
|
||||
}
|
||||
|
||||
// MARK: - Loopback Hosts
|
||||
|
||||
enum OAuthLoopbackHost {
|
||||
static let localhost = "localhost"
|
||||
static let ipv4 = "127.0.0.1"
|
||||
static let ipv6 = "::1"
|
||||
|
||||
static func isLoopback(_ host: String) -> Bool {
|
||||
host == localhost || host == ipv4 || host == ipv6
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token Expiry Skew
|
||||
|
||||
/// Clock skew tolerance applied when checking token expiry.
|
||||
///
|
||||
/// ``OAuthAccessToken/isExpired(now:skewSeconds:)`` treats a token as expired
|
||||
/// when `now + skewSeconds >= expiresAt`, giving the client a safety margin to
|
||||
/// refresh tokens before they actually expire on the server.
|
||||
public enum OAuthTokenExpirySkew {
|
||||
/// Default clock skew buffer: 30 seconds.
|
||||
public static let defaultSeconds: TimeInterval = 30
|
||||
}
|
||||
|
||||
// MARK: - JWT Claim Names
|
||||
|
||||
enum JWTClaimName {
|
||||
static let algorithm = "alg"
|
||||
static let type = "typ"
|
||||
static let typeValue = "JWT"
|
||||
static let issuer = "iss"
|
||||
static let subject = "sub"
|
||||
static let audience = "aud"
|
||||
static let issuedAt = "iat"
|
||||
static let expiration = "exp"
|
||||
static let jwtID = "jti"
|
||||
}
|
||||
|
||||
// MARK: - Client Assertion Type
|
||||
|
||||
enum OAuthClientAssertionType {
|
||||
static let jwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
}
|
||||
|
||||
// MARK: - HTTPHeaderName Extensions
|
||||
|
||||
extension HTTPHeaderName {
|
||||
static let location = "Location"
|
||||
}
|
||||
|
||||
// MARK: - ContentType Extensions
|
||||
|
||||
extension ContentType {
|
||||
static let formURLEncoded = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
// MARK: - Data Base64URL Extension
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
361
Sources/MCP/Base/Authorization/OAuthDiscovery.swift
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - Scope Selection Protocol
|
||||
|
||||
/// Determines which OAuth scopes to request during token acquisition.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to translate raw scope strings from
|
||||
/// `WWW-Authenticate` challenges and Protected Resource Metadata into the
|
||||
/// scope set passed to the token endpoint.
|
||||
///
|
||||
/// Override the default ``DefaultOAuthScopeSelector`` to apply custom scope
|
||||
/// filtering or transformation logic.
|
||||
public protocol OAuthScopeSelecting {
|
||||
/// Selects the scope set to request for a token.
|
||||
///
|
||||
/// Priority order (highest first):
|
||||
/// 1. `challengeScope` — the `scope` parameter from the `WWW-Authenticate` header.
|
||||
/// 2. `scopesSupported` — the `scopes_supported` array from Protected Resource Metadata.
|
||||
/// 3. `nil` — no scope restriction.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - challengeScope: Space-separated scope string from the Bearer challenge, or `nil`.
|
||||
/// - scopesSupported: Array of supported scopes from the resource metadata, or `nil`.
|
||||
/// - Returns: The set of scopes to request, or `nil` to omit the `scope` parameter entirely.
|
||||
func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>?
|
||||
|
||||
/// Parses a space-separated OAuth scope string into individual scope tokens.
|
||||
/// - Parameter scope: A scope string such as `"read write"`.
|
||||
/// - Returns: A set of individual scope strings with whitespace-only tokens removed.
|
||||
func parseScopeString(_ scope: String) -> Set<String>
|
||||
|
||||
/// Serializes a set of scopes into a space-separated string suitable for the `scope` parameter.
|
||||
/// - Parameter scopes: The scope set to serialize.
|
||||
/// - Returns: A sorted, space-separated string, or `nil` if the set is empty.
|
||||
func serialize(_ scopes: Set<String>) -> String?
|
||||
}
|
||||
|
||||
// MARK: - Default Scope Selector
|
||||
|
||||
/// Default ``OAuthScopeSelecting`` implementation.
|
||||
///
|
||||
/// Selects scopes in priority order: challenge scope > `scopes_supported` > `nil`.
|
||||
/// Serializes scopes sorted alphabetically to produce deterministic `scope` parameters.
|
||||
public struct DefaultOAuthScopeSelector: OAuthScopeSelecting {
|
||||
public init() {}
|
||||
|
||||
public func selectScopes(challengeScope: String?, scopesSupported: [String]?) -> Set<String>? {
|
||||
if let challengeScope {
|
||||
let parsed = parseScopeString(challengeScope)
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
if let scopesSupported {
|
||||
let parsed = Set(scopesSupported.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
|
||||
return parsed.isEmpty ? nil : parsed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func parseScopeString(_ scope: String) -> Set<String> {
|
||||
Set(
|
||||
scope
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
)
|
||||
}
|
||||
|
||||
public func serialize(_ scopes: Set<String>) -> String? {
|
||||
guard !scopes.isEmpty else { return nil }
|
||||
return scopes.sorted().joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Discovery Protocol
|
||||
|
||||
/// Builds discovery URLs and normalizes resource identifiers for OAuth metadata discovery.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to construct the candidate URL list for both
|
||||
/// Protected Resource Metadata (RFC 9728) and Authorization Server Metadata (RFC 8414 / OIDC)
|
||||
/// discovery, and to normalize resource URIs for RFC 8707 audience binding.
|
||||
///
|
||||
/// Override the default ``DefaultOAuthMetadataDiscovery`` to customise discovery URL
|
||||
/// construction or resource-matching logic.
|
||||
public protocol OAuthMetadataDiscovering: Sendable {
|
||||
/// Returns candidate URLs for Protected Resource Metadata, ordered by priority.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` tries each URL in order and uses the first successful response.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: An ordered list of discovery URLs (typically `/.well-known/oauth-protected-resource`
|
||||
/// variants).
|
||||
func protectedResourceMetadataURLs(for endpoint: URL) -> [URL]
|
||||
|
||||
/// Returns candidate URLs for Authorization Server Metadata, ordered by priority.
|
||||
///
|
||||
/// Covers RFC 8414 (`/.well-known/oauth-authorization-server`) and
|
||||
/// OIDC Discovery 1.0 (`/.well-known/openid-configuration`), including
|
||||
/// path-inserted variants for issuers with non-root paths.
|
||||
///
|
||||
/// - Parameter issuer: The authorization server issuer URL.
|
||||
/// - Returns: An ordered list of metadata discovery URLs.
|
||||
func authorizationServerMetadataURLs(for issuer: URL) -> [URL]
|
||||
|
||||
/// Derives the canonical RFC 8707 resource URI from an endpoint URL.
|
||||
///
|
||||
/// The canonical form strips the query string, fragment, and trailing slash
|
||||
/// while preserving the scheme, host, port, and path.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: The canonical resource URI.
|
||||
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL does not
|
||||
/// satisfy the HTTPS-or-loopback-HTTP requirement.
|
||||
func canonicalResourceURI(from endpoint: URL) throws -> URL
|
||||
|
||||
/// Derives a fallback authorization server issuer URL from an endpoint URL.
|
||||
///
|
||||
/// Used when no authorization server is listed in Protected Resource Metadata.
|
||||
/// Typically returns the scheme+host+port of the endpoint with an empty path.
|
||||
///
|
||||
/// - Parameter endpoint: The MCP endpoint URL.
|
||||
/// - Returns: A candidate issuer URL.
|
||||
/// - Throws: ``OAuthAuthorizationError/invalidResourceURI(_:)`` if the URL is invalid.
|
||||
func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL
|
||||
|
||||
/// Returns `true` if `resource` is a prefix of `endpoint` in the URL hierarchy.
|
||||
///
|
||||
/// A resource matches an endpoint when the two share the same scheme, host, and port,
|
||||
/// and the endpoint path starts with the resource path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - resource: The canonical resource URI from Protected Resource Metadata.
|
||||
/// - endpoint: The canonical endpoint URI being requested.
|
||||
/// - Returns: `true` if the endpoint falls within the resource's scope.
|
||||
func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool
|
||||
}
|
||||
|
||||
// MARK: - Default Metadata Discovery
|
||||
|
||||
/// Default ``OAuthMetadataDiscovering`` implementation following RFC 9728 and RFC 8414.
|
||||
///
|
||||
/// - Builds `/.well-known/oauth-protected-resource` URLs with and without the endpoint path suffix.
|
||||
/// - Builds RFC 8414 and OIDC discovery URLs with path-inserted and path-appended variants.
|
||||
/// - Canonicalises resource URIs by stripping query, fragment, and trailing slash.
|
||||
/// - Matches resources using scheme/host/port equality and path prefix rules.
|
||||
public struct DefaultOAuthMetadataDiscovery: OAuthMetadataDiscovering {
|
||||
public init() {}
|
||||
|
||||
public func protectedResourceMetadataURLs(for endpoint: URL) -> [URL] {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
let endpointPath = components.path
|
||||
let normalizedPath = endpointPath == "/" ? "" : endpointPath
|
||||
|
||||
var urls: [URL] = []
|
||||
|
||||
var pathSpecific = components
|
||||
pathSpecific.path = "\(OAuthWellKnownPath.protectedResource)\(normalizedPath)"
|
||||
if let url = pathSpecific.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var root = components
|
||||
root.path = OAuthWellKnownPath.protectedResource
|
||||
if let url = root.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
public func authorizationServerMetadataURLs(for issuer: URL) -> [URL] {
|
||||
guard var components = URLComponents(url: issuer, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
let path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let hasPath = !path.isEmpty
|
||||
|
||||
var urls: [URL] = []
|
||||
|
||||
if hasPath {
|
||||
var oauthInserted = components
|
||||
oauthInserted.path = "\(OAuthWellKnownPath.authorizationServer)/\(path)"
|
||||
if let url = oauthInserted.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidcInserted = components
|
||||
oidcInserted.path = "\(OAuthWellKnownPath.openIDConfiguration)/\(path)"
|
||||
if let url = oidcInserted.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidcAppended = components
|
||||
oidcAppended.path = "/\(path)\(OAuthWellKnownPath.openIDConfiguration)"
|
||||
if let url = oidcAppended.url {
|
||||
urls.append(url)
|
||||
}
|
||||
} else {
|
||||
var oauth = components
|
||||
oauth.path = OAuthWellKnownPath.authorizationServer
|
||||
if let url = oauth.url {
|
||||
urls.append(url)
|
||||
}
|
||||
|
||||
var oidc = components
|
||||
oidc.path = OAuthWellKnownPath.openIDConfiguration
|
||||
if let url = oidc.url {
|
||||
urls.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
public func canonicalResourceURI(from endpoint: URL) throws -> URL {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Resource URI must use https or loopback http"
|
||||
)
|
||||
}
|
||||
|
||||
if components.fragment != nil {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Resource URI must not contain a fragment")
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
if components.path == "/" {
|
||||
components.path = ""
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Failed to normalize resource URI")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
public func authorizationServerFallbackIssuer(from endpoint: URL) throws -> URL {
|
||||
guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
Self.isSecureOAuthScheme(scheme: scheme, host: host)
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Resource URI must use https or loopback http"
|
||||
)
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.path = ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI("Failed to derive issuer URI")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
public func protectedResourceMatches(resource: URL, endpoint: URL) -> Bool {
|
||||
guard let resourceComponents = URLComponents(
|
||||
url: resource,
|
||||
resolvingAgainstBaseURL: false
|
||||
),
|
||||
let endpointComponents = URLComponents(
|
||||
url: endpoint,
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let resourceScheme = resourceComponents.scheme?.lowercased()
|
||||
let endpointScheme = endpointComponents.scheme?.lowercased()
|
||||
let resourceHost = resourceComponents.host?.lowercased()
|
||||
let endpointHost = endpointComponents.host?.lowercased()
|
||||
let resourcePort = resourceComponents.port ?? Self.defaultPort(for: resourceScheme)
|
||||
let endpointPort = endpointComponents.port ?? Self.defaultPort(for: endpointScheme)
|
||||
|
||||
guard resourceScheme == endpointScheme,
|
||||
resourceHost == endpointHost,
|
||||
resourcePort == endpointPort
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let resourcePath = Self.normalizedResourcePath(resourceComponents.path)
|
||||
let endpointPath = Self.normalizedResourcePath(endpointComponents.path)
|
||||
if resourcePath.isEmpty {
|
||||
return true
|
||||
}
|
||||
if endpointPath == resourcePath {
|
||||
return true
|
||||
}
|
||||
return endpointPath.hasPrefix(resourcePath + "/")
|
||||
}
|
||||
|
||||
private static func normalizedResourcePath(_ rawPath: String) -> String {
|
||||
if rawPath.isEmpty || rawPath == "/" {
|
||||
return ""
|
||||
}
|
||||
if rawPath.count > 1 && rawPath.hasSuffix("/") {
|
||||
return String(rawPath.dropLast())
|
||||
}
|
||||
return rawPath
|
||||
}
|
||||
|
||||
private static func defaultPort(for scheme: String?) -> Int? {
|
||||
switch scheme?.lowercased() {
|
||||
case OAuthURLScheme.http:
|
||||
return OAuthDefaultPort.http
|
||||
case OAuthURLScheme.https:
|
||||
return OAuthDefaultPort.https
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func isSecureOAuthScheme(scheme: String, host: String) -> Bool {
|
||||
if scheme == OAuthURLScheme.https {
|
||||
return true
|
||||
}
|
||||
if scheme == OAuthURLScheme.http {
|
||||
return OAuthLoopbackHost.isLoopback(host)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
118
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
118
Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for fetching OAuth discovery metadata.
|
||||
protocol OAuthDiscoveryFetching: Sendable {
|
||||
var metadataDiscovery: any OAuthMetadataDiscovering { get }
|
||||
func fetchProtectedResourceMetadata(candidates: [URL], session: URLSession) async throws -> OAuthProtectedResourceMetadata
|
||||
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
|
||||
}
|
||||
|
||||
/// Stateless OAuth metadata fetcher.
|
||||
///
|
||||
/// Fetches Protected Resource Metadata (RFC 9728) and Authorization Server Metadata
|
||||
/// (RFC 8414 / OIDC Discovery 1.0) from ordered candidate URL lists.
|
||||
/// Cache management is the caller's responsibility.
|
||||
struct OAuthDiscoveryClient: Sendable {
|
||||
let metadataDiscovery: any OAuthMetadataDiscovering
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(
|
||||
metadataDiscovery: any OAuthMetadataDiscovering,
|
||||
urlValidator: OAuthURLValidator
|
||||
) {
|
||||
self.metadataDiscovery = metadataDiscovery
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
|
||||
func fetchProtectedResourceMetadata(
|
||||
candidates: [URL],
|
||||
session: URLSession
|
||||
) async throws -> OAuthProtectedResourceMetadata {
|
||||
let decoder = JSONDecoder()
|
||||
for url in candidates {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let metadata = try decoder.decode(OAuthProtectedResourceMetadata.self, from: data)
|
||||
guard !metadata.authorizationServers.isEmpty else { continue }
|
||||
return metadata
|
||||
} catch let error as OAuthAuthorizationError {
|
||||
throw error
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
||||
}
|
||||
|
||||
/// Fetches Authorization Server Metadata from the first candidate that returns a valid response.
|
||||
func fetchAuthorizationServerMetadata(
|
||||
candidates: [URL],
|
||||
session: URLSession
|
||||
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
||||
let decoder = JSONDecoder()
|
||||
for candidateServer in candidates {
|
||||
guard (try? urlValidator.validateAuthorizationServer(
|
||||
candidateServer, context: "Authorization server issuer")) != nil
|
||||
else {
|
||||
continue
|
||||
}
|
||||
if let host = URLComponents(url: candidateServer, resolvingAgainstBaseURL: false)?
|
||||
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
||||
{
|
||||
continue
|
||||
}
|
||||
|
||||
for metadataURL in metadataDiscovery.authorizationServerMetadataURLs(
|
||||
for: candidateServer)
|
||||
{
|
||||
var request = URLRequest(url: metadataURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
let asMetadata = try decoder.decode(
|
||||
OAuthAuthorizationServerMetadata.self, from: data)
|
||||
|
||||
// RFC 8414 §3: issuer field must match the candidate server URL.
|
||||
// Absent issuer is tolerated (some servers omit it).
|
||||
if let metadataIssuer = asMetadata.issuer {
|
||||
guard metadataIssuer.absoluteString.lowercased()
|
||||
== candidateServer.absoluteString.lowercased()
|
||||
else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return (server: candidateServer, metadata: asMetadata)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthDiscoveryClient: OAuthDiscoveryFetching {}
|
||||
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
203
Sources/MCP/Base/Authorization/OAuthErrors.swift
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import Foundation
|
||||
|
||||
/// Errors thrown during OAuth 2.1 authorization by ``OAuthAuthorizer``.
|
||||
///
|
||||
/// These errors surface when the authorizer is unable to complete the authorization flow,
|
||||
/// either due to discovery failures, token exchange problems, security policy violations,
|
||||
/// or authorization code flow issues.
|
||||
public enum OAuthAuthorizationError: LocalizedError {
|
||||
/// No authorization server URL could be found in the Protected Resource Metadata.
|
||||
case missingAuthorizationServer
|
||||
|
||||
/// All Protected Resource Metadata discovery candidates returned errors or invalid documents.
|
||||
case metadataDiscoveryFailed
|
||||
|
||||
/// All Authorization Server Metadata discovery candidates (RFC 8414 / OIDC) returned errors.
|
||||
case authorizationServerMetadataDiscoveryFailed
|
||||
|
||||
/// The authorization server metadata does not include a `token_endpoint`.
|
||||
case tokenEndpointMissing
|
||||
|
||||
/// The token endpoint returned a non-2xx HTTP response.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - statusCode: HTTP status code from the token endpoint.
|
||||
/// - oauthError: The `error` field from the OAuth error response body, if present.
|
||||
case tokenRequestFailed(statusCode: Int, oauthError: String?)
|
||||
|
||||
/// The token response body is missing `access_token`, has an empty token, or specifies
|
||||
/// a non-Bearer token type.
|
||||
case tokenResponseInvalid
|
||||
|
||||
/// A URL that must be a valid resource identifier (RFC 8707) failed validation.
|
||||
case invalidResourceURI(String)
|
||||
|
||||
/// The configured client ID looks like a URL but is not a valid HTTPS URL with a path,
|
||||
/// which is required for Client ID Metadata Documents.
|
||||
case invalidClientIDMetadataURL(String)
|
||||
|
||||
/// The `resource` field in the Protected Resource Metadata does not match the requested endpoint.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The canonical URI derived from the endpoint.
|
||||
/// - actual: The canonical URI derived from the metadata's `resource` field.
|
||||
case protectedResourceMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization server does not support dynamic registration and no pre-registered
|
||||
/// credentials were supplied.
|
||||
case registrationInformationRequired
|
||||
|
||||
/// The authorization server does not support Client ID Metadata Documents and no dynamic
|
||||
/// registration endpoint is available.
|
||||
///
|
||||
/// - Parameter clientID: The client ID URL that was provided as a CIMD URL.
|
||||
case cimdNotSupported(clientID: String)
|
||||
|
||||
/// A URL received from a discovery response resolves to a private or reserved IP address,
|
||||
/// which is blocked to prevent SSRF attacks.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which URL was blocked.
|
||||
/// - url: The blocked URL string.
|
||||
case privateIPAddressBlocked(context: String, url: String)
|
||||
|
||||
/// An endpoint URL used during the OAuth flow does not satisfy the HTTPS-or-loopback requirement.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which endpoint failed (e.g., `"Token endpoint"`).
|
||||
/// - url: The offending URL string.
|
||||
case insecureOAuthEndpoint(context: String, url: String)
|
||||
|
||||
/// An authorization server endpoint does not satisfy the HTTPS-only requirement.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Human-readable label identifying which endpoint failed.
|
||||
/// - url: The offending URL string.
|
||||
case insecureAuthorizationServerEndpoint(context: String, url: String)
|
||||
|
||||
/// The redirect URI supplied for the `authorization_code` flow is not a valid HTTPS or
|
||||
/// loopback HTTP URI, or it contains a fragment.
|
||||
case invalidRedirectURI(String)
|
||||
|
||||
/// The authorization endpoint returned an HTTP error response during the authorization code flow.
|
||||
///
|
||||
/// - Parameter statusCode: HTTP status code from the authorization endpoint.
|
||||
case authorizationRequestFailed(statusCode: Int)
|
||||
|
||||
/// The authorization response did not include a `Location` redirect header.
|
||||
case authorizationResponseMissingRedirectLocation
|
||||
|
||||
/// The redirect URI in the authorization response does not match the expected redirect URI.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The redirect URI supplied in the authorization request.
|
||||
/// - actual: The redirect URI received in the authorization response.
|
||||
case authorizationResponseRedirectMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization response redirect URL is missing the `state` parameter.
|
||||
case authorizationResponseMissingState
|
||||
|
||||
/// The `state` in the authorization response does not match the one sent in the request.
|
||||
///
|
||||
/// This may indicate a CSRF attack.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The `state` value sent in the authorization request.
|
||||
/// - actual: The `state` value received in the authorization response.
|
||||
case authorizationResponseStateMismatch(expected: String, actual: String)
|
||||
|
||||
/// The authorization response redirect URL is missing the `code` parameter.
|
||||
case authorizationResponseMissingCode
|
||||
|
||||
/// The authorization server metadata does not include `code_challenge_methods_supported`,
|
||||
/// which is required for PKCE (RFC 7636).
|
||||
case pkceCodeChallengeMethodsMissing
|
||||
|
||||
/// The authorization server does not advertise `S256` in `code_challenge_methods_supported`.
|
||||
///
|
||||
/// The MCP specification mandates S256; plain PKCE is not accepted.
|
||||
///
|
||||
/// - Parameter advertisedMethods: The methods listed in the server metadata.
|
||||
case pkceS256NotSupported(advertisedMethods: [String])
|
||||
|
||||
/// PKCE S256 challenge generation is unavailable because CryptoKit is not present on this platform.
|
||||
case pkceS256Unavailable
|
||||
|
||||
/// The `issuer` field in the Authorization Server Metadata does not match the URL used to
|
||||
/// resolve the metadata document, as required by RFC 8414 §3.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expected: The issuer URL derived from the discovery candidate.
|
||||
/// - actual: The `issuer` field value found in the metadata document.
|
||||
case authorizationServerIssuerMismatch(expected: String, actual: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingAuthorizationServer:
|
||||
return "No authorization server was found in protected resource metadata"
|
||||
case .metadataDiscoveryFailed:
|
||||
return "Failed to discover protected resource metadata"
|
||||
case .authorizationServerMetadataDiscoveryFailed:
|
||||
return "Failed to discover authorization server metadata"
|
||||
case .tokenEndpointMissing:
|
||||
return "Authorization server metadata is missing token_endpoint"
|
||||
case .tokenRequestFailed(let statusCode, let oauthError):
|
||||
if let oauthError, !oauthError.isEmpty {
|
||||
return "Token request failed with status \(statusCode) (oauth_error: \(oauthError))"
|
||||
}
|
||||
return "Token request failed with status \(statusCode)"
|
||||
case .tokenResponseInvalid:
|
||||
return "Token response is invalid"
|
||||
case .invalidResourceURI(let detail):
|
||||
return "Invalid resource URI: \(detail)"
|
||||
case .invalidClientIDMetadataURL(let value):
|
||||
return
|
||||
"Client ID metadata document URL must use https and include a path: \(value)"
|
||||
case .protectedResourceMismatch(let expected, let actual):
|
||||
return
|
||||
"Protected resource metadata resource mismatch. Expected \(expected), got \(actual)"
|
||||
case .registrationInformationRequired:
|
||||
return
|
||||
"No supported client registration mechanism was available; provide pre-registered client credentials"
|
||||
case .cimdNotSupported(let clientID):
|
||||
return
|
||||
"Authorization server does not support Client ID Metadata Documents; configure pre-registered credentials or ensure the server advertises client_id_metadata_document_supported: \(clientID)"
|
||||
case .privateIPAddressBlocked(let context, let url):
|
||||
return
|
||||
"\(context) resolves to a private or reserved IP address which is blocked for SSRF protection: \(url)"
|
||||
case .insecureOAuthEndpoint(let context, let url):
|
||||
return "\(context) must use https or loopback http: \(url)"
|
||||
case .insecureAuthorizationServerEndpoint(let context, let url):
|
||||
return "\(context) must use https: \(url)"
|
||||
case .invalidRedirectURI(let url):
|
||||
return
|
||||
"Redirect URI must use https or loopback http and must not include fragments: \(url)"
|
||||
case .authorizationRequestFailed(let statusCode):
|
||||
return "Authorization request failed with status \(statusCode)"
|
||||
case .authorizationResponseMissingRedirectLocation:
|
||||
return "Authorization response is missing redirect location"
|
||||
case .authorizationResponseRedirectMismatch(let expected, let actual):
|
||||
return
|
||||
"Authorization response redirect URI mismatch. Expected \(expected), got \(actual)"
|
||||
case .authorizationResponseMissingState:
|
||||
return "Authorization response is missing state"
|
||||
case .authorizationResponseStateMismatch(let expected, let actual):
|
||||
return "Authorization response state mismatch. Expected \(expected), got \(actual)"
|
||||
case .authorizationResponseMissingCode:
|
||||
return "Authorization response is missing the authorization code"
|
||||
case .pkceCodeChallengeMethodsMissing:
|
||||
return
|
||||
"Authorization server metadata must include code_challenge_methods_supported for PKCE"
|
||||
case .pkceS256NotSupported(let advertisedMethods):
|
||||
let methods = advertisedMethods.joined(separator: ", ")
|
||||
return
|
||||
"Authorization server metadata must support PKCE S256 (advertised: \(methods))"
|
||||
case .pkceS256Unavailable:
|
||||
return
|
||||
"PKCE S256 code challenge generation is unavailable on this platform"
|
||||
case .authorizationServerIssuerMismatch(let expected, let actual):
|
||||
return
|
||||
"Authorization server issuer mismatch. Expected \(expected), got \(actual)"
|
||||
}
|
||||
}
|
||||
}
|
||||
247
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
247
Sources/MCP/Base/Authorization/OAuthModels.swift
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
struct HTTPAuthenticationChallengeError: Error {
|
||||
let statusCode: Int
|
||||
let headers: [String: String]
|
||||
}
|
||||
|
||||
/// Parsed representation of a `WWW-Authenticate: Bearer` challenge header.
|
||||
///
|
||||
/// Servers return this challenge in `401 Unauthorized` and `403 Forbidden` responses
|
||||
/// to indicate that a Bearer token is required or that the presented token lacks
|
||||
/// sufficient scope.
|
||||
///
|
||||
/// The ``OAuthWWWAuthenticateParsing`` protocol produces instances of this type.
|
||||
public struct OAuthBearerChallenge: Sendable {
|
||||
/// Raw key-value parameters extracted from the `Bearer` challenge.
|
||||
public let parameters: [String: String]
|
||||
|
||||
/// Creates a challenge from raw parsed parameters.
|
||||
/// - Parameter parameters: Key-value pairs from the `WWW-Authenticate` header.
|
||||
public init(parameters: [String: String]) {
|
||||
self.parameters = parameters
|
||||
}
|
||||
|
||||
/// The `resource_metadata` parameter, parsed as a URL.
|
||||
///
|
||||
/// Points to the server's RFC 9728 Protected Resource Metadata document.
|
||||
/// When present, ``OAuthAuthorizer`` uses this URL as the highest-priority
|
||||
/// discovery candidate.
|
||||
public var resourceMetadataURL: URL? {
|
||||
guard let value = parameters["resource_metadata"] else { return nil }
|
||||
return URL(string: value)
|
||||
}
|
||||
|
||||
/// The `scope` parameter from the Bearer challenge.
|
||||
///
|
||||
/// Specifies the scopes required or recommended for this resource.
|
||||
/// ``OAuthAuthorizer`` uses this value as the highest-priority scope hint.
|
||||
public var scope: String? {
|
||||
parameters["scope"]
|
||||
}
|
||||
|
||||
/// The `error` parameter from the Bearer challenge (e.g., `"invalid_token"`, `"insufficient_scope"`).
|
||||
public var error: String? {
|
||||
parameters["error"]
|
||||
}
|
||||
|
||||
/// The `error_description` parameter from the Bearer challenge.
|
||||
public var errorDescription: String? {
|
||||
parameters["error_description"]
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC9728 OAuth Protected Resource metadata (client-side, decode-only).
|
||||
struct OAuthProtectedResourceMetadata: Decodable, Sendable, Equatable {
|
||||
let resource: String?
|
||||
let authorizationServers: [URL]
|
||||
let scopesSupported: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case resource
|
||||
case authorizationServers = "authorization_servers"
|
||||
case scopesSupported = "scopes_supported"
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC8414/OIDC authorization server metadata.
|
||||
struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable {
|
||||
let issuer: URL?
|
||||
let authorizationEndpoint: URL?
|
||||
let tokenEndpoint: URL?
|
||||
let registrationEndpoint: URL?
|
||||
let codeChallengeMethodsSupported: [String]?
|
||||
let tokenEndpointAuthMethodsSupported: [String]?
|
||||
let clientIDMetadataDocumentSupported: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case issuer
|
||||
case authorizationEndpoint = "authorization_endpoint"
|
||||
case tokenEndpoint = "token_endpoint"
|
||||
case registrationEndpoint = "registration_endpoint"
|
||||
case codeChallengeMethodsSupported = "code_challenge_methods_supported"
|
||||
case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
|
||||
case clientIDMetadataDocumentSupported = "client_id_metadata_document_supported"
|
||||
}
|
||||
}
|
||||
|
||||
struct OAuthTokenResponse: Decodable, Sendable, Equatable {
|
||||
let accessToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int?
|
||||
let scope: String?
|
||||
let refreshToken: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case tokenType = "token_type"
|
||||
case expiresIn = "expires_in"
|
||||
case scope
|
||||
case refreshToken = "refresh_token"
|
||||
}
|
||||
}
|
||||
|
||||
struct OAuthTokenErrorResponse: Decodable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
/// An OAuth 2.1 access token and its associated metadata.
|
||||
///
|
||||
/// Stored by ``TokenStorage`` and produced by ``OAuthAuthorizer`` after a successful
|
||||
/// token request. Use ``isExpired(now:skewSeconds:)`` to check validity before use.
|
||||
public struct OAuthAccessToken: Sendable {
|
||||
/// The raw bearer token string for use in the `Authorization` header.
|
||||
public let value: String
|
||||
|
||||
/// The token type returned by the authorization server (should be `"Bearer"`).
|
||||
public let tokenType: String
|
||||
|
||||
/// The UTC date after which the token is considered expired, or `nil` if no expiry was specified.
|
||||
public let expiresAt: Date?
|
||||
|
||||
/// The set of OAuth scopes granted with this token.
|
||||
public let scopes: Set<String>
|
||||
|
||||
/// The issuer URL of the authorization server that issued this token.
|
||||
///
|
||||
/// Used to detect when the active authorization server changes between requests,
|
||||
/// triggering a token invalidation.
|
||||
public let authorizationServer: URL?
|
||||
|
||||
/// The refresh token, if the authorization server issued one alongside the access token.
|
||||
public let refreshToken: String?
|
||||
|
||||
/// Creates a new access token record.
|
||||
public init(
|
||||
value: String,
|
||||
tokenType: String,
|
||||
expiresAt: Date?,
|
||||
scopes: Set<String>,
|
||||
authorizationServer: URL?,
|
||||
refreshToken: String?
|
||||
) {
|
||||
self.value = value
|
||||
self.tokenType = tokenType
|
||||
self.expiresAt = expiresAt
|
||||
self.scopes = scopes
|
||||
self.authorizationServer = authorizationServer
|
||||
self.refreshToken = refreshToken
|
||||
}
|
||||
|
||||
/// Returns `true` if the token has expired or will expire within the skew window.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - now: The reference time to compare against. Defaults to `Date()`.
|
||||
/// - skewSeconds: Clock skew buffer in seconds. Defaults to ``OAuthTokenExpirySkew/defaultSeconds`` (30 s).
|
||||
/// Tokens are considered expired when `now + skewSeconds >= expiresAt`.
|
||||
/// - Returns: `false` if `expiresAt` is `nil` (no expiry).
|
||||
public func isExpired(now: Date = Date(), skewSeconds: TimeInterval = OAuthTokenExpirySkew.defaultSeconds) -> Bool {
|
||||
guard let expiresAt else { return false }
|
||||
return now.addingTimeInterval(skewSeconds) >= expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
/// Token introspection result passed from the caller's token validator to ``BearerTokenValidator``.
|
||||
///
|
||||
/// The validator uses this to enforce expiry and audience checks before allowing a request through.
|
||||
/// Produce an instance in your ``BearerTokenValidator/TokenValidator`` closure after verifying
|
||||
/// the token's signature and extracting its claims.
|
||||
public struct BearerTokenInfo: Sendable, Equatable {
|
||||
/// Audience values from the token (`aud` JWT claim or introspection response).
|
||||
///
|
||||
/// `nil` indicates an opaque token whose audience cannot be inspected;
|
||||
/// ``BearerTokenValidator`` skips the audience check in that case.
|
||||
public let audience: [String]?
|
||||
|
||||
/// Scopes granted by the token.
|
||||
public let scopes: Set<String>?
|
||||
|
||||
/// UTC date after which the token is considered expired.
|
||||
///
|
||||
/// `nil` means no expiry information is available and the expiry check is skipped.
|
||||
public let expiresAt: Date?
|
||||
|
||||
public init(
|
||||
audience: [String]? = nil,
|
||||
scopes: Set<String>? = nil,
|
||||
expiresAt: Date? = nil
|
||||
) {
|
||||
self.audience = audience
|
||||
self.scopes = scopes
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
struct OAuthClientRegistrationResponse: Decodable {
|
||||
let clientID: String
|
||||
let clientSecret: String?
|
||||
let tokenEndpointAuthMethod: String?
|
||||
/// Unix timestamp after which `clientSecret` is no longer valid, per RFC 7591 §3.2.
|
||||
/// A value of `0` means the secret does not expire.
|
||||
let clientSecretExpiresAt: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case clientID = "client_id"
|
||||
case clientSecret = "client_secret"
|
||||
case tokenEndpointAuthMethod = "token_endpoint_auth_method"
|
||||
case clientSecretExpiresAt = "client_secret_expires_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side encodable RFC 9728 Protected Resource Metadata.
|
||||
///
|
||||
/// Use this type to construct the metadata document that MCP servers **MUST** serve
|
||||
/// at `/.well-known/oauth-protected-resource` per the MCP authorization specification.
|
||||
///
|
||||
/// Pair with ``ProtectedResourceMetadataValidator`` to automatically serve this
|
||||
/// document in the server's validation pipeline.
|
||||
public struct OAuthProtectedResourceServerMetadata: Codable, Sendable {
|
||||
/// The canonical resource identifier (RFC 8707).
|
||||
public let resource: String
|
||||
|
||||
/// One or more authorization server URLs that protect this resource.
|
||||
public let authorizationServers: [URL]
|
||||
|
||||
/// The scopes supported by this resource server.
|
||||
public let scopesSupported: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case resource
|
||||
case authorizationServers = "authorization_servers"
|
||||
case scopesSupported = "scopes_supported"
|
||||
}
|
||||
|
||||
public init(
|
||||
resource: String,
|
||||
authorizationServers: [URL],
|
||||
scopesSupported: [String]? = nil
|
||||
) {
|
||||
self.resource = resource
|
||||
self.authorizationServers = authorizationServers
|
||||
self.scopesSupported = scopesSupported
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for making OAuth token endpoint requests.
|
||||
protocol OAuthTokenRequesting: Sendable {
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse
|
||||
}
|
||||
|
||||
/// Stateless OAuth token endpoint HTTP client.
|
||||
///
|
||||
/// Handles the low-level HTTP mechanics of making token requests.
|
||||
struct OAuthTokenEndpointClient: Sendable {
|
||||
let urlValidator: OAuthURLValidator
|
||||
|
||||
init(urlValidator: OAuthURLValidator) {
|
||||
self.urlValidator = urlValidator
|
||||
}
|
||||
|
||||
/// Makes a token request to the given endpoint.
|
||||
func request(
|
||||
parameters: inout [String: String],
|
||||
endpoint: URL,
|
||||
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
||||
session: URLSession
|
||||
) async throws -> OAuthTokenResponse {
|
||||
var urlRequest = URLRequest(url: endpoint)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
try await authentication.apply(
|
||||
to: &urlRequest,
|
||||
bodyParameters: ¶meters,
|
||||
tokenEndpoint: endpoint
|
||||
)
|
||||
urlRequest.httpBody = encodeForm(parameters)
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
let oauthError =
|
||||
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
||||
throw OAuthAuthorizationError.tokenRequestFailed(
|
||||
statusCode: httpResponse.statusCode,
|
||||
oauthError: oauthError
|
||||
)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||||
guard !decoded.accessToken.isEmpty else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !tokenType.isEmpty,
|
||||
tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
|
||||
else {
|
||||
throw OAuthAuthorizationError.tokenResponseInvalid
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func encodeForm(_ params: [String: String]) -> Data {
|
||||
let body = params
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
|
||||
.joined(separator: "&")
|
||||
return Data(body.utf8)
|
||||
}
|
||||
|
||||
private func percentEncode(_ string: String) -> String {
|
||||
let allowed = CharacterSet(
|
||||
charactersIn:
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthTokenEndpointClient: OAuthTokenRequesting {}
|
||||
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
135
Sources/MCP/Base/Authorization/OAuthURLValidator.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
/// Internal protocol for URL security validation in OAuth flows.
|
||||
protocol OAuthURLValidating: Sendable {
|
||||
func validateHTTPSOrLoopback(_ url: URL, context: String) throws
|
||||
func validateAuthorizationServer(_ url: URL, context: String) throws
|
||||
func validateRedirectURI(_ url: URL) throws
|
||||
func isPrivateIPHost(_ host: String) -> Bool
|
||||
}
|
||||
|
||||
/// URL security rules for OAuth endpoints.
|
||||
///
|
||||
/// Validates that URLs used in the OAuth flow satisfy HTTPS requirements and SSRF protections.
|
||||
/// Configured once and shared across the discovery, token, and registration components.
|
||||
public struct OAuthURLValidator: Sendable {
|
||||
|
||||
/// When `true`, loopback HTTP URLs are accepted for authorization server endpoints.
|
||||
///
|
||||
/// This is a package-level compatibility option for local test environments.
|
||||
public let allowLoopbackHTTPForAuthorizationServer: Bool
|
||||
|
||||
public init(allowLoopbackHTTPForAuthorizationServer: Bool = false) {
|
||||
self.allowLoopbackHTTPForAuthorizationServer = allowLoopbackHTTPForAuthorizationServer
|
||||
}
|
||||
|
||||
/// Validates that the URL uses HTTPS or loopback HTTP, and has no fragment.
|
||||
///
|
||||
/// Used for MCP endpoints and protected resource metadata URLs.
|
||||
public func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https
|
||||
|| (scheme == OAuthURLScheme.http && OAuthLoopbackHost.isLoopback(host))
|
||||
else {
|
||||
throw OAuthAuthorizationError.insecureOAuthEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the URL is an HTTPS authorization server endpoint.
|
||||
///
|
||||
/// Loopback HTTP is permitted when `allowLoopbackHTTPForAuthorizationServer` is `true`.
|
||||
public func validateAuthorizationServer(_ url: URL, context: String) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty,
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidResourceURI(
|
||||
"Invalid \(context): \(url.absoluteString)"
|
||||
)
|
||||
}
|
||||
|
||||
if allowLoopbackHTTPForAuthorizationServer,
|
||||
scheme == OAuthURLScheme.http,
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
guard scheme == OAuthURLScheme.https else {
|
||||
throw OAuthAuthorizationError.insecureAuthorizationServerEndpoint(
|
||||
context: context,
|
||||
url: url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the redirect URI for authorization_code flows.
|
||||
///
|
||||
/// Accepts HTTPS or loopback HTTP; rejects any URI containing a fragment.
|
||||
public func validateRedirectURI(_ url: URL) throws {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
components.fragment == nil
|
||||
else {
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
if scheme == OAuthURLScheme.https { return }
|
||||
|
||||
if scheme == OAuthURLScheme.http,
|
||||
let host = components.host?.lowercased(),
|
||||
OAuthLoopbackHost.isLoopback(host)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
throw OAuthAuthorizationError.invalidRedirectURI(url.absoluteString)
|
||||
}
|
||||
|
||||
/// Returns `true` if `host` is a literal IPv4 or IPv6 address in a private or reserved range.
|
||||
///
|
||||
/// Blocked ranges:
|
||||
/// - IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`
|
||||
/// - IPv6: `fc00::/7` (ULA), `fe80::/10` (link-local)
|
||||
///
|
||||
/// **Limitation**: only literal IP addresses are checked. DNS rebinding is not prevented here.
|
||||
public func isPrivateIPHost(_ host: String) -> Bool {
|
||||
let octets = host.split(separator: ".").compactMap { UInt8($0) }
|
||||
if octets.count == 4 && !host.contains(":") {
|
||||
let (a, b) = (octets[0], octets[1])
|
||||
return a == 10 // 10.0.0.0/8
|
||||
|| (a == 172 && (16...31).contains(b)) // 172.16.0.0/12
|
||||
|| (a == 192 && b == 168) // 192.168.0.0/16
|
||||
|| (a == 169 && b == 254) // 169.254.0.0/16 (link-local / cloud metadata)
|
||||
|| (a == 100 && (64...127).contains(b)) // 100.64.0.0/10 (CGNAT)
|
||||
}
|
||||
let lower = host.lowercased()
|
||||
if lower.hasPrefix("fc") || lower.hasPrefix("fd") { return true } // fc00::/7 (ULA)
|
||||
if lower.hasPrefix("fe") && lower.count > 2 {
|
||||
let idx = lower.index(lower.startIndex, offsetBy: 2)
|
||||
if "89ab".contains(lower[idx]) { return true } // fe80::/10 (link-local)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthURLValidator: OAuthURLValidating {}
|
||||
189
Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
Normal file
189
Sources/MCP/Base/Authorization/OAuthWWWAuthenticateParser.swift
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - WWW-Authenticate Parsing Protocol
|
||||
|
||||
/// Parses the `WWW-Authenticate` response header to extract Bearer challenge parameters.
|
||||
///
|
||||
/// ``OAuthAuthorizer`` uses this protocol to inspect 401 and 403 responses from the server.
|
||||
/// Override the default ``DefaultOAuthWWWAuthenticateParser`` to handle custom challenge formats.
|
||||
public protocol OAuthWWWAuthenticateParsing {
|
||||
/// Parses the `WWW-Authenticate` header from a response header dictionary.
|
||||
///
|
||||
/// - Parameter headers: The full set of HTTP response headers (case-insensitive lookup is expected).
|
||||
/// - Returns: An ``OAuthBearerChallenge`` if a `Bearer` scheme is found, or `nil` otherwise.
|
||||
func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge?
|
||||
}
|
||||
|
||||
// MARK: - Default Implementation
|
||||
|
||||
/// Default ``OAuthWWWAuthenticateParsing`` implementation.
|
||||
///
|
||||
/// Parses `WWW-Authenticate` headers following RFC 6750 §3, handling:
|
||||
/// - Multiple challenge schemes in a single header value (e.g., `Bearer …, Basic …`)
|
||||
/// - Quoted-string parameter values with backslash escaping
|
||||
/// - Case-insensitive scheme and parameter key matching
|
||||
public struct DefaultOAuthWWWAuthenticateParser: OAuthWWWAuthenticateParsing {
|
||||
public init() {}
|
||||
private let tokenCharacters = CharacterSet(
|
||||
charactersIn: "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
)
|
||||
|
||||
public func parseBearer(from headers: [String: String]) -> OAuthBearerChallenge? {
|
||||
guard let value = headers.first(where: {
|
||||
$0.key.caseInsensitiveCompare(HTTPHeaderName.wwwAuthenticate) == .orderedSame
|
||||
})?.value else {
|
||||
return nil
|
||||
}
|
||||
return parseBearerHeader(value)
|
||||
}
|
||||
|
||||
private func parseBearerHeader(_ header: String) -> OAuthBearerChallenge? {
|
||||
let trimmed = header.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let parametersPart = extractBearerParameters(from: trimmed) else { return nil }
|
||||
|
||||
if parametersPart.isEmpty {
|
||||
return OAuthBearerChallenge(parameters: [:])
|
||||
}
|
||||
|
||||
let components = splitParameters(parametersPart)
|
||||
var parameters: [String: String] = [:]
|
||||
|
||||
for component in components {
|
||||
let pair = component.split(separator: "=", maxSplits: 1)
|
||||
guard pair.count == 2 else { continue }
|
||||
let key = pair[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
var value = pair[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
|
||||
value.removeFirst()
|
||||
value.removeLast()
|
||||
value = value.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
}
|
||||
parameters[key] = value
|
||||
}
|
||||
|
||||
return OAuthBearerChallenge(parameters: parameters)
|
||||
}
|
||||
|
||||
private func extractBearerParameters(from header: String) -> String? {
|
||||
let segments = splitParameters(header)
|
||||
|
||||
for index in segments.indices {
|
||||
let segment = segments[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard isBearerChallengeStart(segment) else { continue }
|
||||
|
||||
var parameters: [String] = []
|
||||
let initial = stripBearerScheme(from: segment)
|
||||
if !initial.isEmpty {
|
||||
parameters.append(initial)
|
||||
}
|
||||
|
||||
var nextIndex = segments.index(after: index)
|
||||
while nextIndex < segments.endIndex {
|
||||
let next = segments[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if next.isEmpty {
|
||||
nextIndex = segments.index(after: nextIndex)
|
||||
continue
|
||||
}
|
||||
if startsNewChallenge(next) {
|
||||
break
|
||||
}
|
||||
parameters.append(next)
|
||||
nextIndex = segments.index(after: nextIndex)
|
||||
}
|
||||
|
||||
return parameters.joined(separator: ",")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isBearerChallengeStart(_ segment: String) -> Bool {
|
||||
guard segment.count >= OAuthTokenType.bearer.count else { return false }
|
||||
let schemeEnd = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
|
||||
let scheme = segment[..<schemeEnd]
|
||||
guard String(scheme).caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame else {
|
||||
return false
|
||||
}
|
||||
|
||||
if schemeEnd == segment.endIndex {
|
||||
return true
|
||||
}
|
||||
return segment[schemeEnd].isWhitespace
|
||||
}
|
||||
|
||||
private func stripBearerScheme(from segment: String) -> String {
|
||||
guard segment.count > OAuthTokenType.bearer.count else { return "" }
|
||||
let index = segment.index(segment.startIndex, offsetBy: OAuthTokenType.bearer.count)
|
||||
return String(segment[index...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func startsNewChallenge(_ segment: String) -> Bool {
|
||||
if isBearerChallengeStart(segment) {
|
||||
return true
|
||||
}
|
||||
|
||||
if let equalsIndex = segment.firstIndex(of: "=") {
|
||||
let parameterName = String(segment[..<equalsIndex]).trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
if !parameterName.isEmpty
|
||||
&& !parameterName.contains(where: { $0.isWhitespace })
|
||||
&& isToken(parameterName)
|
||||
{
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
guard let whitespaceIndex = segment.firstIndex(where: \.isWhitespace) else {
|
||||
// No whitespace, no '=': bare token. Treat as new auth scheme if it's a valid token.
|
||||
return isToken(segment)
|
||||
}
|
||||
|
||||
let scheme = segment[..<whitespaceIndex]
|
||||
return isToken(String(scheme))
|
||||
}
|
||||
|
||||
private func isToken(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else { return false }
|
||||
return value.rangeOfCharacter(from: tokenCharacters.inverted) == nil
|
||||
}
|
||||
|
||||
private func splitParameters(_ value: String) -> [String] {
|
||||
var components: [String] = []
|
||||
var current = ""
|
||||
var inQuotes = false
|
||||
var escaping = false
|
||||
|
||||
for character in value {
|
||||
if escaping {
|
||||
current.append(character)
|
||||
escaping = false
|
||||
continue
|
||||
}
|
||||
if character == "\\" {
|
||||
current.append(character)
|
||||
escaping = true
|
||||
continue
|
||||
}
|
||||
if character == "\"" {
|
||||
inQuotes.toggle()
|
||||
current.append(character)
|
||||
continue
|
||||
}
|
||||
if character == "," && !inQuotes {
|
||||
components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
current = ""
|
||||
continue
|
||||
}
|
||||
current.append(character)
|
||||
}
|
||||
|
||||
if !current.isEmpty {
|
||||
components.append(current.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
return components.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
62
Sources/MCP/Base/Authorization/PKCE.swift
Normal file
62
Sources/MCP/Base/Authorization/PKCE.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
import CryptoKit
|
||||
#endif
|
||||
|
||||
/// Pure PKCE (RFC 7636) helpers required by the authorization_code flow.
|
||||
public enum PKCE {
|
||||
|
||||
/// Generates a cryptographically random PKCE code verifier.
|
||||
///
|
||||
/// - Parameter length: Number of characters in the verifier. Defaults to 64.
|
||||
/// RFC 7636 requires 43–128 characters.
|
||||
/// - Returns: A URL-safe random string suitable for use as a PKCE code verifier.
|
||||
public static func makeVerifier(length: Int = 64) -> String {
|
||||
let charset = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||
// 66 characters; 256 % 66 == 52, so reject bytes > 252 to eliminate modulo bias.
|
||||
let limit = UInt8(255 - (255 % charset.count)) // 252
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
var result = ""
|
||||
result.reserveCapacity(length)
|
||||
while result.count < length {
|
||||
let byte = UInt8.random(in: 0...255, using: &rng)
|
||||
if byte <= limit {
|
||||
result.append(charset[Int(byte % UInt8(charset.count))])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Derives the PKCE S256 code challenge from a verifier.
|
||||
///
|
||||
/// - Parameter verifier: A code verifier produced by ``makeVerifier(length:)``.
|
||||
/// - Returns: The base64url-encoded SHA-256 hash of the verifier.
|
||||
/// - Throws: ``OAuthAuthorizationError/pkceS256Unavailable`` on platforms without CryptoKit.
|
||||
public static func makeChallenge(from verifier: String) throws -> String {
|
||||
#if canImport(CryptoKit)
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
return Data(hash).base64URLEncodedString()
|
||||
#else
|
||||
throw OAuthAuthorizationError.pkceS256Unavailable
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Verifies that the authorization server metadata advertises S256 PKCE support.
|
||||
///
|
||||
/// - Parameter metadata: Authorization server metadata to inspect.
|
||||
/// - Throws: ``OAuthAuthorizationError/pkceCodeChallengeMethodsMissing`` if
|
||||
/// `code_challenge_methods_supported` is absent or empty, or
|
||||
/// ``OAuthAuthorizationError/pkceS256NotSupported(advertisedMethods:)`` if S256 is not listed.
|
||||
static func checkSupport(in metadata: OAuthAuthorizationServerMetadata) throws {
|
||||
guard let methods = metadata.codeChallengeMethodsSupported, !methods.isEmpty else {
|
||||
throw OAuthAuthorizationError.pkceCodeChallengeMethodsMissing
|
||||
}
|
||||
let supportsS256 = methods.contains {
|
||||
$0.caseInsensitiveCompare(OAuthCodeChallengeMethod.s256) == .orderedSame
|
||||
}
|
||||
guard supportsS256 else {
|
||||
throw OAuthAuthorizationError.pkceS256NotSupported(advertisedMethods: methods)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Sources/MCP/Base/Authorization/TokenStorage.swift
Normal file
37
Sources/MCP/Base/Authorization/TokenStorage.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Token Storage Protocol
|
||||
|
||||
/// Abstraction for persisting OAuth access tokens.
|
||||
///
|
||||
/// Implement this protocol to provide custom token storage (e.g., Keychain-backed).
|
||||
/// The default ``InMemoryTokenStorage`` stores tokens in memory only.
|
||||
public protocol TokenStorage: AnyObject, Sendable {
|
||||
func save(_ token: OAuthAccessToken)
|
||||
func load() -> OAuthAccessToken?
|
||||
func clear()
|
||||
}
|
||||
|
||||
// MARK: - In-Memory Implementation
|
||||
|
||||
/// Default ``TokenStorage`` that stores the access token in memory only.
|
||||
///
|
||||
/// The token is lost when the process exits. For persistent storage
|
||||
/// (e.g., system Keychain), implement ``TokenStorage`` directly.
|
||||
public final class InMemoryTokenStorage: TokenStorage, @unchecked Sendable {
|
||||
private var token: OAuthAccessToken?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func save(_ token: OAuthAccessToken) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
public func load() -> OAuthAccessToken? {
|
||||
token
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
token = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,9 @@ public actor HTTPClientTransport: Transport {
|
|||
/// Closure to modify requests before they are sent
|
||||
private let requestModifier: (URLRequest) -> URLRequest
|
||||
|
||||
/// Optional OAuth 2.1 authorizer.
|
||||
private let authorizer: (any HTTPClientAuthorizer)?
|
||||
|
||||
private var isConnected = false
|
||||
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
|
||||
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
|
||||
|
|
@ -101,6 +104,7 @@ public actor HTTPClientTransport: Transport {
|
|||
/// - streaming: Whether to enable SSE streaming mode (default: true)
|
||||
/// - sseInitializationTimeout: Maximum time to wait for session ID before proceeding with SSE (default: 10 seconds)
|
||||
/// - protocolVersion: The MCP protocol version to use (default: "2025-11-25")
|
||||
/// - authorizer: Optional ``HTTPClientAuthorizer`` for automatic Bearer token acquisition and retries.
|
||||
/// - requestModifier: Optional closure to customize requests before they are sent (default: no modification)
|
||||
/// - logger: Optional logger instance for transport events
|
||||
public init(
|
||||
|
|
@ -109,6 +113,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: Bool = true,
|
||||
sseInitializationTimeout: TimeInterval = 10,
|
||||
protocolVersion: String = Version.latest,
|
||||
authorizer: (any HTTPClientAuthorizer)? = nil,
|
||||
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
|
|
@ -118,6 +123,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: streaming,
|
||||
sseInitializationTimeout: sseInitializationTimeout,
|
||||
protocolVersion: protocolVersion,
|
||||
authorizer: authorizer,
|
||||
requestModifier: requestModifier,
|
||||
logger: logger
|
||||
)
|
||||
|
|
@ -129,6 +135,7 @@ public actor HTTPClientTransport: Transport {
|
|||
streaming: Bool = false,
|
||||
sseInitializationTimeout: TimeInterval = 10,
|
||||
protocolVersion: String = Version.latest,
|
||||
authorizer: (any HTTPClientAuthorizer)? = nil,
|
||||
requestModifier: @escaping (URLRequest) -> URLRequest = { $0 },
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
|
|
@ -138,6 +145,7 @@ public actor HTTPClientTransport: Transport {
|
|||
self.sseInitializationTimeout = sseInitializationTimeout
|
||||
self.protocolVersion = protocolVersion
|
||||
self.requestModifier = requestModifier
|
||||
self.authorizer = authorizer
|
||||
|
||||
// Create message stream
|
||||
var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation!
|
||||
|
|
@ -157,7 +165,6 @@ public actor HTTPClientTransport: Transport {
|
|||
self.initialSessionIDSignalTask = Task {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.initialSessionIDContinuation = continuation
|
||||
// This task will suspend here until continuation.resume() is called
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,7 +173,7 @@ public actor HTTPClientTransport: Transport {
|
|||
private func triggerInitialSessionIDSignal() {
|
||||
if let continuation = self.initialSessionIDContinuation {
|
||||
continuation.resume()
|
||||
self.initialSessionIDContinuation = nil // Consume the continuation
|
||||
self.initialSessionIDContinuation = nil
|
||||
logger.debug("✓ Initial session ID signal triggered for SSE task")
|
||||
} else {
|
||||
logger.debug("✗ No continuation to trigger - signal already consumed or SSE task not waiting")
|
||||
|
|
@ -174,19 +181,13 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Establishes connection with the transport
|
||||
///
|
||||
/// This prepares the transport for communication and sets up SSE streaming
|
||||
/// if streaming mode is enabled. The actual HTTP connection happens with the
|
||||
/// first message sent.
|
||||
public func connect() async throws {
|
||||
guard !isConnected else { return }
|
||||
isConnected = true
|
||||
|
||||
// Setup initial session ID signal
|
||||
setupInitialSessionIDSignal()
|
||||
|
||||
if streaming {
|
||||
// Start listening to server events
|
||||
streamingTask = Task { await startListeningForServerEvents() }
|
||||
}
|
||||
|
||||
|
|
@ -194,27 +195,18 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Disconnects from the transport
|
||||
///
|
||||
/// This terminates any active connections, cancels the streaming task,
|
||||
/// and releases any resources being used by the transport.
|
||||
public func disconnect() async {
|
||||
guard isConnected else { return }
|
||||
isConnected = false
|
||||
|
||||
// Cancel streaming task if active
|
||||
streamingTask?.cancel()
|
||||
streamingTask = nil
|
||||
|
||||
// Cancel any in-progress requests
|
||||
session.invalidateAndCancel()
|
||||
|
||||
// Clean up message stream
|
||||
messageContinuation.finish()
|
||||
|
||||
// Cancel the initial session ID signal task if active
|
||||
initialSessionIDSignalTask?.cancel()
|
||||
initialSessionIDSignalTask = nil
|
||||
// Resume the continuation if it's still pending to avoid leaks
|
||||
initialSessionIDContinuation?.resume()
|
||||
initialSessionIDContinuation = nil
|
||||
|
||||
|
|
@ -222,77 +214,108 @@ public actor HTTPClientTransport: Transport {
|
|||
}
|
||||
|
||||
/// Updates the protocol version used for `MCP-Protocol-Version` headers on subsequent requests.
|
||||
///
|
||||
/// Call this after lifecycle negotiation if the server responds with a different version.
|
||||
/// - Parameter version: The negotiated protocol version.
|
||||
public func updateNegotiatedProtocolVersion(_ version: String) {
|
||||
self.protocolVersion = version
|
||||
}
|
||||
|
||||
/// Sends data through an HTTP POST request
|
||||
///
|
||||
/// This sends a JSON-RPC message to the server via HTTP POST and processes
|
||||
/// the response according to the MCP Streamable HTTP specification. It handles:
|
||||
///
|
||||
/// - Adding appropriate Accept headers for both JSON and SSE
|
||||
/// - Including the MCP-Protocol-Version header as required by the specification
|
||||
/// - Including the session ID in requests if one has been established
|
||||
/// - Processing different response types (JSON vs SSE)
|
||||
/// - Handling HTTP error codes according to the specification
|
||||
///
|
||||
/// - Parameter data: The JSON-RPC message to send
|
||||
/// - Throws: MCPError for transport failures or server errors
|
||||
public func send(_ data: Data) async throws {
|
||||
guard isConnected else {
|
||||
throw MCPError.internalError("Transport not connected")
|
||||
}
|
||||
|
||||
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.httpBody = data
|
||||
|
||||
// Add protocol version header (required by MCP specification 2025-11-25)
|
||||
if let protocolVersion = protocolVersion {
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: "MCP-Protocol-Version")
|
||||
if let authorizer {
|
||||
do {
|
||||
try authorizer.validateEndpointSecurity(for: endpoint)
|
||||
} catch {
|
||||
throw MCPError.internalError(
|
||||
"Authorization flow failed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID if available
|
||||
if let sessionID = sessionID {
|
||||
request.addValue(sessionID, forHTTPHeaderField: "MCP-Session-Id")
|
||||
if let authorizer {
|
||||
try? await authorizer.prepareAuthorization(for: endpoint, session: session)
|
||||
}
|
||||
|
||||
// Apply request modifier
|
||||
request = requestModifier(request)
|
||||
var attempts = 0
|
||||
let operationKey = jsonRPCOperationKey(from: data)
|
||||
|
||||
#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
|
||||
while true {
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue(
|
||||
"\(ContentType.json), \(ContentType.sse)",
|
||||
forHTTPHeaderField: HTTPHeaderName.accept
|
||||
)
|
||||
request.addValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.contentType)
|
||||
request.httpBody = data
|
||||
|
||||
if let protocolVersion = protocolVersion {
|
||||
request.addValue(protocolVersion, forHTTPHeaderField: HTTPHeaderName.protocolVersion)
|
||||
}
|
||||
|
||||
if let sessionID = sessionID {
|
||||
request.addValue(sessionID, forHTTPHeaderField: HTTPHeaderName.sessionID)
|
||||
}
|
||||
|
||||
if let authValue = authorizer?.authorizationHeader(for: endpoint) {
|
||||
request.setValue(authValue, forHTTPHeaderField: HTTPHeaderName.authorization)
|
||||
}
|
||||
|
||||
request = requestModifier(request)
|
||||
|
||||
do {
|
||||
#if os(Linux)
|
||||
let (responseData, response) = try await session.data(for: request)
|
||||
try await processResponse(response: response, data: responseData)
|
||||
#else
|
||||
let (responseStream, response) = try await session.bytes(for: request)
|
||||
try await processResponse(response: response, stream: responseStream)
|
||||
#endif
|
||||
return
|
||||
} catch let authError as HTTPAuthenticationChallengeError {
|
||||
guard let authorizer else {
|
||||
throw mapAuthenticationChallengeError(authError)
|
||||
}
|
||||
|
||||
let handled: Bool
|
||||
do {
|
||||
handled = try await authorizer.handleChallenge(
|
||||
statusCode: authError.statusCode,
|
||||
headers: authError.headers,
|
||||
endpoint: endpoint,
|
||||
operationKey: operationKey,
|
||||
session: session
|
||||
)
|
||||
} catch {
|
||||
throw MCPError.internalError(
|
||||
"Authorization flow failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
|
||||
if handled, attempts < authorizer.maxAuthorizationAttempts {
|
||||
continue
|
||||
}
|
||||
|
||||
throw mapAuthenticationChallengeError(authError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -340,6 +340,353 @@ public struct OriginValidator: HTTPRequestValidator {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Protected Resource Metadata Validator
|
||||
|
||||
/// Serves the RFC 9728 Protected Resource Metadata document for discovery.
|
||||
///
|
||||
/// Per the MCP authorization specification, servers **MUST** serve Protected Resource
|
||||
/// Metadata at `/.well-known/oauth-protected-resource` so that clients can discover
|
||||
/// authorization server endpoints automatically.
|
||||
///
|
||||
/// Place this validator **before** ``BearerTokenValidator`` in the pipeline so that
|
||||
/// unauthenticated metadata discovery requests succeed.
|
||||
///
|
||||
/// ```swift
|
||||
/// let prmValidator = ProtectedResourceMetadataValidator(
|
||||
/// metadata: OAuthProtectedResourceServerMetadata(
|
||||
/// resource: "https://api.example.com",
|
||||
/// authorizationServers: [URL(string: "https://auth.example.com")!]
|
||||
/// )
|
||||
/// )
|
||||
/// let pipeline = StandardValidationPipeline(validators: [
|
||||
/// prmValidator,
|
||||
/// bearerTokenValidator,
|
||||
/// // ...
|
||||
/// ])
|
||||
/// ```
|
||||
public struct ProtectedResourceMetadataValidator: HTTPRequestValidator {
|
||||
private let encodedMetadata: Data
|
||||
|
||||
public init(metadata: OAuthProtectedResourceServerMetadata) {
|
||||
self.encodedMetadata = (try? JSONEncoder().encode(metadata)) ?? Data()
|
||||
}
|
||||
|
||||
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
|
||||
guard context.httpMethod == "GET",
|
||||
let path = request.path,
|
||||
path == OAuthWellKnownPath.protectedResource
|
||||
|| path.hasPrefix("\(OAuthWellKnownPath.protectedResource)/")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return .data(encodedMetadata, headers: [HTTPHeaderName.contentType: ContentType.json])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OAuth Bearer Validator
|
||||
|
||||
/// Result produced by ``BearerTokenValidator`` when validating an access token.
|
||||
public enum BearerTokenValidationResult: Sendable, Equatable {
|
||||
/// Access token is valid for this request, with its extracted claims.
|
||||
///
|
||||
/// Supply a ``BearerTokenInfo`` with `audience` and `expiresAt` populated so that
|
||||
/// ``BearerTokenValidator`` can enforce expiry and audience checks automatically.
|
||||
/// Pass `BearerTokenInfo()` (all `nil`) to delegate all enforcement to the caller.
|
||||
case valid(BearerTokenInfo)
|
||||
|
||||
/// Access token is missing required privileges, and new scopes are required.
|
||||
case insufficientScope(requiredScopes: Set<String>, errorDescription: String? = nil)
|
||||
|
||||
/// Access token is invalid or expired.
|
||||
case invalidToken(errorDescription: String? = nil)
|
||||
|
||||
/// Authorization request is malformed.
|
||||
case malformedRequest(errorDescription: String? = nil)
|
||||
}
|
||||
|
||||
/// Validates OAuth 2.1 Bearer authorization for protected MCP HTTP endpoints.
|
||||
///
|
||||
/// This validator implements resource-server error semantics aligned with the MCP auth spec:
|
||||
/// - `401` with `WWW-Authenticate: Bearer ...` for missing/invalid tokens
|
||||
/// - `403` with `error="insufficient_scope"` for insufficient permissions
|
||||
/// - `400` for malformed authorization requests
|
||||
///
|
||||
/// Include this validator early in your pipeline, before `SessionValidator`, so unauthenticated
|
||||
/// initialization requests can return a challenge.
|
||||
///
|
||||
/// ## Audience Validation (MUST)
|
||||
///
|
||||
/// Per the MCP authorization specification, **the resource server MUST validate the audience
|
||||
/// (`aud` claim) of the access token** to ensure it matches the resource server's own identifier.
|
||||
/// Failure to validate the audience allows token substitution attacks where a token intended
|
||||
/// for a different resource is replayed against your server.
|
||||
///
|
||||
/// Your ``TokenValidator`` closure **MUST** verify the audience. Example:
|
||||
///
|
||||
/// ```swift
|
||||
/// let validator = BearerTokenValidator(
|
||||
/// resourceMetadataURL: metadataURL,
|
||||
/// tokenValidator: { token, request, context in
|
||||
/// guard let claims = verifyAndDecode(token) else {
|
||||
/// return .invalidToken(errorDescription: "Token verification failed")
|
||||
/// }
|
||||
/// // MUST: Verify token audience matches this resource server
|
||||
/// guard claims.audience.contains("https://api.example.com") else {
|
||||
/// return .invalidToken(errorDescription: "Token audience mismatch")
|
||||
/// }
|
||||
/// return .valid
|
||||
/// }
|
||||
/// )
|
||||
/// ```
|
||||
public struct BearerTokenValidator: HTTPRequestValidator {
|
||||
/// Validates a bearer token and returns token info for audience and expiry enforcement.
|
||||
public typealias TokenValidator = @Sendable (
|
||||
_ token: String,
|
||||
_ request: HTTPRequest,
|
||||
_ context: HTTPValidationContext
|
||||
) -> BearerTokenValidationResult
|
||||
|
||||
/// Closure that returns the scopes to advertise in `WWW-Authenticate` challenge headers.
|
||||
///
|
||||
/// Return `nil` to omit the `scope` parameter from the challenge.
|
||||
public typealias ChallengeScopeProvider = @Sendable (
|
||||
_ request: HTTPRequest,
|
||||
_ context: HTTPValidationContext
|
||||
) -> Set<String>?
|
||||
|
||||
/// Closure that decides whether a request requires Bearer authentication.
|
||||
///
|
||||
/// Return `false` to allow a request through unauthenticated (e.g., public health-check endpoints).
|
||||
/// Defaults to requiring authentication on all requests.
|
||||
public typealias RequirementPredicate = @Sendable (
|
||||
_ request: HTTPRequest,
|
||||
_ context: HTTPValidationContext
|
||||
) -> Bool
|
||||
|
||||
public let resourceMetadataURL: URL
|
||||
public let resourceIdentifier: URL
|
||||
private let tokenValidator: TokenValidator
|
||||
private let challengeScopeProvider: ChallengeScopeProvider?
|
||||
private let requiresAuthentication: RequirementPredicate
|
||||
private let metadataDiscovery: any OAuthMetadataDiscovering
|
||||
|
||||
/// Creates a `BearerTokenValidator`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - resourceMetadataURL: Included in `WWW-Authenticate` challenge headers as the
|
||||
/// `resource_metadata` parameter, pointing to the RFC 9728 Protected Resource Metadata document.
|
||||
/// - resourceIdentifier: The canonical URI of this resource server. Used to validate the
|
||||
/// `aud` claim in tokens that supply audience information via ``BearerTokenInfo``.
|
||||
/// - tokenValidator: Validates the Bearer token and returns ``BearerTokenInfo`` with
|
||||
/// claims for SDK-side expiry and audience enforcement.
|
||||
/// - challengeScopeProvider: Optional closure supplying scopes to include in challenge headers.
|
||||
/// - requiresAuthentication: Predicate controlling which requests require a Bearer token.
|
||||
/// Defaults to requiring authentication on all requests.
|
||||
/// - metadataDiscovery: Used for audience URL matching. Defaults to ``DefaultOAuthMetadataDiscovery``.
|
||||
public init(
|
||||
resourceMetadataURL: URL,
|
||||
resourceIdentifier: URL,
|
||||
tokenValidator: @escaping TokenValidator,
|
||||
challengeScopeProvider: ChallengeScopeProvider? = nil,
|
||||
requiresAuthentication: @escaping RequirementPredicate = { _, _ in true },
|
||||
metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
|
||||
) {
|
||||
self.resourceMetadataURL = resourceMetadataURL
|
||||
self.resourceIdentifier = resourceIdentifier
|
||||
self.tokenValidator = tokenValidator
|
||||
self.challengeScopeProvider = challengeScopeProvider
|
||||
self.requiresAuthentication = requiresAuthentication
|
||||
self.metadataDiscovery = metadataDiscovery
|
||||
}
|
||||
|
||||
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
|
||||
guard requiresAuthentication(request, context) else { return nil }
|
||||
|
||||
guard let authorizationHeader = request.header(HTTPHeaderName.authorization) else {
|
||||
return unauthorizedResponse(
|
||||
challengeScope: challengeScopeProvider?(request, context),
|
||||
error: nil,
|
||||
errorDescription: nil,
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
}
|
||||
|
||||
let parsedToken: String
|
||||
switch parseBearerToken(from: authorizationHeader) {
|
||||
case .success(let token):
|
||||
parsedToken = token
|
||||
case .failure(let error):
|
||||
return .error(
|
||||
statusCode: 400,
|
||||
.invalidRequest("Bad Request: \(error.message)"),
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
}
|
||||
|
||||
switch tokenValidator(parsedToken, request, context) {
|
||||
case .valid(let info):
|
||||
// Expiry check
|
||||
if let exp = info.expiresAt, exp <= Date() {
|
||||
return unauthorizedResponse(
|
||||
challengeScope: challengeScopeProvider?(request, context),
|
||||
error: "invalid_token",
|
||||
errorDescription: "Token has expired",
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
}
|
||||
// Audience check — skipped for opaque tokens (audience == nil)
|
||||
if let audience = info.audience {
|
||||
let matches = audience.contains { audString in
|
||||
guard let audURL = URL(string: audString) else { return false }
|
||||
return metadataDiscovery.protectedResourceMatches(
|
||||
resource: audURL, endpoint: resourceIdentifier)
|
||||
}
|
||||
if !matches {
|
||||
return unauthorizedResponse(
|
||||
challengeScope: challengeScopeProvider?(request, context),
|
||||
error: "invalid_token",
|
||||
errorDescription: "Token audience mismatch",
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case .invalidToken(let errorDescription):
|
||||
return unauthorizedResponse(
|
||||
challengeScope: challengeScopeProvider?(request, context),
|
||||
error: "invalid_token",
|
||||
errorDescription: errorDescription,
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
|
||||
case .insufficientScope(let requiredScopes, let errorDescription):
|
||||
return forbiddenInsufficientScopeResponse(
|
||||
requiredScopes: requiredScopes,
|
||||
errorDescription: errorDescription,
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
|
||||
case .malformedRequest(let errorDescription):
|
||||
let message = errorDescription ?? "Malformed authorization request"
|
||||
return .error(
|
||||
statusCode: 400,
|
||||
.invalidRequest("Bad Request: \(message)"),
|
||||
sessionID: context.sessionID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func unauthorizedResponse(
|
||||
challengeScope: Set<String>?,
|
||||
error: String?,
|
||||
errorDescription: String?,
|
||||
sessionID: String?
|
||||
) -> HTTPResponse {
|
||||
let challenge = makeBearerChallenge(
|
||||
resourceMetadataURL: resourceMetadataURL,
|
||||
scope: challengeScope,
|
||||
error: error,
|
||||
errorDescription: errorDescription
|
||||
)
|
||||
return .error(
|
||||
statusCode: 401,
|
||||
.invalidRequest("Unauthorized"),
|
||||
sessionID: sessionID,
|
||||
extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
|
||||
)
|
||||
}
|
||||
|
||||
private func forbiddenInsufficientScopeResponse(
|
||||
requiredScopes: Set<String>,
|
||||
errorDescription: String?,
|
||||
sessionID: String?
|
||||
) -> HTTPResponse {
|
||||
let challenge = makeBearerChallenge(
|
||||
resourceMetadataURL: resourceMetadataURL,
|
||||
scope: requiredScopes,
|
||||
error: "insufficient_scope",
|
||||
errorDescription: errorDescription
|
||||
)
|
||||
return .error(
|
||||
statusCode: 403,
|
||||
.invalidRequest("Forbidden: Insufficient scope"),
|
||||
sessionID: sessionID,
|
||||
extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
|
||||
)
|
||||
}
|
||||
|
||||
private func makeBearerChallenge(
|
||||
resourceMetadataURL: URL,
|
||||
scope: Set<String>?,
|
||||
error: String?,
|
||||
errorDescription: String?
|
||||
) -> String {
|
||||
var parameters: [String] = []
|
||||
parameters.append("resource_metadata=\"\(escapeAuthParameter(resourceMetadataURL.absoluteString))\"")
|
||||
|
||||
if let scope, !scope.isEmpty {
|
||||
let serializedScope = scope.sorted().joined(separator: " ")
|
||||
parameters.append("scope=\"\(escapeAuthParameter(serializedScope))\"")
|
||||
}
|
||||
|
||||
if let error {
|
||||
parameters.append("error=\"\(escapeAuthParameter(error))\"")
|
||||
}
|
||||
|
||||
if let errorDescription, !errorDescription.isEmpty {
|
||||
parameters.append("error_description=\"\(escapeAuthParameter(errorDescription))\"")
|
||||
}
|
||||
|
||||
return "\(OAuthTokenType.bearer) " + parameters.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func escapeAuthParameter(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
}
|
||||
|
||||
private struct BearerTokenParseError: Swift.Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private func parseBearerToken(
|
||||
from authorizationHeader: String
|
||||
) -> Result<String, BearerTokenParseError> {
|
||||
let trimmed = authorizationHeader.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return .failure(.init(message: "Authorization header is empty"))
|
||||
}
|
||||
|
||||
let parts = trimmed.split(
|
||||
maxSplits: 1,
|
||||
whereSeparator: { $0.isWhitespace }
|
||||
)
|
||||
|
||||
guard parts.count == 2 else {
|
||||
return .failure(
|
||||
.init(message: "Authorization header must be in the form: Bearer <token>")
|
||||
)
|
||||
}
|
||||
|
||||
guard String(parts[0]).caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame else {
|
||||
return .failure(.init(message: "Authorization scheme must be Bearer"))
|
||||
}
|
||||
|
||||
let token = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else {
|
||||
return .failure(.init(message: "Bearer token is empty"))
|
||||
}
|
||||
|
||||
if token.contains(where: \.isWhitespace) {
|
||||
return .failure(.init(message: "Bearer token must not contain whitespace"))
|
||||
}
|
||||
|
||||
return .success(token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation Pipeline Protocol
|
||||
|
||||
/// Runs a validation pipeline against an HTTP request.
|
||||
|
|
|
|||
|
|
@ -42,10 +42,15 @@ public struct HTTPRequest: Sendable {
|
|||
/// The request body data, if any.
|
||||
public let body: Data?
|
||||
|
||||
public init(method: String, headers: [String: String] = [:], body: Data? = nil) {
|
||||
/// The request path (e.g., "/mcp", "/.well-known/oauth-protected-resource").
|
||||
/// Used by validators that need to match on specific paths.
|
||||
public let path: String?
|
||||
|
||||
public init(method: String, headers: [String: String] = [:], body: Data? = nil, path: String? = nil) {
|
||||
self.method = method
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.path = path
|
||||
}
|
||||
|
||||
/// Case-insensitive header lookup.
|
||||
|
|
@ -139,9 +144,11 @@ public enum HTTPResponse: Sendable {
|
|||
public enum HTTPHeaderName {
|
||||
public static let sessionID = "MCP-Session-Id"
|
||||
public static let protocolVersion = "MCP-Protocol-Version"
|
||||
public static let lastEventID = "Last-Event-Id"
|
||||
public static let lastEventID = "Last-Event-ID"
|
||||
public static let accept = "Accept"
|
||||
public static let contentType = "Content-Type"
|
||||
public static let authorization = "Authorization"
|
||||
public static let wwwAuthenticate = "WWW-Authenticate"
|
||||
public static let origin = "Origin"
|
||||
public static let host = "Host"
|
||||
public static let cacheControl = "Cache-Control"
|
||||
|
|
|
|||
|
|
@ -511,8 +511,8 @@ import Logging
|
|||
var messageWithNewline = message
|
||||
messageWithNewline.append(UInt8(ascii: "\n"))
|
||||
|
||||
// Use a local actor-isolated variable to track continuation state
|
||||
var sendContinuationResumed = false
|
||||
// Use a main-isolated variable to track continuation state
|
||||
let sendContinuationResumed = MainFlag()
|
||||
|
||||
try await withCheckedThrowingContinuation {
|
||||
[weak self] (continuation: CheckedContinuation<Void, Swift.Error>) in
|
||||
|
|
@ -529,8 +529,8 @@ import Logging
|
|||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
if !sendContinuationResumed {
|
||||
sendContinuationResumed = true
|
||||
if !sendContinuationResumed.flag {
|
||||
sendContinuationResumed.flag = true
|
||||
if let error = error {
|
||||
self.logger.error("Send error: \(error)")
|
||||
|
||||
|
|
@ -747,7 +747,8 @@ import Logging
|
|||
/// - Returns: The received data chunk
|
||||
/// - Throws: Network errors or transport failures
|
||||
private func receiveData() async throws -> Data {
|
||||
var receiveContinuationResumed = false
|
||||
// Use a main-isolated variable to track continuation state
|
||||
let receiveContinuationResumed = MainFlag()
|
||||
|
||||
return try await withCheckedThrowingContinuation {
|
||||
[weak self] (continuation: CheckedContinuation<Data, Swift.Error>) in
|
||||
|
|
@ -760,8 +761,8 @@ import Logging
|
|||
connection.receive(minimumIncompleteLength: 1, maximumLength: maxLength) {
|
||||
content, _, isComplete, error in
|
||||
Task { @MainActor in
|
||||
if !receiveContinuationResumed {
|
||||
receiveContinuationResumed = true
|
||||
if !receiveContinuationResumed.flag {
|
||||
receiveContinuationResumed.flag = true
|
||||
if let error = error {
|
||||
continuation.resume(throwing: MCPError.transportError(error))
|
||||
} else if let content = content {
|
||||
|
|
@ -787,6 +788,7 @@ import Logging
|
|||
self.isConnected = connected
|
||||
}
|
||||
}
|
||||
@MainActor final class MainFlag { var flag = false }
|
||||
|
||||
extension NWError {
|
||||
/// Whether this error indicates a connection has been lost or reset.
|
||||
|
|
|
|||
Loading…
Reference in a new issue