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
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
253
Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// MARK: - OAuthAuthorizationCodeFlowing Protocol
|
||||
|
||||
/// Internal protocol for driving the OAuth 2.1 authorization code flow.
|
||||
protocol OAuthAuthorizationCodeFlowing: Sendable {
|
||||
func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL
|
||||
|
||||
func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
// MARK: - No-Redirect Session Delegate
|
||||
|
||||
final class OAuthNoRedirectSessionDelegate: NSObject, URLSessionTaskDelegate,
|
||||
@unchecked Sendable
|
||||
{
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Authorization Code Flow
|
||||
|
||||
/// Handles the browser-facing steps of the OAuth 2.1 authorization_code flow.
|
||||
///
|
||||
/// Builds the authorization request URL, drives the redirect (via delegate or direct HTTP),
|
||||
/// and extracts the authorization code from the callback redirect URL.
|
||||
public struct OAuthAuthorizationCodeFlow: Sendable {
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Builds the authorization request URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationEndpoint: The AS authorization endpoint.
|
||||
/// - resource: The RFC 8707 resource indicator.
|
||||
/// - redirectURI: The redirect URI registered for this client.
|
||||
/// - clientID: The OAuth client identifier.
|
||||
/// - codeChallenge: The PKCE S256 code challenge.
|
||||
/// - scopes: Optional scope set to request.
|
||||
/// - state: The CSRF state nonce.
|
||||
/// - scopeSerializer: Serializes the scope set to a space-separated string.
|
||||
/// - Returns: The full authorization request URL with all query parameters.
|
||||
public func buildURL(
|
||||
authorizationEndpoint: URL,
|
||||
resource: URL,
|
||||
redirectURI: URL,
|
||||
clientID: String,
|
||||
codeChallenge: String,
|
||||
scopes: Set<String>?,
|
||||
state: String,
|
||||
scopeSerializer: any OAuthScopeSelecting
|
||||
) throws -> URL {
|
||||
guard var components = URLComponents(
|
||||
url: authorizationEndpoint,
|
||||
resolvingAgainstBaseURL: false
|
||||
) else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] = [
|
||||
.init(name: OAuthParameterName.responseType, value: OAuthParameterName.code),
|
||||
.init(name: OAuthParameterName.clientID, value: clientID),
|
||||
.init(name: OAuthParameterName.redirectURI, value: redirectURI.absoluteString),
|
||||
.init(name: OAuthParameterName.state, value: state),
|
||||
.init(name: OAuthParameterName.resource, value: resource.absoluteString),
|
||||
.init(name: OAuthParameterName.codeChallenge, value: codeChallenge),
|
||||
.init(
|
||||
name: OAuthParameterName.codeChallengeMethod, value: OAuthCodeChallengeMethod.s256),
|
||||
]
|
||||
|
||||
if let scope = scopes.flatMap(scopeSerializer.serialize) {
|
||||
queryItems.append(.init(name: OAuthParameterName.scope, value: scope))
|
||||
}
|
||||
|
||||
components.queryItems = queryItems
|
||||
guard let url = components.url else {
|
||||
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Drives the interactive authorization redirect and returns the authorization code.
|
||||
///
|
||||
/// When a delegate is provided, presents the authorization URL and awaits the redirect.
|
||||
/// Without a delegate, sends a GET request and captures the redirect `Location` header.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - authorizationURL: The full authorization request URL.
|
||||
/// - redirectURI: The expected redirect URI base for validation.
|
||||
/// - state: The CSRF state nonce to verify in the redirect.
|
||||
/// - delegate: Optional user-facing delegate for browser-based flows.
|
||||
/// - session: The `URLSession` used for the no-redirect path.
|
||||
/// - Returns: The extracted authorization code.
|
||||
public func perform(
|
||||
authorizationURL: URL,
|
||||
redirectURI: URL,
|
||||
state: String,
|
||||
delegate: (any OAuthAuthorizationDelegate)?,
|
||||
session: URLSession
|
||||
) async throws -> String {
|
||||
if let delegate {
|
||||
let redirectURL = try await delegate.presentAuthorizationURL(authorizationURL)
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: authorizationURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(
|
||||
"text/html, \(ContentType.json)", forHTTPHeaderField: HTTPHeaderName.accept)
|
||||
|
||||
let noRedirectDelegate = OAuthNoRedirectSessionDelegate()
|
||||
let noRedirectSession = URLSession(
|
||||
configuration: session.configuration,
|
||||
delegate: noRedirectDelegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
defer { noRedirectSession.invalidateAndCancel() }
|
||||
|
||||
let (_, response) = try await noRedirectSession.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard (300..<400).contains(httpResponse.statusCode) else {
|
||||
if httpResponse.statusCode >= 400 {
|
||||
throw OAuthAuthorizationError.authorizationRequestFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
guard let location = httpResponse.value(forHTTPHeaderField: HTTPHeaderName.location),
|
||||
!location.isEmpty,
|
||||
let redirectURL = URL(string: location)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingRedirectLocation
|
||||
}
|
||||
|
||||
return try extractCode(
|
||||
from: redirectURL,
|
||||
expectedRedirectURI: redirectURI,
|
||||
expectedState: state
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts and validates the authorization code from the redirect URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - redirectURL: The redirect URL received from the authorization server.
|
||||
/// - expectedRedirectURI: The redirect URI used in the authorization request.
|
||||
/// - expectedState: The CSRF state nonce sent in the authorization request.
|
||||
/// - Returns: The authorization code.
|
||||
public func extractCode(
|
||||
from redirectURL: URL,
|
||||
expectedRedirectURI: URL,
|
||||
expectedState: String
|
||||
) throws -> String {
|
||||
guard
|
||||
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
|
||||
let expectedComponents = URLComponents(
|
||||
url: expectedRedirectURI, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
if normalizedRedirectBase(redirectComponents) != normalizedRedirectBase(expectedComponents) {
|
||||
throw OAuthAuthorizationError.authorizationResponseRedirectMismatch(
|
||||
expected: expectedRedirectURI.absoluteString,
|
||||
actual: redirectURL.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let state = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.state
|
||||
})?.value,
|
||||
!state.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingState
|
||||
}
|
||||
|
||||
guard state == expectedState else {
|
||||
throw OAuthAuthorizationError.authorizationResponseStateMismatch(
|
||||
expected: expectedState,
|
||||
actual: state
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let code = redirectComponents.queryItems?.first(where: {
|
||||
$0.name == OAuthParameterName.code
|
||||
})?.value,
|
||||
!code.isEmpty
|
||||
else {
|
||||
throw OAuthAuthorizationError.authorizationResponseMissingCode
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func normalizedRedirectBase(_ components: URLComponents) -> String {
|
||||
let scheme = components.scheme?.lowercased() ?? ""
|
||||
let host = components.host?.lowercased() ?? ""
|
||||
let port: Int
|
||||
if let explicitPort = components.port {
|
||||
port = explicitPort
|
||||
} else if scheme == OAuthURLScheme.https {
|
||||
port = OAuthDefaultPort.https
|
||||
} else if scheme == OAuthURLScheme.http {
|
||||
port = OAuthDefaultPort.http
|
||||
} else {
|
||||
port = -1
|
||||
}
|
||||
let path = components.path.isEmpty ? "/" : components.path
|
||||
return "\(scheme)://\(host):\(port)\(path)"
|
||||
}
|
||||
}
|
||||
|
||||
extension OAuthAuthorizationCodeFlow: OAuthAuthorizationCodeFlowing {}
|
||||
Loading…
Reference in a new issue