Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ protocol OAuthAuthorizationCodeFlowing: Sendable {
authorizationURL: URL,
redirectURI: URL,
state: String,
expectedIssuer: String?,
requireIssuer: Bool,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String
Expand Down Expand Up @@ -114,13 +116,17 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
/// - authorizationURL: The full authorization request URL.
/// - redirectURI: The expected redirect URI base for validation.
/// - state: The CSRF state nonce to verify in the redirect.
/// - expectedIssuer: Authorization server issuer to verify in the redirect.
/// - requireIssuer: Whether the redirect must include an issuer.
/// - delegate: Optional user-facing delegate for browser-based flows.
/// - session: The `URLSession` used for the no-redirect path.
/// - Returns: The extracted authorization code.
public func perform(
authorizationURL: URL,
redirectURI: URL,
state: String,
expectedIssuer: String?,
requireIssuer: Bool,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String {
Expand All @@ -129,7 +135,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
expectedState: state,
expectedIssuer: expectedIssuer,
requireIssuer: requireIssuer
)
}

Expand Down Expand Up @@ -168,7 +176,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
return try extractCode(
from: redirectURL,
expectedRedirectURI: redirectURI,
expectedState: state
expectedState: state,
expectedIssuer: expectedIssuer,
requireIssuer: requireIssuer
)
}

Expand All @@ -178,11 +188,15 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
/// - redirectURL: The redirect URL received from the authorization server.
/// - expectedRedirectURI: The redirect URI used in the authorization request.
/// - expectedState: The CSRF state nonce sent in the authorization request.
/// - expectedIssuer: Authorization server issuer to verify in the redirect.
/// - requireIssuer: Whether the redirect must include an issuer.
/// - Returns: The authorization code.
public func extractCode(
from redirectURL: URL,
expectedRedirectURI: URL,
expectedState: String
expectedState: String,
expectedIssuer: String? = nil,
requireIssuer: Bool = false
) throws -> String {
guard
let redirectComponents = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false),
Expand Down Expand Up @@ -218,6 +232,17 @@ public struct OAuthAuthorizationCodeFlow: Sendable {
)
}

let issuer = redirectComponents.queryItems?.first(where: {
$0.name == OAuthParameterName.issuer
})?.value
if requireIssuer && issuer == nil {
throw OAuthAuthorizationError.authorizationResponseMissingIssuer
}
if let issuer, let expectedIssuer, issuer != expectedIssuer {
throw OAuthAuthorizationError.authorizationResponseIssuerMismatch(
expected: expectedIssuer, actual: issuer)
}

guard
let code = redirectComponents.queryItems?.first(where: {
$0.name == OAuthParameterName.code
Expand Down
21 changes: 21 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthAuthorizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {

switch statusCode {
case 401:
if tokenStorage.load() != nil {
let previousServer = selectedAuthorizationServer
protectedResourceMetadata = nil
authorizationServerMetadata = nil
cachedProtectedResourceMetadataURL = nil
selectedAuthorizationServer = nil
let currentMetadata = try await discoverProtectedResourceMetadata(
endpoint: endpoint, challenge: challenge, session: session)
_ = try await resolveAuthorizationServerMetadata(
metadata: currentMetadata, session: session)
if let previousServer, let currentServer = selectedAuthorizationServer,
!authorizationServersMatch(previousServer, currentServer) {
tokenStorage.clear()
configuration.authentication = .none(clientID: "")
clientRegistrationAttempted = false
clientSecretExpiresAt = nil
}
}
if let refreshToken = tokenStorage.load()?.refreshToken {
tokenStorage.clear()
let metadata = try await discoverProtectedResourceMetadata(
Expand Down Expand Up @@ -600,6 +618,9 @@ public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
authorizationURL: authorizationURL,
redirectURI: configuration.authorizationRedirectURI,
state: state,
expectedIssuer: asMetadata.issuer?.absoluteString
?? selectedAuthorizationServer?.absoluteString,
requireIssuer: asMetadata.authorizationResponseISSParameterSupported == true,
delegate: configuration.authorizationDelegate,
session: session
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ struct OAuthClientRegistrar: Sendable {

var registrationPayload: [String: Any] = [
"client_name": configuration.clientName,
"application_type": configuration.applicationType.rawValue,
"grant_types": grantTypes,
"token_endpoint_auth_method": configuration.authentication.methodName,
]
Expand Down
9 changes: 9 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import Foundation
/// Supports both `authorization_code` (interactive, browser-based) and `client_credentials`
/// (machine-to-machine) grant types via the ``grantType`` property.
public struct OAuthConfiguration: Sendable {
public enum ApplicationType: String, Sendable {
case native
case web
}
/// The OAuth 2.1 grant type to use for token acquisition.
public enum GrantType: Sendable {
/// OAuth 2.1 authorization_code flow with PKCE.
Expand Down Expand Up @@ -341,6 +345,8 @@ public struct OAuthConfiguration: Sendable {
/// The `client_name` sent during dynamic client registration (RFC 7591).
/// Defaults to `"mcp-swift-sdk"`. Override with your application's name.
public let clientName: String
/// The RFC 7591 application type sent during dynamic client registration.
public let applicationType: ApplicationType

/// Optional provider for externally acquired access tokens.
public let accessTokenProvider: AccessTokenProvider?
Expand All @@ -366,6 +372,7 @@ public struct OAuthConfiguration: Sendable {
/// - authorizationRedirectURI: Redirect URI for the `authorization_code` flow.
/// Defaults to a random loopback URI (`http://127.0.0.1:<port>/callback`).
/// - clientName: The `client_name` sent during dynamic client registration. Defaults to `"mcp-swift-sdk"`.
/// - applicationType: The RFC 7591 application type sent during registration.
/// - additionalTokenRequestParameters: Extra form fields appended to every token request.
/// - accessTokenProvider: Optional closure invoked after discovery, allowing the host app
/// to supply an externally acquired token (e.g., from a system credential store).
Expand All @@ -380,6 +387,7 @@ public struct OAuthConfiguration: Sendable {
endpointOverrides: EndpointOverrides = .none,
authorizationRedirectURI: URL? = nil,
clientName: String = "mcp-swift-sdk",
applicationType: ApplicationType = .native,
additionalTokenRequestParameters: [String: String] = [:],
accessTokenProvider: AccessTokenProvider? = nil,
authorizationDelegate: (any OAuthAuthorizationDelegate)? = nil,
Expand All @@ -393,6 +401,7 @@ public struct OAuthConfiguration: Sendable {
self.authorizationRedirectURI =
authorizationRedirectURI ?? Self.defaultAuthorizationRedirectURI()
self.clientName = clientName
self.applicationType = applicationType
self.additionalTokenRequestParameters = additionalTokenRequestParameters
self.accessTokenProvider = accessTokenProvider
self.authorizationDelegate = authorizationDelegate
Expand Down
1 change: 1 addition & 0 deletions Sources/MCP/Base/Authorization/OAuthConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum OAuthParameterName {
static let clientAssertionType = "client_assertion_type"
static let refreshToken = "refresh_token"
static let state = "state"
static let issuer = "iss"
}

// MARK: - OAuth Well-Known Paths
Expand Down
29 changes: 9 additions & 20 deletions Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,13 @@ struct OAuthDiscoveryClient: Sendable {
throw OAuthAuthorizationError.metadataDiscoveryFailed
}

/// Fetches Authorization Server Metadata from candidates, preferring a response whose
/// `issuer` matches the candidate URL (RFC 8414 §3). If no candidate produces a matching
/// issuer, the first valid response is accepted and its own `issuer` is used as the
/// server identity — accommodating servers that serve metadata at one host but advertise
/// a different issuer.
/// Fetches Authorization Server Metadata from candidates and checks its issuer.
func fetchAuthorizationServerMetadata(
candidates: [URL],
session: URLSession
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
let decoder = JSONDecoder()
var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)?
var issuerMismatch: OAuthAuthorizationError?

for candidateServer in candidates {
guard (try? urlValidator.validateAuthorizationServer(
Expand Down Expand Up @@ -113,28 +109,21 @@ struct OAuthDiscoveryClient: Sendable {
let asMetadata = try decoder.decode(
OAuthAuthorizationServerMetadata.self, from: data)

// Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3).
let issuerMatches =
asMetadata.issuer == nil
|| asMetadata.issuer?.absoluteString.lowercased()
== candidateServer.absoluteString.lowercased()
if issuerMatches {
guard let issuer = asMetadata.issuer else { continue }
if issuer.absoluteString != candidateServer.absoluteString {
issuerMismatch = .authorizationServerIssuerMismatch(
expected: candidateServer.absoluteString,
actual: issuer.absoluteString)
} else {
return (server: candidateServer, metadata: asMetadata)
}
// Keep as fallback in case no issuer-matching candidate is found.
if firstValid == nil {
let server = asMetadata.issuer ?? candidateServer
firstValid = (server: server, metadata: asMetadata)
}
} catch {
continue
}
}
}

if let firstValid {
return firstValid
}
if let issuerMismatch { throw issuerMismatch }
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ public enum OAuthAuthorizationError: LocalizedError {
/// - expected: The `state` value sent in the authorization request.
/// - actual: The `state` value received in the authorization response.
case authorizationResponseStateMismatch(expected: String, actual: String)
case authorizationResponseMissingIssuer
case authorizationResponseIssuerMismatch(expected: String, actual: String)

/// The authorization response redirect URL is missing the `code` parameter.
case authorizationResponseMissingCode
Expand Down Expand Up @@ -183,6 +185,10 @@ public enum OAuthAuthorizationError: LocalizedError {
return "Authorization response is missing state"
case .authorizationResponseStateMismatch(let expected, let actual):
return "Authorization response state mismatch. Expected \(expected), got \(actual)"
case .authorizationResponseMissingIssuer:
return "Authorization response is missing the issuer"
case .authorizationResponseIssuerMismatch(let expected, let actual):
return "Authorization response issuer mismatch. Expected \(expected), got \(actual)"
case .authorizationResponseMissingCode:
return "Authorization response is missing the authorization code"
case .pkceCodeChallengeMethodsMissing:
Expand Down
2 changes: 2 additions & 0 deletions Sources/MCP/Base/Authorization/OAuthModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable {
let codeChallengeMethodsSupported: [String]?
let tokenEndpointAuthMethodsSupported: [String]?
let clientIDMetadataDocumentSupported: Bool?
var authorizationResponseISSParameterSupported: Bool? = nil

enum CodingKeys: String, CodingKey {
case issuer
Expand All @@ -86,6 +87,7 @@ struct OAuthAuthorizationServerMetadata: Decodable, Sendable, Equatable {
case codeChallengeMethodsSupported = "code_challenge_methods_supported"
case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
case clientIDMetadataDocumentSupported = "client_id_metadata_document_supported"
case authorizationResponseISSParameterSupported = "authorization_response_iss_parameter_supported"
}
}

Expand Down
21 changes: 21 additions & 0 deletions Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,25 @@ struct OAuthAuthorizationCodeFlowTests {
)
#expect(code == "auth-code-123")
}

@Test("Missing required issuer is rejected before the code is used")
func missingIssuer() {
let redirect = URL(string: "https://app.example.com/callback?code=abc&state=my-state")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(from: redirect, expectedRedirectURI: redirectURI,
expectedState: "my-state", expectedIssuer: "https://auth.example.com",
requireIssuer: true)
}
}

@Test("Issuer comparison keeps exact URL spelling")
func issuerMismatch() {
let redirect = URL(string:
"https://app.example.com/callback?code=abc&state=my-state&iss=https%3A%2F%2Fauth.example.com%2F")!
#expect(throws: OAuthAuthorizationError.self) {
try flow.extractCode(from: redirect, expectedRedirectURI: redirectURI,
expectedState: "my-state", expectedIssuer: "https://auth.example.com",
requireIssuer: false)
}
}
}
2 changes: 2 additions & 0 deletions Tests/MCPTests/OAuthAuthorizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ final class MockAuthCodeFlow: OAuthAuthorizationCodeFlowing, @unchecked Sendable
authorizationURL: URL,
redirectURI: URL,
state: String,
expectedIssuer: String?,
requireIssuer: Bool,
delegate: (any OAuthAuthorizationDelegate)?,
session: URLSession
) async throws -> String {
Expand Down
1 change: 1 addition & 0 deletions Tests/MCPTests/OAuthClientRegistrarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import Testing
}

let config = makeConfig(authentication: .none(clientID: ""))
#expect(config.applicationType == .native)
let result = try await registrar.register(
configuration: config,
asMetadata: makeASMetadata(registrationEndpoint: registrationEndpoint),
Expand Down
38 changes: 31 additions & 7 deletions Tests/MCPTests/OAuthDiscoveryClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ import Testing
#expect(metadata == expectedMetadata)
}

@Test("Uses metadata issuer as server identity when it differs from candidate URL")
func testFetchAuthorizationServerMetadataUsesMetadataIssuer() async throws {
@Test("Rejects metadata whose issuer differs from the discovery candidate")
func testFetchAuthorizationServerMetadataRejectsIssuerMismatch() async throws {
let metadataIssuer = "https://other.example.com"
let body = try makeASMetadataBody(issuer: metadataIssuer)
let (session, key) = makeIsolatedSession()
Expand All @@ -200,11 +200,35 @@ import Testing
return (response, body)
}

let (server, _) = try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://auth.example.com")!],
session: session
)
#expect(server == URL(string: metadataIssuer)!)
await #expect(throws: OAuthAuthorizationError.self) {
try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://auth.example.com")!],
session: session
)
}
}

@Test("Rejects authorization server metadata without an issuer")
func testFetchAuthorizationServerMetadataRejectsMissingIssuer() async throws {
let body = Data(#"{"token_endpoint":"https://auth.example.com/token"}"#.utf8)
let (session, key) = makeIsolatedSession()
await IsolatedMockURLProtocol.setHandler(key: key) { request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body)
}

do {
_ = try await makeClient().fetchAuthorizationServerMetadata(
candidates: [URL(string: "https://auth.example.com")!],
session: session
)
Issue.record("Metadata without an issuer was accepted")
} catch OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed {
// The document is invalid, so no candidate was accepted.
} catch {
Issue.record("Unexpected error: \(error)")
}
}

@Test("Skips private IP candidates without making HTTP calls")
Expand Down
Loading