-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
base: byociam
Are you sure you want to change the base?
Changes from all commits
860fb46
0fac248
5bf9162
60940b7
dcdfa66
aed4e2e
d86b0d1
dc606c6
ed886f9
f2703e6
05d25cc
f068bbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
@@ -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 |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)" | ||
Comment on lines
+89
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)" | ||
|
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
/// 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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For |
||
} | ||
|
||
/// 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 } | ||
} |
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) | ||
} | ||
} |
There was a problem hiding this comment.
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.