From 8f8801a58f6d163fea05d7346e1a5633119de796 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sat, 2 Aug 2025 22:05:15 +0530 Subject: [PATCH 1/2] implementing finalize signin for passkey --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 28 ++++++++ .../RPC/FinalizePasskeySignInRequest.swift | 68 +++++++++++++++++++ .../RPC/FinalizePasskeySignInResponse.swift | 33 +++++++++ 3 files changed, 129 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index b83737f9e19..20dbe473f1a 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1669,6 +1669,34 @@ extension Auth: AuthInterop { challenge: challengeInData ) } + + /// finalize sign in with passkey with existing credential assertion. + /// - Parameter platformCredential The existing credential assertion created by device. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func finalizePasskeySignIn(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws + -> AuthDataResult { + let credentialID = platformCredential.credentialID.base64EncodedString() + let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() + let authenticatorData = platformCredential.rawAuthenticatorData.base64EncodedString() + let signature = platformCredential.signature.base64EncodedString() + let userID = platformCredential.userID.base64EncodedString() + let request = FinalizePasskeySignInRequest( + credentialID: credentialID, + clientDataJSON: clientDataJSON, + authenticatorData: authenticatorData, + signature: signature, + userId: userID, + requestConfiguration: requestConfiguration + ) + let response = try await backend.call(with: request) + let user = try await Auth.auth().completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } #endif // MARK: Internal methods diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift new file mode 100644 index 00000000000..5a8d71055a3 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift @@ -0,0 +1,68 @@ +/* + * 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/LICENSE2.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. + */ + +/// The GCIP endpoint for finalizePasskeySignIn rpc +private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize" + +class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeySignInResponse + /// The credential ID + let credentialID: String + /// The CollectedClientData object from the authenticator. + let clientDataJSON: String + /// The AuthenticatorData from the authenticator. + let authenticatorData: String + /// The signature from the authenticator. + let signature: String + /// The user handle + let userId: String + + init(credentialID: String, + clientDataJSON: String, + authenticatorData: String, + signature: String, + userId: String, + requestConfiguration: AuthRequestConfiguration) { + self.credentialID = credentialID + self.clientDataJSON = clientDataJSON + self.authenticatorData = authenticatorData + self.signature = signature + self.userId = userId + super.init( + endpoint: finalizePasskeySignInEndPoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + var postBody: [String: AnyHashable] = [ + "authenticatorAssertionResponse": [ + "credentialId": credentialID, + "authenticatorAssertionResponse": [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": userId, + ], + ] as [String: AnyHashable], + ] + if let tenantID = tenantID { + postBody["tenantId"] = tenantID + } + return postBody + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift new file mode 100644 index 00000000000..439cdb0b6a9 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift @@ -0,0 +1,33 @@ +/* + * 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/LICENSE2.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. + */ + +struct FinalizePasskeySignInResponse: 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 + } +} From ca4510ad1ee9d3599290534807f032aa7430a4b8 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Sun, 3 Aug 2025 02:48:39 +0530 Subject: [PATCH 2/2] adding tests and invalid_authenticator_response error --- .../Sources/Swift/Backend/AuthBackend.swift | 1 + .../Swift/Utilities/AuthErrorUtils.swift | 4 + .../Sources/Swift/Utilities/AuthErrors.swift | 11 ++ FirebaseAuth/Tests/Unit/AuthTests.swift | 119 ++++++++++++++++++ .../FinalizePasskeySignInRequestTests.swift | 116 +++++++++++++++++ .../FinalizePasskeySignInResponseTests.swift | 65 ++++++++++ 6 files changed, 316 insertions(+) create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeySignInResponseTests.swift diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 7a0c39340ae..c82ae7587a6 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -440,6 +440,7 @@ final class AuthBackend: AuthBackendProtocol { return AuthErrorUtils.credentialAlreadyInUseError( message: serverDetailErrorMessage, credential: credential, email: email ) + case "INVALID_AUTHENTICATOR_RESPONSE": return AuthErrorUtils.invalidAuthenticatorResponse() default: if let underlyingErrors = errorDictionary["errors"] as? [[String: String]] { for underlyingError in underlyingErrors { diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 5c78b223ab4..0440e638aa2 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -207,6 +207,10 @@ class AuthErrorUtils { error(code: .invalidRecaptchaToken) } + static func invalidAuthenticatorResponse() -> Error { + error(code: .invalidAuthenticatorResponse) + } + static func unauthorizedDomainError(message: String?) -> Error { error(code: .unauthorizedDomain, message: message) } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index dde29c11ab3..2179b8354d9 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -336,6 +336,10 @@ import Foundation /// Indicates that the reCAPTCHA SDK actions class failed to create. case recaptchaActionCreationFailed = 17210 + /// the authenticator response for passkey signin or enrollment is not parseable, missing required + /// fields, or certain fields are invalid values + case invalidAuthenticatorResponse = 17211 + /// Indicates an error occurred while attempting to access the keychain. case keychainError = 17995 @@ -528,6 +532,8 @@ import Foundation return kErrorSiteKeyMissing case .recaptchaActionCreationFailed: return kErrorRecaptchaActionCreationFailed + case .invalidAuthenticatorResponse: + return kErrorInvalidAuthenticatorResponse } } @@ -719,6 +725,8 @@ import Foundation return "ERROR_RECAPTCHA_SITE_KEY_MISSING" case .recaptchaActionCreationFailed: return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED" + case .invalidAuthenticatorResponse: + return "ERROR_INVALID_AUTHENTICATOR_RESPONSE" } } } @@ -996,3 +1004,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 kErrorInvalidAuthenticatorResponse = + "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." diff --git a/FirebaseAuth/Tests/Unit/AuthTests.swift b/FirebaseAuth/Tests/Unit/AuthTests.swift index 3f2077b0f3c..de5ca08e2ef 100644 --- a/FirebaseAuth/Tests/Unit/AuthTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthTests.swift @@ -29,6 +29,11 @@ class AuthTests: RPCBaseTests { static let kFakeRecaptchaVersion = "RecaptchaVersion" static let kRpId = "FAKE_RP_ID" static let kChallenge = "Y2hhbGxlbmdl" + private let kCredentialID = "FAKE_CREDENTIAL_ID" + private let kClientDataJSON = "FAKE_CLIENT_DATA" + private let kAuthenticatorData = "FAKE_AUTHENTICATOR_DATA" + private let kSignature = "FAKE_SIGNATURE" + private let kUserId = "FAKE_USERID" var auth: Auth! static var testNum = 0 var authDispatcherCallback: (() -> Void)? @@ -2510,5 +2515,119 @@ class AuthTests: RPCBaseTests { } waitForExpectations(timeout: 5) } + + /// Helper mock to simulate platform credential fields + struct MockPlatformCredential { + let credentialID: Data + let clientDataJSON: Data + let authenticatorData: Data + let signature: Data + let userID: Data + } + + private func buildFinalizeRequest(mock: MockPlatformCredential) + -> FinalizePasskeySignInRequest { + return FinalizePasskeySignInRequest( + credentialID: kCredentialID, + clientDataJSON: kClientDataJSON, + authenticatorData: kAuthenticatorData, + signature: kSignature, + userId: kUserId, + requestConfiguration: auth!.requestConfiguration + ) + } + + func testFinalizePasskeysigninSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? FinalizePasskeySignInRequest) + XCTAssertEqual(request.credentialID, self.kCredentialID) + XCTAssertNotNil(request.credentialID) + XCTAssertEqual(request.clientDataJSON, self.kClientDataJSON) + XCTAssertNotNil(request.clientDataJSON) + XCTAssertEqual(request.authenticatorData, self.kAuthenticatorData) + XCTAssertNotNil(request.authenticatorData) + XCTAssertEqual(request.signature, self.kSignature) + XCTAssertNotNil(request.signature) + XCTAssertEqual(request.userId, self.kUserId) + XCTAssertNotNil(request.userId) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + ] + ) + } + let mock = MockPlatformCredential( + credentialID: Data(kCredentialID.utf8), + clientDataJSON: Data(kClientDataJSON.utf8), + authenticatorData: Data(kAuthenticatorData.utf8), + signature: Data(kSignature.utf8), + userID: Data(kUserId.utf8) + ) + Task { + let request = self.buildFinalizeRequest(mock: mock) + _ = try await self.authBackend.call(with: request) + expectation.fulfill() + } + XCTAssertNotNil(AuthTests.kFakeAccessToken) + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeySignInFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + rpcIssuer.respondBlock = { + // Simulate backend error (e.g., OperationNotAllowed) + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + let mock = MockPlatformCredential( + credentialID: Data(kCredentialID.utf8), + clientDataJSON: Data(kClientDataJSON.utf8), + authenticatorData: Data(kAuthenticatorData.utf8), + signature: Data(kSignature.utf8), + userID: Data(kUserId.utf8) + ) + Task { + let request = self.buildFinalizeRequest(mock: mock) + do { + _ = try await self.authBackend.call(with: request) + XCTFail("Expected error but got success") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeySignInFailureWithoutAssertion() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "INVALID_AUTHENTICATOR_RESPONSE") + } + let mock = MockPlatformCredential( + credentialID: Data(kCredentialID.utf8), + clientDataJSON: Data(), // Empty or missing data + authenticatorData: Data(kAuthenticatorData.utf8), + signature: Data(), // Empty or missing data + userID: Data(kUserId.utf8) + ) + Task { + let request = self.buildFinalizeRequest(mock: mock) + do { + _ = try await self.authBackend.call(with: request) + XCTFail("Expected invalid_authenticator_response error") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.code, AuthErrorCode.invalidAuthenticatorResponse.rawValue) + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } } #endif diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift new file mode 100644 index 00000000000..a077f1784fb --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift @@ -0,0 +1,116 @@ +// 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 XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class FinalizePasskeySignInRequestTests: XCTestCase { + private var request: FinalizePasskeySignInRequest! + private var fakeConfig: AuthRequestConfiguration! + + // Fake values + private let kCredentialID = "FAKE_CREDENTIAL_ID" + private let kClientDataJSON = "FAKE_CLIENT_DATA" + private let kAuthenticatorData = "FAKE_AUTHENTICATOR_DATA" + private let kSignature = "FAKE_SIGNATURE" + private let kUserId = "FAKE_USERID" + + 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 testInitWithValidParameters() { + request = FinalizePasskeySignInRequest( + credentialID: kCredentialID, + clientDataJSON: kClientDataJSON, + authenticatorData: kAuthenticatorData, + signature: kSignature, + userId: kUserId, + requestConfiguration: fakeConfig + ) + XCTAssertEqual(request.credentialID, kCredentialID) + XCTAssertEqual(request.clientDataJSON, kClientDataJSON) + XCTAssertEqual(request.authenticatorData, kAuthenticatorData) + XCTAssertEqual(request.signature, kSignature) + XCTAssertEqual(request.userId, kUserId) + XCTAssertEqual(request.endpoint, "accounts/passkeySignIn:finalize") + XCTAssertTrue(request.useIdentityPlatform) + } + + func testUnencodedHTTPRequestBodyWithoutTenantId() { + request = FinalizePasskeySignInRequest( + credentialID: kCredentialID, + clientDataJSON: kClientDataJSON, + authenticatorData: kAuthenticatorData, + signature: kSignature, + userId: kUserId, + requestConfiguration: fakeConfig + ) + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + let authnAssertionResp = body?["authenticatorAssertionResponse"] as? [String: AnyHashable] + XCTAssertNotNil(authnAssertionResp) + XCTAssertEqual(authnAssertionResp?["credentialId"] as? String, kCredentialID) + let innerResponse = + authnAssertionResp?["authenticatorAssertionResponse"] as? [String: AnyHashable] + XCTAssertNotNil(innerResponse) + XCTAssertEqual(innerResponse?["clientDataJSON"] as? String, kClientDataJSON) + XCTAssertEqual(innerResponse?["authenticatorData"] as? String, kAuthenticatorData) + XCTAssertEqual(innerResponse?["signature"] as? String, kSignature) + XCTAssertEqual(innerResponse?["userHandle"] as? String, kUserId) + XCTAssertNil(body?["tenantId"]) + } + + func testUnencodedHTTPRequestBodyWithTenantId() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = "FAKE_API_KEY" + options.projectID = "myProjectID" + let fakeApp = FirebaseApp(instanceWithName: "testApp", options: options) + let fakeAuth = Auth(app: fakeApp) + fakeAuth.tenantID = "TEST_TENANT" + let configWithTenant = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID", + auth: fakeAuth + ) + request = FinalizePasskeySignInRequest( + credentialID: kCredentialID, + clientDataJSON: kClientDataJSON, + authenticatorData: kAuthenticatorData, + signature: kSignature, + userId: kUserId, + requestConfiguration: configWithTenant + ) + + let body = request.unencodedHTTPRequestBody + XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInResponseTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInResponseTests.swift new file mode 100644 index 00000000000..6fe1d0c926e --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInResponseTests.swift @@ -0,0 +1,65 @@ +// 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 FinalizePasskeySignInResponseTests: XCTestCase { + func makeValidDictionary() -> [String: AnyHashable] { + return [ + "idToken": "FAKE_ID_TOKEN", + "refreshToken": "FAKE_REFRESH_TOKEN", + ] + } + + func testInitWithValidDictionary() throws { + let response = try FinalizePasskeySignInResponse(dictionary: makeValidDictionary()) + XCTAssertEqual(response.idToken, "FAKE_ID_TOKEN") + XCTAssertEqual(response.refreshToken, "FAKE_REFRESH_TOKEN") + } + + func testInitWithMissingIdToken() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "idToken") + XCTAssertThrowsError(try FinalizePasskeySignInResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingRefreshToken() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "refreshToken") + XCTAssertThrowsError(try FinalizePasskeySignInResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithEmptyDictionary() { + let emptyDict: [String: AnyHashable] = [:] + XCTAssertThrowsError(try FinalizePasskeySignInResponse(dictionary: emptyDict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + } + +#endif