Skip to content

implementing regionalized auth and exchangeToken #14865

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

Open
wants to merge 12 commits into
base: byociam
Choose a base branch
from
126 changes: 126 additions & 0 deletions FirebaseAuth/Sources/Swift/Auth/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,31 @@ extension Auth: AuthInterop {
}
}

/// Holds configuration for a R-GCIP tenant.
public struct TenantConfig: Sendable {
public let tenantId: String /// The ID of the tenant.
public let location: String /// The location of the tenant.

/// Initializes a `TenantConfig` instance.
/// - Parameters:
/// - location: The location of the tenant, defaults to "prod-global".
/// - tenantId: The ID of the tenant.
public init(tenantId: String, location: String = "prod-global") {
self.location = location
self.tenantId = tenantId
}
}

/// Holds a Firebase ID token and its expiration.
public struct AuthExchangeToken: Sendable {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call it FirebaseToken instead? AuthExchange was name for a different project and I think we should avoid that name.

public let token: String
public let expirationDate: Date?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this optional?

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

/// Manages authentication for Firebase apps.
///
/// This class is thread-safe.
Expand Down Expand Up @@ -170,6 +195,25 @@ extension Auth: AuthInterop {
/// Gets the `FirebaseApp` object that this auth object is connected to.
@objc public internal(set) weak var app: FirebaseApp?

/// Gets the auth object for a `FirebaseApp` with an optional `TenantConfig`.
/// - Parameters:
/// - app: The Firebase app instance.
/// - tenantConfig: The optional configuration for the RGCIP.
/// - Returns: The `Auth` instance associated with the given app and tenant config.
public static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth {
let auth = auth(app: app)
kAuthGlobalWorkQueue.sync {
auth.requestConfiguration.location = tenantConfig.location
auth.requestConfiguration.tenantId = tenantConfig.tenantId
}
return auth
}

// public static func auth(app: FirebaseApp, tenantConfig: nil) -> Auth {
// let auth = auth(app: app)
// return auth
// }
Comment on lines +212 to +215
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this commented code?


/// Synchronously gets the cached current user, or null if there is none.
@objc public var currentUser: User? {
kAuthGlobalWorkQueue.sync {
Expand Down Expand Up @@ -2425,3 +2469,85 @@ extension Auth: AuthInterop {
/// Mutations should occur within a @synchronized(self) context.
private var listenerHandles: NSMutableArray = []
}

@available(iOS 13, *)
public extension Auth {
/// Exchanges a third-party OIDC token for a Firebase STS token.
///
/// This method is used in R-GCIP (multi-tenant) environments where the `Auth` instance must
/// be configured with a `TenantConfig`, including `location` and `tenantId`.
/// Unlike other sign-in methods, this flow *does not* create or update a `User` object.
///
/// - Parameters:
/// - request: The ExchangeTokenRequest containing the OIDC token and other parameters.
/// - completion: A closure that gets called with either an `AuthTokenResult` or an `Error`.
func exchangeToken(customToken: String,
idpConfigId: String,
completion: @escaping (AuthExchangeToken?, Error?) -> Void) {
// Ensure R-GCIP is configured with location and tenant ID
guard let _ = requestConfiguration.location,
let _ = requestConfiguration.tenantId
else {
Auth.wrapMainAsync(
callback: completion,
with: .failure(AuthErrorUtils
.operationNotAllowedError(message: "R-GCIP is not configured."))
)
return
}
let request = ExchangeTokenRequest(
customToken: customToken,
idpConfigID: idpConfigId,
config: requestConfiguration
)
Task {
do {
let response = try await backend.call(with: request)
let authExchangeToken = AuthExchangeToken(
token: response.firebaseToken,
expirationDate: response.expirationDate
)
Auth.wrapMainAsync(callback: completion, with: .success(authExchangeToken))
} catch {
Auth.wrapMainAsync(callback: completion, with: .failure(error))
}
}
}

/// Exchanges a third-party OIDC token for a Firebase STS token using Swift concurrency.
///
/// This async variant performs the same operation as the completion-based method but returns
/// the result directly and throws on failure.
///
/// The `Auth` instance must be configured with `TenantConfig` containing `location` and
/// `tenantId`.
/// Unlike other sign-in methods, this flow *does not* create or update a `User` object.
///
/// - Parameters:
/// - request: The ExchangeTokenRequest containing the OIDC token and other parameters.
/// - Returns: An `AuthTokenResult` containing the Firebase ID token and its expiration details.
/// - Throws: An error if R-GCIP is not configured, if the network call fails,
/// or if the token parsing fails.
func exchangeToken(customToken: String, idpConfigId: String) async throws -> AuthExchangeToken {
// Ensure R-GCIP is configured with location and tenant ID
guard let _ = requestConfiguration.location,
let _ = requestConfiguration.tenantId
else {
throw AuthErrorUtils.operationNotAllowedError(message: "R-GCIP is not configured.")
}
let request = ExchangeTokenRequest(
customToken: customToken,
idpConfigID: idpConfigId,
config: requestConfiguration
)
do {
let response = try await backend.call(with: request)
return AuthExchangeToken(
token: response.firebaseToken,
expirationDate: response.expirationDate
)
} catch {
throw error
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,24 @@ final class AuthRequestConfiguration {
/// If set, the local emulator host and port to point to instead of the remote backend.
var emulatorHostAndPort: String?

/// R-GCIP region, set once during Auth init.
var location: String?

/// R-GCIP tenantId, set once during Auth init.
var tenantId: String?

Comment on lines +47 to +52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we are setting this separately? Why can't we have tenantConfig here?

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
location = tenantConfig?.location
tenantId = tenantConfig?.tenantId
}
}
32 changes: 29 additions & 3 deletions FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ private nonisolated(unsafe) var gAPIHost = "www.googleapis.com"

private let kFirebaseAuthAPIHost = "www.googleapis.com"
private let kIdentityPlatformAPIHost = "identitytoolkit.googleapis.com"
private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com" // Regional R-GCIP v2 hosts

private let kFirebaseAuthStagingAPIHost = "staging-www.sandbox.googleapis.com"
private let kIdentityPlatformStagingAPIHost =
"staging-identitytoolkit.sandbox.googleapis.com"
private let kRegionalGCIPStagingAPIHost =
"staging-identityplatform.sandbox.googleapis.com" // Regional R-GCIP v2 hosts

/// Represents a request to an identity toolkit endpoint.
/// Represents a request to an identity toolkit endpoint routing either to legacy GCIP or
/// regionalized R-GCIP
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
class IdentityToolkitRequest {
/// Gets the RPC's endpoint.
Expand All @@ -39,7 +43,7 @@ class IdentityToolkitRequest {
/// Gets the client's API key used for the request.
var apiKey: String

/// The tenant ID of the request. nil if none is available.
/// The tenant ID of the request. nil if none is available (not for r-gcip).
let tenantID: String?

/// The toggle of using Identity Platform endpoints.
Expand Down Expand Up @@ -74,7 +78,29 @@ class IdentityToolkitRequest {
let apiHostAndPathPrefix: String
let urlString: String
let emulatorHostAndPort = _requestConfiguration.emulatorHostAndPort
if useIdentityPlatform {
/// R-GCIP
if let region = _requestConfiguration.location,
let tenant = _requestConfiguration.tenantId, // Use tenantId from requestConfiguration
!region.isEmpty,
!tenant.isEmpty {
let projectID = _requestConfiguration.auth?.app?.options.projectID
// Choose emulator, staging, or prod host
if let emulatorHostAndPort = emulatorHostAndPort {
apiProtocol = kHttpProtocol
apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kRegionalGCIPAPIHost)"
Comment on lines +89 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we support this new endpoint in the emulator? If not, shouldn't we throw exception here saying not supported? IIUC, for this to work, we need the new identityplatform's api supported on the emulator, let me know if this is something else.

} else if useStaging {
apiProtocol = kHttpsProtocol
apiHostAndPathPrefix = kRegionalGCIPStagingAPIHost
} else {
apiProtocol = kHttpsProtocol
apiHostAndPathPrefix = kRegionalGCIPAPIHost
}
urlString =
"\(apiProtocol)//\(apiHostAndPathPrefix)/v2/projects/\(projectID ?? "projectID")"
+ "/locations/\(region)/tenants/\(tenant)/idpConfigs/\(endpoint)?key=\(apiKey)"
}
// legacy gcip existing logic
else if useIdentityPlatform {
if let emulatorHostAndPort = emulatorHostAndPort {
apiProtocol = kHttpProtocol
apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kIdentityPlatformAPIHost)"
Expand Down
98 changes: 98 additions & 0 deletions FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 kCustomTokenKey = "customToken"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the significance of this? And why are we using customToken as the name here, probably we should use exchangeToken instead


/// A request to exchange a third-party OIDC token for a Firebase STS token.
///
/// This structure encapsulates the parameters required to make an API request
/// to exchange an OIDC token for a Firebase ID token. It conforms to the
/// `AuthRPCRequest` protocol, providing the necessary properties and
/// methods for the authentication backend to perform the request.
@available(iOS 13, *)
struct ExchangeTokenRequest: AuthRPCRequest {
/// The type of the expected response.
typealias Response = ExchangeTokenResponse

/// The OIDC provider's Authorization code or Id Token to exchange.
let customToken: String

/// The ExternalUserDirectoryId corresponding to the OIDC custom Token.
let idpConfigID: String

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

var path: String {
guard let region = config.location,
let tenant = config.tenantId,
let project = config.auth?.app?.options.projectID
else {
fatalError(
"exchangeOidcToken requires `auth.location` & `auth.tenantID`"
)
}
_ = "\(region)-identityplatform.googleapis.com"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let us use the location term instead of region as region has different meaning.

return "/v2alpha/projects/\(project)/locations/\(region)" +
"/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"
Comment on lines +48 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For prod-global we will not have (region)-identityplatform, instead it will be just identityplatform. Please add a special check for that.

}

/// Initializes a new `ExchangeTokenRequest` instance.
///
/// - Parameters:
/// - idpConfigID: The identifier of the OIDC provider configuration.
/// - idToken: The third-party OIDC token to exchange.
/// - config: The configuration for the request.
init(customToken: String,
idpConfigID: String,
config: AuthRequestConfiguration) {
self.idpConfigID = idpConfigID
self.customToken = customToken
self.config = config
}

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

/// Constructs the URL for the API request.
///
/// - Returns: The URL for the token exchange endpoint.
/// - FatalError: if location, tenantID, projectID or apiKey are missing.
func requestURL() -> URL {
guard let region = config.location,
let tenant = config.tenantId,
let project = config.auth?.app?.options.projectID
else {
fatalError(
"exchangeOidcToken requires `auth.useIdentityPlatform`, `auth.location`, `auth.tenantID` & `projectID`"
)
}
let host = "\(region)-identityplatform.googleapis.com"
let path = "/v2/projects/$\(project)/locations/$\(region)" +
"/tenants/$\(tenant)/idpConfigs/$\(idpConfigID):exchangeOidcToken"
guard let url = URL(string: "https://\(host)\(path)?key=\(config.apiKey)") else {
fatalError("Failed to create URL for exchangeOidcToken")
}
return url
}

/// Returns the request configuration.
///
/// - Returns: The `AuthRequestConfiguration`.
func requestConfiguration() -> AuthRequestConfiguration { config }
}
44 changes: 44 additions & 0 deletions FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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

import Foundation

/// Response containing the new Firebase STS token and its expiration time in seconds.
@available(iOS 13, *)
struct ExchangeTokenResponse: AuthRPCResponse {
/// The Firebase ID token.
let firebaseToken: String

/// The time interval (in *seconds*) until the token expires.
let expiresIn: TimeInterval

/// The expiration date of the token, calculated from `expiresInSeconds`.
let expirationDate: Date

/// Initializes a new ExchangeTokenResponse from a dictionary.
///
/// - Parameter dictionary: The dictionary representing the JSON response from the server.
/// - Throws: `AuthErrorUtils.unexpectedResponse` if the dictionary is missing required fields
/// or contains invalid data.
init(dictionary: [String: AnyHashable]) throws {
guard let token = dictionary["idToken"] as? String else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
firebaseToken = token
expiresIn = (dictionary["expiresIn"] as? TimeInterval) ?? 3600
expirationDate = Date().addingTimeInterval(expiresIn)
}
}
Loading
Loading