* feat: improvement to oauth resources resolution * feat: improvements to refreshToken procedure and updates to unit tests * feat: improvements to documentation of clien_id
429 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|