Skip to content

Passkey new finalize enrollment #15163

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

Merged
Merged
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
@@ -0,0 +1,72 @@
// 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 finalizePasskeyEnrollment rpc
private let finalizePasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:finalize"

@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest {
typealias Response = FinalizePasskeyEnrollmentResponse

/// The raw user access token.
let idToken: String
/// The passkey name.
let name: String
/// The credential ID.
let credentialID: String
/// The CollectedClientData object from the authenticator.
let clientDataJSON: String
/// The attestation object from the authenticator.
let attestationObject: String

init(idToken: String,
name: String,
credentialID: String,
clientDataJSON: String,
attestationObject: String,
requestConfiguration: AuthRequestConfiguration) {
self.idToken = idToken
self.name = name
self.credentialID = credentialID
self.clientDataJSON = clientDataJSON
self.attestationObject = attestationObject
super.init(
endpoint: finalizePasskeyEnrollmentEndPoint,
requestConfiguration: requestConfiguration,
useIdentityPlatform: true
)
}

var unencodedHTTPRequestBody: [String: AnyHashable]? {
var postBody: [String: AnyHashable] = [
"idToken": idToken,
"name": name,
]
let authAttestationResponse: [String: AnyHashable] = [
"clientDataJSON": clientDataJSON,
"attestationObject": attestationObject,
]
let authRegistrationResponse: [String: AnyHashable] = [
"id": credentialID,
"response": authAttestationResponse,
]
postBody["authenticatorRegistrationResponse"] = authRegistrationResponse
if let tenantId = tenantID {
postBody["tenantId"] = tenantId
}
return postBody
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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 FinalizePasskeyEnrollmentResponse: AuthRPCResponse {
/// The user raw access token.
let idToken: String
/// Refresh token for the authenticated user.
let refreshToken: String

init(dictionary: [String: AnyHashable]) throws {
guard
let idToken = dictionary["idToken"] as? String,
let refreshToken = dictionary["refreshToken"] as? String
else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
self.idToken = idToken
self.refreshToken = refreshToken
}
}
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 final class PasskeyInfo: NSObject, AuthProto, NSSecureCoding, 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
55 changes: 55 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 @@ -1107,6 +1108,52 @@ extension User: NSSecureCoding {}
userID: userIdInData
)
}

/// Finalize the passkey enrollment with the platfrom public key credential.
/// - Parameter platformCredential: The name for the passkey to be created.
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws
-> AuthDataResult {
let credentialID = platformCredential.credentialID.base64EncodedString()
let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString()
let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString()

let request = FinalizePasskeyEnrollmentRequest(
idToken: rawAccessToken(),
name: passkeyName ?? "Unnamed account (Apple)",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would have already set this in start, do we really need to this check again?

Copy link
Contributor Author

@srushtisv srushtisv Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case, since passkeyName is an optional string, we either need to provide a default value like this, or force-unwrap using '!' to abort execution if the optional value contains nil. There's no compiler guarantee that startEnrollment would be called and will set the passkeyName. Although, we can add guard else throw{..} in the beginning to confirm that passkeyName is set but as we know in the passkey flow, finalizePasskeyEnrollment will always be called after startPasskeyEnrollment hence I didnt use an explicit error handling here and used the default name which could be the only case where passkeyName is nil. Please let me know if I should add a guard statement here also, in that case we can remove "Unnamed account (Apple)" from here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay if we need to keep it, you can update this to use the same constant as you used in the StartEnrollment flow once you merge

credentialID: credentialID,
clientDataJSON: clientDataJSON,
attestationObject: attestationObject,
requestConfiguration: auth!.requestConfiguration
)
let response = try await backend.call(with: request)
let user = try await auth!.completeSignIn(
withAccessToken: response.idToken,
accessTokenExpirationDate: nil,
refreshToken: response.refreshToken,
anonymous: false
)
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 @@ -1130,6 +1177,7 @@ extension User: NSSecureCoding {}
tenantID = nil
#if os(iOS)
multiFactor = MultiFactor(withMFAEnrollments: [])
enrolledPasskeys = []
#endif
uid = ""
hasEmailPasswordCredential = false
Expand Down Expand Up @@ -1364,6 +1412,7 @@ extension User: NSSecureCoding {}
multiFactor = MultiFactor(withMFAEnrollments: enrollments)
}
multiFactor.user = self
enrolledPasskeys = user.enrolledPasskeys ?? []
#endif
}

Expand Down Expand Up @@ -1760,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 @@ -1782,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 @@ -1811,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 @@ -1844,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."
Loading
Loading