diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 20dbe473f1a..226d0ea3416 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -1695,6 +1695,8 @@ extension Auth: AuthInterop { refreshToken: response.refreshToken, anonymous: false ) + try await user.reload() + try await updateCurrentUser(user) return AuthDataResult(withUser: user, additionalUserInfo: nil) } #endif diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift index 1b5cc3c68d1..771849ec4c0 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInRequest.swift @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import Foundation /// The GCIP endpoint for finalizePasskeySignIn rpc private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize" @@ -50,19 +51,21 @@ class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { } var unencodedHTTPRequestBody: [String: AnyHashable]? { + let assertion: [String: AnyHashable] = [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature, + "userHandle": userId, + ] + let authResponse: [String: AnyHashable] = [ + "id": credentialID, + "response": assertion, + ] var postBody: [String: AnyHashable] = [ - "authenticatorAssertionResponse": [ - "credentialId": credentialID, - "authenticatorAssertionResponse": [ - "clientDataJSON": clientDataJSON, - "authenticatorData": authenticatorData, - "signature": signature, - "userHandle": userId, - ], - ] as [String: AnyHashable], + "authenticatorAuthenticationResponse": authResponse, ] - if let tenantID = tenantID { - postBody["tenantId"] = tenantID + if let tenant = tenantID { + postBody["tenantId"] = tenant } return postBody } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift index f172a9a3259..6d0b772ee9c 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeySignInResponse.swift @@ -14,6 +14,8 @@ * limitations under the License. */ +import Foundation + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) struct FinalizePasskeySignInResponse: AuthRPCResponse { /// The user raw access token. diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift index 63d419e740e..012d4cda950 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/GetAccountInfoResponse.swift @@ -136,7 +136,7 @@ struct GetAccountInfoResponse: AuthRPCResponse { } else { mfaEnrollments = nil } - if let passkeyEnrollmentData = dictionary["passkeys"] as? [[String: AnyHashable]] { + if let passkeyEnrollmentData = dictionary["passkeyInfo"] as? [[String: AnyHashable]] { enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) } } else { enrolledPasskeys = nil diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift index 4b317460681..e36dc40caf8 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInRequest.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + /// The GCIP endpoint for startPasskeySignIn rpc private let startPasskeySignInEndpoint = "accounts/passkeySignIn:start" @@ -29,7 +31,7 @@ class StartPasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest { var unencodedHTTPRequestBody: [String: AnyHashable]? { guard let tenantID = tenantID else { - return nil + return [:] } return ["tenantId": tenantID] } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift index 096e674ee56..7461425312e 100644 --- a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeySignInResponse.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) struct StartPasskeySignInResponse: AuthRPCResponse { /// The RP ID of the FIDO Relying Party diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index ba6036a5528..c2378fcad1c 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1079,12 +1079,7 @@ extension User: NSSecureCoding {} requestConfiguration: requestConfiguration ) let response = try await backend.call(with: request) - guard let passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name - else { throw NSError( - domain: AuthErrorDomain, - code: AuthErrorCode.internalError.rawValue, - userInfo: [NSLocalizedDescriptionKey: "Failed to unwrap passkey name"] - ) } + passkeyName = (name?.isEmpty ?? true) ? defaultPasskeyName : name guard let challengeInData = Data(base64Encoded: response.challenge) else { throw NSError( domain: AuthErrorDomain, @@ -1104,7 +1099,7 @@ extension User: NSSecureCoding {} ) return provider.createCredentialRegistrationRequest( challenge: challengeInData, - name: passkeyName, + name: passkeyName ?? defaultPasskeyName, userID: userIdInData ) } @@ -1114,13 +1109,26 @@ extension User: NSSecureCoding {} @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) public func finalizePasskeyEnrollment(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws -> AuthDataResult { + guard + !platformCredential.credentialID.isEmpty, + !platformCredential.rawClientDataJSON.isEmpty, + let attestation = platformCredential.rawAttestationObject, + !attestation.isEmpty + else { + throw NSError( + domain: AuthErrorDomain, + code: AuthErrorCode.internalError.rawValue, + userInfo: [NSLocalizedDescriptionKey: + "Invalid platform credential: missing credentialID, clientDataJSON, or attestationObject."] + ) + } let credentialID = platformCredential.credentialID.base64EncodedString() let clientDataJSON = platformCredential.rawClientDataJSON.base64EncodedString() let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() let request = FinalizePasskeyEnrollmentRequest( idToken: rawAccessToken(), - name: passkeyName ?? "Unnamed account (Apple)", + name: passkeyName ?? defaultPasskeyName, credentialID: credentialID, clientDataJSON: clientDataJSON, attestationObject: attestationObject, @@ -1133,6 +1141,9 @@ extension User: NSSecureCoding {} refreshToken: response.refreshToken, anonymous: false ) + defer { self.passkeyName = nil } + try await user.reload() + try await auth!.updateCurrentUser(user) return AuthDataResult(withUser: user, additionalUserInfo: nil) } @@ -1147,12 +1158,14 @@ extension User: NSSecureCoding {} request.deletePasskeys = [credentialID] request.accessToken = rawAccessToken() let response = try await backend.call(with: request) - _ = try await auth!.completeSignIn( + let user = try await auth!.completeSignIn( withAccessToken: response.idToken, accessTokenExpirationDate: response.approximateExpirationDate, refreshToken: response.refreshToken, anonymous: false ) + try await user.reload() + try await auth!.updateCurrentUser(user) } #endif 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/SwiftApiTests/PasskeyTests.swift b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift new file mode 100644 index 00000000000..bee4e2f4d91 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift @@ -0,0 +1,244 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if os(iOS) || os(tvOS) || os(macOS) + + import AuthenticationServices + @testable import FirebaseAuth + import Foundation + import XCTest + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + class PasskeyTests: TestsBase { + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testStartPasskeyEnrollmentSuccess() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let request = try await user.startPasskeyEnrollment(withName: "Test1Passkey") + XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") + XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") + XCTAssertNotNil(request.userID, "userID should be present") + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func DRAFTtestStartPasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + // user not reloaded hence id token not updated + let request = try await user.startPasskeyEnrollment(withName: "Test2Passkey") + XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") + XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") + XCTAssertNotNil(request.userID, "userID should be present") + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + let badRequest = FinalizePasskeyEnrollmentRequest( + idToken: "invalidToken", + name: "fakeName", + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + attestationObject: "fakeAttestion".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: user.requestConfiguration + ) + do { + _ = try await user.backend.call(with: badRequest) + XCTFail("Expected invalid_user_token") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .invalidUserToken, "Expected .invalidUserToken, got \(code)") + } + } + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeyEnrollmentFailureWithoutAttestation() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let token = user.rawAccessToken() + let badRequest = FinalizePasskeyEnrollmentRequest( + idToken: token, + name: "fakeName", + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + attestationObject: "fakeAttestion".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: user.requestConfiguration + ) + do { + _ = try await user.backend.call(with: badRequest) + XCTFail("Expected invalid_authenticator_response") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .invalidAuthenticatorResponse, + "Expected .invalidAuthenticatorResponse, got \(code)") + } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "DURING PASSKEY ENROLLMENT AND SIGN IN, THE AUTHENTICATOR RESPONSE IS NOT PARSEABLE, MISSING REQUIRED FIELDS, OR CERTAIN FIELDS ARE INVALID VALUES THAT COMPROMISE THE SECURITY OF THE SIGN-IN OR ENROLLMENT." + ), + "Expected INVALID_AUTHENTICATOR_RESPONSE, got: \(message)" + ) + } + try? await deleteCurrentUserAsync() + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testStartPasskeySignInSuccess() async throws { + let assertionRequest = try await Auth.auth().startPasskeySignIn() + XCTAssertFalse(assertionRequest.relyingPartyIdentifier.isEmpty, + "rpID should be non-empty") + XCTAssertFalse(assertionRequest.challenge.isEmpty, + "challenge should be non-empty") + XCTAssertTrue(assertionRequest + is ASAuthorizationPlatformPublicKeyCredentialAssertionRequest) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeySignInFailureInvalidAttestation() async throws { + let auth = Auth.auth() + let config = auth.requestConfiguration + let badRequest = FinalizePasskeySignInRequest( + credentialID: "fakeCredentialId".data(using: .utf8)!.base64EncodedString(), + clientDataJSON: "fakeClientData".data(using: .utf8)!.base64EncodedString(), + authenticatorData: "fakeAuthenticatorData".data(using: .utf8)!.base64EncodedString(), + signature: "fakeSignature".data(using: .utf8)!.base64EncodedString(), + userId: "fakeUID".data(using: .utf8)!.base64EncodedString(), + requestConfiguration: config + ) + do { + _ = try await auth.backend.call(with: badRequest) + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .userNotFound) + } + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testFinalizePasskeySignInFailureWithoutAttestation() async throws { + let auth = Auth.auth() + let config = auth.requestConfiguration + let badRequest = FinalizePasskeySignInRequest( + credentialID: "", + clientDataJSON: "", + authenticatorData: "", + signature: "", + userId: "", + requestConfiguration: config + ) + do { + _ = try await auth.backend.call(with: badRequest) + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .userNotFound) + } + } + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func DRAFTtestUnenrollPasskeySuccess() async throws { + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testCredentialId = "cred_id" + let auth = Auth.auth() + try await auth.signIn(withEmail: testEmail, password: testPassword) + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + let prevPasskeys = user.enrolledPasskeys ?? [] + XCTAssertTrue( + prevPasskeys.contains { $0.credentialID == testCredentialId }, + "Precondition failed: passkey \(testCredentialId) is not enrolled on this account." + ) + let prevCount = prevPasskeys.count + let _ = try await user.unenrollPasskey(withCredentialID: testCredentialId) + try? await user.reload() + let updatedPasskeys = user.enrolledPasskeys ?? [] + XCTAssertFalse( + updatedPasskeys.contains { $0.credentialID == testCredentialId }, + "Passkey \(testCredentialId) should be removed after unenroll." + ) + XCTAssertEqual( + updatedPasskeys.count, prevCount - 1, + "Exactly one passkey should have been removed." + ) + XCTAssertNil( + updatedPasskeys.first { $0.credentialID == testCredentialId }, + "Passkey \(testCredentialId) should not exist after unenroll." + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 16.0, *) + func testUnenrollPasskeyFailure() async throws { + let testEmail = "sample.ios.auth@gmail.com" + let testPassword = "sample.ios.auth" + let testCredentialId = "FCBopZ3mzjfPNXqWXXjAM/ZnnlQ=" + let auth = Auth.auth() + try await auth.signIn(withEmail: testEmail, password: testPassword) + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + try? await user.reload() + do { + let _ = try await user.unenrollPasskey(withCredentialID: testCredentialId) + XCTFail("Expected invalid passkey error") + } catch { + let ns = error as NSError + if let code = AuthErrorCode(rawValue: ns.code) { + XCTAssertEqual(code, .missingPasskeyEnrollment, + "Expected .missingPasskeyEnrollment, got \(code)") + } + let message = (ns.userInfo[NSLocalizedDescriptionKey] as? String ?? "").uppercased() + XCTAssertTrue( + message + .contains( + "CANNOT FIND THE PASSKEY LINKED TO THE CURRENT ACCOUNT" + ), + "Expected Missing Passkey Enrollment error, got: \(message)" + ) + } + } + } + +#endif diff --git a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift index a077f1784fb..277ba0f36b7 100644 --- a/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/FinalizePasskeySignInRequestTests.swift @@ -71,33 +71,38 @@ userId: kUserId, requestConfiguration: fakeConfig ) - let body = request.unencodedHTTPRequestBody - XCTAssertNotNil(body) - let authnAssertionResp = body?["authenticatorAssertionResponse"] as? [String: AnyHashable] - XCTAssertNotNil(authnAssertionResp) - XCTAssertEqual(authnAssertionResp?["credentialId"] as? String, kCredentialID) - let innerResponse = - authnAssertionResp?["authenticatorAssertionResponse"] as? [String: AnyHashable] - XCTAssertNotNil(innerResponse) - XCTAssertEqual(innerResponse?["clientDataJSON"] as? String, kClientDataJSON) - XCTAssertEqual(innerResponse?["authenticatorData"] as? String, kAuthenticatorData) - XCTAssertEqual(innerResponse?["signature"] as? String, kSignature) - XCTAssertEqual(innerResponse?["userHandle"] as? String, kUserId) - XCTAssertNil(body?["tenantId"]) + guard let postBody = request.unencodedHTTPRequestBody else { + return XCTFail("Body should not be nil") + } + guard let authnAssertionResp = + postBody["authenticatorAuthenticationResponse"] as? [String: AnyHashable] else { + return XCTFail("Missing authenticatorAuthenticationResponse") + } + XCTAssertEqual(authnAssertionResp["id"] as? String, kCredentialID) + guard let response = authnAssertionResp["response"] as? [String: AnyHashable] else { + return XCTFail("Missing nested response dictionary") + } + XCTAssertEqual(response["clientDataJSON"] as? String, kClientDataJSON) + XCTAssertEqual(response["authenticatorData"] as? String, kAuthenticatorData) + XCTAssertEqual(response["signature"] as? String, kSignature) + XCTAssertEqual(response["userHandle"] as? String, kUserId) + XCTAssertNil(postBody["tenantId"]) // no tenant by default } func testUnencodedHTTPRequestBodyWithTenantId() { - let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", - gcmSenderID: "00000000000000000-00000000000-000000000") + let options = FirebaseOptions( + googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000" + ) options.apiKey = "FAKE_API_KEY" options.projectID = "myProjectID" - let fakeApp = FirebaseApp(instanceWithName: "testApp", options: options) - let fakeAuth = Auth(app: fakeApp) - fakeAuth.tenantID = "TEST_TENANT" + let app = FirebaseApp(instanceWithName: "testApp", options: options) + let auth = Auth(app: app) + auth.tenantID = "TEST_TENANT" let configWithTenant = AuthRequestConfiguration( apiKey: "FAKE_API_KEY", appID: "FAKE_APP_ID", - auth: fakeAuth + auth: auth ) request = FinalizePasskeySignInRequest( credentialID: kCredentialID, @@ -107,9 +112,22 @@ userId: kUserId, requestConfiguration: configWithTenant ) - - let body = request.unencodedHTTPRequestBody - XCTAssertEqual(body?["tenantId"] as? String, "TEST_TENANT") + guard let body = request.unencodedHTTPRequestBody else { + return XCTFail("Body should not be nil") + } + XCTAssertEqual(body["tenantId"] as? String, "TEST_TENANT") + // also checking structure remains same with tenant + guard let top = body["authenticatorAuthenticationResponse"] as? [String: AnyHashable] else { + return XCTFail("Missing authenticatorAuthenticationResponse") + } + XCTAssertEqual(top["id"] as? String, kCredentialID) + guard let response = top["response"] as? [String: AnyHashable] else { + return XCTFail("Missing nested response dictionary") + } + XCTAssertEqual(response["clientDataJSON"] as? String, kClientDataJSON) + XCTAssertEqual(response["authenticatorData"] as? String, kAuthenticatorData) + XCTAssertEqual(response["signature"] as? String, kSignature) + XCTAssertEqual(response["userHandle"] as? String, kUserId) } } diff --git a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift index 764fdaad55d..46390ab8b89 100644 --- a/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift +++ b/FirebaseAuth/Tests/Unit/GetAccountInfoTests.swift @@ -80,11 +80,11 @@ class GetAccountInfoTests: RPCBaseTests { let kEmailVerifiedKey = "emailVerified" let kLocalIDKey = "localId" let kTestLocalID = "testLocalId" - let kPasskeysKey = "passkeys" + let kPasskeysKey = "passkeyInfo" // Fake PasskeyInfo let testCredentialId = "credential_id" - let testPasskeyName = "Test Passkey" + let testPasskeyName = "testPasskey" let passkeys = [[ "credentialId": testCredentialId, "name": testPasskeyName, @@ -141,7 +141,7 @@ class GetAccountInfoTests: RPCBaseTests { let userDict: [String: AnyHashable] = [ "localId": "user123", "email": "user@example.com", - "passkeys": [passkey1, passkey2], + "passkeyInfo": [passkey1, passkey2], ] let dict: [String: AnyHashable] = ["users": [userDict]] let response = try GetAccountInfoResponse(dictionary: dict) diff --git a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift index 40fbe92f26f..8d3b370b1bc 100644 --- a/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/StartPasskeySignInRequestTests.swift @@ -68,7 +68,7 @@ func testUnencodedHTTPRequestBody_WithoutTenantId() { let request = StartPasskeySignInRequest(requestConfiguration: config) - XCTAssertNil(request.unencodedHTTPRequestBody) + XCTAssertEqual(request.unencodedHTTPRequestBody, [:]) } }