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
12 changed files with 10925 additions and 199 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
|
|
|
|||
216
Tests/MCPTests/MockResponses.swift
Normal file
216
Tests/MCPTests/MockResponses.swift
Normal 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)
|
||||
178
Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift
Normal file
178
Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
735
Tests/MCPTests/OAuthAuthorizationTests.swift
Normal file
735
Tests/MCPTests/OAuthAuthorizationTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
384
Tests/MCPTests/OAuthAuthorizerTests.swift
Normal file
384
Tests/MCPTests/OAuthAuthorizerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
194
Tests/MCPTests/OAuthClientRegistrarTests.swift
Normal file
194
Tests/MCPTests/OAuthClientRegistrarTests.swift
Normal 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
|
||||
90
Tests/MCPTests/OAuthComponentTestHelpers.swift
Normal file
90
Tests/MCPTests/OAuthComponentTestHelpers.swift
Normal 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
|
||||
213
Tests/MCPTests/OAuthDiscoveryClientTests.swift
Normal file
213
Tests/MCPTests/OAuthDiscoveryClientTests.swift
Normal 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
|
||||
2361
Tests/MCPTests/OAuthTestScenarios.swift
Normal file
2361
Tests/MCPTests/OAuthTestScenarios.swift
Normal file
File diff suppressed because it is too large
Load diff
194
Tests/MCPTests/OAuthTokenEndpointClientTests.swift
Normal file
194
Tests/MCPTests/OAuthTokenEndpointClientTests.swift
Normal 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: ¶ms,
|
||||
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: ¶ms,
|
||||
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: ¶ms,
|
||||
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: ¶ms,
|
||||
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: ¶ms,
|
||||
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: ¶ms,
|
||||
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
|
||||
141
Tests/MCPTests/OAuthURLValidatorTests.swift
Normal file
141
Tests/MCPTests/OAuthURLValidatorTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue