Skip to content

Commit 0f96c65

Browse files
authored
implementing finalize signin for passkey (#15173)
1 parent e1cd312 commit 0f96c65

9 files changed

+445
-0
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,34 @@ extension Auth: AuthInterop {
16691669
challenge: challengeInData
16701670
)
16711671
}
1672+
1673+
/// finalize sign in with passkey with existing credential assertion.
1674+
/// - Parameter platformCredential The existing credential assertion created by device.
1675+
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
1676+
public func finalizePasskeySignIn(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws
1677+
-> AuthDataResult {
1678+
let credentialID = platformCredential.credentialID.base64EncodedString()
1679+
let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString()
1680+
let authenticatorData = platformCredential.rawAuthenticatorData.base64EncodedString()
1681+
let signature = platformCredential.signature.base64EncodedString()
1682+
let userID = platformCredential.userID.base64EncodedString()
1683+
let request = FinalizePasskeySignInRequest(
1684+
credentialID: credentialID,
1685+
clientDataJSON: clientDataJSON,
1686+
authenticatorData: authenticatorData,
1687+
signature: signature,
1688+
userId: userID,
1689+
requestConfiguration: requestConfiguration
1690+
)
1691+
let response = try await backend.call(with: request)
1692+
let user = try await Auth.auth().completeSignIn(
1693+
withAccessToken: response.idToken,
1694+
accessTokenExpirationDate: nil,
1695+
refreshToken: response.refreshToken,
1696+
anonymous: false
1697+
)
1698+
return AuthDataResult(withUser: user, additionalUserInfo: nil)
1699+
}
16721700
#endif
16731701

16741702
// MARK: Internal methods

FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ final class AuthBackend: AuthBackendProtocol {
440440
return AuthErrorUtils.credentialAlreadyInUseError(
441441
message: serverDetailErrorMessage, credential: credential, email: email
442442
)
443+
case "INVALID_AUTHENTICATOR_RESPONSE": return AuthErrorUtils.invalidAuthenticatorResponse()
443444
default:
444445
if let underlyingErrors = errorDictionary["errors"] as? [[String: String]] {
445446
for underlyingError in underlyingErrors {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/// The GCIP endpoint for finalizePasskeySignIn rpc
18+
private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize"
19+
20+
class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest {
21+
typealias Response = FinalizePasskeySignInResponse
22+
/// The credential ID
23+
let credentialID: String
24+
/// The CollectedClientData object from the authenticator.
25+
let clientDataJSON: String
26+
/// The AuthenticatorData from the authenticator.
27+
let authenticatorData: String
28+
/// The signature from the authenticator.
29+
let signature: String
30+
/// The user handle
31+
let userId: String
32+
33+
init(credentialID: String,
34+
clientDataJSON: String,
35+
authenticatorData: String,
36+
signature: String,
37+
userId: String,
38+
requestConfiguration: AuthRequestConfiguration) {
39+
self.credentialID = credentialID
40+
self.clientDataJSON = clientDataJSON
41+
self.authenticatorData = authenticatorData
42+
self.signature = signature
43+
self.userId = userId
44+
super.init(
45+
endpoint: finalizePasskeySignInEndPoint,
46+
requestConfiguration: requestConfiguration,
47+
useIdentityPlatform: true
48+
)
49+
}
50+
51+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
52+
var postBody: [String: AnyHashable] = [
53+
"authenticatorAssertionResponse": [
54+
"credentialId": credentialID,
55+
"authenticatorAssertionResponse": [
56+
"clientDataJSON": clientDataJSON,
57+
"authenticatorData": authenticatorData,
58+
"signature": signature,
59+
"userHandle": userId,
60+
],
61+
] as [String: AnyHashable],
62+
]
63+
if let tenantID = tenantID {
64+
postBody["tenantId"] = tenantID
65+
}
66+
return postBody
67+
}
68+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
struct FinalizePasskeySignInResponse: AuthRPCResponse {
18+
/// The user raw access token.
19+
let idToken: String
20+
/// Refresh token for the authenticated user.
21+
let refreshToken: String
22+
23+
init(dictionary: [String: AnyHashable]) throws {
24+
guard
25+
let idToken = dictionary["idToken"] as? String,
26+
let refreshToken = dictionary["refreshToken"] as? String
27+
else {
28+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
29+
}
30+
self.idToken = idToken
31+
self.refreshToken = refreshToken
32+
}
33+
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ class AuthErrorUtils {
207207
error(code: .invalidRecaptchaToken)
208208
}
209209

210+
static func invalidAuthenticatorResponse() -> Error {
211+
error(code: .invalidAuthenticatorResponse)
212+
}
213+
210214
static func unauthorizedDomainError(message: String?) -> Error {
211215
error(code: .unauthorizedDomain, message: message)
212216
}

FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ import Foundation
336336
/// Indicates that the reCAPTCHA SDK actions class failed to create.
337337
case recaptchaActionCreationFailed = 17210
338338

339+
/// the authenticator response for passkey signin or enrollment is not parseable, missing required
340+
/// fields, or certain fields are invalid values
341+
case invalidAuthenticatorResponse = 17211
342+
339343
/// Indicates an error occurred while attempting to access the keychain.
340344
case keychainError = 17995
341345

@@ -528,6 +532,8 @@ import Foundation
528532
return kErrorSiteKeyMissing
529533
case .recaptchaActionCreationFailed:
530534
return kErrorRecaptchaActionCreationFailed
535+
case .invalidAuthenticatorResponse:
536+
return kErrorInvalidAuthenticatorResponse
531537
}
532538
}
533539

@@ -719,6 +725,8 @@ import Foundation
719725
return "ERROR_RECAPTCHA_SITE_KEY_MISSING"
720726
case .recaptchaActionCreationFailed:
721727
return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED"
728+
case .invalidAuthenticatorResponse:
729+
return "ERROR_INVALID_AUTHENTICATOR_RESPONSE"
722730
}
723731
}
724732
}
@@ -996,3 +1004,6 @@ private let kErrorSiteKeyMissing =
9961004
private let kErrorRecaptchaActionCreationFailed =
9971005
"The reCAPTCHA SDK action class failed to initialize. See " +
9981006
"https://cloud.google.com/recaptcha-enterprise/docs/instrument-ios-apps"
1007+
1008+
private let kErrorInvalidAuthenticatorResponse =
1009+
"During passkey enrollment and sign in, the authenticator response is not parseable, missing required fields, or certain fields are invalid values that compromise the security of the sign-in or enrollment."

FirebaseAuth/Tests/Unit/AuthTests.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class AuthTests: RPCBaseTests {
2929
static let kFakeRecaptchaVersion = "RecaptchaVersion"
3030
static let kRpId = "FAKE_RP_ID"
3131
static let kChallenge = "Y2hhbGxlbmdl"
32+
private let kCredentialID = "FAKE_CREDENTIAL_ID"
33+
private let kClientDataJSON = "FAKE_CLIENT_DATA"
34+
private let kAuthenticatorData = "FAKE_AUTHENTICATOR_DATA"
35+
private let kSignature = "FAKE_SIGNATURE"
36+
private let kUserId = "FAKE_USERID"
3237
var auth: Auth!
3338
static var testNum = 0
3439
var authDispatcherCallback: (() -> Void)?
@@ -2510,5 +2515,119 @@ class AuthTests: RPCBaseTests {
25102515
}
25112516
waitForExpectations(timeout: 5)
25122517
}
2518+
2519+
/// Helper mock to simulate platform credential fields
2520+
struct MockPlatformCredential {
2521+
let credentialID: Data
2522+
let clientDataJSON: Data
2523+
let authenticatorData: Data
2524+
let signature: Data
2525+
let userID: Data
2526+
}
2527+
2528+
private func buildFinalizeRequest(mock: MockPlatformCredential)
2529+
-> FinalizePasskeySignInRequest {
2530+
return FinalizePasskeySignInRequest(
2531+
credentialID: kCredentialID,
2532+
clientDataJSON: kClientDataJSON,
2533+
authenticatorData: kAuthenticatorData,
2534+
signature: kSignature,
2535+
userId: kUserId,
2536+
requestConfiguration: auth!.requestConfiguration
2537+
)
2538+
}
2539+
2540+
func testFinalizePasskeysigninSuccess() async throws {
2541+
setFakeGetAccountProvider()
2542+
let expectation = expectation(description: #function)
2543+
rpcIssuer.respondBlock = {
2544+
let request = try XCTUnwrap(self.rpcIssuer?.request as? FinalizePasskeySignInRequest)
2545+
XCTAssertEqual(request.credentialID, self.kCredentialID)
2546+
XCTAssertNotNil(request.credentialID)
2547+
XCTAssertEqual(request.clientDataJSON, self.kClientDataJSON)
2548+
XCTAssertNotNil(request.clientDataJSON)
2549+
XCTAssertEqual(request.authenticatorData, self.kAuthenticatorData)
2550+
XCTAssertNotNil(request.authenticatorData)
2551+
XCTAssertEqual(request.signature, self.kSignature)
2552+
XCTAssertNotNil(request.signature)
2553+
XCTAssertEqual(request.userId, self.kUserId)
2554+
XCTAssertNotNil(request.userId)
2555+
return try self.rpcIssuer.respond(
2556+
withJSON: [
2557+
"idToken": RPCBaseTests.kFakeAccessToken,
2558+
"refreshToken": self.kRefreshToken,
2559+
]
2560+
)
2561+
}
2562+
let mock = MockPlatformCredential(
2563+
credentialID: Data(kCredentialID.utf8),
2564+
clientDataJSON: Data(kClientDataJSON.utf8),
2565+
authenticatorData: Data(kAuthenticatorData.utf8),
2566+
signature: Data(kSignature.utf8),
2567+
userID: Data(kUserId.utf8)
2568+
)
2569+
Task {
2570+
let request = self.buildFinalizeRequest(mock: mock)
2571+
_ = try await self.authBackend.call(with: request)
2572+
expectation.fulfill()
2573+
}
2574+
XCTAssertNotNil(AuthTests.kFakeAccessToken)
2575+
await fulfillment(of: [expectation], timeout: 5)
2576+
}
2577+
2578+
func testFinalizePasskeySignInFailure() async throws {
2579+
setFakeGetAccountProvider()
2580+
let expectation = expectation(description: #function)
2581+
rpcIssuer.respondBlock = {
2582+
// Simulate backend error (e.g., OperationNotAllowed)
2583+
try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED")
2584+
}
2585+
let mock = MockPlatformCredential(
2586+
credentialID: Data(kCredentialID.utf8),
2587+
clientDataJSON: Data(kClientDataJSON.utf8),
2588+
authenticatorData: Data(kAuthenticatorData.utf8),
2589+
signature: Data(kSignature.utf8),
2590+
userID: Data(kUserId.utf8)
2591+
)
2592+
Task {
2593+
let request = self.buildFinalizeRequest(mock: mock)
2594+
do {
2595+
_ = try await self.authBackend.call(with: request)
2596+
XCTFail("Expected error but got success")
2597+
} catch {
2598+
let nsError = error as NSError
2599+
XCTAssertEqual(nsError.code, AuthErrorCode.operationNotAllowed.rawValue)
2600+
expectation.fulfill()
2601+
}
2602+
}
2603+
await fulfillment(of: [expectation], timeout: 5)
2604+
}
2605+
2606+
func testFinalizePasskeySignInFailureWithoutAssertion() async throws {
2607+
setFakeGetAccountProvider()
2608+
let expectation = expectation(description: #function)
2609+
rpcIssuer.respondBlock = {
2610+
try self.rpcIssuer.respond(serverErrorMessage: "INVALID_AUTHENTICATOR_RESPONSE")
2611+
}
2612+
let mock = MockPlatformCredential(
2613+
credentialID: Data(kCredentialID.utf8),
2614+
clientDataJSON: Data(), // Empty or missing data
2615+
authenticatorData: Data(kAuthenticatorData.utf8),
2616+
signature: Data(), // Empty or missing data
2617+
userID: Data(kUserId.utf8)
2618+
)
2619+
Task {
2620+
let request = self.buildFinalizeRequest(mock: mock)
2621+
do {
2622+
_ = try await self.authBackend.call(with: request)
2623+
XCTFail("Expected invalid_authenticator_response error")
2624+
} catch {
2625+
let nsError = error as NSError
2626+
XCTAssertEqual(nsError.code, AuthErrorCode.invalidAuthenticatorResponse.rawValue)
2627+
expectation.fulfill()
2628+
}
2629+
}
2630+
await fulfillment(of: [expectation], timeout: 5)
2631+
}
25132632
}
25142633
#endif

0 commit comments

Comments
 (0)