firebase / firebase-android-sdk

Firebase Android SDK
https://firebase.google.com
Apache License 2.0
2.27k stars 577 forks source link

Cannot use callable function to exchange integrity verdicts for an App Check Token #5402

Open nohe427 opened 1 year ago

nohe427 commented 1 year ago

[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:

[REQUIRED] Step 2: Describe your environment

[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.

nohe427 commented 1 year ago

We should likely document that callable functions should not be used when exchanging attestation tokens for app check tokens or we should add in some checks that getToken can do to firebase callable functions to make sure that folks do not accidentally do this.

rajatbeck commented 1 year ago

@nohe427 in sendToServerTask are you using what URL you are sending your token too is it your own server or google services like exchangePlayIntegrityToken? What does private const val URL = "MY-FUNCTION-URL" MY-FUNCTION-URL points too?

nohe427 commented 1 year ago

@rajatbeck - Its my own Google Cloud Function that I created.