Skip to content

Commit c958c21

Browse files
committed
api implementation
1 parent 491d037 commit c958c21

File tree

5 files changed

+263
-49
lines changed

5 files changed

+263
-49
lines changed

FirebaseAuth/Sources/Swift/Auth/Auth.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,48 @@ extension Auth: AuthInterop {
169169

170170
/// Gets the `FirebaseApp` object that this auth object is connected to.
171171
@objc public internal(set) weak var app: FirebaseApp?
172+
173+
/// New R-GCIP v2 + BYO-CIAM initializer.
174+
///
175+
/// This initializer allows to create an `Auth` instance with a specific `tenantConfig` for R-GCIP.
176+
/// - Parameters:
177+
/// - app: The `FirebaseApp` for which to initialize the `Auth` instance.
178+
/// - tenantConfig: The configuration for the tenant, including location and tenant ID.
179+
/// - Returns: An `Auth` instance configured for the specified tenant.
180+
public static func auth(app: FirebaseApp, tenantConfig: TenantConfig) -> Auth {
181+
// start from the legacy initializer so we get a fully-formed Auth object
182+
let auth = auth(app: app)
183+
kAuthGlobalWorkQueue.sync {
184+
auth.requestConfiguration.location = tenantConfig.location
185+
auth.requestConfiguration.tenantId = tenantConfig.tenantId
186+
}
187+
return auth
188+
}
189+
190+
/// Holds configuration for a R-GCIP tenant.
191+
public struct TenantConfig {
192+
public let location: String /// The location of the tenant.
193+
public let tenantId: String /// The ID of the tenant.
194+
195+
/// Initializes a `TenantConfig` instance.
196+
/// - Parameters:
197+
/// - location: The location of the tenant, defaults to "prod-global".
198+
/// - tenantId: The ID of the tenant.
199+
public init(location: String = "prod-global", tenantId: String) {
200+
self.location = location
201+
self.tenantId = tenantId
202+
}
203+
}
204+
205+
/// Holds a Firebase ID token and its expiration.
206+
public struct AuthExchangeToken {
207+
public let token: String
208+
public let expirationDate: Date?
209+
init(token: String, expirationDate: Date?) {
210+
self.token = token
211+
self.expirationDate = expirationDate
212+
}
213+
}
172214

173215
/// Synchronously gets the cached current user, or null if there is none.
174216
@objc public var currentUser: User? {
@@ -2425,3 +2467,41 @@ extension Auth: AuthInterop {
24252467
/// Mutations should occur within a @synchronized(self) context.
24262468
private var listenerHandles: NSMutableArray = []
24272469
}
2470+
2471+
@available(iOS 13, *)
2472+
public extension Auth {
2473+
2474+
/// Exchanges a third-party OIDC token for a Firebase STS token.
2475+
///
2476+
/// Requires the `Auth` instance to be configured with a `TenantConfig` for R-GCIP.
2477+
/// - Parameters:
2478+
/// - idpConfigID: The ID of the OIDC provider configuration.
2479+
/// - ciamOidcToken: The OIDC token to exchange.
2480+
/// - completion: Called with the Firebase ID token or an error.
2481+
@objc func exchangeToken(_ idpConfigID: String,
2482+
_ ciamOidcToken: String,
2483+
completion: @escaping (String?, Error?) -> Void) {
2484+
// Check if R-GCIP (location and tenantId) is configured.
2485+
guard let location = requestConfiguration.location,
2486+
let tenantId = requestConfiguration.tenantId
2487+
else {
2488+
completion(nil, AuthErrorUtils.operationNotAllowedError(
2489+
message: "Set location & tenantId first"
2490+
))
2491+
return
2492+
}
2493+
let request = ExchangeTokenRequest(
2494+
idpConfigID: idpConfigID,
2495+
idToken: ciamOidcToken,
2496+
config: requestConfiguration
2497+
)
2498+
Task {
2499+
do {
2500+
let resp = try await backend.call(with: request)
2501+
DispatchQueue.main.async { completion(resp.firebaseToken, nil) }
2502+
} catch {
2503+
DispatchQueue.main.async { completion(nil, error) }
2504+
}
2505+
}
2506+
}
2507+
}

FirebaseAuth/Sources/Swift/Backend/AuthRequestConfiguration.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,25 @@ final class AuthRequestConfiguration {
4343

4444
/// If set, the local emulator host and port to point to instead of the remote backend.
4545
var emulatorHostAndPort: String?
46+
47+
/// R-GCIP region, set once during Auth init.
48+
var location: String?
49+
50+
/// R-GCIP tenantId, set once during Auth init.
51+
var tenantId: String?
4652

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: Auth.TenantConfig) {
5259
self.apiKey = apiKey
5360
self.appID = appID
5461
self.auth = auth
5562
self.heartbeatLogger = heartbeatLogger
5663
self.appCheck = appCheck
64+
self.location = tenantConfig.location
65+
self.tenantId = tenantConfig.tenantId
5766
}
5867
}

FirebaseAuth/Sources/Swift/Backend/IdentityToolkitRequest.swift

Lines changed: 64 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,43 +16,40 @@ import Foundation
1616

1717
private let kHttpsProtocol = "https:"
1818
private let kHttpProtocol = "http:"
19-
20-
private let kEmulatorHostAndPrefixFormat = "%@/%@"
21-
22-
/// Host for server API calls. This should be changed via
23-
/// `IdentityToolkitRequest.setHost(_ host:)` for testing purposes only.
24-
private nonisolated(unsafe) var gAPIHost = "www.googleapis.com"
25-
19+
// Legacy GCIP v1 hosts
2620
private let kFirebaseAuthAPIHost = "www.googleapis.com"
27-
private let kIdentityPlatformAPIHost = "identitytoolkit.googleapis.com"
28-
2921
private let kFirebaseAuthStagingAPIHost = "staging-www.sandbox.googleapis.com"
30-
private let kIdentityPlatformStagingAPIHost =
31-
"staging-identitytoolkit.sandbox.googleapis.com"
32-
33-
/// Represents a request to an identity toolkit endpoint.
22+
// Regional R-GCIP v2 hosts
23+
private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com"
24+
private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"
25+
#if compiler(>=6)
26+
private nonisolated(unsafe) var gAPIHost = "www.googleapis.com"
27+
#else
28+
private var gAPIHost = "www.googleapis.com"
29+
#endif
30+
/// Represents a request to an Identity Toolkit endpoint, routing either to
31+
/// legacy GCIP v1 or regionalized R-GCIP v2 based on presence of tenantID.
3432
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
3533
class IdentityToolkitRequest {
36-
/// Gets the RPC's endpoint.
34+
/// RPC endpoint name, e.g. "signInWithPassword" or full exchange path
3735
let endpoint: String
38-
3936
/// Gets the client's API key used for the request.
4037
var apiKey: String
41-
4238
/// The tenant ID of the request. nil if none is available.
4339
let tenantID: String?
44-
4540
/// The toggle of using Identity Platform endpoints.
4641
let useIdentityPlatform: Bool
47-
4842
/// The toggle of using staging endpoints.
4943
let useStaging: Bool
50-
5144
/// The type of the client that the request sent from, which should be CLIENT_TYPE_IOS;
5245
var clientType: String
5346

54-
private let _requestConfiguration: AuthRequestConfiguration
47+
/// Optional local emulator host and port
48+
var emulatorHostAndPort: String? {
49+
return _requestConfiguration.emulatorHostAndPort
50+
}
5551

52+
private let _requestConfiguration: AuthRequestConfiguration
5653
init(endpoint: String, requestConfiguration: AuthRequestConfiguration,
5754
useIdentityPlatform: Bool = false, useStaging: Bool = false) {
5855
self.endpoint = endpoint
@@ -64,57 +61,76 @@ class IdentityToolkitRequest {
6461
tenantID = requestConfiguration.auth?.tenantID
6562
}
6663

64+
/// Override this if you need query parameters (default none)
6765
func queryParams() -> String {
6866
return ""
6967
}
7068

71-
/// Returns the request's full URL.
69+
/// Provide the same configuration for AuthBackend
70+
func requestConfiguration() -> AuthRequestConfiguration {
71+
return _requestConfiguration
72+
}
73+
74+
/// Build the full URL, branching on whether tenantID is set.
7275
func requestURL() -> URL {
73-
let apiProtocol: String
74-
let apiHostAndPathPrefix: String
76+
guard let auth = _requestConfiguration.auth else {
77+
fatalError("Internal Auth error: missing Auth on requestConfiguration")
78+
}
79+
let protocolScheme: String
80+
let hostPrefix: String
7581
let urlString: String
76-
let emulatorHostAndPort = _requestConfiguration.emulatorHostAndPort
77-
if useIdentityPlatform {
78-
if let emulatorHostAndPort = emulatorHostAndPort {
79-
apiProtocol = kHttpProtocol
80-
apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kIdentityPlatformAPIHost)"
82+
// R-GCIP v2 if location is non-nil
83+
let tenant = _requestConfiguration.tenantId
84+
if let region = _requestConfiguration.location {
85+
// Project identifier
86+
guard let project = auth.app?.options.projectID else {
87+
fatalError("Internal Auth error: missing projectID")
88+
}
89+
// Choose emulator, staging, or prod host
90+
if let emu = emulatorHostAndPort {
91+
protocolScheme = kHttpProtocol
92+
hostPrefix = "\(emu)/\(kRegionalGCIPAPIHost)"
8193
} else if useStaging {
82-
apiHostAndPathPrefix = kIdentityPlatformStagingAPIHost
83-
apiProtocol = kHttpsProtocol
94+
protocolScheme = kHttpsProtocol
95+
hostPrefix = kRegionalGCIPStagingAPIHost
8496
} else {
85-
apiHostAndPathPrefix = kIdentityPlatformAPIHost
86-
apiProtocol = kHttpsProtocol
97+
protocolScheme = kHttpsProtocol
98+
hostPrefix = kRegionalGCIPAPIHost
8799
}
88-
urlString = "\(apiProtocol)//\(apiHostAndPathPrefix)/v2/\(endpoint)?key=\(apiKey)"
89-
100+
// Regionalized v2 path
101+
urlString =
102+
"\(protocolScheme)//\(hostPrefix)/v2/projects/\(project)"
103+
+ "/locations/\(region)/tenants/\(tenant)/idpConfigs/\(endpoint)?key=\(apiKey)"
90104
} else {
91-
if let emulatorHostAndPort = emulatorHostAndPort {
92-
apiProtocol = kHttpProtocol
93-
apiHostAndPathPrefix = "\(emulatorHostAndPort)/\(kFirebaseAuthAPIHost)"
105+
// Legacy GCIP v1 branch
106+
if let emu = emulatorHostAndPort {
107+
protocolScheme = kHttpProtocol
108+
hostPrefix = "\(emu)/\(kFirebaseAuthAPIHost)"
94109
} else if useStaging {
95-
apiProtocol = kHttpsProtocol
96-
apiHostAndPathPrefix = kFirebaseAuthStagingAPIHost
110+
protocolScheme = kHttpsProtocol
111+
hostPrefix = kFirebaseAuthStagingAPIHost
97112
} else {
98-
apiProtocol = kHttpsProtocol
99-
apiHostAndPathPrefix = kFirebaseAuthAPIHost
113+
protocolScheme = kHttpsProtocol
114+
hostPrefix = kFirebaseAuthAPIHost
100115
}
101116
urlString =
102-
"\(apiProtocol)//\(apiHostAndPathPrefix)/identitytoolkit/v3/relyingparty/\(endpoint)?key=\(apiKey)"
117+
"\(protocolScheme)//\(hostPrefix)" +
118+
"/identitytoolkit/v3/relyingparty/\(endpoint)?key=\(apiKey)"
103119
}
104120
guard let returnURL = URL(string: "\(urlString)\(queryParams())") else {
105121
fatalError("Internal Auth error: Failed to generate URL for \(urlString)")
106122
}
107123
return returnURL
108124
}
109125

110-
/// Returns the request's configuration.
111-
func requestConfiguration() -> AuthRequestConfiguration {
112-
_requestConfiguration
113-
}
126+
// MARK: - Testing API
114127

115-
// MARK: Internal API for development
128+
/// For testing: override the global host for legacy flows
129+
static var host: String {
130+
get { gAPIHost }
131+
set { gAPIHost = newValue }
132+
}
116133

117-
static var host: String { gAPIHost }
118134
static func setHost(_ host: String) {
119135
gAPIHost = host
120136
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
/// A request to exchange a third-party OIDC token for a Firebase STS token.
18+
///
19+
/// This structure encapsulates the parameters required to make an API request
20+
/// to exchange an OIDC token for a Firebase ID token. It conforms to the
21+
/// `AuthRPCRequest` protocol, providing the necessary properties and
22+
/// methods for the authentication backend to perform the request.
23+
@available(iOS 13, *)
24+
struct ExchangeTokenRequest: AuthRPCRequest {
25+
/// The type of the expected response.
26+
typealias Response = ExchangeTokenResponse
27+
28+
/// The identifier of the OIDC provider configuration.
29+
private let idpConfigID: String
30+
31+
/// The third-party OIDC token to exchange.
32+
private let idToken: String
33+
34+
/// The configuration for the request, holding API key, tenant, etc.
35+
private let config: AuthRequestConfiguration
36+
37+
/// Initializes a new `ExchangeTokenRequest` instance.
38+
///
39+
/// - Parameters:
40+
/// - idpConfigID: The identifier of the OIDC provider configuration.
41+
/// - idToken: The third-party OIDC token to exchange.
42+
/// - config: The configuration for the request.
43+
init(idpConfigID: String,
44+
idToken: String,
45+
config: AuthRequestConfiguration) {
46+
self.idpConfigID = idpConfigID
47+
self.idToken = idToken
48+
self.config = config
49+
}
50+
51+
/// The unencoded HTTP request body for the API.
52+
var unencodedHTTPRequestBody: [String: AnyHashable]? {
53+
return ["id_token": idToken]
54+
}
55+
56+
/// Constructs the URL for the API request.
57+
///
58+
/// - Returns: The URL for the token exchange endpoint.
59+
/// - FatalError: if location, tenantID, projectID or apiKey are missing.
60+
func requestURL() -> URL {
61+
guard let region = config.location,
62+
let tenant = config.tenantId,
63+
let project = config.auth?.app?.options.projectID
64+
else {
65+
fatalError(
66+
"exchangeOidcToken requires `auth.useIdentityPlatform`, `auth.location`, `auth.tenantID` & `projectID`"
67+
)
68+
}
69+
let host = "\(region)-identityplatform.googleapis.com"
70+
let path = "/v2/projects/\(project)/locations/\(region)" +
71+
"/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"
72+
guard let url = URL(string: "https://\(host)\(path)?key=\(config.apiKey)") else {
73+
fatalError("Failed to create URL for exchangeOidcToken")
74+
}
75+
return url
76+
}
77+
78+
/// Returns the request configuration.
79+
///
80+
/// - Returns: The `AuthRequestConfiguration`.
81+
func requestConfiguration() -> AuthRequestConfiguration { config }
82+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
/// Response containing the new Firebase STS token.
18+
@available(iOS 13, *)
19+
struct ExchangeTokenResponse: AuthRPCResponse {
20+
let firebaseToken: String
21+
init(dictionary: [String: AnyHashable]) throws {
22+
guard let token = dictionary["idToken"] as? String else {
23+
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
24+
}
25+
firebaseToken = token
26+
}
27+
}

0 commit comments

Comments
 (0)