diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 7a0c39340ae..b05866dba25 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -150,6 +150,32 @@ final class AuthBackend: AuthBackendProtocol { #endif // !os(iOS) } + // MARK: passkeys + + public func startPasskeyEnrollment(request: StartPasskeyEnrollmentRequest) async throws + -> StartPasskeyEnrollmentResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + throw AuthErrorUtils.error( + code: AuthErrorCode.operationNotAllowed, + message: "OS version is not supported for this action." + ) + } + let response = try await call(with: request) as StartPasskeyEnrollmentResponse + return response + } + + public func finalizePasskeyEnrollment(request: FinalizePasskeyEnrollmentRequest) async throws + -> FinalizePasskeyEnrollmentResponse { + guard #available(iOS 16.0, macOS 12.0, tvOS 16.0, *) else { + throw AuthErrorUtils.error( + code: AuthErrorCode.operationNotAllowed, + message: "OS version is not supported for this action." + ) + } + let response = try await call(with: request) as FinalizePasskeyEnrollmentResponse + return response + } + /// Calls the RPC using HTTP request. /// /// Possible error responses: diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..d8ee747025b --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentRequest.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Represents the request for the `finalizePasskeyEnrollment` endpoint. +@available(iOS 13, *) +class FinalizePasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = FinalizePasskeyEnrollmentResponse + var unencodedHTTPRequestBody: [String: AnyHashable]? + + /// GCIP endpoint for finalizePasskeyEnrollment RPC. + let kFinalizePasskeyEnrollmentEndpoint = "accounts/passkeyEnrollment:finalize" + + /// The raw user access token. + let idToken: String + // name of user or passkey ?.? + let name: String + /// The credential ID. + var credentialID: String = "id" + /// The CollectedClientData object from the authenticator. + var clientDataJson: String = "clientDataJSON" + /// The attestation object from the authenticator. + var attestationObject: String = "response" + + /// The request configuration. + let requestConfiguration: AuthRequestConfiguration? + + /// Initializes a new `FinalizePasskeyEnrollmentRequest`. + /// + /// - Parameters: + /// - IDToken: The raw user access token. + /// - name: The passkey name. + /// - credentialID: The credential ID. + /// - clientDataJson: The CollectedClientData object from the authenticator. + /// - attestationObject: The attestation object from the authenticator. + /// - requestConfiguration: The request configuration. + 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 + self.requestConfiguration = requestConfiguration + super.init( + endpoint: kFinalizePasskeyEnrollmentEndpoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true + ) + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..520fee6c3ab --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/FinalizePasskeyEnrollmentResponse.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Represents the response from the `finalizePasskeyEnrollment` endpoint. +@available(iOS 13, *) +struct FinalizePasskeyEnrollmentResponse: AuthRPCResponse { + /// The ID token for the authenticated user. + public let idToken: String + + /// The refresh token for the authenticated user. + public let refreshToken: String + + private static let kIdTokenKey = "idToken" + private static let kRefreshTokenKey = "refreshToken" + + /// Initializes a new `FinalizePasskeyEnrollmentResponse` from a dictionary. + /// + /// - Parameter dictionary: The dictionary containing the response data. + /// - Throws: An error if parsing fails. + public init(dictionary: [String: AnyHashable]) throws { + guard let idToken = dictionary[Self.kIdTokenKey] as? String, + let refreshToken = dictionary[Self.kRefreshTokenKey] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + self.idToken = idToken + self.refreshToken = refreshToken + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift new file mode 100644 index 00000000000..19fcabf182b --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentRequest.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Represents the parameters for the `startPasskeyEnrollment` endpoint. +@available(iOS 13, *) +class StartPasskeyEnrollmentRequest: IdentityToolkitRequest, AuthRPCRequest { + typealias Response = StartPasskeyEnrollmentResponse + var unencodedHTTPRequestBody: [String: AnyHashable]? + + /// The raw user access token. + let idToken: String + + /// The tenant ID for the request. + let tenantId: String? + + /// The endpoint for the request. + private let kStartPasskeyEnrollmentEndpoint = "accounts/passkeyEnrollment:start" + + /// The request configuration. + let requestConfiguration: AuthRequestConfiguration? + + /// Initializes a new `StartPasskeyEnrollmentRequest`. + /// + /// - Parameters: + /// - idToken: The raw user access token. + /// - requestConfiguration: The request configuration. + /// - tenantId: The tenant ID for the request. + init(idToken: String, requestConfiguration: AuthRequestConfiguration?, tenantId: String? = nil) { + self.idToken = idToken + self.requestConfiguration = requestConfiguration + self.tenantId = tenantId + super.init( + endpoint: kStartPasskeyEnrollmentEndpoint, + requestConfiguration: requestConfiguration!, + useIdentityPlatform: true + ) + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift new file mode 100644 index 00000000000..5287dd6e386 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/StartPasskeyEnrollmentResponse.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Represents the response from the `startPasskeyEnrollment` endpoint. +@available(iOS 13, *) +struct StartPasskeyEnrollmentResponse: AuthRPCResponse { + /// The RP ID of the FIDO Relying Party. + private(set) var rpID: String = "fir-ios-auth-sample.web.app.com" + + /// The user ID. + private(set) var userID: Data + + /// The FIDO challenge. + private(set) var challenge: Data + + /// The name of the field in the response JSON for CredentialCreationOptions. + private let kOptionsKey = "credentialCreationOptions" + + /// The name of the field in the response JSON for Relying Party. + private let kRpKey = "rp" + + /// The name of the field in the response JSON for User. + private let kUserKey = "user" + + /// The name of the field in the response JSON for ids. + private let kIDKey = "id" + + /// The name of the field in the response JSON for challenge. + private let kChallengeKey = "challenge" + + /// Initializes a new `StartPasskeyEnrollmentResponse` from a dictionary. + /// + /// - Parameters: + /// - dictionary: The dictionary containing the response data. + /// - Throws: An error if parsing fails. + init(dictionary: [String: AnyHashable]) throws { + guard let options = dictionary[kOptionsKey] as? [String: AnyHashable] else { + throw NSError( + domain: "com.firebase.auth", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing credentialCreationOptions"] + ) + } + + guard let rp = options[kRpKey] as? [String: AnyHashable], + let rpID = rp[kIDKey] as? String, !rpID.isEmpty else { + throw NSError( + domain: "com.firebase.auth", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing or invalid rpID"] + ) + } + + guard let user = options[kUserKey] as? [String: AnyHashable], + let userID = user[kIDKey] as? String, !userID.isEmpty, + let userIDData = userID.data(using: .utf8) else { + throw NSError( + domain: "com.firebase.auth", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing or invalid userID"] + ) + } + + guard let challenge = options[kChallengeKey] as? String, !challenge.isEmpty, + let challengeData = challenge.data(using: .utf8) else { + throw NSError( + domain: "com.firebase.auth", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Missing or invalid challenge"] + ) + } + self.rpID = rpID + self.userID = userIDData + self.challenge = challengeData + } + + // MARK: - AuthRPCResponse default implementation + + func clientError(shortErrorMessage: String, detailedErrorMessage: String? = nil) -> Error? { + return nil + } +} diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 4ef324e177c..239ed5f44e5 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -1047,6 +1047,65 @@ extension User: NSSecureCoding {} } } + // MARK: Passkey Implementation + + /// Current user object. + var currentUser: User? + + /// Starts the passkey enrollment flow, creating a platform public key registration request. + /// + /// - Parameter name: The desired name for the passkey. + /// - Returns: The ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest. + @available(iOS 15.0, *) + public func startPasskeyEnrollmentWithName(withName name: String?) async throws + -> ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest { + let idToken = rawAccessToken() + let request = StartPasskeyEnrollmentRequest( + idToken: idToken, + requestConfiguration: requestConfiguration, + tenantId: auth?.tenantID + ) + let response = try await backend.startPasskeyEnrollment(request: request) + // Cache the passkey name + passkeyName = name + let provider = + ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: response.rpID) + let registrationRequest = provider.createCredentialRegistrationRequest( + challenge: response.challenge, + name: passkeyName ?? "Unnamed account (Apple)", + userID: response.userID + ) + return registrationRequest + } + + @available(iOS 15.0, *) + public func finalizePasskeyEnrollmentWithPlatformCredentials(platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws + -> AuthDataResult { + let credentialID = platformCredential.credentialID.base64EncodedString() ?? "nil" + let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString() ?? "nil" + let attestationObject = platformCredential.rawAttestationObject!.base64EncodedString() + + let rawAccessToken = self.rawAccessToken + let request = FinalizePasskeyEnrollmentRequest( + idToken: rawAccessToken(), + name: passkeyName!, + credentialID: credentialID, + clientDataJson: clientDataJson, + attestationObject: attestationObject!, + requestConfiguration: auth!.requestConfiguration + ) + + let response = try await backend.finalizePasskeyEnrollment(request: request) + + let user = try await auth!.completeSignIn( + withAccessToken: response.idToken, + accessTokenExpirationDate: nil, + refreshToken: response.refreshToken, + anonymous: false + ) + return AuthDataResult(withUser: user, additionalUserInfo: nil) + } + // MARK: Internal implementations below func rawAccessToken() -> String { diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift index 5e9f8af3cf0..b4b80c0e60b 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/AuthMenu.swift @@ -53,6 +53,7 @@ enum AuthMenu: String { case phoneEnroll case totpEnroll case multifactorUnenroll + case passkeySignUp // More intuitively named getter for `rawValue`. var id: String { rawValue } @@ -139,6 +140,9 @@ enum AuthMenu: String { return "TOTP Enroll" case .multifactorUnenroll: return "Multifactor unenroll" + // Passkey + case .passkeySignUp: + return "Sign Up with Passkey" } } @@ -220,6 +224,8 @@ enum AuthMenu: String { self = .totpEnroll case "Multifactor unenroll": self = .multifactorUnenroll + case "Sign Up with Passkey": + self = .passkeySignUp default: return nil } @@ -354,9 +360,17 @@ class AuthMenuData: DataSourceProvidable { return Section(headerDescription: header, items: items) } + static var passkeySection: Section { + let header = "Passkey" + let items: [Item] = [ + Item(title: AuthMenu.passkeySignUp.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/AccountLinkingViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift index a21d1e93da1..827beba47dc 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift @@ -523,6 +523,23 @@ extension AccountLinkingViewController: DataSourceProvidable { } } +// MARK: - Passkey Enrollment + +@available(iOS 16.0, *) +private func handlePasskeyEnrollment(platformCredential: ASAuthorizationPlatformPublicKeyCredentialRegistration) { + let user = Auth.auth().currentUser + Task { + do { + let authResult = try await user?.finalizePasskeyEnrollmentWithPlatformCredentials( + platformCredential: platformCredential + ) + print("Passkey Enrollment succeeded with uid: \(authResult?.user.uid ?? "empty with uid")") + } catch { + print("Passkey Enrollment failed with error: \(error)") + } + } +} + // MARK: - Implementing Sign in with Apple with Firebase extension AccountLinkingViewController: ASAuthorizationControllerDelegate, diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift index 240346b6975..89b6130d1e3 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AuthViewController.swift @@ -191,6 +191,9 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { case .multifactorUnenroll: mfaUnenroll() + + case .passkeySignUp: + passkeySignUp() } } @@ -922,6 +925,57 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate { } } + 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 + } + // Sign in anonymously + AppManager.shared.auth().signInAnonymously { [weak self] result, error in + guard let self = self else { return } + if let error = error { + self.showAlert(for: "sign-in anonymously failed") + print("Sign in anonymously first") + } else { + print("sign-in anonymously succeeded.") + if let user = result?.user { + print("User ID : \(user.uid)") + self.passkeyEnroll() + } else { + self.showAlert(for: "sign-in anonymously failed: User is nil") + print("sign-in anonymously failed: User is nil") + } + } + } + } + + func passkeyEnroll() { + 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 = Auth.auth().currentUser else { + print("Please sign in first.") + return + } + showTextInputPrompt(with: "Passkey name") { [weak self] passkeyName in + let passkeyName: String = passkeyName + print("Passkey Name is \(passkeyName)") + Task { + do { + let regRequest = try await user.startPasskeyEnrollmentWithName(withName: passkeyName) + print("request done \(regRequest)") + let controller = ASAuthorizationController(authorizationRequests: [regRequest]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } catch { + print("Error during passkey enrollment: \(error)") + } + } + } + } + // MARK: - Private Helpers private func showTextInputPrompt(with message: String, completion: ((String) -> Void)? = nil) {