diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 5d8050cc891..aa70469bfe1 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -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 { + 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. @@ -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 +// } + /// Synchronously gets the cached current user, or null if there is none. @objc public var currentUser: User? { kAuthGlobalWorkQueue.sync { @@ -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 + } + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift b/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift index 91f99c266f8..b76e17fa1d1 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift @@ -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? + 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 } } diff --git a/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift b/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift index 7378b517a7d..ce485ef67bc 100644 --- a/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift @@ -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. @@ -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. @@ -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)" + } 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)" diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift new file mode 100644 index 00000000000..4da85b50acf --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift @@ -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" + +/// 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" + return "/v2alpha/projects/\(project)/locations/\(region)" + + "/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken" + } + + /// 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 } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift new file mode 100644 index 00000000000..d730d66ab50 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift @@ -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) + } +} diff --git a/FirebaseAuth/Tests/Unit/IdentityToolkitRequestTests.swift b/FirebaseAuth/Tests/Unit/IdentityToolkitRequestTests.swift index 441198a4d1f..6e55ac3437b 100644 --- a/FirebaseAuth/Tests/Unit/IdentityToolkitRequestTests.swift +++ b/FirebaseAuth/Tests/Unit/IdentityToolkitRequestTests.swift @@ -25,6 +25,9 @@ class IdentityToolkitRequestTests: XCTestCase { let kEndpoint = "endpoint" let kAPIKey = "APIKey" let kEmulatorHostAndPort = "emulatorhost:12345" + let kRegion = "us-central1" + let kTenantID = "tenant-id" + let kProjectID = "my-project-id" /** @fn testInitWithEndpointExpectedRequestURL @brief Tests the @c requestURL method to make sure the URL it produces corresponds to the @@ -118,6 +121,8 @@ class IdentityToolkitRequestTests: XCTestCase { gcmSenderID: "00000000000000000-00000000000-000000000") options.apiKey = kAPIKey let nonDefaultApp = FirebaseApp(instanceWithName: "nonDefaultApp", options: options) + // Force initialize Auth for the non-default app to set the weak reference in + // AuthRequestConfiguration let nonDefaultAuth = Auth(app: nonDefaultApp) nonDefaultAuth.tenantID = "tenant-id" let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", @@ -128,4 +133,310 @@ class IdentityToolkitRequestTests: XCTestCase { useIdentityPlatform: true) XCTAssertEqual("tenant-id", request.tenantID) } + + // MARK: - R-GCIP specific tests + + /** @fn testInitWithRGCIPIExpectedRequestURL + @brief Tests the @c requestURL method for R-GCIP with region and tenant ID. + */ + func testInitWithRGCIPIExpectedRequestURL() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPApp", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth + .tenantID = kTenantID // Tenant ID is also needed in Auth for the request logic to pick it up + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: kRegion) + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + let expectedURL = "https://identityplatform.googleapis.com/v2/projects/\(kProjectID)" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIUseStagingExpectedRequestURL + @brief Tests the @c requestURL method for R-GCIP with region, tenant ID, and staging. + */ + func testInitWithRGCIPIUseStagingExpectedRequestURL() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppStaging", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: kRegion) + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration, + useStaging: true) + let expectedURL = + "https://staging-identityplatform.sandbox.googleapis.com/v2/projects/\(kProjectID)" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIUseEmulatorExpectedRequestURL + @brief Tests the @c requestURL method for R-GCIP with region, tenant ID, and emulator. + */ + func testInitWithRGCIPIUseEmulatorExpectedRequestURL() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppEmulator", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: kRegion) + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + requestConfiguration.emulatorHostAndPort = kEmulatorHostAndPort + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + let expectedURL = + "http://\(kEmulatorHostAndPort)/identityplatform.googleapis.com/v2/projects/\(kProjectID)" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIWithoutProjectID + @brief Tests the @c requestURL method for R-GCIP when the project ID is not available in options. + */ + func testInitWithRGCIPIWithoutProjectID() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + // Project ID is not set in options + + let app = FirebaseApp(instanceWithName: "rGCIPAppWithoutProjectID", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: kRegion) + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + // The expected URL should use "projectID" as a placeholder + let expectedURL = "https://identityplatform.googleapis.com/v2/projects/projectID" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIWithEmptyRegion + @brief Tests that the request falls back to the non-R-GCIP logic if the region is empty. + */ + func testInitWithRGCIPIWithEmptyRegion() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppEmptyRegion", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: "") // Empty region + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) // R-GCIP logic + // will fail due to empty region + // Expecting fallback to the default Firebase Auth endpoint logic + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIWithEmptyTenantIDInTenantConfig + @brief Tests that the request falls back to the non-R-GCIP logic if the tenant ID in tenant config is empty. + */ + func testInitWithRGCIPIWithEmptyTenantIDInTenantConfig() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppEmptyTenantIDTC", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID // Tenant ID is set in Auth but R-GCIP logic uses tenantConfig + + let tenantConfig = TenantConfig(tenantId: "", + location: kRegion) // Empty tenantId in tenant config + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) // R-GCIP logic + // will fail due to empty tenantId + // Expecting fallback to the default Firebase Auth endpoint logic + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIWithNilTenantIDInTenantConfig + @brief Tests that the request falls back to the non-R-GCIP logic if the tenant ID in tenant config is nil. + */ + func testInitWithRGCIPIWithNilTenantIDInTenantConfig() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppNilTenantIDTC", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID // Tenant ID is set in Auth but R-GCIP logic uses tenantConfig + + let tenantConfig = TenantConfig(tenantId: "", + location: kRegion) // Nil tenantId in tenant config + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) // R-GCIP logic + // will fail due to nil tenantId + // Expecting fallback to the default Firebase Auth endpoint logic + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIWithNilTenantConfig + @brief Tests that the request falls back to the non-R-GCIP logic if the tenant config is nil. + */ + func testInitWithRGCIPIWithNilTenantConfig() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppNilTenantConfig", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID // Tenant ID is set in Auth but R-GCIP logic requires tenantConfig + + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, + tenantConfig: nil) // Nil tenant config + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) // R-GCIP logic + // will fail due to nil tenant config + // Expecting fallback to the default Firebase Auth endpoint logic + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIUseIdentityPlatform + @brief Tests that using `useIdentityPlatform` does not affect R-GCIP endpoint selection when R-GCIP configuration is present. + */ + func testInitWithRGCIPIUseIdentityPlatform() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppUseIP", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID + + let tenantConfig = TenantConfig(tenantId: kTenantID, location: kRegion) + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration, + useIdentityPlatform: true) // useIdentityPlatform is true + + let expectedURL = "https://identityplatform.googleapis.com/v2/projects/\(kProjectID)" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIAndLegacyTenantID + @brief Tests that R-GCIP logic prioritizes tenantId from `tenantConfig` over the one in `Auth`. + */ + func testInitWithRGCIPIAndLegacyTenantID() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppLegacyTenant", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = "legacy-tenant-id" // Tenant ID in Auth + + let tenantConfig = TenantConfig(tenantId: kTenantID, + location: kRegion) // Tenant ID in tenantConfig + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + + let expectedURL = "https://identityplatform.googleapis.com/v2/projects/\(kProjectID)" + + "/locations/\(kRegion)/tenants/\(kTenantID)/idpConfigs/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIAndLegacyTenantIDAndNoTenantIDInTenantConfig + @brief Tests that R-GCIP logic falls back to legacy tenant ID if tenantId is nil in `tenantConfig` but present in `Auth`. + */ + func testInitWithRGCIPIAndLegacyTenantIDAndNoTenantIDInTenantConfig() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppLegacyTenantFallback", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID // Tenant ID in Auth + + let tenantConfig = TenantConfig(tenantId: "", location: kRegion) // Nil tenantId in tenantConfig + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + + // Even though region is present, since tenantId in tenantConfig is nil, + // it should not use the R-GCIP path and fall back to default Auth endpoint. + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } + + /** @fn testInitWithRGCIPIAndLegacyTenantIDAndEmptyTenantIDInTenantConfig + @brief Tests that R-GCIP logic falls back to legacy tenant ID if tenantId is empty in `tenantConfig` but present in `Auth`. + */ + func testInitWithRGCIPIAndLegacyTenantIDAndEmptyTenantIDInTenantConfig() { + let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", + gcmSenderID: "00000000000000000-00000000000-000000000") + options.apiKey = kAPIKey + options.projectID = kProjectID + let app = FirebaseApp(instanceWithName: "rGCIPAppLegacyTenantFallbackEmpty", options: options) + // Force initialize Auth for the app to set the weak reference in AuthRequestConfiguration + let auth = Auth(app: app) + auth.tenantID = kTenantID // Tenant ID in Auth + + let tenantConfig = TenantConfig(tenantId: "", + location: kRegion) // Empty tenantId in tenantConfig + let requestConfiguration = AuthRequestConfiguration(apiKey: kAPIKey, appID: "appID", + auth: auth, tenantConfig: tenantConfig) + let request = IdentityToolkitRequest(endpoint: kEndpoint, + requestConfiguration: requestConfiguration) + + // Even though region is present, since tenantId in tenantConfig is empty, + // it should not use the R-GCIP path and fall back to default Auth endpoint. + let expectedURL = + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/\(kEndpoint)?key=\(kAPIKey)" + XCTAssertEqual(expectedURL, request.requestURL().absoluteString) + } }