Files
app-check/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProvider.swift
T
Andrew Heard 958f0212cf fix(debug): only track registration state for local debug token (#135)
The debug provider stores a per-service "registered" flag so it stops
logging the local debug token's registration instructions once that
token has been exchanged successfully. Exchanges using the
AppCheckDebugToken or FIRAAppCheckDebugToken environment variable also
updated the flag, even though the local token wasn't used:

- On success, the local token was marked as registered, so after the
  variable was removed the SDK fell back to an unregistered token
  without logging how to register it.
- On failure, the flag was cleared, so an already registered local
  token logged its registration instructions again.

Only set or clear the flag when the local token was the one exchanged;
serverUnreachable failures still leave it untouched. There are no
public API changes.

Also document all parameters of the public initializer, and fix the
test tearDown, which only removed the registered key's prefix and let
the flag leak between tests.
2026-11-05 16:47:46 -05:00

267 lines
11 KiB
Swift

// Copyright 2026 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
#if COCOAPODS
import GoogleUtilities
#else
import GoogleUtilities_UserDefaults
#endif
private let kDebugTokenEnvKey = "AppCheckDebugToken"
private let kFirebaseDebugTokenEnvKey = "FIRAAppCheckDebugToken"
@objc(GACAppCheckDebugProvider)
public class AppCheckCoreDebugProvider: NSObject, AppCheckCoreProvider {
/// Storage key for the debug token in UserDefaults.
/// Internal scope exists for testing purposes.
/// Do not rename: retains the "GAC" prefix for compatibility with existing stored data from v11
/// or lower.
static let debugTokenUserDefaultsKey = "GACAppCheckDebugToken"
/// Storage key prefix for registered debug tokens in UserDefaults.
/// Internal scope exists for testing purposes.
/// Do not rename: retains the "GAC" prefix for compatibility with existing stored data from v11
/// or lower.
static let debugTokenRegisteredUserDefaultsKeyPrefix = "GACAppCheckDebugTokenRegistered"
private let apiService: AppCheckCoreDebugProviderAPIServiceProtocol
private let debugTokenEnvValue: String?
private let registeredUserDefaultsKey: String
// Internal initializer
init(apiService: AppCheckCoreDebugProviderAPIServiceProtocol,
serviceName: String,
resourceName: String,
environment: [String: String]) {
self.apiService = apiService
let registeredUserDefaultsKey = Self.registeredUserDefaultsKey(
forServiceName: serviceName,
resourceName: resourceName
)
self.registeredUserDefaultsKey = registeredUserDefaultsKey
let debugTokenEnvValue = Self.environmentVariableDebugToken(environment: environment)
self.debugTokenEnvValue = debugTokenEnvValue
super.init()
if debugTokenEnvValue == nil {
Self.logLocalDebugTokenIfNotRegistered(registeredUserDefaultsKey: registeredUserDefaultsKey)
}
}
/// Creates a debug provider that uses the environment variable, stored, or newly generated
/// debug token.
///
/// - Parameters:
/// - serviceName: The name of the service (e.g. the Firebase app name) using the provider.
/// - resourceName: The App Check resource name, `projects/{project}/apps/{app}`.
/// - baseURL: The App Check API base URL, or `nil` for the default.
/// - apiKey: The API key used to call the App Check API.
/// - requestHooks: Hooks invoked on each outgoing request. From Swift, pass
/// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the
/// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile
/// time and a mismatch will crash when the hook is invoked.
///
/// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot
/// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed
/// signature traps at runtime for any non-nil array passed from Objective-C. Do not
/// "simplify" this type — see PR #111.
@objc(initWithServiceName:resourceName:baseURL:APIKey:requestHooks:)
public convenience init(serviceName: String,
resourceName: String,
baseURL: String?,
apiKey: String,
requestHooks: [Any]?) {
self.init(serviceName: serviceName,
resourceName: resourceName,
baseURL: baseURL,
apiKey: apiKey,
requestHooks: requestHooks,
environment: ProcessInfo.processInfo.environment)
}
// Additional internal initializer for testing
convenience init(serviceName: String,
resourceName: String,
baseURL: String?,
apiKey: String,
requestHooks: [Any]?,
environment: [String: String]) {
let urlSession = URLSession(configuration: .ephemeral)
let coreAPIService = AppCheckCoreAPIService(urlSession: urlSession,
baseURL: baseURL,
apiKey: apiKey,
requestHooks: requestHooks,
environment: environment)
let debugAPIService = AppCheckCoreDebugProviderAPIService(apiService: coreAPIService,
resourceName: resourceName)
self.init(apiService: debugAPIService,
serviceName: serviceName,
resourceName: resourceName,
environment: environment)
}
@objc public func localDebugToken() -> String {
return Self.localDebugToken()
}
@objc public func currentDebugToken() -> String {
return debugTokenEnvValue ?? Self.localDebugToken()
}
/// Whether `currentDebugToken()` falls through to the locally stored or generated token.
private var usesLocalDebugToken: Bool {
return debugTokenEnvValue == nil
}
// MARK: - AppCheckCoreProvider
public func getToken() async throws -> AppCheckCoreToken {
return try await getToken(limitedUse: false)
}
public func getLimitedUseToken() async throws -> AppCheckCoreToken {
return try await getToken(limitedUse: true)
}
@objc(getTokenWithCompletion:)
public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) {
getToken(limitedUse: false, completion: handler)
}
@objc(getLimitedUseTokenWithCompletion:)
public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?)
-> Void) {
getToken(limitedUse: true, completion: handler)
}
// MARK: - Internal
private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken {
do {
let token = try await apiService.appCheckToken(
debugToken: currentDebugToken(),
limitedUse: limitedUse
)
// Only the locally generated token is tracked as registered: if an environment variable
// debug token is later removed, the local token's registration instructions must still be
// logged.
if usesLocalDebugToken {
GULUserDefaults.standard().setObject(true, forKey: registeredUserDefaultsKey)
}
return token
} catch {
let logMessage = "Failed to exchange debug token to app check token: \(error)"
AppCheckCoreLogger.log(
code: .debugProviderFailedExchange,
logLevel: .debug,
message: logMessage
)
let nsError = error as NSError
let isServerReachable = nsError.domain != AppCheckCoreErrorDomain ||
nsError.code != AppCheckCoreErrorCode.serverUnreachable.rawValue
// As on success, only the local token's registration is tracked: a rejected environment
// variable token says nothing about whether the local token is registered.
if usesLocalDebugToken, isServerReachable {
GULUserDefaults.standard().removeObject(forKey: registeredUserDefaultsKey)
}
throw error
}
}
private func getToken(limitedUse: Bool,
completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) {
Task {
do {
let token = try await getToken(limitedUse: limitedUse)
AppCheckCore.deliverOnMainQueue(token, error: nil as Error?, to: handler)
} catch {
AppCheckCore.deliverOnMainQueue(nil, error: error, to: handler)
}
}
}
private static func localDebugToken() -> String {
if let token = GULUserDefaults.standard().string(forKey: debugTokenUserDefaultsKey) {
return token
} else {
let token = UUID().uuidString
GULUserDefaults.standard().setObject(token, forKey: debugTokenUserDefaultsKey)
return token
}
}
/// Internal scope exists for testing purposes.
static func registeredUserDefaultsKey(forServiceName serviceName: String,
resourceName: String) -> String {
let safeServiceName = serviceName.isEmpty ? "default" : serviceName
var safeResourceName = resourceName.replacingOccurrences(of: "/", with: "_")
if safeResourceName.isEmpty {
safeResourceName = "default"
}
return "\(debugTokenRegisteredUserDefaultsKeyPrefix)_\(safeServiceName)_\(safeResourceName)"
}
private static func environmentVariableDebugToken(environment: [String: String]) -> String? {
let envVariableValue = environment[kDebugTokenEnvKey]?
.isEmpty == false ? environment[kDebugTokenEnvKey] : nil
let firebaseEnvVariableValue = environment[kFirebaseDebugTokenEnvKey]?
.isEmpty == false ? environment[kFirebaseDebugTokenEnvKey] : nil
if let env = envVariableValue, let _ = firebaseEnvVariableValue {
let message =
"The environment variables \(kDebugTokenEnvKey) and \(kFirebaseDebugTokenEnvKey) are both set; using the debug token specified in \(kDebugTokenEnvKey) and ignoring the value of \(kFirebaseDebugTokenEnvKey)."
AppCheckCoreLogger.log(
code: .debugProviderFirebaseEnvironmentVariable,
logLevel: .warning,
message: message
)
return env
} else if let env = envVariableValue {
let message =
"Using the debug token specified in the environment variable \(kDebugTokenEnvKey)."
AppCheckCoreLogger.log(
code: .environmentVariableDebugToken,
logLevel: .debug,
message: message
)
return env
} else if let firebaseEnv = firebaseEnvVariableValue {
let message =
"Using the debug token specified in the environment variable \(kFirebaseDebugTokenEnvKey)."
AppCheckCoreLogger.log(
code: .debugProviderFirebaseEnvironmentVariable,
logLevel: .debug,
message: message
)
return firebaseEnv
} else {
return nil
}
}
/// Logs the locally stored debug token unless it has previously been exchanged successfully.
///
/// As a side effect, generates and persists the local token if none is stored yet.
private static func logLocalDebugTokenIfNotRegistered(registeredUserDefaultsKey: String) {
let isRegistered = GULUserDefaults.standard().bool(forKey: registeredUserDefaultsKey)
if !isRegistered {
let message = "App Check debug token: '\(Self.localDebugToken())'."
AppCheckCoreLogger.log(code: .localDebugToken, logLevel: .warning, message: message)
}
}
}