diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 7a0c39340ae..d0523bccac0 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -355,6 +355,7 @@ final class AuthBackend: AuthBackendProtocol { .missingIosBundleIDError(message: serverDetailErrorMessage) case "MISSING_ANDROID_PACKAGE_NAME": return AuthErrorUtils .missingAndroidPackageNameError(message: serverDetailErrorMessage) + case "PASSKEY_ENROLLMENT_NOT_FOUND": return AuthErrorUtils.missingPasskeyEnrollment() case "UNAUTHORIZED_DOMAIN": return AuthErrorUtils .unauthorizedDomainError(message: serverDetailErrorMessage) case "INVALID_CONTINUE_URI": return AuthErrorUtils diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..c189856c48d --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift @@ -0,0 +1,72 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// The GCIP endpoint for finalizePasskeyEnrollment rpc +private let finalizePasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:finalize" + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeyEnrollmentResponse + + /// The raw user access token. + let idToken: String + /// The passkey name. + let name: String + /// The credential ID. + let credentialID: String + /// The CollectedClientData object from the authenticator. + let clientDataJSON: String + /// The attestation object from the authenticator. + let attestationObject: String + + init(idToken: String, + name: String, + credentialID: String, + clientDataJSON: String, + attestationObject: String, + requestConfiguration: AuthRequestConfiguration) { + self.idToken = idToken + self.name = name + self.credentialID = credentialID + self.clientDataJSON = clientDataJSON + self.attestationObject = attestationObject + super.init( + endpoint: finalizePasskeyEnrollmentEndPoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + var postBody: [String: AnyHashable] = [ + "idToken": idToken, + "name": name, + ] + let authAttestationResponse: [String: AnyHashable] = [ + "clientDataJSON": clientDataJSON, + "attestationObject": attestationObject, + ] + let authRegistrationResponse: [String: AnyHashable] = [ + "id": credentialID, + "response": authAttestationResponse, + ] + postBody["authenticatorRegistrationResponse"] = authRegistrationResponse + if let tenantId = tenantID { + postBody["tenantId"] = tenantId + } + return postBody + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..16fe2c78d6d --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift @@ -0,0 +1,34 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +struct FinalizePasskeyEnrollmentResponse: AuthRPCResponse { + /// The user raw access token. + let idToken: String + /// Refresh token for the authenticated user. + let refreshToken: String + + init(dictionary: [String: AnyHashable]) throws { + guard + let idToken = dictionary["idToken"] as? String, + let refreshToken = dictionary["refreshToken"] as? String + else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.idToken = idToken + self.refreshToken = refreshToken + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 4fb5795bcd5..63d419e740e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -92,6 +92,9 @@ struct GetAccountInfoResponse: AuthRPCResponse { let mfaEnrollments: [AuthProtoMFAEnrollment]? + /// A list of the user’s enrolled passkeys. + let enrolledPasskeys: [PasskeyInfo]? + /// Designated initializer. /// - Parameter dictionary: The provider user info data from endpoint. init(dictionary: [String: Any]) { @@ -133,6 +136,11 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } + if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] { + enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) } + } else { + enrolledPasskeys = nil + } } } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift new file mode 100644 index 00000000000..13f658c5749 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift @@ -0,0 +1,40 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public final class PasskeyInfo: NSObject, AuthProto, NSSecureCoding, Sendable { + /// The display name for this passkey. + public let name: String? + /// The credential ID used by the server. + public let credentialID: String? + required init(dictionary: [String: AnyHashable]) { + name = dictionary["name"] as? String + credentialID = dictionary["credentialId"] as? String + } + + // NSSecureCoding + public static var supportsSecureCoding: Bool { true } + + public func encode(with coder: NSCoder) { + coder.encode(name, forKey: "name") + coder.encode(credentialID, forKey: "credentialId") + } + + public required init?(coder: NSCoder) { + name = coder.decodeObject(of: NSString.self, forKey: "name") as String? + credentialID = coder.decodeObject(of: NSString.self, forKey: "credentialId") as String? + super.init() + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift index 5e310d4a656..951eed9d044 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/SetAccountInfoRequest.swift @@ -73,6 +73,8 @@ private let kDeleteProvidersKey = "deleteProvider" /// The key for the "returnSecureToken" value in the request. private let kReturnSecureTokenKey = "returnSecureToken" +private let kDeletePasskeysKey = "deletePasskey" + /// The key for the tenant id value in the request. private let kTenantIDKey = "tenantId" @@ -131,6 +133,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { /// The default value is `true` . var returnSecureToken: Bool = true + /// The list of credential IDs of the passkeys to be deleted. + var deletePasskeys: [String]? = nil + init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) { self.accessToken = accessToken super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration) @@ -183,6 +188,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest { if returnSecureToken { postBody[kReturnSecureTokenKey] = true } + if let deletePasskeys { + postBody[kDeletePasskeysKey] = deletePasskeys + } if let tenantID { postBody[kTenantIDKey] = tenantID } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..9a43ce09480 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift @@ -0,0 +1,46 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// The GCIP endpoint for startPasskeyEnrollment rpc +private let startPasskeyEnrollmentEndPoint = "accounts/passkeyEnrollment:start" + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +class StartPasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeyEnrollmentResponse + + /// The raw user access token + let idToken: String + + init(idToken: String, + requestConfiguration: AuthRequestConfiguration) { + self.idToken = idToken + super.init( + endpoint: startPasskeyEnrollmentEndPoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + var body: [String: AnyHashable] = [ + "idToken": idToken, + ] + if let tenantID = tenantID { + body["tenantId"] = tenantID + } + return body + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..5139e0a2eeb --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift @@ -0,0 +1,45 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +@available(iOS 15.0, macOS 12.0, tvOS 16.0, *) +struct StartPasskeyEnrollmentResponse: AuthRPCResponse { + /// The RP ID of the FIDO Relying Party. + let rpID: String + /// The user id + let userID: String + /// The FIDO challenge. + let challenge: String + + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary["credentialCreationOptions"] as? [String: Any] else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let rp = options["rp"] as? [String: Any], + let rpID = rp["id"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let user = options["user"] as? [String: Any], + let userID = user["id"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + guard let challenge = options["challenge"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.rpID = rpID + self.userID = userID + self.challenge = challenge + } +} diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 4ef324e177c..ba6036a5528 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -14,6 +14,10 @@ import Foundation +#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + import AuthenticationServices +#endif + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension User: NSSecureCoding {} @@ -63,6 +67,7 @@ extension User: NSSecureCoding {} /// /// This property is available on iOS only. @objc public private(set) var multiFactor: MultiFactor + public private(set) var enrolledPasskeys: [PasskeyInfo]? #endif /// [Deprecated] Updates the email address for the user. @@ -1047,6 +1052,110 @@ extension User: NSSecureCoding {} } } + // MARK: Passkey Implementation + + #if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst) + + /// A cached passkey name being passed from startPasskeyEnrollment(withName:) call and consumed + /// at finalizePasskeyEnrollment(withPlatformCredential:) call + private var passkeyName: String? + private let defaultPasskeyName: String = "Unnamed account (Apple)" + + /// Start the passkey enrollment creating a plaform public key creation request with the + /// challenge from GCIP backend. + /// - Parameter name: The name for the passkey to be created. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func startPasskeyEnrollment(withName name: String?) async throws + -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { + guard auth != nil else { + /// If auth is nil, this User object is in an invalid state for this operation. + fatalError( + "Firebase Auth Internal Error: Set user's auth property with non-nil instance. Cannot start passkey enrollment." + ) + } + let enrollmentIdToken = rawAccessToken() + let request = StartPasskeyEnrollmentRequest( + idToken: enrollmentIdToken, + requestConfiguration: requestConfiguration + ) + let response = try await backend.call(with: request) + guard let passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name + else { throw NSError( + domain: AuthErrorDomain, + code: AuthErrorCode.internalError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to unwrap passkey name"] + ) } + guard let challengeInData = Data(base64Encoded: response.challenge) else { + throw NSError( + domain: AuthErrorDomain, + code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 challenge from response."] + ) + } + guard let userIdInData = Data(base64Encoded: response.userID) else { + throw NSError( + domain: AuthErrorDomain, + code: AuthInternalErrorCode.RPCResponseDecodingError.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 userId from response."] + ) + } + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: response.rpID + ) + return provider.createCredentialRegistrationRequest( + challenge: challengeInData, + name: passkeyName, + userID: userIdInData + ) + } + + /// Finalize the passkey enrollment with the platfrom public key credential. + /// - Parameter platformCredential: The name for the passkey to be created. + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws + -> AuthDataResult { + let credentialID = platformCredential.credentialID.base64EncodedString() + let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() + let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() + + let request = FinalizePasskeyEnrollmentRequest( + idToken: rawAccessToken(), + name: passkeyName ?? "Unnamed account (Apple)", + credentialID: credentialID, + clientDataJSON: clientDataJSON, + attestationObject: attestationObject, + requestConfiguration: auth!.requestConfiguration + ) + let response = try await backend.call(with: request) + let user = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + public func unenrollPasskey(withCredentialID credentialID: String) async throws { + guard !credentialID.isEmpty else { + throw AuthErrorCode.missingPasskeyEnrollment + } + let request = SetAccountInfoRequest( + requestConfiguration: auth!.requestConfiguration + ) + request.deletePasskeys = [credentialID] + request.accessToken = rawAccessToken() + let response = try await backend.call(with: request) + _ = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: response.approximateExpirationDate, + refreshToken: response.refreshToken, + anonymous: false + ) + } + #endif + // MARK: Internal implementations below func rawAccessToken() -> String { @@ -1068,6 +1177,7 @@ extension User: NSSecureCoding {} tenantID = nil #if os(iOS) multiFactor = MultiFactor(withMFAEnrollments: []) + enrolledPasskeys = [] #endif uid = "" hasEmailPasswordCredential = false @@ -1302,6 +1412,7 @@ extension User: NSSecureCoding {} multiFactor = MultiFactor(withMFAEnrollments: enrollments) } multiFactor.user = self + enrolledPasskeys = user.enrolledPasskeys ?? [] #endif } @@ -1698,6 +1809,7 @@ extension User: NSSecureCoding {} private let kMetadataCodingKey = "metadata" private let kMultiFactorCodingKey = "multiFactor" private let kTenantIDCodingKey = "tenantID" + private let kEnrolledPasskeysKey = "passkeys" public static let supportsSecureCoding = true @@ -1720,6 +1832,7 @@ extension User: NSSecureCoding {} coder.encode(tokenService, forKey: kTokenServiceCodingKey) #if os(iOS) coder.encode(multiFactor, forKey: kMultiFactorCodingKey) + coder.encode(enrolledPasskeys, forKey: kEnrolledPasskeysKey) #endif } @@ -1749,6 +1862,9 @@ extension User: NSSecureCoding {} let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String #if os(iOS) let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey) + let passkeyAllowed: [AnyClass] = [NSArray.self, PasskeyInfo.self] + let passkeys = coder.decodeObject(of: passkeyAllowed, + forKey: kEnrolledPasskeysKey) as? [PasskeyInfo] #endif self.tokenService = tokenService uid = userID @@ -1782,6 +1898,7 @@ extension User: NSSecureCoding {} self.multiFactor = multiFactor ?? MultiFactor() super.init() multiFactor?.user = self + enrolledPasskeys = passkeys ?? [] #endif } } diff --git a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift index 5c78b223ab4..20bbf313082 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrorUtils.swift @@ -235,6 +235,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..59547067a82 100644 --- a/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift +++ b/FirebaseAuth/Sources/Swift/Utilities/AuthErrors.swift @@ -336,6 +336,8 @@ import Foundation /// Indicates that the reCAPTCHA SDK actions class failed to create. case recaptchaActionCreationFailed = 17210 + case missingPasskeyEnrollment = 17212 + /// Indicates an error occurred while attempting to access the keychain. case keychainError = 17995 @@ -528,6 +530,8 @@ import Foundation return kErrorSiteKeyMissing case .recaptchaActionCreationFailed: return kErrorRecaptchaActionCreationFailed + case .missingPasskeyEnrollment: + return kErrorMissingPasskeyEnrollment } } @@ -719,6 +723,8 @@ import Foundation return "ERROR_RECAPTCHA_SITE_KEY_MISSING" case .recaptchaActionCreationFailed: return "ERROR_RECAPTCHA_ACTION_CREATION_FAILED" + case .missingPasskeyEnrollment: + return "ERROR_PASSKEY_ENROLLMENT_NOT_FOUND" } } } @@ -996,3 +1002,6 @@ private let kErrorSiteKeyMissing = private let kErrorRecaptchaActionCreationFailed = "The reCAPTCHA SDK action class failed to initialize. See " + "https://cloud.google.com/recaptcha-enterprise/docs/instrument-ios-apps" + +private let 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..0acf2c2fd77 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift @@ -53,6 +53,9 @@ enum AuthMenu: String { case phoneEnroll case totpEnroll case multifactorUnenroll + case passkeySignUp + case passkeyEnroll + case passkeyUnenroll // More intuitively named getter for `rawValue`. var id: String { rawValue } @@ -139,6 +142,13 @@ 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 .passkeyUnenroll: + return "Unenroll Passkey" } } @@ -220,6 +230,12 @@ enum AuthMenu: String { self = .totpEnroll case "Multifactor unenroll": self = .multifactorUnenroll + case "Sign Up with Passkey": + self = .passkeySignUp + case "Enroll with Passkey": + self = .passkeyEnroll + case "Unenroll Passkey": + self = .passkeyUnenroll default: return nil } @@ -354,9 +370,19 @@ 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.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/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 240346b6975..c2c679b047f 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -191,6 +191,15 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { case .multifactorUnenroll: mfaUnenroll() + + case .passkeySignUp: + passkeySignUp() + + case .passkeyEnroll: + Task { await passkeyEnroll() } + + case .passkeyUnenroll: + Task { await passkeyUnenroll() } } } @@ -922,6 +931,72 @@ 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 #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + print("OS version is not supported for this action.") + return + } + guard let user = AppManager.shared.auth().currentUser else { + showAlert(for: "Please sign in first.") + return + } + guard let passkeyName = await showTextInputPrompt(with: "Passkey name") else { + print("Passkey enrollment cancelled: no name entered.") + 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 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) { @@ -1019,7 +1094,7 @@ extension AuthViewController: LoginDelegate { } } -// MARK: - Implementing Sign in with Apple with Firebase +// MARK: - Implementing Passkeys and Sign in with Apple with Firebase extension AuthViewController: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { @@ -1027,6 +1102,27 @@ 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 + } + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { print("Unable to retrieve AppleIDCredential") @@ -1074,10 +1170,10 @@ extension AuthViewController: ASAuthorizationControllerDelegate, func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) { - // Ensure that you have: + print("ASAuthorization 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/Unit/FinalizePasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..e9a0504c3b8 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentRequestTests.swift @@ -0,0 +1,114 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(macOS) + + @testable import FirebaseAuth + import FirebaseCore + import Foundation + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class FinalizePasskeyEnrollmentRequestTests: XCTestCase { + private var request: FinalizePasskeyEnrollmentRequest! + private var fakeConfig: AuthRequestConfiguration! + + override func setUp() { + super.setUp() + fakeConfig = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID" + ) + } + + override func tearDown() { + request = nil + fakeConfig = nil + super.tearDown() + } + + func testInitWithValidParameters() { + request = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: fakeConfig + ) + + XCTAssertEqual(request.idToken, "ID_TOKEN") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertEqual(request.credentialID, "CRED_ID") + XCTAssertEqual(request.clientDataJSON, "CLIENT_JSON") + XCTAssertEqual(request.attestationObject, "ATTEST_OBJ") + XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:finalize") + XCTAssertTrue(request.useIdentityPlatform) + } + + func testUnencodedHTTPRequestBodyWithoutTenantId() { + request = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: fakeConfig + ) + + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "ID_TOKEN") + XCTAssertEqual(body?["name"] as? String, "MyPasskey") + + let authReg = body?["authenticatorRegistrationResponse"] as? [String: AnyHashable] + XCTAssertNotNil(authReg) + XCTAssertEqual(authReg?["id"] as? String, "CRED_ID") + + let authResp = authReg?["response"] as? [String: AnyHashable] + XCTAssertEqual(authResp?["clientDataJSON"] as? String, "CLIENT_JSON") + XCTAssertEqual(authResp?["attestationObject"] as? String, "ATTEST_OBJ") + + XCTAssertNil(body?["tenantId"]) + } + + func testUnencodedHTTPRequestBodyWithTenantId() { + // setting up fake auth to set tenantId + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = AuthTests.kFakeAPIKey + options.projectID = "myProjectID" + let name = "test-AuthTests\(AuthTests.testNum)" + AuthTests.testNum = AuthTests.testNum + 1 + let fakeAuth = Auth(app: FirebaseApp(instanceWithName: name, options: options)) + fakeAuth.tenantID = "TEST_TENANT" + let configWithTenant = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID", + auth: fakeAuth + ) + request = FinalizePasskeyEnrollmentRequest( + idToken: "ID_TOKEN", + name: "MyPasskey", + credentialID: "CRED_ID", + clientDataJSON: "CLIENT_JSON", + attestationObject: "ATTEST_OBJ", + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..49d7625fc12 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeyEnrollmentResponseTests.swift @@ -0,0 +1,60 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(macOS) + + @testable import FirebaseAuth + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class FinalizePasskeyEnrollmentResponseTests: XCTestCase { + private func makeValidDictionary() -> [String: AnyHashable] { + return [ + "idToken": "FAKE_ID_TOKEN" as AnyHashable, + "refreshToken": "FAKE_REFRESH_TOKEN" as AnyHashable, + ] + } + + func testInitWithValidDictionary() throws { + let response = try FinalizePasskeyEnrollmentResponse( + dictionary: makeValidDictionary() + ) + XCTAssertEqual(response.idToken, "FAKE_ID_TOKEN") + XCTAssertEqual(response.refreshToken, "FAKE_REFRESH_TOKEN") + } + + func testInitWithMissingIdTokenThrowsError() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "idToken") + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: dict) + ) + } + + func testInitWithMissingRefreshTokenThrowsError() { + var dict = makeValidDictionary() + dict.removeValue(forKey: "refreshToken") + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: dict) + ) + } + + func testInitWithEmptyDictionaryThrowsError() { + XCTAssertThrowsError( + try FinalizePasskeyEnrollmentResponse(dictionary: [:]) + ) + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index ec5eba4e2d0..764fdaad55d 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -80,6 +80,15 @@ class GetAccountInfoTests: RPCBaseTests { let kEmailVerifiedKey = "emailVerified" let kLocalIDKey = "localId" let kTestLocalID = "testLocalId" + let kPasskeysKey = "passkeys" + + // Fake PasskeyInfo + let testCredentialId = "credential_id" + let testPasskeyName = "Test Passkey" + let passkeys = [[ + "credentialId": testCredentialId, + "name": testPasskeyName, + ]] let usersIn = [[ kProviderUserInfoKey: [[ @@ -95,6 +104,7 @@ class GetAccountInfoTests: RPCBaseTests { kPhotoUrlKey: kTestPhotoURL, kEmailVerifiedKey: true, kPasswordHashKey: kTestPasswordHash, + kPasskeysKey: passkeys, ] as [String: Any]] let rpcIssuer = try XCTUnwrap(self.rpcIssuer) @@ -119,6 +129,43 @@ class GetAccountInfoTests: RPCBaseTests { XCTAssertEqual(firstProviderUser.email, kTestEmail) XCTAssertEqual(firstProviderUser.providerID, kTestProviderID) XCTAssertEqual(firstProviderUser.federatedID, kTestFederatedID) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 1) + XCTAssertEqual(enrolledPasskeys[0].credentialID, testCredentialId) + XCTAssertEqual(enrolledPasskeys[0].name, testPasskeyName) + } + + func testInitWithMultipleEnrolledPasskeys() throws { + let passkey1: [String: AnyHashable] = ["name": "passkey1", "credentialId": "cred1"] + let passkey2: [String: AnyHashable] = ["name": "passkey2", "credentialId": "cred2"] + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + "passkeys": [passkey1, passkey2], + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + let enrolledPasskeys = try XCTUnwrap(firstUser.enrolledPasskeys) + XCTAssertEqual(enrolledPasskeys.count, 2) + XCTAssertEqual(enrolledPasskeys[0].name, "passkey1") + XCTAssertEqual(enrolledPasskeys[0].credentialID, "cred1") + XCTAssertEqual(enrolledPasskeys[1].name, "passkey2") + XCTAssertEqual(enrolledPasskeys[1].credentialID, "cred2") + } + + func testInitWithNoEnrolledPasskeys() throws { + let userDict: [String: AnyHashable] = [ + "localId": "user123", + "email": "user@example.com", + // No "passkeys" present + ] + let dict: [String: AnyHashable] = ["users": [userDict]] + let response = try GetAccountInfoResponse(dictionary: dict) + let users = try XCTUnwrap(response.users) + let firstUser = try XCTUnwrap(users.first) + XCTAssertNil(firstUser.enrolledPasskeys) } private func makeGetAccountInfoRequest() -> GetAccountInfoRequest { diff --git a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift index b3ccb3e8ad1..7283c2e2c71 100644 --- a/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/SetAccountInfoTests.swift @@ -64,6 +64,8 @@ class SetAccountInfoTests: RPCBaseTests { let kTestDeleteProviders = "TestDeleteProviders" let kReturnSecureTokenKey = "returnSecureToken" let kTestAccessToken = "accessToken" + let kDeletePasskeysKey = "deletePasskey" + let kDeletePasskey = "credential_id" let kExpectedAPIURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/setAccountInfo?key=APIKey" @@ -82,6 +84,7 @@ class SetAccountInfoTests: RPCBaseTests { request.captchaResponse = kTestCaptchaResponse request.deleteAttributes = [kTestDeleteAttributes] request.deleteProviders = [kTestDeleteProviders] + request.deletePasskeys = [kDeletePasskey] try await checkRequest( request: request, @@ -105,6 +108,7 @@ class SetAccountInfoTests: RPCBaseTests { XCTAssertEqual(decodedRequest[kDeleteAttributesKey] as? [String], [kTestDeleteAttributes]) XCTAssertEqual(decodedRequest[kDeleteProvidersKey] as? [String], [kTestDeleteProviders]) XCTAssertEqual(decodedRequest[kReturnSecureTokenKey] as? Bool, true) + XCTAssertEqual(decodedRequest[kDeletePasskeysKey] as? [String], [kDeletePasskey]) } func testSetAccountInfoErrors() async throws { @@ -122,6 +126,7 @@ class SetAccountInfoTests: RPCBaseTests { let kInvalidRecipientEmailErrorMessage = "INVALID_RECIPIENT_EMAIL" let kWeakPasswordErrorMessage = "WEAK_PASSWORD : Password should be at least 6 characters" let kWeakPasswordClientErrorMessage = "Password should be at least 6 characters" + let kInvalidCredentialIdForPasskeyUnenroll = "PASSKEY_ENROLLMENT_NOT_FOUND" try await checkBackendError( request: setAccountInfoRequest(), @@ -189,6 +194,11 @@ class SetAccountInfoTests: RPCBaseTests { message: kInvalidRecipientEmailErrorMessage, errorCode: AuthErrorCode.invalidRecipientEmail ) + try await checkBackendError( + request: setAccountInfoRequest(), + message: kInvalidCredentialIdForPasskeyUnenroll, + errorCode: AuthErrorCode.missingPasskeyEnrollment + ) } /** @fn testSuccessfulSetAccountInfoResponse diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift new file mode 100644 index 00000000000..345425fe2b6 --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentRequestTests.swift @@ -0,0 +1,88 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(iOS) || os(tvOS) || os(macOS) + + @testable import FirebaseAuth + import FirebaseCore + import Foundation + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class StartPasskeyEnrollmentRequestTests: XCTestCase { + private var request: StartPasskeyEnrollmentRequest! + private var fakeConfig: AuthRequestConfiguration! + + override func setUp() { + super.setUp() + fakeConfig = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID" + ) + } + + override func tearDown() { + request = nil + fakeConfig = nil + super.tearDown() + } + + func testInitWithValidIdTokenAndConfiguration() { + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: fakeConfig + ) + XCTAssertEqual(request.idToken, "FAKE_ID_TOKEN") + XCTAssertEqual(request.endpoint, "accounts/passkeyEnrollment:start") + XCTAssertTrue(request.useIdentityPlatform) + } + + func testUnencodedHTTPRequestBodyWithoutTenantId() { + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: fakeConfig + ) + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN") + XCTAssertNil(body?["tenantId"]) + } + + func testUnencodedHTTPRequestBodyWithTenantId() { + // setting up fake auth to set tenantId + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = AuthTests.kFakeAPIKey + options.projectID = "myProjectID" + let name = "test-AuthTests\(AuthTests.testNum)" + AuthTests.testNum = AuthTests.testNum + 1 + let fakeAuth = Auth(app: FirebaseApp(instanceWithName: name, options: options)) + fakeAuth.tenantID = "TEST_TENANT" + let configWithTenant = AuthRequestConfiguration( + apiKey: "FAKE_API_KEY", + appID: "FAKE_APP_ID", + auth: fakeAuth + ) + request = StartPasskeyEnrollmentRequest( + idToken: "FAKE_ID_TOKEN", + requestConfiguration: configWithTenant + ) + let body = request.unencodedHTTPRequestBody + XCTAssertNotNil(body) + XCTAssertEqual(body?["idToken"] as? String, "FAKE_ID_TOKEN") + XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift new file mode 100644 index 00000000000..06e0f239caf --- /dev/null +++ b/FirebaseAuth/Tests/Unit/StartPasskeyEnrollmentResponseTests.swift @@ -0,0 +1,143 @@ +// 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": "example.com"] as [String: AnyHashable], + "user": ["id": "USER_123"] as [String: AnyHashable], + "challenge": "FAKE_CHALLENGE" as String, + ] as [String: AnyHashable], + ] + } + + func testInitWithValidDictionary() throws { + let response = try StartPasskeyEnrollmentResponse(dictionary: makeValidDictionary()) + XCTAssertEqual(response.rpID, "example.com") + XCTAssertEqual(response.userID, "USER_123") + XCTAssertEqual(response.challenge, "FAKE_CHALLENGE") + } + + func testInitWithMissingCredentialCreationOptions() { + let invalidDict: [String: AnyHashable] = [:] + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: invalidDict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingRp() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any] { + options.removeValue(forKey: "rp") + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingRpId() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any], + var rp = options["rp"] as? [String: Any] { + rp.removeValue(forKey: "id") + options["rp"] = rp + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingUser() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any] { + options.removeValue(forKey: "user") + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingUserId() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: Any], + var user = options["user"] as? [String: Any] { + user.removeValue(forKey: "id") + options["user"] = user + dict["credentialCreationOptions"] = options as? AnyHashable + } + XCTAssertThrowsError(try StartPasskeyEnrollmentResponse(dictionary: dict)) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testInitWithMissingChallenge() { + var dict = makeValidDictionary() + if var options = dict["credentialCreationOptions"] as? [String: AnyHashable] { + options.removeValue(forKey: "challenge") + dict["credentialCreationOptions"] = options as [String: AnyHashable] + } + XCTAssertThrowsError( + try StartPasskeyEnrollmentResponse(dictionary: dict) + ) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AuthErrorDomain) + XCTAssertEqual(nsError.code, AuthErrorCode.internalError.rawValue) + } + } + + func testSuccessfulStartPasskeyEnrollmentResponse() async throws { + let expectedRpID = "example.com" + let expectedUserID = "USER_123" + let expectedChallenge = "FAKE_CHALLENGE" + rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": expectedRpID], + "user": ["id": expectedUserID], + "challenge": expectedChallenge, + ], + ]) + } + let request = StartPasskeyEnrollmentRequest( + idToken: "DUMMY_ID_TOKEN", + requestConfiguration: AuthRequestConfiguration(apiKey: "API_KEY", appID: "APP_ID") + ) + let response = try await authBackend.call(with: request) + XCTAssertEqual(response.rpID, expectedRpID) + XCTAssertEqual(response.userID, expectedUserID) + XCTAssertEqual(response.challenge, expectedChallenge) + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/UserTests.swift b/FirebaseAuth/Tests/Unit/UserTests.swift index c610e04a0bc..138926eee37 100644 --- a/FirebaseAuth/Tests/Unit/UserTests.swift +++ b/FirebaseAuth/Tests/Unit/UserTests.swift @@ -1891,3 +1891,306 @@ class UserTests: RPCBaseTests { } } } + +#if os(iOS) + import AuthenticationServices + + @available(iOS 15.0, *) + extension UserTests { + func testStartPasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + do { + // Mock backend response for StartPasskeyEnrollment + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? StartPasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], // Base64 userID + "challenge": "Q2hhbGxlbmdl", // Base64 challenge + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "MyPasskey") + XCTAssertEqual(request.name, "MyPasskey") + XCTAssertNotNil(request.challenge) + XCTAssertNotNil(request.userID) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithNilNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: nil) + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentWithEmptyNameSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond( + withJSON: [ + "credentialCreationOptions": [ + "rp": ["id": "example.com"], + "user": ["id": "VXNlcklE"], + "challenge": "Q2hhbGxlbmdl", + ], + ] + ) + } + Task { + let request = try await user.startPasskeyEnrollment(withName: "") + XCTAssertEqual(request.name, "Unnamed account (Apple)") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testStartPasskeyEnrollmentFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "OPERATION_NOT_ALLOWED") + } + Task { + do { + _ = try await user.startPasskeyEnrollment(withName: "FailCase") + XCTFail("Expected to throw error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.operationNotAllowed.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + /// Helper mock to simulate platform credential fields + struct MockPlatformCredential { + let credentialID: Data + let rawClientDataJSON: Data + let rawAttestationObject: Data? + } + + /// Helper to build FinalizePasskeyEnrollmentRequest manually + private func buildFinalizeRequest(user: User, + mock: MockPlatformCredential) + -> FinalizePasskeyEnrollmentRequest { + return FinalizePasskeyEnrollmentRequest( + idToken: RPCBaseTests.kFakeAccessToken, + name: "MyPasskey", + credentialID: mock.credentialID.base64EncodedString(), + clientDataJSON: mock.rawClientDataJSON.base64EncodedString(), + attestationObject: mock.rawAttestationObject?.base64EncodedString() ?? "", + requestConfiguration: auth!.requestConfiguration + ) + } + + func testFinalizePasskeyEnrollmentSuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + // Mock backend response + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? FinalizePasskeyEnrollmentRequest) + XCTAssertEqual(request.idToken, RPCBaseTests.kFakeAccessToken) + XCTAssertNotNil(request.credentialID) + XCTAssertNotNil(request.clientDataJSON) + XCTAssertNotNil(request.attestationObject) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + ] + ) + } + + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: Data("attestation".utf8) + ) + + Task { + let request = self.buildFinalizeRequest(user: user, mock: mock) + let response = try await self.authBackend.call(with: request) + let userResult = try await self.auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + XCTAssertEqual(userResult.refreshToken, self.kRefreshToken) + expectation.fulfill() + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeyEnrollmentFailureWithInvalidToken() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "INVALID_ID_TOKEN") + } + + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: Data("attestation".utf8) + ) + + Task { + do { + let request = self.buildFinalizeRequest(user: user, mock: mock) + _ = try await self.authBackend.call(with: request) + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.invalidUserToken.rawValue) + expectation.fulfill() + } + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testFinalizePasskeyEnrollmentFailureWithoutAttestation() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer.respond(serverErrorMessage: "MISSING_ATTESTATION_OBJECT") + } + + // Missing attestationObject + let mock = MockPlatformCredential( + credentialID: Data("credentialID".utf8), + rawClientDataJSON: Data("clientData".utf8), + rawAttestationObject: nil + ) + + Task { + do { + let request = self.buildFinalizeRequest(user: user, mock: mock) + _ = try await self.authBackend.call(with: request) + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.internalError.rawValue) + expectation.fulfill() + } + } + } + + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeySuccess() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + let request = try XCTUnwrap(self.rpcIssuer?.request as? SetAccountInfoRequest) + XCTAssertEqual(request.deletePasskeys, ["testCredentialID"]) + XCTAssertEqual(request.accessToken, RPCBaseTests.kFakeAccessToken) + return try self.rpcIssuer.respond( + withJSON: [ + "idToken": RPCBaseTests.kFakeAccessToken, + "refreshToken": self.kRefreshToken, + "approximateExpirationDate": "\(Date().timeIntervalSince1970 * 1000)", + ] + ) + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "testCredentialID") + expectation.fulfill() + } catch { + XCTFail("Should not throw error: \(error)") + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeyNotFoundFailure() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + self.rpcIssuer.respondBlock = { + try self.rpcIssuer + .respond( + serverErrorMessage: "PASSKEY_ENROLLMENT_NOT_FOUND" + ) + } + Task { + do { + try await user.unenrollPasskey(withCredentialID: "invalidCredentialID") + XCTFail("Expected error not thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, AuthErrorDomain) + XCTAssertEqual( + error.localizedDescription, + "Cannot find the passkey linked to the current account." + ) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 5) + } + + func testUnenrollPasskeyFailure_EmptyCredentialID() async throws { + setFakeGetAccountProvider() + let expectation = expectation(description: #function) + signInWithEmailPasswordReturnFakeUser { user in + Task { + do { + try await user.unenrollPasskey(withCredentialID: "") + XCTFail("Expected error for empty credentialID") + } catch let error as NSError { + XCTAssertEqual(error.code, AuthErrorCode.missingPasskeyEnrollment.rawValue) + expectation.fulfill() + } + } + } + await fulfillment(of: [expectation], timeout: 2) + } + } +#endif