Skip to content

Commit 4d6831b

Browse files
authored
implementing passkey unenrollment (#15189)
1 parent d7bb9ca commit 4d6831b

File tree

10 files changed

+227
-0
lines changed

10 files changed

+227
-0
lines changed

FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ final class AuthBackend: AuthBackendProtocol {
355355
.missingIosBundleIDError(message: serverDetailErrorMessage)
356356
case "MISSING_ANDROID_PACKAGE_NAME": return AuthErrorUtils
357357
.missingAndroidPackageNameError(message: serverDetailErrorMessage)
358+
case "PASSKEY_ENROLLMENT_NOT_FOUND": return AuthErrorUtils.missingPasskeyEnrollment()
358359
case "UNAUTHORIZED_DOMAIN": return AuthErrorUtils
359360
.unauthorizedDomainError(message: serverDetailErrorMessage)
360361
case "INVALID_CONTINUE_URI": return AuthErrorUtils

FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ struct GetAccountInfoResponse: AuthRPCResponse {
9292

9393
let mfaEnrollments: [AuthProtoMFAEnrollment]?
9494

95+
/// A list of the user’s enrolled passkeys.
96+
let enrolledPasskeys: [PasskeyInfo]?
97+
9598
/// Designated initializer.
9699
/// - Parameter dictionary: The provider user info data from endpoint.
97100
init(dictionary: [String: Any]) {
@@ -133,6 +136,11 @@ struct GetAccountInfoResponse: AuthRPCResponse {
133136
} else {
134137
mfaEnrollments = nil
135138
}
139+
if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] {
140+
enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) }
141+
} else {
142+
enrolledPasskeys = nil
143+
}
136144
}
137145
}
138146

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
public final class PasskeyInfo: NSObject, AuthProto, NSSecureCoding, Sendable {
18+
/// The display name for this passkey.
19+
public let name: String?
20+
/// The credential ID used by the server.
21+
public let credentialID: String?
22+
required init(dictionary: [String: AnyHashable]) {
23+
name = dictionary["name"] as? String
24+
credentialID = dictionary["credentialId"] as? String
25+
}
26+
27+
// NSSecureCoding
28+
public static var supportsSecureCoding: Bool { true }
29+
30+
public func encode(with coder: NSCoder) {
31+
coder.encode(name, forKey: "name")
32+
coder.encode(credentialID, forKey: "credentialId")
33+
}
34+
35+
public required init?(coder: NSCoder) {
36+
name = coder.decodeObject(of: NSString.self, forKey: "name") as String?
37+
credentialID = coder.decodeObject(of: NSString.self, forKey: "credentialId") as String?
38+
super.init()
39+
}
40+
}

FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ private let kDeleteProvidersKey = "deleteProvider"
7373
/// The key for the "returnSecureToken" value in the request.
7474
private let kReturnSecureTokenKey = "returnSecureToken"
7575

76+
private let kDeletePasskeysKey = "deletePasskey"
77+
7678
/// The key for the tenant id value in the request.
7779
private let kTenantIDKey = "tenantId"
7880

@@ -131,6 +133,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest {
131133
/// The default value is `true` .
132134
var returnSecureToken: Bool = true
133135

136+
/// The list of credential IDs of the passkeys to be deleted.
137+
var deletePasskeys: [String]? = nil
138+
134139
init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) {
135140
self.accessToken = accessToken
136141
super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration)
@@ -183,6 +188,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest {
183188
if returnSecureToken {
184189
postBody[kReturnSecureTokenKey] = true
185190
}
191+
if let deletePasskeys {
192+
postBody[kDeletePasskeysKey] = deletePasskeys
193+
}
186194
if let tenantID {
187195
postBody[kTenantIDKey] = tenantID
188196
}

FirebaseAuth/Sources/Swift/User/User.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ extension User: NSSecureCoding {}
6767
///
6868
/// This property is available on iOS only.
6969
@objc public private(set) var multiFactor: MultiFactor
70+
public private(set) var enrolledPasskeys: [PasskeyInfo]?
7071
#endif
7172

7273
/// [Deprecated] Updates the email address for the user.
@@ -1134,6 +1135,25 @@ extension User: NSSecureCoding {}
11341135
)
11351136
return AuthDataResult(withUser: user, additionalUserInfo: nil)
11361137
}
1138+
1139+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1140+
public func unenrollPasskey(withCredentialID credentialID: String) async throws {
1141+
guard !credentialID.isEmpty else {
1142+
throw AuthErrorCode.missingPasskeyEnrollment
1143+
}
1144+
let request = SetAccountInfoRequest(
1145+
requestConfiguration: auth!.requestConfiguration
1146+
)
1147+
request.deletePasskeys = [credentialID]
1148+
request.accessToken = rawAccessToken()
1149+
let response = try await backend.call(with: request)
1150+
_ = try await auth!.completeSignIn(
1151+
withAccessToken: response.idToken,
1152+
accessTokenExpirationDate: response.approximateExpirationDate,
1153+
refreshToken: response.refreshToken,
1154+
anonymous: false
1155+
)
1156+
}
11371157
#endif
11381158

11391159
// MARK: Internal implementations below
@@ -1157,6 +1177,7 @@ extension User: NSSecureCoding {}
11571177
tenantID = nil
11581178
#if os(iOS)
11591179
multiFactor = MultiFactor(withMFAEnrollments: [])
1180+
enrolledPasskeys = []
11601181
#endif
11611182
uid = ""
11621183
hasEmailPasswordCredential = false
@@ -1391,6 +1412,7 @@ extension User: NSSecureCoding {}
13911412
multiFactor = MultiFactor(withMFAEnrollments: enrollments)
13921413
}
13931414
multiFactor.user = self
1415+
enrolledPasskeys = user.enrolledPasskeys ?? []
13941416
#endif
13951417
}
13961418

@@ -1787,6 +1809,7 @@ extension User: NSSecureCoding {}
17871809
private let kMetadataCodingKey = "metadata"
17881810
private let kMultiFactorCodingKey = "multiFactor"
17891811
private let kTenantIDCodingKey = "tenantID"
1812+
private let kEnrolledPasskeysKey = "passkeys"
17901813

17911814
public static let supportsSecureCoding = true
17921815

@@ -1809,6 +1832,7 @@ extension User: NSSecureCoding {}
18091832
coder.encode(tokenService, forKey: kTokenServiceCodingKey)
18101833
#if os(iOS)
18111834
coder.encode(multiFactor, forKey: kMultiFactorCodingKey)
1835+
coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey)
18121836
#endif
18131837
}
18141838

@@ -1838,6 +1862,9 @@ extension User: NSSecureCoding {}
18381862
let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String
18391863
#if os(iOS)
18401864
let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey)
1865+
let passkeyAllowed: [AnyClass] = [NSArray.self, PasskeyInfo.self]
1866+
let passkeys = coder.decodeObject(of: passkeyAllowed,
1867+
forKey: kEnrolledPasskeysKey) as? [PasskeyInfo]
18411868
#endif
18421869
self.tokenService = tokenService
18431870
uid = userID
@@ -1871,6 +1898,7 @@ extension User: NSSecureCoding {}
18711898
self.multiFactor = multiFactor ?? MultiFactor()
18721899
super.init()
18731900
multiFactor?.user = self
1901+
enrolledPasskeys = passkeys ?? []
18741902
#endif
18751903
}
18761904
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ class AuthErrorUtils {
235235
error(code: .missingVerificationCode, message: message)
236236
}
237237

238+
static func missingPasskeyEnrollment() -> Error {
239+
error(code: .missingPasskeyEnrollment)
240+
}
241+
238242
static func invalidVerificationCodeError(message: String?) -> Error {
239243
error(code: .invalidVerificationCode, message: message)
240244
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,8 @@ import Foundation
336336
/// Indicates that the reCAPTCHA SDK actions class failed to create.
337337
case recaptchaActionCreationFailed = 17210
338338

339+
case missingPasskeyEnrollment = 17212
340+
339341
/// Indicates an error occurred while attempting to access the keychain.
340342
case keychainError = 17995
341343

@@ -528,6 +530,8 @@ import Foundation
528530
return kErrorSiteKeyMissing
529531
case .recaptchaActionCreationFailed:
530532
return kErrorRecaptchaActionCreationFailed
533+
case .missingPasskeyEnrollment:
534+
return kErrorMissingPasskeyEnrollment
531535
}
532536
}
533537

@@ -719,6 +723,8 @@ import Foundation
719723
return "ERROR_RECAPTCHA_SITE_KEY_MISSING"
720724
case .recaptchaActionCreationFailed:
721725
return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED"
726+
case .missingPasskeyEnrollment:
727+
return "ERROR_PASSKEY_ENROLLMENT_NOT_FOUND"
722728
}
723729
}
724730
}
@@ -996,3 +1002,6 @@ private let kErrorSiteKeyMissing =
9961002
private let kErrorRecaptchaActionCreationFailed =
9971003
"The reCAPTCHA SDK action class failed to initialize. See " +
9981004
"https://cloud.google.com/recaptcha-enterprise/docs/instrument-ios-apps"
1005+
1006+
private let kErrorMissingPasskeyEnrollment =
1007+
"Cannot find the passkey linked to the current account."

FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ class GetAccountInfoTests: RPCBaseTests {
8080
let kEmailVerifiedKey = "emailVerified"
8181
let kLocalIDKey = "localId"
8282
let kTestLocalID = "testLocalId"
83+
let kPasskeysKey = "passkeys"
84+
85+
// Fake PasskeyInfo
86+
let testCredentialId = "credential_id"
87+
let testPasskeyName = "Test Passkey"
88+
let passkeys = [[
89+
"credentialId": testCredentialId,
90+
"name": testPasskeyName,
91+
]]
8392

8493
let usersIn = [[
8594
kProviderUserInfoKey: [[
@@ -95,6 +104,7 @@ class GetAccountInfoTests: RPCBaseTests {
95104
kPhotoUrlKey: kTestPhotoURL,
96105
kEmailVerifiedKey: true,
97106
kPasswordHashKey: kTestPasswordHash,
107+
kPasskeysKey: passkeys,
98108
] as [String: Any]]
99109
let rpcIssuer = try XCTUnwrap(self.rpcIssuer)
100110

@@ -119,6 +129,43 @@ class GetAccountInfoTests: RPCBaseTests {
119129
XCTAssertEqual(firstProviderUser.email, kTestEmail)
120130
XCTAssertEqual(firstProviderUser.providerID, kTestProviderID)
121131
XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID)
132+
let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys)
133+
XCTAssertEqual(enrolledPasskeys.count, 1)
134+
XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId)
135+
XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName)
136+
}
137+
138+
func testInitWithMultipleEnrolledPasskeys() throws {
139+
let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"]
140+
let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"]
141+
let userDict: [String: AnyHashable] = [
142+
"localId": "user123",
143+
"email": "[email protected]",
144+
"passkeys": [passkey1, passkey2],
145+
]
146+
let dict: [String: AnyHashable] = ["users": [userDict]]
147+
let response = try GetAccountInfoResponse(dictionary: dict)
148+
let users = try XCTUnwrap(response.users)
149+
let firstUser = try XCTUnwrap(users.first)
150+
let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys)
151+
XCTAssertEqual(enrolledPasskeys.count, 2)
152+
XCTAssertEqual(enrolledPasskeys[0].name, "passkey1")
153+
XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1")
154+
XCTAssertEqual(enrolledPasskeys[1].name, "passkey2")
155+
XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2")
156+
}
157+
158+
func testInitWithNoEnrolledPasskeys() throws {
159+
let userDict: [String: AnyHashable] = [
160+
"localId": "user123",
161+
"email": "[email protected]",
162+
// No "passkeys" present
163+
]
164+
let dict: [String: AnyHashable] = ["users": [userDict]]
165+
let response = try GetAccountInfoResponse(dictionary: dict)
166+
let users = try XCTUnwrap(response.users)
167+
let firstUser = try XCTUnwrap(users.first)
168+
XCTAssertNil(firstUser.enrolledPasskeys)
122169
}
123170

124171
private func makeGetAccountInfoRequest() -> GetAccountInfoRequest {

FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ class SetAccountInfoTests: RPCBaseTests {
6464
let kTestDeleteProviders = "TestDeleteProviders"
6565
let kReturnSecureTokenKey = "returnSecureToken"
6666
let kTestAccessToken = "accessToken"
67+
let kDeletePasskeysKey = "deletePasskey"
68+
let kDeletePasskey = "credential_id"
6769
let kExpectedAPIURL =
6870
"https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey"
6971

@@ -82,6 +84,7 @@ class SetAccountInfoTests: RPCBaseTests {
8284
request.captchaResponse = kTestCaptchaResponse
8385
request.deleteAttributes = [kTestDeleteAttributes]
8486
request.deleteProviders = [kTestDeleteProviders]
87+
request.deletePasskeys = [kDeletePasskey]
8588

8689
try await checkRequest(
8790
request: request,
@@ -105,6 +108,7 @@ class SetAccountInfoTests: RPCBaseTests {
105108
XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes])
106109
XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders])
107110
XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true)
111+
XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey])
108112
}
109113

110114
func testSetAccountInfoErrors() async throws {
@@ -122,6 +126,7 @@ class SetAccountInfoTests: RPCBaseTests {
122126
let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL"
123127
let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters"
124128
let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters"
129+
let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND"
125130

126131
try await checkBackendError(
127132
request: setAccountInfoRequest(),
@@ -189,6 +194,11 @@ class SetAccountInfoTests: RPCBaseTests {
189194
message: kInvalidRecipientEmailErrorMessage,
190195
errorCode: AuthErrorCode.invalidRecipientEmail
191196
)
197+
try await checkBackendError(
198+
request: setAccountInfoRequest(),
199+
message: kInvalidCredentialIdForPasskeyUnenroll,
200+
errorCode: AuthErrorCode.missingPasskeyEnrollment
201+
)
192202
}
193203

194204
/** @fn testSuccessfulSetAccountInfoResponse

0 commit comments

Comments
 (0)