Skip to content
Draft
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
60 changes: 60 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,62 @@ 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)
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."]
)
}
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
)
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)
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ private let kDeleteProvidersKey = "deleteProvider"
/// The key for the "returnSecureToken" value in the request.
private let kReturnSecureTokenKey = "returnSecureToken"

private let kDeletePasskeysKey = "deletePasskey"

/// The key for the tenant id value in the request.
private let kTenantIDKey = "tenantId"

Expand Down Expand Up @@ -131,6 +133,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest {
/// The default value is `true` .
var returnSecureToken: Bool = true

/// The list of credential IDs of the passkeys to be deleted.
var deletePasskeys: [String]? = nil

init(accessToken: String? = nil, requestConfiguration: AuthRequestConfiguration) {
self.accessToken = accessToken
super.init(endpoint: kSetAccountInfoEndpoint, requestConfiguration: requestConfiguration)
Expand Down Expand Up @@ -183,6 +188,9 @@ class SetAccountInfoRequest: IdentityToolkitRequest, AuthRPCRequest {
if returnSecureToken {
postBody[kReturnSecureTokenKey] = true
}
if let deletePasskeys {
postBody[kDeletePasskeysKey] = deletePasskeys
}
if let tenantID {
postBody[kTenantIDKey] = tenantID
}
Expand Down
Loading
Loading