diff --git a/FirebaseAuth/Sources/Swift/Auth/Auth.swift b/FirebaseAuth/Sources/Swift/Auth/Auth.swift index 5d8050cc891..1531d79bc54 100644 --- a/FirebaseAuth/Sources/Swift/Auth/Auth.swift +++ b/FirebaseAuth/Sources/Swift/Auth/Auth.swift @@ -167,6 +167,27 @@ extension Auth: AuthInterop { return ComponentType.instance(for: AuthInterop.self, in: app.container) as! Auth } + /// New R-GCIP v2 + BYO-CIAM initializer + public static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth { + // start from the legacy initializer so we get a fully-formed Auth object + let auth = auth(app: app) + kAuthGlobalWorkQueue.sync { + auth.requestConfiguration.location = tenantConfig.location + auth.requestConfiguration.tenantId = tenantConfig.tenantId + } + return auth + } + + /// Holds region & tenant for R-GCIP + public struct TenantConfig { + public let location: String + public let tenantId: String + public init(location: String = "prod-global", tenantId: String) { + self.location = location + self.tenantId = tenantId + } + } + /// Gets the `FirebaseApp` object that this auth object is connected to. @objc public internal(set) weak var app: FirebaseApp? @@ -2425,3 +2446,44 @@ extension Auth: AuthInterop { /// Mutations should occur within a @synchronized(self) context. private var listenerHandles: NSMutableArray = [] } + +@available(iOS 13, *) +public extension Auth { + + struct AuthExchangeToken { + public let token: String // The Firebase ID token. + public let expirationDate: Date? // The expiration time of the token. + init(token: String, expirationDate: Date?) { + self.token = token + self.expirationDate = expirationDate + } + } + + /// Exchange a third-party OIDC token for a short-lived Firebase STS token. + @objc func exchangeToken(_ idpConfigID: String, + _ ciamOidcToken: String, + completion: @escaping (String?, Error?) -> Void) { + // Must have opted into R-GCIP + guard let location = requestConfiguration.location, + let tenantId = requestConfiguration.tenantId + else { + completion(nil, AuthErrorUtils.operationNotAllowedError( + message: "Set location & tenantId first" + )) + return + } + let req = ExchangeTokenRequest( + idpConfigID: idpConfigID, + idToken: ciamOidcToken, + cfg: requestConfiguration + ) + Task { + do { + let resp = try await backend.call(with: req) + DispatchQueue.main.async { completion(resp.firebaseToken, nil) } + } catch { + DispatchQueue.main.async { completion(nil, error) } + } + } + } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift b/FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift index 91f99c266f8..98627006ba4 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: Auth.TenantConfig) { self.apiKey = apiKey self.appID = appID self.auth = auth self.heartbeatLogger = heartbeatLogger self.appCheck = appCheck + let location = tenantConfig.location + let tenantId = tenantConfig.tenantId } } diff --git a/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift b/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift index 7378b517a7d..8028cf0c777 100644 --- a/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift +++ b/FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift @@ -16,43 +16,40 @@ import Foundation private let kHttpsProtocol = "https:" private let kHttpProtocol = "http:" - -private let kEmulatorHostAndPrefixFormat = "%@/%@" - -/// Host for server API calls. This should be changed via -/// `IdentityToolkitRequest.setHost(_ host:)` for testing purposes only. -private nonisolated(unsafe) var gAPIHost = "www.googleapis.com" - +// Legacy GCIP v1 hosts private let kFirebaseAuthAPIHost = "www.googleapis.com" -private let kIdentityPlatformAPIHost = "identitytoolkit.googleapis.com" - private let kFirebaseAuthStagingAPIHost = "staging-www.sandbox.googleapis.com" -private let kIdentityPlatformStagingAPIHost = - "staging-identitytoolkit.sandbox.googleapis.com" - -/// Represents a request to an identity toolkit endpoint. +// Regional R-GCIP v2 hosts +private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com" +private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com" +#if compiler(>=6) + private nonisolated(unsafe) var gAPIHost = "www.googleapis.com" +#else + private var gAPIHost = "www.googleapis.com" +#endif +/// Represents a request to an Identity Toolkit endpoint, routing either to +/// legacy GCIP v1 or regionalized R-GCIP v2 based on presence of tenantID. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class IdentityToolkitRequest { - /// Gets the RPC's endpoint. + /// RPC endpoint name, e.g. "signInWithPassword" or full exchange path let endpoint: String - /// Gets the client's API key used for the request. var apiKey: String - /// The tenant ID of the request. nil if none is available. let tenantID: String? - /// The toggle of using Identity Platform endpoints. let useIdentityPlatform: Bool - /// The toggle of using staging endpoints. let useStaging: Bool - /// The type of the client that the request sent from, which should be CLIENT_TYPE_IOS; var clientType: String - private let _requestConfiguration: AuthRequestConfiguration + /// Optional local emulator host and port + var emulatorHostAndPort: String? { + return _requestConfiguration.emulatorHostAndPort + } + private let _requestConfiguration: AuthRequestConfiguration init(endpoint: String, requestConfiguration: AuthRequestConfiguration, useIdentityPlatform: Bool = false, useStaging: Bool = false) { self.endpoint = endpoint @@ -64,42 +61,61 @@ class IdentityToolkitRequest { tenantID = requestConfiguration.auth?.tenantID } + /// Override this if you need query parameters (default none) func queryParams() -> String { return "" } - /// Returns the request's full URL. + /// Provide the same configuration for AuthBackend + func requestConfiguration() -> AuthRequestConfiguration { + return _requestConfiguration + } + + /// Build the full URL, branching on whether tenantID is set. func requestURL() -> URL { - let apiProtocol: String - let apiHostAndPathPrefix: String + guard let auth = _requestConfiguration.auth else { + fatalError("Internal Auth error: missing Auth on requestConfiguration") + } + let protocolScheme: String + let hostPrefix: String let urlString: String - let emulatorHostAndPort = _requestConfiguration.emulatorHostAndPort - if useIdentityPlatform { - if let emulatorHostAndPort = emulatorHostAndPort { - apiProtocol = kHttpProtocol - apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kIdentityPlatformAPIHost)" + // R-GCIP v2 if location is non-nil + let tenant = _requestConfiguration.tenantId + if let region = _requestConfiguration.location { + // Project identifier + guard let project = auth.app?.options.projectID else { + fatalError("Internal Auth error: missing projectID") + } + // Choose emulator, staging, or prod host + if let emu = emulatorHostAndPort { + protocolScheme = kHttpProtocol + hostPrefix = "\(emu)/\(kRegionalGCIPAPIHost)" } else if useStaging { - apiHostAndPathPrefix = kIdentityPlatformStagingAPIHost - apiProtocol = kHttpsProtocol + protocolScheme = kHttpsProtocol + hostPrefix = kRegionalGCIPStagingAPIHost } else { - apiHostAndPathPrefix = kIdentityPlatformAPIHost - apiProtocol = kHttpsProtocol + protocolScheme = kHttpsProtocol + hostPrefix = kRegionalGCIPAPIHost } - urlString = "\(apiProtocol)//\(apiHostAndPathPrefix)/v2/\(endpoint)?key=\(apiKey)" - + // Regionalized v2 path + urlString = + "\(protocolScheme)//\(hostPrefix)/v2/projects/\(project)" + + "/locations/\(region)/tenants/\(tenant)/idpConfigs/\(endpoint)?key=\(apiKey)" } else { - if let emulatorHostAndPort = emulatorHostAndPort { - apiProtocol = kHttpProtocol - apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kFirebaseAuthAPIHost)" + // Legacy GCIP v1 branch + if let emu = emulatorHostAndPort { + protocolScheme = kHttpProtocol + hostPrefix = "\(emu)/\(kFirebaseAuthAPIHost)" } else if useStaging { - apiProtocol = kHttpsProtocol - apiHostAndPathPrefix = kFirebaseAuthStagingAPIHost + protocolScheme = kHttpsProtocol + hostPrefix = kFirebaseAuthStagingAPIHost } else { - apiProtocol = kHttpsProtocol - apiHostAndPathPrefix = kFirebaseAuthAPIHost + protocolScheme = kHttpsProtocol + hostPrefix = kFirebaseAuthAPIHost } urlString = - "\(apiProtocol)//\(apiHostAndPathPrefix)/identitytoolkit/v3/relyingparty/\(endpoint)?key=\(apiKey)" + "\(protocolScheme)//\(hostPrefix)" + + "/identitytoolkit/v3/relyingparty/\(endpoint)?key=\(apiKey)" } guard let returnURL = URL(string: "\(urlString)\(queryParams())") else { fatalError("Internal Auth error: Failed to generate URL for \(urlString)") @@ -107,14 +123,14 @@ class IdentityToolkitRequest { return returnURL } - /// Returns the request's configuration. - func requestConfiguration() -> AuthRequestConfiguration { - _requestConfiguration - } + // MARK: - Testing API - // MARK: Internal API for development + /// For testing: override the global host for legacy flows + static var host: String { + get { gAPIHost } + set { gAPIHost = newValue } + } - static var host: String { gAPIHost } static func setHost(_ host: String) { gAPIHost = host } diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift new file mode 100644 index 00000000000..cba9c1b8ba8 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenRequest.swift @@ -0,0 +1,54 @@ +// 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 + +/// Swap a third‑party OIDC token for a Firebase STS token. +@available(iOS 13, *) +struct ExchangeTokenRequest: AuthRPCRequest { + typealias Response = ExchangeTokenResponse + + private let idpConfigID: String + private let idToken: String + private let cfg: AuthRequestConfiguration + init(idpConfigID: String, + idToken: String, + cfg: AuthRequestConfiguration) { + self.idpConfigID = idpConfigID + self.idToken = idToken + self.cfg = cfg + } + + var unencodedHTTPRequestBody: [String: AnyHashable]? { + ["id_token": idToken] + } + + func requestURL() -> URL { + // Pull flags from the requestConfiguration + guard let region = cfg.location, + let tenant = cfg.tenantId, + let project = cfg.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" + return URL(string: "https://\(host)\(path)?key=\(cfg.apiKey)")! + } + + func requestConfiguration() -> AuthRequestConfiguration { cfg } +} diff --git a/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift new file mode 100644 index 00000000000..d0b15b6c428 --- /dev/null +++ b/FirebaseAuth/Sources/Swift/Backend/RPC/ExchangeTokenResponse.swift @@ -0,0 +1,27 @@ +// 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 + +/// Response containing the new Firebase STS token. +@available(iOS 13, *) +struct ExchangeTokenResponse: AuthRPCResponse { + let firebaseToken: String + init(dictionary: [String: AnyHashable]) throws { + guard let token = dictionary["idToken"] as? String else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary) + } + firebaseToken = token + } +}