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
1 changed files with 10925 additions and 199 deletions
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
|
||||
}
|
||||
Loading…
Reference in a new issue