Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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"
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?")]
Expand All @@ -62,7 +76,7 @@ extension User: DataSourceProvidable {
}

var sections: [Section] {
[infoSection, metaDataSection, otherSection, actionSection]
[infoSection, metaDataSection, passkeysSection, otherSection, actionSection]
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions FirebaseAuth/Tests/SampleSwift/SwiftApiTests/PasskeyTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading