swift-sdk/Sources/MCP/Base/Authorization/OAuthDiscovery.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

361 lines
14 KiB
Swift

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
}
}