From eecb146cc0f5a63845d2ef0ca70395fe0eb0c715 Mon Sep 17 00:00:00 2001 From: EhsanAzish80 Date: Fri, 25 Sep 2026 17:36:21 +0300 Subject: [PATCH 1/3] Validate OAuth issuers and registration metadata --- .../OAuthAuthorizationCodeFlow.swift | 27 +++++++++++++++-- .../Base/Authorization/OAuthAuthorizer.swift | 21 ++++++++++++++ .../Authorization/OAuthClientRegistrar.swift | 1 + .../Authorization/OAuthConfiguration.swift | 8 +++++ .../Base/Authorization/OAuthConstants.swift | 1 + .../Authorization/OAuthDiscoveryClient.swift | 29 ++++++------------- .../MCP/Base/Authorization/OAuthErrors.swift | 6 ++++ .../MCP/Base/Authorization/OAuthModels.swift | 2 ++ .../OAuthAuthorizationCodeFlowTests.swift | 21 ++++++++++++++ Tests/MCPTests/OAuthAuthorizerTests.swift | 2 ++ .../MCPTests/OAuthClientRegistrarTests.swift | 1 + .../MCPTests/OAuthDiscoveryClientTests.swift | 15 +++++----- 12 files changed, 104 insertions(+), 30 deletions(-) diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift index 0c278f1c..d3cc49ad 100644 --- a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift +++ b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift @@ -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 @@ -121,6 +123,8 @@ public struct OAuthAuthorizationCodeFlow: Sendable { authorizationURL: URL, redirectURI: URL, state: String, + expectedIssuer: String?, + requireIssuer: Bool, delegate: (any OAuthAuthorizationDelegate)?, session: URLSession ) async throws -> String { @@ -129,7 +133,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { return try extractCode( from: redirectURL, expectedRedirectURI: redirectURI, - expectedState: state + expectedState: state, + expectedIssuer: expectedIssuer, + requireIssuer: requireIssuer ) } @@ -168,7 +174,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { return try extractCode( from: redirectURL, expectedRedirectURI: redirectURI, - expectedState: state + expectedState: state, + expectedIssuer: expectedIssuer, + requireIssuer: requireIssuer ) } @@ -182,7 +190,9 @@ public struct OAuthAuthorizationCodeFlow: Sendable { 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), @@ -218,6 +228,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 diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift index b2e5be3b..3a0dd2a4 100644 --- a/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift +++ b/Sources/MCP/Base/Authorization/OAuthAuthorizer.swift @@ -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( @@ -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 ) diff --git a/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift index 6267421c..1904bec7 100644 --- a/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift +++ b/Sources/MCP/Base/Authorization/OAuthClientRegistrar.swift @@ -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, ] diff --git a/Sources/MCP/Base/Authorization/OAuthConfiguration.swift b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift index eb115a98..98fc26cc 100644 --- a/Sources/MCP/Base/Authorization/OAuthConfiguration.swift +++ b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift @@ -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. @@ -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? @@ -380,6 +386,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, @@ -393,6 +400,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 diff --git a/Sources/MCP/Base/Authorization/OAuthConstants.swift b/Sources/MCP/Base/Authorization/OAuthConstants.swift index 73cde0f1..cfef573b 100644 --- a/Sources/MCP/Base/Authorization/OAuthConstants.swift +++ b/Sources/MCP/Base/Authorization/OAuthConstants.swift @@ -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 diff --git a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift index 8795ed7a..5f8c27b8 100644 --- a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift +++ b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift @@ -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( @@ -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 { + if let issuer = asMetadata.issuer, + 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 } } diff --git a/Sources/MCP/Base/Authorization/OAuthErrors.swift b/Sources/MCP/Base/Authorization/OAuthErrors.swift index 2f8249ce..b1549a59 100644 --- a/Sources/MCP/Base/Authorization/OAuthErrors.swift +++ b/Sources/MCP/Base/Authorization/OAuthErrors.swift @@ -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 @@ -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: diff --git a/Sources/MCP/Base/Authorization/OAuthModels.swift b/Sources/MCP/Base/Authorization/OAuthModels.swift index 28043b9f..2ea50e2c 100644 --- a/Sources/MCP/Base/Authorization/OAuthModels.swift +++ b/Sources/MCP/Base/Authorization/OAuthModels.swift @@ -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 @@ -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" } } diff --git a/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift b/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift index 85c610e4..4960a27b 100644 --- a/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift +++ b/Tests/MCPTests/OAuthAuthorizationCodeFlowTests.swift @@ -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) + } + } } diff --git a/Tests/MCPTests/OAuthAuthorizerTests.swift b/Tests/MCPTests/OAuthAuthorizerTests.swift index 0384307b..36d8227c 100644 --- a/Tests/MCPTests/OAuthAuthorizerTests.swift +++ b/Tests/MCPTests/OAuthAuthorizerTests.swift @@ -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 { diff --git a/Tests/MCPTests/OAuthClientRegistrarTests.swift b/Tests/MCPTests/OAuthClientRegistrarTests.swift index 7bdec5dd..2f140d0d 100644 --- a/Tests/MCPTests/OAuthClientRegistrarTests.swift +++ b/Tests/MCPTests/OAuthClientRegistrarTests.swift @@ -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), diff --git a/Tests/MCPTests/OAuthDiscoveryClientTests.swift b/Tests/MCPTests/OAuthDiscoveryClientTests.swift index b962bb6f..168228b9 100644 --- a/Tests/MCPTests/OAuthDiscoveryClientTests.swift +++ b/Tests/MCPTests/OAuthDiscoveryClientTests.swift @@ -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() @@ -200,11 +200,12 @@ 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("Skips private IP candidates without making HTTP calls") From d58eb1efaed05e866b5f60861f91386fc26c700d Mon Sep 17 00:00:00 2001 From: EhsanAzish80 Date: Sat, 3 Oct 2026 08:59:19 +0300 Subject: [PATCH 2/3] Require issuer in authorization server metadata --- .../Authorization/OAuthDiscoveryClient.swift | 4 ++-- .../MCPTests/OAuthDiscoveryClientTests.swift | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift index 5f8c27b8..b593b5f8 100644 --- a/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift +++ b/Sources/MCP/Base/Authorization/OAuthDiscoveryClient.swift @@ -109,8 +109,8 @@ struct OAuthDiscoveryClient: Sendable { let asMetadata = try decoder.decode( OAuthAuthorizationServerMetadata.self, from: data) - if let issuer = asMetadata.issuer, - issuer.absoluteString != candidateServer.absoluteString { + guard let issuer = asMetadata.issuer else { continue } + if issuer.absoluteString != candidateServer.absoluteString { issuerMismatch = .authorizationServerIssuerMismatch( expected: candidateServer.absoluteString, actual: issuer.absoluteString) diff --git a/Tests/MCPTests/OAuthDiscoveryClientTests.swift b/Tests/MCPTests/OAuthDiscoveryClientTests.swift index 168228b9..1c4d1fff 100644 --- a/Tests/MCPTests/OAuthDiscoveryClientTests.swift +++ b/Tests/MCPTests/OAuthDiscoveryClientTests.swift @@ -208,6 +208,29 @@ import Testing } } + @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") func testFetchAuthorizationServerMetadataSkipsPrivateIP() async throws { let (session, _) = makeIsolatedSession() From 1e28f081af7dbcec24be8586f227949bd1617eb4 Mon Sep 17 00:00:00 2001 From: EhsanAzish80 Date: Sat, 24 Oct 2026 20:57:54 +0300 Subject: [PATCH 3/3] Document OAuth issuer and application type parameters --- .../MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift | 4 ++++ Sources/MCP/Base/Authorization/OAuthConfiguration.swift | 1 + 2 files changed, 5 insertions(+) diff --git a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift index d3cc49ad..f7eb989f 100644 --- a/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift +++ b/Sources/MCP/Base/Authorization/OAuthAuthorizationCodeFlow.swift @@ -116,6 +116,8 @@ 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. @@ -186,6 +188,8 @@ 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, diff --git a/Sources/MCP/Base/Authorization/OAuthConfiguration.swift b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift index 98fc26cc..28eb3bb1 100644 --- a/Sources/MCP/Base/Authorization/OAuthConfiguration.swift +++ b/Sources/MCP/Base/Authorization/OAuthConfiguration.swift @@ -372,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:/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).