[Auth] TOTP support for macOS (#15112)

Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>
This commit is contained in:
Ben HagenandNick Cooke authored and GitHub committed 2025-08-12 11:39:28 -04:00
1 parent 7f3c935bcf
commit b7db7b4492
26 files changed
+135 -94

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Unreleased
- [added] Added TOTP support for macOS.
# 12.1.0
- [fixed] Fix a formatting issue with generated TOTP URLs that prevented them
from working with the Google Authenticator app. (#15128)
+1 -1
View File
@@ -1,4 +1,4 @@
# Firebase Auth for iOS
# Firebase Auth for iOS and macOS
Firebase Auth enables apps to easily support multiple authentication options
for their end users.
@@ -15,7 +15,7 @@
*/
#import <TargetConditionals.h>
#if TARGET_OS_IOS
#if TARGET_OS_IOS || TARGET_OS_OSX
#import <Foundation/Foundation.h>
@@ -5,7 +5,7 @@
* 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
* 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,
@@ -22,27 +22,26 @@ NS_ASSUME_NONNULL_BEGIN
/** @typedef FIRMultiFactorSessionCallback
@brief The callback that triggered when a developer calls `getSessionWithCompletion`.
This type is available on iOS only.
This type is available on iOS and macOS.
@param session The multi factor session returned, if any.
@param error The error which occurred, if any.
*/
typedef void (^FIRMultiFactorSessionCallback)(FIRMultiFactorSession *_Nullable session,
NSError *_Nullable error)
NS_SWIFT_UNAVAILABLE("Use Swift's closure syntax instead.")
API_UNAVAILABLE(macos, tvos, watchos);
NS_SWIFT_UNAVAILABLE("Use Swift's closure syntax instead.") API_UNAVAILABLE(tvos, watchos);
/**
@brief The string identifier for using phone as a second factor.
This constant is available on iOS only.
This constant is available on iOS and macOS.
*/
extern NSString *const _Nonnull FIRPhoneMultiFactorID NS_SWIFT_NAME(PhoneMultiFactorID)
API_UNAVAILABLE(macos, tvos, watchos);
API_UNAVAILABLE(tvos, watchos);
/**
@brief The string identifier for using TOTP as a second factor.
This constant is available on iOS only.
This constant is available on iOS and macOS.
*/
extern NSString *const _Nonnull FIRTOTPMultiFactorID NS_SWIFT_NAME(TOTPMultiFactorID)
API_UNAVAILABLE(macos, tvos, watchos);
API_UNAVAILABLE(tvos, watchos);
NS_ASSUME_NONNULL_END
@@ -99,9 +99,7 @@ final class AuthBackend: AuthBackendProtocol {
}
private static func generateMFAError(response: AuthRPCResponse, auth: Auth) -> Error? {
#if !os(iOS)
return nil
#else
#if os(iOS) || os(macOS)
if let mfaResponse = response as? AuthMFAResponse,
mfaResponse.idToken == nil,
let enrollments = mfaResponse.mfaInfo {
@@ -124,7 +122,9 @@ final class AuthBackend: AuthBackendProtocol {
} else {
return nil
}
#endif // !os(iOS)
#else
return nil
#endif // os(iOS) || os(macOS)
}
// Check whether or not the successful response is actually the special case phone
@@ -14,7 +14,7 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
extension MultiFactor: NSSecureCoding {}
@@ -22,7 +22,7 @@ import Foundation
/// The interface defining the multi factor related properties and operations pertaining to a
/// user.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRMultiFactor) open class MultiFactor: NSObject {
@objc open var enrolledFactors: [MultiFactorInfo]
@@ -14,12 +14,12 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// The base class for asserting ownership of a second factor. This is equivalent to the
/// AuthCredential class.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@objc(FIRMultiFactorAssertion) open class MultiFactorAssertion: NSObject {
/// The second factor identifier for this opaque object asserting a second factor.
@objc open var factorID: String
@@ -16,12 +16,12 @@ import Foundation
// TODO(Swift 6 Breaking): Make checked Sendable.
#if os(iOS)
#if os(iOS) || os(macOS)
extension MultiFactorInfo: NSSecureCoding {}
/// Safe public structure used to represent a second factor entity from a client perspective.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@objc(FIRMultiFactorInfo) open class MultiFactorInfo: NSObject, @unchecked Sendable {
/// The multi-factor enrollment ID.
@objc(UID) public let uid: String
@@ -14,12 +14,12 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// The subclass of base class `MultiFactorAssertion`, used to assert ownership of a phone
/// second factor.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRMultiFactorResolver)
open class MultiFactorResolver: NSObject {
@@ -14,7 +14,7 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// Opaque object that identifies the current session to enroll a second factor or to
/// complete sign in when previously enrolled.
@@ -23,7 +23,7 @@ import Foundation
/// or to complete sign in when previously enrolled. It contains additional context on the
/// existing user, notably the confirmation that the user passed the first factor challenge.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRMultiFactorSession) open class MultiFactorSession: NSObject {
/// The ID token for an enroll flow. This has to be retrieved after recent authentication.
@@ -14,12 +14,12 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// The subclass of base class FIRMultiFactorAssertion, used to assert ownership of a phone
/// second factor.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRPhoneMultiFactorAssertion) open class PhoneMultiFactorAssertion: MultiFactorAssertion {
var authCredential: PhoneAuthCredential?
@@ -14,14 +14,14 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// The data structure used to help initialize an assertion for a second factor entity to the
/// Firebase Auth/CICP server.
///
/// Depending on the type of second factor, this will help generate the assertion.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRPhoneMultiFactorGenerator)
open class PhoneMultiFactorGenerator: NSObject {
@@ -16,13 +16,13 @@ import Foundation
// TODO(Swift 6 Breaking): Make checked Sendable.
#if os(iOS)
#if os(iOS) || os(macOS)
/// Extends the MultiFactorInfo class for phone number second factors.
///
/// The identifier of this second factor is "phone".
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@objc(FIRPhoneMultiFactorInfo) open class PhoneMultiFactorInfo: MultiFactorInfo,
@unchecked Sendable {
/// The string identifier for using phone as a second factor.
@@ -14,7 +14,7 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
enum SecretOrID {
case secret(TOTPSecret)
@@ -24,7 +24,7 @@ import Foundation
/// The subclass of base class MultiFactorAssertion, used to assert ownership of a TOTP
/// (Time-based One Time Password) second factor.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@objc(FIRTOTPMultiFactorAssertion) open class TOTPMultiFactorAssertion: MultiFactorAssertion {
let oneTimePassword: String
let secretOrID: SecretOrID
@@ -14,13 +14,13 @@
import Foundation
#if os(iOS)
#if os(iOS) || os(macOS)
/// The data structure used to help initialize an assertion for a second factor entity to the
/// Firebase Auth/CICP server. Depending on the type of second factor, this will help generate
/// the assertion.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
@objc(FIRTOTPMultiFactorGenerator) open class TOTPMultiFactorGenerator: NSObject {
/// Creates a TOTP secret as part of enrolling a TOTP second factor. Used for generating a
@@ -17,13 +17,13 @@ import Foundation
// TODO(Swift 6 Breaking): Make checked Sendable. Also, does this need
// to be public?
#if os(iOS)
#if os(iOS) || os(macOS)
/// Extends the MultiFactorInfo class for time based one-time password second factors.
///
/// The identifier of this second factor is "totp".
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
class TOTPMultiFactorInfo: MultiFactorInfo, @unchecked Sendable {
/// Initialize the AuthProtoMFAEnrollment instance with proto.
/// - Parameter proto: AuthProtoMFAEnrollment proto object.
@@ -19,13 +19,17 @@ import Foundation
internal import GoogleUtilities_Environment
#endif
#if os(iOS)
import UIKit
#if os(iOS) || os(macOS)
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// The subclass of base class MultiFactorAssertion, used to assert ownership of a TOTP
/// (Time-based One Time Password) second factor.
///
/// This class is available on iOS only.
/// This class is available on iOS and macOS.
@objc(FIRTOTPSecret) open class TOTPSecret: NSObject {
/// Returns the shared secret key/seed used to generate time-based one-time passwords.
@objc open func sharedSecretKey() -> String {
@@ -57,24 +61,33 @@ import Foundation
@MainActor @objc(openInOTPAppWithQRCodeURL:)
open func openInOTPApp(withQRCodeURL qrCodeURL: String) {
if GULAppEnvironmentUtil.isAppExtension() {
// iOS App extensions should not call [UIApplication sharedApplication], even if
// UIApplication responds to it.
// App extensions should not call [UIApplication sharedApplication] or [NSWorkspace
// sharedWorkspace], even if they respond to it.
return
}
// Using reflection here to avoid build errors in extensions.
let sel = NSSelectorFromString("sharedApplication")
guard UIApplication.responds(to: sel),
let rawApplication = UIApplication.perform(sel),
let application = rawApplication.takeUnretainedValue() as? UIApplication else {
return
}
if let url = URL(string: qrCodeURL), application.canOpenURL(url) {
application.open(url, options: [:], completionHandler: nil)
} else {
AuthLog.logError(code: "I-AUT000019",
message: "URL: \(qrCodeURL) cannot be opened")
}
#if os(iOS)
// Using reflection here to avoid build errors in extensions.
let sel = NSSelectorFromString("sharedApplication")
guard UIApplication.responds(to: sel),
let rawApplication = UIApplication.perform(sel),
let application = rawApplication.takeUnretainedValue() as? UIApplication else {
return
}
if let url = URL(string: qrCodeURL), application.canOpenURL(url) {
application.open(url, options: [:], completionHandler: nil)
} else {
AuthLog.logError(code: "I-AUT000019",
message: "URL: \(qrCodeURL) cannot be opened")
}
#elseif os(macOS)
if let url = URL(string: qrCodeURL) {
NSWorkspace.shared.open(url)
} else {
AuthLog.logError(code: "I-AUT000019",
message: "URL: \(qrCodeURL) cannot be opened")
}
#endif
}
/// Shared secret key/seed used for enrolling in TOTP MFA and generating OTPs.
+7 -7
View File
@@ -58,10 +58,10 @@ extension User: NSSecureCoding {}
/// The tenant ID of the current user. `nil` if none is available.
@objc public private(set) var tenantID: String?
#if os(iOS)
#if os(iOS) || os(macOS)
/// Multi factor object associated with the user.
///
/// This property is available on iOS only.
/// This property is available on iOS and macOS.
@objc public private(set) var multiFactor: MultiFactor
#endif
@@ -1066,7 +1066,7 @@ extension User: NSSecureCoding {}
isEmailVerified = false
metadata = UserMetadata(withCreationDate: nil, lastSignInDate: nil)
tenantID = nil
#if os(iOS)
#if os(iOS) || os(macOS)
multiFactor = MultiFactor(withMFAEnrollments: [])
#endif
uid = ""
@@ -1297,7 +1297,7 @@ extension User: NSSecureCoding {}
}
}
providerDataRaw = providerData
#if os(iOS)
#if os(iOS) || os(macOS)
if let enrollments = user.mfaEnrollments {
multiFactor = MultiFactor(withMFAEnrollments: enrollments)
}
@@ -1718,7 +1718,7 @@ extension User: NSSecureCoding {}
coder.encode(auth.requestConfiguration.appID, forKey: kFirebaseAppIDCodingKey)
}
coder.encode(tokenService, forKey: kTokenServiceCodingKey)
#if os(iOS)
#if os(iOS) || os(macOS)
coder.encode(multiFactor, forKey: kMultiFactorCodingKey)
#endif
}
@@ -1747,7 +1747,7 @@ extension User: NSSecureCoding {}
as? [String: UserInfoImpl]
let metadata = coder.decodeObject(of: UserMetadata.self, forKey: kMetadataCodingKey)
let tenantID = coder.decodeObject(of: NSString.self, forKey: kTenantIDCodingKey) as? String
#if os(iOS)
#if os(iOS) || os(macOS)
let multiFactor = coder.decodeObject(of: MultiFactor.self, forKey: kMultiFactorCodingKey)
#endif
self.tokenService = tokenService
@@ -1778,7 +1778,7 @@ extension User: NSSecureCoding {}
backend = AuthBackend(rpcIssuer: AuthBackendRPCIssuer())
userProfileUpdate = UserProfileUpdate()
#if os(iOS)
#if os(iOS) || os(macOS)
self.multiFactor = multiFactor ?? MultiFactor()
super.init()
multiFactor?.user = self
@@ -568,7 +568,7 @@ class AuthErrorUtils {
return error(code: .blockingCloudFunctionError, message: errorMessage)
}
#if os(iOS)
#if os(iOS) || os(macOS)
static func secondFactorRequiredError(pendingCredential: String?,
hints: [MultiFactorInfo],
auth: Auth)
@@ -581,7 +581,7 @@ class AuthErrorUtils {
return error(code: .secondFactorRequired, userInfo: userInfo)
}
#endif // os(iOS)
#endif // os(iOS) || os(macOS)
static func recaptchaSDKNotLinkedError() -> Error {
// TODO(ObjC): point the link to GCIP doc once available.
+4 -2
View File
@@ -372,7 +372,7 @@
accessToken:@"token"];
}
#if TARGET_OS_IOS
#if TARGET_OS_IOS || TARGET_OS_OSX
- (void)FIRMultiFactor_h:(FIRMultiFactor *)mf mfa:(FIRMultiFactorAssertion *)mfa {
[mf getSessionWithCompletion:^(FIRMultiFactorSession *_Nullable credential,
NSError *_Nullable error){
@@ -466,7 +466,9 @@
- (void)phoneMultiFactorInfo:(FIRPhoneMultiFactorInfo *)info {
__unused NSString *s = [info phoneNumber];
}
#endif
#if TARGET_OS_IOS || TARGET_OS_OSX
- (void)FIRTOTPSecret_h:(FIRTOTPSecret *)secret {
NSString *s = [secret sharedSecretKey];
s = [secret generateQRCodeURLWithAccountName:@"name" issuer:@"issuer"];
@@ -571,7 +573,7 @@
b = [user isEmailVerified];
__unused NSArray<NSObject<FIRUserInfo> *> *userInfo = [user providerData];
__unused FIRUserMetadata *meta = [user metadata];
#if TARGET_OS_IOS
#if TARGET_OS_IOS || TARGET_OS_OSX
__unused FIRMultiFactor *mf = [user multiFactor];
#endif
NSString *s = [user refreshToken];
+3 -1
View File
@@ -41,9 +41,11 @@
s = FIRGoogleAuthSignInMethod;
#if TARGET_OS_IOS
s = FIRPhoneMultiFactorID;
s = FIRTOTPMultiFactorID;
s = FIRPhoneAuthProviderID;
s = FIRPhoneAuthSignInMethod;
#endif
#if TARGET_OS_IOS || TARGET_OS_OSX
s = FIRTOTPMultiFactorID;
#endif
s = FIRTwitterAuthProviderID;
s = FIRTwitterAuthSignInMethod;
+3 -1
View File
@@ -548,7 +548,9 @@ class AuthAPI_hOnlyTests: XCTestCase {
func phoneMultiFactorInfo(mfi: PhoneMultiFactorInfo) {
let _: String = mfi.phoneNumber
}
#endif
#if os(iOS) || os(macOS)
func FIRTOTPSecret_h(session: MultiFactorSession) async throws {
let obj = try await TOTPMultiFactorGenerator.generateSecret(with: session)
_ = obj.sharedSecretKey()
@@ -638,7 +640,7 @@ class AuthAPI_hOnlyTests: XCTestCase {
let _: Bool = user.isEmailVerified
let _: [UserInfo] = user.providerData
let _: UserMetadata = user.metadata
#if os(iOS)
#if os(iOS) || os(macOS)
let _: MultiFactor = user.multiFactor
#endif
if let _: String = user.refreshToken,
@@ -38,9 +38,10 @@ class SwiftGlobalTests: XCTestCase {
let _: String = GitHubAuthSignInMethod
let _: String = GoogleAuthProviderID
let _: String = GoogleAuthSignInMethod
#if os(iOS)
let _: String = PhoneMultiFactorID
#if os(iOS) || os(macOS)
let _: String = TOTPMultiFactorID
#endif
#if os(iOS)
let _: String = PhoneAuthProviderID
let _: String = PhoneAuthSignInMethod
#endif
+38 -17
View File
@@ -135,6 +135,27 @@ class UserTests: RPCBaseTests {
])
#endif
var mfaInfo: [[AnyHashable: AnyHashable]] = []
#if os(iOS)
mfaInfo.append([
"phoneInfo": kPhoneInfo,
"mfaEnrollmentId": kEnrollmentID,
"displayName": kDisplayName,
"enrolledAt": kEnrolledAt,
])
#endif
#if os(iOS) || os(macOS)
mfaInfo.append([
// In practice, this will be an empty dictionary.
"totpInfo": [AnyHashable: AnyHashable](),
"mfaEnrollmentId": kEnrollmentID,
"displayName": kDisplayName,
"enrolledAt": kEnrolledAt,
])
#endif
rpcIssuer?.fakeGetAccountProviderJSON = [[
kProviderUserInfoKey: providerUserInfos,
kLocalIDKey: kLocalID,
@@ -146,21 +167,7 @@ class UserTests: RPCBaseTests {
"phoneNumber": kPhoneNumber,
"createdAt": String(Int(kCreationDateTimeIntervalInSeconds) * 1000), // to nanoseconds
"lastLoginAt": String(Int(kLastSignInDateTimeIntervalInSeconds) * 1000),
"mfaInfo": [
[
"phoneInfo": kPhoneInfo,
"mfaEnrollmentId": kEnrollmentID,
"displayName": kDisplayName,
"enrolledAt": kEnrolledAt,
],
[
// In practice, this will be an empty dictionary.
"totpInfo": [AnyHashable: AnyHashable](),
"mfaEnrollmentId": kEnrollmentID,
"displayName": kDisplayName,
"enrolledAt": kEnrolledAt,
] as [AnyHashable: AnyHashable],
],
"mfaInfo": mfaInfo,
]]
let expectation = self.expectation(description: #function)
@@ -247,9 +254,12 @@ class UserTests: RPCBaseTests {
var encodedClasses = [User.self, NSDictionary.self, NSURL.self, SecureTokenService.self,
UserInfoImpl.self, NSDate.self, UserMetadata.self, NSString.self,
NSArray.self]
#if os(iOS)
#if os(iOS) || os(macOS)
encodedClasses.append(MultiFactor.self)
encodedClasses.append(PhoneMultiFactorInfo.self)
encodedClasses.append(TOTPMultiFactorInfo.self)
#if os(iOS)
encodedClasses.append(PhoneMultiFactorInfo.self)
#endif
#endif
let unarchivedUser = try XCTUnwrap(NSKeyedUnarchiver.unarchivedObject(
@@ -370,6 +380,17 @@ class UserTests: RPCBaseTests {
XCTAssertEqual("\(date)", kEnrolledAtMatch)
}
#endif
#if os(macOS)
// Verify TOTP MultiFactorInfo properties.
let enrolledFactors = try XCTUnwrap(user.multiFactor.enrolledFactors)
XCTAssertEqual(enrolledFactors.count, 1)
XCTAssertEqual(enrolledFactors[0].factorID, PhoneMultiFactorInfo.TOTPMultiFactorID)
XCTAssertEqual(enrolledFactors[0].uid, kEnrollmentID)
XCTAssertEqual(enrolledFactors[0].displayName, self.kDisplayName)
let date = try XCTUnwrap(enrolledFactors[0].enrollmentDate)
XCTAssertEqual("\(date)", kEnrolledAtMatch)
#endif
} catch {
XCTFail("Caught an error in \(#function): \(error)")
}
@@ -12,13 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#if os(iOS) || targetEnvironment(macCatalyst)
#if os(iOS) || targetEnvironment(macCatalyst) || os(macOS)
import Combine
import FirebaseAuth
@available(iOS 13.0, macCatalyst 13.0, *)
@available(macOS, unavailable)
@available(iOS 13.0, macCatalyst 13.0, macOS 10.15, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public extension MultiFactor {
@@ -111,4 +110,4 @@
}
}
#endif // os(iOS) || targetEnvironment(macCatalyst)
#endif // os(iOS) || targetEnvironment(macCatalyst) || os(macOS)
@@ -12,13 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#if os(iOS) || targetEnvironment(macCatalyst)
#if os(iOS) || targetEnvironment(macCatalyst) || os(macOS)
import Combine
import FirebaseAuth
@available(iOS 13.0, macCatalyst 13.0, *)
@available(macOS, unavailable)
@available(iOS 13.0, macCatalyst 13.0, macOS 10.15, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public extension MultiFactorResolver {
@@ -43,4 +42,4 @@
}
}
#endif // os(iOS) || targetEnvironment(macCatalyst)
#endif // os(iOS) || targetEnvironment(macCatalyst) || os(macOS)