diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 1f89d5ddfe4..b6fb7217d48 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -140,6 +140,36 @@ extension Auth: AuthInterop { } } +/// 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.tenantId = tenantId + self.location = location + } +} + +/// 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 + } +} + /// Manages authentication for Firebase apps. /// /// This class is thread-safe. @@ -170,6 +200,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 { @@ -2425,3 +2469,103 @@ 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 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 + /// (e.g., "oidc.my-provider"). + /// - useStaging: A Boolean value indicating whether to use the staging Identity Platform + /// backend. Defaults to `false`. + /// - completion: A closure that is called asynchronously on the main thread with either a + /// `FirebaseToken` on success or an `Error` on failure. + func exchangeToken(idToken: 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: "Auth instance must be configured with a TenantConfig, including location and tenantId, to use exchangeToken." + )) + ) + return + } + let request = ExchangeTokenRequest( + idToken: idToken, + 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 ID token for a Firebase ID token using Swift concurrency. + /// + /// 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 + /// (e.g., "oidc.my-provider"). + /// - 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.location, + let _ = requestConfiguration.tenantId + else { + throw AuthErrorUtils.operationNotAllowedError(message: "R-GCIP is not configured.") + } + let request = ExchangeTokenRequest( + idToken: idToken, + 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 + } + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift b/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift index 91f99c266f8..8439094be13 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift @@ -44,15 +44,26 @@ final class AuthRequestConfiguration { /// If set, the local emulator host and port to point to instead of the remote backend. var emulatorHostAndPort: String? + /// The Regional Google Cloud Identity Platform (R-GCIP) location. + /// This is set when the `Auth` instance is initialized with a `TenantConfig`. + var location: String? + + /// The Regional Google Cloud Identity Platform (R-GCIP) tenant ID. + /// This is set when the `Auth` instance is initialized with a `TenantConfig`. + 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/RPC/ExchangeTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift new file mode 100644 index 00000000000..75af6e83ef7 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift @@ -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.location, + let tenant = config.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 + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift new file mode 100644 index 00000000000..7c4713818d7 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift @@ -0,0 +1,51 @@ +// 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 + +// MARK: - ExchangeTokenResponse + +/// An internal response containing the result of a successful OIDC token exchange. +/// +/// Contains the Firebase ID token and its expiration time. +/// This struct implements `AuthRPCResponse` to parse the JSON payload from the +/// `exchangeOidcToken` endpoint. +@available(iOS 13, *) +struct ExchangeTokenResponse: AuthRPCResponse { + /// The exchanged firebase access token. + let firebaseToken: String + + /// The lifetime of the token in seconds. + let expiresIn: TimeInterval + + /// The calculated date and time when the token expires. + let expirationDate: Date + + /// Initializes an `ExchangeTokenResponse` by parsing a dictionary from a JSON + /// payload. + /// + /// - Parameter dictionary: The dictionary representing the JSON response from server. + /// - Throws: `AuthErrorUtils.unexpectedResponse` if the required fields + /// (like "idToken") are missing or have unexpected types. + init(dictionary: [String: AnyHashable]) throws { + guard let token = dictionary["idToken"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + firebaseToken = token + /// Default to 1 hour (3600 seconds) if the field is missing. + expiresIn = dictionary["expiresIn"] as? TimeInterval ?? 3600 + + expirationDate = Date().addingTimeInterval(expiresIn) + } +}