From 714df231acb5179f2df40e10caf113b4b47bc6bb Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 03:02:43 +0530 Subject: [PATCH 1/8] implement start passkey enrollment --- .../RPC/StartPasskeyEnrollmentRequest.swift | 46 +++++++++++++++ .../RPC/StartPasskeyEnrollmentResponse.swift | 45 +++++++++++++++ FirebaseAuth/Sources/Swift/User/User.swift | 57 +++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..9a43ce09480 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift @@ -0,0 +1,46 @@ +// 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. + +import Foundation + +/// The GCIP endpoint for startPasskeyEnrollment rpc +private let startPasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:start" + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class StartPasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeyEnrollmentResponse + + /// The raw user access token + let idToken: String + + init(idToken: String, + requestConfiguration: AuthRequestConfiguration) { + self.idToken = idToken + super.init( + endpoint: startPasskeyEnrollmentEndPoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + var body: [String: AnyHashable] = [ + "idToken": idToken, + ] + if let tenantID = tenantID { + body["tenantId"] = tenantID + } + return body + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..5139e0a2eeb --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift @@ -0,0 +1,45 @@ +// 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. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +struct StartPasskeyEnrollmentResponse: AuthRPCResponse { + /// The RP ID of the FIDO Relying Party. + let rpID: String + /// The user id + let userID: String + /// The FIDO challenge. + let challenge: String + + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary["credentialCreationOptions"] as? [String: Any] else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let rp = options["rp"] as? [String: Any], + let rpID = rp["id"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let user = options["user"] as? [String: Any], + let userID = user["id"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let challenge = options["challenge"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.rpID = rpID + self.userID = userID + self.challenge = challenge + } +} diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 4ef324e177c..b40369a7574 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -14,6 +14,10 @@ import Foundation +#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + import AuthenticationServices +#endif + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension User: NSSecureCoding {} @@ -1047,6 +1051,59 @@ extension User: NSSecureCoding {} } } + // MARK: Passkey Implementation + + #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + + /// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed + /// at finalizePasskeyEnrollment(withPlatformCredential:) call + private var passkeyName: String? + + /// Start the passkey enrollment creating a plaform public key creation request with the + /// challenge from GCIP backend. + /// - Parameter name: The name for the passkey to be created. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func startPasskeyEnrollment(withName name: String?) async throws + -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { + guard auth != nil else { + /// If auth is nil, this User object is in an invalid state for this operation. + fatalError( + "Firebase Auth Internal Error: Set user's auth property with non-nil instance. Cannot start passkey enrollment." + ) + } + let enrollmentIdToken = rawAccessToken() + let request = StartPasskeyEnrollmentRequest( + idToken: enrollmentIdToken, + requestConfiguration: requestConfiguration + ) + let response = try await backend.call(with: request) + passkeyName = (name?.isEmpty ?? true) ? "Unnamed account (Apple)" : name! + 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."] + ) + } + guard let userIdInData = Data(base64Encoded: response.userID) else { + throw NSError( + domain: AuthErrorDomain, + code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 userId from response."] + ) + } + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID + ) + let registrationRequest = provider.createCredentialRegistrationRequest( + challenge: challengeInData, + name: passkeyName ?? "Unnamed account (Apple)", + userID: userIdInData + ) + return registrationRequest + } + #endif + // MARK: Internal implementations below func rawAccessToken() -> String { From e63a770de6c8b47f13d46ca27b99bcd9066985bb Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 03:28:44 +0530 Subject: [PATCH 2/8] adding unit tests --- .../StartPasskeyEnrollmentRequestTests.swift | 88 +++++++++++++++++ .../StartPasskeyEnrollmentResponseTests.swift | 94 +++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..345425fe2b6 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -0,0 +1,88 @@ +// 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, *) + class StartPasskeyEnrollmentRequestTests: XCTestCase { + private var request: StartPasskeyEnrollmentRequest! + private var fakeConfig: AuthRequestConfiguration! + + override func setUp() { + super.setUp() + fakeConfig = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID" + ) + } + + override func tearDown() { + request = nil + fakeConfig = nil + super.tearDown() + } + + func testInitWithValidIdTokenAndConfiguration() { + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: fakeConfig + ) + XCTAssertEqual(request.idToken, "FAKE_ID_TOKEN") + XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:start") + XCTAssertTrue(request.useIdentityPlatform) + } + + func testUnencodedHTTPRequestBodyWithoutTenantId() { + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: fakeConfig + ) + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN") + XCTAssertNil(body?["tenantId"]) + } + + func testUnencodedHTTPRequestBodyWithTenantId() { + // 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 + ) + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN") + XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..6a757552726 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -0,0 +1,94 @@ +// 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, *) + class StartPasskeyEnrollmentResponseTests: XCTestCase { + private func makeValidDictionary() -> [String: AnyHashable] { + return [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"] as [String: AnyHashable], + "user": ["id": "USER_123"] as [String: AnyHashable], + "challenge": "FAKE_CHALLENGE" as String, + ] as [String: AnyHashable], + ] + } + + func testInitWithValidDictionary() throws { + let response = try StartPasskeyEnrollmentResponse(dictionary: makeValidDictionary()) + XCTAssertEqual(response.rpID, "example.com") + XCTAssertEqual(response.userID, "USER_123") + XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") + } + + func testInitWithMissingCredentialCreationOptionsThrowsError() { + let invalidDict: [String: AnyHashable] = [:] + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) + } + + func testInitWithMissingRpThrowsError() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any] { + options.removeValue(forKey: "rp") + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + } + + func testInitWithMissingRpIdThrowsError() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any], + var rp = options["rp"] as? [String: Any] { + rp.removeValue(forKey: "id") + options["rp"] = rp + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + } + + func testInitWithMissingUserThrowsError() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any] { + options.removeValue(forKey: "user") + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + } + + func testInitWithMissingUserIdThrowsError() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any], + var user = options["user"] as? [String: Any] { + user.removeValue(forKey: "id") + options["user"] = user + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + } + + func testInitWithMissingChallengeThrowsError() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any] { + options.removeValue(forKey: "challenge") + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + } + } + +#endif From 9d87eb9ba96b4abbbf7e68b9cc825b8e0aaa7410 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 03:43:06 +0530 Subject: [PATCH 3/8] adding unit tests --- FirebaseAuth/Tests/Unit/UserTests.swift | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index c610e04a0bc..0ff72cab663 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -1891,3 +1891,111 @@ class UserTests: RPCBaseTests { } } } + +#if os(iOS) + import AuthenticationServices + + @available(iOS 15.0, *) +extension UserTests { + func testStartPasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + do { + // Mock backend response for StartPasskeyEnrollment + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? StartPasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], // Base64 userID + "challenge": "Q2hhbGxlbmdl", // Base64 challenge + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "MyPasskey") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertNotNil(request.challenge) + XCTAssertNotNil(request.userID) + expectation.fulfill() + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithNilNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: nil) + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithEmptyNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "") + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await user.startPasskeyEnrollment(withName: "FailCase") + XCTFail("Expected to throw error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } +} +#endif From bf8853726dfafc4d5e90a91d4e0cb0322a67d12b Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 11:35:15 +0530 Subject: [PATCH 4/8] lint fixes --- FirebaseAuth/Tests/Unit/UserTests.swift | 164 ++++++++++++------------ 1 file changed, 81 insertions(+), 83 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index 0ff72cab663..c58d3a03f5a 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -1896,106 +1896,104 @@ class UserTests: RPCBaseTests { import AuthenticationServices @available(iOS 15.0, *) -extension UserTests { - func testStartPasskeyEnrollmentSuccess() async throws { - setFakeGetAccountProvider() - let expectation = expectation(description: #function) - signInWithEmailPasswordReturnFakeUser { user in - do { - // Mock backend response for StartPasskeyEnrollment + extension UserTests { + func testStartPasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + do { + // Mock backend response for StartPasskeyEnrollment + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? StartPasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], // Base64 userID + "challenge": "Q2hhbGxlbmdl", // Base64 challenge + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "MyPasskey") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertNotNil(request.challenge) + XCTAssertNotNil(request.userID) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithNilNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in self.rpcIssuer.respondBlock = { - let request = try XCTUnwrap(self.rpcIssuer?.request as? StartPasskeyEnrollmentRequest) - XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) - return try self.rpcIssuer.respond( + try self.rpcIssuer.respond( withJSON: [ "credentialCreationOptions": [ "rp": ["id": "example.com"], - "user": ["id": "VXNlcklE"], // Base64 userID - "challenge": "Q2hhbGxlbmdl", // Base64 challenge + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", ], ] ) } Task { - let request = try await user.startPasskeyEnrollment(withName: "MyPasskey") - XCTAssertEqual(request.name, "MyPasskey") - XCTAssertNotNil(request.challenge) - XCTAssertNotNil(request.userID) + let request = try await user.startPasskeyEnrollment(withName: nil) + XCTAssertEqual(request.name, "Unnamed account (Apple)") expectation.fulfill() } - } catch { - XCTFail("Unexpected error: \(error)") } + await fulfillment(of: [expectation], timeout: 5) } - await fulfillment(of: [expectation], timeout: 5) - } - - func testStartPasskeyEnrollmentWithNilNameSuccess() async throws { - setFakeGetAccountProvider() - let expectation = expectation(description: #function) - signInWithEmailPasswordReturnFakeUser { user in - self.rpcIssuer.respondBlock = { - try self.rpcIssuer.respond( - withJSON: [ - "credentialCreationOptions": [ - "rp": ["id": "example.com"], - "user": ["id": "VXNlcklE"], - "challenge": "Q2hhbGxlbmdl", - ], - ] - ) - } - Task { - let request = try await user.startPasskeyEnrollment(withName: nil) - XCTAssertEqual(request.name, "Unnamed account (Apple)") - expectation.fulfill() - } - } - await fulfillment(of: [expectation], timeout: 5) - } - - func testStartPasskeyEnrollmentWithEmptyNameSuccess() async throws { - setFakeGetAccountProvider() - let expectation = expectation(description: #function) - signInWithEmailPasswordReturnFakeUser { user in - self.rpcIssuer.respondBlock = { - try self.rpcIssuer.respond( - withJSON: [ - "credentialCreationOptions": [ - "rp": ["id": "example.com"], - "user": ["id": "VXNlcklE"], - "challenge": "Q2hhbGxlbmdl", - ], - ] - ) - } - Task { - let request = try await user.startPasskeyEnrollment(withName: "") - XCTAssertEqual(request.name, "Unnamed account (Apple)") - expectation.fulfill() + + func testStartPasskeyEnrollmentWithEmptyNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "") + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } } + await fulfillment(of: [expectation], timeout: 5) } - await fulfillment(of: [expectation], timeout: 5) - } - - func testStartPasskeyEnrollmentFailure() async throws { - setFakeGetAccountProvider() - let expectation = expectation(description: #function) - signInWithEmailPasswordReturnFakeUser { user in - self.rpcIssuer.respondBlock = { - try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") - } - Task { - do { - _ = try await user.startPasskeyEnrollment(withName: "FailCase") - XCTFail("Expected to throw error") - } catch let error as NSError { - XCTAssertEqual(error.code, AuthErrorCode.operationNotAllowed.rawValue) - expectation.fulfill() + + func testStartPasskeyEnrollmentFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await user.startPasskeyEnrollment(withName: "FailCase") + XCTFail("Expected to throw error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } } } + await fulfillment(of: [expectation], timeout: 5) } - await fulfillment(of: [expectation], timeout: 5) } -} #endif From b6f1fe8696b4a92400250d43d6781877e172d593 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 16:47:29 +0530 Subject: [PATCH 5/8] resolving comments in user.swift --- FirebaseAuth/Sources/Swift/User/User.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index b40369a7574..58e58ec10a9 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1058,6 +1058,7 @@ extension User: NSSecureCoding {} /// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed /// at finalizePasskeyEnrollment(withPlatformCredential:) call private var passkeyName: String? + private let defaultPasskeyName: String = "Unnamed account (Apple)" /// Start the passkey enrollment creating a plaform public key creation request with the /// challenge from GCIP backend. @@ -1077,7 +1078,12 @@ extension User: NSSecureCoding {} requestConfiguration: requestConfiguration ) let response = try await backend.call(with: request) - passkeyName = (name?.isEmpty ?? true) ? "Unnamed account (Apple)" : name! + guard let passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name + else { throw NSError( + domain: AuthErrorDomain, + code: AuthErrorCode.internalError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to unwrap passkey name"] + ) } guard let challengeInData = Data(base64Encoded: response.challenge) else { throw NSError( domain: AuthErrorDomain, @@ -1095,12 +1101,11 @@ extension User: NSSecureCoding {} let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: response.rpID ) - let registrationRequest = provider.createCredentialRegistrationRequest( + return provider.createCredentialRegistrationRequest( challenge: challengeInData, - name: passkeyName ?? "Unnamed account (Apple)", + name: passkeyName, userID: userIdInData ) - return registrationRequest } #endif From cba28d9da540cef783f4f681d2e94166c98abade Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 20:14:29 +0530 Subject: [PATCH 6/8] resolving comments in tests --- .../StartPasskeyEnrollmentResponseTests.swift | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift index 6a757552726..75f401aab37 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -18,7 +18,7 @@ import XCTest @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) - class StartPasskeyEnrollmentResponseTests: XCTestCase { + class StartPasskeyEnrollmentResponseTests: RPCBaseTests { private func makeValidDictionary() -> [String: AnyHashable] { return [ "credentialCreationOptions": [ @@ -41,7 +41,7 @@ XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) } - func testInitWithMissingRpThrowsError() { + func testInitWithMissingRp() { var dict = makeValidDictionary() if var options = dict["credentialCreationOptions"] as? [String: Any] { options.removeValue(forKey: "rp") @@ -50,7 +50,7 @@ XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) } - func testInitWithMissingRpIdThrowsError() { + func testInitWithMissingRpId() { var dict = makeValidDictionary() if var options = dict["credentialCreationOptions"] as? [String: Any], var rp = options["rp"] as? [String: Any] { @@ -61,7 +61,7 @@ XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) } - func testInitWithMissingUserThrowsError() { + func testInitWithMissingUser() { var dict = makeValidDictionary() if var options = dict["credentialCreationOptions"] as? [String: Any] { options.removeValue(forKey: "user") @@ -70,7 +70,7 @@ XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) } - func testInitWithMissingUserIdThrowsError() { + func testInitWithMissingUserId() { var dict = makeValidDictionary() if var options = dict["credentialCreationOptions"] as? [String: Any], var user = options["user"] as? [String: Any] { @@ -81,13 +81,43 @@ XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) } - func testInitWithMissingChallengeThrowsError() { + func testInitWithMissingChallenge() { var dict = makeValidDictionary() - if var options = dict["credentialCreationOptions"] as? [String: Any] { + if var options = dict["credentialCreationOptions"] as? [String: AnyHashable] { options.removeValue(forKey: "challenge") - dict["credentialCreationOptions"] = options as? AnyHashable + dict["credentialCreationOptions"] = options as [String: AnyHashable] } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + XCTAssertThrowsError( + try StartPasskeyEnrollmentResponse(dictionary: dict) + ) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testSuccessfulStartPasskeyEnrollmentResponse() async throws { + let expectedRpID = "example.com" + let expectedUserID = "USER_123" + let expectedChallenge = "FAKE_CHALLENGE" + + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": expectedRpID], + "user": ["id": expectedUserID], + "challenge": expectedChallenge, + ], + ]) + } + let request = StartPasskeyEnrollmentRequest( + idToken: "DUMMY_ID_TOKEN", + requestConfiguration: AuthRequestConfiguration(apiKey: "API_KEY", appID: "APP_ID") + ) + let response = try await authBackend.call(with: request) + XCTAssertEqual(response.rpID, expectedRpID) + XCTAssertEqual(response.userID, expectedUserID) + XCTAssertEqual(response.challenge, expectedChallenge) } } From 1a932429b90e70d4311103483cdcfc68cf00ec6b Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 20:26:57 +0530 Subject: [PATCH 7/8] fixes --- .../StartPasskeyEnrollmentResponseTests.swift | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift index 75f401aab37..06e0f239caf 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -36,9 +36,13 @@ XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") } - func testInitWithMissingCredentialCreationOptionsThrowsError() { + func testInitWithMissingCredentialCreationOptions() { let invalidDict: [String: AnyHashable] = [:] - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } func testInitWithMissingRp() { @@ -47,7 +51,11 @@ options.removeValue(forKey: "rp") dict["credentialCreationOptions"] = options as? AnyHashable } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } func testInitWithMissingRpId() { @@ -58,7 +66,11 @@ options["rp"] = rp dict["credentialCreationOptions"] = options as? AnyHashable } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } func testInitWithMissingUser() { @@ -67,7 +79,11 @@ options.removeValue(forKey: "user") dict["credentialCreationOptions"] = options as? AnyHashable } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } func testInitWithMissingUserId() { @@ -78,7 +94,11 @@ options["user"] = user dict["credentialCreationOptions"] = options as? AnyHashable } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } func testInitWithMissingChallenge() { @@ -100,7 +120,6 @@ let expectedRpID = "example.com" let expectedUserID = "USER_123" let expectedChallenge = "FAKE_CHALLENGE" - rpcIssuer.respondBlock = { try self.rpcIssuer.respond(withJSON: [ "credentialCreationOptions": [ From 8e10c1af9f7702e66fd5e53ba60c2de731f5ebe2 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 4 Aug 2025 15:32:41 +0530 Subject: [PATCH 8/8] updating unit tests --- .../StartPasskeyEnrollmentResponseTests.swift | 122 ++++++------------ 1 file changed, 39 insertions(+), 83 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift index 06e0f239caf..a167ce5d6af 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -22,103 +22,59 @@ private func makeValidDictionary() -> [String: AnyHashable] { return [ "credentialCreationOptions": [ - "rp": ["id": "example.com"] as [String: AnyHashable], - "user": ["id": "USER_123"] as [String: AnyHashable], + "rp": ["id": "FAKE_RP_ID"] as [String: AnyHashable], + "user": ["id": "FAKE_USER_ID"] as [String: AnyHashable], "challenge": "FAKE_CHALLENGE" as String, ] as [String: AnyHashable], ] } - func testInitWithValidDictionary() throws { - let response = try StartPasskeyEnrollmentResponse(dictionary: makeValidDictionary()) - XCTAssertEqual(response.rpID, "example.com") - XCTAssertEqual(response.userID, "USER_123") - XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") - } - - func testInitWithMissingCredentialCreationOptions() { - let invalidDict: [String: AnyHashable] = [:] - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) - } - } - - func testInitWithMissingRp() { - var dict = makeValidDictionary() - if var options = dict["credentialCreationOptions"] as? [String: Any] { - options.removeValue(forKey: "rp") - dict["credentialCreationOptions"] = options as? AnyHashable - } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + /// Helper function to remove a nested key from a 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["credentialCreationOptions"] as? [String: Any], - var rp = options["rp"] as? [String: Any] { - rp.removeValue(forKey: "id") - options["rp"] = rp - dict["credentialCreationOptions"] = options as? AnyHashable - } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) - } - } - - func testInitWithMissingUser() { - var dict = makeValidDictionary() - if var options = dict["credentialCreationOptions"] as? [String: Any] { - options.removeValue(forKey: "user") - dict["credentialCreationOptions"] = options as? AnyHashable - } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) - } - } - - func testInitWithMissingUserId() { - var dict = makeValidDictionary() - if var options = dict["credentialCreationOptions"] as? [String: Any], - var user = options["user"] as? [String: Any] { - user.removeValue(forKey: "id") - options["user"] = user - dict["credentialCreationOptions"] = options as? AnyHashable - } - XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in - let nsError = error as NSError - XCTAssertEqual(nsError.domain, AuthErrorDomain) - XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) - } + func testInitWithValidDictionary() throws { + let response = try StartPasskeyEnrollmentResponse(dictionary: makeValidDictionary()) + XCTAssertEqual(response.rpID, "FAKE_RP_ID") + XCTAssertEqual(response.userID, "FAKE_USER_ID") + XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") } - func testInitWithMissingChallenge() { - var dict = makeValidDictionary() - if var options = dict["credentialCreationOptions"] as? [String: AnyHashable] { - options.removeValue(forKey: "challenge") - dict["credentialCreationOptions"] = options as [String: AnyHashable] + func testInitWithMissingFields() throws { + struct TestCase { + let name: String + let removeFieldPath: [String] } - XCTAssertThrowsError( - try StartPasskeyEnrollmentResponse(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 rpId", removeFieldPath: ["credentialCreationOptions", "rp", "id"]), + .init(name: "Missing userId", removeFieldPath: ["credentialCreationOptions", "user", "id"]), + .init( + name: "Missing Challenge", + removeFieldPath: ["credentialCreationOptions", "challenge"] + ), + ] + for testCase in cases { + var dict = makeValidDictionary() + removeField(&dict, keyPath: testCase.removeFieldPath) + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict), + testCase.name) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } } } func testSuccessfulStartPasskeyEnrollmentResponse() async throws { - let expectedRpID = "example.com" - let expectedUserID = "USER_123" + let expectedRpID = "FAKE_RP_ID" + let expectedUserID = "FAKE_USER_ID" let expectedChallenge = "FAKE_CHALLENGE" rpcIssuer.respondBlock = { try self.rpcIssuer.respond(withJSON: [ @@ -130,7 +86,7 @@ ]) } let request = StartPasskeyEnrollmentRequest( - idToken: "DUMMY_ID_TOKEN", + idToken: "FAKE_ID_TOKEN", requestConfiguration: AuthRequestConfiguration(apiKey: "API_KEY", appID: "APP_ID") ) let response = try await authBackend.call(with: request)