Skip to content

[Auth] Regionalised Auth and BYO-CIAM feature implementation #15048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: gcip-byociam
Choose a base branch
from
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
214 changes: 179 additions & 35 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,48 +84,74 @@ 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.withLock { $0 }

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
}
/// 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
}
// Call back with 'nil' if there is no current user.
guard let strongSelf = self, let currentUser = strongSelf._currentUser else {

guard let currentUser = self._currentUser else {
DispatchQueue.main.async {
callback(nil, nil)
}
return
}
// Call back with current user token.
currentUser
.internalGetToken(forceRefresh: forceRefresh, backend: strongSelf.backend) { token, error in
.internalGetToken(forceRefresh: forceRefresh, backend: self.backend) { token, error in
DispatchQueue.main.async {
callback(token, error)
}
Expand Down Expand Up @@ -1645,7 +1672,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 @@ -1668,7 +1695,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 @@ -2264,6 +2292,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 @@ -2427,4 +2460,115 @@ 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 = FIRAllocatedUnfairLock<FirebaseToken?>(initialState: 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 let _ = requestConfiguration.tenantConfig?.location,
let _ = requestConfiguration.tenantConfig?.tenantId
else {
/// This should never happen in production code, as it indicates a misconfiguration.
fatalError("R-GCIP is not configured correctly.")
}
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 token, signing out any current user.
rGCIPFirebaseTokenLock.withLock { token in
if self._currentUser != nil {
try? self.signOut()
}
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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

private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com"
private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"

// MARK: - ExchangeTokenRequest

/// A request to exchange a third-party OIDC ID token for a Firebase ID token.
///
/// This structure encapsulates the parameters required to call the
/// `exchangeOidcToken` endpoint on the regionalized Identity Platform backend.
/// It conforms to `AuthRPCRequest`, providing the necessary properties and
/// methods for the authentication backend to perform the request.
/// This is used for the BYO-CIAM (regionalized GCIP) flow.
@available(iOS 13, *)
struct ExchangeTokenRequest: AuthRPCRequest {
/// The type of the expected response.
typealias Response = ExchangeTokenResponse

/// The customer application redirects the user to the OIDC provider,
/// and receives this idToken for the user upon successful authentication.
let idToken: String

/// The ID of the Identity Provider configuration, as configured for the tenant.
let idpConfigID: String

/// The auth configuration for the request, holding API key, etc.
let config: AuthRequestConfiguration

/// Flag for whether to use the staging backend.
let useStaging: Bool

/// Initializes an `ExchangeTokenRequest`.
///
/// - Parameters:
/// - idToken: The third-party OIDC ID token from the external IdP to be exchanged.
/// - idpConfigID: The ID of the IdP configuration.
/// - config: The `AuthRequestConfiguration`.
/// - useStaging: Set to `true` to target the staging environment. Defaults to `false`.
init(idToken: String,
idpConfigID: String,
config: AuthRequestConfiguration,
useStaging: Bool = false) {
self.idToken = idToken
self.idpConfigID = idpConfigID
self.config = config
self.useStaging = useStaging
}

/// The unencoded HTTP request body for the API.
var unencodedHTTPRequestBody: [String: AnyHashable]? {
return ["id_token": idToken]
}

/// Constructs the full URL for the `ExchangeOidcToken` API endpoint.
///
/// - Important: This method will cause a `fatalError` if the `location`, `tenantId`, or
/// `projectID` are missing from the configuration, as they are essential for
/// constructing a valid regional endpoint URL.
/// - Returns: The fully constructed `URL` for the API request.
func requestURL() -> URL {
guard let location = config.tenantConfig?.location,
let tenant = config.tenantConfig?.tenantId,
let project = config.auth?.app?.options.projectID
else {
fatalError(
"Internal Error: ExchangeTokenRequest requires `location`, `tenantId`, and `projectID`."
)
}
let baseHost = useStaging ? kRegionalGCIPStagingAPIHost : kRegionalGCIPAPIHost
let host = (location == "prod-global" || location == "global") ? baseHost :
"\(location)-\(baseHost)"

let locationPath = (location == "prod-global") ? "global" : location

let path = "/v2beta/projects/\(project)/locations/\(locationPath)" +
"/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"

guard let url = URL(string: "https://\(host)\(path)?key=\(config.apiKey)") else {
fatalError("Failed to create URL for ExchangeTokenRequest")
}
return url
}

/// Returns the request configuration.
func requestConfiguration() -> AuthRequestConfiguration {
return config
}
}
Loading
Loading