From 393f5d2ecce23a2a6a7f531727873a7eb458f27c Mon Sep 17 00:00:00 2001 From: Kolya Date: Tue, 13 Oct 2026 14:47:42 +0200 Subject: [PATCH] Add SAML POST-to-GET binding conversion for federated IdP URL --- .../XcodesLoginKit/AuthenticationState.swift | 78 +++++++- .../XcodesLoginKitTests.swift | 169 ++++++++++++++++++ 2 files changed, 243 insertions(+), 4 deletions(-) diff --git a/Sources/XcodesLoginKit/AuthenticationState.swift b/Sources/XcodesLoginKit/AuthenticationState.swift index 2dbcd15..ed5b6cb 100644 --- a/Sources/XcodesLoginKit/AuthenticationState.swift +++ b/Sources/XcodesLoginKit/AuthenticationState.swift @@ -271,11 +271,64 @@ public struct FederationResponse: Decodable, Equatable, Sendable { /// A ready-to-open identity-provider URL containing Apple's request parameters. public var idpURL: URL? { guard let idpRequest = federatedIdpRequest else { return nil } - var components = URLComponents(string: idpRequest.idPUrl) - components?.queryItems = idpRequest.requestParams.map { key, value in - URLQueryItem(name: key, value: value) + + if usesPostBinding, isSAMLRequest { + // SAML HTTP-POST binding: convert to the HTTP-Redirect binding so it opens as a GET. + return Self.convertSAMLPOSTtoGET(for: idpRequest) + } else { + // SAML HTTP-Redirect binding, OIDC, or a plain federated GET: open the params as-is. + return Self.getRequestURL(for: idpRequest, params: idpRequest.requestParams) + } + } + + /// Whether the identity provider expects the request via an HTTP form POST + public var usesPostBinding: Bool { + federatedIdpRequest?.httpMethod?.uppercased() == "POST" + } + + /// Whether the request is a SAML request (carries a `SAMLRequest` parameter) + public var isSAMLRequest: Bool { + federatedIdpRequest?.requestParams["SAMLRequest"] != nil + } + + /// Builds a GET URL by appending `params` to the endpoint as a percent-encoded query string. + private static func getRequestURL(for idpRequest: FederatedIdpRequest, params: [String: String]) -> URL? { + guard var components = URLComponents(string: idpRequest.idPUrl) else { return nil } + components.percentEncodedQuery = formURLEncodedQuery(from: params) + return components.url + } + + /// Converts a SAML HTTP-POST-binding request into an HTTP-Redirect-binding GET URL. + /// + /// Only called for requests that carry a `SAMLRequest` (see ``isSAMLRequest``). Apple's + /// POST-binding `SAMLRequest` is base64 of the uncompressed SAML XML; the redirect binding + /// requires it to be raw-DEFLATE compressed and re-base64-encoded. All other parameters are + /// preserved. Returns `nil` if the `SAMLRequest` value cannot be decoded and re-compressed — a + /// broken redirect URL would only be rejected by the identity provider, so no URL is produced in + /// that case. + private static func convertSAMLPOSTtoGET(for idpRequest: FederatedIdpRequest) -> URL? { + var params = idpRequest.requestParams + + guard let samlRequest = params["SAMLRequest"], + let rawXML = Data(base64Encoded: samlRequest), + let deflated = rawXML.samlDeflated() else { + return nil } - return components?.url + + params["SAMLRequest"] = deflated.base64EncodedString() + return getRequestURL(for: idpRequest, params: params) + } + + /// Builds a percent-encoded query string from the given parameters, encoding every character + /// outside the RFC 3986 unreserved set. + private static func formURLEncodedQuery(from params: [String: String]) -> String { + params + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlFormValueAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlFormValueAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") } } @@ -356,3 +409,20 @@ public struct FederatedAuthenticationCallback: Equatable, Sendable { try self.init(callbackURL: callbackURL) } } + +private extension Data { + func samlDeflated() -> Data? { + guard !isEmpty else { return nil } + return try? (self as NSData).compressed(using: .zlib) as Data + } +} + +private extension CharacterSet { + /// The RFC 3986 unreserved characters (`A-Z a-z 0-9 - . _ ~`), suitable for percent-encoding a + /// query-string value that will be decoded as `application/x-www-form-urlencoded`. + static let urlFormValueAllowed: CharacterSet = { + var set = CharacterSet.alphanumerics + set.insert(charactersIn: "-._~") + return set + }() +} diff --git a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift index 5a45f6c..c4770b8 100644 --- a/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift +++ b/Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift @@ -682,6 +682,175 @@ final class XcodesLoginKitTests: XCTestCase { XCTAssertTrue(recorder.didPromptForPassword) XCTAssertNil(recorder.openedURL) } + + // MARK: - FederationResponse.idpURL + + func testIdpURLReturnsNilWhenNoRequest() { + let response = FederationResponse(federated: true, federatedIdpRequest: nil) + XCTAssertNil(response.idpURL) + } + + func testIdpURLPassesGETRequestParamsThroughUnchanged() throws { + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["login_hint": "test@company.com"], + httpMethod: "GET" + ) + ) + + XCTAssertFalse(response.usesPostBinding) + + let url = try XCTUnwrap(response.idpURL) + let items = try queryItems(from: url) + XCTAssertEqual(items["login_hint"], "test@company.com") + } + + func testIdpURLReportsPostBinding() { + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["SAMLRequest": Data("".utf8).base64EncodedString()], + httpMethod: "POST" + ) + ) + + XCTAssertTrue(response.usesPostBinding) + XCTAssertTrue(response.isSAMLRequest) + } + + func testIsSAMLRequestReflectsSAMLRequestParameter() { + let saml = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["SAMLRequest": "abc"], + httpMethod: "GET" + ) + ) + XCTAssertTrue(saml.isSAMLRequest) + + let oidc = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://accounts.google.com/o/oauth2/v2/auth", + requestParams: ["client_id": "abc", "response_type": "code"], + httpMethod: "POST" + ) + ) + XCTAssertFalse(oidc.isSAMLRequest) + + let none = FederationResponse(federated: true, federatedIdpRequest: nil) + XCTAssertFalse(none.isSAMLRequest) + } + + func testIdpURLReEncodesPostSAMLRequestAsRawDeflate() throws { + // Apple's POST-binding SAMLRequest is base64 of the uncompressed XML. + let samlXML = "redirect-binding round-trip test content" + let samlData = Data(samlXML.utf8) + let postBindingSAMLRequest = samlData.base64EncodedString() + + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: [ + "SAMLRequest": postBindingSAMLRequest, + "RelayState": "state-123" + ], + httpMethod: "POST" + ) + ) + + let url = try XCTUnwrap(response.idpURL) + let items = try queryItems(from: url) + + // RelayState is preserved untouched. + XCTAssertEqual(items["RelayState"], "state-123") + + // The SAMLRequest value must have changed (it is now DEFLATE-compressed, not raw base64 XML). + let redirectBindingSAMLRequest = try XCTUnwrap(items["SAMLRequest"]) + XCTAssertNotEqual(redirectBindingSAMLRequest, postBindingSAMLRequest) + + // And it must inflate back to the original XML (raw DEFLATE / RFC 1951). + let compressed = try XCTUnwrap(Data(base64Encoded: redirectBindingSAMLRequest)) + let inflated = try (compressed as NSData).decompressed(using: .zlib) as Data + XCTAssertEqual(inflated, samlData) + } + + func testIdpURLPercentEncodesPlusInSAMLRequest() throws { + // A base64 payload containing '+' must be percent-encoded as %2B, otherwise the identity + // provider decodes '+' to a space and rejects the request (Entra AADSTS750056). + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["SAMLRequest": "ab+cd/ef=="], + // GET so the value is passed through verbatim and we can assert on encoding only. + httpMethod: "GET" + ) + ) + + let url = try XCTUnwrap(response.idpURL) + let rawQuery = try XCTUnwrap(url.query) + XCTAssertTrue(rawQuery.contains("%2B"), "'+' should be percent-encoded, got: \(rawQuery)") + XCTAssertFalse(rawQuery.contains("+"), "query must not contain a literal '+', got: \(rawQuery)") + // '/' and '=' are also form-urlencoded concerns and must be escaped. + XCTAssertTrue(rawQuery.contains("%2F"), "'/' should be percent-encoded, got: \(rawQuery)") + XCTAssertFalse(rawQuery.contains("/"), "query must not contain a literal '/', got: \(rawQuery)") + XCTAssertTrue(rawQuery.contains("%3D"), "'=' should be percent-encoded, got: \(rawQuery)") + + // Round-trips back to the exact original value. + let items = try queryItems(from: url) + XCTAssertEqual(items["SAMLRequest"], "ab+cd/ef==") + } + + func testIdpURLPassesNonSAMLPostRequestThroughAsGET() throws { + // A POST request that is not SAML (e.g. OIDC form_post) has no SAMLRequest to DEFLATE. Its + // parameters are opened as-is rather than being dropped, so federated OIDC still works. + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://accounts.google.com/o/oauth2/v2/auth", + requestParams: ["client_id": "test-client", "response_type": "code"], + httpMethod: "POST" + ) + ) + + XCTAssertTrue(response.usesPostBinding) + XCTAssertFalse(response.isSAMLRequest) + + let url = try XCTUnwrap(response.idpURL) + let items = try queryItems(from: url) + XCTAssertEqual(items["client_id"], "test-client") + XCTAssertEqual(items["response_type"], "code") + } + + func testIdpURLReturnsNilForInvalidBase64SAMLRequest() { + // Invalid base64 in a SAML POST request can't be DEFLATE re-encoded; idpURL is nil rather + // than a broken redirect URL. + let response = FederationResponse( + federated: true, + federatedIdpRequest: FederatedIdpRequest( + idPUrl: "https://login.microsoftonline.com/test-tenant/oauth2/authorize", + requestParams: ["SAMLRequest": "not-valid-base64!!!"], + httpMethod: "POST" + ) + ) + + XCTAssertNil(response.idpURL) + } + + private func queryItems(from url: URL) throws -> [String: String] { + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + var result: [String: String] = [:] + for item in components.queryItems ?? [] { + result[item.name] = item.value + } + return result + } } private extension XcodesLoginKitTests {