swift-sdk/Tests/MCPTests/OAuthAuthorizerTests.swift
yehorsobko cb6a62f7c2
feat: auth improvements (#217)
* feat: improvement to oauth resources resolution

* feat: improvements to refreshToken procedure and updates to unit tests

* feat: improvements to documentation of clien_id
2026-04-17 17:34:59 +03:00

429 lines
15 KiB
Swift

@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], fallbackIssuer: 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
var registrationResult: (
response: OAuthClientRegistrationResponse,
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
)?
func register(
configuration: OAuthConfiguration,
asMetadata: OAuthAuthorizationServerMetadata,
session: URLSession
) async throws -> (
response: OAuthClientRegistrationResponse,
updatedAuthentication: OAuthConfiguration.TokenEndpointAuthentication
)? {
registerCallCount += 1
return registrationResult
}
}
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)
}
@Test("handleChallenge persists the DCR-assigned clientID on the saved token")
func testHandleChallengePersistsDCRClientIDOnToken() async throws {
let assignedClientID = "dcr-assigned-client-id"
let tokenStorage = InMemoryTokenStorage()
let registrar = MockClientRegistrar()
registrar.registrationResult = (
response: OAuthClientRegistrationResponse(
clientID: assignedClientID,
clientSecret: nil,
tokenEndpointAuthMethod: nil,
clientSecretExpiresAt: nil
),
updatedAuthentication: .none(clientID: assignedClientID)
)
let config = OAuthConfiguration(
authentication: .none(clientID: "")
)
let authorizer = OAuthAuthorizer(
configuration: config,
tokenStorage: tokenStorage,
urlValidator: MockURLValidator(),
discoveryClient: MockDiscoveryClient(),
tokenEndpointClient: MockTokenClient(),
clientRegistrar: registrar,
authCodeFlow: MockAuthCodeFlow()
)
let handled = try await authorizer.handleChallenge(
statusCode: 401,
headers: headers401,
endpoint: endpoint,
operationKey: nil,
session: .shared
)
#expect(handled == true)
#expect(registrar.registerCallCount == 1)
#expect(tokenStorage.load()?.clientID == assignedClientID)
}
}