Open
Description
[READ] Step 1: Are you in the right place?
Issues filed here should be about bugs in the code in this repository.
If you have a general question, need help debugging, or fall into some
other category use one of these other channels:
- For general technical questions, post a question on StackOverflow
with the firebase tag. - For general Firebase discussion, use the firebase-talk
google group. - For help troubleshooting your application that does not fall under one
of the above categories, reach out to the personalized
Firebase support channel.
[REQUIRED] Step 2: Describe your environment
- Android Studio version:
- Firebase Component: Functions, App Check
- Component version:
[REQUIRED] Step 3: Describe the problem
Steps to reproduce:
What happened? How can we make the problem occur?
When using callable functions to exchange integrity verdicts for App Check tokens, the getToken() call gets called a bunch until the underlying attestation provider quota is exhausted. When the quota is exhausted, the first request does return a valid App Check token, but we quickly exhaust the App Check quota to exchange tokens as well.
Relevant Code:
package dev.nohe.noheplayintegrity
import android.util.Log
import com.google.android.gms.tasks.Continuation
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.TaskCompletionSource
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
import com.google.firebase.FirebaseApp
import com.google.firebase.appcheck.AppCheckProvider
import com.google.firebase.appcheck.AppCheckProviderFactory
import com.google.firebase.appcheck.AppCheckToken
import com.google.firebase.functions.ktx.functions
import com.google.firebase.ktx.Firebase
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.IOException
import org.json.JSONObject
import java.util.Calendar
class PlayIntegrityCustomProviderToken(
private val token: String,
private val expiration: Long
) : AppCheckToken() {
override fun getToken(): String = token
override fun getExpireTimeMillis(): Long = expiration
}
class PlayIntegrityCustomProviderFactory(
private val cloudProjectNumber: Long): AppCheckProviderFactory {
override fun create(firebaseApp: FirebaseApp): AppCheckProvider {
// Create and return an AppCheckProvider object.
return PlayIntegrityCustomProvider(firebaseApp, cloudProjectNumber)
}
}
class PlayIntegrityCustomProvider(
private val firebaseApp: FirebaseApp, private val cloudProjectNumber: Long): AppCheckProvider {
private lateinit var integrityTokenProvider: StandardIntegrityTokenProvider;
init {
Log.d("NOHE", firebaseApp.name)
IntegrityManagerFactory.createStandard(firebaseApp.applicationContext).apply {
prepareIntegrityToken(
PrepareIntegrityTokenRequest.builder()
.setCloudProjectNumber(cloudProjectNumber)
.build()
)
.addOnSuccessListener { tokenProvider -> integrityTokenProvider = tokenProvider }
.addOnFailureListener { exception ->
Log.e("Integrity", exception.message.toString())
}
}
}
override fun getToken(): Task<AppCheckToken> {
var integrityTokenResponse = integrityTokenProvider.request(
StandardIntegrityTokenRequest.builder().build()
)
return integrityTokenResponse
.continueWithTask(
SendToServerTask(firebaseApp)
// SendToServerTaskCallable(firebaseApp)
)
}
}
// Don't do this. Using a Firebase callable function will recursively call getToken because
// Firebase functions try to grab the full application context (including app check tokens) before
// making a request to the callable function. Use HTTPS onRequest instead.
class SendToServerTaskCallable(
// HERE FOR DEMO PURPOSES ONLY. NEVER USE THIS
private val firebaseApp: FirebaseApp) :
Continuation<StandardIntegrityToken, Task<AppCheckToken>> {
companion object {
private const val TOKEN = "token"
private const val APP_ID = "appid"
private const val TTL_MILLIS = "ttlMillis"
private const val REQUEST_CONTENT = "application/json; charset=utf-8"
private const val URL =
"https://MY-FUNCTION-URL"
}
override fun then(sit: Task<StandardIntegrityToken>): Task<AppCheckToken> {
Log.d("NOHETOKEN", sit.result.token())
Log.e("TOKENREQUEST", "Requesting...")
return sendToServer(sit.result.token())
}
private fun sendToServer(token: String?): Task<AppCheckToken> {
val taskCompletionSource = TaskCompletionSource<AppCheckToken>()
Firebase
.functions(firebaseApp)
.getHttpsCallable("playtokenexchangecall")
.call(
hashMapOf(
TOKEN to token,
APP_ID to firebaseApp.options.applicationId,
)
).addOnSuccessListener {
result ->
val resultStr = result.data as HashMap<*, *>
var customToken = PlayIntegrityCustomProviderToken(
resultStr.get(TOKEN) as String,
(resultStr.get(TTL_MILLIS) as Int)
+ Calendar.getInstance().timeInMillis)
taskCompletionSource.setResult(customToken)
}
return taskCompletionSource.task
}
}
class SendToServerTask(
private val firebaseApp: FirebaseApp) :
Continuation<StandardIntegrityToken, Task<AppCheckToken>> {
private val client = OkHttpClient()
companion object {
private const val TOKEN = "token"
private const val APP_ID = "appid"
private const val TTL_MILLIS = "ttlMillis"
private const val REQUEST_CONTENT = "application/json; charset=utf-8"
private const val URL =
"MY-FUNCTION-URL"
}
override fun then(sit: Task<StandardIntegrityToken>): Task<AppCheckToken> {
Log.d("NOHETOKEN", sit.result.token())
return sendToServer(sit.result.token())
}
private fun sendToServer(token: String?): Task<AppCheckToken> {
Log.wtf("NOHETOKEN", token)
val jsonObject = JSONObject().put(TOKEN, token).toString()
val requestBody = jsonObject.toRequestBody(REQUEST_CONTENT.toMediaType())
val request = Request.Builder()
.url(URL).addHeader(APP_ID, firebaseApp.options.applicationId)
.post(requestBody).build()
val taskCompletionSource = TaskCompletionSource<AppCheckToken>()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
onSuccess(response, taskCompletionSource)
}
override fun onFailure(call: Call, e: IOException) {
println("API execute failed")
taskCompletionSource.setResult(
PlayIntegrityCustomProviderToken("", 9999999999999))
}
})
return taskCompletionSource.task
}
private fun onSuccess(
response: Response,taskCompletionSource: TaskCompletionSource<AppCheckToken>) {
val responseString = response.body!!.string()
Log.d("NOHE", responseString)
var responseToken = JSONObject(responseString)
response.close()
var customToken = PlayIntegrityCustomProviderToken(
responseToken.get(TOKEN) as String,
(responseToken.get(TTL_MILLIS) as Int)
+ Calendar.getInstance().timeInMillis)
taskCompletionSource.setResult(customToken)
}
}
Server Code
/**
* Import function triggers from their respective submodules:
*
* import {onCall} from "firebase-functions/v2/https";
* import {onDocumentWritten} from "firebase-functions/v2/firestore";
*
* See a full list of supported triggers at https://firebase.google.com/docs/functions
*/
import {
playintegrity,
playintegrity_v1 as playintegrityv1,
} from "@googleapis/playintegrity";
import {google} from "googleapis";
import {onCall, onRequest} from "firebase-functions/v2/https";
import {getAppCheck} from "firebase-admin/app-check";
import {initializeApp} from "firebase-admin/app";
import {AppRecognitionVerdict, DeviceIntegrity} from "./values";
import * as logger from "firebase-functions/logger";
// Start writing functions
// https://firebase.google.com/docs/functions/typescript
const app = initializeApp();
export const playtokenexchangecall = onCall(async (request) => {
logger.log(request.data);
// const body = JSON.parse(request.body);
const token = request.data["token"];
const appid = request.data["appid"] || "";
logger.log("Token is %s\nappid is %s", token, appid);
const verdict = await parsePlayIntToken(token);
if (!validateVerdict(verdict)) {
// response.send({token: "", ttlMillis: 99999999});
return {token: "", ttlMillis: 99999999};
}
const appCheckToken = await getAppCheck(app).createToken(appid, {
ttlMillis: 1_800_000, // 30 minute token
});
// response.send(appCheckToken);
return appCheckToken;
});
export const playtokenexchange = onRequest(async (request, response) => {
logger.log(request.body);
// const body = JSON.parse(request.body);
const token = request.body["token"];
const appid = request.header("appid") || "";
logger.log("Token is %s\nappid is %s", token, appid);
const verdict = await parsePlayIntToken(token);
if (!validateVerdict(verdict)) {
response.send({token: "", ttlMillis: 99999999});
return;
}
const appCheckToken = await getAppCheck(app).createToken(appid, {
ttlMillis: 604_800_000, // 7 day token
});
response.send(appCheckToken);
});
/**
* parsePlayIntToken - This parses the play integrity token and returns a
* response
* @param {string} token - The token to be parsed
* @return {void}
*/
async function parsePlayIntToken(token:string)
: Promise<playintegrityv1.Schema$TokenPayloadExternal | undefined> {
const auth = new google.auth.GoogleAuth({
// Scopes can be specified either as an array or as a single,
// space-delimited string.
scopes: ["https://www.googleapis.com/auth/playintegrity"],
});
// const authClient = await auth.getClient();
const response = await playintegrity({version: "v1", auth: auth})
.v1
.decodeIntegrityToken(
{
packageName: "dev.nohe.noheplayintegrity",
requestBody: {integrityToken: token},
}
);
logger.log("Token value %s", response.data.tokenPayloadExternal);
const tokenPayload = response.data.tokenPayloadExternal;
logger.log(
"Token Info:\n Device Recognition Verdict : %s\n" +
" App License Verdict : %s\n Signing Cert : %s\n" +
" Version Code : %s\n App Recognized by Play : %s\n",
tokenPayload?.deviceIntegrity?.deviceRecognitionVerdict,
tokenPayload?.accountDetails?.appLicensingVerdict,
tokenPayload?.appIntegrity?.certificateSha256Digest,
tokenPayload?.appIntegrity?.versionCode,
tokenPayload?.appIntegrity?.appRecognitionVerdict);
return tokenPayload;
}
/**
* validateVerdict - Test for which verdicts you think are acceptable for your
* app.
* @param {playintegrityv1.Schema$TokenPayloadExternal | undefined} verdict
* @return {boolean}
*/
function validateVerdict(
verdict: playintegrityv1.Schema$TokenPayloadExternal | undefined)
: boolean {
if (
verdict?.appIntegrity?.appRecognitionVerdict ==
AppRecognitionVerdict.UNEVALUATED) {
return false;
}
if (
(verdict?.deviceIntegrity?.deviceRecognitionVerdict as string[])
.includes(DeviceIntegrity.MEETS_VIRTUAL_INTEGRITY)) {
return false;
}
return true;
}
I can also demo for you if you are interested.