Skip to content

implementing passkey unenrollment - 2 #15185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ struct GetAccountInfoResponse: AuthRPCResponse {

let mfaEnrollments: [AuthProtoMFAEnrollment]?

/// A list of the user’s enrolled passkeys.
let enrolledPasskeys: [PasskeyInfo]?

/// Designated initializer.
/// - Parameter dictionary: The provider user info data from endpoint.
init(dictionary: [String: Any]) {
Expand Down Expand Up @@ -133,6 +136,11 @@ struct GetAccountInfoResponse: AuthRPCResponse {
} else {
mfaEnrollments = nil
}
if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] {
enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) }
} else {
enrolledPasskeys = nil
}
}
}

Expand Down
40 changes: 40 additions & 0 deletions FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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: NSObject, AuthProto, NSSecureCoding, @unchecked Sendable {
/// The display name for this passkey.
public let name: String?
/// The credential ID used by the server.
public let credentialID: String?
required init(dictionary: [String: AnyHashable]) {
name = dictionary["name"] as? String
credentialID = dictionary["credentialId"] as? String
}

// NSSecureCoding
public static var supportsSecureCoding: Bool { true }

public func encode(with coder: NSCoder) {
coder.encode(name, forKey: "name")
coder.encode(credentialID, forKey: "credentialId")
}

public required init?(coder: NSCoder) {
name = coder.decodeObject(of: NSString.self, forKey: "name") as String?
credentialID = coder.decodeObject(of: NSString.self, forKey: "credentialId") as String?
super.init()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
var deletePasskeys: [String]? = nil

init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) {
self.accessToken = accessToken
super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration)
Expand Down Expand Up @@ -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
}
Expand Down
28 changes: 28 additions & 0 deletions FirebaseAuth/Sources/Swift/User/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1134,6 +1135,25 @@ 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 {
guard !credentialID.isEmpty else {
throw AuthErrorCode.missingPasskeyEnrollment
}
let 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
Expand All @@ -1157,6 +1177,7 @@ extension User: NSSecureCoding {}
tenantID = nil
#if os(iOS)
multiFactor = MultiFactor(withMFAEnrollments: [])
enrolledPasskeys = []
#endif
uid = ""
hasEmailPasswordCredential = false
Expand Down Expand Up @@ -1391,6 +1412,7 @@ extension User: NSSecureCoding {}
multiFactor = MultiFactor(withMFAEnrollments: enrollments)
}
multiFactor.user = self
enrolledPasskeys = user.enrolledPasskeys ?? []
#endif
}

Expand Down Expand Up @@ -1787,6 +1809,7 @@ extension User: NSSecureCoding {}
private let kMetadataCodingKey = "metadata"
private let kMultiFactorCodingKey = "multiFactor"
private let kTenantIDCodingKey = "tenantID"
private let kEnrolledPasskeysKey = "passkeys"

public static let supportsSecureCoding = true

Expand All @@ -1809,6 +1832,7 @@ extension User: NSSecureCoding {}
coder.encode(tokenService, forKey: kTokenServiceCodingKey)
#if os(iOS)
coder.encode(multiFactor, forKey: kMultiFactorCodingKey)
coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey)
#endif
}

Expand Down Expand Up @@ -1838,6 +1862,9 @@ 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 passkeyAllowed: [AnyClass] = [NSArray.self, PasskeyInfo.self]
let passkeys = coder.decodeObject(of: passkeyAllowed,
forKey: kEnrolledPasskeysKey) as? [PasskeyInfo]
#endif
self.tokenService = tokenService
uid = userID
Expand Down Expand Up @@ -1871,6 +1898,7 @@ extension User: NSSecureCoding {}
self.multiFactor = multiFactor ?? MultiFactor()
super.init()
multiFactor?.user = self
enrolledPasskeys = passkeys ?? []
#endif
}
}
4 changes: 4 additions & 0 deletions FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ import Foundation
/// Indicates that the reCAPTCHA SDK actions class failed to create.
case recaptchaActionCreationFailed = 17210

case missingPasskeyEnrollment = 17212

/// Indicates an error occurred while attempting to access the keychain.
case keychainError = 17995

Expand Down Expand Up @@ -528,6 +530,8 @@ import Foundation
return kErrorSiteKeyMissing
case .recaptchaActionCreationFailed:
return kErrorRecaptchaActionCreationFailed
case .missingPasskeyEnrollment:
return kErrorMissingPasskeyEnrollment
}
}

Expand Down Expand Up @@ -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"
}
}
}
Expand Down Expand Up @@ -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."
47 changes: 47 additions & 0 deletions FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [[
Expand All @@ -95,6 +104,7 @@ class GetAccountInfoTests: RPCBaseTests {
kPhotoUrlKey: kTestPhotoURL,
kEmailVerifiedKey: true,
kPasswordHashKey: kTestPasswordHash,
kPasskeysKey: passkeys,
] as [String: Any]]
let rpcIssuer = try XCTUnwrap(self.rpcIssuer)

Expand All @@ -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": "[email protected]",
"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": "[email protected]",
// 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 {
Expand Down
10 changes: 10 additions & 0 deletions FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -82,6 +84,7 @@ class SetAccountInfoTests: RPCBaseTests {
request.captchaResponse = kTestCaptchaResponse
request.deleteAttributes = [kTestDeleteAttributes]
request.deleteProviders = [kTestDeleteProviders]
request.deletePasskeys = [kDeletePasskey]

try await checkRequest(
request: request,
Expand All @@ -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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -189,6 +194,11 @@ class SetAccountInfoTests: RPCBaseTests {
message: kInvalidRecipientEmailErrorMessage,
errorCode: AuthErrorCode.invalidRecipientEmail
)
try await checkBackendError(
request: setAccountInfoRequest(),
message: kInvalidCredentialIdForPasskeyUnenroll,
errorCode: AuthErrorCode.missingPasskeyEnrollment
)
}

/** @fn testSuccessfulSetAccountInfoResponse
Expand Down
Loading
Loading