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 {