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