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

Merged
merged 18 commits into from
Jun 16, 2025
Merged
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
151 changes: 151 additions & 0 deletions ExchangeTokensRequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright 2023 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 XCTest

@testable import FirebaseAuth
import FirebaseCore

/// @class ExchangeTokenRequestTests
/// @brief Tests for the @c ExchangeTokenRequest struct.
@available(iOS 13, *)
class ExchangeTokenRequestTests: XCTestCase {
// MARK: - Constants for Testing

let kAPIKey = "test-api-key"
let kProjectID = "test-project-id"
let kLocation = "asia-northeast1"
let kTenantID = "test-tenant-id-123"
let kCustomToken = "a-very-long-and-secure-oidc-token-string"
let kIdpConfigId = "oidc.my-test-provider"

let kProductionHost = "identityplatform.googleapis.com"
let kStagingHost = "staging-identityplatform.sandbox.googleapis.com"

// MARK: - Test Cases

func testProductionURLIsCorrectlyConstructed() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
customToken: kCustomToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: false
)

let expectedHost = "\(kLocation)-\(kProductionHost)"
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

func testProductionURLIsCorrectlyConstructedForGlobalLocation() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: "prod-global",
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
customToken: kCustomToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: false
)

let expectedHost = kProductionHost
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
"/locations/prod-global/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

func testStagingURLIsCorrectlyConstructed() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
customToken: kCustomToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: true
)

let expectedHost = "\(kLocation)-\(kStagingHost)"
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

func testUnencodedHTTPBodyIsCorrect() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
customToken: kCustomToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration
)

let body = request.unencodedHTTPRequestBody
XCTAssertNotNil(body)
XCTAssertEqual(body?.count, 1)
XCTAssertEqual(body?["custom_token"] as? String, kCustomToken)
}

// MARK: - Helper Function

private func createTestAuthInstance(projectID: String?, location: String?,
tenantId: String?) -> (auth: Auth, app: FirebaseApp) {
let appName = "TestApp-\(UUID().uuidString)"
let options = FirebaseOptions(
googleAppID: "1:1234567890:ios:abcdef123456",
gcmSenderID: "1234567890"
)
options.apiKey = kAPIKey
if let projectID = projectID {
options.projectID = projectID
}

if FirebaseApp.app(name: appName) != nil {
FirebaseApp.app(name: appName)?.delete { _ in }
}
let app = FirebaseApp(instanceWithName: appName, options: options)

let auth = Auth(app: app)
auth.app = app
auth.requestConfiguration.location = location
auth.requestConfiguration.tenantId = tenantId

return (auth, app)
}
}
125 changes: 125 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 FirebaseToken: Sendable {
public let token: String
public let expirationDate: Date
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,20 @@ 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
}

/// 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 +2464,89 @@ 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,
useStaging: Bool = false,
completion: @escaping (FirebaseToken?, 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,
useStaging: true
)
Task {
do {
let response = try await backend.call(with: request)
let firebaseToken = FirebaseToken(
token: response.firebaseToken,
expirationDate: response.expirationDate
)
Auth.wrapMainAsync(callback: completion, with: .success(firebaseToken))
} 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,
useStaging: Bool = false) async throws -> FirebaseToken {
// 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,
useStaging: true
)
do {
let response = try await backend.call(with: request)
return FirebaseToken(
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 location, set once during Auth init.
var location: String?

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

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ private let kFirebaseAuthStagingAPIHost = "staging-www.sandbox.googleapis.com"
private let kIdentityPlatformStagingAPIHost =
"staging-identitytoolkit.sandbox.googleapis.com"

/// 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 +40,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,6 +75,7 @@ class IdentityToolkitRequest {
let apiHostAndPathPrefix: String
let urlString: String
let emulatorHostAndPort = _requestConfiguration.emulatorHostAndPort
// legacy gcip logic
if useIdentityPlatform {
if let emulatorHostAndPort = emulatorHostAndPort {
apiProtocol = kHttpProtocol
Expand Down
Loading
Loading