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

Open
wants to merge 8 commits into
base: passkey-new
Choose a base branch
from
Open
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
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
}
}
62 changes: 62 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,64 @@ 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?
private let defaultPasskeyName: String = "Unnamed account (Apple)"

/// 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)
guard let passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name
else { throw NSError(
domain: AuthErrorDomain,
code: AuthErrorCode.internalError.rawValue,
userInfo: [NSLocalizedDescriptionKey: "Failed to unwrap passkey name"]
) }
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
)
return provider.createCredentialRegistrationRequest(
challenge: challengeInData,
name: passkeyName,
userID: userIdInData
)
}
#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
99 changes: 99 additions & 0 deletions FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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: RPCBaseTests {
private func makeValidDictionary() -> [String: AnyHashable] {
return [
"credentialCreationOptions": [
"rp": ["id": "FAKE_RP_ID"] as [String: AnyHashable],
"user": ["id": "FAKE_USER_ID"] as [String: AnyHashable],
"challenge": "FAKE_CHALLENGE" as String,
] as [String: AnyHashable],
]
}

/// Helper function to remove a nested key from a dictionary
private func removeField(_ dict: inout [String: AnyHashable], keyPath: [String]) {
guard let first = keyPath.first else { return }
if keyPath.count == 1 {
dict.removeValue(forKey: first)
} else if var inDict = dict[first] as? [String: AnyHashable] {
removeField(&inDict, keyPath: Array(keyPath.dropFirst()))
dict[first] = inDict
}
}

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

func testInitWithMissingFields() throws {
struct TestCase {
let name: String
let removeFieldPath: [String]
}
let cases: [TestCase] = [
.init(name: "Missing rpId", removeFieldPath: ["credentialCreationOptions", "rp", "id"]),
.init(name: "Missing userId", removeFieldPath: ["credentialCreationOptions", "user", "id"]),
.init(
name: "Missing Challenge",
removeFieldPath: ["credentialCreationOptions", "challenge"]
),
]
for testCase in cases {
var dict = makeValidDictionary()
removeField(&dict, keyPath: testCase.removeFieldPath)
XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict),
testCase.name) { error in
let nsError = error as NSError
XCTAssertEqual(nsError.domain, AuthErrorDomain)
XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue)
}
}
}

func testSuccessfulStartPasskeyEnrollmentResponse() async throws {
let expectedRpID = "FAKE_RP_ID"
let expectedUserID = "FAKE_USER_ID"
let expectedChallenge = "FAKE_CHALLENGE"
rpcIssuer.respondBlock = {
try self.rpcIssuer.respond(withJSON: [
"credentialCreationOptions": [
"rp": ["id": expectedRpID],
"user": ["id": expectedUserID],
"challenge": expectedChallenge,
],
])
}
let request = StartPasskeyEnrollmentRequest(
idToken: "FAKE_ID_TOKEN",
requestConfiguration: AuthRequestConfiguration(apiKey: "API_KEY", appID: "APP_ID")
)
let response = try await authBackend.call(with: request)
XCTAssertEqual(response.rpID, expectedRpID)
XCTAssertEqual(response.userID, expectedUserID)
XCTAssertEqual(response.challenge, expectedChallenge)
}
}

#endif
Loading
Loading