diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index d198f5418f5..671ecec199d 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,62 @@ 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 + ) + try await user.reload() + try await updateCurrentUser(user) + 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..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 @@ -440,6 +441,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/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/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift new file mode 100644 index 00000000000..771849ec4c0 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.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/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. + */ +import Foundation + +/// 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 + 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]? { + let assertion: [String: AnyHashable] = [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": userId, + ] + let authResponse: [String: AnyHashable] = [ + "id": credentialID, + "response": assertion, + ] + var postBody: [String: AnyHashable] = [ + "authenticatorAuthenticationResponse": authResponse, + ] + 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 new file mode 100644 index 00000000000..6d0b772ee9c --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.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/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. + */ + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +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/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 4fb5795bcd5..012d4cda950 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["passkeyInfo"] 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/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift new file mode 100644 index 00000000000..e36dc40caf8 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -0,0 +1,38 @@ +// 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 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 [:] + } + 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..7461425312e --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -0,0 +1,35 @@ +// 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 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/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 4ef324e177c..75aa3939eeb 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,123 @@ 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) + passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : 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 ?? defaultPasskeyName, + 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 { + 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 ?? defaultPasskeyName, + 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 + ) + defer { self.passkeyName = nil } + try await user.reload() + try await auth!.updateCurrentUser(user) + 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) + 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 + // MARK: Internal implementations below func rawAccessToken() -> String { @@ -1068,6 +1190,7 @@ extension User: NSSecureCoding {} tenantID = nil #if os(iOS) multiFactor = MultiFactor(withMFAEnrollments: []) + enrolledPasskeys = [] #endif uid = "" hasEmailPasswordCredential = false @@ -1302,6 +1425,7 @@ extension User: NSSecureCoding {} multiFactor = MultiFactor(withMFAEnrollments: enrollments) } multiFactor.user = self + enrolledPasskeys = user.enrolledPasskeys ?? [] #endif } @@ -1698,6 +1822,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 +1845,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 +1875,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 +1911,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 5c78b223ab4..15a1317aa1d 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) } @@ -235,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 dde29c11ab3..d4a966af4b9 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -336,6 +336,12 @@ 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 @@ -528,6 +534,10 @@ import Foundation return kErrorSiteKeyMissing case .recaptchaActionCreationFailed: return kErrorRecaptchaActionCreationFailed + case .invalidAuthenticatorResponse: + return kErrorInvalidAuthenticatorResponse + case .missingPasskeyEnrollment: + return kErrorMissingPasskeyEnrollment } } @@ -719,6 +729,10 @@ import Foundation return "ERROR_RECAPTCHA_SITE_KEY_MISSING" case .recaptchaActionCreationFailed: return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED" + case .invalidAuthenticatorResponse: + return "ERROR_INVALID_AUTHENTICATOR_RESPONSE" + case .missingPasskeyEnrollment: + return "ERROR_PASSKEY_ENROLLMENT_NOT_FOUND" } } } @@ -996,3 +1010,9 @@ 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." + +private let kErrorMissingPasskeyEnrollment = + "Cannot find the passkey linked to the current account." 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 diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift index d7c893d20c8..c22286cf996 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExampleUITests/AuthenticationExampleUITests.swift @@ -309,6 +309,40 @@ class AuthenticationExampleUITests: XCTestCase { ) } + #if os(iOS) || os(tvOS) || os(macOS) + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + 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." + ) + } + #endif + // MARK: - Private Helpers private func signOut() { diff --git a/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift new file mode 100644 index 00000000000..6106e48d852 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift @@ -0,0 +1,235 @@ +/* + * 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) + + 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 testStartPasskeyEnrollmentResponseSuccess() 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") + XCTAssertNotNil(request as ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest) + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testStartPasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + 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, *) + 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)") + } + 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() + } + + @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") + XCTAssertNotNil( + assertionRequest as 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 testFinalizePasskeySignInFailureIncorrectAttestation() 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 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 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/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/FinalizePasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift new file mode 100644 index 00000000000..277ba0f36b7 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift @@ -0,0 +1,134 @@ +// 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 + ) + 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" + ) + options.apiKey = "FAKE_API_KEY" + options.projectID = "myProjectID" + 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: auth + ) + request = FinalizePasskeySignInRequest( + credentialID: kCredentialID, + clientDataJSON: kClientDataJSON, + authenticatorData: kAuthenticatorData, + signature: kSignature, + userId: kUserId, + requestConfiguration: configWithTenant + ) + 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) + } + } + +#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/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index ec5eba4e2d0..d7f77c7c1f8 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -60,66 +60,116 @@ 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" + #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, + 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, - ] as [String: Any]] - let rpcIssuer = try XCTUnwrap(self.rpcIssuer) + 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]) + 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) - } + 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) + } + #endif private func makeGetAccountInfoRequest() -> GetAccountInfoRequest { return GetAccountInfoRequest(accessToken: kTestAccessToken, diff --git a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift index b3ccb3e8ad1..65107e0382c 100644 --- a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift @@ -36,160 +36,173 @@ 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 kExpectedAPIURL = - "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" + #if os(iOS) || os(tvOS) || os(macOS) - 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] + 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" - 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) - } + 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] - 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" + 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 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 - ) - } + 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 + ) + } + #endif /** @fn testSuccessfulSetAccountInfoResponse @brief This test simulates a successful @c SetAccountInfo flow. 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/StartPasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift new file mode 100644 index 00000000000..8d3b370b1bc --- /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) + XCTAssertEqual(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 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