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