Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

import Foundation

private let regionalGCIPAPIHost = "identityplatform.googleapis.com"
private let regionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"
private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com"
private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"
Comment on lines +17 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Prefer the non-prefixed versions for constants. The k prefix is more common in ObjC

Suggested change
private let kRegionalGCIPAPIHost = "identityplatform.googleapis.com"
private let kRegionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"
private let regionalGCIPAPIHost = "identityplatform.googleapis.com"
private let regionalGCIPStagingAPIHost = "staging-identityplatform.sandbox.googleapis.com"


// MARK: - ExchangeTokenRequest

Expand Down Expand Up @@ -44,14 +44,6 @@ struct ExchangeTokenRequest: AuthRPCRequest {
/// Flag for whether to use the staging backend.
let useStaging: Bool

/// The base URL components for the request, will be used to construct the final URL.
private var baseURLComponents: URLComponents {
var components = URLComponents()
components.scheme = "https"
components.host = useStaging ? regionalGCIPStagingAPIHost : regionalGCIPAPIHost
return components
}

/// Initializes an `ExchangeTokenRequest`.
///
/// - Parameters:
Expand Down Expand Up @@ -89,16 +81,16 @@ struct ExchangeTokenRequest: AuthRPCRequest {
"Internal Error: ExchangeTokenRequest requires `location`, `tenantId`, and `projectID`."
)
}
var components = baseURLComponents
if location != "global" {
components.host = "\(location)-\(components.host ?? "")"
}
let locationPath = location == "global" ? "global" : location
components
.path =
"/v2beta/projects/\(project)/locations/\(locationPath)/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"
components.queryItems = [URLQueryItem(name: "key", value: config.apiKey)]
guard let url = components.url else {
let baseHost = useStaging ? kRegionalGCIPStagingAPIHost : kRegionalGCIPAPIHost
let host = (location == "prod-global" || location == "global") ? baseHost :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there global and prod-global?

"\(location)-\(baseHost)"

let locationPath = (location == "prod-global") ? "global" : location

let path = "/v2beta/projects/\(project)/locations/\(locationPath)" +
"/tenants/\(tenant)/idpConfigs/\(idpConfigID):exchangeOidcToken"

guard let url = URL(string: "https://\(host)\(path)?key=\(config.apiKey)") else {
fatalError("Failed to create URL for ExchangeTokenRequest")
}
return url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,14 @@ struct ExchangeTokenResponse: AuthRPCResponse {
/// - Throws: `AuthErrorUtils.unexpectedResponse` if the required fields
/// (like "idToken", "expiresIn") are missing, have unexpected types
init(dictionary: [String: AnyHashable]) throws {
guard let token = dictionary["idToken"] as? String else {
guard let token = dictionary["accessToken"] as? String else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
firebaseToken = token
guard let expiresInString = dictionary["expiresIn"] as? String,
let expiresInInterval = TimeInterval(expiresInString) else {
guard let expireIn = dictionary["expiresIn"] as? Int else {
throw AuthErrorUtils.unexpectedResponse(deserializedResponse: dictionary)
}
expiresIn = expiresInInterval
expirationDate = Date().addingTimeInterval(expiresIn)
expiresIn = TimeInterval(expireIn)
expirationDate = Date().addingTimeInterval(TimeInterval(expiresIn))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ class AppManager {
private var otherApp: FirebaseApp
var app: FirebaseApp

// Initialise Auth with TenantConfig
let tenantConfig = Auth.TenantConfig(tenantId: "Foo-e2e-tenant-007", location: "global")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TenantConfig should be moved out of Auth namespace.

func auth() -> Auth {
return Auth.auth(app: app)
return Auth.auth(app: app, tenantConfig: tenantConfig)
}

private init() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ enum AuthMenu: String {
case phoneEnroll
case totpEnroll
case multifactorUnenroll
case exchangeToken

// More intuitively named getter for `rawValue`.
var id: String { rawValue }
Expand Down Expand Up @@ -139,6 +140,9 @@ enum AuthMenu: String {
return "TOTP Enroll"
case .multifactorUnenroll:
return "Multifactor unenroll"
// R-GCIP Exchange Token
case .exchangeToken:
return "Exchange Token"
}
}

Expand Down Expand Up @@ -220,6 +224,8 @@ enum AuthMenu: String {
self = .totpEnroll
case "Multifactor unenroll":
self = .multifactorUnenroll
case "Exchange Token":
self = .exchangeToken
default:
return nil
}
Expand Down Expand Up @@ -354,9 +360,16 @@ class AuthMenuData: DataSourceProvidable {
return Section(headerDescription: header, items: items)
}

static var exchangeTokenSection: Section {
let header = "Exchange Token [Regionalized Auth]"
let items: [Item] = [
Item(title: AuthMenu.exchangeToken.name),
]
return Section(headerDescription: header, items: items)
}

static let sections: [Section] =
[settingsSection, providerSection, emailPasswordSection, otherSection, recaptchaSection,
customAuthDomainSection, appSection, oobSection, multifactorSection]
[exchangeTokenSection]

static var authLinkSections: [Section] {
let allItems = [providerSection, emailPasswordSection, otherSection].flatMap { $0.items }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ class AuthViewController: UIViewController, DataSourceProviderDelegate {

case .multifactorUnenroll:
mfaUnenroll()

case .exchangeToken:
callExchangeToken()
}
}

Expand Down Expand Up @@ -1085,4 +1088,110 @@ extension AuthViewController: ASAuthorizationControllerDelegate,
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
return view.window!
}

/// Orchestrates the UI flow to demonstrate the OIDC token exchange feature.
///
/// This function sequentially prompts the user for the necessary inputs (idpConfigID and custom
/// token) using async/await with UIAlerts. If both inputs are provided,
/// it calls the Auth.exchangeToken API and displays the result to the user.
private func callExchangeToken() {
Task {
do {
// 1. Prompt for the IDP Config ID and await user input.
guard let idpConfigId = await showTextInputPrompt(with: "Enter IDP Config ID:") else {
print("Token exchange cancelled: IDP Config ID was not provided.")
// Present an alert on the main thread to indicate cancellation.
DispatchQueue.main.async {
let alert = UIAlertController(title: "Cancelled",
message: "An IDP Config ID is required to proceed.",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
return
}

// 2. Prompt for the custom OIDC token and await user input.
guard let idToken = await showTextInputPrompt(with: "Enter OIDC Token:") else {
print("Token exchange cancelled: OIDC Token was not provided.")
// Present an alert on the main thread to indicate cancellation.
DispatchQueue.main.async {
let alert = UIAlertController(title: "Cancelled",
message: "An OIDC Token is required to proceed.",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
return
}

// 3. With both inputs, call the exchangeToken API.
// The `auth()` instance is pre-configured with a regional tenant in AppManager.
print("Attempting to exchange token...")
let result = try await AppManager.shared.auth().exchangeToken(
idToken: idToken,
idpConfigId: idpConfigId,
useStaging: true
)

// 4. Handle the success case by presenting an alert on the main thread.
print("Token exchange successful. Access Token: \(result.token)")
DispatchQueue.main.async {
let fullToken = result.token
let truncatedToken = self.truncateString(fullToken, maxLength: 20)
let message = "Firebase Access Token:\n\(truncatedToken)"
let alert = UIAlertController(
title: "Token Exchange Succeeded",
message: message,
preferredStyle: .alert
)
// Action to copy the token
let copyAction = UIAlertAction(title: "Copy Token", style: .default) { _ in
UIPasteboard.general.string = fullToken
// Show a brief confirmation
self.showCopyConfirmation()
}
alert.addAction(copyAction)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}

} catch {
// 5. Handle any errors during the process by presenting an alert on the main thread.
print("Failed to exchange token: \(error)")
DispatchQueue.main.async {
let alert = UIAlertController(
title: "Token Exchange Error",
message: error.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
}

// Helper function to truncate strings
private func truncateString(_ string: String, maxLength: Int) -> String {
if string.count > maxLength {
return String(string.prefix(maxLength)) + "..."
} else {
return string
}
}

// Helper function to show copy confirmation
private func showCopyConfirmation() {
let confirmationAlert = UIAlertController(
title: "Copied!",
message: "Token copied to clipboard.",
preferredStyle: .alert
)
present(confirmationAlert, animated: true)
// Automatically dismiss the confirmation after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
confirmationAlert.dismiss(animated: true, completion: nil)
}
}
}
Loading
Loading