Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.google.gms.googleservices

import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.DynamicFeatureAndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
Expand Down Expand Up @@ -86,6 +87,15 @@ class GoogleServicesPlugin : Plugin<Project> {
}

private fun handleVariant(variant: Variant, packageName: Provider<String>, project: Project) {
val buildType = variant.buildType.orEmpty()
val isDebug =
project.extensions
.findByType(ApplicationExtension::class.java)
?.buildTypes
?.findByName(buildType)
?.isDebuggable
?: (buildType.equals("debug", ignoreCase = true) ||
variant.name.endsWith("Debug", ignoreCase = true))
val jsonToXmlTask =
project.tasks.register(
"process${variant.name.capitalize()}GoogleServices", GoogleServicesTask::class.java) {
Expand All @@ -95,11 +105,19 @@ class GoogleServicesPlugin : Plugin<Project> {
.missingGoogleServicesStrategy)
it.googleServicesJsonFiles.set(
getJsonFiles(
variant.buildType.orEmpty(),
buildType,
variant.productFlavors.map { it.second },
project.projectDir))
it.applicationId.set(packageName)
it.gmpAppId.set(project.layout.buildDirectory.file("gmpAppId/${variant.name}.txt"))
it.debugBuild.set(isDebug)
if (isDebug) {
it.appCheckConfigFiles.set(getAppCheckConfigFiles(project))
}
it.outputs.doNotCacheIf("Firebase App Check debug token is injected") { task ->
(task as GoogleServicesTask).debugBuild.getOrElse(false) &&
task.appCheckConfigFiles.getOrElse(emptyList()).any { f -> f.isFile }
}
}

// TODO: add an AGP version check to this block
Expand Down Expand Up @@ -129,6 +147,34 @@ class GoogleServicesPlugin : Plugin<Project> {
const val MINIMUM_VERSION = "9.0.0"
const val SOURCE_TYPE = "google-services"
const val JSON_FILE_NAME = "google-services.json"
const val APPCHECK_CONFIG_FILE_PROPERTY = "firebase.appcheck.configFile"
const val APPCHECK_CONFIG_FILE_NAME = "firebase-appcheck.json"

fun getAppCheckConfigFiles(project: Project): List<File> {
val overridePath =
project.providers.gradleProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull
?: project.providers.systemProperty(APPCHECK_CONFIG_FILE_PROPERTY).orNull
if (!overridePath.isNullOrBlank()) {
return listOf(project.file(overridePath))
}
val files = mutableListOf<File>()
// Per go/firebase-api-sdk-cli-seamless-debug-tokens, the Firebase CLI always writes
// $HOME/.config/configstore/firebase-appcheck.json as the canonical primary path and
// mirrors to $XDG_CONFIG_HOME/configstore/firebase-appcheck.json when XDG_CONFIG_HOME
// is set, so GUI IDEs/Gradle daemons and interactive shells never diverge.
val userHome = project.providers.systemProperty("user.home").orNull
if (!userHome.isNullOrBlank()) {
files.add(File(userHome, ".config/configstore/$APPCHECK_CONFIG_FILE_NAME"))
}
val xdgConfigHome = project.providers.environmentVariable("XDG_CONFIG_HOME").orNull
if (!xdgConfigHome.isNullOrBlank()) {
val xdgFile = File(xdgConfigHome, "configstore/$APPCHECK_CONFIG_FILE_NAME")
if (!files.contains(xdgFile)) {
files.add(xdgFile)
}
}
return files
}

fun getJsonFiles(buildType: String, flavorNames: List<String>, root: File): List<File> {
return getJsonLocations(buildType, flavorNames).map { root.resolve(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
Expand Down Expand Up @@ -57,6 +58,13 @@ abstract class GoogleServicesTask : DefaultTask() {

@get:OutputFile abstract val gmpAppId: RegularFileProperty

@get:Optional @get:Input abstract val debugBuild: Property<Boolean>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MEDIUM] Debug tokens are written into the outputs of a @CacheableTask, so they get pushed to any configured remote build cache.

The generated values.xml containing the per-developer secret becomes a build-cache artifact. The cache key includes the config file hash so cross-developer hits are unlikely, but the token is still persisted in shared infrastructure the CLI explicitly tells users not to commit. Consider disabling caching of this task when a token is actually injected, e.g. in the plugin's task registration:

it.outputs.doNotCacheIf("Firebase App Check debug token is injected") { task ->
  (task as GoogleServicesTask).debugBuild.getOrElse(false) &&
      task.appCheckConfigFiles.getOrElse(emptyList()).any { f -> f.isFile }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 653b280 — added outputs.doNotCacheIf when debugBuild is true and a local config file is present.


@get:Optional
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFiles
abstract val appCheckConfigFiles: Property<Collection<File>>

@Throws(GradleException::class)
@TaskAction
fun action() {
Expand Down Expand Up @@ -190,6 +198,7 @@ abstract class GoogleServicesTask : DefaultTask() {

companion object {
const val JSON_FILE_NAME = "google-services.json"
const val APPCHECK_DEBUG_TOKEN_RES_NAME = "firebase_app_check_debug_token"
private const val STATUS_DISABLED = "1"
private const val STATUS_ENABLED = "2"
private const val OAUTH_CLIENT_TYPE_WEB = "3"
Expand Down Expand Up @@ -235,6 +244,45 @@ abstract class GoogleServicesTask : DefaultTask() {
}
resValues["google_app_id"] = googleAppIdStr
gmpAppId.get().asFile.writeText(googleAppIdStr, Charsets.UTF_8)
if (debugBuild.getOrElse(false)) {
val debugToken = resolveAppCheckDebugToken(googleAppIdStr)
if (!debugToken.isNullOrEmpty()) {
resValues[APPCHECK_DEBUG_TOKEN_RES_NAME] = debugToken
}
}
}

private fun resolveAppCheckDebugToken(googleAppId: String): String? {
val configFiles = appCheckConfigFiles.orNull ?: return null
for (configFile in configFiles) {
if (!configFile.isFile) {
continue
}
try {
val root = configFile.bufferedReader(Charsets.UTF_8).use { JsonParser().parse(it) }
if (!root.isJsonObject) {
continue
}
val tokensObj = root.asJsonObject.getAsJsonObject("tokens") ?: continue
val tokenPrimitive = tokensObj.getAsJsonPrimitive(googleAppId) ?: continue
if (tokenPrimitive.isString) {
val token = tokenPrimitive.asString.trim()
if (
token.isNotEmpty() &&
token.none { it in "<>&\"'" } &&
!token.startsWith("@") &&
!token.startsWith("?")
) {
logger.info(
"Injecting Firebase App Check debug token for app $googleAppId from ${configFile.path}")
return token
}
}
} catch (e: Exception) {
logger.warn("Failed to parse App Check config file at ${configFile.path}: ${e.message}")
}
}
return null
}

fun FirebaseClientData.handleWebClientId(resValues: MutableMap<String, String?>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.gms.googleservices

import java.io.File
import org.gradle.testfixtures.ProjectBuilder
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import org.junit.Assert
Expand Down Expand Up @@ -270,6 +271,123 @@ class GoogleServicesPluginTest {
"src/flavor/testTestRelease/google-services.json")))
}

@Test
fun `appCheck debug token injected in debug build and omitted in release build`() {
val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
val googleServicesJson = File("src/test/testData/project1/app/google-services.json")
val appCheckConfigFile = tempFolder.newFile("firebase-appcheck.json")
val expectedToken = "9539cd91-5aa4-4505-b35c-0ee48ea23b04"
appCheckConfigFile.writeText(
"""
{
"_comment": "Warning: Do not commit this file to source control.",
"tokens": {
"1:123456789000:android:f1bf012572b04063": "$expectedToken"
}
}
""".trimIndent())

val debugOutputDir = tempFolder.newFolder("debugRes")
val debugGmpAppId = File(tempFolder.root, "debugGmpAppId.txt")
val debugTask =
project.tasks.register("processDebugGoogleServicesTest", GoogleServicesTask::class.java) {
it.googleServicesJsonFiles.set(listOf(googleServicesJson))
it.applicationId.set("com.google.samples.quickstart.admobexample")
it.missingGoogleServicesStrategy.set(
GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR)
it.outputDirectory.set(debugOutputDir)
it.gmpAppId.set(debugGmpAppId)
it.debugBuild.set(true)
it.appCheckConfigFiles.set(listOf(appCheckConfigFile))
}
debugTask.get().action()

val debugValuesXml = File(debugOutputDir, "values/values.xml").readText()
Assert.assertTrue(
debugValuesXml.contains(
"""<string name="firebase_app_check_debug_token" translatable="false">$expectedToken</string>"""))

val releaseOutputDir = tempFolder.newFolder("releaseRes")
val releaseGmpAppId = File(tempFolder.root, "releaseGmpAppId.txt")
val releaseTask =
project.tasks.register(
"processReleaseGoogleServicesTest", GoogleServicesTask::class.java) {
it.googleServicesJsonFiles.set(listOf(googleServicesJson))
it.applicationId.set("com.google.samples.quickstart.admobexample")
it.missingGoogleServicesStrategy.set(
GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR)
it.outputDirectory.set(releaseOutputDir)
it.gmpAppId.set(releaseGmpAppId)
it.debugBuild.set(false)
it.appCheckConfigFiles.set(listOf(appCheckConfigFile))
}
releaseTask.get().action()

val releaseValuesXml = File(releaseOutputDir, "values/values.xml").readText()
Assert.assertFalse(releaseValuesXml.contains("firebase_app_check_debug_token"))
}

@Test
fun `malformed appCheck config file is ignored gracefully`() {
val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
val googleServicesJson = File("src/test/testData/project1/app/google-services.json")
val malformedConfigFile = tempFolder.newFile("malformed-firebase-appcheck.json")
malformedConfigFile.writeText("{ invalid json")

val outputDir = tempFolder.newFolder("malformedRes")
val gmpAppId = File(tempFolder.root, "malformedGmpAppId.txt")
val task =
project.tasks.register(
"processMalformedGoogleServicesTest", GoogleServicesTask::class.java) {
it.googleServicesJsonFiles.set(listOf(googleServicesJson))
it.applicationId.set("com.google.samples.quickstart.admobexample")
it.missingGoogleServicesStrategy.set(
GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR)
it.outputDirectory.set(outputDir)
it.gmpAppId.set(gmpAppId)
it.debugBuild.set(true)
it.appCheckConfigFiles.set(listOf(malformedConfigFile))
}
task.get().action()

val valuesXml = File(outputDir, "values/values.xml").readText()
Assert.assertTrue(valuesXml.contains("""<string name="google_app_id""""))
Assert.assertFalse(valuesXml.contains("firebase_app_check_debug_token"))
}

@Test
fun `appCheck token sanitization rejects AAPT special characters and resource prefixes`() {
val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
val googleServicesJson = File("src/test/testData/project1/app/google-services.json")
val invalidTokens = listOf("@string/foo", "?attr/bar", "bad'quote", "bad<tag>")

for ((idx, invalidToken) in invalidTokens.withIndex()) {
val configFile = tempFolder.newFile("invalid-appcheck-$idx.json")
configFile.writeText(
"""{"tokens":{"1:123456789000:android:f1bf012572b04063":"$invalidToken"}}""")
val outputDir = tempFolder.newFolder("invalidRes-$idx")
val gmpAppId = File(tempFolder.root, "invalidGmpAppId-$idx.txt")
val task =
project.tasks.register(
"processInvalidToken${idx}GoogleServicesTest", GoogleServicesTask::class.java) {
it.googleServicesJsonFiles.set(listOf(googleServicesJson))
it.applicationId.set("com.google.samples.quickstart.admobexample")
it.missingGoogleServicesStrategy.set(
GoogleServicesPlugin.MissingGoogleServicesStrategy.ERROR)
it.outputDirectory.set(outputDir)
it.gmpAppId.set(gmpAppId)
it.debugBuild.set(true)
it.appCheckConfigFiles.set(listOf(configFile))
}
task.get().action()

val valuesXml = File(outputDir, "values/values.xml").readText()
Assert.assertFalse(
"Expected token '$invalidToken' to be rejected",
valuesXml.contains("firebase_app_check_debug_token"))
}
}

// /* Uncomment, edit project paths and run this test to regenerate the
// XML files that will be used for comparing output. */
// @Test
Expand Down
Loading