From 74761d18e4f0dbf43a214c4a635e7de048854f27 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 14 Aug 2025 11:08:16 +0530 Subject: [PATCH 01/13] implementing start passkey sign-in (#15168) --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 58 ++++++ .../Sources/Swift/Backend/AuthBackend.swift | 1 + .../RPC/FinalizePasskeySignInRequest.swift | 68 +++++++ .../RPC/FinalizePasskeySignInResponse.swift | 33 ++++ .../RPC/StartPasskeySignInRequest.swift | 36 ++++ .../RPC/StartPasskeySignInResponse.swift | 33 ++++ .../Swift/Utilities/AuthErrorUtils.swift | 4 + .../Sources/Swift/Utilities/AuthErrors.swift | 11 ++ FirebaseAuth/Tests/Unit/AuthTests.swift | 176 ++++++++++++++++++ .../FinalizePasskeySignInRequestTests.swift | 116 ++++++++++++ .../FinalizePasskeySignInResponseTests.swift | 65 +++++++ .../Unit/StartPasskeySignInRequestTests.swift | 75 ++++++++ .../StartPasskeySignInResponseTests.swift | 75 ++++++++ 13 files changed, 751 insertions(+) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeySignInResponseTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index d198f5418f5..20dbe473f1a 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -29,6 +29,10 @@ import FirebaseCoreExtension import UIKit #endif +#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + import AuthenticationServices +#endif + // Export the deprecated Objective-C defined globals and typedefs. #if SWIFT_PACKAGE @_exported import FirebaseAuthInternal @@ -1641,6 +1645,60 @@ extension Auth: AuthInterop { public static let authStateDidChangeNotification = NSNotification.Name(rawValue: "FIRAuthStateDidChangeNotification") + // MARK: Passkey Implementation + + #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + + /// starts sign in with passkey retrieving challenge from GCIP and create an assertion request. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func startPasskeySignIn() async throws -> + ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) + let response = try await backend.call(with: request) + 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."] + ) + } + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID + ) + return provider.createCredentialAssertionRequest( + 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 init(app: FirebaseApp, 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/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 + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift new file mode 100644 index 00000000000..4b317460681 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -0,0 +1,36 @@ +// 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. + +/// The GCIP endpoint for startPasskeySignIn rpc +private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start" + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeySignInResponse + + init(requestConfiguration: AuthRequestConfiguration) { + super.init( + endpoint: startPasskeySignInEndpoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + guard let tenantID = tenantID else { + return nil + } + return ["tenantId": tenantID] + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift new file mode 100644 index 00000000000..096e674ee56 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.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/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. + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +struct StartPasskeySignInResponse: AuthRPCResponse { + /// The RP ID of the FIDO Relying Party + let rpID: String + /// The FIDO challenge + let challenge: String + + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary["credentialRequestOptions"] as? [String: Any] else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let rpID = options["rpId"] as? String, + let challenge = options["challenge"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.rpID = rpID + self.challenge = challenge + } +} 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 5ae1d522108..de5ca08e2ef 100644 --- a/FirebaseAuth/Tests/Unit/AuthTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthTests.swift @@ -27,6 +27,13 @@ class AuthTests: RPCBaseTests { static let kFakeAPIKey = "FAKE_API_KEY" static let kFakeRecaptchaResponse = "RecaptchaResponse" 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)? @@ -2455,3 +2462,172 @@ class AuthTests: RPCBaseTests { XCTAssertEqual(user.providerData.count, 0) } } + +// MARK: Passkey Sign-In Tests + +#if os(iOS) + import AuthenticationServices + + @available(iOS 15.0, *) + extension AuthTests { + func testStartPasskeySignInSuccess() throws { + let expectation = self.expectation(description: #function) + let expectedChallenge = AuthTests.kChallenge // base64 string + let expectedRpId = AuthTests.kRpId + let expectedChallengeData = Data(base64Encoded: expectedChallenge)! + rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer.request as? StartPasskeySignInRequest) + XCTAssertEqual(request.apiKey, AuthTests.kFakeAPIKey) + return try self.rpcIssuer.respond(withJSON: [ + "credentialRequestOptions": [ + "rpId": expectedRpId, + "challenge": expectedChallenge, + ], + ]) + } + Task { + do { + let assertionRequest = try await self.auth.startPasskeySignIn() + XCTAssertEqual(assertionRequest.challenge, expectedChallengeData) + XCTAssertEqual(assertionRequest.relyingPartyIdentifier, expectedRpId) + expectation.fulfill() + } catch { + XCTFail("Unexpected error: \(error)") + } + } + waitForExpectations(timeout: 5) + } + + func testStartPasskeySignInFailure() throws { + let expectation = self.expectation(description: #function) + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await self.auth.startPasskeySignIn() + XCTFail("Expected error from backend but got success") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + 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 diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift new file mode 100644 index 00000000000..40fbe92f26f --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift @@ -0,0 +1,75 @@ +// 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, *) + final class StartPasskeySignInRequestTests: XCTestCase { + private var config: AuthRequestConfiguration! + + override func setUp() { + super.setUp() + config = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID" + ) + } + + override func tearDown() { + config = nil + super.tearDown() + } + + func testInit_SetsEndpointAndConfig() { + let request = StartPasskeySignInRequest(requestConfiguration: config) + XCTAssertEqual(request.endpoint, "accounts/passkeySignIn:start") + XCTAssertTrue(request.useIdentityPlatform) + XCTAssertEqual(request.requestConfiguration().apiKey, "FAKE_API_KEY") + XCTAssertEqual(request.requestConfiguration().appID, "FAKE_APP_ID") + } + + func testUnencodedHTTPRequestBody_WithTenantId() { + // 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 + ) + _ = AuthRequestConfiguration(apiKey: "apiKey", appID: "appId") + let request = StartPasskeySignInRequest( + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertEqual(body!["tenantId"], "TEST_TENANT") + } + + func testUnencodedHTTPRequestBody_WithoutTenantId() { + let request = StartPasskeySignInRequest(requestConfiguration: config) + XCTAssertNil(request.unencodedHTTPRequestBody) + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift new file mode 100644 index 00000000000..b89ebcece1e --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInResponseTests.swift @@ -0,0 +1,75 @@ +// 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, *) + final class StartPasskeySignInResponseTests: XCTestCase { + private func makeValidDictionary() -> [String: AnyHashable] { + return [ + "credentialRequestOptions": [ + "rpId": "FAKE_RPID", + "challenge": "FAKE_CHALLENGE", + ] as [String: AnyHashable], + ] + } + + func testInitWithValidDictionary() throws { + let dict = makeValidDictionary() + let response = try StartPasskeySignInResponse(dictionary: dict) + XCTAssertEqual(response.rpID, "FAKE_RPID") + XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") + } + + /// Helper function to remove nested field from 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 testInitWithInvalidDictionary() throws { + struct TestCase { + let name: String + let removeFieldPath: [String] + } + let cases: [TestCase] = [ + .init(name: "Missing credential options", removeFieldPath: ["credentialRequestOptions"]), + .init(name: "Missing rpId", removeFieldPath: ["credentialRequestOptions", "rpId"]), + .init( + name: "Missing challenge", + removeFieldPath: ["credentialRequestOptions", "challenge"] + ), + ] + for testCase in cases { + var dict = makeValidDictionary() + removeField(&dict, keyPath: testCase.removeFieldPath) + XCTAssertThrowsError(try StartPasskeySignInResponse(dictionary: dict), + testCase.name) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + } + } + +#endif From 006848e3fbd129fd58db02b2ec9346af2f6d6b40 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 14 Aug 2025 11:12:09 +0530 Subject: [PATCH 02/13] implementing start passkey enrollment (#15162) --- .../Sources/Swift/Backend/AuthBackend.swift | 1 + .../FinalizePasskeyEnrollmentRequest.swift | 72 +++++ .../FinalizePasskeyEnrollmentResponse.swift | 34 ++ .../Backend/RPC/GetAccountInfoResponse.swift | 8 + .../Swift/Backend/RPC/Proto/PasskeyInfo.swift | 40 +++ .../Backend/RPC/SetAccountInfoRequest.swift | 8 + .../RPC/StartPasskeyEnrollmentRequest.swift | 46 +++ .../RPC/StartPasskeyEnrollmentResponse.swift | 45 +++ FirebaseAuth/Sources/Swift/User/User.swift | 117 +++++++ .../Swift/Utilities/AuthErrorUtils.swift | 4 + .../Sources/Swift/Utilities/AuthErrors.swift | 11 +- ...inalizePasskeyEnrollmentRequestTests.swift | 114 +++++++ ...nalizePasskeyEnrollmentResponseTests.swift | 60 ++++ .../Tests/Unit/GetAccountInfoTests.swift | 47 +++ .../Tests/Unit/SetAccountInfoTests.swift | 10 + .../StartPasskeyEnrollmentRequestTests.swift | 88 +++++ .../StartPasskeyEnrollmentResponseTests.swift | 99 ++++++ FirebaseAuth/Tests/Unit/UserTests.swift | 303 ++++++++++++++++++ 18 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift create mode 100644 FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift create mode 100644 FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index c82ae7587a6..8b88ec0693c 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -355,6 +355,7 @@ final class AuthBackend: AuthBackendProtocol { .missingIosBundleIDError(message: serverDetailErrorMessage) case "MISSING_ANDROID_PACKAGE_NAME": return AuthErrorUtils .missingAndroidPackageNameError(message: serverDetailErrorMessage) + case "PASSKEY_ENROLLMENT_NOT_FOUND": return AuthErrorUtils.missingPasskeyEnrollment() case "UNAUTHORIZED_DOMAIN": return AuthErrorUtils .unauthorizedDomainError(message: serverDetailErrorMessage) case "INVALID_CONTINUE_URI": return AuthErrorUtils diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..c189856c48d --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift @@ -0,0 +1,72 @@ +// 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 finalizePasskeyEnrollment rpc +private let finalizePasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:finalize" + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeyEnrollmentResponse + + /// The raw user access token. + let idToken: String + /// The passkey name. + let name: String + /// The credential ID. + let credentialID: String + /// The CollectedClientData object from the authenticator. + let clientDataJSON: String + /// The attestation object from the authenticator. + let attestationObject: String + + init(idToken: String, + name: String, + credentialID: String, + clientDataJSON: String, + attestationObject: String, + requestConfiguration: AuthRequestConfiguration) { + self.idToken = idToken + self.name = name + self.credentialID = credentialID + self.clientDataJSON = clientDataJSON + self.attestationObject = attestationObject + super.init( + endpoint: finalizePasskeyEnrollmentEndPoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + var postBody: [String: AnyHashable] = [ + "idToken": idToken, + "name": name, + ] + let authAttestationResponse: [String: AnyHashable] = [ + "clientDataJSON": clientDataJSON, + "attestationObject": attestationObject, + ] + let authRegistrationResponse: [String: AnyHashable] = [ + "id": credentialID, + "response": authAttestationResponse, + ] + postBody["authenticatorRegistrationResponse"] = authRegistrationResponse + if let tenantId = tenantID { + postBody["tenantId"] = tenantId + } + return postBody + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..16fe2c78d6d --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift @@ -0,0 +1,34 @@ +// 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 FinalizePasskeyEnrollmentResponse: 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 + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 4fb5795bcd5..63d419e740e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -92,6 +92,9 @@ struct GetAccountInfoResponse: AuthRPCResponse { let mfaEnrollments: [AuthProtoMFAEnrollment]? + /// A list of the user’s enrolled passkeys. + let enrolledPasskeys: [PasskeyInfo]? + /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. init(dictionary: [String: Any]) { @@ -133,6 +136,11 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } + if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] { + enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) } + } else { + enrolledPasskeys = nil + } } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift new file mode 100644 index 00000000000..13f658c5749 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -0,0 +1,40 @@ +// 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 + +public final class PasskeyInfo: NSObject, AuthProto, NSSecureCoding, Sendable { + /// The display name for this passkey. + public let name: String? + /// The credential ID used by the server. + public let credentialID: String? + required init(dictionary: [String: AnyHashable]) { + name = dictionary["name"] as? String + credentialID = dictionary["credentialId"] as? String + } + + // NSSecureCoding + public static var supportsSecureCoding: Bool { true } + + public func encode(with coder: NSCoder) { + coder.encode(name, forKey: "name") + coder.encode(credentialID, forKey: "credentialId") + } + + public required init?(coder: NSCoder) { + name = coder.decodeObject(of: NSString.self, forKey: "name") as String? + credentialID = coder.decodeObject(of: NSString.self, forKey: "credentialId") as String? + super.init() + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift index 5e310d4a656..951eed9d044 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift @@ -73,6 +73,8 @@ private let kDeleteProvidersKey = "deleteProvider" /// The key for the "returnSecureToken" value in the request. private let kReturnSecureTokenKey = "returnSecureToken" +private let kDeletePasskeysKey = "deletePasskey" + /// The key for the tenant id value in the request. private let kTenantIDKey = "tenantId" @@ -131,6 +133,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { /// The default value is `true` . var returnSecureToken: Bool = true + /// The list of credential IDs of the passkeys to be deleted. + var deletePasskeys: [String]? = nil + init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) { self.accessToken = accessToken super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration) @@ -183,6 +188,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { if returnSecureToken { postBody[kReturnSecureTokenKey] = true } + if let deletePasskeys { + postBody[kDeletePasskeysKey] = deletePasskeys + } if let tenantID { postBody[kTenantIDKey] = tenantID } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..9a43ce09480 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift @@ -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 + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..5139e0a2eeb --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift @@ -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 + } +} diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 4ef324e177c..ba6036a5528 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -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 {} @@ -63,6 +67,7 @@ extension User: NSSecureCoding {} /// /// This property is available on iOS only. @objc public private(set) var multiFactor: MultiFactor + public private(set) var enrolledPasskeys: [PasskeyInfo]? #endif /// [Deprecated] Updates the email address for the user. @@ -1047,6 +1052,110 @@ 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 + ) + } + + /// Finalize the passkey enrollment with the platfrom public key credential. + /// - Parameter platformCredential: The name for the passkey to be created. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws + -> AuthDataResult { + let credentialID = platformCredential.credentialID.base64EncodedString() + let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() + let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() + + let request = FinalizePasskeyEnrollmentRequest( + idToken: rawAccessToken(), + name: passkeyName ?? "Unnamed account (Apple)", + credentialID: credentialID, + clientDataJSON: clientDataJSON, + attestationObject: attestationObject, + requestConfiguration: auth!.requestConfiguration + ) + let response = try await backend.call(with: request) + let user = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func unenrollPasskey(withCredentialID credentialID: String) async throws { + guard !credentialID.isEmpty else { + throw AuthErrorCode.missingPasskeyEnrollment + } + let request = SetAccountInfoRequest( + requestConfiguration: auth!.requestConfiguration + ) + request.deletePasskeys = [credentialID] + request.accessToken = rawAccessToken() + let response = try await backend.call(with: request) + _ = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + } + #endif + // MARK: Internal implementations below func rawAccessToken() -> String { @@ -1068,6 +1177,7 @@ extension User: NSSecureCoding {} tenantID = nil #if os(iOS) multiFactor = MultiFactor(withMFAEnrollments: []) + enrolledPasskeys = [] #endif uid = "" hasEmailPasswordCredential = false @@ -1302,6 +1412,7 @@ extension User: NSSecureCoding {} multiFactor = MultiFactor(withMFAEnrollments: enrollments) } multiFactor.user = self + enrolledPasskeys = user.enrolledPasskeys ?? [] #endif } @@ -1698,6 +1809,7 @@ extension User: NSSecureCoding {} private let kMetadataCodingKey = "metadata" private let kMultiFactorCodingKey = "multiFactor" private let kTenantIDCodingKey = "tenantID" + private let kEnrolledPasskeysKey = "passkeys" public static let supportsSecureCoding = true @@ -1720,6 +1832,7 @@ extension User: NSSecureCoding {} coder.encode(tokenService, forKey: kTokenServiceCodingKey) #if os(iOS) coder.encode(multiFactor, forKey: kMultiFactorCodingKey) + coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey) #endif } @@ -1749,6 +1862,9 @@ extension User: NSSecureCoding {} let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String #if os(iOS) let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey) + let passkeyAllowed: [AnyClass] = [NSArray.self, PasskeyInfo.self] + let passkeys = coder.decodeObject(of: passkeyAllowed, + forKey: kEnrolledPasskeysKey) as? [PasskeyInfo] #endif self.tokenService = tokenService uid = userID @@ -1782,6 +1898,7 @@ extension User: NSSecureCoding {} self.multiFactor = multiFactor ?? MultiFactor() super.init() multiFactor?.user = self + enrolledPasskeys = passkeys ?? [] #endif } } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 0440e638aa2..15a1317aa1d 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -239,6 +239,10 @@ class AuthErrorUtils { error(code: .missingVerificationCode, message: message) } + static func missingPasskeyEnrollment() -> Error { + error(code: .missingPasskeyEnrollment) + } + static func invalidVerificationCodeError(message: String?) -> Error { error(code: .invalidVerificationCode, message: message) } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index 2179b8354d9..10c98aa0a21 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -335,11 +335,13 @@ 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 + case missingPasskeyEnrollment = 17212 + /// Indicates an error occurred while attempting to access the keychain. case keychainError = 17995 @@ -534,6 +536,8 @@ import Foundation return kErrorRecaptchaActionCreationFailed case .invalidAuthenticatorResponse: return kErrorInvalidAuthenticatorResponse + case .missingPasskeyEnrollment: + return kErrorMissingPasskeyEnrollment } } @@ -727,6 +731,8 @@ import Foundation return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED" case .invalidAuthenticatorResponse: return "ERROR_INVALID_AUTHENTICATOR_RESPONSE" + case .missingPasskeyEnrollment: + return "ERROR_PASSKEY_ENROLLMENT_NOT_FOUND" } } } @@ -1007,3 +1013,6 @@ private let kErrorRecaptchaActionCreationFailed = 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." + +private let kErrorMissingPasskeyEnrollment = + "Cannot find the passkey linked to the current account." diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..e9a0504c3b8 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift @@ -0,0 +1,114 @@ +// 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 FinalizePasskeyEnrollmentRequestTests: XCTestCase { + private var request: FinalizePasskeyEnrollmentRequest! + 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 testInitWithValidParameters() { + request = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: fakeConfig + ) + + XCTAssertEqual(request.idToken, "ID_TOKEN") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertEqual(request.credentialID, "CRED_ID") + XCTAssertEqual(request.clientDataJSON, "CLIENT_JSON") + XCTAssertEqual(request.attestationObject, "ATTEST_OBJ") + XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:finalize") + XCTAssertTrue(request.useIdentityPlatform) + } + + func testUnencodedHTTPRequestBodyWithoutTenantId() { + request = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: fakeConfig + ) + + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "ID_TOKEN") + XCTAssertEqual(body?["name"] as? String, "MyPasskey") + + let authReg = body?["authenticatorRegistrationResponse"] as? [String: AnyHashable] + XCTAssertNotNil(authReg) + XCTAssertEqual(authReg?["id"] as? String, "CRED_ID") + + let authResp = authReg?["response"] as? [String: AnyHashable] + XCTAssertEqual(authResp?["clientDataJSON"] as? String, "CLIENT_JSON") + XCTAssertEqual(authResp?["attestationObject"] as? String, "ATTEST_OBJ") + + 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 = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..49d7625fc12 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift @@ -0,0 +1,60 @@ +// 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 FinalizePasskeyEnrollmentResponseTests: XCTestCase { + private func makeValidDictionary() -> [String: AnyHashable] { + return [ + "idToken": "FAKE_ID_TOKEN" as AnyHashable, + "refreshToken": "FAKE_REFRESH_TOKEN" as AnyHashable, + ] + } + + func testInitWithValidDictionary() throws { + let response = try FinalizePasskeyEnrollmentResponse( + dictionary: makeValidDictionary() + ) + XCTAssertEqual(response.idToken, "FAKE_ID_TOKEN") + XCTAssertEqual(response.refreshToken, "FAKE_REFRESH_TOKEN") + } + + func testInitWithMissingIdTokenThrowsError() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "idToken") + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: dict) + ) + } + + func testInitWithMissingRefreshTokenThrowsError() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "refreshToken") + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: dict) + ) + } + + func testInitWithEmptyDictionaryThrowsError() { + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: [:]) + ) + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index ec5eba4e2d0..764fdaad55d 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -80,6 +80,15 @@ class GetAccountInfoTests: RPCBaseTests { let kEmailVerifiedKey = "emailVerified" let kLocalIDKey = "localId" let kTestLocalID = "testLocalId" + let kPasskeysKey = "passkeys" + + // Fake PasskeyInfo + let testCredentialId = "credential_id" + let testPasskeyName = "Test Passkey" + let passkeys = [[ + "credentialId": testCredentialId, + "name": testPasskeyName, + ]] let usersIn = [[ kProviderUserInfoKey: [[ @@ -95,6 +104,7 @@ class GetAccountInfoTests: RPCBaseTests { kPhotoUrlKey: kTestPhotoURL, kEmailVerifiedKey: true, kPasswordHashKey: kTestPasswordHash, + kPasskeysKey: passkeys, ] as [String: Any]] let rpcIssuer = try XCTUnwrap(self.rpcIssuer) @@ -119,6 +129,43 @@ class GetAccountInfoTests: RPCBaseTests { XCTAssertEqual(firstProviderUser.email, kTestEmail) XCTAssertEqual(firstProviderUser.providerID, kTestProviderID) XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 1) + XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId) + XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName) + } + + func testInitWithMultipleEnrolledPasskeys() throws { + let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"] + let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"] + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + "passkeys": [passkey1, passkey2], + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 2) + XCTAssertEqual(enrolledPasskeys[0].name, "passkey1") + XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1") + XCTAssertEqual(enrolledPasskeys[1].name, "passkey2") + XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2") + } + + func testInitWithNoEnrolledPasskeys() throws { + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + // No "passkeys" present + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + XCTAssertNil(firstUser.enrolledPasskeys) } private func makeGetAccountInfoRequest() -> GetAccountInfoRequest { diff --git a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift index b3ccb3e8ad1..7283c2e2c71 100644 --- a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift @@ -64,6 +64,8 @@ class SetAccountInfoTests: RPCBaseTests { let kTestDeleteProviders = "TestDeleteProviders" let kReturnSecureTokenKey = "returnSecureToken" let kTestAccessToken = "accessToken" + let kDeletePasskeysKey = "deletePasskey" + let kDeletePasskey = "credential_id" let kExpectedAPIURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" @@ -82,6 +84,7 @@ class SetAccountInfoTests: RPCBaseTests { request.captchaResponse = kTestCaptchaResponse request.deleteAttributes = [kTestDeleteAttributes] request.deleteProviders = [kTestDeleteProviders] + request.deletePasskeys = [kDeletePasskey] try await checkRequest( request: request, @@ -105,6 +108,7 @@ class SetAccountInfoTests: RPCBaseTests { XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes]) XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders]) XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey]) } func testSetAccountInfoErrors() async throws { @@ -122,6 +126,7 @@ class SetAccountInfoTests: RPCBaseTests { let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL" let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters" let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters" + let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND" try await checkBackendError( request: setAccountInfoRequest(), @@ -189,6 +194,11 @@ class SetAccountInfoTests: RPCBaseTests { message: kInvalidRecipientEmailErrorMessage, errorCode: AuthErrorCode.invalidRecipientEmail ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidCredentialIdForPasskeyUnenroll, + errorCode: AuthErrorCode.missingPasskeyEnrollment + ) } /** @fn testSuccessfulSetAccountInfoResponse diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..345425fe2b6 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -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 diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..a167ce5d6af --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -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 diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index c610e04a0bc..138926eee37 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -1891,3 +1891,306 @@ class UserTests: RPCBaseTests { } } } + +#if os(iOS) + import AuthenticationServices + + @available(iOS 15.0, *) + extension UserTests { + func testStartPasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + do { + // Mock backend response for StartPasskeyEnrollment + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? StartPasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], // Base64 userID + "challenge": "Q2hhbGxlbmdl", // Base64 challenge + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "MyPasskey") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertNotNil(request.challenge) + XCTAssertNotNil(request.userID) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithNilNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: nil) + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithEmptyNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "") + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await user.startPasskeyEnrollment(withName: "FailCase") + XCTFail("Expected to throw error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + /// Helper mock to simulate platform credential fields + struct MockPlatformCredential { + let credentialID: Data + let rawClientDataJSON: Data + let rawAttestationObject: Data? + } + + /// Helper to build FinalizePasskeyEnrollmentRequest manually + private func buildFinalizeRequest(user: User, + mock: MockPlatformCredential) + -> FinalizePasskeyEnrollmentRequest { + return FinalizePasskeyEnrollmentRequest( + idToken: RPCBaseTests.kFakeAccessToken, + name: "MyPasskey", + credentialID: mock.credentialID.base64EncodedString(), + clientDataJSON: mock.rawClientDataJSON.base64EncodedString(), + attestationObject: mock.rawAttestationObject?.base64EncodedString() ?? "", + requestConfiguration: auth!.requestConfiguration + ) + } + + func testFinalizePasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + // Mock backend response + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? FinalizePasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + XCTAssertNotNil(request.credentialID) + XCTAssertNotNil(request.clientDataJSON) + XCTAssertNotNil(request.attestationObject) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + ] + ) + } + + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: Data("attestation".utf8) + ) + + Task { + let request = self.buildFinalizeRequest(user: user, mock: mock) + let response = try await self.authBackend.call(with: request) + let userResult = try await self.auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + XCTAssertEqual(userResult.refreshToken, self.kRefreshToken) + expectation.fulfill() + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeyEnrollmentFailureWithInvalidToken() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "INVALID_ID_TOKEN") + } + + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: Data("attestation".utf8) + ) + + Task { + do { + let request = self.buildFinalizeRequest(user: user, mock: mock) + _ = try await self.authBackend.call(with: request) + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.invalidUserToken.rawValue) + expectation.fulfill() + } + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeyEnrollmentFailureWithoutAttestation() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "MISSING_ATTESTATION_OBJECT") + } + + // Missing attestationObject + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: nil + ) + + Task { + do { + let request = self.buildFinalizeRequest(user: user, mock: mock) + _ = try await self.authBackend.call(with: request) + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.internalError.rawValue) + expectation.fulfill() + } + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeySuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? SetAccountInfoRequest) + XCTAssertEqual(request.deletePasskeys, ["testCredentialID"]) + XCTAssertEqual(request.accessToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + "approximateExpirationDate": "\(Date().timeIntervalSince1970 * 1000)", + ] + ) + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "testCredentialID") + expectation.fulfill() + } catch { + XCTFail("Should not throw error: \(error)") + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeyNotFoundFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer + .respond( + serverErrorMessage: "PASSKEY_ENROLLMENT_NOT_FOUND" + ) + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "invalidCredentialID") + XCTFail("Expected error not thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, AuthErrorDomain) + XCTAssertEqual( + error.localizedDescription, + "Cannot find the passkey linked to the current account." + ) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeyFailure_EmptyCredentialID() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + Task { + do { + try await user.unenrollPasskey(withCredentialID: "") + XCTFail("Expected error for empty credentialID") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.missingPasskeyEnrollment.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 2) + } + } +#endif From d13d7fee2d7c103259ddabfc5bfe33ffbeaa2494 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 14 Aug 2025 11:52:50 +0530 Subject: [PATCH 03/13] lint fixes --- FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift index 10c98aa0a21..d4a966af4b9 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -335,7 +335,7 @@ 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 From d68507bac5acffbc58f0292b98942e21de94fb30 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Thu, 14 Aug 2025 12:04:32 +0530 Subject: [PATCH 04/13] adding version checks --- .../Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift | 1 + .../Swift/Backend/RPC/FinalizePasskeySignInResponse.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift index 5a8d71055a3..1b5cc3c68d1 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift @@ -17,6 +17,7 @@ /// The GCIP endpoint for finalizePasskeySignIn rpc private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize" +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { typealias Response = FinalizePasskeySignInResponse /// The credential ID diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift index 439cdb0b6a9..f172a9a3259 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift @@ -14,6 +14,7 @@ * limitations under the License. */ +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) struct FinalizePasskeySignInResponse: AuthRPCResponse { /// The user raw access token. let idToken: String From 452c24f7638a4a844d452cc71caa30a29a2b4f4c Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 13:02:10 +0530 Subject: [PATCH 05/13] Fixing errors in sdk --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 2 ++ .../RPC/FinalizePasskeySignInRequest.swift | 25 ++++++++------- .../RPC/FinalizePasskeySignInResponse.swift | 2 ++ .../Backend/RPC/GetAccountInfoResponse.swift | 2 +- .../RPC/StartPasskeySignInRequest.swift | 4 ++- .../RPC/StartPasskeySignInResponse.swift | 2 ++ FirebaseAuth/Sources/Swift/User/User.swift | 31 +++++++++++++------ 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 20dbe473f1a..226d0ea3416 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1695,6 +1695,8 @@ extension Auth: AuthInterop { refreshToken: response.refreshToken, anonymous: false ) + try await user.reload() + try await updateCurrentUser(user) return AuthDataResult(withUser: user, additionalUserInfo: nil) } #endif diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift index 1b5cc3c68d1..771849ec4c0 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import Foundation /// The GCIP endpoint for finalizePasskeySignIn rpc private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize" @@ -50,19 +51,21 @@ class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { } var unencodedHTTPRequestBody: [String: AnyHashable]? { + let assertion: [String: AnyHashable] = [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": userId, + ] + let authResponse: [String: AnyHashable] = [ + "id": credentialID, + "response": assertion, + ] var postBody: [String: AnyHashable] = [ - "authenticatorAssertionResponse": [ - "credentialId": credentialID, - "authenticatorAssertionResponse": [ - "clientDataJSON": clientDataJSON, - "authenticatorData": authenticatorData, - "signature": signature, - "userHandle": userId, - ], - ] as [String: AnyHashable], + "authenticatorAuthenticationResponse": authResponse, ] - if let tenantID = tenantID { - postBody["tenantId"] = tenantID + if let tenant = tenantID { + postBody["tenantId"] = tenant } return postBody } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift index f172a9a3259..6d0b772ee9c 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift @@ -14,6 +14,8 @@ * limitations under the License. */ +import Foundation + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) struct FinalizePasskeySignInResponse: AuthRPCResponse { /// The user raw access token. diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 63d419e740e..012d4cda950 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -136,7 +136,7 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } - if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] { + if let passkeyEnrollmentData = dictionary["passkeyInfo"] as? [[String: AnyHashable]] { enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) } } else { enrolledPasskeys = nil diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift index 4b317460681..e36dc40caf8 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + /// The GCIP endpoint for startPasskeySignIn rpc private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start" @@ -29,7 +31,7 @@ class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { var unencodedHTTPRequestBody: [String: AnyHashable]? { guard let tenantID = tenantID else { - return nil + return [:] } return ["tenantId": tenantID] } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift index 096e674ee56..7461425312e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -12,6 +12,8 @@ // 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 StartPasskeySignInResponse: AuthRPCResponse { /// The RP ID of the FIDO Relying Party diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index ba6036a5528..c2378fcad1c 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1079,12 +1079,7 @@ extension User: NSSecureCoding {} 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"] - ) } + passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name guard let challengeInData = Data(base64Encoded: response.challenge) else { throw NSError( domain: AuthErrorDomain, @@ -1104,7 +1099,7 @@ extension User: NSSecureCoding {} ) return provider.createCredentialRegistrationRequest( challenge: challengeInData, - name: passkeyName, + name: passkeyName ?? defaultPasskeyName, userID: userIdInData ) } @@ -1114,13 +1109,26 @@ extension User: NSSecureCoding {} @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws -> AuthDataResult { + guard + !platformCredential.credentialID.isEmpty, + !platformCredential.rawClientDataJSON.isEmpty, + let attestation = platformCredential.rawAttestationObject, + !attestation.isEmpty + else { + throw NSError( + domain: AuthErrorDomain, + code: AuthErrorCode.internalError.rawValue, + userInfo: [NSLocalizedDescriptionKey: + "Invalid platform credential: missing credentialID, clientDataJSON, or attestationObject."] + ) + } let credentialID = platformCredential.credentialID.base64EncodedString() let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() let request = FinalizePasskeyEnrollmentRequest( idToken: rawAccessToken(), - name: passkeyName ?? "Unnamed account (Apple)", + name: passkeyName ?? defaultPasskeyName, credentialID: credentialID, clientDataJSON: clientDataJSON, attestationObject: attestationObject, @@ -1133,6 +1141,9 @@ extension User: NSSecureCoding {} refreshToken: response.refreshToken, anonymous: false ) + defer { self.passkeyName = nil } + try await user.reload() + try await auth!.updateCurrentUser(user) return AuthDataResult(withUser: user, additionalUserInfo: nil) } @@ -1147,12 +1158,14 @@ extension User: NSSecureCoding {} request.deletePasskeys = [credentialID] request.accessToken = rawAccessToken() let response = try await backend.call(with: request) - _ = try await auth!.completeSignIn( + let user = try await auth!.completeSignIn( withAccessToken: response.idToken, accessTokenExpirationDate: response.approximateExpirationDate, refreshToken: response.refreshToken, anonymous: false ) + try await user.reload() + try await auth!.updateCurrentUser(user) } #endif From 8352157d716c6def086203f7e8ba5cab328e604b Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 13:32:47 +0530 Subject: [PATCH 06/13] updating tests with recent changes --- .../FinalizePasskeySignInRequestTests.swift | 62 ++++++++++++------- .../Tests/Unit/GetAccountInfoTests.swift | 6 +- .../Unit/StartPasskeySignInRequestTests.swift | 2 +- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift index a077f1784fb..277ba0f36b7 100644 --- a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift @@ -71,33 +71,38 @@ 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"]) + guard let postBody = request.unencodedHTTPRequestBody else { + return XCTFail("Body should not be nil") + } + guard let authnAssertionResp = + postBody["authenticatorAuthenticationResponse"] as? [String: AnyHashable] else { + return XCTFail("Missing authenticatorAuthenticationResponse") + } + XCTAssertEqual(authnAssertionResp["id"] as? String, kCredentialID) + guard let response = authnAssertionResp["response"] as? [String: AnyHashable] else { + return XCTFail("Missing nested response dictionary") + } + XCTAssertEqual(response["clientDataJSON"] as? String, kClientDataJSON) + XCTAssertEqual(response["authenticatorData"] as? String, kAuthenticatorData) + XCTAssertEqual(response["signature"] as? String, kSignature) + XCTAssertEqual(response["userHandle"] as? String, kUserId) + XCTAssertNil(postBody["tenantId"]) // no tenant by default } func testUnencodedHTTPRequestBodyWithTenantId() { - let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", - gcmSenderID: "00000000000000000-00000000000-000000000") + 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 app = FirebaseApp(instanceWithName: "testApp", options: options) + let auth = Auth(app: app) + auth.tenantID = "TEST_TENANT" let configWithTenant = AuthRequestConfiguration( apiKey: "FAKE_API_KEY", appID: "FAKE_APP_ID", - auth: fakeAuth + auth: auth ) request = FinalizePasskeySignInRequest( credentialID: kCredentialID, @@ -107,9 +112,22 @@ userId: kUserId, requestConfiguration: configWithTenant ) - - let body = request.unencodedHTTPRequestBody - XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + guard let body = request.unencodedHTTPRequestBody else { + return XCTFail("Body should not be nil") + } + XCTAssertEqual(body["tenantId"] as? String, "TEST_TENANT") + // also checking structure remains same with tenant + guard let top = body["authenticatorAuthenticationResponse"] as? [String: AnyHashable] else { + return XCTFail("Missing authenticatorAuthenticationResponse") + } + XCTAssertEqual(top["id"] as? String, kCredentialID) + guard let response = top["response"] as? [String: AnyHashable] else { + return XCTFail("Missing nested response dictionary") + } + XCTAssertEqual(response["clientDataJSON"] as? String, kClientDataJSON) + XCTAssertEqual(response["authenticatorData"] as? String, kAuthenticatorData) + XCTAssertEqual(response["signature"] as? String, kSignature) + XCTAssertEqual(response["userHandle"] as? String, kUserId) } } diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index 764fdaad55d..46390ab8b89 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -80,11 +80,11 @@ class GetAccountInfoTests: RPCBaseTests { let kEmailVerifiedKey = "emailVerified" let kLocalIDKey = "localId" let kTestLocalID = "testLocalId" - let kPasskeysKey = "passkeys" + let kPasskeysKey = "passkeyInfo" // Fake PasskeyInfo let testCredentialId = "credential_id" - let testPasskeyName = "Test Passkey" + let testPasskeyName = "testPasskey" let passkeys = [[ "credentialId": testCredentialId, "name": testPasskeyName, @@ -141,7 +141,7 @@ class GetAccountInfoTests: RPCBaseTests { let userDict: [String: AnyHashable] = [ "localId": "user123", "email": "user@example.com", - "passkeys": [passkey1, passkey2], + "passkeyInfo": [passkey1, passkey2], ] let dict: [String: AnyHashable] = ["users": [userDict]] let response = try GetAccountInfoResponse(dictionary: dict) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift index 40fbe92f26f..8d3b370b1bc 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift @@ -68,7 +68,7 @@ func testUnencodedHTTPRequestBody_WithoutTenantId() { let request = StartPasskeySignInRequest(requestConfiguration: config) - XCTAssertNil(request.unencodedHTTPRequestBody) + XCTAssertEqual(request.unencodedHTTPRequestBody, [:]) } } From 6163b72f1359d3b77ea9570658a5a64adbf5017f Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 19:47:04 +0530 Subject: [PATCH 07/13] sample app passkey support --- .../Models/AuthMenu.swift | 34 ++++- .../Utility/Extensions.swift | 16 ++- .../ViewControllers/AuthViewController.swift | 134 +++++++++++++++++- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift index 5e9f8af3cf0..593b7c582f9 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift @@ -53,6 +53,10 @@ enum AuthMenu: String { case phoneEnroll case totpEnroll case multifactorUnenroll + case passkeySignUp + case passkeyEnroll + case passkeySignIn + case passkeyUnenroll // More intuitively named getter for `rawValue`. var id: String { rawValue } @@ -139,6 +143,15 @@ enum AuthMenu: String { return "TOTP Enroll" case .multifactorUnenroll: return "Multifactor unenroll" + // Passkey + case .passkeySignUp: + return "Sign Up with Passkey" + case .passkeyEnroll: + return "Enroll with Passkey" + case .passkeySignIn: + return "Sign In with Passkey" + case .passkeyUnenroll: + return "Unenroll Passkey" } } @@ -220,6 +233,14 @@ enum AuthMenu: String { self = .totpEnroll case "Multifactor unenroll": self = .multifactorUnenroll + case "Sign Up with Passkey": + self = .passkeySignUp + case "Enroll with Passkey": + self = .passkeyEnroll + case "Sign In with Passkey": + self = .passkeySignIn + case "Unenroll Passkey": + self = .passkeyUnenroll default: return nil } @@ -354,9 +375,20 @@ class AuthMenuData: DataSourceProvidable { return Section(headerDescription: header, items: items) } + static var passkeySection: Section { + let header = "Passkey" + let items: [Item] = [ + Item(title: AuthMenu.passkeySignUp.name), + Item(title: AuthMenu.passkeyEnroll.name), + Item(title: AuthMenu.passkeySignIn.name), + Item(title: AuthMenu.passkeyUnenroll.name), + ] + return Section(headerDescription: header, items: items) + } + static let sections: [Section] = [settingsSection, providerSection, emailPasswordSection, otherSection, recaptchaSection, - customAuthDomainSection, appSection, oobSection, multifactorSection] + customAuthDomainSection, appSection, oobSection, multifactorSection, passkeySection] static var authLinkSections: [Section] { let allItems = [providerSection, emailPasswordSection, otherSection].flatMap { $0.items } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift index 33aab86f922..4fa1503e0e0 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift @@ -33,6 +33,20 @@ extension User: DataSourceProvidable { return Section(headerDescription: "Info", items: items) } + private var passkeysSection: Section { + let passkeys = enrolledPasskeys ?? [] + guard !passkeys.isEmpty else { + return Section( + headerDescription: "Passkeys", + items: [Item(title: "None", detailTitle: "No passkeys enrolled")] + ) + } + let items: [Item] = passkeys.map { info in + Item(title: info.name, detailTitle: info.credentialID) + } + return Section(headerDescription: "Passkeys", items: items) + } + private var metaDataSection: Section { let metadataRows = [ Item(title: metadata.lastSignInDate?.description, detailTitle: "Last Sign-in Date"), @@ -62,7 +76,7 @@ extension User: DataSourceProvidable { } var sections: [Section] { - [infoSection, metaDataSection, otherSection, actionSection] + [infoSection, passkeysSection, metaDataSection, otherSection, actionSection] } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 240346b6975..bb3fc378c91 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -191,6 +191,18 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { case .multifactorUnenroll: mfaUnenroll() + + case .passkeySignUp: + passkeySignUp() + + case .passkeyEnroll: + Task { await passkeyEnroll() } + + case .passkeySignIn: + Task { await passkeySignIn() } + + case .passkeyUnenroll: + Task { await passkeyUnenroll() } } } @@ -922,6 +934,87 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { } } + // MARK: - Passkey + + private func passkeySignUp() { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + Task { + do { + _ = try await AppManager.shared.auth().signInAnonymously() + print("sign-in anonymously succeeded.") + if let uid = AppManager.shared.auth().currentUser?.uid { + print("User ID: \(uid)") + } + // Continue to enroll a passkey. + await passkeyEnroll() + } catch { + print("sign-in anonymously failed: \(error.localizedDescription)") + self.showAlert(for: "Anonymous Sign-In Failed") + } + } + } + + private func passkeyEnroll() async { + guard let user = AppManager.shared.auth().currentUser else { + showAlert(for: "Please sign in first.") + return + } + let passkeyName = await showTextInputPrompt(with: "Passkey name") + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + showAlert(for: "Not Supported", message: "This OS version does not support passkeys.") + return + } + + do { + let request = try await user.startPasskeyEnrollment(withName: passkeyName) + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + print("Started passkey enrollment (challenge created).") + } catch { + showAlert(for: "Passkey enrollment failed", message: error.localizedDescription) + print("startPasskeyEnrollment failed: \(error.localizedDescription)") + } + } + + private func passkeySignIn() async { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + do { + let request = try await AppManager.shared.auth().startPasskeySignIn() + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + print("Started passkey sign in (challenge created).") + } catch { + print("Passkey sign-in failed with error: \(error)") + } + } + + private func passkeyUnenroll() async { + guard let user = AppManager.shared.auth().currentUser else { + showAlert(for: "Please sign in first.") + return + } + guard let credentialId = await showTextInputPrompt(with: "Credential Id") else { + print("Passkey unenrollment cancelled: no credential id entered.") + return + } + do { + let _ = try await user.unenrollPasskey(withCredentialID: credentialId) + } catch { + showAlert(for: "Passkey unenrollment failed", message: error.localizedDescription) + print("unenrollPasskey failed: \(error.localizedDescription)") + } + } + // MARK: - Private Helpers private func showTextInputPrompt(with message: String, completion: ((String) -> Void)? = nil) { @@ -1027,6 +1120,43 @@ extension AuthViewController: ASAuthorizationControllerDelegate, func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + if #available(iOS 16.0, macOS 12.0, tvOS 16.0, *), + let regCred = authorization.credential + as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + Task { @MainActor [weak self] in + guard let self else { return } + do { + guard let user = AppManager.shared.auth().currentUser else { + self.showAlert(for: "Finalize failed", message: "No signed-in user.") + return + } + _ = try await user.finalizePasskeyEnrollment(withPlatformCredential: regCred) + self.showAlert(for: "Passkey Enrollment", message: "Succeeded") + print("Passkey Enrollment succeeded.") + } catch { + self.showAlert(for: "Passkey Enrollment failed", message: error.localizedDescription) + print("Finalize enrollment failed: \(error.localizedDescription)") + } + } + return + } + if let assertion = authorization + .credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + Task { @MainActor [weak self] in + guard let self else { return } + do { + let _ = try await AppManager.shared.auth() + .finalizePasskeySignIn(withPlatformCredential: assertion) + self.showAlert(for: "Passkey Sign-In", message: "Succeeded") + print("Passkey sign-in succeeded.") + self.transitionToUserViewController() + } catch { + self.showAlert(for: "Passkey Sign-In failed", message: error.localizedDescription) + print("Finalize passkey sign-in failed: \(error.localizedDescription)") + } + } + return + } guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { print("Unable to retrieve AppleIDCredential") @@ -1074,10 +1204,10 @@ extension AuthViewController: ASAuthorizationControllerDelegate, func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) { - // Ensure that you have: + print("Apple authorization failed: \(error)") + // for Sign In with Apple, ensure that you have: // - enabled `Sign in with Apple` on the Firebase console // - added the `Sign in with Apple` capability for this project - print("Sign in with Apple failed: \(error)") } // MARK: ASAuthorizationControllerPresentationContextProviding From d0a0243aa1bdfc6a5e14bf97bf6091591d87de8b Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 19:57:52 +0530 Subject: [PATCH 08/13] fixing --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 12 ++++++------ FirebaseAuth/Sources/Swift/User/User.swift | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 226d0ea3416..671ecec199d 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1675,17 +1675,17 @@ extension Auth: AuthInterop { @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 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 userId = platformCredential.userID.base64EncodedString() let request = FinalizePasskeySignInRequest( - credentialID: credentialID, - clientDataJSON: clientDataJSON, + credentialID: credentialId, + clientDataJSON: clientDataJson, authenticatorData: authenticatorData, signature: signature, - userId: userID, + userId: userId, requestConfiguration: requestConfiguration ) let response = try await backend.call(with: request) diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index c2378fcad1c..75aa3939eeb 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1122,15 +1122,15 @@ extension User: NSSecureCoding {} "Invalid platform credential: missing credentialID, clientDataJSON, or attestationObject."] ) } - let credentialID = platformCredential.credentialID.base64EncodedString() - let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() + let credentialId = platformCredential.credentialID.base64EncodedString() + let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString() let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() let request = FinalizePasskeyEnrollmentRequest( idToken: rawAccessToken(), name: passkeyName ?? defaultPasskeyName, - credentialID: credentialID, - clientDataJSON: clientDataJSON, + credentialID: credentialId, + clientDataJSON: clientDataJson, attestationObject: attestationObject, requestConfiguration: auth!.requestConfiguration ) From dae86218a271d7cc65450dd82c40bc2dc6d03fa3 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 20:05:05 +0530 Subject: [PATCH 09/13] adding ui tests --- .../AuthenticationExampleUITests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift index d7c893d20c8..d8d6b6eecf8 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift @@ -309,6 +309,37 @@ class AuthenticationExampleUITests: XCTestCase { ) } + func testPasskeyList() { + signOut() + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testPasskeyName = "sampleiosauth" + app.staticTexts["Email & Password Login"].tap() + app.textFields["Email"].tap() + app.textFields["Email"].typeText(testEmail) + app.textFields["Password"].tap() + app.textFields["Password"].typeText(testPassword) + app.buttons["Login"].tap() + wait(forElement: app.navigationBars["User"], timeout: 5.0) + XCTAssertTrue(app.navigationBars["User"].exists) + XCTAssertTrue( + app.staticTexts[testEmail].exists, + "The user should be signed in and the email field should display their email." + ) + let userTable = app.tables.firstMatch + XCTAssertTrue(userTable.waitForExistence(timeout: 5.0), "User detail list should exist") + let passkeyLabel = userTable.staticTexts[testPasskeyName] + if !passkeyLabel.exists { + for _ in 0 ..< 5 where !passkeyLabel.exists { + userTable.swipeUp() + } + } + XCTAssertTrue( + passkeyLabel.waitForExistence(timeout: 5.0), + "Passkey named '\(testPasskeyName)' should be visible in the Passkeys section." + ) + } + // MARK: - Private Helpers private func signOut() { From b7d1b3da11a225bc86217870f5ef11060a0e9646 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 20:10:33 +0530 Subject: [PATCH 10/13] integration tests --- .../SwiftApiTests/PasskeyTests.swift | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift diff --git a/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift new file mode 100644 index 00000000000..bee4e2f4d91 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift @@ -0,0 +1,244 @@ +/* + * Copyright 2024 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) + + import AuthenticationServices + @testable import FirebaseAuth + import Foundation + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class PasskeyTests: TestsBase { + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testStartPasskeyEnrollmentSuccess() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let request = try await user.startPasskeyEnrollment(withName: "Test1Passkey") + XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") + XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") + XCTAssertNotNil(request.userID, "userID should be present") + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func DRAFTtestStartPasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + // user not reloaded hence id token not updated + let request = try await user.startPasskeyEnrollment(withName: "Test2Passkey") + XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") + XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") + XCTAssertNotNil(request.userID, "userID should be present") + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + let badRequest = FinalizePasskeyEnrollmentRequest( + idToken: "invalidToken", + name: "fakeName", + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + attestationObject: "fakeAttestion".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: user.requestConfiguration + ) + do { + _ = try await user.backend.call(with: badRequest) + XCTFail("Expected invalid_user_token") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .invalidUserToken, "Expected .invalidUserToken, got \(code)") + } + } + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeyEnrollmentFailureWithoutAttestation() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let token = user.rawAccessToken() + let badRequest = FinalizePasskeyEnrollmentRequest( + idToken: token, + name: "fakeName", + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + attestationObject: "fakeAttestion".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: user.requestConfiguration + ) + do { + _ = try await user.backend.call(with: badRequest) + XCTFail("Expected invalid_authenticator_response") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .invalidAuthenticatorResponse, + "Expected .invalidAuthenticatorResponse, got \(code)") + } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "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." + ), + "Expected INVALID_AUTHENTICATOR_RESPONSE, got: \(message)" + ) + } + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testStartPasskeySignInSuccess() async throws { + let assertionRequest = try await Auth.auth().startPasskeySignIn() + XCTAssertFalse(assertionRequest.relyingPartyIdentifier.isEmpty, + "rpID should be non-empty") + XCTAssertFalse(assertionRequest.challenge.isEmpty, + "challenge should be non-empty") + XCTAssertTrue(assertionRequest + is ASAuthorizationPlatformPublicKeyCredentialAssertionRequest) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeySignInFailureInvalidAttestation() async throws { + let auth = Auth.auth() + let config = auth.requestConfiguration + let badRequest = FinalizePasskeySignInRequest( + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + authenticatorData: "fakeAuthenticatorData".data(using: .utf8)!.base64EncodedString(), + signature: "fakeSignature".data(using: .utf8)!.base64EncodedString(), + userId: "fakeUID".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: config + ) + do { + _ = try await auth.backend.call(with: badRequest) + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .userNotFound) + } + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeySignInFailureWithoutAttestation() async throws { + let auth = Auth.auth() + let config = auth.requestConfiguration + let badRequest = FinalizePasskeySignInRequest( + credentialID: "", + clientDataJSON: "", + authenticatorData: "", + signature: "", + userId: "", + requestConfiguration: config + ) + do { + _ = try await auth.backend.call(with: badRequest) + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .userNotFound) + } + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func DRAFTtestUnenrollPasskeySuccess() async throws { + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testCredentialId = "cred_id" + let auth = Auth.auth() + try await auth.signIn(withEmail: testEmail, password: testPassword) + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let prevPasskeys = user.enrolledPasskeys ?? [] + XCTAssertTrue( + prevPasskeys.contains { $0.credentialID == testCredentialId }, + "Precondition failed: passkey \(testCredentialId) is not enrolled on this account." + ) + let prevCount = prevPasskeys.count + let _ = try await user.unenrollPasskey(withCredentialID: testCredentialId) + try? await user.reload() + let updatedPasskeys = user.enrolledPasskeys ?? [] + XCTAssertFalse( + updatedPasskeys.contains { $0.credentialID == testCredentialId }, + "Passkey \(testCredentialId) should be removed after unenroll." + ) + XCTAssertEqual( + updatedPasskeys.count, prevCount - 1, + "Exactly one passkey should have been removed." + ) + XCTAssertNil( + updatedPasskeys.first { $0.credentialID == testCredentialId }, + "Passkey \(testCredentialId) should not exist after unenroll." + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testUnenrollPasskeyFailure() async throws { + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testCredentialId = "FCBopZ3mzjfPNXqWXXjAM/ZnnlQ=" + let auth = Auth.auth() + try await auth.signIn(withEmail: testEmail, password: testPassword) + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + do { + let _ = try await user.unenrollPasskey(withCredentialID: testCredentialId) + XCTFail("Expected invalid passkey error") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .missingPasskeyEnrollment, + "Expected .missingPasskeyEnrollment, got \(code)") + } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "CANNOT FIND THE PASSKEY LINKED TO THE CURRENT ACCOUNT" + ), + "Expected Missing Passkey Enrollment error, got: \(message)" + ) + } + } + } + +#endif From 02128ff2cd3a964710fc05c1a1be37906f90c8e9 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 22:09:15 +0530 Subject: [PATCH 11/13] updating integration tests --- .../SwiftApiTests/PasskeyTests.swift | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift index bee4e2f4d91..3a2ac165aca 100644 --- a/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift +++ b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift @@ -24,7 +24,7 @@ @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) class PasskeyTests: TestsBase { @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) - func testStartPasskeyEnrollmentSuccess() async throws { + func testStartPasskeyEnrollmentResponseSuccess() async throws { try await signInAnonymouslyAsync() guard let user = Auth.auth().currentUser else { XCTFail("Expected a signed-in user") @@ -35,22 +35,39 @@ XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") XCTAssertNotNil(request.userID, "userID should be present") + XCTAssertNotNil(request as ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest) try? await deleteCurrentUserAsync() } @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) - func DRAFTtestStartPasskeyEnrollmentFailureWithInvalidToken() async throws { + func testStartPasskeyEnrollmentFailureWithInvalidToken() async throws { try await signInAnonymouslyAsync() guard let user = Auth.auth().currentUser else { XCTFail("Expected a signed-in user") return } - // user not reloaded hence id token not updated - let request = try await user.startPasskeyEnrollment(withName: "Test2Passkey") - XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") - XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") - XCTAssertNotNil(request.userID, "userID should be present") - try? await deleteCurrentUserAsync() + let config = user.requestConfiguration + let token = "invalidToken" + let badRequest = StartPasskeyEnrollmentRequest(idToken: token, requestConfiguration: config) + do { + _ = try await user.backend.call(with: badRequest) + XCTFail("Expected .invalidUserToken") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .invalidUserToken, "Expected .invalidUserToken, got \(code)") + } else { + XCTFail("Unexpected error: \(error)") + } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "THIS USER'S CREDENTIAL ISN'T VALID FOR THIS PROJECT. THIS CAN HAPPEN IF THE USER'S TOKEN HAS BEEN TAMPERED WITH, OR IF THE USER DOESN’T BELONG TO THE PROJECT ASSOCIATED WITH THE API KEY USED IN YOUR REQUEST." + ), + "Expected invalidUserToken, got: \(message)" + ) + } } @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) @@ -76,6 +93,14 @@ if let code = AuthErrorCode(rawValue: ns.code) { XCTAssertEqual(code, .invalidUserToken, "Expected .invalidUserToken, got \(code)") } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "THIS USER'S CREDENTIAL ISN'T VALID FOR THIS PROJECT. THIS CAN HAPPEN IF THE USER'S TOKEN HAS BEEN TAMPERED WITH, OR IF THE USER DOESN’T BELONG TO THE PROJECT ASSOCIATED WITH THE API KEY USED IN YOUR REQUEST." + ), + "Expected invalidUserToken, got: \(message)" + ) } try? await deleteCurrentUserAsync() } @@ -125,8 +150,9 @@ "rpID should be non-empty") XCTAssertFalse(assertionRequest.challenge.isEmpty, "challenge should be non-empty") - XCTAssertTrue(assertionRequest - is ASAuthorizationPlatformPublicKeyCredentialAssertionRequest) + XCTAssertNotNil( + assertionRequest as ASAuthorizationPlatformPublicKeyCredentialAssertionRequest + ) } @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) @@ -152,7 +178,7 @@ } @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) - func testFinalizePasskeySignInFailureWithoutAttestation() async throws { + func testFinalizePasskeySignInFailureIncorrectAttestation() async throws { let auth = Auth.auth() let config = auth.requestConfiguration let badRequest = FinalizePasskeySignInRequest( From 9ffd13d5a72824477f85108cacf5c7d8a9bf27b7 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Mon, 18 Aug 2025 22:54:58 +0530 Subject: [PATCH 12/13] ios check --- .../AuthenticationExampleUITests.swift | 58 ++-- .../Tests/Unit/GetAccountInfoTests.swift | 208 +++++------ .../Tests/Unit/SetAccountInfoTests.swift | 322 +++++++++--------- 3 files changed, 298 insertions(+), 290 deletions(-) diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift index d8d6b6eecf8..8f77d50175b 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift @@ -309,36 +309,38 @@ class AuthenticationExampleUITests: XCTestCase { ) } - func testPasskeyList() { - signOut() - let testEmail = "sample.ios.auth@gmail.com" - let testPassword = "sample.ios.auth" - let testPasskeyName = "sampleiosauth" - app.staticTexts["Email & Password Login"].tap() - app.textFields["Email"].tap() - app.textFields["Email"].typeText(testEmail) - app.textFields["Password"].tap() - app.textFields["Password"].typeText(testPassword) - app.buttons["Login"].tap() - wait(forElement: app.navigationBars["User"], timeout: 5.0) - XCTAssertTrue(app.navigationBars["User"].exists) - XCTAssertTrue( - app.staticTexts[testEmail].exists, - "The user should be signed in and the email field should display their email." - ) - let userTable = app.tables.firstMatch - XCTAssertTrue(userTable.waitForExistence(timeout: 5.0), "User detail list should exist") - let passkeyLabel = userTable.staticTexts[testPasskeyName] - if !passkeyLabel.exists { - for _ in 0 ..< 5 where !passkeyLabel.exists { - userTable.swipeUp() + #if os(iOS) || os(tvOS) || os(macOS) + func testPasskeyList() { + signOut() + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testPasskeyName = "sampleiosauth" + app.staticTexts["Email & Password Login"].tap() + app.textFields["Email"].tap() + app.textFields["Email"].typeText(testEmail) + app.textFields["Password"].tap() + app.textFields["Password"].typeText(testPassword) + app.buttons["Login"].tap() + wait(forElement: app.navigationBars["User"], timeout: 5.0) + XCTAssertTrue(app.navigationBars["User"].exists) + XCTAssertTrue( + app.staticTexts[testEmail].exists, + "The user should be signed in and the email field should display their email." + ) + let userTable = app.tables.firstMatch + XCTAssertTrue(userTable.waitForExistence(timeout: 5.0), "User detail list should exist") + let passkeyLabel = userTable.staticTexts[testPasskeyName] + if !passkeyLabel.exists { + for _ in 0 ..< 5 where !passkeyLabel.exists { + userTable.swipeUp() + } } + XCTAssertTrue( + passkeyLabel.waitForExistence(timeout: 5.0), + "Passkey named '\(testPasskeyName)' should be visible in the Passkeys section." + ) } - XCTAssertTrue( - passkeyLabel.waitForExistence(timeout: 5.0), - "Passkey named '\(testPasskeyName)' should be visible in the Passkeys section." - ) - } + #endif // MARK: - Private Helpers diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index 46390ab8b89..53b34ef0765 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -60,113 +60,117 @@ class GetAccountInfoTests: RPCBaseTests { ) } - /** @fn testSuccessfulGetAccountInfoResponse - @brief This test simulates a successful @c GetAccountInfo flow. - */ - func testSuccessfulGetAccountInfoResponse() async throws { - let kProviderUserInfoKey = "providerUserInfo" - let kPhotoUrlKey = "photoUrl" - let kTestPhotoURL = "testPhotoURL" - let kProviderIDkey = "providerId" - let kDisplayNameKey = "displayName" - let kTestDisplayName = "DisplayName" - let kFederatedIDKey = "federatedId" - let kTestFederatedID = "testFederatedId" - let kEmailKey = "email" - let kTestEmail = "testEmail" - let kPasswordHashKey = "passwordHash" - let kTestPasswordHash = "testPasswordHash" - let kTestProviderID = "testProviderID" - let kEmailVerifiedKey = "emailVerified" - let kLocalIDKey = "localId" - let kTestLocalID = "testLocalId" - let kPasskeysKey = "passkeyInfo" - - // Fake PasskeyInfo - let testCredentialId = "credential_id" - let testPasskeyName = "testPasskey" - let passkeys = [[ - "credentialId": testCredentialId, - "name": testPasskeyName, - ]] - - let usersIn = [[ - kProviderUserInfoKey: [[ - kProviderIDkey: kTestProviderID, + #if os(iOS) || os(tvOS) || os(macOS) + + /** @fn testSuccessfulGetAccountInfoResponse + @brief This test simulates a successful @c GetAccountInfo flow. + */ + func testSuccessfulGetAccountInfoResponse() async throws { + let kProviderUserInfoKey = "providerUserInfo" + let kPhotoUrlKey = "photoUrl" + let kTestPhotoURL = "testPhotoURL" + let kProviderIDkey = "providerId" + let kDisplayNameKey = "displayName" + let kTestDisplayName = "DisplayName" + let kFederatedIDKey = "federatedId" + let kTestFederatedID = "testFederatedId" + let kEmailKey = "email" + let kTestEmail = "testEmail" + let kPasswordHashKey = "passwordHash" + let kTestPasswordHash = "testPasswordHash" + let kTestProviderID = "testProviderID" + let kEmailVerifiedKey = "emailVerified" + let kLocalIDKey = "localId" + let kTestLocalID = "testLocalId" + let kPasskeysKey = "passkeyInfo" + + // Fake PasskeyInfo + let testCredentialId = "credential_id" + let testPasskeyName = "testPasskey" + let passkeys = [[ + "credentialId": testCredentialId, + "name": testPasskeyName, + ]] + + let usersIn = [[ + kProviderUserInfoKey: [[ + kProviderIDkey: kTestProviderID, + kDisplayNameKey: kTestDisplayName, + kPhotoUrlKey: kTestPhotoURL, + kFederatedIDKey: kTestFederatedID, + kEmailKey: kTestEmail, + ]], + kLocalIDKey: kTestLocalID, kDisplayNameKey: kTestDisplayName, - kPhotoUrlKey: kTestPhotoURL, - kFederatedIDKey: kTestFederatedID, kEmailKey: kTestEmail, - ]], - kLocalIDKey: kTestLocalID, - kDisplayNameKey: kTestDisplayName, - kEmailKey: kTestEmail, - kPhotoUrlKey: kTestPhotoURL, - kEmailVerifiedKey: true, - kPasswordHashKey: kTestPasswordHash, - kPasskeysKey: passkeys, - ] as [String: Any]] - let rpcIssuer = try XCTUnwrap(self.rpcIssuer) - - rpcIssuer.respondBlock = { - try self.rpcIssuer.respond(withJSON: ["users": usersIn]) + kPhotoUrlKey: kTestPhotoURL, + kEmailVerifiedKey: true, + kPasswordHashKey: kTestPasswordHash, + kPasskeysKey: passkeys, + ] as [String: Any]] + let rpcIssuer = try XCTUnwrap(self.rpcIssuer) + + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: ["users": usersIn]) + } + let rpcResponse = try await authBackend.call(with: makeGetAccountInfoRequest()) + + let users = try XCTUnwrap(rpcResponse.users) + XCTAssertGreaterThan(users.count, 0) + let firstUser = try XCTUnwrap(users.first) + XCTAssertEqual(firstUser.photoURL?.absoluteString, kTestPhotoURL) + XCTAssertEqual(firstUser.displayName, kTestDisplayName) + XCTAssertEqual(firstUser.email, kTestEmail) + XCTAssertEqual(firstUser.localID, kTestLocalID) + XCTAssertTrue(firstUser.emailVerified) + let providerUserInfo = try XCTUnwrap(firstUser.providerUserInfo) + XCTAssertGreaterThan(providerUserInfo.count, 0) + let firstProviderUser = try XCTUnwrap(providerUserInfo.first) + XCTAssertEqual(firstProviderUser.photoURL?.absoluteString, kTestPhotoURL) + XCTAssertEqual(firstProviderUser.displayName, kTestDisplayName) + XCTAssertEqual(firstProviderUser.email, kTestEmail) + XCTAssertEqual(firstProviderUser.providerID, kTestProviderID) + XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 1) + XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId) + XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName) } - let rpcResponse = try await authBackend.call(with: makeGetAccountInfoRequest()) - - let users = try XCTUnwrap(rpcResponse.users) - XCTAssertGreaterThan(users.count, 0) - let firstUser = try XCTUnwrap(users.first) - XCTAssertEqual(firstUser.photoURL?.absoluteString, kTestPhotoURL) - XCTAssertEqual(firstUser.displayName, kTestDisplayName) - XCTAssertEqual(firstUser.email, kTestEmail) - XCTAssertEqual(firstUser.localID, kTestLocalID) - XCTAssertTrue(firstUser.emailVerified) - let providerUserInfo = try XCTUnwrap(firstUser.providerUserInfo) - XCTAssertGreaterThan(providerUserInfo.count, 0) - let firstProviderUser = try XCTUnwrap(providerUserInfo.first) - XCTAssertEqual(firstProviderUser.photoURL?.absoluteString, kTestPhotoURL) - XCTAssertEqual(firstProviderUser.displayName, kTestDisplayName) - XCTAssertEqual(firstProviderUser.email, kTestEmail) - XCTAssertEqual(firstProviderUser.providerID, kTestProviderID) - XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID) - let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) - XCTAssertEqual(enrolledPasskeys.count, 1) - XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId) - XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName) - } - func testInitWithMultipleEnrolledPasskeys() throws { - let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"] - let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"] - let userDict: [String: AnyHashable] = [ - "localId": "user123", - "email": "user@example.com", - "passkeyInfo": [passkey1, passkey2], - ] - let dict: [String: AnyHashable] = ["users": [userDict]] - let response = try GetAccountInfoResponse(dictionary: dict) - let users = try XCTUnwrap(response.users) - let firstUser = try XCTUnwrap(users.first) - let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) - XCTAssertEqual(enrolledPasskeys.count, 2) - XCTAssertEqual(enrolledPasskeys[0].name, "passkey1") - XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1") - XCTAssertEqual(enrolledPasskeys[1].name, "passkey2") - XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2") - } + func testInitWithMultipleEnrolledPasskeys() throws { + let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"] + let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"] + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + "passkeyInfo": [passkey1, passkey2], + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 2) + XCTAssertEqual(enrolledPasskeys[0].name, "passkey1") + XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1") + XCTAssertEqual(enrolledPasskeys[1].name, "passkey2") + XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2") + } - func testInitWithNoEnrolledPasskeys() throws { - let userDict: [String: AnyHashable] = [ - "localId": "user123", - "email": "user@example.com", - // No "passkeys" present - ] - let dict: [String: AnyHashable] = ["users": [userDict]] - let response = try GetAccountInfoResponse(dictionary: dict) - let users = try XCTUnwrap(response.users) - let firstUser = try XCTUnwrap(users.first) - XCTAssertNil(firstUser.enrolledPasskeys) - } + func testInitWithNoEnrolledPasskeys() throws { + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + // No "passkeys" present + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + XCTAssertNil(firstUser.enrolledPasskeys) + } + + #endif private func makeGetAccountInfoRequest() -> GetAccountInfoRequest { return GetAccountInfoRequest(accessToken: kTestAccessToken, diff --git a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift index 7283c2e2c71..afbf8a86195 100644 --- a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift @@ -36,170 +36,172 @@ class SetAccountInfoTests: RPCBaseTests { XCTAssertEqual(decodedRequest.count, 0) } - func testSetAccountInfoRequestOptionalFields() async throws { - let kIDTokenKey = "idToken" - let kDisplayNameKey = "displayName" - let kTestDisplayName = "testDisplayName" - let kLocalIDKey = "localId" - let kTestLocalID = "testLocalId" - let kEmailKey = "email" - let ktestEmail = "testEmail" - let kPasswordKey = "password" - let kTestPassword = "testPassword" - let kPhotoURLKey = "photoUrl" - let kTestPhotoURL = "testPhotoUrl" - let kProvidersKey = "provider" - let kTestProviders = "testProvider" - let kOOBCodeKey = "oobCode" - let kTestOOBCode = "testOobCode" - let kEmailVerifiedKey = "emailVerified" - let kUpgradeToFederatedLoginKey = "upgradeToFederatedLogin" - let kCaptchaChallengeKey = "captchaChallenge" - let kTestCaptchaChallenge = "TestCaptchaChallenge" - let kCaptchaResponseKey = "captchaResponse" - let kTestCaptchaResponse = "TestCaptchaResponse" - let kDeleteAttributesKey = "deleteAttribute" - let kTestDeleteAttributes = "TestDeleteAttributes" - let kDeleteProvidersKey = "deleteProvider" - let kTestDeleteProviders = "TestDeleteProviders" - let kReturnSecureTokenKey = "returnSecureToken" - let kTestAccessToken = "accessToken" - let kDeletePasskeysKey = "deletePasskey" - let kDeletePasskey = "credential_id" - let kExpectedAPIURL = - "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" + #if os(iOS) || os(tvOS) || os(macOS) + func testSetAccountInfoRequestOptionalFields() async throws { + let kIDTokenKey = "idToken" + let kDisplayNameKey = "displayName" + let kTestDisplayName = "testDisplayName" + let kLocalIDKey = "localId" + let kTestLocalID = "testLocalId" + let kEmailKey = "email" + let ktestEmail = "testEmail" + let kPasswordKey = "password" + let kTestPassword = "testPassword" + let kPhotoURLKey = "photoUrl" + let kTestPhotoURL = "testPhotoUrl" + let kProvidersKey = "provider" + let kTestProviders = "testProvider" + let kOOBCodeKey = "oobCode" + let kTestOOBCode = "testOobCode" + let kEmailVerifiedKey = "emailVerified" + let kUpgradeToFederatedLoginKey = "upgradeToFederatedLogin" + let kCaptchaChallengeKey = "captchaChallenge" + let kTestCaptchaChallenge = "TestCaptchaChallenge" + let kCaptchaResponseKey = "captchaResponse" + let kTestCaptchaResponse = "TestCaptchaResponse" + let kDeleteAttributesKey = "deleteAttribute" + let kTestDeleteAttributes = "TestDeleteAttributes" + let kDeleteProvidersKey = "deleteProvider" + let kTestDeleteProviders = "TestDeleteProviders" + let kReturnSecureTokenKey = "returnSecureToken" + let kTestAccessToken = "accessToken" + let kDeletePasskeysKey = "deletePasskey" + let kDeletePasskey = "credential_id" + let kExpectedAPIURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" - let request = setAccountInfoRequest() - request.accessToken = kTestAccessToken - request.displayName = kTestDisplayName - request.localID = kTestLocalID - request.email = ktestEmail - request.password = kTestPassword - request.providers = [kTestProviders] - request.oobCode = kTestOOBCode - request.emailVerified = true - request.photoURL = URL(string: kTestPhotoURL) - request.upgradeToFederatedLogin = true - request.captchaChallenge = kTestCaptchaChallenge - request.captchaResponse = kTestCaptchaResponse - request.deleteAttributes = [kTestDeleteAttributes] - request.deleteProviders = [kTestDeleteProviders] - request.deletePasskeys = [kDeletePasskey] + let request = setAccountInfoRequest() + request.accessToken = kTestAccessToken + request.displayName = kTestDisplayName + request.localID = kTestLocalID + request.email = ktestEmail + request.password = kTestPassword + request.providers = [kTestProviders] + request.oobCode = kTestOOBCode + request.emailVerified = true + request.photoURL = URL(string: kTestPhotoURL) + request.upgradeToFederatedLogin = true + request.captchaChallenge = kTestCaptchaChallenge + request.captchaResponse = kTestCaptchaResponse + request.deleteAttributes = [kTestDeleteAttributes] + request.deleteProviders = [kTestDeleteProviders] + request.deletePasskeys = [kDeletePasskey] - try await checkRequest( - request: request, - expected: kExpectedAPIURL, - key: kIDTokenKey, - value: kTestAccessToken - ) - let decodedRequest = try XCTUnwrap(rpcIssuer.decodedRequest) - XCTAssertEqual(decodedRequest[kIDTokenKey] as? String, kTestAccessToken) - XCTAssertEqual(decodedRequest[kDisplayNameKey] as? String, kTestDisplayName) - XCTAssertEqual(decodedRequest[kLocalIDKey] as? String, kTestLocalID) - XCTAssertEqual(decodedRequest[kEmailKey] as? String, ktestEmail) - XCTAssertEqual(decodedRequest[kPasswordKey] as? String, kTestPassword) - XCTAssertEqual(decodedRequest[kPhotoURLKey] as? String, kTestPhotoURL) - XCTAssertEqual(decodedRequest[kProvidersKey] as? [String], [kTestProviders]) - XCTAssertEqual(decodedRequest[kOOBCodeKey] as? String, kTestOOBCode) - XCTAssertEqual(decodedRequest[kEmailVerifiedKey] as? Bool, true) - XCTAssertEqual(decodedRequest[kUpgradeToFederatedLoginKey] as? Bool, true) - XCTAssertEqual(decodedRequest[kCaptchaChallengeKey] as? String, kTestCaptchaChallenge) - XCTAssertEqual(decodedRequest[kCaptchaResponseKey] as? String, kTestCaptchaResponse) - XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes]) - XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders]) - XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true) - XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey]) - } + try await checkRequest( + request: request, + expected: kExpectedAPIURL, + key: kIDTokenKey, + value: kTestAccessToken + ) + let decodedRequest = try XCTUnwrap(rpcIssuer.decodedRequest) + XCTAssertEqual(decodedRequest[kIDTokenKey] as? String, kTestAccessToken) + XCTAssertEqual(decodedRequest[kDisplayNameKey] as? String, kTestDisplayName) + XCTAssertEqual(decodedRequest[kLocalIDKey] as? String, kTestLocalID) + XCTAssertEqual(decodedRequest[kEmailKey] as? String, ktestEmail) + XCTAssertEqual(decodedRequest[kPasswordKey] as? String, kTestPassword) + XCTAssertEqual(decodedRequest[kPhotoURLKey] as? String, kTestPhotoURL) + XCTAssertEqual(decodedRequest[kProvidersKey] as? [String], [kTestProviders]) + XCTAssertEqual(decodedRequest[kOOBCodeKey] as? String, kTestOOBCode) + XCTAssertEqual(decodedRequest[kEmailVerifiedKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kUpgradeToFederatedLoginKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kCaptchaChallengeKey] as? String, kTestCaptchaChallenge) + XCTAssertEqual(decodedRequest[kCaptchaResponseKey] as? String, kTestCaptchaResponse) + XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes]) + XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders]) + XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey]) + } - func testSetAccountInfoErrors() async throws { - let kEmailExistsErrorMessage = "EMAIL_EXISTS" - let kEmailSignUpNotAllowedErrorMessage = "OPERATION_NOT_ALLOWED" - let kPasswordLoginDisabledErrorMessage = "PASSWORD_LOGIN_DISABLED" - let kCredentialTooOldErrorMessage = "CREDENTIAL_TOO_OLD_LOGIN_AGAIN" - let kInvalidUserTokenErrorMessage = "INVALID_ID_TOKEN" - let kUserDisabledErrorMessage = "USER_DISABLED" - let kInvalidEmailErrorMessage = "INVALID_EMAIL" - let kExpiredActionCodeErrorMessage = "EXPIRED_OOB_CODE:" - let kInvalidActionCodeErrorMessage = "INVALID_OOB_CODE" - let kInvalidMessagePayloadErrorMessage = "INVALID_MESSAGE_PAYLOAD" - let kInvalidSenderErrorMessage = "INVALID_SENDER" - let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL" - let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters" - let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters" - let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND" + func testSetAccountInfoErrors() async throws { + let kEmailExistsErrorMessage = "EMAIL_EXISTS" + let kEmailSignUpNotAllowedErrorMessage = "OPERATION_NOT_ALLOWED" + let kPasswordLoginDisabledErrorMessage = "PASSWORD_LOGIN_DISABLED" + let kCredentialTooOldErrorMessage = "CREDENTIAL_TOO_OLD_LOGIN_AGAIN" + let kInvalidUserTokenErrorMessage = "INVALID_ID_TOKEN" + let kUserDisabledErrorMessage = "USER_DISABLED" + let kInvalidEmailErrorMessage = "INVALID_EMAIL" + let kExpiredActionCodeErrorMessage = "EXPIRED_OOB_CODE:" + let kInvalidActionCodeErrorMessage = "INVALID_OOB_CODE" + let kInvalidMessagePayloadErrorMessage = "INVALID_MESSAGE_PAYLOAD" + let kInvalidSenderErrorMessage = "INVALID_SENDER" + let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL" + let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters" + let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters" + let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND" - try await checkBackendError( - request: setAccountInfoRequest(), - message: kEmailExistsErrorMessage, - errorCode: AuthErrorCode.emailAlreadyInUse - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kEmailSignUpNotAllowedErrorMessage, - errorCode: AuthErrorCode.operationNotAllowed - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kPasswordLoginDisabledErrorMessage, - errorCode: AuthErrorCode.operationNotAllowed - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kUserDisabledErrorMessage, - errorCode: AuthErrorCode.userDisabled - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidUserTokenErrorMessage, - errorCode: AuthErrorCode.invalidUserToken - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kCredentialTooOldErrorMessage, - errorCode: AuthErrorCode.requiresRecentLogin - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kWeakPasswordErrorMessage, - errorCode: AuthErrorCode.weakPassword, - errorReason: kWeakPasswordClientErrorMessage - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidEmailErrorMessage, - errorCode: AuthErrorCode.invalidEmail - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidActionCodeErrorMessage, - errorCode: AuthErrorCode.invalidActionCode - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kExpiredActionCodeErrorMessage, - errorCode: AuthErrorCode.expiredActionCode - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidMessagePayloadErrorMessage, - errorCode: AuthErrorCode.invalidMessagePayload - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidSenderErrorMessage, - errorCode: AuthErrorCode.invalidSender - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidRecipientEmailErrorMessage, - errorCode: AuthErrorCode.invalidRecipientEmail - ) - try await checkBackendError( - request: setAccountInfoRequest(), - message: kInvalidCredentialIdForPasskeyUnenroll, - errorCode: AuthErrorCode.missingPasskeyEnrollment - ) - } + try await checkBackendError( + request: setAccountInfoRequest(), + message: kEmailExistsErrorMessage, + errorCode: AuthErrorCode.emailAlreadyInUse + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kEmailSignUpNotAllowedErrorMessage, + errorCode: AuthErrorCode.operationNotAllowed + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kPasswordLoginDisabledErrorMessage, + errorCode: AuthErrorCode.operationNotAllowed + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kUserDisabledErrorMessage, + errorCode: AuthErrorCode.userDisabled + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidUserTokenErrorMessage, + errorCode: AuthErrorCode.invalidUserToken + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kCredentialTooOldErrorMessage, + errorCode: AuthErrorCode.requiresRecentLogin + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kWeakPasswordErrorMessage, + errorCode: AuthErrorCode.weakPassword, + errorReason: kWeakPasswordClientErrorMessage + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidEmailErrorMessage, + errorCode: AuthErrorCode.invalidEmail + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidActionCodeErrorMessage, + errorCode: AuthErrorCode.invalidActionCode + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kExpiredActionCodeErrorMessage, + errorCode: AuthErrorCode.expiredActionCode + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidMessagePayloadErrorMessage, + errorCode: AuthErrorCode.invalidMessagePayload + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidSenderErrorMessage, + errorCode: AuthErrorCode.invalidSender + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidRecipientEmailErrorMessage, + errorCode: AuthErrorCode.invalidRecipientEmail + ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidCredentialIdForPasskeyUnenroll, + errorCode: AuthErrorCode.missingPasskeyEnrollment + ) + } + #endif /** @fn testSuccessfulSetAccountInfoResponse @brief This test simulates a successful @c SetAccountInfo flow. From bc32b099136549314f725a13dda639df59c18fc9 Mon Sep 17 00:00:00 2001 From: Srushti Vaidya Date: Tue, 19 Aug 2025 06:43:31 +0530 Subject: [PATCH 13/13] added logs --- FirebaseAuth/Sources/Swift/Auth/Auth.swift | 4 ++ FirebaseAuth/Sources/Swift/User/User.swift | 12 ++++- .../ViewControllers/AuthViewController.swift | 45 +++++++++++++------ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 671ecec199d..fc103a40707 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1654,6 +1654,7 @@ extension Auth: AuthInterop { public func startPasskeySignIn() async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration) + print("invoking StartPasskeySignIn\n") let response = try await backend.call(with: request) guard let challengeInData = Data(base64Encoded: response.challenge) else { throw NSError( @@ -1662,6 +1663,7 @@ extension Auth: AuthInterop { userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] ) } + print("StartPasskeySignIn Succeed!\n") let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: response.rpID ) @@ -1688,6 +1690,7 @@ extension Auth: AuthInterop { userId: userId, requestConfiguration: requestConfiguration ) + print("invoking finalizePasskeySignIn for Passkey credentialId: \(credentialId)\n\n") let response = try await backend.call(with: request) let user = try await Auth.auth().completeSignIn( withAccessToken: response.idToken, @@ -1697,6 +1700,7 @@ extension Auth: AuthInterop { ) try await user.reload() try await updateCurrentUser(user) + print("FinalizePasskeySignIn Succeed!\nidToken: \(response.idToken)\n") return AuthDataResult(withUser: user, additionalUserInfo: nil) } #endif diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 75aa3939eeb..85d554d5d12 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1078,6 +1078,7 @@ extension User: NSSecureCoding {} idToken: enrollmentIdToken, requestConfiguration: requestConfiguration ) + print("invoking startPasskeyEnrollemt with idToken: \(enrollmentIdToken)\n") let response = try await backend.call(with: request) passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name guard let challengeInData = Data(base64Encoded: response.challenge) else { @@ -1087,6 +1088,7 @@ extension User: NSSecureCoding {} userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] ) } + print("StartPasskeyEnrollemt Succeed!\n") guard let userIdInData = Data(base64Encoded: response.userID) else { throw NSError( domain: AuthErrorDomain, @@ -1125,7 +1127,9 @@ extension User: NSSecureCoding {} let credentialId = platformCredential.credentialID.base64EncodedString() let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString() let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() - + print( + "invoking finalizePasskeyEnrollemt for Passkey:\nname: \(passkeyName)\ncredentialId: \(credentialId)\n" + ) let request = FinalizePasskeyEnrollmentRequest( idToken: rawAccessToken(), name: passkeyName ?? defaultPasskeyName, @@ -1144,6 +1148,7 @@ extension User: NSSecureCoding {} defer { self.passkeyName = nil } try await user.reload() try await auth!.updateCurrentUser(user) + print("FinalizePasskeyEnrollemt Succeed!\nidToken: \(response.idToken)\n") return AuthDataResult(withUser: user, additionalUserInfo: nil) } @@ -1155,8 +1160,10 @@ extension User: NSSecureCoding {} let request = SetAccountInfoRequest( requestConfiguration: auth!.requestConfiguration ) + let token = rawAccessToken() request.deletePasskeys = [credentialID] - request.accessToken = rawAccessToken() + request.accessToken = token + print("invoking UnenrollPasskey for credentialId: \(credentialID) with IdToken: \(token)\n") let response = try await backend.call(with: request) let user = try await auth!.completeSignIn( withAccessToken: response.idToken, @@ -1166,6 +1173,7 @@ extension User: NSSecureCoding {} ) try await user.reload() try await auth!.updateCurrentUser(user) + print("UnenrollPasskey Succeed!\nidToken: \(response.idToken)\n") } #endif diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index bb3fc378c91..a727b65f9e7 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -941,33 +941,39 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { print("OS version is not supported for this action.") return } + guard AppManager.shared.auth().currentUser == nil else { + presentPasskeyAlert("User Already Signed In", message: "Please sign out first.") + print("User Already Signed In. Sign out first.") + return + } Task { do { _ = try await AppManager.shared.auth().signInAnonymously() - print("sign-in anonymously succeeded.") + print("sign-in anonymously succeeded.\n") if let uid = AppManager.shared.auth().currentUser?.uid { - print("User ID: \(uid)") + print("User ID: \(uid)\n") } - // Continue to enroll a passkey. await passkeyEnroll() } catch { print("sign-in anonymously failed: \(error.localizedDescription)") - self.showAlert(for: "Anonymous Sign-In Failed") + presentPasskeyAlert("Anonymous Sign-In Failed", + message: "Anonymous Sign-In Failed while Passkey Sign Up") } } } private func passkeyEnroll() async { guard let user = AppManager.shared.auth().currentUser else { - showAlert(for: "Please sign in first.") + presentPasskeyAlert("Signed In User needed", message: "Please sign in first.") + print("Please sign in first.") return } let passkeyName = await showTextInputPrompt(with: "Passkey name") guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { - showAlert(for: "Not Supported", message: "This OS version does not support passkeys.") + presentPasskeyAlert("Not Supported", message: "This OS version does not support passkeys.") return } - + print("Enrolling Passkey with Name: \(passkeyName)\n") do { let request = try await user.startPasskeyEnrollment(withName: passkeyName) let controller = ASAuthorizationController(authorizationRequests: [request]) @@ -976,7 +982,7 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { controller.performRequests() print("Started passkey enrollment (challenge created).") } catch { - showAlert(for: "Passkey enrollment failed", message: error.localizedDescription) + presentPasskeyAlert("Passkey enrollment failed", message: error.localizedDescription) print("startPasskeyEnrollment failed: \(error.localizedDescription)") } } @@ -984,6 +990,7 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { private func passkeySignIn() async { guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { print("OS version is not supported for this action.") + presentPasskeyAlert("Not Supported", message: "This OS version does not support passkeys.") return } do { @@ -994,13 +1001,14 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { controller.performRequests() print("Started passkey sign in (challenge created).") } catch { + presentPasskeyAlert("Passkey Sign-In Failed", message: error.localizedDescription) print("Passkey sign-in failed with error: \(error)") } } private func passkeyUnenroll() async { guard let user = AppManager.shared.auth().currentUser else { - showAlert(for: "Please sign in first.") + presentPasskeyAlert("Signed In User needed", message: "Please sign in first.") return } guard let credentialId = await showTextInputPrompt(with: "Credential Id") else { @@ -1009,12 +1017,21 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { } do { let _ = try await user.unenrollPasskey(withCredentialID: credentialId) + presentPasskeyAlert("Passkey Unenrollment", message: "Succeeded") } catch { - showAlert(for: "Passkey unenrollment failed", message: error.localizedDescription) print("unenrollPasskey failed: \(error.localizedDescription)") + presentPasskeyAlert("Passkey unenrollment failed", message: error.localizedDescription) } } + /// passkey helper to present alerts + @MainActor + private func presentPasskeyAlert(_ title: String, message: String? = nil) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + present(alert, animated: true) + } + // MARK: - Private Helpers private func showTextInputPrompt(with message: String, completion: ((String) -> Void)? = nil) { @@ -1134,8 +1151,8 @@ extension AuthViewController: ASAuthorizationControllerDelegate, self.showAlert(for: "Passkey Enrollment", message: "Succeeded") print("Passkey Enrollment succeeded.") } catch { - self.showAlert(for: "Passkey Enrollment failed", message: error.localizedDescription) - print("Finalize enrollment failed: \(error.localizedDescription)") + print("Passkey Enrollment failed: \(error.localizedDescription)") + presentPasskeyAlert("Passkey Enrollment failed", message: error.localizedDescription) } } return @@ -1151,8 +1168,8 @@ extension AuthViewController: ASAuthorizationControllerDelegate, print("Passkey sign-in succeeded.") self.transitionToUserViewController() } catch { - self.showAlert(for: "Passkey Sign-In failed", message: error.localizedDescription) - print("Finalize passkey sign-in failed: \(error.localizedDescription)") + presentPasskeyAlert("Passkey Sign-In failed", message: error.localizedDescription) + print("Passkey sign-in failed: \(error.localizedDescription)") } } return