Skip to content

Commit 687cf87

Browse files
committed
implementing finalize passkey enrollment
1 parent 9d87eb9 commit 687cf87

File tree

6 files changed

+406
-79
lines changed

6 files changed

+406
-79
lines changed
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/User/User.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,33 @@ extension User: NSSecureCoding {}
11021102
)
11031103
return registrationRequest
11041104
}
1105+
1106+
/// Finalize the passkey enrollment with the platfrom public key credential.
1107+
/// - Parameter platformCredential: The name for the passkey to be created.
1108+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1109+
public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws
1110+
-> AuthDataResult {
1111+
let credentialID = platformCredential.credentialID.base64EncodedString()
1112+
let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString()
1113+
let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString()
1114+
1115+
let request = FinalizePasskeyEnrollmentRequest(
1116+
idToken: rawAccessToken(),
1117+
name: passkeyName ?? "Unnamed account (Apple)",
1118+
credentialID: credentialID,
1119+
clientDataJSON: clientDataJSON,
1120+
attestationObject: attestationObject,
1121+
requestConfiguration: auth!.requestConfiguration
1122+
)
1123+
let response = try await backend.call(with: request)
1124+
let user = try await auth!.completeSignIn(
1125+
withAccessToken: response.idToken,
1126+
accessTokenExpirationDate: nil,
1127+
refreshToken: response.refreshToken,
1128+
anonymous: false
1129+
)
1130+
return AuthDataResult(withUser: user, additionalUserInfo: nil)
1131+
}
11051132
#endif
11061133

11071134
// MARK: Internal implementations below
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
@testable import FirebaseAuth
16+
import Foundation
17+
import XCTest
18+
19+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
20+
class FinalizePasskeyEnrollmentRequestTests: XCTestCase {
21+
private var request: FinalizePasskeyEnrollmentRequest!
22+
private var fakeConfig: AuthRequestConfiguration!
23+
24+
override func setUp() {
25+
super.setUp()
26+
fakeConfig = AuthRequestConfiguration(
27+
apiKey: "FAKE_API_KEY",
28+
appID: "FAKE_APP_ID"
29+
)
30+
}
31+
32+
override func tearDown() {
33+
request = nil
34+
fakeConfig = nil
35+
super.tearDown()
36+
}
37+
38+
func testInitWithValidParameters() {
39+
request = FinalizePasskeyEnrollmentRequest(
40+
idToken: "ID_TOKEN",
41+
name: "MyPasskey",
42+
credentialID: "CRED_ID",
43+
clientDataJSON: "CLIENT_JSON",
44+
attestationObject: "ATTEST_OBJ",
45+
requestConfiguration: fakeConfig
46+
)
47+
48+
XCTAssertEqual(request.idToken, "ID_TOKEN")
49+
XCTAssertEqual(request.name, "MyPasskey")
50+
XCTAssertEqual(request.credentialID, "CRED_ID")
51+
XCTAssertEqual(request.clientDataJSON, "CLIENT_JSON")
52+
XCTAssertEqual(request.attestationObject, "ATTEST_OBJ")
53+
XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:finalize")
54+
XCTAssertTrue(request.useIdentityPlatform)
55+
}
56+
57+
func testUnencodedHTTPRequestBodyWithoutTenantId() {
58+
request = FinalizePasskeyEnrollmentRequest(
59+
idToken: "ID_TOKEN",
60+
name: "MyPasskey",
61+
credentialID: "CRED_ID",
62+
clientDataJSON: "CLIENT_JSON",
63+
attestationObject: "ATTEST_OBJ",
64+
requestConfiguration: fakeConfig
65+
)
66+
67+
let body = request.unencodedHTTPRequestBody
68+
XCTAssertNotNil(body)
69+
XCTAssertEqual(body?["idToken"] as? String, "ID_TOKEN")
70+
XCTAssertEqual(body?["name"] as? String, "MyPasskey")
71+
72+
let authReg = body?["authenticatorRegistrationResponse"] as? [String: AnyHashable]
73+
XCTAssertNotNil(authReg)
74+
XCTAssertEqual(authReg?["id"] as? String, "CRED_ID")
75+
76+
let authResp = authReg?["response"] as? [String: AnyHashable]
77+
XCTAssertEqual(authResp?["clientDataJSON"] as? String, "CLIENT_JSON")
78+
XCTAssertEqual(authResp?["attestationObject"] as? String, "ATTEST_OBJ")
79+
80+
XCTAssertNil(body?["tenantId"])
81+
}
82+
83+
func testUnencodedHTTPRequestBodyWithTenantId() {
84+
request = FinalizePasskeyEnrollmentRequest(
85+
idToken: "ID_TOKEN",
86+
name: "MyPasskey",
87+
credentialID: "CRED_ID",
88+
clientDataJSON: "CLIENT_JSON",
89+
attestationObject: "ATTEST_OBJ",
90+
requestConfiguration: fakeConfig
91+
)
92+
request.tenantID = "TENANT_ID"
93+
94+
let body = request.unencodedHTTPRequestBody
95+
XCTAssertEqual(body?["tenantId"] as? String, "TENANT_ID")
96+
}
97+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
@testable import FirebaseAuth
16+
import XCTest
17+
18+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
19+
class FinalizePasskeyEnrollmentResponseTests: XCTestCase {
20+
private func makeValidDictionary() -> [String: AnyHashable] {
21+
return [
22+
"idToken": "FAKE_ID_TOKEN" as AnyHashable,
23+
"refreshToken": "FAKE_REFRESH_TOKEN" as AnyHashable,
24+
]
25+
}
26+
27+
func testInitWithValidDictionary() throws {
28+
let response = try FinalizePasskeyEnrollmentResponse(
29+
dictionary: makeValidDictionary()
30+
)
31+
XCTAssertEqual(response.idToken, "FAKE_ID_TOKEN")
32+
XCTAssertEqual(response.refreshToken, "FAKE_REFRESH_TOKEN")
33+
}
34+
35+
func testInitWithMissingIdTokenThrowsError() {
36+
var dict = makeValidDictionary()
37+
dict.removeValue(forKey: "idToken")
38+
XCTAssertThrowsError(
39+
try FinalizePasskeyEnrollmentResponse(dictionary: dict)
40+
)
41+
}
42+
43+
func testInitWithMissingRefreshTokenThrowsError() {
44+
var dict = makeValidDictionary()
45+
dict.removeValue(forKey: "refreshToken")
46+
XCTAssertThrowsError(
47+
try FinalizePasskeyEnrollmentResponse(dictionary: dict)
48+
)
49+
}
50+
51+
func testInitWithEmptyDictionaryThrowsError() {
52+
XCTAssertThrowsError(
53+
try FinalizePasskeyEnrollmentResponse(dictionary: [:])
54+
)
55+
}
56+
}

0 commit comments

Comments
 (0)