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
228 changes: 190 additions & 38 deletions FirebaseAuth/Sources/Swift/Auth/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import FirebaseAppCheckInterop
import FirebaseAuthInterop
import FirebaseCore
import FirebaseCoreExtension
import FirebaseCoreInternal
#if COCOAPODS
internal import GoogleUtilities
#else
Expand Down Expand Up @@ -83,40 +84,66 @@ extension Auth: AuthInterop {
public func getToken(forcingRefresh forceRefresh: Bool,
completion callback: @escaping (String?, Error?) -> Void) {
kAuthGlobalWorkQueue.async { [weak self] in
if let strongSelf = self {
// Enable token auto-refresh if not already enabled.
if !strongSelf.autoRefreshTokens {
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
strongSelf.autoRefreshTokens = true
strongSelf.scheduleAutoTokenRefresh()

#if os(iOS) || os(tvOS) // TODO(ObjC): Is a similar mechanism needed on macOS?
strongSelf.applicationDidBecomeActiveObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil, queue: nil
) { notification in
if let strongSelf = self {
strongSelf.isAppInBackground = false
if !strongSelf.autoRefreshScheduled {
strongSelf.scheduleAutoTokenRefresh()
}
}
}
strongSelf.applicationDidEnterBackgroundObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: nil
) { notification in
if let strongSelf = self {
strongSelf.isAppInBackground = true
}
}
#endif
guard let self else {
DispatchQueue.main.async { callback(nil, nil) }
return
}
/// Before checking for a standard user, check if we are in a token-only session established
/// by a successful exchangeToken call.
let rGCIPToken = self.rGCIPFirebaseTokenLock.value()

if let token = rGCIPToken {
/// Logic for tokens obtained via exchangeToken (R-GCIP mode)
if token.expirationDate < Date() {
/// Token expired
let error = AuthErrorUtils
.userTokenExpiredError(
message: "The firebase access token obtained via exchangeToken() has expired."
)
Auth.wrapMainAsync(callback: callback, with: .failure(error))
} else if forceRefresh {
/// Token is not expired, but forceRefresh was requested which is currently unsupported
let error = AuthErrorUtils
.operationNotAllowedError(
message: "forceRefresh is not supported for firebase access tokens obtained via exchangeToken()."
)
Auth.wrapMainAsync(callback: callback, with: .failure(error))
} else {
/// The token is valid and not expired.
Auth.wrapMainAsync(callback: callback, with: .success(token.token))
}
/// Exit here as this path is for rGCIPFirebaseToken only.
return
}
// Call back with 'nil' if there is no current user.
guard let strongSelf = self, let currentUser = strongSelf._currentUser else {
/// Fallback to standard `currentUser` logic if not in token-only mode.
if !self.autoRefreshTokens {
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
self.autoRefreshTokens = true
self.scheduleAutoTokenRefresh()

#if os(iOS) || os(tvOS)
self.applicationDidBecomeActiveObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: nil
) { [weak self] _ in
guard let self = self, !self.isAppInBackground,
!self.autoRefreshScheduled else { return }
self.scheduleAutoTokenRefresh()
}
self.applicationDidEnterBackgroundObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.isAppInBackground = true
}
#endif
}

guard let currentUser = self._currentUser else {
DispatchQueue.main.async {
callback(nil, nil)
}
Expand All @@ -126,7 +153,7 @@ extension Auth: AuthInterop {
currentUser
.internalGetToken(
forceRefresh: forceRefresh,
backend: strongSelf.backend,
backend: self.backend,
callback: callback,
callCallbackOnMain: true
)
Expand Down Expand Up @@ -1293,10 +1320,17 @@ extension Auth: AuthInterop {
/// dictionary will contain more information about the error encountered.
@objc(signOut:) open func signOut() throws {
try kAuthGlobalWorkQueue.sync {
guard self._currentUser != nil else {
return
if self._currentUser != nil {
// Clear standard user session if one exists.
try self.updateCurrentUser(nil, byForce: false, savingToDisk: true)
}

// Clear R-GCIP token-only session.
self.rGCIPFirebaseTokenLock.withLock { token in
if token != nil {
token = nil
}
}
return try self.updateCurrentUser(nil, byForce: false, savingToDisk: true)
}
}

Expand Down Expand Up @@ -1646,7 +1680,7 @@ extension Auth: AuthInterop {
init(app: FirebaseApp,
keychainStorageProvider: AuthKeychainStorage = AuthKeychainStorageReal.shared,
backend: AuthBackend = .init(rpcIssuer: AuthBackendRPCIssuer()),
authDispatcher: AuthDispatcher = .init()) {
authDispatcher: AuthDispatcher = .init(), tenantConfig: TenantConfig? = nil) {
self.app = app
mainBundleUrlTypes = Bundle.main
.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]
Expand All @@ -1669,7 +1703,8 @@ extension Auth: AuthInterop {
appID: app.options.googleAppID,
auth: nil,
heartbeatLogger: app.heartbeatLogger,
appCheck: appCheck)
appCheck: appCheck,
tenantConfig: tenantConfig)
self.backend = backend
self.authDispatcher = authDispatcher

Expand Down Expand Up @@ -2265,6 +2300,11 @@ extension Auth: AuthInterop {
return { result in
switch result {
case let .success(authResult):
/// When a standard user successfully signs in, any existing token-only session must be
/// invalidated to prevent a conflicting auth state.
/// Clear any R-GCIP session state when a standard user signs in. This ensures we exit
/// Token-Only Mode.
self.rGCIPFirebaseTokenLock.withLock { $0 = nil }
do {
try self.updateCurrentUser(authResult.user, byForce: false, savingToDisk: true)
Auth.wrapMainAsync(callback: callback, with: .success(authResult))
Expand Down Expand Up @@ -2428,4 +2468,116 @@ extension Auth: AuthInterop {
///
/// Mutations should occur within a @synchronized(self) context.
private var listenerHandles: NSMutableArray = []

// R-GCIP Token-Only Session State

/// The session token obtained from a successful `exchangeToken` call, protected by a lock.
///
/// This property is used to support a "token-only" authentication mode for Regionalized
/// GCIP, where no `User` object is created. It is mutually exclusive with `_currentUser`.
/// If the wrapped value is non-nil, the `AuthInterop` layer will use it for token generation
/// instead of relying on a `currentUser`.
private let rGCIPFirebaseTokenLock = UnfairLock<FirebaseToken?>(nil)
}

// MARK: - Regionalized Auth

/// Holds configuration for a Regional Google Cloud Identity Platform (R-GCIP) tenant.
public struct TenantConfig: Sendable {
public let tenantId: String
public let location: String
/// Initializes a `TenantConfig` instance.
///
/// - Parameters:
/// - tenantId: The ID of the tenant.
/// - location: The location of the tenant. Defaults to "prod-global".
public init(tenantId: String, location: String = "prod-global") {
self.location = location
self.tenantId = tenantId
}
}

/// Represents the result of a successful OIDC token exchange, containing a Firebase ID token
/// and its expiration.
public struct FirebaseToken: Sendable {
/// The Firebase ID token string.
public let token: String
/// The date at which the Firebase ID token expires.
public let expirationDate: Date

init(token: String, expirationDate: Date) {
self.token = token
self.expirationDate = expirationDate
}
}

/// Regionalized auth
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
public extension Auth {
/// Gets the Auth object for a `FirebaseApp` configured for a specific Regional Google Cloud
/// Identity Platform (R-GCIP) tenant.
///
/// Use this method to create an `Auth` instance that interacts with a regionalized
/// authentication backend instead of the default endpoint.
///
/// - Parameters:
/// - app: The Firebase app instance.
/// - tenantConfig: The configuration for the R-GCIP tenant, specifying the tenant ID and its
/// location.
/// - Returns: The `Auth` instance associated with the given app and tenant config.
static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth {
return Auth(app: app, tenantConfig: tenantConfig)
}

/// Exchanges a third-party OIDC ID token for a Firebase ID token.
///
/// This method is used for Bring Your Own CIAM (BYO-CIAM) in Regionalized GCIP (R-GCIP),
/// where the `Auth` instance must be configured with a `TenantConfig`, including `location`
/// and `tenantId`, typically by using `Auth.auth(app:tenantConfig:)`.
///
/// Unlike standard sign-in methods, this flow *does not* create or update a `User`object and
/// *does not* set `CurrentUser` on the `Auth` instance. It only returns a Firebase token.
///
/// - Parameters:
/// - oidcToken: The OIDC ID token obtained from the third-party identity provider.
/// - idpConfigId: The ID of the Identity Provider configuration within your GCIP tenant
/// - useStaging: A Boolean value indicating whether to use the staging Identity Platform
/// backend. Defaults to `false`.
/// - Returns: A `FirebaseToken` containing the Firebase ID token and its expiration date.
/// - Throws: An error if the `Auth` instance is not configured for R-GCIP, if the network
/// call fails, or if the token response parsing fails.
func exchangeToken(idToken: String, idpConfigId: String,
useStaging: Bool = false) async throws -> FirebaseToken {
/// Ensure R-GCIP is configured with location and tenant ID.
guard requestConfiguration.tenantConfig != nil else {
/// This should never happen in production code, as it indicates a misconfiguration.
fatalError("R-GCIP is not configured correctly.")
}
/// This method should only be called on an R-GCIP instance
guard _currentUser == nil else {
fatalError(
"exchangeToken cannot be called on an Auth instance with an active standard user session."
)
}
let request = ExchangeTokenRequest(
idToken: idToken,
idpConfigID: idpConfigId,
config: requestConfiguration,
useStaging: useStaging
)
do {
let response = try await backend.call(with: request)
let newToken = FirebaseToken(
token: response.firebaseToken,
expirationDate: response.expirationDate
)
// Lock and update the R-GCIP token.
rGCIPFirebaseTokenLock.withLock { token in
token = newToken
}
return newToken
} catch {
throw error
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,22 @@ final class AuthRequestConfiguration {
/// If set, the local emulator host and port to point to instead of the remote backend.
var emulatorHostAndPort: String?

/// The regionalized GCIP tenant configuration, if provided.
/// This property contains tenant ID and location for regionalized GCIP services
/// It's non-`nil` only when the `Auth` instance is initialized with `TenantConfig`.
let tenantConfig: TenantConfig?

init(apiKey: String,
appID: String,
auth: Auth? = nil,
heartbeatLogger: FIRHeartbeatLoggerProtocol? = nil,
appCheck: AppCheckInterop? = nil) {
appCheck: AppCheckInterop? = nil,
tenantConfig: TenantConfig? = nil) {
self.apiKey = apiKey
self.appID = appID
self.auth = auth
self.heartbeatLogger = heartbeatLogger
self.appCheck = appCheck
self.tenantConfig = tenantConfig
}
}
Loading
Loading