Files
firebase-ios-sdk/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.m
T
Andrew Heard c06a1d7199 feat(appcheck): read CLI debug token on Simulator (#17017)
Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>

In a Simulator, AppCheckDebugProvider now uses the debug token that
`firebase appcheck:debugtokens:create` saves to
~/.config/configstore/firebase-appcheck.json on the host, so apps
launched from Xcode work without manually registering a token. The
token is looked up by each app's Google App ID through
SIMULATOR_HOST_HOME. The lookup is compiled out on devices, macOS and
Mac Catalyst. No public API changes.

Debug tokens are now resolved in this order:
1. AppCheckDebugToken or FIRAAppCheckDebugToken environment variable
2. Token from the CLI config store (Simulator only)
3. Token stored in UserDefaults
4. Newly generated UUID

The config store comes before the stored token so a newly provisioned
token takes effect without reinstalling the app.

Logging:
- I-FAA004003: debug token loaded from the config store.
- I-FAA004004: exchanging the config store token failed; suggests
  re-running the CLI with --force and relaunching.
- I-FAA004002: exchanging an environment variable token failed; now
  names the variables instead of showing an unrelated local token.
- I-FAA004005: the config store exists but can't be read or parsed.
A missing file, or a path where it can't exist (e.g., ~/.config is a
regular file), is silent. Exchange-failure warnings are skipped when
the server is unreachable, and no message includes the token.
2026-11-06 19:23:51 -05:00

125 lines
4.6 KiB
Objective-C

/*
* 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 "FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugTokenConfigStore.h"
#import <TargetConditionals.h>
#import <errno.h>
#import "FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h"
#import "FirebaseCore/Extension/FirebaseCoreInternal.h"
NS_ASSUME_NONNULL_BEGIN
#if TARGET_OS_SIMULATOR
/// Environment variable set by the Simulator to the host user's home directory.
static NSString *const kSimulatorHostHomeEnvKey = @"SIMULATOR_HOST_HOME";
/// Top-level key of the config store file mapping Google App IDs to debug tokens.
static NSString *const kTokensKey = @"tokens";
#endif // TARGET_OS_SIMULATOR
@implementation FIRAppCheckDebugTokenConfigStore
+ (NSString *)relativePath {
return @".config/configstore/firebase-appcheck.json";
}
#if TARGET_OS_SIMULATOR
/// Returns whether `error`, from reading the config store file, means that the file does not exist.
///
/// Besides a missing file, this includes a path component that is a regular file rather than a
/// directory (e.g., `~/.config`), since the config store cannot exist at that path either.
+ (BOOL)isMissingConfigStoreError:(nullable NSError *)error {
if ([error.domain isEqualToString:NSCocoaErrorDomain] &&
error.code == NSFileReadNoSuchFileError) {
return YES;
}
NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey];
if (![underlyingError isKindOfClass:[NSError class]]) {
return NO;
}
return [underlyingError.domain isEqualToString:NSPOSIXErrorDomain] &&
underlyingError.code == ENOTDIR;
}
#endif // TARGET_OS_SIMULATOR
+ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID {
return [self debugTokenForGoogleAppID:googleAppID
environment:[NSProcessInfo processInfo].environment];
}
+ (nullable NSString *)debugTokenForGoogleAppID:(NSString *)googleAppID
environment:
(NSDictionary<NSString *, NSString *> *)environment {
#if TARGET_OS_SIMULATOR
NSString *hostHome = environment[kSimulatorHostHomeEnvKey];
if (hostHome.length == 0 || googleAppID.length == 0) {
return nil;
}
NSURL *fileURL = [[NSURL fileURLWithPath:hostHome
isDirectory:YES] URLByAppendingPathComponent:self.relativePath
isDirectory:NO];
NSError *readError;
NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&readError];
if (!data) {
// A missing file (or a path where it cannot exist) means the Firebase CLI flow is not in use.
if (![self isMissingConfigStoreError:readError]) {
FIRLogWarning(kFIRLoggerAppCheck,
kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable,
@"Failed to read App Check debug tokens from ~/%@: %@", self.relativePath,
readError.localizedDescription);
}
return nil;
}
NSError *parseError;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError];
if (![json isKindOfClass:[NSDictionary class]]) {
FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable,
@"Failed to parse App Check debug tokens from ~/%@: %@", self.relativePath,
parseError.localizedDescription ?: @"expected a JSON object");
return nil;
}
NSDictionary *tokens = json[kTokensKey];
if (!tokens) {
// A config store without `tokens` (e.g., `{}` after the last token was removed) is valid.
return nil;
}
if (![tokens isKindOfClass:[NSDictionary class]]) {
FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenConfigStoreUnreadable,
@"Failed to parse App Check debug tokens from ~/%@: `%@` is not a JSON object",
self.relativePath, kTokensKey);
return nil;
}
NSString *token = tokens[googleAppID];
if (![token isKindOfClass:[NSString class]] || token.length == 0) {
return nil;
}
return token;
#else
return nil;
#endif // TARGET_OS_SIMULATOR
}
@end
NS_ASSUME_NONNULL_END