Skip to content

implement start passkey enrollment- updated #15162

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 10 commits into from
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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 startPasskeyEnrollment rpc
private let startPasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:start"

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

/// The raw user access token
let idToken: String

init(idToken: String,
requestConfiguration: AuthRequestConfiguration) {
self.idToken = idToken
super.init(
endpoint: startPasskeyEnrollmentEndPoint,
requestConfiguration: requestConfiguration,
useIdentityPlatform: true
)
}

var unencodedHTTPRequestBody: [String: AnyHashable]? {
var body: [String: AnyHashable] = [
"idToken": idToken,
]
if let tenantID = tenantID {
body["tenantId"] = tenantID
}
return body
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 StartPasskeyEnrollmentResponse: AuthRPCResponse {
/// The RP ID of the FIDO Relying Party.
let rpID: String
/// The user id
let userID: String
/// The FIDO challenge.
let challenge: String

init(dictionary: [String: AnyHashable]) throws {
guard let options = dictionary["credentialCreationOptions"] as? [String: Any] else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
guard let rp = options["rp"] as? [String: Any],
let rpID = rp["id"] as? String else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
guard let user = options["user"] as? [String: Any],
let userID = user["id"] as? String else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
guard let challenge = options["challenge"] as? String else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
self.rpID = rpID
self.userID = userID
self.challenge = challenge
}
}
57 changes: 57 additions & 0 deletions FirebaseAuth/Sources/Swift/User/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

import Foundation

#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)
import AuthenticationServices
#endif

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
extension User: NSSecureCoding {}

Expand Down Expand Up @@ -1047,6 +1051,59 @@ extension User: NSSecureCoding {}
}
}

// MARK: Passkey Implementation

#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)

/// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed
/// at finalizePasskeyEnrollment(withPlatformCredential:) call
private var passkeyName: String?

/// Start the passkey enrollment creating a plaform public key creation request with the
/// challenge from GCIP backend.
/// - Parameter name: The name for the passkey to be created.
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
public func startPasskeyEnrollment(withName name: String?) async throws
-> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest {
guard auth != nil else {
/// If auth is nil, this User object is in an invalid state for this operation.
fatalError(
"Firebase Auth Internal Error: Set user's auth property with non-nil instance. Cannot start passkey enrollment."
)
}
let enrollmentIdToken = rawAccessToken()
let request = StartPasskeyEnrollmentRequest(
idToken: enrollmentIdToken,
requestConfiguration: requestConfiguration
)
let response = try await backend.call(with: request)
passkeyName = (name?.isEmpty ?? true) ? "Unnamed account (Apple)" : name!
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we use guard or if let construct here to prevent force unwrapping similar to how we did in StartPasskeyEnrollmentRequest?

Also nit: We should either do it in the top of the method as a input validation and assignment or just before we use it so that we can look at it's value where it is being used. In this particular case, I would lean in to do it in the beginning of the method as this is a class level variable.

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.

actually force unwrapping is not neccesarily required here. we can safely remove it (also it wont go in that condition anyways, i added it just to be on the safe side). Removed force execution and used guard instead.
also Pavan, thanks for pointing that correctly but we are assigning the value just before we use it (in the just below registration request). I added it below the startEnrollment backend call because incase the backend call fails, we dont need to store the passkey name in that case.

Copy link
Contributor

Choose a reason for hiding this comment

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

We should store "Unnamed account (Apple)" as a constant.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure Pavan, created a constant defaultPasskeyName to store this. refactored.

guard let challengeInData = Data(base64Encoded: response.challenge) else {
throw NSError(
domain: AuthErrorDomain,
code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."]
)
}
guard let userIdInData = Data(base64Encoded: response.userID) else {
throw NSError(
domain: AuthErrorDomain,
code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 userId from response."]
)
}
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: response.rpID
)
let registrationRequest = provider.createCredentialRegistrationRequest(
challenge: challengeInData,
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.

Is this required? We are just setting passkeyName above with default value. Is there a case where passkeyName would still be not populated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing this out Pavan, removed the optional from here.

userID: userIdInData
)
return registrationRequest
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel we can directly return from the previous statement, instead of storing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated!

}
#endif

// MARK: Internal implementations below

func rawAccessToken() -> String {
Expand Down
88 changes: 88 additions & 0 deletions FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 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.

#if os(iOS) || os(tvOS) || os(macOS)

@testable import FirebaseAuth
import FirebaseCore
import Foundation
import XCTest

@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
class StartPasskeyEnrollmentRequestTests: XCTestCase {
private var request: StartPasskeyEnrollmentRequest!
private var fakeConfig: AuthRequestConfiguration!

override func setUp() {
super.setUp()
fakeConfig = AuthRequestConfiguration(
apiKey: "FAKE_API_KEY",
appID: "FAKE_APP_ID"
)
}

override func tearDown() {
request = nil
fakeConfig = nil
super.tearDown()
}

func testInitWithValidIdTokenAndConfiguration() {
request = StartPasskeyEnrollmentRequest(
idToken: "FAKE_ID_TOKEN",
requestConfiguration: fakeConfig
)
XCTAssertEqual(request.idToken, "FAKE_ID_TOKEN")
XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:start")
XCTAssertTrue(request.useIdentityPlatform)
}

func testUnencodedHTTPRequestBodyWithoutTenantId() {
request = StartPasskeyEnrollmentRequest(
idToken: "FAKE_ID_TOKEN",
requestConfiguration: fakeConfig
)
let body = request.unencodedHTTPRequestBody
XCTAssertNotNil(body)
XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN")
XCTAssertNil(body?["tenantId"])
}

func testUnencodedHTTPRequestBodyWithTenantId() {
// setting up fake auth to set tenantId
let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000",
gcmSenderID: "00000000000000000-00000000000-000000000")
options.apiKey = AuthTests.kFakeAPIKey
options.projectID = "myProjectID"
let name = "test-AuthTests\(AuthTests.testNum)"
AuthTests.testNum = AuthTests.testNum + 1
let fakeAuth = Auth(app: FirebaseApp(instanceWithName: name, options: options))
fakeAuth.tenantID = "TEST_TENANT"
let configWithTenant = AuthRequestConfiguration(
apiKey: "FAKE_API_KEY",
appID: "FAKE_APP_ID",
auth: fakeAuth
)
request = StartPasskeyEnrollmentRequest(
idToken: "FAKE_ID_TOKEN",
requestConfiguration: configWithTenant
)
let body = request.unencodedHTTPRequestBody
XCTAssertNotNil(body)
XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN")
XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT")
}
}

#endif
94 changes: 94 additions & 0 deletions FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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.

#if os(iOS) || os(tvOS) || os(macOS)

@testable import FirebaseAuth
import XCTest

@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
class StartPasskeyEnrollmentResponseTests: XCTestCase {
private func makeValidDictionary() -> [String: AnyHashable] {
return [
"credentialCreationOptions": [
"rp": ["id": "example.com"] as [String: AnyHashable],
"user": ["id": "USER_123"] as [String: AnyHashable],
"challenge": "FAKE_CHALLENGE" as String,
] as [String: AnyHashable],
]
}

func testInitWithValidDictionary() throws {
let response = try StartPasskeyEnrollmentResponse(dictionary: makeValidDictionary())
XCTAssertEqual(response.rpID, "example.com")
XCTAssertEqual(response.userID, "USER_123")
XCTAssertEqual(response.challenge, "FAKE_CHALLENGE")
}

func testInitWithMissingCredentialCreationOptionsThrowsError() {
let invalidDict: [String: AnyHashable] = [:]
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict))
}

func testInitWithMissingRpThrowsError() {
var dict = makeValidDictionary()
if var options = dict["credentialCreationOptions"] as? [String: Any] {
options.removeValue(forKey: "rp")
dict["credentialCreationOptions"] = options as? AnyHashable
}
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict))
}

func testInitWithMissingRpIdThrowsError() {
var dict = makeValidDictionary()
if var options = dict["credentialCreationOptions"] as? [String: Any],
var rp = options["rp"] as? [String: Any] {
rp.removeValue(forKey: "id")
options["rp"] = rp
dict["credentialCreationOptions"] = options as? AnyHashable
}
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict))
}

func testInitWithMissingUserThrowsError() {
var dict = makeValidDictionary()
if var options = dict["credentialCreationOptions"] as? [String: Any] {
options.removeValue(forKey: "user")
dict["credentialCreationOptions"] = options as? AnyHashable
}
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict))
}

func testInitWithMissingUserIdThrowsError() {
var dict = makeValidDictionary()
if var options = dict["credentialCreationOptions"] as? [String: Any],
var user = options["user"] as? [String: Any] {
user.removeValue(forKey: "id")
Copy link
Contributor

Choose a reason for hiding this comment

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

Optional suggestion: Should we club all these test cases together and say testInitWithInvalid parameters and then loop them inside for each type of object as I see the testing as assertion part is same for all.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I’ve added a new test case in this file to validate all these parameters together and assert them collectively. (I had tried it earlier to make these tests more effficient but was encountering some errors so for now I added this extra case.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the tests as discussed.

options["user"] = user
dict["credentialCreationOptions"] = options as? AnyHashable
}
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict))
}

func testInitWithMissingChallengeThrowsError() {
var dict = makeValidDictionary()
if var options = dict["credentialCreationOptions"] as? [String: Any] {
options.removeValue(forKey: "challenge")
dict["credentialCreationOptions"] = options as? AnyHashable
}
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict))
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also assert the error type to make sure code is not throwing error due to some other reason here? Similar for other testcases:
Something like below

XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in
        XCTAssertEqual(error.code, "expected code  or message here")
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure Pavan, updated the tests to assert specific errors.

}
}

#endif
Loading
Loading