swift-sdk/Sources/MCP/Base/Transports/HTTPServer/HTTPRequestValidation.swift
Maksym Mova 6132fd4b5b
V0.12.x (#215)
* fix #203 NetworkTransport race, with main-isolated variables (#206)

Co-authored-by: wti <wti@users.noreply.github.com>

* feat/auth: Authentication implementation (#205)

* feat: added authentication

* feat(auth): improvement to error handling

* feat(auth): updated errors and tests in OAuthClientRegistrar

* feat(auth): updated ci conformance package version

* feat(auth): bumped node version

* feat(auth): updated ci conformance step

* feat(auth): improvement to Linux support

* chore: added docc plugin

* chore: added spi yml

* chore: added documentation step to ci

* chore: increased timeout for test from 5 to 10m

---------

Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com>
Co-authored-by: wti <wti@users.noreply.github.com>
Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
2026-03-24 11:05:22 +02:00

724 lines
27 KiB
Swift

import Foundation
// MARK: - Validation Protocol
/// Validates an incoming HTTP request before the transport processes it.
///
/// Validators are composed into a pipeline and executed in order. The first validator
/// that returns a non-nil response short-circuits the pipeline and that error response
/// is returned to the client.
///
/// Conform to this protocol to add custom validation (e.g., authentication):
/// ```swift
/// struct BearerTokenValidator: HTTPRequestValidator {
/// func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
/// guard let auth = request.header("Authorization"),
/// auth.hasPrefix("Bearer ") else {
/// return .error(statusCode: 401, .invalidRequest("Missing bearer token"))
/// }
/// return nil
/// }
/// }
/// ```
public protocol HTTPRequestValidator: Sendable {
/// Validates the request. Returns an error response if invalid, or `nil` if valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Validation Context
/// Context provided to validators for making validation decisions.
public struct HTTPValidationContext: Sendable {
/// The HTTP method of the request (GET, POST, DELETE).
public let httpMethod: String
/// The current session ID, if any (nil in stateless mode or before initialization).
public let sessionID: String?
/// Whether the request body contains an `initialize` JSON-RPC request.
public let isInitializationRequest: Bool
/// The set of protocol versions this server supports.
public let supportedProtocolVersions: Set<String>
public init(
httpMethod: String,
sessionID: String? = nil,
isInitializationRequest: Bool = false,
supportedProtocolVersions: Set<String> = Version.supported
) {
self.httpMethod = httpMethod
self.sessionID = sessionID
self.isInitializationRequest = isInitializationRequest
self.supportedProtocolVersions = supportedProtocolVersions
}
}
// MARK: - Accept Header Validator
/// Validates the `Accept` header based on the HTTP method and transport response mode.
///
/// - Stateful (SSE) mode: POST requests must accept both `application/json` and `text/event-stream`
/// - Stateless (JSON) mode: POST requests only need to accept `application/json`
/// - GET requests always require `text/event-stream`
public struct AcceptHeaderValidator: HTTPRequestValidator {
/// The response mode determines which content types are required.
public enum Mode: Sendable {
/// POST requires both `application/json` and `text/event-stream`.
case sseRequired
/// POST only requires `application/json`.
case jsonOnly
}
public let mode: Mode
public init(mode: Mode) {
self.mode = mode
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
let accept = request.header(HTTPHeaderName.accept) ?? ""
let acceptTypes = accept.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespaces)
}
let hasJSON = acceptTypes.contains { $0.hasPrefix(ContentType.json) }
let hasSSE = acceptTypes.contains { $0.hasPrefix(ContentType.sse) }
switch context.httpMethod {
case "POST":
switch mode {
case .sseRequired:
guard hasJSON, hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept both application/json and text/event-stream"
),
sessionID: context.sessionID
)
}
case .jsonOnly:
guard hasJSON else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept application/json"
),
sessionID: context.sessionID
)
}
}
case "GET":
guard hasSSE else {
return .error(
statusCode: 406,
.invalidRequest(
"Not Acceptable: Client must accept text/event-stream"
),
sessionID: context.sessionID
)
}
default:
break
}
return nil
}
}
// MARK: - Content-Type Validator
/// Validates that POST requests have `Content-Type: application/json`.
public struct ContentTypeValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard context.httpMethod == "POST" else { return nil }
let contentType = request.header(HTTPHeaderName.contentType) ?? ""
let mainType = contentType.split(separator: ";").first?
.trimmingCharacters(in: .whitespaces) ?? ""
guard mainType == ContentType.json else {
return .error(
statusCode: 415,
.invalidRequest(
"Unsupported Media Type: Content-Type must be application/json"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Protocol Version Validator
/// Validates the `MCP-Protocol-Version` header against supported versions.
///
/// Per spec:
/// - If the header is absent, the server assumes the default negotiated version
/// - If the header is present but unsupported, the server returns 400 Bad Request
/// - Initialization requests are exempt (protocol version comes from the request body)
public struct ProtocolVersionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip for initialization requests (version is in the body, not the header)
guard !context.isInitializationRequest else { return nil }
// Skip for non-POST methods (GET/DELETE don't carry protocol version)
// Actually, per spec, all subsequent requests should include it
guard let version = request.header(HTTPHeaderName.protocolVersion) else {
// Per spec: if not received, assume default version
return nil
}
guard context.supportedProtocolVersions.contains(version) else {
let supported = context.supportedProtocolVersions.sorted().joined(separator: ", ")
return .error(
statusCode: 400,
.invalidRequest(
"Bad Request: Unsupported protocol version: \(version). Supported: \(supported)"
),
sessionID: context.sessionID
)
}
return nil
}
}
// MARK: - Session Validator
/// Validates the `Mcp-Session-Id` header for stateful transports.
///
/// - Initialization requests are exempt (no session exists yet)
/// - Non-initialization requests must include the session ID header
/// - The session ID must match the active session
public struct SessionValidator: HTTPRequestValidator {
public init() {}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
// Skip validation for initialization requests
guard !context.isInitializationRequest else { return nil }
// Non-initialization requests require an established session.
guard let expectedSessionID = context.sessionID else {
return .error(
statusCode: 400,
.invalidRequest("Bad Request: Session not initialized"),
sessionID: nil
)
}
let requestSessionID = request.header(HTTPHeaderName.sessionID)
guard let requestSessionID else {
return .error(
statusCode: 400,
.invalidRequest("Bad Request: Missing \(HTTPHeaderName.sessionID) header"),
sessionID: expectedSessionID
)
}
guard requestSessionID == expectedSessionID else {
return .error(
statusCode: 404,
.invalidRequest("Not Found: Invalid or expired session ID"),
sessionID: expectedSessionID
)
}
return nil
}
}
// MARK: - Origin Validator
/// DNS rebinding protection: validates `Origin` and `Host` headers.
///
/// Per spec, servers MUST validate the Origin header to prevent DNS rebinding attacks.
/// This is particularly important for servers running on localhost.
///
/// Use `.localhost()` for local development servers.
/// Use `.disabled` to skip validation (e.g., cloud deployments).
/// Use `init(allowedHosts:allowedOrigins:)` for custom configurations.
public struct OriginValidator: HTTPRequestValidator {
public let allowedHosts: [String]
public let allowedOrigins: [String]
private let enabled: Bool
public init(allowedHosts: [String], allowedOrigins: [String]) {
self.allowedHosts = allowedHosts
self.allowedOrigins = allowedOrigins
self.enabled = true
}
private init(disabled: Void) {
self.allowedHosts = []
self.allowedOrigins = []
self.enabled = false
}
/// Protection for localhost-bound servers.
/// Allows requests from `localhost`, `127.0.0.1`, and `[::1]` with the specified port.
public static func localhost(port: Int? = nil) -> OriginValidator {
let portPattern = port.map { String($0) } ?? "*"
return OriginValidator(
allowedHosts: [
"127.0.0.1:\(portPattern)",
"localhost:\(portPattern)",
"[::1]:\(portPattern)",
],
allowedOrigins: [
"http://127.0.0.1:\(portPattern)",
"http://localhost:\(portPattern)",
"http://[::1]:\(portPattern)",
]
)
}
/// Disables DNS rebinding protection.
/// Use for cloud deployments where DNS rebinding is not a threat.
public static var disabled: OriginValidator {
OriginValidator(disabled: ())
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard enabled else { return nil }
// Validate Host header
if let host = request.header(HTTPHeaderName.host) {
let hostAllowed = allowedHosts.contains { pattern in
matchesPattern(host, pattern: pattern)
}
if !hostAllowed {
return .error(
statusCode: 421,
.invalidRequest("Misdirected Request: Host header not allowed"),
sessionID: context.sessionID
)
}
}
// Validate Origin header (only if present — non-browser clients won't send it)
if let origin = request.header(HTTPHeaderName.origin) {
let originAllowed = allowedOrigins.contains { pattern in
matchesPattern(origin, pattern: pattern)
}
if !originAllowed {
return .error(
statusCode: 403,
.invalidRequest("Forbidden: Origin not allowed"),
sessionID: context.sessionID
)
}
}
return nil
}
/// Matches a value against a pattern that may contain a port wildcard `:*`.
///
/// Examples:
/// - `"localhost:*"` matches `"localhost:8080"`, `"localhost:3000"`
/// - `"http://localhost:*"` matches `"http://localhost:8080"`
/// - `"localhost:8080"` matches only `"localhost:8080"` exactly
private func matchesPattern(_ value: String, pattern: String) -> Bool {
guard pattern.hasSuffix(":*") else {
return value == pattern
}
let prefix = String(pattern.dropLast(2))
guard value.hasPrefix(prefix + ":") else { return false }
let portPart = value.dropFirst(prefix.count + 1)
return !portPart.isEmpty && portPart.allSatisfy(\.isNumber)
}
}
// MARK: - Protected Resource Metadata Validator
/// Serves the RFC 9728 Protected Resource Metadata document for discovery.
///
/// Per the MCP authorization specification, servers **MUST** serve Protected Resource
/// Metadata at `/.well-known/oauth-protected-resource` so that clients can discover
/// authorization server endpoints automatically.
///
/// Place this validator **before** ``BearerTokenValidator`` in the pipeline so that
/// unauthenticated metadata discovery requests succeed.
///
/// ```swift
/// let prmValidator = ProtectedResourceMetadataValidator(
/// metadata: OAuthProtectedResourceServerMetadata(
/// resource: "https://api.example.com",
/// authorizationServers: [URL(string: "https://auth.example.com")!]
/// )
/// )
/// let pipeline = StandardValidationPipeline(validators: [
/// prmValidator,
/// bearerTokenValidator,
/// // ...
/// ])
/// ```
public struct ProtectedResourceMetadataValidator: HTTPRequestValidator {
private let encodedMetadata: Data
public init(metadata: OAuthProtectedResourceServerMetadata) {
self.encodedMetadata = (try? JSONEncoder().encode(metadata)) ?? Data()
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard context.httpMethod == "GET",
let path = request.path,
path == OAuthWellKnownPath.protectedResource
|| path.hasPrefix("\(OAuthWellKnownPath.protectedResource)/")
else {
return nil
}
return .data(encodedMetadata, headers: [HTTPHeaderName.contentType: ContentType.json])
}
}
// MARK: - OAuth Bearer Validator
/// Result produced by ``BearerTokenValidator`` when validating an access token.
public enum BearerTokenValidationResult: Sendable, Equatable {
/// Access token is valid for this request, with its extracted claims.
///
/// Supply a ``BearerTokenInfo`` with `audience` and `expiresAt` populated so that
/// ``BearerTokenValidator`` can enforce expiry and audience checks automatically.
/// Pass `BearerTokenInfo()` (all `nil`) to delegate all enforcement to the caller.
case valid(BearerTokenInfo)
/// Access token is missing required privileges, and new scopes are required.
case insufficientScope(requiredScopes: Set<String>, errorDescription: String? = nil)
/// Access token is invalid or expired.
case invalidToken(errorDescription: String? = nil)
/// Authorization request is malformed.
case malformedRequest(errorDescription: String? = nil)
}
/// Validates OAuth 2.1 Bearer authorization for protected MCP HTTP endpoints.
///
/// This validator implements resource-server error semantics aligned with the MCP auth spec:
/// - `401` with `WWW-Authenticate: Bearer ...` for missing/invalid tokens
/// - `403` with `error="insufficient_scope"` for insufficient permissions
/// - `400` for malformed authorization requests
///
/// Include this validator early in your pipeline, before `SessionValidator`, so unauthenticated
/// initialization requests can return a challenge.
///
/// ## Audience Validation (MUST)
///
/// Per the MCP authorization specification, **the resource server MUST validate the audience
/// (`aud` claim) of the access token** to ensure it matches the resource server's own identifier.
/// Failure to validate the audience allows token substitution attacks where a token intended
/// for a different resource is replayed against your server.
///
/// Your ``TokenValidator`` closure **MUST** verify the audience. Example:
///
/// ```swift
/// let validator = BearerTokenValidator(
/// resourceMetadataURL: metadataURL,
/// tokenValidator: { token, request, context in
/// guard let claims = verifyAndDecode(token) else {
/// return .invalidToken(errorDescription: "Token verification failed")
/// }
/// // MUST: Verify token audience matches this resource server
/// guard claims.audience.contains("https://api.example.com") else {
/// return .invalidToken(errorDescription: "Token audience mismatch")
/// }
/// return .valid
/// }
/// )
/// ```
public struct BearerTokenValidator: HTTPRequestValidator {
/// Validates a bearer token and returns token info for audience and expiry enforcement.
public typealias TokenValidator = @Sendable (
_ token: String,
_ request: HTTPRequest,
_ context: HTTPValidationContext
) -> BearerTokenValidationResult
/// Closure that returns the scopes to advertise in `WWW-Authenticate` challenge headers.
///
/// Return `nil` to omit the `scope` parameter from the challenge.
public typealias ChallengeScopeProvider = @Sendable (
_ request: HTTPRequest,
_ context: HTTPValidationContext
) -> Set<String>?
/// Closure that decides whether a request requires Bearer authentication.
///
/// Return `false` to allow a request through unauthenticated (e.g., public health-check endpoints).
/// Defaults to requiring authentication on all requests.
public typealias RequirementPredicate = @Sendable (
_ request: HTTPRequest,
_ context: HTTPValidationContext
) -> Bool
public let resourceMetadataURL: URL
public let resourceIdentifier: URL
private let tokenValidator: TokenValidator
private let challengeScopeProvider: ChallengeScopeProvider?
private let requiresAuthentication: RequirementPredicate
private let metadataDiscovery: any OAuthMetadataDiscovering
/// Creates a `BearerTokenValidator`.
///
/// - Parameters:
/// - resourceMetadataURL: Included in `WWW-Authenticate` challenge headers as the
/// `resource_metadata` parameter, pointing to the RFC 9728 Protected Resource Metadata document.
/// - resourceIdentifier: The canonical URI of this resource server. Used to validate the
/// `aud` claim in tokens that supply audience information via ``BearerTokenInfo``.
/// - tokenValidator: Validates the Bearer token and returns ``BearerTokenInfo`` with
/// claims for SDK-side expiry and audience enforcement.
/// - challengeScopeProvider: Optional closure supplying scopes to include in challenge headers.
/// - requiresAuthentication: Predicate controlling which requests require a Bearer token.
/// Defaults to requiring authentication on all requests.
/// - metadataDiscovery: Used for audience URL matching. Defaults to ``DefaultOAuthMetadataDiscovery``.
public init(
resourceMetadataURL: URL,
resourceIdentifier: URL,
tokenValidator: @escaping TokenValidator,
challengeScopeProvider: ChallengeScopeProvider? = nil,
requiresAuthentication: @escaping RequirementPredicate = { _, _ in true },
metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
) {
self.resourceMetadataURL = resourceMetadataURL
self.resourceIdentifier = resourceIdentifier
self.tokenValidator = tokenValidator
self.challengeScopeProvider = challengeScopeProvider
self.requiresAuthentication = requiresAuthentication
self.metadataDiscovery = metadataDiscovery
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
guard requiresAuthentication(request, context) else { return nil }
guard let authorizationHeader = request.header(HTTPHeaderName.authorization) else {
return unauthorizedResponse(
challengeScope: challengeScopeProvider?(request, context),
error: nil,
errorDescription: nil,
sessionID: context.sessionID
)
}
let parsedToken: String
switch parseBearerToken(from: authorizationHeader) {
case .success(let token):
parsedToken = token
case .failure(let error):
return .error(
statusCode: 400,
.invalidRequest("Bad Request: \(error.message)"),
sessionID: context.sessionID
)
}
switch tokenValidator(parsedToken, request, context) {
case .valid(let info):
// Expiry check
if let exp = info.expiresAt, exp <= Date() {
return unauthorizedResponse(
challengeScope: challengeScopeProvider?(request, context),
error: "invalid_token",
errorDescription: "Token has expired",
sessionID: context.sessionID
)
}
// Audience check — skipped for opaque tokens (audience == nil)
if let audience = info.audience {
let matches = audience.contains { audString in
guard let audURL = URL(string: audString) else { return false }
return metadataDiscovery.protectedResourceMatches(
resource: audURL, endpoint: resourceIdentifier)
}
if !matches {
return unauthorizedResponse(
challengeScope: challengeScopeProvider?(request, context),
error: "invalid_token",
errorDescription: "Token audience mismatch",
sessionID: context.sessionID
)
}
}
return nil
case .invalidToken(let errorDescription):
return unauthorizedResponse(
challengeScope: challengeScopeProvider?(request, context),
error: "invalid_token",
errorDescription: errorDescription,
sessionID: context.sessionID
)
case .insufficientScope(let requiredScopes, let errorDescription):
return forbiddenInsufficientScopeResponse(
requiredScopes: requiredScopes,
errorDescription: errorDescription,
sessionID: context.sessionID
)
case .malformedRequest(let errorDescription):
let message = errorDescription ?? "Malformed authorization request"
return .error(
statusCode: 400,
.invalidRequest("Bad Request: \(message)"),
sessionID: context.sessionID
)
}
}
private func unauthorizedResponse(
challengeScope: Set<String>?,
error: String?,
errorDescription: String?,
sessionID: String?
) -> HTTPResponse {
let challenge = makeBearerChallenge(
resourceMetadataURL: resourceMetadataURL,
scope: challengeScope,
error: error,
errorDescription: errorDescription
)
return .error(
statusCode: 401,
.invalidRequest("Unauthorized"),
sessionID: sessionID,
extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
)
}
private func forbiddenInsufficientScopeResponse(
requiredScopes: Set<String>,
errorDescription: String?,
sessionID: String?
) -> HTTPResponse {
let challenge = makeBearerChallenge(
resourceMetadataURL: resourceMetadataURL,
scope: requiredScopes,
error: "insufficient_scope",
errorDescription: errorDescription
)
return .error(
statusCode: 403,
.invalidRequest("Forbidden: Insufficient scope"),
sessionID: sessionID,
extraHeaders: [HTTPHeaderName.wwwAuthenticate: challenge]
)
}
private func makeBearerChallenge(
resourceMetadataURL: URL,
scope: Set<String>?,
error: String?,
errorDescription: String?
) -> String {
var parameters: [String] = []
parameters.append("resource_metadata=\"\(escapeAuthParameter(resourceMetadataURL.absoluteString))\"")
if let scope, !scope.isEmpty {
let serializedScope = scope.sorted().joined(separator: " ")
parameters.append("scope=\"\(escapeAuthParameter(serializedScope))\"")
}
if let error {
parameters.append("error=\"\(escapeAuthParameter(error))\"")
}
if let errorDescription, !errorDescription.isEmpty {
parameters.append("error_description=\"\(escapeAuthParameter(errorDescription))\"")
}
return "\(OAuthTokenType.bearer) " + parameters.joined(separator: ", ")
}
private func escapeAuthParameter(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
private struct BearerTokenParseError: Swift.Error {
let message: String
}
private func parseBearerToken(
from authorizationHeader: String
) -> Result<String, BearerTokenParseError> {
let trimmed = authorizationHeader.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .failure(.init(message: "Authorization header is empty"))
}
let parts = trimmed.split(
maxSplits: 1,
whereSeparator: { $0.isWhitespace }
)
guard parts.count == 2 else {
return .failure(
.init(message: "Authorization header must be in the form: Bearer <token>")
)
}
guard String(parts[0]).caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame else {
return .failure(.init(message: "Authorization scheme must be Bearer"))
}
let token = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
return .failure(.init(message: "Bearer token is empty"))
}
if token.contains(where: \.isWhitespace) {
return .failure(.init(message: "Bearer token must not contain whitespace"))
}
return .success(token)
}
}
// MARK: - Validation Pipeline Protocol
/// Runs a validation pipeline against an HTTP request.
///
/// Implementations execute a sequence of validators and return the first error,
/// or `nil` if all validations pass.
public protocol HTTPRequestValidationPipeline: Sendable {
/// Validates the request using the configured pipeline.
/// Returns an error response if validation fails, or `nil` if the request is valid.
func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse?
}
// MARK: - Standard Validation Pipeline
/// Standard implementation of `HTTPRequestValidationPipeline` that runs validators in sequence.
///
/// The first validator that returns a non-nil error response short-circuits the pipeline.
public struct StandardValidationPipeline: HTTPRequestValidationPipeline {
private let validators: [any HTTPRequestValidator]
/// Creates a pipeline with the given validators.
/// Validators are executed in the order provided.
public init(validators: [any HTTPRequestValidator]) {
self.validators = validators
}
public func validate(_ request: HTTPRequest, context: HTTPValidationContext) -> HTTPResponse? {
for validator in validators {
if let errorResponse = validator.validate(request, context: context) {
return errorResponse
}
}
return nil
}
}