* 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>
2361 lines
124 KiB
Swift
2361 lines
124 KiB
Swift
@preconcurrency import Foundation
|
|
import Testing
|
|
|
|
@testable import MCP
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
#if swift(>=6.1)
|
|
|
|
// MARK: - Scenario Context
|
|
|
|
struct OAuthScenarioContext: Sendable {
|
|
let testEndpoint: URL
|
|
let oauthConfiguration: OAuthConfiguration
|
|
let messageData: Data
|
|
let expectedResponseData: Data?
|
|
let expectedCallCounts: [URL: Int]
|
|
let streaming: Bool
|
|
let sseInitializationTimeout: TimeInterval?
|
|
let expectedErrorSubstring: String?
|
|
let unexpectedErrorSubstrings: [String]
|
|
let secondMessageData: Data?
|
|
let secondExpectedResponseData: Data?
|
|
|
|
init(
|
|
testEndpoint: URL,
|
|
oauthConfiguration: OAuthConfiguration,
|
|
messageData: Data,
|
|
expectedResponseData: Data? = nil,
|
|
expectedCallCounts: [URL: Int] = [:],
|
|
streaming: Bool = false,
|
|
sseInitializationTimeout: TimeInterval? = nil,
|
|
expectedErrorSubstring: String? = nil,
|
|
unexpectedErrorSubstrings: [String] = [],
|
|
secondMessageData: Data? = nil,
|
|
secondExpectedResponseData: Data? = nil
|
|
) {
|
|
self.testEndpoint = testEndpoint
|
|
self.oauthConfiguration = oauthConfiguration
|
|
self.messageData = messageData
|
|
self.expectedResponseData = expectedResponseData
|
|
self.expectedCallCounts = expectedCallCounts
|
|
self.streaming = streaming
|
|
self.sseInitializationTimeout = sseInitializationTimeout
|
|
self.expectedErrorSubstring = expectedErrorSubstring
|
|
self.unexpectedErrorSubstrings = unexpectedErrorSubstrings
|
|
self.secondMessageData = secondMessageData
|
|
self.secondExpectedResponseData = secondExpectedResponseData
|
|
}
|
|
}
|
|
|
|
// MARK: - Request Body Helper
|
|
|
|
func readRequestBody(_ request: URLRequest) -> Data? {
|
|
if let data = request.httpBody { return data }
|
|
guard let stream = request.httpBodyStream else { return nil }
|
|
stream.open()
|
|
defer { stream.close() }
|
|
let bufferSize = 4096
|
|
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
|
defer { buffer.deallocate() }
|
|
var data = Data()
|
|
while stream.hasBytesAvailable {
|
|
let bytesRead = stream.read(buffer, maxLength: bufferSize)
|
|
data.append(buffer, count: bytesRead)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// MARK: - Trackers
|
|
|
|
actor ProviderTracker: Sendable {
|
|
var capturedContext: OAuthConfiguration.AccessTokenProviderContext?
|
|
func capture(_ context: OAuthConfiguration.AccessTokenProviderContext) {
|
|
capturedContext = context
|
|
}
|
|
}
|
|
|
|
actor OrderTracker: Sendable {
|
|
var requests: [URL] = []
|
|
func append(_ url: URL) { requests.append(url) }
|
|
func count() -> Int { requests.count }
|
|
}
|
|
|
|
/// Dispenses tokens in order; repeats the last token when the list is exhausted.
|
|
actor TokenDispenser: Sendable {
|
|
private var tokens: [String]
|
|
init(_ tokens: String...) { self.tokens = Array(tokens) }
|
|
func next() -> String {
|
|
if tokens.count > 1 { return tokens.removeFirst() }
|
|
return tokens.first ?? "fallback-token"
|
|
}
|
|
}
|
|
|
|
// MARK: - Scenario Definitions
|
|
|
|
#if !canImport(FoundationNetworking)
|
|
|
|
extension RequestHandlerStorage {
|
|
|
|
// MARK: 1 - Client Credentials Retry After 401
|
|
|
|
func configureOAuthClientCredentialsRetryAfter401() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/test")!
|
|
let resourceMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource/test")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 1)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-123")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read", "files:write"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
#expect(request.httpMethod == "POST")
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains("resource=https%3A%2F%2Flocalhost%3A8080%2Ftest"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
#expect(body.contains("client_id=test-client"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "access-token-123")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":1}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 2 - Scope Fallback to scopes_supported
|
|
|
|
func configureOAuthScopeSelectionFallsBackToScopesSupported() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/fallback-scope")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/fallback-scope"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 21)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-fallback")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:write", "files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Ffallback-scope"))
|
|
#expect(body.contains("scope=files%3Aread%20files%3Awrite"))
|
|
#expect(body.contains("client_id=test-client"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "access-token-fallback")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":21}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 3 - Scope Omitted When No Hints
|
|
|
|
func configureOAuthScopeOmittedWhenNoHints() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/no-scope-hints")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/no-scope-hints"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 22)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-no-scope")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Fno-scope-hints"))
|
|
#expect(!body.contains("scope="))
|
|
#expect(body.contains("client_id=test-client"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "access-token-no-scope")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":22}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 4 - Resource Parameter in Authorization Code Flow
|
|
|
|
func configureOAuthResourceParameterInAuthorizationAndToken() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp?foo=bar")!
|
|
let canonicalResource = "https://localhost:8080/public/mcp"
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let authorizationEndpointURL = URL(
|
|
string: "https://localhost:8080/oauth/authorize")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 23)
|
|
|
|
requestHandler = {
|
|
[testEndpoint, canonicalResource, resourceMetadataURL, asMetadataURL, authorizationEndpointURL, tokenEndpointURL, finalResponseData]
|
|
request in
|
|
guard let url = request.url else {
|
|
throw MockResponses.mockError("Missing request URL")
|
|
}
|
|
|
|
if url.scheme == authorizationEndpointURL.scheme,
|
|
url.host == authorizationEndpointURL.host,
|
|
url.port == authorizationEndpointURL.port,
|
|
url.path == authorizationEndpointURL.path
|
|
{
|
|
let queryItems =
|
|
URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
|
?? []
|
|
#expect(
|
|
queryItems.contains(where: {
|
|
$0.name == "resource" && $0.value == canonicalResource
|
|
}))
|
|
#expect(
|
|
queryItems.contains(where: {
|
|
$0.name == "response_type" && $0.value == "code"
|
|
}))
|
|
#expect(
|
|
queryItems.contains(where: {
|
|
$0.name == "client_id" && $0.value == "test-client"
|
|
}))
|
|
#expect(
|
|
queryItems.contains(where: {
|
|
$0.name == "scope" && $0.value == "files:read"
|
|
}))
|
|
let state = queryItems.first(where: { $0.name == "state" })?.value
|
|
let redirectURI =
|
|
queryItems.first(where: { $0.name == "redirect_uri" })?.value
|
|
#expect(state != nil)
|
|
#expect(redirectURI != nil)
|
|
|
|
var redirectComponents = URLComponents(string: redirectURI ?? "")
|
|
var redirectQueryItems = redirectComponents?.queryItems ?? []
|
|
redirectQueryItems.append(.init(name: "code", value: "test"))
|
|
redirectQueryItems.append(.init(name: "state", value: state))
|
|
redirectComponents?.queryItems = redirectQueryItems
|
|
let locationValue =
|
|
redirectComponents?.url?.absoluteString
|
|
?? "http://127.0.0.1:3000/callback?code=test&state=\(state ?? "")"
|
|
let response = HTTPURLResponse(
|
|
url: url, statusCode: 302, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Location": locationValue])!
|
|
return (response, Data())
|
|
}
|
|
|
|
switch url {
|
|
case testEndpoint:
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-resource")
|
|
#expect(request.url?.query == "foo=bar")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
|
|
case resourceMetadataURL:
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
|
|
case asMetadataURL:
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["S256"]
|
|
)(request)
|
|
|
|
case tokenEndpointURL:
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=authorization_code"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Fpublic%2Fmcp"))
|
|
#expect(!body.contains("%3Ffoo%3Dbar"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
#expect(body.contains("client_id=test-client"))
|
|
#expect(body.contains("code_verifier="))
|
|
#expect(body.contains("code=test"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "access-token-resource")(request)
|
|
|
|
default:
|
|
throw MockResponses.mockError("Unexpected URL: \(url.absoluteString)")
|
|
}
|
|
}
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client")
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":23}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 5 - Rejects Authorization Without PKCE Metadata
|
|
|
|
func configureOAuthRejectsAuthorizationWithoutPKCEMetadata() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/pkce-metadata-missing")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/pkce-metadata-missing"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let authorizationEndpointURL = URL(
|
|
string: "https://localhost:8080/oauth/authorize")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = {
|
|
[testEndpoint, resourceMetadataURL, asMetadataURL, authorizationEndpointURL, tokenEndpointURL]
|
|
request in
|
|
guard let url = request.url else {
|
|
throw MockResponses.mockError("Missing request URL")
|
|
}
|
|
|
|
if url.scheme == authorizationEndpointURL.scheme,
|
|
url.host == authorizationEndpointURL.host,
|
|
url.port == authorizationEndpointURL.port,
|
|
url.path == authorizationEndpointURL.path
|
|
{
|
|
let response = HTTPURLResponse(
|
|
url: url, statusCode: 302, httpVersion: "HTTP/1.1",
|
|
headerFields: [
|
|
"Location": "http://127.0.0.1:3000/callback?code=test"
|
|
])!
|
|
return (response, Data())
|
|
}
|
|
|
|
switch url {
|
|
case testEndpoint:
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
|
|
case resourceMetadataURL:
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
|
|
case asMetadataURL:
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize"
|
|
)(request)
|
|
|
|
case tokenEndpointURL:
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "should-not-be-issued")(request)
|
|
|
|
default:
|
|
throw MockResponses.mockError("Unexpected URL: \(url.absoluteString)")
|
|
}
|
|
}
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client")
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":24}"#.data(using: .utf8)!,
|
|
expectedCallCounts: [tokenEndpointURL: 0],
|
|
expectedErrorSubstring: "code_challenge_methods_supported"
|
|
)
|
|
}
|
|
|
|
// MARK: 6 - Rejects Authorization Without S256 PKCE
|
|
|
|
func configureOAuthRejectsAuthorizationWithoutS256PKCE() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/pkce-s256-missing")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/pkce-s256-missing"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let authorizationEndpointURL = URL(
|
|
string: "https://localhost:8080/oauth/authorize")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = {
|
|
[testEndpoint, resourceMetadataURL, asMetadataURL, authorizationEndpointURL, tokenEndpointURL]
|
|
request in
|
|
guard let url = request.url else {
|
|
throw MockResponses.mockError("Missing request URL")
|
|
}
|
|
|
|
if url.scheme == authorizationEndpointURL.scheme,
|
|
url.host == authorizationEndpointURL.host,
|
|
url.port == authorizationEndpointURL.port,
|
|
url.path == authorizationEndpointURL.path
|
|
{
|
|
let response = HTTPURLResponse(
|
|
url: url, statusCode: 302, httpVersion: "HTTP/1.1",
|
|
headerFields: [
|
|
"Location": "http://127.0.0.1:3000/callback?code=test"
|
|
])!
|
|
return (response, Data())
|
|
}
|
|
|
|
switch url {
|
|
case testEndpoint:
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
|
|
case resourceMetadataURL:
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
|
|
case asMetadataURL:
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["plain"]
|
|
)(request)
|
|
|
|
case tokenEndpointURL:
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "should-not-be-issued")(request)
|
|
|
|
default:
|
|
throw MockResponses.mockError("Unexpected URL: \(url.absoluteString)")
|
|
}
|
|
}
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client")
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":25}"#.data(using: .utf8)!,
|
|
expectedCallCounts: [tokenEndpointURL: 0],
|
|
expectedErrorSubstring: "must support PKCE S256"
|
|
)
|
|
}
|
|
|
|
// MARK: 7 - Rejects Authorization Response Redirect Mismatch
|
|
|
|
func configureOAuthRejectsAuthorizationResponseRedirectMismatch()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/authorization-redirect-mismatch")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/authorization-redirect-mismatch"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let authorizationEndpointURL = URL(
|
|
string: "https://localhost:8080/oauth/authorize")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = {
|
|
[testEndpoint, resourceMetadataURL, asMetadataURL, authorizationEndpointURL, tokenEndpointURL]
|
|
request in
|
|
guard let url = request.url else {
|
|
throw MockResponses.mockError("Missing request URL")
|
|
}
|
|
|
|
if url.scheme == authorizationEndpointURL.scheme,
|
|
url.host == authorizationEndpointURL.host,
|
|
url.port == authorizationEndpointURL.port,
|
|
url.path == authorizationEndpointURL.path
|
|
{
|
|
let queryItems =
|
|
URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
|
?? []
|
|
let state =
|
|
queryItems.first(where: { $0.name == "state" })?.value ?? ""
|
|
let locationValue =
|
|
"https://evil.example.com/callback?code=test&state=\(state)"
|
|
let response = HTTPURLResponse(
|
|
url: url, statusCode: 302, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Location": locationValue])!
|
|
return (response, Data())
|
|
}
|
|
|
|
switch url {
|
|
case testEndpoint:
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
|
|
case resourceMetadataURL:
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
|
|
case asMetadataURL:
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["S256"]
|
|
)(request)
|
|
|
|
case tokenEndpointURL:
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "should-not-be-issued")(request)
|
|
|
|
default:
|
|
throw MockResponses.mockError("Unexpected URL: \(url.absoluteString)")
|
|
}
|
|
}
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client")
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":26}"#.data(using: .utf8)!,
|
|
expectedCallCounts: [tokenEndpointURL: 0],
|
|
expectedErrorSubstring: "redirect URI mismatch"
|
|
)
|
|
}
|
|
|
|
// MARK: 8 - Rejects Authorization Response State Mismatch
|
|
|
|
func configureOAuthRejectsAuthorizationResponseStateMismatch()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/authorization-state-mismatch")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/authorization-state-mismatch"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let authorizationEndpointURL = URL(
|
|
string: "https://localhost:8080/oauth/authorize")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = {
|
|
[testEndpoint, resourceMetadataURL, asMetadataURL, authorizationEndpointURL, tokenEndpointURL]
|
|
request in
|
|
guard let url = request.url else {
|
|
throw MockResponses.mockError("Missing request URL")
|
|
}
|
|
|
|
if url.scheme == authorizationEndpointURL.scheme,
|
|
url.host == authorizationEndpointURL.host,
|
|
url.port == authorizationEndpointURL.port,
|
|
url.path == authorizationEndpointURL.path
|
|
{
|
|
let queryItems =
|
|
URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
|
|
?? []
|
|
let redirectURI =
|
|
queryItems.first(where: { $0.name == "redirect_uri" })?.value
|
|
var redirectComponents = URLComponents(string: redirectURI ?? "")
|
|
var redirectQueryItems = redirectComponents?.queryItems ?? []
|
|
redirectQueryItems.append(.init(name: "code", value: "test"))
|
|
redirectQueryItems.append(
|
|
.init(name: "state", value: "unexpected-state"))
|
|
redirectComponents?.queryItems = redirectQueryItems
|
|
let locationValue =
|
|
redirectComponents?.url?.absoluteString
|
|
?? "http://127.0.0.1:3000/callback?code=test&state=unexpected-state"
|
|
let response = HTTPURLResponse(
|
|
url: url, statusCode: 302, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Location": locationValue])!
|
|
return (response, Data())
|
|
}
|
|
|
|
switch url {
|
|
case testEndpoint:
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
|
|
case resourceMetadataURL:
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
|
|
case asMetadataURL:
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["S256"]
|
|
)(request)
|
|
|
|
case tokenEndpointURL:
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "should-not-be-issued")(request)
|
|
|
|
default:
|
|
throw MockResponses.mockError("Unexpected URL: \(url.absoluteString)")
|
|
}
|
|
}
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client")
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":27}"#.data(using: .utf8)!,
|
|
expectedCallCounts: [tokenEndpointURL: 0],
|
|
expectedErrorSubstring: "state mismatch"
|
|
)
|
|
}
|
|
|
|
// MARK: 9 - Access Token Only Via Authorization Header
|
|
|
|
func configureOAuthAccessTokenOnlyViaAuthorizationHeader() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/test?foo=bar")!
|
|
let resourceMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource/test")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 12)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-xyz")
|
|
#expect(request.url?.query == "foo=bar")
|
|
#expect(
|
|
!(request.url?.absoluteString.contains("access_token=") ?? false))
|
|
let requestBody =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(!requestBody.contains("access_token="))
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Content-Type")
|
|
== "application/json")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "access-token-xyz"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":12}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 10 - Authorization Header For Every Request In Session
|
|
|
|
func configureOAuthAuthorizationHeaderForEveryRequestInSession()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/session-auth")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/session-auth")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let firstResponseData = #"{"jsonrpc":"2.0","result":{"ok":true},"id":31}"#.data(
|
|
using: .utf8)!
|
|
let secondResponseData = #"{"jsonrpc":"2.0","result":{"ok":true},"id":32}"#.data(
|
|
using: .utf8)!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: {
|
|
[firstResponseData, secondResponseData] request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer session-access-token")
|
|
#expect(
|
|
!(request.url?.absoluteString.contains("access_token=") ?? false))
|
|
let requestBody =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(!requestBody.contains("access_token="))
|
|
|
|
let responseBody: Data
|
|
if requestBody.contains("\"id\":31") {
|
|
responseBody = firstResponseData
|
|
} else if requestBody.contains("\"id\":32") {
|
|
responseBody = secondResponseData
|
|
} else {
|
|
throw MockResponses.mockError(
|
|
"Unexpected JSON-RPC body: \(requestBody)")
|
|
}
|
|
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: [
|
|
"Content-Type": "application/json",
|
|
"MCP-Session-Id": "session-123",
|
|
])!
|
|
return (response, responseBody)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Fsession-auth"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "session-access-token")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":31}"#.data(using: .utf8)!,
|
|
expectedResponseData: firstResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 3, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
],
|
|
secondMessageData: #"{"jsonrpc":"2.0","method":"ping","id":32}"#.data(
|
|
using: .utf8)!,
|
|
secondExpectedResponseData: secondResponseData
|
|
)
|
|
}
|
|
|
|
// MARK: 11 - Streaming GET Uses Authorization Header Only
|
|
|
|
func configureOAuthStreamingGETUsesAuthorizationHeaderOnly() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/stream-auth?foo=bar")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/stream-auth")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let sseEventData = "id: evt-1\ndata: {\"stream\":\"ok\"}\n\n".data(using: .utf8)!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { [sseEventData] request in
|
|
if request.httpMethod == "GET" {
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer stream-access-token")
|
|
#expect(request.url?.query == "foo=bar")
|
|
#expect(
|
|
!(request.url?.absoluteString.contains("access_token=") ?? false))
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "MCP-Session-Id")
|
|
== "stream-session-id")
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "text/event-stream"])!
|
|
return (response, sseEventData)
|
|
}
|
|
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer stream-access-token")
|
|
#expect(
|
|
!(request.url?.absoluteString.contains("access_token=") ?? false))
|
|
let requestBody =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(!requestBody.contains("access_token="))
|
|
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: [
|
|
"Content-Type": "text/plain",
|
|
"MCP-Session-Id": "stream-session-id",
|
|
])!
|
|
return (response, Data())
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Fstream-auth"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "stream-access-token")(request)
|
|
},
|
|
])
|
|
|
|
let expectedEventPayload = #"{"stream":"ok"}"#.data(using: .utf8)!
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":41}"#.data(
|
|
using: .utf8)!,
|
|
expectedResponseData: expectedEventPayload,
|
|
streaming: true,
|
|
sseInitializationTimeout: 1
|
|
)
|
|
}
|
|
|
|
// MARK: 12 - Rejects Non-Bearer Token Type
|
|
|
|
func configureOAuthRejectsNonBearerTokenType() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/non-bearer-token-type")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/non-bearer-token-type"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenResponse(
|
|
accessToken: "non-bearer-token", tokenType: "DPoP"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":51}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring: "Token response is invalid"
|
|
)
|
|
}
|
|
|
|
// MARK: 13 - Token Endpoint Failure Redacts Response Body
|
|
|
|
func configureOAuthTokenEndpointFailureRedactsResponseBody() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/token-error-redaction")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/token-error-redaction"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenError(
|
|
error: "invalid_client",
|
|
errorDescription: "leaked-secret-value",
|
|
extraFields: ["access_token": "should-not-leak"]
|
|
),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":52}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring: "oauth_error: invalid_client",
|
|
unexpectedErrorSubstrings: ["leaked-secret-value", "should-not-leak"]
|
|
)
|
|
}
|
|
|
|
// MARK: 14 - Rejects Non-HTTPS Token Endpoint
|
|
|
|
func configureOAuthRejectsNonHTTPSTokenEndpoint() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/non-https-token-endpoint")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/non-https-token-endpoint"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "http://localhost:8080/oauth/token"
|
|
),
|
|
])
|
|
|
|
var oauth = OAuthConfiguration(
|
|
authentication: .none(clientID: "test-client"))
|
|
oauth.allowLoopbackHTTPAuthorizationServerEndpoints = false
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: oauth,
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":53}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring: "Token endpoint must use https"
|
|
)
|
|
}
|
|
|
|
// MARK: 15 - Allows Loopback HTTP Authorization Server Endpoints
|
|
|
|
func configureOAuthAllowsLoopbackHTTPAuthorizationServerEndpoints()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(
|
|
string: "https://localhost:8080/loopback-http-auth-server-enabled")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/loopback-http-auth-server-enabled"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "http://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "http://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 54)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") != nil {
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
}
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["http://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "http://localhost:8080/auth",
|
|
tokenEndpoint: "http://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "loopback-http-token")(request)
|
|
},
|
|
])
|
|
|
|
var oauth = OAuthConfiguration(
|
|
authentication: .none(clientID: "test-client"))
|
|
oauth.allowLoopbackHTTPAuthorizationServerEndpoints = true
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: oauth,
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":54}"#.data(
|
|
using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [tokenEndpointURL: 1]
|
|
)
|
|
}
|
|
|
|
// MARK: 16 - Access Token Provider Receives Discovery Context
|
|
|
|
func configureOAuthAccessTokenProviderReceivesDiscoveryContext()
|
|
-> (OAuthScenarioContext, ProviderTracker)
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/test")!
|
|
let resourceMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource/test")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 2)
|
|
let providerTracker = ProviderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL,
|
|
scope: "files:read files:write"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer provider-access-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read", "files:write"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["S256"]
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "application/json"])!
|
|
return (response, Data())
|
|
},
|
|
])
|
|
|
|
let oauthConfiguration = OAuthConfiguration(
|
|
authentication: .none(clientID: "test-client"),
|
|
accessTokenProvider: { [providerTracker] providerContext, _ in
|
|
await providerTracker.capture(providerContext)
|
|
return "provider-access-token"
|
|
}
|
|
)
|
|
|
|
let context = OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: oauthConfiguration,
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":2}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 0,
|
|
]
|
|
)
|
|
return (context, providerTracker)
|
|
}
|
|
|
|
// MARK: 17 - Discovery Uses Header Resource Metadata
|
|
|
|
func configureOAuthDiscoveryUsesHeaderResourceMetadata() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let headerMetadataURL = URL(
|
|
string: "https://localhost:8080/custom-metadata")!
|
|
let fallbackPathMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let fallbackRootMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 3)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: headerMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-123")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
headerMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
fallbackPathMetadataURL: MockResponses.httpError(statusCode: 500),
|
|
fallbackRootMetadataURL: MockResponses.httpError(statusCode: 500),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "access-token-123"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":3}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, headerMetadataURL: 1,
|
|
fallbackPathMetadataURL: 0, fallbackRootMetadataURL: 0,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 18 - Discovery Fallback Well-Known Order
|
|
|
|
func configureOAuthDiscoveryFallbackWellKnownOrder()
|
|
-> (OAuthScenarioContext, OrderTracker)
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let fallbackPathMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let fallbackRootMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 4)
|
|
let tracker = OrderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
scope: "files:read")(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer access-token-456")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
fallbackPathMetadataURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
fallbackRootMetadataURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
)(request)
|
|
},
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "access-token-456"),
|
|
])
|
|
|
|
let context = OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":4}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData
|
|
)
|
|
return (context, tracker)
|
|
}
|
|
|
|
// MARK: 19 - Discovery Fails When Metadata Unavailable
|
|
|
|
func configureOAuthDiscoveryFailsWhenMetadataUnavailable()
|
|
-> (OAuthScenarioContext, OrderTracker)
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let fallbackPathMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let fallbackRootMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-protected-resource")!
|
|
let tracker = OrderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
scope: "files:read")(request)
|
|
},
|
|
fallbackPathMetadataURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
fallbackRootMetadataURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
])
|
|
|
|
let context = OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":7}"#.data(using: .utf8)!,
|
|
expectedErrorSubstring: "metadata"
|
|
)
|
|
return (context, tracker)
|
|
}
|
|
|
|
// MARK: 20 - AS Metadata Discovery Order For Path Issuer
|
|
|
|
func configureOAuthASMetadataDiscoveryOrderForPathIssuer()
|
|
-> (OAuthScenarioContext, OrderTracker)
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let authorizationServer = URL(string: "https://localhost:8080/tenant1")!
|
|
let asMetadataOAuthInsertedURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-authorization-server/tenant1")!
|
|
let asMetadataOIDCInsertedURL = URL(
|
|
string: "https://localhost:8080/.well-known/openid-configuration/tenant1")!
|
|
let asMetadataOIDCAppendedURL = URL(
|
|
string: "https://localhost:8080/tenant1/.well-known/openid-configuration")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 5)
|
|
let tracker = OrderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer path-issuer-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/tenant1"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataOAuthInsertedURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
asMetadataOIDCInsertedURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
asMetadataOIDCAppendedURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/tenant1",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
)(request)
|
|
},
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "path-issuer-token")(request)
|
|
},
|
|
authorizationServer: { _ in
|
|
throw MockResponses.mockError(
|
|
"Unexpected direct issuer request")
|
|
},
|
|
])
|
|
|
|
let context = OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":5}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData
|
|
)
|
|
return (context, tracker)
|
|
}
|
|
|
|
// MARK: 21 - AS Metadata Discovery Order For Root Issuer
|
|
|
|
func configureOAuthASMetadataDiscoveryOrderForRootIssuer()
|
|
-> (OAuthScenarioContext, OrderTracker)
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let authorizationServer = URL(string: "https://localhost:8080")!
|
|
let asMetadataOAuthURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server")!
|
|
let asMetadataOIDCURL = URL(
|
|
string: "https://localhost:8080/.well-known/openid-configuration")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 6)
|
|
let tracker = OrderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer root-issuer-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataOAuthURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.httpError(statusCode: 404)(request)
|
|
},
|
|
asMetadataOIDCURL: { [tracker] request in
|
|
await tracker.append(request.url!)
|
|
return try await MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
)(request)
|
|
},
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "root-issuer-token"),
|
|
authorizationServer: { _ in
|
|
throw MockResponses.mockError(
|
|
"Unexpected direct issuer request")
|
|
},
|
|
])
|
|
|
|
let context = OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":6}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData
|
|
)
|
|
return (context, tracker)
|
|
}
|
|
|
|
// MARK: 22 - Registration Prefers CIMD When Advertised
|
|
|
|
func configureOAuthRegistrationPrefersCIMDWhenAdvertised() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let registrationEndpointURL = URL(
|
|
string: "https://localhost:8080/register")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let clientMetadataDocumentID = "https://client.example.com/metadata.json"
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 8)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer cimd-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
registrationEndpoint: "https://localhost:8080/register",
|
|
clientIDMetadataDocumentSupported: true
|
|
),
|
|
registrationEndpointURL: MockResponses.httpError(statusCode: 500),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"client_id=https%3A%2F%2Fclient.example.com%2Fmetadata.json"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "cimd-token")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: clientMetadataDocumentID)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":8}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [registrationEndpointURL: 0]
|
|
)
|
|
}
|
|
|
|
// MARK: 23 - Pre-Registration Uses Static Credentials
|
|
|
|
func configureOAuthPreRegistrationUsesStaticCredentials() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let registrationEndpointURL = URL(
|
|
string: "https://localhost:8080/register")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 13)
|
|
|
|
let expectedClientID = "pre-registered-client"
|
|
let expectedClientSecret = "pre-registered-secret"
|
|
let expectedBasic = Data(
|
|
"\(expectedClientID):\(expectedClientSecret)".utf8
|
|
).base64EncodedString()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer pre-registered-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
registrationEndpoint: "https://localhost:8080/register"
|
|
),
|
|
registrationEndpointURL: MockResponses.httpError(statusCode: 500),
|
|
tokenEndpointURL: { [expectedBasic] request in
|
|
#expect(request.httpMethod == "POST")
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Basic \(expectedBasic)")
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(
|
|
body.contains(
|
|
"resource=https%3A%2F%2Flocalhost%3A8080%2Fpublic%2Fmcp"))
|
|
#expect(body.contains("scope=files%3Aread"))
|
|
#expect(!body.contains("client_id="))
|
|
#expect(!body.contains("client_secret="))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "pre-registered-token")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .clientSecretBasic(
|
|
clientID: expectedClientID,
|
|
clientSecret: expectedClientSecret
|
|
)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":13}"#.data(
|
|
using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [registrationEndpointURL: 0]
|
|
)
|
|
}
|
|
|
|
// MARK: 24 - Registration Falls Back to Dynamic Registration (CIMD Not Advertised)
|
|
|
|
func configureOAuthRegistrationFallsBackToDynamicRegistrationCIMDNotAdvertised()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let registrationEndpointURL = URL(
|
|
string: "https://localhost:8080/register")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let clientMetadataDocumentID = "https://client.example.com/metadata.json"
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 9)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer dynamic-registration-token")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
registrationEndpoint: "https://localhost:8080/register",
|
|
clientIDMetadataDocumentSupported: false
|
|
),
|
|
registrationEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("\"grant_types\":[\"client_credentials\"]"))
|
|
#expect(body.contains("\"token_endpoint_auth_method\":\"none\""))
|
|
#expect(!body.contains("\"response_types\""))
|
|
#expect(!body.contains("redirect_uris"))
|
|
#expect(!body.contains("authorization_code"))
|
|
return try await MockResponses.registrationSuccess(
|
|
clientID: "registered-client")(request)
|
|
},
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(body.contains("client_id=registered-client"))
|
|
#expect(
|
|
!body.contains(
|
|
"client_id=https%3A%2F%2Fclient.example.com%2Fmetadata.json"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "dynamic-registration-token")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: clientMetadataDocumentID)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":9}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [registrationEndpointURL: 1]
|
|
)
|
|
}
|
|
|
|
// MARK: 25 - Registration Falls Back to Dynamic Registration (CIMD Capability Missing)
|
|
|
|
func configureOAuthRegistrationFallsBackToDynamicRegistrationCIMDCapabilityMissing()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let registrationEndpointURL = URL(
|
|
string: "https://localhost:8080/register")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let clientMetadataDocumentID = "https://client.example.com/metadata.json"
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 14)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
}
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Authorization")
|
|
== "Bearer dynamic-registration-token-missing-capability")
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(
|
|
request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
registrationEndpoint: "https://localhost:8080/register"
|
|
),
|
|
registrationEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("\"grant_types\":[\"client_credentials\"]"))
|
|
#expect(!body.contains("redirect_uris"))
|
|
return try await MockResponses.registrationSuccess(
|
|
clientID: "registered-client-2")(request)
|
|
},
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
#expect(body.contains("grant_type=client_credentials"))
|
|
#expect(body.contains("client_id=registered-client-2"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "dynamic-registration-token-missing-capability")(
|
|
request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: clientMetadataDocumentID)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":14}"#.data(
|
|
using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [registrationEndpointURL: 1]
|
|
)
|
|
}
|
|
|
|
// MARK: 26 - Registration Missing Mechanism Returns Actionable Error
|
|
|
|
func configureOAuthRegistrationMissingMechanismReturnsActionableError()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let clientMetadataDocumentID = "https://client.example.com/metadata.json"
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
clientIDMetadataDocumentSupported: false
|
|
),
|
|
tokenEndpointURL: MockResponses.httpError(statusCode: 500),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: clientMetadataDocumentID)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":10}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring:
|
|
"Authorization server does not support Client ID Metadata Documents"
|
|
)
|
|
}
|
|
|
|
// MARK: 27 - CIMD Rejects Non-HTTPS Client ID URL
|
|
|
|
func configureOAuthCIMDRejectsNonHTTPSClientIDURL() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let invalidClientID = "http://client.example.com/metadata.json"
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
clientIDMetadataDocumentSupported: true
|
|
),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: invalidClientID)),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":11}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring:
|
|
"Client ID metadata document URL must use https and include a path"
|
|
)
|
|
}
|
|
|
|
// MARK: 28 - Rejects Insecure MCP Endpoint URL
|
|
|
|
func configureOAuthRejectsInsecureMCPEndpointURL() -> OAuthScenarioContext {
|
|
let insecureEndpoint = URL(string: "http://example.com/public/mcp")!
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: insecureEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":12}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring:
|
|
"MCP endpoint must use https or loopback http"
|
|
)
|
|
}
|
|
|
|
// MARK: 30 - PRM Cache Invalidated When resource_metadata URL Changes
|
|
|
|
func configureOAuthPRMCacheInvalidatedOnResourceMetadataURLChange()
|
|
-> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/cache-invalidation")!
|
|
let resourceMetadataURL_A = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/cache-a")!
|
|
let resourceMetadataURL_B = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/cache-b")!
|
|
let asMetadataURL_A = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-authorization-server/auth-a")!
|
|
let asMetadataURL_B = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-authorization-server/auth-b")!
|
|
let tokenEndpointURL_A = URL(
|
|
string: "https://localhost:8080/oauth/token-a")!
|
|
let tokenEndpointURL_B = URL(
|
|
string: "https://localhost:8080/oauth/token-b")!
|
|
let firstResponseData = MockResponses.jsonRPCResult(id: 71)
|
|
let secondResponseData = MockResponses.jsonRPCResult(id: 72)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
let authHeader = request.value(forHTTPHeaderField: "Authorization")
|
|
let bodyStr =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
if authHeader == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL_A, scope: "files:read"
|
|
)(request)
|
|
} else if authHeader == "Bearer token-a" {
|
|
if bodyStr.contains("\"id\":71") {
|
|
return try await MockResponses.jsonSuccess(
|
|
body: firstResponseData)(request)
|
|
} else {
|
|
// Second message with old token → return 401 with new URL_B
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL_B, scope: "files:read"
|
|
)(request)
|
|
}
|
|
} else if authHeader == "Bearer token-b" {
|
|
return try await MockResponses.jsonSuccess(
|
|
body: secondResponseData)(request)
|
|
}
|
|
throw MockResponses.mockError(
|
|
"Unexpected Authorization header: \(String(describing: authHeader))")
|
|
},
|
|
resourceMetadataURL_A: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth-a"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
resourceMetadataURL_B: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth-b"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL_A: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth-a",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token-a"
|
|
),
|
|
asMetadataURL_B: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth-b",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token-b"
|
|
),
|
|
tokenEndpointURL_A: MockResponses.tokenSuccess(accessToken: "token-a"),
|
|
tokenEndpointURL_B: MockResponses.tokenSuccess(accessToken: "token-b"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":71}"#.data(using: .utf8)!,
|
|
expectedResponseData: firstResponseData,
|
|
expectedCallCounts: [
|
|
resourceMetadataURL_A: 1,
|
|
resourceMetadataURL_B: 1,
|
|
tokenEndpointURL_A: 1,
|
|
tokenEndpointURL_B: 1,
|
|
],
|
|
secondMessageData: #"{"jsonrpc":"2.0","method":"ping","id":72}"#.data(
|
|
using: .utf8)!,
|
|
secondExpectedResponseData: secondResponseData
|
|
)
|
|
}
|
|
|
|
// MARK: 29 - Rejects Non-Loopback HTTP Redirect URI
|
|
|
|
func configureOAuthRejectsNonLoopbackHTTPRedirectURI() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/public/mcp")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/public/mcp")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL, scope: "files:read"
|
|
)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
scopesSupported: ["files:read"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
authorizationEndpoint: "https://localhost:8080/oauth/authorize",
|
|
codeChallengeMethodsSupported: ["S256"]
|
|
),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(
|
|
grantType: .authorizationCode,
|
|
authentication: .none(clientID: "test-client"),
|
|
authorizationRedirectURI: URL(
|
|
string: "http://evil.example.com/callback")!
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":13}"#.data(
|
|
using: .utf8)!,
|
|
expectedErrorSubstring:
|
|
"Redirect URI must use https or loopback http and must not include fragments"
|
|
)
|
|
}
|
|
|
|
// MARK: 31 - Resource Uses PRM Resource Field (Gap 1)
|
|
|
|
func configureOAuthResourceUsesPRMResourceField() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/mcp/tools")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/mcp/tools")!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 91)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL)(request)
|
|
}
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
// PRM resource = "https://localhost:8080" (origin only, no path)
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"],
|
|
resource: "https://localhost:8080"
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
// Must use the PRM resource field, not the specific endpoint path
|
|
#expect(body.contains("resource=https%3A%2F%2Flocalhost%3A8080"))
|
|
#expect(!body.contains("resource=https%3A%2F%2Flocalhost%3A8080%2Fmcp"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "access-token-prm-resource")(request)
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":91}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2, resourceMetadataURL: 1, asMetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 32 - Second Authorization Server Tried When First Fails (Gap 2)
|
|
|
|
func configureOAuthSecondAuthorizationServerTriedWhenFirstFails() -> OAuthScenarioContext
|
|
{
|
|
let testEndpoint = URL(string: "https://localhost:8080/test-as-fallback")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/test-as-fallback"
|
|
)!
|
|
// AS1 discovery URLs — all return 404
|
|
let as1MetadataURL1 = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/as1")!
|
|
let as1MetadataURL2 = URL(
|
|
string: "https://localhost:8080/.well-known/openid-configuration/as1")!
|
|
let as1MetadataURL3 = URL(
|
|
string: "https://localhost:8080/as1/.well-known/openid-configuration")!
|
|
// AS2 first discovery URL — returns valid metadata
|
|
let as2MetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/as2")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token-as2")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 92)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL)(request)
|
|
}
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: [
|
|
"https://localhost:8080/as1",
|
|
"https://localhost:8080/as2",
|
|
]
|
|
),
|
|
// AS1: all well-known URLs return 404
|
|
as1MetadataURL1: MockResponses.httpError(statusCode: 404),
|
|
as1MetadataURL2: MockResponses.httpError(statusCode: 404),
|
|
as1MetadataURL3: MockResponses.httpError(statusCode: 404),
|
|
// AS2: first well-known URL returns valid metadata
|
|
as2MetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/as2",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token-as2"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "access-token-from-as2"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":92}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2,
|
|
resourceMetadataURL: 1,
|
|
as1MetadataURL1: 1,
|
|
as1MetadataURL2: 1,
|
|
as1MetadataURL3: 1,
|
|
as2MetadataURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 33 - Re-Registration After Client Secret Expiry (Gap 3)
|
|
|
|
func configureOAuthReRegistersAfterClientSecretExpiry() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/test-secret-expiry")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/test-secret-expiry"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let registrationEndpointURL = URL(string: "https://localhost:8080/register")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let firstResponseData = MockResponses.jsonRPCResult(id: 93)
|
|
let secondResponseData = MockResponses.jsonRPCResult(id: 94)
|
|
let regTracker = OrderTracker()
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
let auth = request.value(forHTTPHeaderField: "Authorization")
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
if auth == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL)(request)
|
|
}
|
|
if auth == "Bearer token-v1" && body.contains("\"id\":93") {
|
|
return try await MockResponses.jsonSuccess(body: firstResponseData)(
|
|
request)
|
|
}
|
|
// Second request with old token → trigger re-auth
|
|
if auth == "Bearer token-v1" && body.contains("\"id\":94") {
|
|
return try await MockResponses.bearerChallenge()(request)
|
|
}
|
|
if auth == "Bearer token-v2" {
|
|
return try await MockResponses.jsonSuccess(body: secondResponseData)(
|
|
request)
|
|
}
|
|
throw MockResponses.mockError(
|
|
"Unexpected auth: \(String(describing: auth))")
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token",
|
|
registrationEndpoint: "https://localhost:8080/register"
|
|
),
|
|
registrationEndpointURL: { [regTracker] request in
|
|
await regTracker.append(request.url!)
|
|
let count = await regTracker.count()
|
|
// Return different client IDs per registration call
|
|
let clientID = count == 1 ? "client-v1" : "client-v2"
|
|
// client_secret_expires_at = 1 (Unix epoch — always in the past)
|
|
let dict: [String: Any] = [
|
|
"client_id": clientID,
|
|
"client_secret_expires_at": 1,
|
|
]
|
|
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)
|
|
},
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
if body.contains("client_id=client-v1") {
|
|
return try await MockResponses.tokenSuccess(accessToken: "token-v1")(
|
|
request)
|
|
}
|
|
if body.contains("client_id=client-v2") {
|
|
return try await MockResponses.tokenSuccess(accessToken: "token-v2")(
|
|
request)
|
|
}
|
|
throw MockResponses.mockError("Unexpected client_id in token request")
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "anon")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":93}"#.data(using: .utf8)!,
|
|
expectedResponseData: firstResponseData,
|
|
expectedCallCounts: [
|
|
registrationEndpointURL: 2,
|
|
tokenEndpointURL: 2,
|
|
],
|
|
secondMessageData: #"{"jsonrpc":"2.0","method":"ping","id":94}"#.data(
|
|
using: .utf8)!,
|
|
secondExpectedResponseData: secondResponseData
|
|
)
|
|
}
|
|
|
|
// MARK: 34 - Issuer Mismatch Causes Next Discovery URL Variant to Be Tried (Gap 4)
|
|
|
|
func configureOAuthIssuerMismatchTriesNextURLVariant() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/test-issuer-check")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/test-issuer-check"
|
|
)!
|
|
// Discovery URL 1 returns wrong issuer → skipped
|
|
let wrongIssuerURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
// Discovery URL 2 returns correct issuer → used
|
|
let correctIssuerURL = URL(
|
|
string: "https://localhost:8080/.well-known/openid-configuration/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let finalResponseData = MockResponses.jsonRPCResult(id: 95)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
if request.value(forHTTPHeaderField: "Authorization") == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL)(request)
|
|
}
|
|
return try await MockResponses.jsonSuccess(body: finalResponseData)(request)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"]
|
|
),
|
|
// URL 1: returns metadata with wrong issuer field → SDK skips it
|
|
wrongIssuerURL: MockResponses.asMetadata(
|
|
issuer: "https://evil.example.com",
|
|
tokenEndpoint: "https://evil.example.com/token"
|
|
),
|
|
// URL 2: returns metadata with correct issuer → SDK accepts it
|
|
correctIssuerURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: MockResponses.tokenSuccess(
|
|
accessToken: "access-token-after-issuer-check"),
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
oauthConfiguration: .init(authentication: .none(clientID: "test-client")),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":95}"#.data(using: .utf8)!,
|
|
expectedResponseData: finalResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 2,
|
|
resourceMetadataURL: 1,
|
|
wrongIssuerURL: 1,
|
|
correctIssuerURL: 1,
|
|
tokenEndpointURL: 1,
|
|
]
|
|
)
|
|
}
|
|
|
|
// MARK: 35 - Proactive Token Refresh Within Window (Gap 5)
|
|
|
|
func configureOAuthProactiveTokenRefreshWithinWindow() -> OAuthScenarioContext {
|
|
let testEndpoint = URL(string: "https://localhost:8080/test-proactive-refresh")!
|
|
let resourceMetadataURL = URL(
|
|
string:
|
|
"https://localhost:8080/.well-known/oauth-protected-resource/test-proactive-refresh"
|
|
)!
|
|
let asMetadataURL = URL(
|
|
string: "https://localhost:8080/.well-known/oauth-authorization-server/auth")!
|
|
let tokenEndpointURL = URL(string: "https://localhost:8080/oauth/token")!
|
|
let firstResponseData = MockResponses.jsonRPCResult(id: 96)
|
|
let secondResponseData = MockResponses.jsonRPCResult(id: 97)
|
|
|
|
requestHandler = MockResponses.routingHandler(routes: [
|
|
testEndpoint: { request in
|
|
let auth = request.value(forHTTPHeaderField: "Authorization")
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
if auth == nil {
|
|
return try await MockResponses.bearerChallenge(
|
|
resourceMetadataURL: resourceMetadataURL)(request)
|
|
}
|
|
if body.contains("\"id\":96") {
|
|
return try await MockResponses.jsonSuccess(body: firstResponseData)(
|
|
request)
|
|
}
|
|
// Second request: must be sent with the proactively refreshed token
|
|
if auth == "Bearer refreshed-token" {
|
|
return try await MockResponses.jsonSuccess(body: secondResponseData)(
|
|
request)
|
|
}
|
|
throw MockResponses.mockError(
|
|
"Expected refreshed token on second request, got: \(String(describing: auth))"
|
|
)
|
|
},
|
|
resourceMetadataURL: MockResponses.resourceMetadata(
|
|
authorizationServers: ["https://localhost:8080/auth"]
|
|
),
|
|
asMetadataURL: MockResponses.asMetadata(
|
|
issuer: "https://localhost:8080/auth",
|
|
tokenEndpoint: "https://localhost:8080/oauth/token"
|
|
),
|
|
tokenEndpointURL: { request in
|
|
let body =
|
|
String(data: readRequestBody(request) ?? Data(), encoding: .utf8) ?? ""
|
|
if body.contains("grant_type=client_credentials") {
|
|
// Initial token: expires in 300s (within 400s proactive window)
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "initial-token",
|
|
expiresIn: 300,
|
|
refreshToken: "rt-1"
|
|
)(request)
|
|
}
|
|
if body.contains("grant_type=refresh_token") {
|
|
#expect(body.contains("refresh_token=rt-1"))
|
|
return try await MockResponses.tokenSuccess(
|
|
accessToken: "refreshed-token",
|
|
expiresIn: 3600
|
|
)(request)
|
|
}
|
|
throw MockResponses.mockError("Unexpected grant type in token request")
|
|
},
|
|
])
|
|
|
|
return OAuthScenarioContext(
|
|
testEndpoint: testEndpoint,
|
|
// proactiveRefreshWindowSeconds = 400 > expires_in = 300
|
|
oauthConfiguration: .init(
|
|
authentication: .none(clientID: "test-client"),
|
|
proactiveRefreshWindowSeconds: 400
|
|
),
|
|
messageData: #"{"jsonrpc":"2.0","method":"ping","id":96}"#.data(using: .utf8)!,
|
|
expectedResponseData: firstResponseData,
|
|
expectedCallCounts: [
|
|
testEndpoint: 3, // initial 401 + first send retry + second send
|
|
tokenEndpointURL: 2, // initial client_credentials + refresh_token
|
|
],
|
|
secondMessageData: #"{"jsonrpc":"2.0","method":"ping","id":97}"#.data(
|
|
using: .utf8)!,
|
|
secondExpectedResponseData: secondResponseData
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
#endif // !canImport(FoundationNetworking)
|
|
|
|
#endif // swift(>=6.1)
|