Skip to content

Commit 3d23891

Browse files
authored
implementing regionalized auth and exchangeToken (#14865)
Merging into `byociam` branch to retain prior changes and avoid maintaining multiple branches.
1 parent 491d037 commit 3d23891

File tree

10 files changed

+511
-18
lines changed

10 files changed

+511
-18
lines changed

ExchangeTokensRequestTests.swift

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright 2023 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+
import XCTest
17+
18+
@testable import FirebaseAuth
19+
import FirebaseCore
20+
21+
/// @class ExchangeTokenRequestTests
22+
/// @brief Tests for the @c ExchangeTokenRequest struct.
23+
@available(iOS 13, *)
24+
class ExchangeTokenRequestTests: XCTestCase {
25+
// MARK: - Constants for Testing
26+
27+
let kAPIKey = "test-api-key"
28+
let kProjectID = "test-project-id"
29+
let kLocation = "asia-northeast1"
30+
let kTenantID = "test-tenant-id-123"
31+
let kCustomToken = "a-very-long-and-secure-oidc-token-string"
32+
let kIdpConfigId = "oidc.my-test-provider"
33+
34+
let kProductionHost = "identityplatform.googleapis.com"
35+
let kStagingHost = "staging-identityplatform.sandbox.googleapis.com"
36+
37+
// MARK: - Test Cases
38+
39+
func testProductionURLIsCorrectlyConstructed() {
40+
let (auth, app) = createTestAuthInstance(
41+
projectID: kProjectID,
42+
location: kLocation,
43+
tenantId: kTenantID
44+
)
45+
_ = app
46+
47+
let request = ExchangeTokenRequest(
48+
customToken: kCustomToken,
49+
idpConfigID: kIdpConfigId,
50+
config: auth.requestConfiguration,
51+
useStaging: false
52+
)
53+
54+
let expectedHost = "\(kLocation)-\(kProductionHost)"
55+
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
56+
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"
57+
58+
XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
59+
}
60+
61+
func testProductionURLIsCorrectlyConstructedForGlobalLocation() {
62+
let (auth, app) = createTestAuthInstance(
63+
projectID: kProjectID,
64+
location: "prod-global",
65+
tenantId: kTenantID
66+
)
67+
_ = app
68+
69+
let request = ExchangeTokenRequest(
70+
customToken: kCustomToken,
71+
idpConfigID: kIdpConfigId,
72+
config: auth.requestConfiguration,
73+
useStaging: false
74+
)
75+
76+
let expectedHost = kProductionHost
77+
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
78+
"/locations/prod-global/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"
79+
80+
XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
81+
}
82+
83+
func testStagingURLIsCorrectlyConstructed() {
84+
let (auth, app) = createTestAuthInstance(
85+
projectID: kProjectID,
86+
location: kLocation,
87+
tenantId: kTenantID
88+
)
89+
_ = app
90+
91+
let request = ExchangeTokenRequest(
92+
customToken: kCustomToken,
93+
idpConfigID: kIdpConfigId,
94+
config: auth.requestConfiguration,
95+
useStaging: true
96+
)
97+
98+
let expectedHost = "\(kLocation)-\(kStagingHost)"
99+
let expectedURL = "https://\(expectedHost)/v2alpha/projects/\(kProjectID)" +
100+
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"
101+
102+
XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
103+
}
104+
105+
func testUnencodedHTTPBodyIsCorrect() {
106+
let (auth, app) = createTestAuthInstance(
107+
projectID: kProjectID,
108+
location: kLocation,
109+
tenantId: kTenantID
110+
)
111+
_ = app
112+
113+
let request = ExchangeTokenRequest(
114+
customToken: kCustomToken,
115+
idpConfigID: kIdpConfigId,
116+
config: auth.requestConfiguration
117+
)
118+
119+
let body = request.unencodedHTTPRequestBody
120+
XCTAssertNotNil(body)
121+
XCTAssertEqual(body?.count, 1)
122+
XCTAssertEqual(body?["custom_token"] as? String, kCustomToken)
123+
}
124+
125+
// MARK: - Helper Function
126+
127+
private func createTestAuthInstance(projectID: String?, location: String?,
128+
tenantId: String?) -> (auth: Auth, app: FirebaseApp) {
129+
let appName = "TestApp-\(UUID().uuidString)"
130+
let options = FirebaseOptions(
131+
googleAppID: "1:1234567890:ios:abcdef123456",
132+
gcmSenderID: "1234567890"
133+
)
134+
options.apiKey = kAPIKey
135+
if let projectID = projectID {
136+
options.projectID = projectID
137+
}
138+
139+
if FirebaseApp.app(name: appName) != nil {
140+
FirebaseApp.app(name: appName)?.delete { _ in }
141+
}
142+
let app = FirebaseApp(instanceWithName: appName, options: options)
143+
144+
let auth = Auth(app: app)
145+
auth.app = app
146+
auth.requestConfiguration.location = location
147+
auth.requestConfiguration.tenantId = tenantId
148+
149+
return (auth, app)
150+
}
151+
}

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,31 @@ extension Auth: AuthInterop {
140140
}
141141
}
142142

143+
/// Holds configuration for a R-GCIP tenant.
144+
public struct TenantConfig: Sendable {
145+
public let tenantId: String /// The ID of the tenant.
146+
public let location: String /// The location of the tenant.
147+
148+
/// Initializes a `TenantConfig` instance.
149+
/// - Parameters:
150+
/// - location: The location of the tenant, defaults to "prod-global".
151+
/// - tenantId: The ID of the tenant.
152+
public init(tenantId: String, location: String = "prod-global") {
153+
self.location = location
154+
self.tenantId = tenantId
155+
}
156+
}
157+
158+
/// Holds a Firebase ID token and its expiration.
159+
public struct FirebaseToken: Sendable {
160+
public let token: String
161+
public let expirationDate: Date
162+
init(token: String, expirationDate: Date) {
163+
self.token = token
164+
self.expirationDate = expirationDate
165+
}
166+
}
167+
143168
/// Manages authentication for Firebase apps.
144169
///
145170
/// This class is thread-safe.
@@ -170,6 +195,20 @@ extension Auth: AuthInterop {
170195
/// Gets the `FirebaseApp` object that this auth object is connected to.
171196
@objc public internal(set) weak var app: FirebaseApp?
172197

198+
/// Gets the auth object for a `FirebaseApp` with an optional `TenantConfig`.
199+
/// - Parameters:
200+
/// - app: The Firebase app instance.
201+
/// - tenantConfig: The optional configuration for the RGCIP.
202+
/// - Returns: The `Auth` instance associated with the given app and tenant config.
203+
public static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth {
204+
let auth = auth(app: app)
205+
kAuthGlobalWorkQueue.sync {
206+
auth.requestConfiguration.location = tenantConfig.location
207+
auth.requestConfiguration.tenantId = tenantConfig.tenantId
208+
}
209+
return auth
210+
}
211+
173212
/// Synchronously gets the cached current user, or null if there is none.
174213
@objc public var currentUser: User? {
175214
kAuthGlobalWorkQueue.sync {
@@ -2425,3 +2464,89 @@ extension Auth: AuthInterop {
24252464
/// Mutations should occur within a @synchronized(self) context.
24262465
private var listenerHandles: NSMutableArray = []
24272466
}
2467+
2468+
@available(iOS 13, *)
2469+
public extension Auth {
2470+
/// Exchanges a third-party OIDC token for a Firebase STS token.
2471+
///
2472+
/// This method is used in R-GCIP (multi-tenant) environments where the `Auth` instance must
2473+
/// be configured with a `TenantConfig`, including `location` and `tenantId`.
2474+
/// Unlike other sign-in methods, this flow *does not* create or update a `User` object.
2475+
///
2476+
/// - Parameters:
2477+
/// - request: The ExchangeTokenRequest containing the OIDC token and other parameters.
2478+
/// - completion: A closure that gets called with either an `AuthTokenResult` or an `Error`.
2479+
func exchangeToken(customToken: String,
2480+
idpConfigId: String,
2481+
useStaging: Bool = false,
2482+
completion: @escaping (FirebaseToken?, Error?) -> Void) {
2483+
// Ensure R-GCIP is configured with location and tenant ID
2484+
guard let _ = requestConfiguration.location,
2485+
let _ = requestConfiguration.tenantId
2486+
else {
2487+
Auth.wrapMainAsync(
2488+
callback: completion,
2489+
with: .failure(AuthErrorUtils
2490+
.operationNotAllowedError(message: "R-GCIP is not configured."))
2491+
)
2492+
return
2493+
}
2494+
let request = ExchangeTokenRequest(
2495+
customToken: customToken,
2496+
idpConfigID: idpConfigId,
2497+
config: requestConfiguration,
2498+
useStaging: true
2499+
)
2500+
Task {
2501+
do {
2502+
let response = try await backend.call(with: request)
2503+
let firebaseToken = FirebaseToken(
2504+
token: response.firebaseToken,
2505+
expirationDate: response.expirationDate
2506+
)
2507+
Auth.wrapMainAsync(callback: completion, with: .success(firebaseToken))
2508+
} catch {
2509+
Auth.wrapMainAsync(callback: completion, with: .failure(error))
2510+
}
2511+
}
2512+
}
2513+
2514+
/// Exchanges a third-party OIDC token for a Firebase STS token using Swift concurrency.
2515+
///
2516+
/// This async variant performs the same operation as the completion-based method but returns
2517+
/// the result directly and throws on failure.
2518+
///
2519+
/// The `Auth` instance must be configured with `TenantConfig` containing `location` and
2520+
/// `tenantId`.
2521+
/// Unlike other sign-in methods, this flow *does not* create or update a `User` object.
2522+
///
2523+
/// - Parameters:
2524+
/// - request: The ExchangeTokenRequest containing the OIDC token and other parameters.
2525+
/// - Returns: An `AuthTokenResult` containing the Firebase ID token and its expiration details.
2526+
/// - Throws: An error if R-GCIP is not configured, if the network call fails,
2527+
/// or if the token parsing fails.
2528+
func exchangeToken(customToken: String, idpConfigId: String,
2529+
useStaging: Bool = false) async throws -> FirebaseToken {
2530+
// Ensure R-GCIP is configured with location and tenant ID
2531+
guard let _ = requestConfiguration.location,
2532+
let _ = requestConfiguration.tenantId
2533+
else {
2534+
throw AuthErrorUtils.operationNotAllowedError(message: "R-GCIP is not configured.")
2535+
}
2536+
let request = ExchangeTokenRequest(
2537+
customToken: customToken,
2538+
idpConfigID: idpConfigId,
2539+
config: requestConfiguration,
2540+
useStaging: true
2541+
)
2542+
do {
2543+
let response = try await backend.call(with: request)
2544+
return FirebaseToken(
2545+
token: response.firebaseToken,
2546+
expirationDate: response.expirationDate
2547+
)
2548+
} catch {
2549+
throw error
2550+
}
2551+
}
2552+
}

FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,24 @@ 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+
/// R-GCIP location, set once during Auth init.
48+
var location: String?
49+
50+
/// R-GCIP tenantId, set once during Auth init.
51+
var tenantId: String?
52+
4753
init(apiKey: String,
4854
appID: String,
4955
auth: Auth? = nil,
5056
heartbeatLogger: FIRHeartbeatLoggerProtocol? = nil,
51-
appCheck: AppCheckInterop? = nil) {
57+
appCheck: AppCheckInterop? = nil,
58+
tenantConfig: TenantConfig? = nil) {
5259
self.apiKey = apiKey
5360
self.appID = appID
5461
self.auth = auth
5562
self.heartbeatLogger = heartbeatLogger
5663
self.appCheck = appCheck
64+
location = tenantConfig?.location
65+
tenantId = tenantConfig?.tenantId
5766
}
5867
}

FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ private let kFirebaseAuthStagingAPIHost = "staging-www.sandbox.googleapis.com"
3030
private let kIdentityPlatformStagingAPIHost =
3131
"staging-identitytoolkit.sandbox.googleapis.com"
3232

33-
/// Represents a request to an identity toolkit endpoint.
33+
/// Represents a request to an identity toolkit endpoint routing either to legacy GCIP or
34+
/// regionalized R-GCIP
3435
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
3536
class IdentityToolkitRequest {
3637
/// Gets the RPC's endpoint.
@@ -39,7 +40,7 @@ class IdentityToolkitRequest {
3940
/// Gets the client's API key used for the request.
4041
var apiKey: String
4142

42-
/// The tenant ID of the request. nil if none is available.
43+
/// The tenant ID of the request. nil if none is available (not for r-gcip).
4344
let tenantID: String?
4445

4546
/// The toggle of using Identity Platform endpoints.
@@ -74,6 +75,7 @@ class IdentityToolkitRequest {
7475
let apiHostAndPathPrefix: String
7576
let urlString: String
7677
let emulatorHostAndPort = _requestConfiguration.emulatorHostAndPort
78+
// legacy gcip logic
7779
if useIdentityPlatform {
7880
if let emulatorHostAndPort = emulatorHostAndPort {
7981
apiProtocol = kHttpProtocol

0 commit comments

Comments
 (0)