Skip to content

feat(appcheck): support seamless local CLI debug token discovery - #16994

Draft
AustinBenoit wants to merge 1 commit into
mainfrom
feat/appcheck-seamless-debug-tokens
Draft

AustinBenoit wants to merge 1 commit into
mainfrom
feat/appcheck-seamless-debug-tokens

Conversation

@AustinBenoit

Copy link
Copy Markdown
Contributor

Discussion

Implements the Apple / iOS SDK portion of SDK/CLI Seamless Debug Tokens (go/firebase-api-sdk-cli-seamless-debug-tokens):

  1. 6-step debug token resolution order in FIRAppCheckDebugProvider:
    • FIREBASE_APPCHECK_DEBUG_TOKEN environment variable
    • AppCheckDebugToken (and legacy FIRAAppCheckDebugToken) environment variable
    • Host machine CLI config (~/.config/configstore/firebase-appcheck.json via SIMULATOR_HOST_HOME on Simulator or NSHomeDirectory() on macOS, with XDG_CONFIG_HOME fallback) keyed by app.options.googleAppID
    • Build-time injected FIRAppCheckInjectedDebugToken class (+tokens[googleAppID] / +token), guarded by a Release build (!DEBUG) security check that logs an error and ignores the token
    • Existing GULUserDefaults (NSUserDefaults) cached token
    • Auto-generated UUID fallback
  2. SPM Build Tool Plugin & Xcode Build Phase Script:
    • Adds FirebaseAppCheckDebugTokenPlugin (BuildToolPlugin & XcodeBuildToolPlugin) and FirebaseAppCheck/Scripts/inject_debug_token.sh to generate AppCheckDebugToken.generated.swift (#if DEBUG, @objc(FIRAppCheckInjectedDebugToken)) for physical device Debug builds.
  3. Logging & Actionable Recovery Hints:
    • Logs debug token source attribution (I-FAA004003: [FirebaseAppCheck] Debug token loaded from <source>.)
    • Logs an actionable CLI recovery hint when the backend rejects a debug token exchange with HTTP 403 (I-FAA004004: [FirebaseAppCheck] Debug token was rejected by server. Run 'firebase appcheck:debugtokens:create --force' to provision a new token.)

Testing

  • Added unit tests in FIRAppCheckDebugProviderTests.m and FIRAppCheckLoggerTests.m covering all 6 resolution steps, precedence rules, malformed/unmatched configstore JSON resilience, dynamic FIRAppCheckInjectedDebugToken lookup, Release build safeguard, and HTTP 403 error detection.
  • Verified with xcodebuild test -scheme FirebaseAppCheckUnit on both macOS (arm64) and iOS Simulator (iPhone 17 Pro).

API Changes

  • No changes to public Objective-C headers or Swift APIs; adds an optional SPM build tool plugin product (FirebaseAppCheckDebugTokenPlugin) in Package.swift.

Resolve App Check debug tokens from FIREBASE_APPCHECK_DEBUG_TOKEN,
the local Firebase CLI configuration (~/.config/configstore/firebase-appcheck.json)
on simulators and macOS, and build-time injection via
FirebaseAppCheckDebugTokenPlugin prior to falling back to NSUserDefaults
or generating a new UUID. Log token source attribution and actionable
CLI recovery hints on HTTP 403 server rejections.
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@paulb777 paulb777 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for putting this together. Reading the token from the CLI config on simulators and macOS is a nice improvement to the debug workflow. I put fixes for the comments below in #17003, which is based on this branch, so it can be merged into it or cherry-picked.

The main issues:

  1. The build plugin doesn't work for clients. It never finds its script, so it always generates an empty stub. Rotated tokens aren't picked up, and the script embeds every token in the CLI config.
  2. CI tokens can leak into logs when a token exchange fails.
  3. The setenv handoff to AppCheckCore isn't thread-safe. #17003 narrows it, but AppCheckCore should accept the token directly.
  4. CI failures. The Catalyst jobs fail because of unguarded config-file tests and leftover GULUserDefaults state. check most likely fails on swiftformat line length.

One thing that isn't visible in the diff: the currentDebugToken priority list in FIRAppCheckDebugProvider.h is now out of date. #17003 updates it.

Comment on lines +27 to +28
let scriptURL = context.package.directoryURL
.appending(path: "FirebaseAppCheck/Scripts/inject_debug_token.sh")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

context.package is the package of the target the plugin is applied to. For clients, that's their own package (and xcodeProject.directoryURL on L106 is their project), so this script is never found outside this repo and the inline fallback below always generates an empty stub.

I confirmed this with a minimal client package: tokens=[] even with a valid CLI config. Copying the script into the client's FirebaseAppCheck/Scripts/ made it work. #17003 embeds the generator in the plugin instead.


var plistURL: URL?
if let sourceTarget = target as? SourceModuleTarget {
for file in sourceTarget.sourceFiles where file.url.lastPathComponent == "GoogleService-Info.plist" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This line and L115 are over swiftformat's 100-column limit, which is likely why the check job fails.

Comment on lines +68 to +69
inputFiles: inputFiles,
outputFiles: [outputURL]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The CLI config file isn't an input, so after a token is rotated (for example, after the 403 hint's --force) the build keeps the old token until a clean build. I reproduced this. #17003 uses a prebuild command that runs on every build and rewrites the file only when it changes. With xcodebuild, a rotated, removed, or newly created token is picked up by the next incremental build.

Comment on lines +45 to +54
if [ -n "${CONFIGURATION:-}" ]; then
case "$CONFIGURATION" in
*Debug* | *debug* | *DEBUG*)
;;
*)
emit_empty_stub
exit 0
;;
esac
fi

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Build tool plugin commands don't get Xcode build settings like CONFIGURATION, so this check never runs under the plugin. Once the script path issue is fixed, a -c release build still generates a file containing the token. The #if DEBUG in the generated file is what keeps the token out of Release binaries; I confirmed the release binary contains no token strings. This check is still valid in Run Script phases.

Comment on lines +132 to +134
for k in sorted(tokens_map.keys()):
lines.append(f' "{swift_escape(k)}": "{swift_escape(tokens_map[k])}",')
lines.append(" ]")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This writes every token in the CLI config into the app, including tokens for other projects' apps. Debug builds do get shared (ad hoc, App Distribution), so it should embed only the token for this target's GOOGLE_APP_ID. #17003 does that and replaces the python3 dependency with plutil.

Comment on lines +240 to +257
#if TARGET_OS_OSX && !TARGET_OS_SIMULATOR
NSString *homeDir = NSHomeDirectory();
if (homeDir.length > 0) {
NSString *macConfigPath = [[homeDir stringByAppendingPathComponent:@".config/configstore"]
stringByAppendingPathComponent:@"firebase-appcheck.json"];
[candidatePaths addObject:macConfigPath];
}
#endif
}

NSString *xdgConfigHome = environment[kXDGConfigHomeEnvKey];
if ([xdgConfigHome isKindOfClass:[NSString class]] && xdgConfigHome.length > 0) {
NSString *xdgConfigPath = [[xdgConfigHome stringByAppendingPathComponent:@"configstore"]
stringByAppendingPathComponent:@"firebase-appcheck.json"];
if (![candidatePaths containsObject:xdgConfigPath]) {
[candidatePaths addObject:xdgConfigPath];
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two issues here:

  1. In sandboxed macOS apps (the default in Xcode's macOS template), NSHomeDirectory() returns the app container, so the CLI config is never found.
  2. configstore writes to $XDG_CONFIG_HOME/configstore whenever XDG_CONFIG_HOME is set, so that path should be checked first.

#17003 checks XDG first and looks up the actual home directory with getpwuid_r. Sandboxed apps still need a read exception for ~/.config/configstore/.

Comment on lines +287 to +295
NSString *moduleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleExecutable"];
if ([moduleName isKindOfClass:[NSString class]] && moduleName.length > 0) {
NSString *sanitizedModule = [moduleName stringByReplacingOccurrencesOfString:@"-"
withString:@"_"];
sanitizedModule = [sanitizedModule stringByReplacingOccurrencesOfString:@" " withString:@"_"];
NSString *qualifiedName =
[NSString stringWithFormat:@"%@.%@", sanitizedModule, kInjectedDebugTokenClassName];
injectedClass = NSClassFromString(qualifiedName);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: the generated class uses @objc(FIRAppCheckInjectedDebugToken), so its runtime name isn't module-qualified and NSClassFromString finds it directly. I confirmed this in the client package test. The module-qualified fallback isn't needed, and CFBundleExecutable isn't always the module name anyway.

Comment on lines +354 to +370
if (error.code == 403) {
return YES;
}

NSString *failureReason =
error.localizedFailureReason ?: error.userInfo[NSLocalizedFailureReasonErrorKey];
if ([failureReason isKindOfClass:[NSString class]] &&
([failureReason containsString:@"status code: 403"] ||
[failureReason containsString:@"403"])) {
return YES;
}

NSString *description = error.localizedDescription;
if ([description isKindOfClass:[NSString class]] &&
([description containsString:@"status code: 403"] || [description containsString:@"403"])) {
return YES;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

GACAppCheckHTTPError's failure reason includes the request URL (with the project and app IDs) and the response body. So containsString:@"403" flags a 500 or 503 as a rejected token for any app whose IDs contain "403". HTTP errors also have code == 0, so the code check never matches them.

I'd check only httpResponse.statusCode, walking NSUnderlyingErrorKey. #17003 does that and adds tests for these cases. A 403 can also mean the App Check API is disabled or the API key is restricted, so the hint should hedge a bit.

Comment on lines +60 to +68
void FIRAppCheckLogDebugTokenSource(NSString *source) {
FIRLogInfo(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenSource,
@"[FirebaseAppCheck] Debug token loaded from %@.", source);
}

void FIRAppCheckLogDebugTokenRejected(void) {
FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDebugTokenRejected,
@"[FirebaseAppCheck] Debug token was rejected by server. Run 'firebase "
@"appcheck:debugtokens:create --force' to provision a new token.");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nits:

  • kFIRLoggerAppCheck is @"[FirebaseAppCheck]", which the logger already prints, so the prefix is duplicated.
  • Info-level messages aren't shown by default. The init no longer prints the token for external sources, so Notice may be more useful for the source line.
  • Adding --project and --app to the 403 hint would make it copy-pasteable. Can you also confirm that --force matches the CLI?

Comment on lines +384 to +385
[[GULUserDefaults standardUserDefaults] setObject:@"stored_user_defaults_token_555"
forKey:@"GACAppCheckDebugToken"];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This value is never removed, so it leaks into later tests. It's the stored_user_defaults_token_555 that shows up in the Catalyst failures of testResolveTokenFromSimulatorHostHomeLocalConfigFile. I'd clear GACAppCheckDebugToken in setUp and tearDown.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants