Skip to content
64 changes: 64 additions & 0 deletions FirebaseAuth/Sources/Swift/Auth/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import FirebaseCoreExtension
import UIKit
#endif

#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)
import AuthenticationServices
#endif

// Export the deprecated Objective-C defined globals and typedefs.
#if SWIFT_PACKAGE
@_exported import FirebaseAuthInternal
Expand Down Expand Up @@ -1641,6 +1645,66 @@ extension Auth: AuthInterop {
public static let authStateDidChangeNotification =
NSNotification.Name(rawValue: "FIRAuthStateDidChangeNotification")

// MARK: Passkey Implementation

#if os(iOS) || os(tvOS) || os(macOS) || targetEnvironment(macCatalyst)

/// starts sign in with passkey retrieving challenge from GCIP and create an assertion request.
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
public func startPasskeySignIn() async throws ->
ASAuthorizationPlatformPublicKeyCredentialAssertionRequest {
let request = StartPasskeySignInRequest(requestConfiguration: requestConfiguration)
print("invoking StartPasskeySignIn\n")
let response = try await backend.call(with: request)
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."]
)
}
print("StartPasskeySignIn Succeed!\n")
let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: response.rpID
)
return provider.createCredentialAssertionRequest(
challenge: challengeInData
)
}

/// finalize sign in with passkey with existing credential assertion.
/// - Parameter platformCredential The existing credential assertion created by device.
@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
public func finalizePasskeySignIn(withPlatformCredential platformCredential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws
-> AuthDataResult {
let credentialId = platformCredential.credentialID.base64EncodedString()
let clientDataJson = platformCredential.rawClientDataJSON.base64EncodedString()
let authenticatorData = platformCredential.rawAuthenticatorData.base64EncodedString()
let signature = platformCredential.signature.base64EncodedString()
let userId = platformCredential.userID.base64EncodedString()
let request = FinalizePasskeySignInRequest(
credentialID: credentialId,
clientDataJSON: clientDataJson,
authenticatorData: authenticatorData,
signature: signature,
userId: userId,
requestConfiguration: requestConfiguration
)
print("invoking finalizePasskeySignIn for Passkey credentialId: \(credentialId)\n\n")
let response = try await backend.call(with: request)
let user = try await Auth.auth().completeSignIn(
withAccessToken: response.idToken,
accessTokenExpirationDate: nil,
refreshToken: response.refreshToken,
anonymous: false
)
try await user.reload()
try await updateCurrentUser(user)
print("FinalizePasskeySignIn Succeed!\nidToken: \(response.idToken)\n")
return AuthDataResult(withUser: user, additionalUserInfo: nil)
}
#endif

// MARK: Internal methods

init(app: FirebaseApp,
Expand Down
2 changes: 2 additions & 0 deletions FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -440,6 +441,7 @@ final class AuthBackend: AuthBackendProtocol {
return AuthErrorUtils.credentialAlreadyInUseError(
message: serverDetailErrorMessage, credential: credential, email: email
)
case "INVALID_AUTHENTICATOR_RESPONSE": return AuthErrorUtils.invalidAuthenticatorResponse()
default:
if let underlyingErrors = errorDictionary["errors"] as? [[String: String]] {
for underlyingError in underlyingErrors {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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/LICENSE2.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 finalizePasskeySignIn rpc
private let finalizePasskeySignInEndPoint = "accounts/passkeySignIn:finalize"

@available(iOS 15.0, macOS 12.0, tvOS 16.0, *)
class FinalizePasskeySignInRequest: IdentityToolkitRequest, AuthRPCRequest {
typealias Response = FinalizePasskeySignInResponse
/// The credential ID
let credentialID: String
/// The CollectedClientData object from the authenticator.
let clientDataJSON: String
/// The AuthenticatorData from the authenticator.
let authenticatorData: String
/// The signature from the authenticator.
let signature: String
/// The user handle
let userId: String

init(credentialID: String,
clientDataJSON: String,
authenticatorData: String,
signature: String,
userId: String,
requestConfiguration: AuthRequestConfiguration) {
self.credentialID = credentialID
self.clientDataJSON = clientDataJSON
self.authenticatorData = authenticatorData
self.signature = signature
self.userId = userId
super.init(
endpoint: finalizePasskeySignInEndPoint,
requestConfiguration: requestConfiguration,
useIdentityPlatform: true
)
}

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] = [
"authenticatorAuthenticationResponse": authResponse,
]
if let tenant = tenantID {
postBody["tenantId"] = tenant
}
return postBody
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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/LICENSE2.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 FinalizePasskeySignInResponse: 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -133,6 +136,11 @@ struct GetAccountInfoResponse: AuthRPCResponse {
} else {
mfaEnrollments = nil
}
if let passkeyEnrollmentData = dictionary["passkeyInfo"] as? [[String: AnyHashable]] {
enrolledPasskeys = passkeyEnrollmentData.map { PasskeyInfo(dictionary: $0) }
} else {
enrolledPasskeys = nil
}
}
}

Expand Down
40 changes: 40 additions & 0 deletions FirebaseAuth/Sources/Swift/Backend/RPC/Proto/PasskeyInfo.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading