From 2f9256f69b92b52e34fd937dae4dcaa858ddaec9 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Wed, 30 Jul 2025 21:35:48 +0530 Subject: [PATCH 1/4] Implementing StartPasskeyEnrollment --- .../RPC/StartPasskeyEnrollmentRequest.swift | 46 +++++++++++++++++++ .../RPC/StartPasskeyEnrollmentResponse.swift | 45 ++++++++++++++++++ FirebaseAuth/Sources/Swift/User/User.swift | 39 ++++++++++++++++ 3 files changed, 130 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 857dc5060a2..d30cb78a7b0 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -13,6 +13,7 @@ // limitations under the License. import Foundation +import AuthenticationServices @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension User: NSSecureCoding {} @@ -1047,6 +1048,44 @@ extension User: NSSecureCoding {} } } + // MARK: Passkey Implementation + + /// 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 self.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 = try await rawAccessToken() + let request = StartPasskeyEnrollmentRequest( + idToken: enrollmentIdToken, + requestConfiguration: self.requestConfiguration + ) + let response = try await self.backend.call(with: request) + self.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 + } + // MARK: Internal implementations below func rawAccessToken() -> String { From f22f83fd9491b0ace652b01cc157a1dfed2cbd74 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Wed, 30 Jul 2025 22:43:06 +0530 Subject: [PATCH 2/4] adding unit tests --- FirebaseAuth/Sources/Swift/User/User.swift | 2 +- .../StartPasskeyEnrollmentRequestTests.swift | 84 +++++++++++++++++ .../StartPasskeyEnrollmentResponseTests.swift | 91 +++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index d30cb78a7b0..994551ddd2d 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1062,7 +1062,7 @@ extension User: NSSecureCoding {} /// 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 = try await rawAccessToken() + let enrollmentIdToken = rawAccessToken() let request = StartPasskeyEnrollmentRequest( idToken: enrollmentIdToken, requestConfiguration: self.requestConfiguration diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..85972ccd572 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -0,0 +1,84 @@ +// 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. + +@testable import FirebaseAuth +import Foundation +import FirebaseCore +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") + } +} diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..1fe0f7e7b97 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -0,0 +1,91 @@ +// 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. + +@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)) + } +} From 8c76e081c8755e031d8d067476768d7fcf1c2141 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 02:12:44 +0530 Subject: [PATCH 3/4] lint fixes --- FirebaseAuth/Sources/Swift/User/User.swift | 7 ++++++- .../Tests/Unit/StartPasskeyEnrollmentRequestTests.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 994551ddd2d..2b7d5d4fec2 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -13,7 +13,10 @@ // limitations under the License. import Foundation -import AuthenticationServices + +#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 {} @@ -1049,6 +1052,7 @@ 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? @@ -1085,6 +1089,7 @@ extension User: NSSecureCoding {} ) return registrationRequest } +#endif // MARK: Internal implementations below diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift index 85972ccd572..3e1cd48ff22 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -58,7 +58,7 @@ class StartPasskeyEnrollmentRequestTests: XCTestCase { } func testUnencodedHTTPRequestBodyWithTenantId() { - //setting up fake auth to set tenantId + // setting up fake auth to set tenantId let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = AuthTests.kFakeAPIKey From 4827fb68d98382da1c121ad02a03c613aa6d6a51 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 31 Jul 2025 02:29:22 +0530 Subject: [PATCH 4/4] fixes --- .../Tests/Unit/StartPasskeyEnrollmentRequestTests.swift | 4 ++++ .../Tests/Unit/StartPasskeyEnrollmentResponseTests.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift index 3e1cd48ff22..8f0029baea8 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -12,6 +12,8 @@ // 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 Foundation import FirebaseCore @@ -82,3 +84,5 @@ class StartPasskeyEnrollmentRequestTests: XCTestCase { XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") } } + +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift index 1fe0f7e7b97..80b12920aa7 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -12,6 +12,8 @@ // 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 @@ -89,3 +91,5 @@ class StartPasskeyEnrollmentResponseTests: XCTestCase { XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) } } + +#endif