Skip to content

Commit 199549f

Browse files
authored
merging into private preview branch
1 parent 2c45a6c commit 199549f

File tree

11 files changed

+1159
-404
lines changed

11 files changed

+1159
-404
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 179 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import FirebaseAppCheckInterop
1818
import FirebaseAuthInterop
1919
import FirebaseCore
2020
import FirebaseCoreExtension
21+
import FirebaseCoreInternal
2122
#if COCOAPODS
2223
internal import GoogleUtilities
2324
#else
@@ -83,48 +84,74 @@ extension Auth: AuthInterop {
8384
public func getToken(forcingRefresh forceRefresh: Bool,
8485
completion callback: @escaping (String?, Error?) -> Void) {
8586
kAuthGlobalWorkQueue.async { [weak self] in
86-
if let strongSelf = self {
87-
// Enable token auto-refresh if not already enabled.
88-
if !strongSelf.autoRefreshTokens {
89-
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
90-
strongSelf.autoRefreshTokens = true
91-
strongSelf.scheduleAutoTokenRefresh()
92-
93-
#if os(iOS) || os(tvOS) // TODO(ObjC): Is a similar mechanism needed on macOS?
94-
strongSelf.applicationDidBecomeActiveObserver =
95-
NotificationCenter.default.addObserver(
96-
forName: UIApplication.didBecomeActiveNotification,
97-
object: nil, queue: nil
98-
) { notification in
99-
if let strongSelf = self {
100-
strongSelf.isAppInBackground = false
101-
if !strongSelf.autoRefreshScheduled {
102-
strongSelf.scheduleAutoTokenRefresh()
103-
}
104-
}
105-
}
106-
strongSelf.applicationDidEnterBackgroundObserver =
107-
NotificationCenter.default.addObserver(
108-
forName: UIApplication.didEnterBackgroundNotification,
109-
object: nil, queue: nil
110-
) { notification in
111-
if let strongSelf = self {
112-
strongSelf.isAppInBackground = true
113-
}
114-
}
115-
#endif
87+
guard let self else {
88+
DispatchQueue.main.async { callback(nil, nil) }
89+
return
90+
}
91+
/// Before checking for a standard user, check if we are in a token-only session established
92+
/// by a successful exchangeToken call.
93+
let rGCIPToken = self.rGCIPFirebaseTokenLock.withLock { $0 }
94+
95+
if let token = rGCIPToken {
96+
/// Logic for tokens obtained via exchangeToken (R-GCIP mode)
97+
if token.expirationDate < Date() {
98+
/// Token expired
99+
let error = AuthErrorUtils
100+
.userTokenExpiredError(
101+
message: "The firebase access token obtained via exchangeToken() has expired."
102+
)
103+
Auth.wrapMainAsync(callback: callback, with: .failure(error))
104+
} else if forceRefresh {
105+
/// Token is not expired, but forceRefresh was requested which is currently unsupported
106+
let error = AuthErrorUtils
107+
.operationNotAllowedError(
108+
message: "forceRefresh is not supported for firebase access tokens obtained via exchangeToken()."
109+
)
110+
Auth.wrapMainAsync(callback: callback, with: .failure(error))
111+
} else {
112+
/// The token is valid and not expired.
113+
Auth.wrapMainAsync(callback: callback, with: .success(token.token))
116114
}
115+
/// Exit here as this path is for rGCIPFirebaseToken only.
116+
return
117+
}
118+
/// Fallback to standard `currentUser` logic if not in token-only mode.
119+
if !self.autoRefreshTokens {
120+
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
121+
self.autoRefreshTokens = true
122+
self.scheduleAutoTokenRefresh()
123+
124+
#if os(iOS) || os(tvOS)
125+
self.applicationDidBecomeActiveObserver =
126+
NotificationCenter.default.addObserver(
127+
forName: UIApplication.didBecomeActiveNotification,
128+
object: nil,
129+
queue: nil
130+
) { [weak self] _ in
131+
guard let self = self, !self.isAppInBackground,
132+
!self.autoRefreshScheduled else { return }
133+
self.scheduleAutoTokenRefresh()
134+
}
135+
self.applicationDidEnterBackgroundObserver =
136+
NotificationCenter.default.addObserver(
137+
forName: UIApplication.didEnterBackgroundNotification,
138+
object: nil,
139+
queue: nil
140+
) { [weak self] _ in
141+
self?.isAppInBackground = true
142+
}
143+
#endif
117144
}
118-
// Call back with 'nil' if there is no current user.
119-
guard let strongSelf = self, let currentUser = strongSelf._currentUser else {
145+
146+
guard let currentUser = self._currentUser else {
120147
DispatchQueue.main.async {
121148
callback(nil, nil)
122149
}
123150
return
124151
}
125152
// Call back with current user token.
126153
currentUser
127-
.internalGetToken(forceRefresh: forceRefresh, backend: strongSelf.backend) { token, error in
154+
.internalGetToken(forceRefresh: forceRefresh, backend: self.backend) { token, error in
128155
DispatchQueue.main.async {
129156
callback(token, error)
130157
}
@@ -1645,7 +1672,7 @@ extension Auth: AuthInterop {
16451672
init(app: FirebaseApp,
16461673
keychainStorageProvider: AuthKeychainStorage = AuthKeychainStorageReal.shared,
16471674
backend: AuthBackend = .init(rpcIssuer: AuthBackendRPCIssuer()),
1648-
authDispatcher: AuthDispatcher = .init()) {
1675+
authDispatcher: AuthDispatcher = .init(), tenantConfig: TenantConfig? = nil) {
16491676
self.app = app
16501677
mainBundleUrlTypes = Bundle.main
16511678
.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]]
@@ -1668,7 +1695,8 @@ extension Auth: AuthInterop {
16681695
appID: app.options.googleAppID,
16691696
auth: nil,
16701697
heartbeatLogger: app.heartbeatLogger,
1671-
appCheck: appCheck)
1698+
appCheck: appCheck,
1699+
tenantConfig: tenantConfig)
16721700
self.backend = backend
16731701
self.authDispatcher = authDispatcher
16741702

@@ -2264,6 +2292,11 @@ extension Auth: AuthInterop {
22642292
return { result in
22652293
switch result {
22662294
case let .success(authResult):
2295+
/// When a standard user successfully signs in, any existing token-only session must be
2296+
/// invalidated to prevent a conflicting auth state.
2297+
/// Clear any R-GCIP session state when a standard user signs in. This ensures we exit
2298+
/// Token-Only Mode.
2299+
self.rGCIPFirebaseTokenLock.withLock { $0 = nil }
22672300
do {
22682301
try self.updateCurrentUser(authResult.user, byForce: false, savingToDisk: true)
22692302
Auth.wrapMainAsync(callback: callback, with: .success(authResult))
@@ -2427,4 +2460,115 @@ extension Auth: AuthInterop {
24272460
///
24282461
/// Mutations should occur within a @synchronized(self) context.
24292462
private var listenerHandles: NSMutableArray = []
2463+
2464+
// R-GCIP Token-Only Session State
2465+
2466+
/// The session token obtained from a successful `exchangeToken` call, protected by a lock.
2467+
///
2468+
/// This property is used to support a "token-only" authentication mode for Regionalized
2469+
/// GCIP, where no `User` object is created. It is mutually exclusive with `_currentUser`.
2470+
/// If the wrapped value is non-nil, the `AuthInterop` layer will use it for token generation
2471+
/// instead of relying on a `currentUser`.
2472+
private let rGCIPFirebaseTokenLock = FIRAllocatedUnfairLock<FirebaseToken?>(initialState: nil)
2473+
}
2474+
2475+
// MARK: - Regionalized Auth
2476+
2477+
/// Holds configuration for a Regional Google Cloud Identity Platform (R-GCIP) tenant.
2478+
public struct TenantConfig: Sendable {
2479+
public let tenantId: String
2480+
public let location: String
2481+
/// Initializes a `TenantConfig` instance.
2482+
///
2483+
/// - Parameters:
2484+
/// - tenantId: The ID of the tenant.
2485+
/// - location: The location of the tenant. Defaults to "prod-global".
2486+
public init(tenantId: String, location: String = "prod-global") {
2487+
self.location = location
2488+
self.tenantId = tenantId
2489+
}
2490+
}
2491+
2492+
/// Represents the result of a successful OIDC token exchange, containing a Firebase ID token
2493+
/// and its expiration.
2494+
public struct FirebaseToken: Sendable {
2495+
/// The Firebase ID token string.
2496+
public let token: String
2497+
/// The date at which the Firebase ID token expires.
2498+
public let expirationDate: Date
2499+
2500+
init(token: String, expirationDate: Date) {
2501+
self.token = token
2502+
self.expirationDate = expirationDate
2503+
}
2504+
}
2505+
2506+
/// Regionalized auth
2507+
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
2508+
public extension Auth {
2509+
/// Gets the Auth object for a `FirebaseApp` configured for a specific Regional Google Cloud
2510+
/// Identity Platform (R-GCIP) tenant.
2511+
///
2512+
/// Use this method to create an `Auth` instance that interacts with a regionalized
2513+
/// authentication backend instead of the default endpoint.
2514+
///
2515+
/// - Parameters:
2516+
/// - app: The Firebase app instance.
2517+
/// - tenantConfig: The configuration for the R-GCIP tenant, specifying the tenant ID and its
2518+
/// location.
2519+
/// - Returns: The `Auth` instance associated with the given app and tenant config.
2520+
static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth {
2521+
return Auth(app: app, tenantConfig: tenantConfig)
2522+
}
2523+
2524+
/// Exchanges a third-party OIDC ID token for a Firebase ID token.
2525+
///
2526+
/// This method is used for Bring Your Own CIAM (BYO-CIAM) in Regionalized GCIP (R-GCIP),
2527+
/// where the `Auth` instance must be configured with a `TenantConfig`, including `location`
2528+
/// and `tenantId`, typically by using `Auth.auth(app:tenantConfig:)`.
2529+
///
2530+
/// Unlike standard sign-in methods, this flow *does not* create or update a `User`object and
2531+
/// *does not* set `CurrentUser` on the `Auth` instance. It only returns a Firebase token.
2532+
///
2533+
/// - Parameters:
2534+
/// - oidcToken: The OIDC ID token obtained from the third-party identity provider.
2535+
/// - idpConfigId: The ID of the Identity Provider configuration within your GCIP tenant
2536+
/// - useStaging: A Boolean value indicating whether to use the staging Identity Platform
2537+
/// backend. Defaults to `false`.
2538+
/// - Returns: A `FirebaseToken` containing the Firebase ID token and its expiration date.
2539+
/// - Throws: An error if the `Auth` instance is not configured for R-GCIP, if the network
2540+
/// call fails, or if the token response parsing fails.
2541+
func exchangeToken(idToken: String, idpConfigId: String,
2542+
useStaging: Bool = false) async throws -> FirebaseToken {
2543+
// Ensure R-GCIP is configured with location and tenant ID
2544+
guard let _ = requestConfiguration.tenantConfig?.location,
2545+
let _ = requestConfiguration.tenantConfig?.tenantId
2546+
else {
2547+
/// This should never happen in production code, as it indicates a misconfiguration.
2548+
fatalError("R-GCIP is not configured correctly.")
2549+
}
2550+
let request = ExchangeTokenRequest(
2551+
idToken: idToken,
2552+
idpConfigID: idpConfigId,
2553+
config: requestConfiguration,
2554+
useStaging: useStaging
2555+
)
2556+
do {
2557+
let response = try await backend.call(with: request)
2558+
let newToken = FirebaseToken(
2559+
token: response.firebaseToken,
2560+
expirationDate: response.expirationDate
2561+
)
2562+
// Lock and update the token, signing out any current user.
2563+
rGCIPFirebaseTokenLock.withLock { token in
2564+
if self._currentUser != nil {
2565+
try? self.signOut()
2566+
}
2567+
token = newToken
2568+
}
2569+
return newToken
2570+
} catch {
2571+
throw error
2572+
}
2573+
}
24302574
}

FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,22 @@ final class AuthRequestConfiguration {
4444
/// If set, the local emulator host and port to point to instead of the remote backend.
4545
var emulatorHostAndPort: String?
4646

47+
/// The regionalized GCIP tenant configuration, if provided.
48+
/// This property contains tenant ID and location for regionalized GCIP services
49+
/// It's non-`nil` only when the `Auth` instance is initialized with `TenantConfig`.
50+
let tenantConfig: TenantConfig?
51+
4752
init(apiKey: String,
4853
appID: String,
4954
auth: Auth? = nil,
5055
heartbeatLogger: FIRHeartbeatLoggerProtocol? = nil,
51-
appCheck: AppCheckInterop? = nil) {
56+
appCheck: AppCheckInterop? = nil,
57+
tenantConfig: TenantConfig? = nil) {
5258
self.apiKey = apiKey
5359
self.appID = appID
5460
self.auth = auth
5561
self.heartbeatLogger = heartbeatLogger
5662
self.appCheck = appCheck
63+
self.tenantConfig = tenantConfig
5764
}
5865
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com"
18+
private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"
19+
20+
// MARK: - ExchangeTokenRequest
21+
22+
/// A request to exchange a third-party OIDC ID token for a Firebase ID token.
23+
///
24+
/// This structure encapsulates the parameters required to call the
25+
/// `exchangeOidcToken` endpoint on the regionalized Identity Platform backend.
26+
/// It conforms to `AuthRPCRequest`, providing the necessary properties and
27+
/// methods for the authentication backend to perform the request.
28+
/// This is used for the BYO-CIAM (regionalized GCIP) flow.
29+
@available(iOS 13, *)
30+
struct ExchangeTokenRequest: AuthRPCRequest {
31+
/// The type of the expected response.
32+
typealias Response = ExchangeTokenResponse
33+
34+
/// The customer application redirects the user to the OIDC provider,
35+
/// and receives this idToken for the user upon successful authentication.
36+
let idToken: String
37+
38+
/// The ID of the Identity Provider configuration, as configured for the tenant.
39+
let idpConfigID: String
40+
41+
/// The auth configuration for the request, holding API key, etc.
42+
let config: AuthRequestConfiguration
43+
44+
/// Flag for whether to use the staging backend.
45+
let useStaging: Bool
46+
47+
/// Initializes an `ExchangeTokenRequest`.
48+
///
49+
/// - Parameters:
50+
/// - idToken: The third-party OIDC ID token from the external IdP to be exchanged.
51+
/// - idpConfigID: The ID of the IdP configuration.
52+
/// - config: The `AuthRequestConfiguration`.
53+
/// - useStaging: Set to `true` to target the staging environment. Defaults to `false`.
54+
init(idToken: String,
55+
idpConfigID: String,
56+
config: AuthRequestConfiguration,
57+
useStaging: Bool = false) {
58+
self.idToken = idToken
59+
self.idpConfigID = idpConfigID
60+
self.config = config
61+
self.useStaging = useStaging
62+
}
63+
64+
/// The unencoded HTTP request body for the API.
65+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
66+
return ["id_token": idToken]
67+
}
68+
69+
/// Constructs the full URL for the `ExchangeOidcToken` API endpoint.
70+
///
71+
/// - Important: This method will cause a `fatalError` if the `location`, `tenantId`, or
72+
/// `projectID` are missing from the configuration, as they are essential for
73+
/// constructing a valid regional endpoint URL.
74+
/// - Returns: The fully constructed `URL` for the API request.
75+
func requestURL() -> URL {
76+
guard let location = config.tenantConfig?.location,
77+
let tenant = config.tenantConfig?.tenantId,
78+
let project = config.auth?.app?.options.projectID
79+
else {
80+
fatalError(
81+
"Internal Error: ExchangeTokenRequest requires `location`, `tenantId`, and `projectID`."
82+
)
83+
}
84+
let baseHost = useStaging ? kRegionalGCIPStagingAPIHost : kRegionalGCIPAPIHost
85+
let host = (location == "prod-global" || location == "global") ? baseHost :
86+
"\(location)-\(baseHost)"
87+
88+
let locationPath = (location == "prod-global") ? "global" : location
89+
90+
let path = "/v2beta/projects/\(project)/locations/\(locationPath)" +
91+
"/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"
92+
93+
guard let url = URL(string: "https://\(host)\(path)?key=\(config.apiKey)") else {
94+
fatalError("Failed to create URL for ExchangeTokenRequest")
95+
}
96+
return url
97+
}
98+
99+
/// Returns the request configuration.
100+
func requestConfiguration() -> AuthRequestConfiguration {
101+
return config
102+
}
103+
}

0 commit comments

Comments
 (0)