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
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 {}
|
||||
Loading…
Reference in a new issue