Skip to content

Commit bb80eb6

Browse files
authored
implementing passkey finalize enrollment (#15163)
1 parent 8e10c1a commit bb80eb6

14 files changed

+659
-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
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
/// The GCIP endpoint for finalizePasskeyEnrollment rpc
18+
private let finalizePasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:finalize"
19+
20+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
21+
class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest {
22+
typealias Response = FinalizePasskeyEnrollmentResponse
23+
24+
/// The raw user access token.
25+
let idToken: String
26+
/// The passkey name.
27+
let name: String
28+
/// The credential ID.
29+
let credentialID: String
30+
/// The CollectedClientData object from the authenticator.
31+
let clientDataJSON: String
32+
/// The attestation object from the authenticator.
33+
let attestationObject: String
34+
35+
init(idToken: String,
36+
name: String,
37+
credentialID: String,
38+
clientDataJSON: String,
39+
attestationObject: String,
40+
requestConfiguration: AuthRequestConfiguration) {
41+
self.idToken = idToken
42+
self.name = name
43+
self.credentialID = credentialID
44+
self.clientDataJSON = clientDataJSON
45+
self.attestationObject = attestationObject
46+
super.init(
47+
endpoint: finalizePasskeyEnrollmentEndPoint,
48+
requestConfiguration: requestConfiguration,
49+
useIdentityPlatform: true
50+
)
51+
}
52+
53+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
54+
var postBody: [String: AnyHashable] = [
55+
"idToken": idToken,
56+
"name": name,
57+
]
58+
let authAttestationResponse: [String: AnyHashable] = [
59+
"clientDataJSON": clientDataJSON,
60+
"attestationObject": attestationObject,
61+
]
62+
let authRegistrationResponse: [String: AnyHashable] = [
63+
"id": credentialID,
64+
"response": authAttestationResponse,
65+
]
66+
postBody["authenticatorRegistrationResponse"] = authRegistrationResponse
67+
if let tenantId = tenantID {
68+
postBody["tenantId"] = tenantId
69+
}
70+
return postBody
71+
}
72+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
18+
struct FinalizePasskeyEnrollmentResponse: AuthRPCResponse {
19+
/// The user raw access token.
20+
let idToken: String
21+
/// Refresh token for the authenticated user.
22+
let refreshToken: String
23+
24+
init(dictionary: [String: AnyHashable]) throws {
25+
guard
26+
let idToken = dictionary["idToken"] as? String,
27+
let refreshToken = dictionary["refreshToken"] as? String
28+
else {
29+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
30+
}
31+
self.idToken = idToken
32+
self.refreshToken = refreshToken
33+
}
34+
}

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: 55 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.
@@ -1107,6 +1108,52 @@ extension User: NSSecureCoding {}
11071108
userID: userIdInData
11081109
)
11091110
}
1111+
1112+
/// Finalize the passkey enrollment with the platfrom public key credential.
1113+
/// - Parameter platformCredential: The name for the passkey to be created.
1114+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1115+
public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws
1116+
-> AuthDataResult {
1117+
let credentialID = platformCredential.credentialID.base64EncodedString()
1118+
let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString()
1119+
let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString()
1120+
1121+
let request = FinalizePasskeyEnrollmentRequest(
1122+
idToken: rawAccessToken(),
1123+
name: passkeyName ?? "Unnamed account (Apple)",
1124+
credentialID: credentialID,
1125+
clientDataJSON: clientDataJSON,
1126+
attestationObject: attestationObject,
1127+
requestConfiguration: auth!.requestConfiguration
1128+
)
1129+
let response = try await backend.call(with: request)
1130+
let user = try await auth!.completeSignIn(
1131+
withAccessToken: response.idToken,
1132+
accessTokenExpirationDate: nil,
1133+
refreshToken: response.refreshToken,
1134+
anonymous: false
1135+
)
1136+
return AuthDataResult(withUser: user, additionalUserInfo: nil)
1137+
}
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+
}
11101157
#endif
11111158

11121159
// MARK: Internal implementations below
@@ -1130,6 +1177,7 @@ extension User: NSSecureCoding {}
11301177
tenantID = nil
11311178
#if os(iOS)
11321179
multiFactor = MultiFactor(withMFAEnrollments: [])
1180+
enrolledPasskeys = []
11331181
#endif
11341182
uid = ""
11351183
hasEmailPasswordCredential = false
@@ -1364,6 +1412,7 @@ extension User: NSSecureCoding {}
13641412
multiFactor = MultiFactor(withMFAEnrollments: enrollments)
13651413
}
13661414
multiFactor.user = self
1415+
enrolledPasskeys = user.enrolledPasskeys ?? []
13671416
#endif
13681417
}
13691418

@@ -1760,6 +1809,7 @@ extension User: NSSecureCoding {}
17601809
private let kMetadataCodingKey = "metadata"
17611810
private let kMultiFactorCodingKey = "multiFactor"
17621811
private let kTenantIDCodingKey = "tenantID"
1812+
private let kEnrolledPasskeysKey = "passkeys"
17631813

17641814
public static let supportsSecureCoding = true
17651815

@@ -1782,6 +1832,7 @@ extension User: NSSecureCoding {}
17821832
coder.encode(tokenService, forKey: kTokenServiceCodingKey)
17831833
#if os(iOS)
17841834
coder.encode(multiFactor, forKey: kMultiFactorCodingKey)
1835+
coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey)
17851836
#endif
17861837
}
17871838

@@ -1811,6 +1862,9 @@ extension User: NSSecureCoding {}
18111862
let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String
18121863
#if os(iOS)
18131864
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]
18141868
#endif
18151869
self.tokenService = tokenService
18161870
uid = userID
@@ -1844,6 +1898,7 @@ extension User: NSSecureCoding {}
18441898
self.multiFactor = multiFactor ?? MultiFactor()
18451899
super.init()
18461900
multiFactor?.user = self
1901+
enrolledPasskeys = passkeys ?? []
18471902
#endif
18481903
}
18491904
}

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."

0 commit comments

Comments
 (0)