* 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:
Maksym Mova 2026-03-24 11:05:22 +02:00 • committed by GitHub
commit 6132fd4b5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 10925 additions and 199 deletions

File diff suppressed because it is too large Load diff

View file

@ -48,7 +48,11 @@ private func makeResponseBody(id: String = "2") -> Data {
return try! JSONSerialization.data(withJSONObject: json)
}
private func makeStatefulPOSTRequest(body: Data, sessionID: String? = nil) -> HTTPRequest {
private func makeStatefulPOSTRequest(
body: Data,
sessionID: String? = nil,
authorization: String? = nil
) -> HTTPRequest {
var headers: [String: String] = [
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
@ -56,6 +60,9 @@ private func makeStatefulPOSTRequest(body: Data, sessionID: String? = nil) -> HT
if let sessionID {
headers["Mcp-Session-Id"] = sessionID
}
if let authorization {
headers[HTTPHeaderName.authorization] = authorization
}
return HTTPRequest(method: "POST", headers: headers, body: body)
}
@ -65,7 +72,7 @@ private func makeGETRequest(sessionID: String, lastEventID: String? = nil) -> HT
"Mcp-Session-Id": sessionID,
]
if let lastEventID {
headers["Last-Event-Id"] = lastEventID
headers["Last-Event-ID"] = lastEventID
}
return HTTPRequest(method: "GET", headers: headers)
}
@ -97,6 +104,28 @@ private func makeStatefulTransport(
)
}
private let authResourceMetadataURL =
URL(string: "https://mcp.example.com/.well-known/oauth-protected-resource/mcp")!
private let authResourceIdentifier = URL(string: "https://mcp.example.com/mcp")!
private func makeAuthenticatedStatefulTransport(
challengeScopes: Set<String>? = nil,
tokenValidator: @escaping BearerTokenValidator.TokenValidator
) -> StatefulHTTPServerTransport {
let validator = BearerTokenValidator(
resourceMetadataURL: authResourceMetadataURL,
resourceIdentifier: authResourceIdentifier,
tokenValidator: tokenValidator,
challengeScopeProvider: { _, _ in challengeScopes }
)
return StatefulHTTPServerTransport(
validationPipeline: StandardValidationPipeline(validators: [
validator,
SessionValidator(),
])
)
}
private func makeStatelessTransport() -> StatelessHTTPServerTransport {
StatelessHTTPServerTransport(
validationPipeline: StandardValidationPipeline(validators: [])
@ -628,6 +657,132 @@ struct StatefulHTTPServerTransportTests {
await transport.disconnect()
}
// MARK: - OAuth Bearer Validation
@Test("Bearer auth validator returns 401 with challenge when authorization is missing")
func testBearerAuthValidatorMissingAuthorizationReturns401() async throws {
let transport = makeAuthenticatedStatefulTransport(
challengeScopes: ["files:read"]
) { _, _, _ in
.valid(BearerTokenInfo())
}
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(body: makeInitializeBody())
)
#expect(response.statusCode == 401)
let challenge = response.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("Bearer ") == true)
#expect(
challenge?.contains("resource_metadata=\"\(authResourceMetadataURL.absoluteString)\"")
== true
)
#expect(challenge?.contains("scope=\"files:read\"") == true)
await transport.disconnect()
}
@Test("Bearer auth validator returns 400 when authorization header is malformed")
func testBearerAuthValidatorMalformedAuthorizationReturns400() async throws {
let transport = makeAuthenticatedStatefulTransport { _, _, _ in .valid(BearerTokenInfo()) }
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeInitializeBody(),
authorization: "Basic dGVzdA=="
)
)
#expect(response.statusCode == 400)
#expect(response.headers[HTTPHeaderName.wwwAuthenticate] == nil)
await transport.disconnect()
}
@Test("Bearer auth validator returns 401 invalid_token for rejected token")
func testBearerAuthValidatorInvalidTokenReturns401() async throws {
let transport = makeAuthenticatedStatefulTransport { _, _, _ in
.invalidToken(errorDescription: "Token expired")
}
try await transport.connect()
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeInitializeBody(),
authorization: "Bearer expired-token"
)
)
#expect(response.statusCode == 401)
let challenge = response.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("error=\"invalid_token\"") == true)
#expect(challenge?.contains("error_description=\"Token expired\"") == true)
#expect(
challenge?.contains("resource_metadata=\"\(authResourceMetadataURL.absoluteString)\"")
== true
)
await transport.disconnect()
}
@Test("Bearer auth validator returns 403 insufficient_scope with scope challenge")
func testBearerAuthValidatorInsufficientScopeReturns403() async throws {
let transport = makeAuthenticatedStatefulTransport { token, _, context in
if context.isInitializationRequest, token == "init-token" {
return .valid(BearerTokenInfo())
}
if token == "read-only-token" {
return .insufficientScope(
requiredScopes: ["files:read", "files:write"],
errorDescription: "Additional file write permission required"
)
}
return .invalidToken(errorDescription: "Unknown token")
}
try await transport.connect()
let initResponse = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeInitializeBody(),
authorization: "Bearer init-token"
)
)
let sessionID = initResponse.headers[HTTPHeaderName.sessionID]
#expect(sessionID != nil)
guard let sessionID else {
await transport.disconnect()
return
}
let response = await transport.handleRequest(
makeStatefulPOSTRequest(
body: makeNotificationBody(),
sessionID: sessionID,
authorization: "Bearer read-only-token"
)
)
#expect(response.statusCode == 403)
let challenge = response.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("error=\"insufficient_scope\"") == true)
#expect(challenge?.contains("scope=\"files:read files:write\"") == true)
#expect(
challenge?.contains("resource_metadata=\"\(authResourceMetadataURL.absoluteString)\"")
== true
)
#expect(
challenge?.contains(
"error_description=\"Additional file write permission required\""
) == true
)
await transport.disconnect()
}
// MARK: - Resumability
@Test("GET with Last-Event-ID replays stored events")

View file

@ -0,0 +1,216 @@
@preconcurrency import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if swift(>=6.1)
enum MockResponses {
typealias Route = @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data)
static func ephemeralConfiguration() -> URLSessionConfiguration {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return config
}
static func mockError(_ message: String) -> NSError {
NSError(
domain: "MockURLProtocolError", code: 0,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
static func jsonRPCResult(id: Int) -> Data {
#"{"jsonrpc":"2.0","result":{"ok":true},"id":\#(id)}"#.data(using: .utf8)!
}
// MARK: - Route Builders
static func jsonSuccess(body: Data) -> Route {
{ request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, body)
}
}
static func bearerChallenge(
statusCode: Int = 401,
resourceMetadataURL: URL? = nil,
scope: String? = nil,
error: String? = nil,
errorDescription: String? = nil
) -> Route {
{ request in
var params: [String] = []
if let url = resourceMetadataURL {
params.append("resource_metadata=\"\(url.absoluteString)\"")
}
if let scope { params.append("scope=\"\(scope)\"") }
if let error { params.append("error=\"\(error)\"") }
if let errorDescription { params.append("error_description=\"\(errorDescription)\"") }
let headerValue = "Bearer \(params.joined(separator: ", "))"
let response = HTTPURLResponse(
url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1",
headerFields: ["WWW-Authenticate": headerValue])!
return (response, Data())
}
}
static func resourceMetadata(
authorizationServers: [String],
scopesSupported: [String]? = nil,
resource: String? = nil
) -> Route {
{ request in
var dict: [String: Any] = ["authorization_servers": authorizationServers]
if let scopes = scopesSupported { dict["scopes_supported"] = scopes }
if let resource { dict["resource"] = resource }
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func asMetadata(
issuer: String,
tokenEndpoint: String,
authorizationEndpoint: String? = nil,
registrationEndpoint: String? = nil,
codeChallengeMethodsSupported: [String]? = nil,
tokenEndpointAuthMethodsSupported: [String]? = nil,
clientIDMetadataDocumentSupported: Bool? = nil
) -> Route {
{ request in
var dict: [String: Any] = ["issuer": issuer, "token_endpoint": tokenEndpoint]
if let v = authorizationEndpoint { dict["authorization_endpoint"] = v }
if let v = registrationEndpoint { dict["registration_endpoint"] = v }
if let v = codeChallengeMethodsSupported {
dict["code_challenge_methods_supported"] = v
}
if let v = tokenEndpointAuthMethodsSupported {
dict["token_endpoint_auth_methods_supported"] = v
}
if let v = clientIDMetadataDocumentSupported {
dict["client_id_metadata_document_supported"] = v
}
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func tokenSuccess(
accessToken: String,
expiresIn: Int = 3600,
scope: String? = nil,
refreshToken: String? = nil
) -> Route {
{ request in
var dict: [String: Any] = [
"access_token": accessToken, "token_type": "Bearer", "expires_in": expiresIn,
]
if let scope { dict["scope"] = scope }
if let refreshToken { dict["refresh_token"] = refreshToken }
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func tokenResponse(
accessToken: String,
tokenType: String,
expiresIn: Int = 3600
) -> Route {
{ request in
let dict: [String: Any] = [
"access_token": accessToken, "token_type": tokenType, "expires_in": expiresIn,
]
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func tokenError(
statusCode: Int = 400,
error: String,
errorDescription: String? = nil,
extraFields: [String: String] = [:]
) -> Route {
{ request in
var dict: [String: Any] = ["error": error]
if let errorDescription { dict["error_description"] = errorDescription }
for (key, value) in extraFields { dict[key] = value }
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func httpError(statusCode: Int) -> Route {
{ request in
let response = HTTPURLResponse(
url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1",
headerFields: nil)!
return (response, Data())
}
}
static func registrationSuccess(
clientID: String,
clientSecret: String? = nil
) -> Route {
{ request in
var dict: [String: Any] = ["client_id": clientID]
if let clientSecret { dict["client_secret"] = clientSecret }
let data = try JSONSerialization.data(withJSONObject: dict)
let response = HTTPURLResponse(
url: request.url!, statusCode: 201, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"])!
return (response, data)
}
}
static func redirect(to location: String, statusCode: Int = 302) -> Route {
{ request in
let response = HTTPURLResponse(
url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1",
headerFields: ["Location": location])!
return (response, Data())
}
}
// MARK: - Routing Handler
static func routingHandler(
routes: [URL: Route]
) -> @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data) {
{ request in
guard let url = request.url else { throw mockError("Missing request URL") }
guard let handler = routes[url] else {
throw mockError("Unexpected URL: \(url.absoluteString)")
}
return try await handler(request)
}
}
}
#endif // swift(>=6.1)

View file

@ -0,0 +1,178 @@
import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@Suite("OAuthAuthorizationCodeFlow")
struct OAuthAuthorizationCodeFlowTests {
let flow = OAuthAuthorizationCodeFlow()
let authEndpoint = URL(string: "https://auth.example.com/authorize")!
let resource = URL(string: "https://api.example.com")!
let redirectURI = URL(string: "https://app.example.com/callback")!
let scopeSelector = DefaultOAuthScopeSelector()
// MARK: - buildURL
@Test("buildURL includes all required parameters")
func testBuildURLRequiredParams() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: nil,
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = Dictionary(
uniqueKeysWithValues: components.queryItems!.map { ($0.name, $0.value ?? "") })
#expect(items["response_type"] == "code")
#expect(items["client_id"] == "my-client")
#expect(items["redirect_uri"] == redirectURI.absoluteString)
#expect(items["state"] == "state-xyz")
#expect(items["resource"] == resource.absoluteString)
#expect(items["code_challenge"] == "abc123")
#expect(items["code_challenge_method"] == "S256")
}
@Test("buildURL includes scope when provided")
func testBuildURLWithScope() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: Set(["read", "write"]),
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = Dictionary(
uniqueKeysWithValues: components.queryItems!.map { ($0.name, $0.value ?? "") })
let scope = items["scope"] ?? ""
#expect(scope.contains("read"))
#expect(scope.contains("write"))
}
@Test("buildURL omits scope for nil scopes")
func testBuildURLOmitsScopeWhenNil() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: nil,
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = components.queryItems ?? []
#expect(!items.contains(where: { $0.name == "scope" }))
}
@Test("buildURL omits scope for empty scope set")
func testBuildURLOmitsScopeWhenEmpty() throws {
let url = try flow.buildURL(
authorizationEndpoint: authEndpoint,
resource: resource,
redirectURI: redirectURI,
clientID: "my-client",
codeChallenge: "abc123",
scopes: Set(),
state: "state-xyz",
scopeSerializer: scopeSelector
)
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
let items = components.queryItems ?? []
#expect(!items.contains(where: { $0.name == "scope" }))
}
// MARK: - extractCode
@Test("extractCode returns code from valid redirect URL")
func testExtractCodeSuccess() throws {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123&state=my-state")!
let code = try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
#expect(code == "auth-code-123")
}
@Test("extractCode throws state mismatch")
func testExtractCodeThrowsStateMismatch() {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123&state=wrong-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws missing state")
func testExtractCodeThrowsMissingState() {
let redirectURL = URL(string:
"https://app.example.com/callback?code=auth-code-123")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws missing code")
func testExtractCodeThrowsMissingCode() {
let redirectURL = URL(string:
"https://app.example.com/callback?state=my-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode throws redirect URI mismatch")
func testExtractCodeThrowsRedirectMismatch() {
let redirectURL = URL(string:
"https://evil.example.com/callback?code=auth-code-123&state=my-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
}
}
@Test("extractCode normalizes host case in redirect URI comparison")
func testExtractCodeCaseInsensitiveHost() throws {
let redirectURL = URL(string:
"https://APP.EXAMPLE.COM/callback?code=auth-code-123&state=my-state")!
let code = try flow.extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: "my-state"
)
#expect(code == "auth-code-123")
}
}

View file

@ -0,0 +1,735 @@
import Foundation
import Testing
#if canImport(CryptoKit)
import CryptoKit
#endif
@testable import MCP
@Suite("OAuth Authorization Helpers")
struct OAuthAuthorizationTests {
#if canImport(CryptoKit)
// Generated fresh each test run — no hardcoded key material in source.
private static let testPrivateKeyPEM: String = P256.Signing.PrivateKey().pemRepresentation
#else
private static let testPrivateKeyPEM: String = ""
#endif
private var metadataDiscovery: DefaultOAuthMetadataDiscovery { DefaultOAuthMetadataDiscovery() }
private static func decodeBase64URL(_ input: Substring) -> Data? {
var base64 = String(input)
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = base64.count % 4
if remainder != 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
return Data(base64Encoded: base64)
}
@Test("Parse Bearer challenge with resource metadata and scope")
func testParseBearerChallenge() {
let headers = [
"WWW-Authenticate":
"Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\", scope=\"files:read files:write\", error=\"insufficient_scope\""
]
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: headers)
#expect(challenge != nil)
#expect(
challenge?.resourceMetadataURL
== URL(string: "https://mcp.example.com/.well-known/oauth-protected-resource"))
#expect(challenge?.scope == "files:read files:write")
#expect(challenge?.error == "insufficient_scope")
}
@Test("Parse Bearer challenge with optional error description")
func testParseBearerChallengeErrorDescription() {
let headers = [
"WWW-Authenticate":
"Bearer error=\"insufficient_scope\", scope=\"files:read files:write\", resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\", error_description=\"Additional file write permission required\""
]
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: headers)
#expect(challenge != nil)
#expect(challenge?.error == "insufficient_scope")
#expect(challenge?.scope == "files:read files:write")
#expect(
challenge?.resourceMetadataURL
== URL(string: "https://mcp.example.com/.well-known/oauth-protected-resource"))
#expect(challenge?.errorDescription == "Additional file write permission required")
}
@Test("Parse Bearer challenge when another auth scheme appears first")
func testParseBearerChallengeWhenBearerIsNotFirst() {
let headers = [
"WWW-Authenticate":
"Basic realm=\"legacy\", Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\", scope=\"files:read\""
]
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: headers)
#expect(challenge != nil)
#expect(
challenge?.resourceMetadataURL
== URL(string: "https://mcp.example.com/.well-known/oauth-protected-resource"))
#expect(challenge?.scope == "files:read")
}
@Test("Parse Bearer challenge stops before the next auth scheme")
func testParseBearerChallengeStopsBeforeNextScheme() {
let headers = [
"WWW-Authenticate":
"Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\", scope=\"files:read\", DPoP algs=\"ES256\""
]
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: headers)
#expect(challenge != nil)
#expect(
challenge?.resourceMetadataURL
== URL(string: "https://mcp.example.com/.well-known/oauth-protected-resource"))
#expect(challenge?.scope == "files:read")
#expect(challenge?.parameters["algs"] == nil)
}
@Test("Protected resource metadata discovery fallback URLs")
func testProtectedResourceMetadataURLs() {
let endpoint = URL(string: "https://example.com/public/mcp")!
let urls = metadataDiscovery.protectedResourceMetadataURLs(for: endpoint)
#expect(
urls == [
URL(string: "https://example.com/.well-known/oauth-protected-resource/public/mcp")!,
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
])
}
@Test("Authorization server metadata discovery URLs for issuer with path")
func testAuthorizationServerMetadataURLsWithPath() {
let issuer = URL(string: "https://auth.example.com/tenant1")!
let urls = metadataDiscovery.authorizationServerMetadataURLs(for: issuer)
#expect(
urls == [
URL(string: "https://auth.example.com/.well-known/oauth-authorization-server/tenant1")!,
URL(string: "https://auth.example.com/.well-known/openid-configuration/tenant1")!,
URL(string: "https://auth.example.com/tenant1/.well-known/openid-configuration")!,
])
}
@Test("Authorization server metadata discovery URLs for issuer without path")
func testAuthorizationServerMetadataURLsWithoutPath() {
let issuer = URL(string: "https://auth.example.com")!
let urls = metadataDiscovery.authorizationServerMetadataURLs(for: issuer)
#expect(
urls == [
URL(string: "https://auth.example.com/.well-known/oauth-authorization-server")!,
URL(string: "https://auth.example.com/.well-known/openid-configuration")!,
])
}
@Test("Canonical resource URI normalization")
func testCanonicalResourceURINormalization() throws {
let endpoint = URL(string: "HTTPS://MCP.EXAMPLE.COM/?q=1")!
let canonical = try metadataDiscovery.canonicalResourceURI(from: endpoint)
#expect(canonical.absoluteString == "https://mcp.example.com")
}
@Test("Canonical resource URI supports explicit port and root slash normalization")
func testCanonicalResourceURIWithExplicitPort() throws {
let endpoint = URL(string: "HTTPS://MCP.EXAMPLE.COM:8443/")!
let canonical = try metadataDiscovery.canonicalResourceURI(from: endpoint)
#expect(canonical.absoluteString == "https://mcp.example.com:8443")
}
@Test("Canonical resource URI preserves specific server path")
func testCanonicalResourceURIPreservesPath() throws {
let endpoint = URL(string: "https://mcp.example.com/server/mcp")!
let canonical = try metadataDiscovery.canonicalResourceURI(from: endpoint)
#expect(canonical.absoluteString == "https://mcp.example.com/server/mcp")
}
@Test("Canonical resource URI rejects missing scheme")
func testCanonicalResourceURIRejectsMissingScheme() {
#expect(throws: OAuthAuthorizationError.self) {
_ = try metadataDiscovery.canonicalResourceURI(from: URL(string: "mcp.example.com")!)
}
}
@Test("Canonical resource URI rejects insecure non-loopback http scheme")
func testCanonicalResourceURIRejectsNonLoopbackHTTP() {
#expect(throws: OAuthAuthorizationError.self) {
_ = try metadataDiscovery.canonicalResourceURI(
from: URL(string: "http://mcp.example.com/resource")!
)
}
}
@Test("Canonical resource URI allows loopback http scheme")
func testCanonicalResourceURIAllowsLoopbackHTTP() throws {
let canonical = try metadataDiscovery.canonicalResourceURI(
from: URL(string: "http://localhost:8080/mcp")!
)
#expect(canonical.absoluteString == "http://localhost:8080/mcp")
}
@Test("Canonical resource URI rejects fragment")
func testCanonicalResourceURIRejectsFragment() {
#expect(throws: OAuthAuthorizationError.self) {
_ = try metadataDiscovery.canonicalResourceURI(
from: URL(string: "https://mcp.example.com#fragment")!
)
}
}
@Test("Protected resource matching allows same-origin parent resource")
func testProtectedResourceMatchingParentResource() {
let resource = URL(string: "https://mcp.example.com")!
let endpoint = URL(string: "https://mcp.example.com/mcp")!
#expect(metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: endpoint))
}
@Test("Protected resource matching enforces path boundaries")
func testProtectedResourceMatchingPathBoundary() {
let resource = URL(string: "https://mcp.example.com/mcp")!
let validEndpoint = URL(string: "https://mcp.example.com/mcp/tools")!
let invalidEndpoint = URL(string: "https://mcp.example.com/mcp2")!
#expect(metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: validEndpoint))
#expect(!metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: invalidEndpoint))
}
@Test("Protected resource matching rejects origin mismatches")
func testProtectedResourceMatchingOriginMismatch() {
let resource = URL(string: "https://evil.example.com/mcp")!
let endpoint = URL(string: "https://mcp.example.com/mcp")!
#expect(!metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: endpoint))
}
@Test("Scope selection prefers challenge scope")
func testScopeSelection() {
let selected = DefaultOAuthScopeSelector().selectScopes(
challengeScope: "files:read",
scopesSupported: ["files:read", "files:write"]
)
#expect(selected == Set(["files:read"]))
let fallback = DefaultOAuthScopeSelector().selectScopes(
challengeScope: nil,
scopesSupported: ["files:read", "files:write"]
)
#expect(fallback == Set(["files:read", "files:write"]))
let omitted = DefaultOAuthScopeSelector().selectScopes(challengeScope: nil, scopesSupported: nil)
#expect(omitted == nil)
}
#if canImport(CryptoKit)
@Test("private_key_jwt helper builds signed JWT with expected claims")
func testPrivateKeyJWTAssertionHelper() throws {
let tokenEndpoint = URL(string: "https://auth.example.com/oauth/token")!
let assertion = try OAuthConfiguration.makePrivateKeyJWTAssertion(
clientID: "test-client",
tokenEndpoint: tokenEndpoint,
privateKeyPEM: Self.testPrivateKeyPEM,
audience: "https://auth.example.com",
issuedAt: Date(timeIntervalSince1970: 1_700_000_000),
expiresIn: 300
)
let parts = assertion.split(separator: ".")
#expect(parts.count == 3)
let headerData = Self.decodeBase64URL(parts[0])
let payloadData = Self.decodeBase64URL(parts[1])
#expect(headerData != nil)
#expect(payloadData != nil)
let header = try JSONSerialization.jsonObject(with: headerData!) as? [String: Any]
let payload = try JSONSerialization.jsonObject(with: payloadData!) as? [String: Any]
#expect(header?["alg"] as? String == "ES256")
#expect(header?["typ"] as? String == "JWT")
#expect(payload?["iss"] as? String == "test-client")
#expect(payload?["sub"] as? String == "test-client")
#expect(payload?["aud"] as? String == "https://auth.example.com")
#expect(payload?["iat"] as? Int == 1_700_000_000)
#expect(payload?["exp"] as? Int == 1_700_000_300)
#expect((payload?["jti"] as? String)?.isEmpty == false)
}
#endif
@Test("private_key_jwt helper rejects non-positive lifetime")
func testPrivateKeyJWTAssertionRejectsNonPositiveLifetime() {
#expect(throws: OAuthConfiguration.PrivateKeyJWTAssertionError.self) {
_ = try OAuthConfiguration.makePrivateKeyJWTAssertion(
clientID: "test-client",
tokenEndpoint: URL(string: "https://auth.example.com/token")!,
privateKeyPEM: Self.testPrivateKeyPEM,
expiresIn: 0
)
}
}
// MARK: - WWW-Authenticate Parser Edge Cases
@Test("Parse Bearer returns nil when WWW-Authenticate header is absent")
func testParseBearerReturnsNilWhenHeaderAbsent() {
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: ["Content-Type": "text/plain"])
#expect(challenge == nil)
}
@Test("Parse Bearer returns nil for empty header value")
func testParseBearerReturnsNilForEmptyValue() {
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: ["WWW-Authenticate": ""])
#expect(challenge == nil)
}
@Test("Parse Bearer returns empty challenge for bare Bearer with no parameters")
func testParseBearerBareBearerScheme() {
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(from: ["WWW-Authenticate": "Bearer"])
#expect(challenge != nil)
#expect(challenge?.parameters.isEmpty == true)
}
@Test("Parse Bearer header name lookup is case-insensitive")
func testParseBearerCaseInsensitiveHeaderName() {
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(
from: ["www-authenticate": "Bearer scope=\"read\""]
)
#expect(challenge != nil)
#expect(challenge?.scope == "read")
}
@Test("Parse Bearer returns nil when only non-Bearer schemes present")
func testParseBearerReturnsNilForNonBearerScheme() {
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(
from: ["WWW-Authenticate": "Basic realm=\"example\""]
)
#expect(challenge == nil)
}
// MARK: - Metadata Discovery Edge Cases
@Test("Protected resource metadata returns empty for non-HTTPS endpoint")
func testProtectedResourceMetadataRejectsHTTP() {
let urls = metadataDiscovery.protectedResourceMetadataURLs(
for: URL(string: "http://remote.example.com/mcp")!
)
#expect(urls.isEmpty)
}
@Test("Protected resource metadata for root endpoint produces path-specific and root URLs")
func testProtectedResourceMetadataRootEndpoint() {
let urls = metadataDiscovery.protectedResourceMetadataURLs(
for: URL(string: "https://example.com")!
)
#expect(urls.count == 2)
#expect(urls.allSatisfy {
$0 == URL(string: "https://example.com/.well-known/oauth-protected-resource")!
})
}
@Test("Protected resource metadata allows loopback HTTP")
func testProtectedResourceMetadataAllowsLoopbackHTTP() {
let urls = metadataDiscovery.protectedResourceMetadataURLs(
for: URL(string: "http://localhost:8080/mcp")!
)
#expect(!urls.isEmpty)
}
@Test("Authorization server metadata returns empty for non-HTTPS issuer")
func testAuthorizationServerMetadataRejectsHTTP() {
let urls = metadataDiscovery.authorizationServerMetadataURLs(
for: URL(string: "http://remote.example.com")!
)
#expect(urls.isEmpty)
}
@Test("Authorization server fallback issuer derives origin from endpoint")
func testAuthorizationServerFallbackIssuer() throws {
let issuer = try metadataDiscovery.authorizationServerFallbackIssuer(
from: URL(string: "https://mcp.example.com:8443/server/mcp")!
)
#expect(issuer.absoluteString == "https://mcp.example.com:8443")
}
@Test("Authorization server fallback issuer normalizes case and strips query")
func testAuthorizationServerFallbackIssuerNormalization() throws {
let issuer = try metadataDiscovery.authorizationServerFallbackIssuer(
from: URL(string: "HTTPS://AUTH.EXAMPLE.COM/path?q=1")!
)
#expect(issuer.absoluteString == "https://auth.example.com")
}
@Test("Authorization server fallback issuer rejects non-HTTPS non-loopback")
func testAuthorizationServerFallbackIssuerRejectsInsecure() {
#expect(throws: OAuthAuthorizationError.self) {
_ = try metadataDiscovery.authorizationServerFallbackIssuer(
from: URL(string: "http://remote.example.com/mcp")!
)
}
}
@Test("Protected resource matching with port mismatch")
func testProtectedResourceMatchingPortMismatch() {
let resource = URL(string: "https://mcp.example.com:8443")!
let endpoint = URL(string: "https://mcp.example.com:9443/mcp")!
#expect(!metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: endpoint))
}
@Test("Protected resource matching with exact path")
func testProtectedResourceMatchingExactPath() {
let resource = URL(string: "https://mcp.example.com/mcp")!
let endpoint = URL(string: "https://mcp.example.com/mcp")!
#expect(metadataDiscovery.protectedResourceMatches(resource: resource, endpoint: endpoint))
}
// MARK: - Scope Selector Edge Cases
@Test("parseScopeString splits on whitespace and ignores empty tokens")
func testParseScopeString() {
let scopes = DefaultOAuthScopeSelector().parseScopeString("files:read files:write\tprofile")
#expect(scopes == Set(["files:read", "files:write", "profile"]))
}
@Test("parseScopeString returns empty set for blank string")
func testParseScopeStringEmpty() {
let scopes = DefaultOAuthScopeSelector().parseScopeString(" ")
#expect(scopes.isEmpty)
}
@Test("serialize produces sorted space-separated string")
func testSerializeScopes() {
let result = DefaultOAuthScopeSelector().serialize(Set(["write", "read", "admin"]))
#expect(result == "admin read write")
}
@Test("serialize returns nil for empty set")
func testSerializeScopesEmpty() {
let result = DefaultOAuthScopeSelector().serialize(Set())
#expect(result == nil)
}
@Test("selectScopes returns nil for empty challenge scope string")
func testSelectScopesEmptyChallengeScope() {
let result = DefaultOAuthScopeSelector().selectScopes(
challengeScope: " ",
scopesSupported: ["files:read"]
)
#expect(result == nil)
}
@Test("selectScopes returns nil for empty scopesSupported array")
func testSelectScopesEmptyScopesSupported() {
let result = DefaultOAuthScopeSelector().selectScopes(
challengeScope: nil,
scopesSupported: []
)
#expect(result == nil)
}
// MARK: - Token Type Validation
@Test("makePrivateKeyJWTAssertion — token type empty string is rejected")
func testEmptyTokenTypeIsRejected() throws {
// OAuthTokenResponse decodes token_type from JSON; simulate an empty value via
// direct struct construction using the internal initializer path.
let json = #"{"access_token":"tok","token_type":"","expires_in":3600}"#
let data = json.data(using: .utf8)!
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
// The token_type is empty; the authorizer guard must reject this.
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
let isValid = !tokenType.isEmpty
&& tokenType.caseInsensitiveCompare("Bearer") == .orderedSame
#expect(!isValid, "Empty token_type must not be accepted as Bearer")
}
@Test("Token type whitespace-only is rejected")
func testWhitespaceTokenTypeIsRejected() throws {
let json = #"{"access_token":"tok","token_type":" ","expires_in":3600}"#
let data = json.data(using: .utf8)!
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
let isValid = !tokenType.isEmpty
&& tokenType.caseInsensitiveCompare("Bearer") == .orderedSame
#expect(!isValid, "Whitespace-only token_type must not be accepted as Bearer")
}
// MARK: - InMemoryTokenStorage
@Test("InMemoryTokenStorage save and load round-trip")
func testTokenStorageSaveAndLoad() {
let storage = InMemoryTokenStorage()
#expect(storage.load() == nil)
let token = OAuthAccessToken(
value: "access-123",
tokenType: "Bearer",
expiresAt: nil,
scopes: ["read"],
authorizationServer: nil,
refreshToken: nil
)
storage.save(token)
let loaded = storage.load()
#expect(loaded?.value == "access-123")
#expect(loaded?.tokenType == "Bearer")
#expect(loaded?.scopes == Set(["read"]))
}
@Test("InMemoryTokenStorage clear removes stored token")
func testTokenStorageClear() {
let storage = InMemoryTokenStorage()
let token = OAuthAccessToken(
value: "access-456",
tokenType: "Bearer",
expiresAt: nil,
scopes: [],
authorizationServer: nil,
refreshToken: nil
)
storage.save(token)
#expect(storage.load() != nil)
storage.clear()
#expect(storage.load() == nil)
}
@Test("InMemoryTokenStorage save overwrites previous token")
func testTokenStorageSaveOverwrites() {
let storage = InMemoryTokenStorage()
let first = OAuthAccessToken(
value: "first",
tokenType: "Bearer",
expiresAt: nil,
scopes: [],
authorizationServer: nil,
refreshToken: nil
)
let second = OAuthAccessToken(
value: "second",
tokenType: "Bearer",
expiresAt: nil,
scopes: [],
authorizationServer: nil,
refreshToken: nil
)
storage.save(first)
storage.save(second)
#expect(storage.load()?.value == "second")
}
// MARK: - Proactive Token Refresh
@Test("prepareAuthorization does nothing when proactiveRefreshWindowSeconds is zero")
func testPrepareAuthorizationSkipsWhenWindowIsZero() async throws {
let storage = InMemoryTokenStorage()
// Token expires in 50 s — within a large proactive window but not within the 30 s default skew.
storage.save(OAuthAccessToken(
value: "original-token",
tokenType: "Bearer",
expiresAt: Date().addingTimeInterval(50),
scopes: [],
authorizationServer: nil,
refreshToken: "some-refresh-token"
))
let config = OAuthConfiguration(
authentication: .none(clientID: "client"),
proactiveRefreshWindowSeconds: 0
)
let authorizer = OAuthAuthorizer(configuration: config, tokenStorage: storage)
try await authorizer.prepareAuthorization(
for: URL(string: "https://example.com/mcp")!,
session: .shared
)
// Token must be unchanged — proactive refresh is disabled.
#expect(storage.load()?.value == "original-token")
}
@Test("prepareAuthorization does nothing without cached authorization server metadata")
func testPrepareAuthorizationSkipsWithoutCachedASMetadata() async throws {
let storage = InMemoryTokenStorage()
// Token expires in 50 s — within the 400 s proactive window.
storage.save(OAuthAccessToken(
value: "original-token",
tokenType: "Bearer",
expiresAt: Date().addingTimeInterval(50),
scopes: [],
authorizationServer: nil,
refreshToken: "some-refresh-token"
))
let config = OAuthConfiguration(
authentication: .none(clientID: "client"),
proactiveRefreshWindowSeconds: 400
)
// No handleChallenge call → authorizationServerMetadata remains nil.
let authorizer = OAuthAuthorizer(configuration: config, tokenStorage: storage)
try await authorizer.prepareAuthorization(
for: URL(string: "https://example.com/mcp")!,
session: .shared
)
// Token must be unchanged — refresh requires cached AS metadata.
#expect(storage.load()?.value == "original-token")
}
// MARK: - WWW-Authenticate Bare Scheme Detection
@Test("Bare scheme Digest terminates preceding Bearer parameter collection")
func testBareSchemeTerminatesBearer() {
// "Digest" has no params and no '=', so it must start a new challenge and stop
// the Bearer parameter collector before "scope" from the second Bearer leaks in.
let header = #"Bearer scope="a", Digest, Bearer scope="b""#
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(
from: ["WWW-Authenticate": header])
#expect(challenge != nil)
// The outer loop stops at the first Bearer, so scope must be "a", not "b".
#expect(challenge?.scope == "a")
}
@Test("Non-Bearer leading scheme followed by Bearer is parsed correctly")
func testNonBearerLeadingSchemeFollowedByBearer() {
let header = #"SomeScheme, Bearer scope="test""#
let challenge = DefaultOAuthWWWAuthenticateParser().parseBearer(
from: ["WWW-Authenticate": header])
#expect(challenge != nil)
#expect(challenge?.scope == "test")
}
// MARK: - Private IP Blocking
@Test("privateIPAddressBlocked error has informative description")
func testPrivateIPAddressBlockedErrorDescription() {
let url = "https://169.254.169.254/.well-known/oauth-protected-resource"
let error = OAuthAuthorizationError.privateIPAddressBlocked(
context: "Protected resource metadata URL", url: url)
let description = error.errorDescription ?? ""
#expect(description.contains("private or reserved IP"))
#expect(description.contains(url))
}
@Test("cimdNotSupported is thrown when CIMD URL provided but server does not support it")
func testCIMDNotSupportedError() {
// Verify the error case exists and its description is meaningful.
let error = OAuthAuthorizationError.cimdNotSupported(
clientID: "https://client.example.com/client-metadata.json")
let description = error.errorDescription ?? ""
#expect(description.contains("Client ID Metadata Document"))
#expect(description.contains("client.example.com"))
}
// MARK: - BearerTokenValidator Audience and Expiry Tests
private let testResourceMetadataURL =
URL(string: "https://api.example.com/.well-known/oauth-protected-resource")!
private let testResourceIdentifier = URL(string: "https://api.example.com")!
private func makeBearerValidator(
tokenValidator: @escaping BearerTokenValidator.TokenValidator
) -> BearerTokenValidator {
BearerTokenValidator(
resourceMetadataURL: testResourceMetadataURL,
resourceIdentifier: testResourceIdentifier,
tokenValidator: tokenValidator
)
}
private func makeRequest(authorization: String) -> HTTPRequest {
HTTPRequest(
method: "POST",
headers: [HTTPHeaderName.authorization: authorization],
path: "/mcp"
)
}
private func makeContext() -> HTTPValidationContext {
HTTPValidationContext(httpMethod: "POST", isInitializationRequest: false)
}
@Test("BearerTokenValidator allows valid token with nil audience (opaque token)")
func testBearerValidatorNilAudienceAllows() {
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(audience: nil))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result == nil)
}
@Test("BearerTokenValidator allows token with matching audience")
func testBearerValidatorMatchingAudienceAllows() {
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(audience: ["https://api.example.com"]))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result == nil)
}
@Test("BearerTokenValidator allows token when one aud entry matches")
func testBearerValidatorOneOfMultipleAudienceMatches() {
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(audience: ["https://other.example.com", "https://api.example.com"]))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result == nil)
}
@Test("BearerTokenValidator returns 401 invalid_token when audience does not match")
func testBearerValidatorAudienceMismatchReturns401() {
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(audience: ["https://other.example.com"]))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result?.statusCode == 401)
let challenge = result?.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("error=\"invalid_token\"") == true)
#expect(challenge?.contains("Token audience mismatch") == true)
}
@Test("BearerTokenValidator returns 401 invalid_token when token is expired")
func testBearerValidatorExpiredTokenReturns401() {
let pastDate = Date(timeIntervalSinceNow: -3600)
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(expiresAt: pastDate))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result?.statusCode == 401)
let challenge = result?.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("error=\"invalid_token\"") == true)
#expect(challenge?.contains("Token has expired") == true)
}
@Test("BearerTokenValidator allows token that has not yet expired")
func testBearerValidatorNonExpiredTokenAllows() {
let futureDate = Date(timeIntervalSinceNow: 3600)
let validator = makeBearerValidator { _, _, _ in
.valid(BearerTokenInfo(expiresAt: futureDate))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result == nil)
}
@Test("BearerTokenValidator checks expiry before audience")
func testBearerValidatorExpiryCheckedBeforeAudience() {
let pastDate = Date(timeIntervalSinceNow: -1)
let validator = makeBearerValidator { _, _, _ in
// Expired token but matching audience — expiry should win
.valid(BearerTokenInfo(audience: ["https://api.example.com"], expiresAt: pastDate))
}
let result = validator.validate(makeRequest(authorization: "Bearer tok"), context: makeContext())
#expect(result?.statusCode == 401)
let challenge = result?.headers[HTTPHeaderName.wwwAuthenticate]
#expect(challenge?.contains("Token has expired") == true)
}
}

View file

@ -0,0 +1,384 @@
@preconcurrency import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// MARK: - Mock Implementations
final class MockURLValidator: OAuthURLValidating, @unchecked Sendable {
var validateHTTPSOrLoopbackCallCount = 0
var validateAuthorizationServerCallCount = 0
var validateRedirectURICallCount = 0
var shouldThrow: Error?
func validateHTTPSOrLoopback(_ url: URL, context: String) throws {
validateHTTPSOrLoopbackCallCount += 1
if let error = shouldThrow { throw error }
}
func validateAuthorizationServer(_ url: URL, context: String) throws {
validateAuthorizationServerCallCount += 1
if let error = shouldThrow { throw error }
}
func validateRedirectURI(_ url: URL) throws {
validateRedirectURICallCount += 1
if let error = shouldThrow { throw error }
}
func isPrivateIPHost(_ host: String) -> Bool { false }
}
final class MockDiscoveryClient: OAuthDiscoveryFetching, @unchecked Sendable {
var fetchProtectedResourceMetadataCallCount = 0
var fetchAuthorizationServerMetadataCallCount = 0
let metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
var protectedResourceMetadataResult: OAuthProtectedResourceMetadata
var authorizationServerMetadataResult: (server: URL, metadata: OAuthAuthorizationServerMetadata)
init(
authorizationServer: URL = URL(string: "https://auth.example.com")!,
tokenEndpoint: URL = URL(string: "https://auth.example.com/token")!
) {
self.protectedResourceMetadataResult = OAuthProtectedResourceMetadata(
resource: nil,
authorizationServers: [authorizationServer],
scopesSupported: nil
)
self.authorizationServerMetadataResult = (
server: authorizationServer,
metadata: OAuthAuthorizationServerMetadata(
issuer: authorizationServer,
authorizationEndpoint: URL(string: "https://auth.example.com/authorize"),
tokenEndpoint: tokenEndpoint,
registrationEndpoint: nil,
codeChallengeMethodsSupported: ["S256"],
tokenEndpointAuthMethodsSupported: nil,
clientIDMetadataDocumentSupported: nil
)
)
}
func fetchProtectedResourceMetadata(candidates: [URL], session: URLSession) async throws -> OAuthProtectedResourceMetadata {
fetchProtectedResourceMetadataCallCount += 1
return protectedResourceMetadataResult
}
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
fetchAuthorizationServerMetadataCallCount += 1
return authorizationServerMetadataResult
}
}
final class MockTokenClient: OAuthTokenRequesting, @unchecked Sendable {
var requestCallCount = 0
var capturedParameters: [String: String]?
var tokenResponse = OAuthTokenResponse(
accessToken: "mock-access-token",
tokenType: "Bearer",
expiresIn: 3600,
scope: nil,
refreshToken: nil
)
func request(
parameters: inout [String: String],
endpoint: URL,
authentication: OAuthConfiguration.TokenEndpointAuthentication,
session: URLSession
) async throws -> OAuthTokenResponse {
requestCallCount += 1
capturedParameters = parameters
return tokenResponse
}
}
final class MockClientRegistrar: OAuthClientRegistering, @unchecked Sendable {
var registerCallCount = 0
func register(
configuration: OAuthConfiguration,
asMetadata: OAuthAuthorizationServerMetadata,
session: URLSession
) async throws -> (
response: OAuthClientRegistrationResponse,
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
)? {
registerCallCount += 1
return nil
}
}
final class MockAuthCodeFlow: OAuthAuthorizationCodeFlowing, @unchecked Sendable {
var buildURLCallCount = 0
var performCallCount = 0
var authorizationCode = "mock-auth-code"
func buildURL(
authorizationEndpoint: URL,
resource: URL,
redirectURI: URL,
clientID: String,
codeChallenge: String,
scopes: Set<String>?,
state: String,
scopeSerializer: any OAuthScopeSelecting
) throws -> URL {
buildURLCallCount += 1
return URL(string: "https://auth.example.com/authorize?code=stub")!
}
func perform(
authorizationURL: URL,
redirectURI: URL,
state: String,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String {
performCallCount += 1
return authorizationCode
}
}
// MARK: - OAuthAuthorizer Invocation Tests
@Suite("OAuthAuthorizer dependency invocations")
struct OAuthAuthorizerTests {
let endpoint = URL(string: "https://mcp.example.com/mcp")!
let headers401 = [
"WWW-Authenticate":
"Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\""
]
func makeAuthorizer(
grantType: OAuthConfiguration.GrantType = .clientCredentials,
urlValidator: MockURLValidator = MockURLValidator(),
discoveryClient: MockDiscoveryClient = MockDiscoveryClient(),
tokenClient: MockTokenClient = MockTokenClient(),
registrar: MockClientRegistrar = MockClientRegistrar(),
authCodeFlow: MockAuthCodeFlow = MockAuthCodeFlow()
) -> OAuthAuthorizer {
let config = OAuthConfiguration(
grantType: grantType,
authentication: .clientSecretBasic(clientID: "client", clientSecret: "secret")
)
return OAuthAuthorizer(
configuration: config,
urlValidator: urlValidator,
discoveryClient: discoveryClient,
tokenEndpointClient: tokenClient,
clientRegistrar: registrar,
authCodeFlow: authCodeFlow
)
}
// MARK: - validateEndpointSecurity
@Test("validateEndpointSecurity calls urlValidator")
func testValidateEndpointSecurityCallsURLValidator() throws {
let validator = MockURLValidator()
let authorizer = makeAuthorizer(urlValidator: validator)
try authorizer.validateEndpointSecurity(for: endpoint)
#expect(validator.validateHTTPSOrLoopbackCallCount == 1)
}
@Test("validateEndpointSecurity propagates validation error")
func testValidateEndpointSecurityPropagatesError() {
let validator = MockURLValidator()
validator.shouldThrow = OAuthAuthorizationError.insecureOAuthEndpoint(
context: "test", url: "http://example.com")
let authorizer = makeAuthorizer(urlValidator: validator)
#expect(throws: OAuthAuthorizationError.self) {
try authorizer.validateEndpointSecurity(for: endpoint)
}
}
// MARK: - handleChallenge (401 — client_credentials)
@Test("handleChallenge 401 calls discovery and token clients")
func testHandleChallenge401CallsDiscoveryAndTokenClient() async throws {
let discovery = MockDiscoveryClient()
let tokenClient = MockTokenClient()
let authorizer = makeAuthorizer(
discoveryClient: discovery,
tokenClient: tokenClient
)
let handled = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(handled == true)
#expect(discovery.fetchProtectedResourceMetadataCallCount >= 1)
#expect(discovery.fetchAuthorizationServerMetadataCallCount >= 1)
#expect(tokenClient.requestCallCount == 1)
}
@Test("handleChallenge 401 uses client_credentials grant type parameter")
func testHandleChallenge401ClientCredentialsGrantType() async throws {
let tokenClient = MockTokenClient()
let authorizer = makeAuthorizer(tokenClient: tokenClient)
_ = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(tokenClient.capturedParameters?["grant_type"] == "client_credentials")
}
@Test("handleChallenge 401 attaches resource parameter")
func testHandleChallenge401AttachesResourceParameter() async throws {
let tokenClient = MockTokenClient()
let authorizer = makeAuthorizer(tokenClient: tokenClient)
_ = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(tokenClient.capturedParameters?["resource"] != nil)
}
// MARK: - handleChallenge (authorization_code)
#if canImport(CryptoKit)
@Test("handleChallenge 401 calls authCodeFlow for authorization_code grant")
func testHandleChallenge401AuthorizationCodeCallsFlow() async throws {
let authCodeFlow = MockAuthCodeFlow()
let tokenClient = MockTokenClient()
let config = OAuthConfiguration(
grantType: .authorizationCode,
authentication: .none(clientID: "my-client"),
authorizationRedirectURI: URL(string: "https://app.example.com/callback")!
)
let authorizer = OAuthAuthorizer(
configuration: config,
urlValidator: MockURLValidator(),
discoveryClient: MockDiscoveryClient(),
tokenEndpointClient: tokenClient,
clientRegistrar: MockClientRegistrar(),
authCodeFlow: authCodeFlow
)
_ = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(authCodeFlow.buildURLCallCount == 1)
#expect(authCodeFlow.performCallCount == 1)
#expect(tokenClient.capturedParameters?["grant_type"] == "authorization_code")
#expect(tokenClient.capturedParameters?["code"] == "mock-auth-code")
}
#endif
// MARK: - handleChallenge (403)
@Test("handleChallenge 403 returns false for non-insufficient_scope error")
func testHandleChallenge403NonInsufficientScope() async throws {
let authorizer = makeAuthorizer()
let handled = try await authorizer.handleChallenge(
statusCode: 403,
headers: ["WWW-Authenticate": "Bearer error=\"access_denied\""],
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(handled == false)
}
@Test("handleChallenge 403 insufficient_scope acquires token with upgraded scopes")
func testHandleChallenge403InsufficientScope() async throws {
let tokenClient = MockTokenClient()
let discovery = MockDiscoveryClient()
let authorizer = makeAuthorizer(
discoveryClient: discovery,
tokenClient: tokenClient
)
let handled = try await authorizer.handleChallenge(
statusCode: 403,
headers: [
"WWW-Authenticate":
"Bearer error=\"insufficient_scope\", scope=\"admin\""
],
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(handled == true)
#expect(tokenClient.requestCallCount == 1)
}
// MARK: - Client registration
@Test("handleChallenge calls client registrar when authentication is .none")
func testHandleChallengeCallsRegistrar() async throws {
let registrar = MockClientRegistrar()
let config = OAuthConfiguration(
authentication: .none(clientID: "plain-client"))
let authorizer = OAuthAuthorizer(
configuration: config,
urlValidator: MockURLValidator(),
discoveryClient: MockDiscoveryClient(),
tokenEndpointClient: MockTokenClient(),
clientRegistrar: registrar,
authCodeFlow: MockAuthCodeFlow()
)
_ = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(registrar.registerCallCount == 1)
}
@Test("handleChallenge skips client registrar when credentials are already configured")
func testHandleChallengeSkipsRegistrarWithCredentials() async throws {
let registrar = MockClientRegistrar()
let authorizer = makeAuthorizer(registrar: registrar)
_ = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(registrar.registerCallCount == 0)
}
}

View file

@ -0,0 +1,194 @@
@preconcurrency import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if swift(>=6.1) && !os(Linux)
@Suite("OAuthClientRegistrar", .serialized)
struct OAuthClientRegistrarTests {
let registrar = OAuthClientRegistrar(urlValidator: OAuthURLValidator())
let registrationEndpoint = URL(string: "https://auth.example.com/register")!
func makeASMetadata(registrationEndpoint: URL? = nil) -> OAuthAuthorizationServerMetadata {
OAuthAuthorizationServerMetadata(
issuer: URL(string: "https://auth.example.com"),
authorizationEndpoint: URL(string: "https://auth.example.com/authorize"),
tokenEndpoint: URL(string: "https://auth.example.com/token"),
registrationEndpoint: registrationEndpoint,
codeChallengeMethodsSupported: ["S256"],
tokenEndpointAuthMethodsSupported: nil,
clientIDMetadataDocumentSupported: nil
)
}
func makeConfig(
authentication: OAuthConfiguration.TokenEndpointAuthentication = .none(clientID: "")
) -> OAuthConfiguration {
OAuthConfiguration(authentication: authentication)
}
func successRegistrationBody(clientID: String = "registered-client") throws -> Data {
let dict: [String: Any] = ["client_id": clientID]
return try JSONSerialization.data(withJSONObject: dict)
}
// MARK: - Skip Conditions
@Test("Returns nil when authentication is not .none")
func testRegisterReturnsNilForNonNoneAuth() async throws {
let config = makeConfig(
authentication: .clientSecretBasic(clientID: "id", clientSecret: "secret"))
let (session, _) = makeIsolatedSession()
let result = try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(),
session: session
)
#expect(result == nil)
}
@Test("Returns nil when no registration endpoint and no CIMD")
func testRegisterReturnsNilWithoutRegistrationEndpoint() async throws {
let config = makeConfig(authentication: .none(clientID: "plain-client-id"))
let (session, _) = makeIsolatedSession()
let result = try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(registrationEndpoint: nil),
session: session
)
#expect(result == nil)
}
// MARK: - CIMD Errors
@Test("Throws when clientID is HTTPS URL with path but server does not support CIMD")
func testRegisterThrowsCIMDNotSupported() async throws {
let config = makeConfig(
authentication: .none(clientID: "https://client.example.com/metadata.json"))
let asMetadata = OAuthAuthorizationServerMetadata(
issuer: nil, authorizationEndpoint: nil, tokenEndpoint: nil,
registrationEndpoint: nil, codeChallengeMethodsSupported: nil,
tokenEndpointAuthMethodsSupported: nil, clientIDMetadataDocumentSupported: false
)
let (session, _) = makeIsolatedSession()
await #expect(throws: OAuthAuthorizationError.self) {
try await registrar.register(
configuration: config,
asMetadata: asMetadata,
session: session
)
}
}
// MARK: - Successful Registration
@Test("Returns registration response and updated authentication on success")
func testRegisterSucceeds() async throws {
let body = try successRegistrationBody(clientID: "registered-client")
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.registrationEndpoint, statusCode: 201,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
let config = makeConfig(authentication: .none(clientID: ""))
let result = try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(registrationEndpoint: registrationEndpoint),
session: session
)
let resultValue = try #require(result)
let expected = OAuthConfiguration.TokenEndpointAuthentication.none(clientID: "registered-client")
#expect(resultValue.updatedAuthentication == expected)
}
@Test("Throws on 4xx registration response")
func testRegisterThrowsOn4xx() async throws {
let errorBody = try JSONSerialization.data(
withJSONObject: ["error": "invalid_client_metadata"])
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.registrationEndpoint, statusCode: 400,
httpVersion: nil, headerFields: nil)!
return (response, errorBody)
}
let config = makeConfig(authentication: .none(clientID: ""))
await #expect(throws: OAuthAuthorizationError.self) {
try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(registrationEndpoint: registrationEndpoint),
session: session
)
}
}
@Test("Throws tokenRequestFailed on non-2xx non-4xx response")
func testRegisterThrowsOn5xx() async throws {
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.registrationEndpoint, statusCode: 503,
httpVersion: nil, headerFields: nil)!
return (response, Data())
}
let config = makeConfig(authentication: .none(clientID: ""))
let error = await #expect(throws: OAuthAuthorizationError.self) {
try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(registrationEndpoint: registrationEndpoint),
session: session
)
}
guard case .tokenRequestFailed(let statusCode, let oauthError) = error else {
Issue.record("Expected tokenRequestFailed, got \(String(describing: error))")
return
}
#expect(statusCode == 503)
#expect(oauthError == nil)
}
// MARK: - updatedAuthentication helper
@Test("updatedAuthentication updates client ID and secret for basic auth")
func testUpdatedAuthenticationBasic() {
let registration = OAuthClientRegistrationResponse(
clientID: "new-id", clientSecret: "new-secret",
tokenEndpointAuthMethod: nil, clientSecretExpiresAt: nil
)
let result = OAuthClientRegistrar.updatedAuthentication(
from: registration,
current: .clientSecretBasic(clientID: "old-id", clientSecret: "old-secret")
)
let expected = OAuthConfiguration.TokenEndpointAuthentication.clientSecretBasic(clientID: "new-id", clientSecret: "new-secret")
#expect(result == expected)
}
@Test("updatedAuthentication falls back to existing secret when not returned")
func testUpdatedAuthenticationFallsBackToCurrentSecret() {
let registration = OAuthClientRegistrationResponse(
clientID: "new-id", clientSecret: nil,
tokenEndpointAuthMethod: nil, clientSecretExpiresAt: nil
)
let result = OAuthClientRegistrar.updatedAuthentication(
from: registration,
current: .clientSecretBasic(clientID: "old-id", clientSecret: "kept-secret")
)
let expected = OAuthConfiguration.TokenEndpointAuthentication.clientSecretBasic(clientID: "new-id", clientSecret: "kept-secret")
#expect(result == expected)
}
}
#endif

View file

@ -0,0 +1,90 @@
@preconcurrency import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Key-isolated mock URLProtocol.
///
/// Each test creates a session with a unique key and registers its handler under that key.
/// Multiple suites run concurrently without interference because each test's requests carry
/// a different key, so they never read the wrong handler.
#if swift(>=6.1)
actor IsolatedMockStorage {
static let shared = IsolatedMockStorage()
private var handlers:
[String: @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data)] = [:]
func set(
key: String,
handler: @escaping @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data)
) {
handlers[key] = handler
}
func execute(key: String, request: URLRequest) async throws -> (HTTPURLResponse, Data) {
guard let handler = handlers[key] else {
throw NSError(
domain: "IsolatedMockError", code: 0,
userInfo: [NSLocalizedDescriptionKey: "No handler for key '\(key)'"])
}
return try await handler(request)
}
}
final class IsolatedMockURLProtocol: URLProtocol, @unchecked Sendable {
static let sessionKeyHeader = "X-Mock-Key"
static func makeSession(key: String) -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [IsolatedMockURLProtocol.self]
config.httpAdditionalHeaders = [sessionKeyHeader: key]
return URLSession(configuration: config)
}
static func setHandler(
key: String,
_ handler: @escaping @Sendable (URLRequest) async throws -> (HTTPURLResponse, Data)
) async {
await IsolatedMockStorage.shared.set(key: key, handler: handler)
}
func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) {
let key = request.value(forHTTPHeaderField: IsolatedMockURLProtocol.sessionKeyHeader)
?? "unknown"
return try await IsolatedMockStorage.shared.execute(key: key, request: request)
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Task {
do {
let (response, data) = try await self.executeHandler(for: request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
}
override func stopLoading() {}
}
// MARK: - Per-test session factory
/// Creates an isolated URLSession + unique key for one test.
///
/// Usage:
/// ```swift
/// let (session, key) = makeIsolatedSession()
/// await IsolatedMockURLProtocol.setHandler(key: key) { _ in (response, data) }
/// ```
func makeIsolatedSession() -> (session: URLSession, key: String) {
let key = UUID().uuidString
return (IsolatedMockURLProtocol.makeSession(key: key), key)
}
#endif

View file

@ -0,0 +1,213 @@
@preconcurrency import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if swift(>=6.1) && !os(Linux)
@Suite("OAuthDiscoveryClient", .serialized)
struct OAuthDiscoveryClientTests {
let urlValidator = OAuthURLValidator(allowLoopbackHTTPForAuthorizationServer: true)
let metadataDiscovery = DefaultOAuthMetadataDiscovery()
func makeClient() -> OAuthDiscoveryClient {
OAuthDiscoveryClient(metadataDiscovery: metadataDiscovery, urlValidator: urlValidator)
}
func makeProtectedResourceBody(authorizationServers: [String]) throws -> Data {
let dict: [String: Any] = ["authorization_servers": authorizationServers]
return try JSONSerialization.data(withJSONObject: dict)
}
func makeASMetadataBody(issuer: String) throws -> Data {
let dict: [String: Any] = [
"issuer": issuer,
"token_endpoint": "https://auth.example.com/token",
"code_challenge_methods_supported": ["S256"],
]
return try JSONSerialization.data(withJSONObject: dict)
}
// MARK: - fetchProtectedResourceMetadata
@Test("Returns metadata from first successful candidate")
func testFetchProtectedResourceMetadataSuccess() async throws {
let body = try makeProtectedResourceBody(
authorizationServers: ["https://auth.example.com"])
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body)
}
let metadata = try await makeClient().fetchProtectedResourceMetadata(
candidates: [URL(string: "https://example.com/.well-known/oauth-protected-resource")!],
session: session
)
let expected = OAuthProtectedResourceMetadata(
resource: nil,
authorizationServers: [URL(string: "https://auth.example.com")!],
scopesSupported: nil)
#expect(metadata == expected)
}
@Test("Skips candidates that return non-2xx status")
func testFetchProtectedResourceMetadataSkipsNon2xx() async throws {
let body = try makeProtectedResourceBody(
authorizationServers: ["https://auth.example.com"])
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
let statusCode = request.url?.lastPathComponent == "mcp" ? 404 : 200
let response = HTTPURLResponse(
url: request.url!, statusCode: statusCode,
httpVersion: nil, headerFields: nil)!
return (response, statusCode == 200 ? body : Data())
}
let metadata = try await makeClient().fetchProtectedResourceMetadata(
candidates: [
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
],
session: session
)
let expected = OAuthProtectedResourceMetadata(
resource: nil,
authorizationServers: [URL(string: "https://auth.example.com")!],
scopesSupported: nil)
#expect(metadata == expected)
}
@Test("Skips candidates with empty authorizationServers array")
func testFetchProtectedResourceMetadataSkipsEmptyAuthServers() async throws {
let emptyBody = try makeProtectedResourceBody(authorizationServers: [])
let validBody = try makeProtectedResourceBody(
authorizationServers: ["https://auth.example.com"])
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
let body = request.url?.lastPathComponent == "mcp" ? emptyBody : validBody
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body)
}
let metadata = try await makeClient().fetchProtectedResourceMetadata(
candidates: [
URL(string: "https://example.com/.well-known/oauth-protected-resource/mcp")!,
URL(string: "https://example.com/.well-known/oauth-protected-resource")!,
],
session: session
)
let expected = OAuthProtectedResourceMetadata(
resource: nil,
authorizationServers: [URL(string: "https://auth.example.com")!],
scopesSupported: nil)
#expect(metadata == expected)
}
@Test("Throws metadataDiscoveryFailed when all candidates fail")
func testFetchProtectedResourceMetadataThrowsWhenAllFail() async throws {
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
await #expect(throws: OAuthAuthorizationError.self) {
try await makeClient().fetchProtectedResourceMetadata(
candidates: [
URL(string: "https://example.com/.well-known/oauth-protected-resource")!
],
session: session
)
}
}
// MARK: - fetchAuthorizationServerMetadata
@Test("Returns server and metadata when issuer matches")
func testFetchAuthorizationServerMetadataSuccess() async throws {
let issuer = "https://auth.example.com"
let body = try makeASMetadataBody(issuer: issuer)
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: URL(string: "\(issuer)/.well-known/oauth-authorization-server")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body)
}
let (server, metadata) = try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: issuer)!],
session: session
)
let expectedServer = URL(string: issuer)!
let expectedMetadata = OAuthAuthorizationServerMetadata(
issuer: URL(string: issuer),
authorizationEndpoint: nil,
tokenEndpoint: URL(string: "https://auth.example.com/token"),
registrationEndpoint: nil,
codeChallengeMethodsSupported: ["S256"],
tokenEndpointAuthMethodsSupported: nil,
clientIDMetadataDocumentSupported: nil)
#expect(server == expectedServer)
#expect(metadata == expectedMetadata)
}
@Test("Skips candidate when issuer field does not match")
func testFetchAuthorizationServerMetadataSkipsIssuerMismatch() async throws {
let wrongIssuerBody = try makeASMetadataBody(issuer: "https://other.example.com")
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: URL(string: "https://auth.example.com")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, wrongIssuerBody)
}
await #expect(throws: OAuthAuthorizationError.self) {
try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://auth.example.com")!],
session: session
)
}
}
@Test("Skips private IP candidates without making HTTP calls")
func testFetchAuthorizationServerMetadataSkipsPrivateIP() async throws {
let (session, _) = makeIsolatedSession()
await #expect(throws: OAuthAuthorizationError.self) {
try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://10.0.0.1")!],
session: session
)
}
}
@Test("Throws when all candidates return non-2xx")
func testFetchAuthorizationServerMetadataThrowsWhenAllFail() async throws {
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
await #expect(throws: OAuthAuthorizationError.self) {
try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://auth.example.com")!],
session: session
)
}
}
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,194 @@
@preconcurrency import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if swift(>=6.1) && !os(Linux)
@Suite("OAuthTokenEndpointClient", .serialized)
struct OAuthTokenEndpointClientTests {
let client = OAuthTokenEndpointClient(urlValidator: OAuthURLValidator())
let tokenEndpoint = URL(string: "https://auth.example.com/token")!
func successBody(
accessToken: String = "access-token",
tokenType: String = "Bearer",
scope: String? = nil,
refreshToken: String? = nil
) throws -> Data {
var dict: [String: Any] = [
"access_token": accessToken,
"token_type": tokenType,
"expires_in": 3600,
]
if let scope { dict["scope"] = scope }
if let refreshToken { dict["refresh_token"] = refreshToken }
return try JSONSerialization.data(withJSONObject: dict)
}
// MARK: - Success
@Test("Returns decoded token response on success")
func testRequestReturnsToken() async throws {
let body = try successBody()
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
var params = ["grant_type": "client_credentials"]
let result = try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
let expected = OAuthTokenResponse(
accessToken: "access-token", tokenType: "Bearer",
expiresIn: 3600, scope: nil, refreshToken: nil)
#expect(result == expected)
}
@Test("Parses optional scope and refresh token")
func testRequestParsesOptionalFields() async throws {
let body = try successBody(scope: "read write", refreshToken: "refresh-xyz")
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
var params = ["grant_type": "client_credentials"]
let result = try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
let expected = OAuthTokenResponse(
accessToken: "access-token", tokenType: "Bearer",
expiresIn: 3600, scope: "read write", refreshToken: "refresh-xyz")
#expect(result == expected)
}
// MARK: - Error Responses
@Test("Throws for non-2xx status")
func testRequestThrowsForNon2xx() async throws {
let errorBody = try JSONSerialization.data(withJSONObject: ["error": "invalid_client"])
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 401,
httpVersion: nil, headerFields: nil)!
return (response, errorBody)
}
var params = ["grant_type": "client_credentials"]
await #expect(throws: OAuthAuthorizationError.self) {
try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
}
}
@Test("Throws for empty access_token")
func testRequestThrowsForEmptyAccessToken() async throws {
let body = try successBody(accessToken: "")
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
var params = ["grant_type": "client_credentials"]
await #expect(throws: OAuthAuthorizationError.self) {
try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
}
}
@Test("Throws for non-Bearer token_type")
func testRequestThrowsForNonBearerTokenType() async throws {
let body = try successBody(tokenType: "MAC")
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { _ in
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
var params = ["grant_type": "client_credentials"]
await #expect(throws: OAuthAuthorizationError.self) {
try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
}
}
// MARK: - Form Encoding
@Test("Sends parameters as form-encoded POST body")
func testRequestSendsFormEncodedBody() async throws {
let body = try successBody()
let (session, key) = makeIsolatedSession()
actor RequestCapture { var value: URLRequest?; func set(_ r: URLRequest) { value = r } }
let capture = RequestCapture()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
await capture.set(request)
let response = HTTPURLResponse(
url: self.tokenEndpoint, statusCode: 200,
httpVersion: nil, headerFields: nil)!
return (response, body)
}
var params = ["grant_type": "client_credentials", "resource": "https://api.example.com"]
_ = try await client.request(
parameters: &params,
endpoint: tokenEndpoint,
authentication: .none(clientID: "client-id"),
session: session
)
let capturedRequest = await capture.value
let bodyData: Data = {
if let data = capturedRequest?.httpBody { return data }
guard let stream = capturedRequest?.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var data = Data()
let buf = UnsafeMutablePointer<UInt8>.allocate(capacity: 4096)
defer { buf.deallocate() }
while stream.hasBytesAvailable { data.append(buf, count: stream.read(buf, maxLength: 4096)) }
return data
}()
let bodyString = String(data: bodyData, encoding: .utf8) ?? ""
#expect(bodyString.contains("grant_type=client_credentials"))
#expect(bodyString.contains("resource="))
#expect(capturedRequest?.httpMethod == "POST")
}
}
#endif

View file

@ -0,0 +1,141 @@
import Foundation
import Testing
@testable import MCP
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@Suite("OAuthURLValidator")
struct OAuthURLValidatorTests {
// MARK: - validateHTTPSOrLoopback
@Test("Accepts HTTPS URL")
func testValidateHTTPSOrLoopbackAcceptsHTTPS() throws {
try OAuthURLValidator().validateHTTPSOrLoopback(
URL(string: "https://example.com/mcp")!, context: "test")
}
@Test("Accepts loopback HTTP")
func testValidateHTTPSOrLoopbackAcceptsLoopback() throws {
try OAuthURLValidator().validateHTTPSOrLoopback(
URL(string: "http://localhost:8080/mcp")!, context: "test")
try OAuthURLValidator().validateHTTPSOrLoopback(
URL(string: "http://127.0.0.1:9000/mcp")!, context: "test")
}
@Test("Rejects remote HTTP")
func testValidateHTTPSOrLoopbackRejectsRemoteHTTP() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateHTTPSOrLoopback(
URL(string: "http://example.com/mcp")!, context: "test")
}
}
@Test("Rejects URL with fragment")
func testValidateHTTPSOrLoopbackRejectsFragment() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateHTTPSOrLoopback(
URL(string: "https://example.com/mcp#frag")!, context: "test")
}
}
// MARK: - validateAuthorizationServer
@Test("Accepts HTTPS authorization server")
func testValidateAuthorizationServerAcceptsHTTPS() throws {
try OAuthURLValidator().validateAuthorizationServer(
URL(string: "https://auth.example.com")!, context: "test")
}
@Test("Rejects HTTP authorization server by default")
func testValidateAuthorizationServerRejectsHTTP() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateAuthorizationServer(
URL(string: "http://auth.example.com")!, context: "test")
}
}
@Test("Accepts loopback HTTP when flag is set")
func testValidateAuthorizationServerAcceptsLoopbackWhenAllowed() throws {
let v = OAuthURLValidator(allowLoopbackHTTPForAuthorizationServer: true)
try v.validateAuthorizationServer(
URL(string: "http://localhost:8080")!, context: "test")
}
@Test("Rejects loopback HTTP when flag is not set")
func testValidateAuthorizationServerRejectsLoopbackWhenNotAllowed() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateAuthorizationServer(
URL(string: "http://localhost:8080")!, context: "test")
}
}
// MARK: - validateRedirectURI
@Test("Accepts HTTPS redirect URI")
func testValidateRedirectURIAcceptsHTTPS() throws {
try OAuthURLValidator().validateRedirectURI(
URL(string: "https://app.example.com/callback")!)
}
@Test("Accepts loopback HTTP redirect URI")
func testValidateRedirectURIAcceptsLoopback() throws {
try OAuthURLValidator().validateRedirectURI(
URL(string: "http://localhost:8080/callback")!)
}
@Test("Rejects remote HTTP redirect URI")
func testValidateRedirectURIRejectsRemoteHTTP() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateRedirectURI(
URL(string: "http://app.example.com/callback")!)
}
}
@Test("Rejects redirect URI with fragment")
func testValidateRedirectURIRejectsFragment() {
#expect(throws: OAuthAuthorizationError.self) {
try OAuthURLValidator().validateRedirectURI(
URL(string: "https://app.example.com/callback#section")!)
}
}
// MARK: - isPrivateIPHost
@Test("Identifies private IPv4 ranges")
func testIsPrivateIPHostIPv4() {
let v = OAuthURLValidator()
#expect(v.isPrivateIPHost("10.0.0.1"))
#expect(v.isPrivateIPHost("172.16.0.1"))
#expect(v.isPrivateIPHost("172.31.255.255"))
#expect(v.isPrivateIPHost("192.168.1.1"))
#expect(v.isPrivateIPHost("169.254.169.254"))
#expect(v.isPrivateIPHost("100.64.0.1"))
}
@Test("Does not block public IPv4 addresses")
func testIsPrivateIPHostPublicIPv4() {
let v = OAuthURLValidator()
#expect(!v.isPrivateIPHost("1.2.3.4"))
#expect(!v.isPrivateIPHost("8.8.8.8"))
#expect(!v.isPrivateIPHost("203.0.113.1"))
}
@Test("Identifies private IPv6 ULA and link-local addresses")
func testIsPrivateIPHostIPv6() {
let v = OAuthURLValidator()
#expect(v.isPrivateIPHost("fc00::1"))
#expect(v.isPrivateIPHost("fd12:3456::1"))
#expect(v.isPrivateIPHost("fe80::1"))
}
@Test("Does not block public hostnames")
func testIsPrivateIPHostPublicHostname() {
let v = OAuthURLValidator()
#expect(!v.isPrivateIPHost("example.com"))
#expect(!v.isPrivateIPHost("localhost"))
}
}