Skip to content
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we not do let auth = Auth(app: app, tenantConfig: tenantConfig) ? Why are we setting these 2 separately? This is different from how we implemented in other client SDKs.

Copy link
Member

Choose a reason for hiding this comment

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

+1

auth.app = app
auth.requestConfiguration.location = location
auth.requestConfiguration.tenantId = tenantId

return (auth, app)
}
}
121 changes: 86 additions & 35 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
}
/// 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
}
// Call back with 'nil' if there is no current user.
guard let strongSelf = self, let currentUser = strongSelf._currentUser else {

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 Down Expand Up @@ -2511,10 +2554,18 @@ public extension Auth {
)
do {
let response = try await backend.call(with: request)
return FirebaseToken(
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
}
Expand Down
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"

// 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 :
"\(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
Loading
Loading