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
78 changes: 74 additions & 4 deletions Sources/XcodesLoginKit/AuthenticationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "&")
}
}

Expand Down Expand Up @@ -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
}()
}
169 changes: 169 additions & 0 deletions Tests/XcodesLoginKitTests/XcodesLoginKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<xml/>".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 = "<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_abc123\" Version=\"2.0\" IssueInstant=\"2026-10-13T00:00:00Z\">redirect-binding round-trip test content</samlp:AuthnRequest>"
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 {
Expand Down