From 5428146e7b86f24f563418724d9c7391ea08ca98 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 1 Aug 2025 05:47:20 +0530 Subject: [PATCH 1/5] implementing start passkey sign-in --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 25 +++++++++++++++ .../RPC/StartPasskeySignInRequest.swift | 32 +++++++++++++++++++ .../RPC/StartPasskeySignInResponse.swift | 31 ++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index d198f5418f5..2e3c77cfbd9 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -29,6 +29,10 @@ import FirebaseCoreExtension import UIKit #endif +#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + import AuthenticationServices +#endif + // Export the deprecated Objective-C defined globals and typedefs. #if SWIFT_PACKAGE @_exported import FirebaseAuthInternal @@ -1641,6 +1645,27 @@ extension Auth: AuthInterop { public static let authStateDidChangeNotification = NSNotification.Name(rawValue: "FIRAuthStateDidChangeNotification") + // MARK: Passkey Implementation + + public func startPasskeySignIn() async throws -> + ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) + let response = try await backend.call(with: request) + guard let challengeInData = Data(base64Encoded: response.challenge) else { + throw NSError( + domain: AuthErrorDomain, + code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] + ) + } + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID + ) + return provider.createCredentialAssertionRequest( + challenge: challengeInData + ) + } + // MARK: Internal methods init(app: FirebaseApp, diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift new file mode 100644 index 00000000000..f51d08299f2 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -0,0 +1,32 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeySignInResponse + init(requestConfiguration: AuthRequestConfiguration) { + super.init( + endpoint: "accounts/passkeySignIn:start", + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + guard let tenantID = tenantID else { + return nil + } + return ["tenantId": tenantID] + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift new file mode 100644 index 00000000000..6552a535c48 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -0,0 +1,31 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +struct StartPasskeySignInResponse: AuthRPCResponse { + let rpID: String + let challenge: String + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary["credentialCreationOptions"] as? [String: Any] else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard + let options = dictionary["options"] as? [String: Any], + let rpID = options["rpId"] as? String, + let challenge = options["challenge"] as? String + else { throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) } + self.rpID = rpID + self.challenge = challenge + } +} From 59d804cff20b1ca778cd3d43f99e18306caa05ae Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 1 Aug 2025 08:38:54 +0530 Subject: [PATCH 2/5] adding unit tests --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 1 + .../RPC/StartPasskeySignInRequest.swift | 5 +- .../RPC/StartPasskeySignInResponse.swift | 14 ++-- FirebaseAuth/Tests/Unit/AuthTests.swift | 57 ++++++++++++++ .../Unit/StartPasskeySignInRequestTests.swift | 75 +++++++++++++++++++ .../StartPasskeySignInResponseTests.swift | 74 ++++++++++++++++++ 6 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 2e3c77cfbd9..24fd945d794 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1647,6 +1647,7 @@ extension Auth: AuthInterop { // MARK: Passkey Implementation + /// starts sign in with passkey retrieving challenge from GCIP and create an assertion request. public func startPasskeySignIn() async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift index f51d08299f2..982a96b804b 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -12,12 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// The GCIP endpoint for startPasskeySignIn rpc +private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start" + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { typealias Response = StartPasskeySignInResponse init(requestConfiguration: AuthRequestConfiguration) { super.init( - endpoint: "accounts/passkeySignIn:start", + endpoint: startPasskeySignInEndpoint, requestConfiguration: requestConfiguration, useIdentityPlatform: true ) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift index 6552a535c48..096e674ee56 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -14,17 +14,19 @@ @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) struct StartPasskeySignInResponse: AuthRPCResponse { + /// The RP ID of the FIDO Relying Party let rpID: String + /// The FIDO challenge let challenge: String + init(dictionary: [String: AnyHashable]) throws { - guard let options = dictionary["credentialCreationOptions"] as? [String: Any] else { + guard let options = dictionary["credentialRequestOptions"] as? [String: Any] else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let rpID = options["rpId"] as? String, + let challenge = options["challenge"] as? String else { throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) } - guard - let options = dictionary["options"] as? [String: Any], - let rpID = options["rpId"] as? String, - let challenge = options["challenge"] as? String - else { throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) } self.rpID = rpID self.challenge = challenge } diff --git a/FirebaseAuth/Tests/Unit/AuthTests.swift b/FirebaseAuth/Tests/Unit/AuthTests.swift index 5ae1d522108..3f2077b0f3c 100644 --- a/FirebaseAuth/Tests/Unit/AuthTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthTests.swift @@ -27,6 +27,8 @@ class AuthTests: RPCBaseTests { static let kFakeAPIKey = "FAKE_API_KEY" static let kFakeRecaptchaResponse = "RecaptchaResponse" static let kFakeRecaptchaVersion = "RecaptchaVersion" + static let kRpId = "FAKE_RP_ID" + static let kChallenge = "Y2hhbGxlbmdl" var auth: Auth! static var testNum = 0 var authDispatcherCallback: (() -> Void)? @@ -2455,3 +2457,58 @@ class AuthTests: RPCBaseTests { XCTAssertEqual(user.providerData.count, 0) } } + +// MARK: Passkey Sign-In Tests + +#if os(iOS) + import AuthenticationServices + + @available(iOS 15.0, *) + extension AuthTests { + func testStartPasskeySignInSuccess() throws { + let expectation = self.expectation(description: #function) + let expectedChallenge = AuthTests.kChallenge // base64 string + let expectedRpId = AuthTests.kRpId + let expectedChallengeData = Data(base64Encoded: expectedChallenge)! + rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer.request as? StartPasskeySignInRequest) + XCTAssertEqual(request.apiKey, AuthTests.kFakeAPIKey) + return try self.rpcIssuer.respond(withJSON: [ + "credentialRequestOptions": [ + "rpId": expectedRpId, + "challenge": expectedChallenge, + ], + ]) + } + Task { + do { + let assertionRequest = try await self.auth.startPasskeySignIn() + XCTAssertEqual(assertionRequest.challenge, expectedChallengeData) + XCTAssertEqual(assertionRequest.relyingPartyIdentifier, expectedRpId) + expectation.fulfill() + } catch { + XCTFail("Unexpected error: \(error)") + } + } + waitForExpectations(timeout: 5) + } + + func testStartPasskeySignInFailure() throws { + let expectation = self.expectation(description: #function) + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await self.auth.startPasskeySignIn() + XCTFail("Expected error from backend but got success") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + waitForExpectations(timeout: 5) + } + } +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift new file mode 100644 index 00000000000..40fbe92f26f --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift @@ -0,0 +1,75 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +#if os(iOS) || os(tvOS) || os(macOS) + + @testable import FirebaseAuth + import FirebaseCore + import Foundation + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + final class StartPasskeySignInRequestTests: XCTestCase { + private var config: AuthRequestConfiguration! + + override func setUp() { + super.setUp() + config = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID" + ) + } + + override func tearDown() { + config = nil + super.tearDown() + } + + func testInit_SetsEndpointAndConfig() { + let request = StartPasskeySignInRequest(requestConfiguration: config) + XCTAssertEqual(request.endpoint, "accounts/passkeySignIn:start") + XCTAssertTrue(request.useIdentityPlatform) + XCTAssertEqual(request.requestConfiguration().apiKey, "FAKE_API_KEY") + XCTAssertEqual(request.requestConfiguration().appID, "FAKE_APP_ID") + } + + func testUnencodedHTTPRequestBody_WithTenantId() { + // setting up fake auth to set tenantId + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = AuthTests.kFakeAPIKey + options.projectID = "myProjectID" + let name = "test-AuthTests\(AuthTests.testNum)" + AuthTests.testNum = AuthTests.testNum + 1 + let fakeAuth = Auth(app: FirebaseApp(instanceWithName: name, options: options)) + fakeAuth.tenantID = "TEST_TENANT" + let configWithTenant = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID", + auth: fakeAuth + ) + _ = AuthRequestConfiguration(apiKey: "apiKey", appID: "appId") + let request = StartPasskeySignInRequest( + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertEqual(body!["tenantId"], "TEST_TENANT") + } + + func testUnencodedHTTPRequestBody_WithoutTenantId() { + let request = StartPasskeySignInRequest(requestConfiguration: config) + XCTAssertNil(request.unencodedHTTPRequestBody) + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift new file mode 100644 index 00000000000..bc5a91ed9e6 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift @@ -0,0 +1,74 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(macOS) + + @testable import FirebaseAuth + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + final class StartPasskeySignInResponseTests: XCTestCase { + private func makeValidDictionary() -> [String: AnyHashable] { + return [ + "credentialRequestOptions": [ + "rpId": "FAKE_RPID", + "challenge": "FAKE_CHALLENGE", + ] as [String: AnyHashable], + ] + } + + func testInitWithValidDictionary() throws { + let dict = makeValidDictionary() + let response = try StartPasskeySignInResponse(dictionary: dict) + XCTAssertEqual(response.rpID, "FAKE_RPID") + XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") + } + + func testInitWithMissingCredentialRequestOptions() { + let dict: [String: AnyHashable] = [:] + XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingRpId() { + var dict = makeValidDictionary() + if var options = dict["credentialRequestOptions"] as? [String: AnyHashable] { + options.removeValue(forKey: "rpId") + dict["credentialRequestOptions"] = options + } + XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingChallenge() { + var dict = makeValidDictionary() + if var options = dict["credentialRequestOptions"] as? [String: AnyHashable] { + options.removeValue(forKey: "challenge") + dict["credentialRequestOptions"] = options + } + XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + } + +#endif From 2caf1734e29cd5b7fb2105cf2d10df36e1da9a28 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 2 Aug 2025 20:28:05 +0530 Subject: [PATCH 3/5] fix --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 37 ++++++++++++---------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 24fd945d794..fada0bca2de 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1647,25 +1647,28 @@ extension Auth: AuthInterop { // MARK: Passkey Implementation - /// starts sign in with passkey retrieving challenge from GCIP and create an assertion request. - public func startPasskeySignIn() async throws -> - ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { - let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) - let response = try await backend.call(with: request) - guard let challengeInData = Data(base64Encoded: response.challenge) else { - throw NSError( - domain: AuthErrorDomain, - code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] + #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + + /// starts sign in with passkey retrieving challenge from GCIP and create an assertion request. + public func startPasskeySignIn() async throws -> + ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) + let response = try await backend.call(with: request) + guard let challengeInData = Data(base64Encoded: response.challenge) else { + throw NSError( + domain: AuthErrorDomain, + code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] + ) + } + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID + ) + return provider.createCredentialAssertionRequest( + challenge: challengeInData ) } - let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( - relyingPartyIdentifier: response.rpID - ) - return provider.createCredentialAssertionRequest( - challenge: challengeInData - ) - } + #endif // MARK: Internal methods From 527fd31054a7218d706579965e2b1fa6047d07a7 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 2 Aug 2025 20:44:51 +0530 Subject: [PATCH 4/5] lint fixes --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 1 + .../Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index fada0bca2de..b83737f9e19 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1650,6 +1650,7 @@ extension Auth: AuthInterop { #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) /// starts sign in with passkey retrieving challenge from GCIP and create an assertion request. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) public func startPasskeySignIn() async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift index 982a96b804b..4b317460681 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -18,6 +18,7 @@ private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start" @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { typealias Response = StartPasskeySignInResponse + init(requestConfiguration: AuthRequestConfiguration) { super.init( endpoint: startPasskeySignInEndpoint, From e1cd31259c4abeadf445d6e432c245739c5bf7d8 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Wed, 6 Aug 2025 14:29:17 +0530 Subject: [PATCH 5/5] updating unit tests --- .../StartPasskeySignInResponseTests.swift | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift index bc5a91ed9e6..b89ebcece1e 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift @@ -35,38 +35,39 @@ XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") } - func testInitWithMissingCredentialRequestOptions() { - let dict: [String: AnyHashable] = [:] - XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + /// Helper function to remove nested field from dictionary + private func removeField(_ dict: inout [String: AnyHashable], keyPath: [String]) { + guard let first = keyPath.first else { return } + if keyPath.count == 1 { + dict.removeValue(forKey: first) + } else if var inDict = dict[first] as? [String: AnyHashable] { + removeField(&inDict, keyPath: Array(keyPath.dropFirst())) + dict[first] = inDict } } - func testInitWithMissingRpId() { - var dict = makeValidDictionary() - if var options = dict["credentialRequestOptions"] as? [String: AnyHashable] { - options.removeValue(forKey: "rpId") - dict["credentialRequestOptions"] = options + func testInitWithInvalidDictionary() throws { + struct TestCase { + let name: String + let removeFieldPath: [String] } - XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) - } - } - - func testInitWithMissingChallenge() { - var dict = makeValidDictionary() - if var options = dict["credentialRequestOptions"] as? [String: AnyHashable] { - options.removeValue(forKey: "challenge") - dict["credentialRequestOptions"] = options - } - XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + let cases: [TestCase] = [ + .init(name: "Missing credential options", removeFieldPath: ["credentialRequestOptions"]), + .init(name: "Missing rpId", removeFieldPath: ["credentialRequestOptions", "rpId"]), + .init( + name: "Missing challenge", + removeFieldPath: ["credentialRequestOptions", "challenge"] + ), + ] + for testCase in cases { + var dict = makeValidDictionary() + removeField(&dict, keyPath: testCase.removeFieldPath) + XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict), + testCase.name) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } } }