swift-sdk/Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Maksym Mova 6132fd4b5b
V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206)

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

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

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

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

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

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-03-24 11:05:22 +02:00

415 lines
18 KiB
Swift

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
}