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

253 lines
9.2 KiB
Swift

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