Skip to content

Add internal support for regionalized OIDC token exchange (BYO-CIAM) #14981

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

Merged
merged 5 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
154 changes: 154 additions & 0 deletions ExchangeTokenRequestTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2023 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 XCTest

@testable import FirebaseAuth
import FirebaseCore

/// Tests for `ExchangeTokenRequest`
@available(iOS 13, *)
class ExchangeTokenRequestTests: XCTestCase {
// MARK: - Constants for Testing

let kAPIKey = "test-api-key"
let kProjectID = "test-project-id"
let kLocation = "us-east1"
let kTenantID = "test-tenant-id-123"
let kIdToken = "a-very-long-and-secure-oidc-token-string"
let kIdpConfigId = "oidc.my-test-provider"

let kProductionHost = "identityplatform.googleapis.com"
let kStagingHost = "staging-identityplatform.sandbox.googleapis.com"

// MARK: - Test Cases

/// Tests that the production URL is correctly formed for a specific region.
func testProductionURLIsCorrectlyConstructed() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)

let request = ExchangeTokenRequest(
idToken: kIdToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: false
)

let expectedHost = "\(kLocation)-\(kProductionHost)"
let expectedURL = "https://\(expectedHost)/v2beta/projects/\(kProjectID)" +
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

/// Tests that the production URL is correctly formed for the "prod-global" location.
func testProductionURLIsCorrectlyConstructedForGlobalLocation() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: "prod-global",
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
idToken: kIdToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: false
)

let expectedHost = kProductionHost
let expectedURL = "https://\(expectedHost)/v2beta/projects/\(kProjectID)" +
"/locations/global/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

/// Tests that the staging URL is correctly formed.
func testStagingURLIsCorrectlyConstructed() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
idToken: kIdToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration,
useStaging: true
)

let expectedHost = "\(kLocation)-\(kStagingHost)"
let expectedURL = "https://\(expectedHost)/v2beta/projects/\(kProjectID)" +
"/locations/\(kLocation)/tenants/\(kTenantID)/idpConfigs/\(kIdpConfigId):exchangeOidcToken?key=\(kAPIKey)"

XCTAssertEqual(request.requestURL().absoluteString, expectedURL)
}

/// Tests that the unencoded HTTP body contains the correct id_token.
func testUnencodedHTTPBodyIsCorrect() {
let (auth, app) = createTestAuthInstance(
projectID: kProjectID,
location: kLocation,
tenantId: kTenantID
)
_ = app

let request = ExchangeTokenRequest(
idToken: kIdToken,
idpConfigID: kIdpConfigId,
config: auth.requestConfiguration
)

let body = request.unencodedHTTPRequestBody
XCTAssertNotNil(body)
XCTAssertEqual(body?.count, 1)
XCTAssertEqual(body?["id_token"] as? String, kIdToken)
}

// MARK: - Helper Function

/// Creates a test FirebaseApp and Auth instance with specified configurations.
private func createTestAuthInstance(projectID: String?, location: String?,
tenantId: String?) -> (auth: Auth, app: FirebaseApp) {
let appName = "TestApp-\(UUID().uuidString)"
let options = FirebaseOptions(
googleAppID: "1:1234567890:ios:abcdef123456",
gcmSenderID: "1234567890"
)
options.apiKey = kAPIKey
if let projectID = projectID {
options.projectID = projectID
}

if FirebaseApp.app(name: appName) != nil {
FirebaseApp.app(name: appName)?.delete { _ in }
}
let app = FirebaseApp(instanceWithName: appName, options: options)

let auth = Auth(app: app)
auth.app = app
auth.requestConfiguration.location = location
auth.requestConfiguration.tenantId = tenantId

return (auth, app)
}
}
180 changes: 144 additions & 36 deletions FirebaseAuth/Sources/Swift/Auth/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import FirebaseAppCheckInterop
import FirebaseAuthInterop
import FirebaseCore
import FirebaseCoreExtension
import FirebaseCoreInternal
#if COCOAPODS
internal import GoogleUtilities
#else
Expand Down Expand Up @@ -83,48 +84,74 @@ extension Auth: AuthInterop {
public func getToken(forcingRefresh forceRefresh: Bool,
completion callback: @escaping (String?, Error?) -> Void) {
kAuthGlobalWorkQueue.async { [weak self] in
if let strongSelf = self {
// Enable token auto-refresh if not already enabled.
if !strongSelf.autoRefreshTokens {
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
strongSelf.autoRefreshTokens = true
strongSelf.scheduleAutoTokenRefresh()

#if os(iOS) || os(tvOS) // TODO(ObjC): Is a similar mechanism needed on macOS?
strongSelf.applicationDidBecomeActiveObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil, queue: nil
) { notification in
if let strongSelf = self {
strongSelf.isAppInBackground = false
if !strongSelf.autoRefreshScheduled {
strongSelf.scheduleAutoTokenRefresh()
}
}
}
strongSelf.applicationDidEnterBackgroundObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: nil
) { notification in
if let strongSelf = self {
strongSelf.isAppInBackground = true
}
}
#endif
guard let self else {
DispatchQueue.main.async { callback(nil, nil) }
return
}
/// Before checking for a standard user, check if we are in a token-only session established
/// by a successful exchangeToken call.
let rGCIPToken = self.rGCIPFirebaseTokenLock.withLock { $0 }

if let token = rGCIPToken {
/// Logic for tokens obtained via exchangeToken (R-GCIP mode)
if token.expirationDate < Date() {
/// Token expired
let error = AuthErrorUtils
.userTokenExpiredError(
message: "The firebase access token obtained via exchangeToken() has expired."
)
Auth.wrapMainAsync(callback: callback, with: .failure(error))
} else if forceRefresh {
/// Token is not expired, but forceRefresh was requested which is currently unsupported
let error = AuthErrorUtils
.operationNotAllowedError(
message: "forceRefresh is not supported for firebase access tokens obtained via exchangeToken()."
)
Auth.wrapMainAsync(callback: callback, with: .failure(error))
} else {
/// The token is valid and not expired.
Auth.wrapMainAsync(callback: callback, with: .success(token.token))
}
/// Exit here as this path is for rGCIPFirebaseToken only.
return
}
// Call back with 'nil' if there is no current user.
guard let strongSelf = self, let currentUser = strongSelf._currentUser else {
/// Fallback to standard `currentUser` logic if not in token-only mode.
if !self.autoRefreshTokens {
AuthLog.logInfo(code: "I-AUT000002", message: "Token auto-refresh enabled.")
self.autoRefreshTokens = true
self.scheduleAutoTokenRefresh()

#if os(iOS) || os(tvOS)
self.applicationDidBecomeActiveObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: nil
) { [weak self] _ in
guard let self = self, !self.isAppInBackground,
!self.autoRefreshScheduled else { return }
self.scheduleAutoTokenRefresh()
}
self.applicationDidEnterBackgroundObserver =
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.isAppInBackground = true
}
#endif
}

guard let currentUser = self._currentUser else {
DispatchQueue.main.async {
callback(nil, nil)
}
return
}
// Call back with current user token.
currentUser
.internalGetToken(forceRefresh: forceRefresh, backend: strongSelf.backend) { token, error in
.internalGetToken(forceRefresh: forceRefresh, backend: self.backend) { token, error in
DispatchQueue.main.async {
callback(token, error)
}
Expand Down Expand Up @@ -2265,6 +2292,11 @@ extension Auth: AuthInterop {
return { result in
switch result {
case let .success(authResult):
/// When a standard user successfully signs in, any existing token-only session must be
/// invalidated to prevent a conflicting auth state.
/// Clear any R-GCIP session state when a standard user signs in. This ensures we exit
/// Token-Only Mode.
self.rGCIPFirebaseTokenLock.withLock { $0 = nil }
do {
try self.updateCurrentUser(authResult.user, byForce: false, savingToDisk: true)
Auth.wrapMainAsync(callback: callback, with: .success(authResult))
Expand Down Expand Up @@ -2428,9 +2460,20 @@ extension Auth: AuthInterop {
///
/// Mutations should occur within a @synchronized(self) context.
private var listenerHandles: NSMutableArray = []

// R-GCIP Token-Only Session State

/// The session token obtained from a successful `exchangeToken` call, protected by a lock.
///
/// This property is used to support a "token-only" authentication mode for Regionalized
/// GCIP, where no `User` object is created. It is mutually exclusive with `_currentUser`.
/// If the wrapped value is non-nil, the `AuthInterop` layer will use it for token generation
/// instead of relying on a `currentUser`.
private let rGCIPFirebaseTokenLock = FIRAllocatedUnfairLock<FirebaseToken?>(initialState: nil)
}

/// Regionalized auth
// MARK: - Regionalized auth

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
public extension Auth {
/// Gets the Auth object for a `FirebaseApp` configured for a specific Regional Google Cloud
Expand All @@ -2457,10 +2500,75 @@ public extension Auth {
///
/// - Parameters:
/// - tenantId: The ID of the tenant.
/// - location: The location of the tenant. Defaults to "prod-global".
public init(tenantId: String, location: String = "prod-global") {
/// - location: The location of the tenant. Defaults to "global".
public init(tenantId: String, location: String = "global") {
self.location = location
self.tenantId = tenantId
}
}

/// Exchanges a third-party OIDC ID token for a Firebase ID token.
///
/// This method is used for Bring Your Own CIAM (BYO-CIAM) in Regionalized GCIP (R-GCIP),
/// where the `Auth` instance must be configured with a `TenantConfig`, including `location`
/// and `tenantId`, typically by using `Auth.auth(app:tenantConfig:)`.
///
/// Unlike standard sign-in methods, this flow *does not* create or update a `User`object and
/// *does not* set `CurrentUser` on the `Auth` instance. It only returns a Firebase token.
///
/// - Parameters:
/// - oidcToken: The OIDC ID token obtained from the third-party identity provider.
/// - idpConfigId: The ID of the Identity Provider configuration within your GCIP tenant
/// - useStaging: A Boolean value indicating whether to use the staging Identity Platform
/// backend. Defaults to `false`.
/// - Returns: A `FirebaseToken` containing the Firebase ID token and its expiration date.
/// - Throws: An error if the `Auth` instance is not configured for R-GCIP, if the network
/// call fails, or if the token response parsing fails.
func exchangeToken(idToken: String, idpConfigId: String,
useStaging: Bool = false) async throws -> FirebaseToken {
// Ensure R-GCIP is configured with location and tenant ID
guard let _ = requestConfiguration.tenantConfig?.location,
let _ = requestConfiguration.tenantConfig?.tenantId
else {
/// This should never happen in production code, as it indicates a misconfiguration.
fatalError("R-GCIP is not configured correctly.")
}
let request = ExchangeTokenRequest(
idToken: idToken,
idpConfigID: idpConfigId,
config: requestConfiguration,
useStaging: useStaging
)
do {
let response = try await backend.call(with: request)
let newToken = FirebaseToken(
token: response.firebaseToken,
expirationDate: response.expirationDate
)
// Lock and update the token, signing out any current user.
rGCIPFirebaseTokenLock.withLock { token in
if self._currentUser != nil {
try? self.signOut()
}
token = newToken
}
return newToken
} catch {
throw error
}
}
}

/// Represents the result of a successful OIDC token exchange, containing a Firebase ID token
/// and its expiration.
public struct FirebaseToken: Sendable {
/// The Firebase ID token string.
public let token: String
/// The date at which the Firebase ID token expires.
public let expirationDate: Date

init(token: String, expirationDate: Date) {
self.token = token
self.expirationDate = expirationDate
}
}
Loading
Loading