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