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..7ee8aa941d3 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift @@ -41,6 +41,20 @@ extension User: DataSourceProvidable { return Section(headerDescription: "Firebase Metadata", items: metadataRows) } + 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 otherSection: Section { let otherRows = [Item(title: isAnonymous ? "Yes" : "No", detailTitle: "Is User Anonymous?"), Item(title: isEmailVerified ? "Yes" : "No", detailTitle: "Is Email Verified?")] @@ -62,7 +76,7 @@ extension User: DataSourceProvidable { } var sections: [Section] { - [infoSection, metaDataSection, otherSection, actionSection] + [infoSection, metaDataSection, passkeysSection, otherSection, actionSection] } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 240346b6975..bd0d14fff6a 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,91 @@ 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 + } + guard let passkeyName = await showTextInputPrompt(with: "Passkey name") else { + print("Passkey enrollment cancelled: no name entered.") + return + } + 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 + } + Task { + do { + let request = try await AppManager.shared.auth().startPasskeySignIn() + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests(options: .preferImmediatelyAvailableCredentials) + } 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 +1124,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 +1192,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..45ab6413602 --- /dev/null +++ b/FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift @@ -0,0 +1,124 @@ +/* + * 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 { + // MARK: Enrollment Tests + + @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 testStartPasskeyEnrollmentFailureWithInvalidToken() async throws { + try await signInAnonymouslyAsync() + guard let user = Auth.auth().currentUser else { + XCTFail("Expected a signed-in user") + return + } + // user not reloaded hence id token not updated + let request = try await user.startPasskeyEnrollment(withName: "Test2Passkey") + XCTAssertFalse(request.relyingPartyIdentifier.isEmpty, "rpID should be non-empty") + XCTAssertFalse(request.challenge.isEmpty, "challenge should be non-empty") + XCTAssertNotNil(request.userID, "userID should be present") + try? await deleteCurrentUserAsync() + } + + @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() + } + } + +#endif