From 0d200527e6c7bcb11390f36d24c723fd94946900 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Wed, 6 Aug 2025 17:40:29 +0530 Subject: [PATCH 01/12] implementing passkey unenrollment --- .../Sources/Swift/Backend/AuthBackend.swift | 1 + .../Backend/RPC/GetAccountInfoResponse.swift | 14 ++++++ .../Swift/Backend/RPC/Proto/PasskeyInfo.swift | 31 ++++++++++++ .../Backend/RPC/SetAccountInfoRequest.swift | 8 +++ FirebaseAuth/Sources/Swift/User/User.swift | 23 +++++++++ .../Swift/Utilities/AuthErrorUtils.swift | 4 ++ .../Sources/Swift/Utilities/AuthErrors.swift | 9 ++++ FirebaseAuth/Tests/Unit/UserTests.swift | 49 +++++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 7a0c39340ae..d0523bccac0 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -355,6 +355,7 @@ final class AuthBackend: AuthBackendProtocol { .missingIosBundleIDError(message: serverDetailErrorMessage) case "MISSING_ANDROID_PACKAGE_NAME": return AuthErrorUtils .missingAndroidPackageNameError(message: serverDetailErrorMessage) + case "PASSKEY_ENROLLMENT_NOT_FOUND": return AuthErrorUtils.missingPasskeyEnrollment() case "UNAUTHORIZED_DOMAIN": return AuthErrorUtils .unauthorizedDomainError(message: serverDetailErrorMessage) case "INVALID_CONTINUE_URI": return AuthErrorUtils diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 4fb5795bcd5..f5ed1d727f4 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -39,6 +39,9 @@ struct GetAccountInfoResponse: AuthRPCResponse { /// A phone number associated with the user. let phoneNumber: String? + /// Information on which passkeys are enrolled for this account + let enrolledPasskeys: [PasskeyInfo]? + /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. init(dictionary: [String: Any]) { @@ -53,6 +56,17 @@ struct GetAccountInfoResponse: AuthRPCResponse { dictionary["federatedId"] as? String email = dictionary["email"] as? String phoneNumber = dictionary["phoneNumber"] as? String + if let enrolledPasskeysInfo = dictionary["passkeyInfo"] as? [[String: Any]] { + var passkeys: [PasskeyInfo] = [] + for passkeyDict in enrolledPasskeysInfo { + if let info = PasskeyInfo(dictionary: passkeyDict) { + passkeys.append(info) + } + } + enrolledPasskeys = passkeys + } else { + enrolledPasskeys = nil + } } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift new file mode 100644 index 00000000000..d7627c2cb0c --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.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. + +import Foundation + +public class PasskeyInfo { + /// The display name for this passkey. + public let name: String + /// The credential ID used by the server. + public let credentialID: String + + public init?(dictionary: [String: Any]) { + guard let name = dictionary["name"] as? String, + let credentialID = dictionary["credentialId"] as? String else { + return nil + } + self.name = name + self.credentialID = credentialID + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift index 5e310d4a656..37b581225f4 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift @@ -73,6 +73,8 @@ private let kDeleteProvidersKey = "deleteProvider" /// The key for the "returnSecureToken" value in the request. private let kReturnSecureTokenKey = "returnSecureToken" +private let kDeletePasskeysKey = "deletePasskey" + /// The key for the tenant id value in the request. private let kTenantIDKey = "tenantId" @@ -131,6 +133,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { /// The default value is `true` . var returnSecureToken: Bool = true + /// The list of credential IDs of the passkeys to be deleted. + public var deletePasskeys: [String]? = nil + init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) { self.accessToken = accessToken super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration) @@ -183,6 +188,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { if returnSecureToken { postBody[kReturnSecureTokenKey] = true } + if let deletePasskeys { + postBody[kDeletePasskeysKey] = deletePasskeys + } if let tenantID { postBody[kTenantIDKey] = tenantID } diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index fc72539937d..64f314773f9 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1053,6 +1053,8 @@ extension User: NSSecureCoding {} // MARK: Passkey Implementation + public var enrolledPasskeys: [PasskeyInfo] = [] + #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) /// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed @@ -1134,6 +1136,22 @@ extension User: NSSecureCoding {} ) return AuthDataResult(withUser: user, additionalUserInfo: nil) } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func unenrollPasskey(withCredentialID credentialID: String) async throws { + var request = SetAccountInfoRequest( + requestConfiguration: auth!.requestConfiguration + ) + request.deletePasskeys = [credentialID] + request.accessToken = rawAccessToken() + let response = try await backend.call(with: request) + _ = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + } #endif // MARK: Internal implementations below @@ -1157,6 +1175,7 @@ extension User: NSSecureCoding {} tenantID = nil #if os(iOS) multiFactor = MultiFactor(withMFAEnrollments: []) + enrolledPasskeys = [] #endif uid = "" hasEmailPasswordCredential = false @@ -1787,6 +1806,7 @@ extension User: NSSecureCoding {} private let kMetadataCodingKey = "metadata" private let kMultiFactorCodingKey = "multiFactor" private let kTenantIDCodingKey = "tenantID" + private let kEnrolledPasskeysKey = "enrolledPasskeys" public static let supportsSecureCoding = true @@ -1809,6 +1829,7 @@ extension User: NSSecureCoding {} coder.encode(tokenService, forKey: kTokenServiceCodingKey) #if os(iOS) coder.encode(multiFactor, forKey: kMultiFactorCodingKey) + coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey) #endif } @@ -1838,6 +1859,7 @@ extension User: NSSecureCoding {} let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String #if os(iOS) let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey) + let enrolledPasskeys = coder.decodeObject(forKey: "enrolledPasskeys") as? [PasskeyInfo] ?? [] #endif self.tokenService = tokenService uid = userID @@ -1851,6 +1873,7 @@ extension User: NSSecureCoding {} self.phoneNumber = phoneNumber self.metadata = metadata ?? UserMetadata(withCreationDate: nil, lastSignInDate: nil) self.tenantID = tenantID + self.enrolledPasskeys = enrolledPasskeys // Note, in practice, the caller will set the `auth` property of this user // instance which will as a side-effect overwrite the request configuration. diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 5c78b223ab4..20bbf313082 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -235,6 +235,10 @@ class AuthErrorUtils { error(code: .missingVerificationCode, message: message) } + static func missingPasskeyEnrollment() -> Error { + error(code: .missingPasskeyEnrollment) + } + static func invalidVerificationCodeError(message: String?) -> Error { error(code: .invalidVerificationCode, message: message) } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index dde29c11ab3..2887d7dfa84 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -336,6 +336,8 @@ import Foundation /// Indicates that the reCAPTCHA SDK actions class failed to create. case recaptchaActionCreationFailed = 17210 + case missingPasskeyEnrollment = 172_212 + /// Indicates an error occurred while attempting to access the keychain. case keychainError = 17995 @@ -528,6 +530,8 @@ import Foundation return kErrorSiteKeyMissing case .recaptchaActionCreationFailed: return kErrorRecaptchaActionCreationFailed + case .missingPasskeyEnrollment: + return kErrorMissingPasskeyEnrollment } } @@ -719,6 +723,8 @@ import Foundation return "ERROR_RECAPTCHA_SITE_KEY_MISSING" case .recaptchaActionCreationFailed: return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED" + case .missingPasskeyEnrollment: + return "ERROR_PASSKEY_ENROLLMENT_NOT_FOUND" } } } @@ -996,3 +1002,6 @@ private let kErrorSiteKeyMissing = private let kErrorRecaptchaActionCreationFailed = "The reCAPTCHA SDK action class failed to initialize. See " + "https://cloud.google.com/recaptcha-enterprise/docs/instrument-ios-apps" + +private let kErrorMissingPasskeyEnrollment = + "Cannot find the passkey linked to the current account." diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index f1fd3712cd2..0e7d6550a65 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -2120,5 +2120,54 @@ class UserTests: RPCBaseTests { await fulfillment(of: [expectation], timeout: 5) } + + func testUnenrollPasskeySuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + // Mock backend response + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? SetAccountInfoRequest) + XCTAssertEqual(request.deletePasskeys, ["someCredentialID"]) + XCTAssertEqual(request.accessToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + "approximateExpirationDate": "\(Date().timeIntervalSince1970 * 1000)", + ] + ) + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "someCredentialID") + expectation.fulfill() + } catch { + XCTFail("Should not throw error: \(error)") + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeyFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + throw AuthErrorCode.networkError as NSError + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "someCredentialID") + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.networkError.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } } #endif From d029bb00f1c6f1d1de26059a3d68c71b84a63ca7 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 7 Aug 2025 13:47:02 +0530 Subject: [PATCH 02/12] fixes --- .../Backend/RPC/GetAccountInfoResponse.swift | 28 +++++++++---------- .../Backend/RPC/SetAccountInfoRequest.swift | 2 +- FirebaseAuth/Sources/Swift/User/User.swift | 8 +++--- .../Sources/Swift/Utilities/AuthErrors.swift | 2 +- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index f5ed1d727f4..6df6de0b55e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -39,9 +39,6 @@ struct GetAccountInfoResponse: AuthRPCResponse { /// A phone number associated with the user. let phoneNumber: String? - /// Information on which passkeys are enrolled for this account - let enrolledPasskeys: [PasskeyInfo]? - /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. init(dictionary: [String: Any]) { @@ -56,17 +53,6 @@ struct GetAccountInfoResponse: AuthRPCResponse { dictionary["federatedId"] as? String email = dictionary["email"] as? String phoneNumber = dictionary["phoneNumber"] as? String - if let enrolledPasskeysInfo = dictionary["passkeyInfo"] as? [[String: Any]] { - var passkeys: [PasskeyInfo] = [] - for passkeyDict in enrolledPasskeysInfo { - if let info = PasskeyInfo(dictionary: passkeyDict) { - passkeys.append(info) - } - } - enrolledPasskeys = passkeys - } else { - enrolledPasskeys = nil - } } } @@ -105,6 +91,9 @@ struct GetAccountInfoResponse: AuthRPCResponse { let phoneNumber: String? let mfaEnrollments: [AuthProtoMFAEnrollment]? + + /// A list of the user’s enrolled passkeys (may be nil if none). + public private(set) var enrolledPasskeys: [PasskeyInfo]? /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. @@ -147,6 +136,17 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } + if let enrolledPasskeysInfo = dictionary["passkeys"] as? [[String: Any]] { + var passkeys: [PasskeyInfo] = [] + for passkey in enrolledPasskeysInfo { + if let info = PasskeyInfo(dictionary: passkey) { + passkeys.append(info) + } + } + enrolledPasskeys = passkeys + } else { + enrolledPasskeys = nil + } } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift index 37b581225f4..951eed9d044 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift @@ -134,7 +134,7 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { var returnSecureToken: Bool = true /// The list of credential IDs of the passkeys to be deleted. - public var deletePasskeys: [String]? = nil + var deletePasskeys: [String]? = nil init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) { self.accessToken = accessToken diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 64f314773f9..0e04fa2fae4 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -67,6 +67,7 @@ extension User: NSSecureCoding {} /// /// This property is available on iOS only. @objc public private(set) var multiFactor: MultiFactor + public private(set) var enrolledPasskeys: [PasskeyInfo]? #endif /// [Deprecated] Updates the email address for the user. @@ -1053,8 +1054,6 @@ extension User: NSSecureCoding {} // MARK: Passkey Implementation - public var enrolledPasskeys: [PasskeyInfo] = [] - #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) /// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed @@ -1411,6 +1410,7 @@ extension User: NSSecureCoding {} } multiFactor.user = self #endif + self.enrolledPasskeys = user.enrolledPasskeys ?? [] } #if os(iOS) @@ -1806,7 +1806,7 @@ extension User: NSSecureCoding {} private let kMetadataCodingKey = "metadata" private let kMultiFactorCodingKey = "multiFactor" private let kTenantIDCodingKey = "tenantID" - private let kEnrolledPasskeysKey = "enrolledPasskeys" + private let kEnrolledPasskeysKey = "passkeys" public static let supportsSecureCoding = true @@ -1859,7 +1859,7 @@ extension User: NSSecureCoding {} let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String #if os(iOS) let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey) - let enrolledPasskeys = coder.decodeObject(forKey: "enrolledPasskeys") as? [PasskeyInfo] ?? [] + let enrolledPasskeys = coder.decodeObject(forKey: "passkeys") as? [PasskeyInfo] #endif self.tokenService = tokenService uid = userID diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index 2887d7dfa84..59547067a82 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -336,7 +336,7 @@ import Foundation /// Indicates that the reCAPTCHA SDK actions class failed to create. case recaptchaActionCreationFailed = 17210 - case missingPasskeyEnrollment = 172_212 + case missingPasskeyEnrollment = 17212 /// Indicates an error occurred while attempting to access the keychain. case keychainError = 17995 From ed2b6f19e07fec31097d8232a890f943f0d55524 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 7 Aug 2025 14:00:39 +0530 Subject: [PATCH 03/12] fixes --- .../Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift | 2 +- FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift | 2 +- FirebaseAuth/Sources/Swift/User/User.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 6df6de0b55e..8cfd5354cf1 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -91,7 +91,7 @@ struct GetAccountInfoResponse: AuthRPCResponse { let phoneNumber: String? let mfaEnrollments: [AuthProtoMFAEnrollment]? - + /// A list of the user’s enrolled passkeys (may be nil if none). public private(set) var enrolledPasskeys: [PasskeyInfo]? diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index d7627c2cb0c..11fb75bc077 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,7 +14,7 @@ import Foundation -public class PasskeyInfo { +public class PasskeyInfo: NSObject { /// The display name for this passkey. public let name: String /// The credential ID used by the server. diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 0e04fa2fae4..90e3b52c461 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1410,7 +1410,7 @@ extension User: NSSecureCoding {} } multiFactor.user = self #endif - self.enrolledPasskeys = user.enrolledPasskeys ?? [] + enrolledPasskeys = user.enrolledPasskeys ?? [] } #if os(iOS) From 635b573e6c1eff826a1d91d5139e206d566bcb3c Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 8 Aug 2025 21:15:01 +0530 Subject: [PATCH 04/12] adding tests --- .../Backend/RPC/GetAccountInfoResponse.swift | 2 +- FirebaseAuth/Sources/Swift/User/User.swift | 5 +- .../Tests/Unit/GetAccountInfoTests.swift | 47 +++++++++++++++++++ .../Tests/Unit/SetAccountInfoTests.swift | 10 ++++ FirebaseAuth/Tests/Unit/UserTests.swift | 39 +++++++++++---- 5 files changed, 93 insertions(+), 10 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 8cfd5354cf1..fbe06303f44 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -136,7 +136,7 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } - if let enrolledPasskeysInfo = dictionary["passkeys"] as? [[String: Any]] { + if let enrolledPasskeysInfo = dictionary["passkeys"] as? [[String: AnyHashable]] { var passkeys: [PasskeyInfo] = [] for passkey in enrolledPasskeysInfo { if let info = PasskeyInfo(dictionary: passkey) { diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 90e3b52c461..60e3a71b1e6 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1138,7 +1138,10 @@ extension User: NSSecureCoding {} @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) public func unenrollPasskey(withCredentialID credentialID: String) async throws { - var request = SetAccountInfoRequest( + guard !credentialID.isEmpty else { + throw AuthErrorCode.missingPasskeyEnrollment + } + let request = SetAccountInfoRequest( requestConfiguration: auth!.requestConfiguration ) request.deletePasskeys = [credentialID] diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index ec5eba4e2d0..764fdaad55d 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -80,6 +80,15 @@ class GetAccountInfoTests: RPCBaseTests { let kEmailVerifiedKey = "emailVerified" let kLocalIDKey = "localId" let kTestLocalID = "testLocalId" + let kPasskeysKey = "passkeys" + + // Fake PasskeyInfo + let testCredentialId = "credential_id" + let testPasskeyName = "Test Passkey" + let passkeys = [[ + "credentialId": testCredentialId, + "name": testPasskeyName, + ]] let usersIn = [[ kProviderUserInfoKey: [[ @@ -95,6 +104,7 @@ class GetAccountInfoTests: RPCBaseTests { kPhotoUrlKey: kTestPhotoURL, kEmailVerifiedKey: true, kPasswordHashKey: kTestPasswordHash, + kPasskeysKey: passkeys, ] as [String: Any]] let rpcIssuer = try XCTUnwrap(self.rpcIssuer) @@ -119,6 +129,43 @@ class GetAccountInfoTests: RPCBaseTests { XCTAssertEqual(firstProviderUser.email, kTestEmail) XCTAssertEqual(firstProviderUser.providerID, kTestProviderID) XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 1) + XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId) + XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName) + } + + func testInitWithMultipleEnrolledPasskeys() throws { + let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"] + let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"] + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + "passkeys": [passkey1, passkey2], + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 2) + XCTAssertEqual(enrolledPasskeys[0].name, "passkey1") + XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1") + XCTAssertEqual(enrolledPasskeys[1].name, "passkey2") + XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2") + } + + func testInitWithNoEnrolledPasskeys() throws { + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + // No "passkeys" present + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + XCTAssertNil(firstUser.enrolledPasskeys) } private func makeGetAccountInfoRequest() -> GetAccountInfoRequest { diff --git a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift index b3ccb3e8ad1..7283c2e2c71 100644 --- a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift @@ -64,6 +64,8 @@ class SetAccountInfoTests: RPCBaseTests { let kTestDeleteProviders = "TestDeleteProviders" let kReturnSecureTokenKey = "returnSecureToken" let kTestAccessToken = "accessToken" + let kDeletePasskeysKey = "deletePasskey" + let kDeletePasskey = "credential_id" let kExpectedAPIURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" @@ -82,6 +84,7 @@ class SetAccountInfoTests: RPCBaseTests { request.captchaResponse = kTestCaptchaResponse request.deleteAttributes = [kTestDeleteAttributes] request.deleteProviders = [kTestDeleteProviders] + request.deletePasskeys = [kDeletePasskey] try await checkRequest( request: request, @@ -105,6 +108,7 @@ class SetAccountInfoTests: RPCBaseTests { XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes]) XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders]) XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey]) } func testSetAccountInfoErrors() async throws { @@ -122,6 +126,7 @@ class SetAccountInfoTests: RPCBaseTests { let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL" let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters" let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters" + let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND" try await checkBackendError( request: setAccountInfoRequest(), @@ -189,6 +194,11 @@ class SetAccountInfoTests: RPCBaseTests { message: kInvalidRecipientEmailErrorMessage, errorCode: AuthErrorCode.invalidRecipientEmail ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidCredentialIdForPasskeyUnenroll, + errorCode: AuthErrorCode.missingPasskeyEnrollment + ) } /** @fn testSuccessfulSetAccountInfoResponse diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index 0e7d6550a65..138926eee37 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -2125,10 +2125,9 @@ class UserTests: RPCBaseTests { setFakeGetAccountProvider() let expectation = expectation(description: #function) signInWithEmailPasswordReturnFakeUser { user in - // Mock backend response self.rpcIssuer.respondBlock = { let request = try XCTUnwrap(self.rpcIssuer?.request as? SetAccountInfoRequest) - XCTAssertEqual(request.deletePasskeys, ["someCredentialID"]) + XCTAssertEqual(request.deletePasskeys, ["testCredentialID"]) XCTAssertEqual(request.accessToken, RPCBaseTests.kFakeAccessToken) return try self.rpcIssuer.respond( withJSON: [ @@ -2140,7 +2139,7 @@ class UserTests: RPCBaseTests { } Task { do { - try await user.unenrollPasskey(withCredentialID: "someCredentialID") + try await user.unenrollPasskey(withCredentialID: "testCredentialID") expectation.fulfill() } catch { XCTFail("Should not throw error: \(error)") @@ -2150,24 +2149,48 @@ class UserTests: RPCBaseTests { await fulfillment(of: [expectation], timeout: 5) } - func testUnenrollPasskeyFailure() async throws { + func testUnenrollPasskeyNotFoundFailure() async throws { setFakeGetAccountProvider() let expectation = expectation(description: #function) signInWithEmailPasswordReturnFakeUser { user in self.rpcIssuer.respondBlock = { - throw AuthErrorCode.networkError as NSError + try self.rpcIssuer + .respond( + serverErrorMessage: "PASSKEY_ENROLLMENT_NOT_FOUND" + ) } Task { do { - try await user.unenrollPasskey(withCredentialID: "someCredentialID") - XCTFail("Expected error to be thrown") + try await user.unenrollPasskey(withCredentialID: "invalidCredentialID") + XCTFail("Expected error not thrown") } catch let error as NSError { - XCTAssertEqual(error.code, AuthErrorCode.networkError.rawValue) + XCTAssertEqual(error.domain, AuthErrorDomain) + XCTAssertEqual( + error.localizedDescription, + "Cannot find the passkey linked to the current account." + ) expectation.fulfill() } } } await fulfillment(of: [expectation], timeout: 5) } + + func testUnenrollPasskeyFailure_EmptyCredentialID() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + Task { + do { + try await user.unenrollPasskey(withCredentialID: "") + XCTFail("Expected error for empty credentialID") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.missingPasskeyEnrollment.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 2) + } } #endif From 26dcd7e254475dc172f869ad41dc3b6d76fa1363 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 8 Aug 2025 21:44:46 +0530 Subject: [PATCH 05/12] fixes --- FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index 11fb75bc077..bcab1960289 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,7 +14,7 @@ import Foundation -public class PasskeyInfo: NSObject { +public class PasskeyInfo: Sendable { /// The display name for this passkey. public let name: String /// The credential ID used by the server. From 3203e5b9bf0f7af093a63d07def3b08de95a9f85 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 8 Aug 2025 22:22:50 +0530 Subject: [PATCH 06/12] fixes --- FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index bcab1960289..1af0d9c15ba 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,7 +14,7 @@ import Foundation -public class PasskeyInfo: Sendable { +public class PasskeyInfo: NSObject, @unchecked Sendable { /// The display name for this passkey. public let name: String /// The credential ID used by the server. From 190f8a8d0b4c6d35ff69692912f58661f1f2e034 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Fri, 8 Aug 2025 22:35:54 +0530 Subject: [PATCH 07/12] fixes --- .../Swift/Backend/RPC/Proto/PasskeyInfo.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index 1af0d9c15ba..93809873a93 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,18 +14,14 @@ import Foundation -public class PasskeyInfo: NSObject, @unchecked Sendable { +public class PasskeyInfo: AuthProto { /// The display name for this passkey. public let name: String /// The credential ID used by the server. public let credentialID: String - public init?(dictionary: [String: Any]) { - guard let name = dictionary["name"] as? String, - let credentialID = dictionary["credentialId"] as? String else { - return nil - } - self.name = name - self.credentialID = credentialID + public required init(dictionary: [String: AnyHashable]) { + name = dictionary["name"] as! String + credentialID = dictionary["credentialId"] as! String } } From 8159d312233c46b2be2ead4ea0f00b64b53740f9 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 9 Aug 2025 17:07:33 +0530 Subject: [PATCH 08/12] fixes --- .../Sources/Swift/Backend/RPC/Proto/AuthProto.swift | 2 +- .../Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift index 8983e54dfac..f2f826259ff 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift @@ -15,5 +15,5 @@ import Foundation protocol AuthProto { - init(dictionary: [String: AnyHashable]) + init?(dictionary: [String: AnyHashable]) } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index 93809873a93..ab3f12eaee2 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -20,8 +20,13 @@ public class PasskeyInfo: AuthProto { /// The credential ID used by the server. public let credentialID: String - public required init(dictionary: [String: AnyHashable]) { - name = dictionary["name"] as! String - credentialID = dictionary["credentialId"] as! String + public required init?(dictionary: [String: AnyHashable]) { + guard + let name = dictionary["name"] as? String, + let credentialID = dictionary["credentialId"] as? String + else { return nil } + + self.name = name + self.credentialID = credentialID } } From fb52ad251d2fcbc8d2723453f60509333163b754 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 9 Aug 2025 17:40:21 +0530 Subject: [PATCH 09/12] fixes --- .../Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index ab3f12eaee2..f52f2ec5d20 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,18 +14,17 @@ import Foundation -public class PasskeyInfo: AuthProto { +public struct PasskeyInfo: Sendable, AuthProto { /// The display name for this passkey. public let name: String /// The credential ID used by the server. public let credentialID: String - public required init?(dictionary: [String: AnyHashable]) { + public init?(dictionary: [String: AnyHashable]) { guard let name = dictionary["name"] as? String, let credentialID = dictionary["credentialId"] as? String else { return nil } - self.name = name self.credentialID = credentialID } From 7a3d991dec9733b6e7c428b6be44bad86e3c248d Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 9 Aug 2025 18:21:10 +0530 Subject: [PATCH 10/12] fixes --- FirebaseAuth/Sources/Swift/User/User.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 60e3a71b1e6..8b1489ef993 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1412,8 +1412,8 @@ extension User: NSSecureCoding {} multiFactor = MultiFactor(withMFAEnrollments: enrollments) } multiFactor.user = self + enrolledPasskeys = user.enrolledPasskeys ?? [] #endif - enrolledPasskeys = user.enrolledPasskeys ?? [] } #if os(iOS) From d72096d6b122226b2abdd1260336ffc91236f08a Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 9 Aug 2025 18:39:31 +0530 Subject: [PATCH 11/12] fixes --- FirebaseAuth/Sources/Swift/User/User.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 8b1489ef993..f49ce7fa697 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1876,7 +1876,6 @@ extension User: NSSecureCoding {} self.phoneNumber = phoneNumber self.metadata = metadata ?? UserMetadata(withCreationDate: nil, lastSignInDate: nil) self.tenantID = tenantID - self.enrolledPasskeys = enrolledPasskeys // Note, in practice, the caller will set the `auth` property of this user // instance which will as a side-effect overwrite the request configuration. @@ -1897,6 +1896,7 @@ extension User: NSSecureCoding {} self.multiFactor = multiFactor ?? MultiFactor() super.init() multiFactor?.user = self + self.enrolledPasskeys = enrolledPasskeys #endif } } From 63e05826b491a8918b91e0a8b37da59880b2a740 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 9 Aug 2025 19:11:11 +0530 Subject: [PATCH 12/12] fixes --- .../Swift/Backend/RPC/Proto/AuthProto.swift | 2 +- .../Swift/Backend/RPC/Proto/PasskeyInfo.swift | 27 +++++++++++++++++-- FirebaseAuth/Sources/Swift/User/User.swift | 6 ++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift index f2f826259ff..8983e54dfac 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/AuthProto.swift @@ -15,5 +15,5 @@ import Foundation protocol AuthProto { - init?(dictionary: [String: AnyHashable]) + init(dictionary: [String: AnyHashable]) } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift index f52f2ec5d20..b15328fd162 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -14,18 +14,41 @@ import Foundation -public struct PasskeyInfo: Sendable, AuthProto { +public final class PasskeyInfo: NSObject, NSSecureCoding, Sendable { + public static var supportsSecureCoding: Bool { true } /// The display name for this passkey. public let name: String /// The credential ID used by the server. public let credentialID: String - public init?(dictionary: [String: AnyHashable]) { + public convenience init?(dictionary: [String: AnyHashable]) { guard let name = dictionary["name"] as? String, let credentialID = dictionary["credentialId"] as? String else { return nil } + self.init(name: name, credentialID: credentialID) + } + + public init(name: String, credentialID: String) { + self.name = name + self.credentialID = credentialID + super.init() + } + + // MARK: NSSecureCoding + + public func encode(with coder: NSCoder) { + coder.encode(name as NSString, forKey: "name") + coder.encode(credentialID as NSString, forKey: "credentialId") + } + + public required init?(coder: NSCoder) { + guard + let name = coder.decodeObject(of: NSString.self, forKey: "name") as String?, + let credentialID = coder.decodeObject(of: NSString.self, forKey: "credentialId") as String? + else { return nil } self.name = name self.credentialID = credentialID + super.init() } } diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index f49ce7fa697..54702af2373 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1862,7 +1862,11 @@ extension User: NSSecureCoding {} let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String #if os(iOS) let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey) - let enrolledPasskeys = coder.decodeObject(forKey: "passkeys") as? [PasskeyInfo] + let passkeyClasses: [AnyClass] = [NSArray.self, PasskeyInfo.self, NSString.self] + let enrolledPasskeys = coder.decodeObject( + of: passkeyClasses, + forKey: kEnrolledPasskeysKey + ) as? [PasskeyInfo] #endif self.tokenService = tokenService uid = userID