firebase-android-sdk icon indicating copy to clipboard operation
firebase-android-sdk copied to clipboard

Custom provider getToken() creates infinite loop when device time is tempered

Open im12345dev opened this issue 2 years ago • 14 comments

[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: Chipmunk 2021.2.1
  • Firebase Component: App Check
  • Component version: bom 30.2.0

[REQUIRED] Step 3: Describe the problem

I have implemented a custom provider using Firebase Functions and the firebase-admin sdk which return a token and expiration time using some custom logic, the implementation works perfectly when the time on the device is correct and not tempered with, but as soon as the time on the device is being tempered, for example a user set the clock to +2 hours using the device settings, Android App Check SDK gets into infinite loop as the token expiration time from the server is current time + 60 minutes but the device time is current time + 2 hours (due to settings change), which leads to the SDK to think the token is expired and start an infinite refresh loop

Steps to reproduce:

Configuring a token TTL to 60 minutes and changing the device clock to +1 hour should reproduce it

Relevant Code:

`public class YourCustomAppCheckProvider implements AppCheckProvider {

private final Context applicationContext;

public YourCustomAppCheckProvider(Context applicationContext) {
    this.applicationContext = applicationContext;
}

@Override
public Task<AppCheckToken> getToken() {

    TaskCompletionSource<AppCheckToken> taskCompletionSource = new TaskCompletionSource<>();

    try {
        String token = customLogicToGetToken()
        int expirationFromServer = expiration received from the server endpoint...

        long expMillis = expirationFromServer * 1000L - 60000;

        taskCompletionSource.setResult(new CustomAppCheckToken(token,expMillis));
    } catch (IOException | JSONException e) {
        taskCompletionSource.setException(e);
    }

    return taskCompletionSource.getTask();
}

} `

The Firebase function to generate the token + expiration time:

`

exports.fetchAppCheckToken = functions.https.onCall((authenticityData, context) => {

//Custom logic to decide if the request is valid or not, in case not return "error"...


    return admin.appCheck().createToken(APP ID)
        .then(function (appCheckToken) {
            const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60;

            return {token: appCheckToken.token, expiresAt: expiresAt, ttlMillis: appCheckToken.ttlMillis}
        })
        .catch(function (err) {
            console.error("Unable to create App Check token.");
            console.error(err);

            return "error"
        });
});

Now of course I can just check if the "expiresAt" received from the function is smaller than System.currentTimeInMills() and then return exception in the getToken() method, but that means any user who changed his device time in settings wont be able to user my app. Is it a bug or just something I am doing wrong?

im12345dev avatar Jul 23 '22 21:07 im12345dev

Hey @rizafran, did you manage to replicate it? it should be quite straight forward

im12345dev avatar Aug 02 '22 16:08 im12345dev

Hi @im12345dev, thanks for reporting. Sorry for the delayed response here. I tried testing this scenario using SafetyNet and it seems to work fine. I'm guessing this use case should also work when using a custom provider since the behavior for this scenario should be the same. Let me notify and consult our engineering team and see what we can do here. Thanks!

argzdev avatar Aug 03 '22 10:08 argzdev

@argzdev I can replicate the issue on multiple devices using custom provider, the issue is coming from using System.currentTimeInMills() in the expire check, when you change the device time to +1 hour the System.currentTimeInMills() will change as well to +1 hour, causing the token to be expire immediately as the expire time is time in mills received from the server rather the device.

Also, isn't SafetyNet based on Play Services?

im12345dev avatar Aug 08 '22 03:08 im12345dev

Hi @im12345dev, yes, SafetyNet is based on Play Services. Sorry for the misunderstanding, what I'm trying to say is that if this type of use case works for SafetyNet, then I think we should also be supporting this in the custom provider setup as well.

I've raised this up to our engineering team, however while waiting for a response, could you provide more details on how you retrieve your getToken, specifically on the customLogicToGetToken() and expirationFromServer? I'm assuming this is when you call Firebase Functions. Can you share a bit more code regarding this? But of course, feel free to redact any PII or details you don't want to share. Other than that, this'll help us a lot.

Also if you could share a MCVE, that'll speed up our investigation even faster. Thanks!

Relevant code:

@Override
public Task<AppCheckToken> getToken() {

    TaskCompletionSource<AppCheckToken> taskCompletionSource = new TaskCompletionSource<>();

    try {
        String token = customLogicToGetToken()
        int expirationFromServer = expiration received from the server endpoint...

        long expMillis = expirationFromServer * 1000L - 60000;

        taskCompletionSource.setResult(new CustomAppCheckToken(token,expMillis));
    } catch (IOException | JSONException e) {
        taskCompletionSource.setException(e);
    }

    return taskCompletionSource.getTask();
}

argzdev avatar Aug 09 '22 09:08 argzdev

Below is the getToken() code that I use to retrieve the token from the Firebase Function:

    @Override
    public Task<AppCheckToken> getToken() {

        Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("https://{project-id}.cloudfunctions.net")
        .addConverterFactory(GsonConverterFactory.create())
        .build();
        MainActivity.APIService retroService = retrofit.create(MainActivity.APIService.class);

        Call<ResponseBody> call = retroService.appToken();
        TaskCompletionSource<AppCheckToken> taskCompletionSource = new TaskCompletionSource<>();

        try {
            JSONObject jsonObject = new JSONObject(call.execute().body().string());
            String token = jsonObject.getJSONObject("result").getString("token");
            int expirationFromServer = jsonObject.getJSONObject("result").getInt("expiresAt");
            long expMillis = expirationFromServer * 1000L - 60000;

            taskCompletionSource.setResult(new CustomAppCheckToken(token,expMillis));
        } catch (IOException | JSONException e) {
            taskCompletionSource.setException(e);
        }

        return taskCompletionSource.getTask();
    }

This is the Firebase Functions I use to generate the token and send the information back to the client:

exports.fetchAppCheckToken = functions.https.onCall((authenticityData, context) => {
    console.log('authenticityData: '+JSON.stringify(authenticityData))

    return admin.appCheck().createToken('{app-id}')
        .then(function (appCheckToken) {
            // Token expires in an hour.
            const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60;
            console.log('Token: '+JSON.stringify(appCheckToken))
            console.log('expiresAt: '+expiresAt)

            return {token: appCheckToken.token, expiresAt: expiresAt, ttlMillis: appCheckToken.ttlMillis}
        })
        .catch(function (err) {
            console.error('Unable to create App Check token.');
            console.error(err);

            return "error"
        });
});

I am using RetroFit to call the https link of the Firebase Functions and retrieve the data:

  1. JSON object containing the token & expire time in milliseconds
  2. Error incase something went wrong

The expire time the Firebase Functions sends together with the token is: Math.floor(Date.now() / 1000) + 60 * 60 which is exactly the same as the docs

im12345dev avatar Aug 09 '22 16:08 im12345dev

Thanks for the extra details, @im12345dev. I'm running into NetworkOnMainThreadException on your code snippet, so I tweaked it a bit. However, I'm still unable to reproduce the same behavior even after adding 1 or 2 hrs to the physical/emulator device time.

Relevant code:

public Task<AppCheckToken> getToken() {
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://{project-id}.cloudfunctions.net")
                .addConverterFactory(GsonConverterFactory.create())
                .build();
        APIService retroService = retrofit.create(APIService.class);

        Call<ResponseBody> call = null;
        try {
            call = retroService.appToken(new DataRequest(new JSONObject().put("data", null)));
        } catch (JSONException e) {
            e.printStackTrace();
        }

        TaskCompletionSource<AppCheckToken> taskCompletionSource = new TaskCompletionSource<>();

        call.enqueue(new Callback<ResponseBody>() {
            @Override
            public void onResponse(Call<okhttp3.ResponseBody> call, Response<ResponseBody> response) {
                try {
                    JSONObject jsonObject = new JSONObject(response.body().string());
                    String token = jsonObject.getJSONObject("result").getString("token");
                    int expirationFromServer = jsonObject.getJSONObject("result").getInt("expiresAt");
                    long expMillis = expirationFromServer * 1000L - 60000;

                    Log.d(TAG, "onResponse: " + response.body().string());

                    taskCompletionSource.setResult(new YourCustomAppCheckToken(token,expMillis));
                } catch (IOException | JSONException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void onFailure(Call<ResponseBody> call, Throwable t) {
                Log.d(TAG, "onFailure: " + t.getMessage());
            }
        });

        return taskCompletionSource.getTask();
    }

To test this, I called this via a button click:

private void retrieveTokenAuto(){
        firebaseAppCheck.getToken(true).addOnCompleteListener(task -> {
            if(task.isSuccessful()){
                String token = task.getResult().getToken();
                Log.d(TAG, "token received: " + token);
            } else {
                Log.d(TAG, "token retrieval failed: ");
            }
        });
    }

Am I missing anything?

argzdev avatar Aug 10 '22 14:08 argzdev

@argzdev Using the exact same code you posted I was able to replicate it, it goes into infinite loop of renewing the token. Can you post the Firebase Function you used in order to generate the token?

In this video you can see the infinite loop been created, using the same code as yours, I just added 2 hours to the emulator time: https://user-images.githubusercontent.com/91768057/184416004-f22f86ca-3604-4987-abc0-50256f8723f6.mov

Clicking on the Fab button in the video is starting the retriveTokenAuto() you posted

im12345dev avatar Aug 12 '22 17:08 im12345dev

I just noticed that the App Check Provider is checking if the token is expired by the expiration time in mills inside the token itself, so what is the purpose of adding expiration time to your Custom Token object?

im12345dev avatar Aug 12 '22 20:08 im12345dev

I've used the following for my Firebase functions:

exports.fetchAppCheckToken = firebasefunctions.https.onCall((data, context) => {
  const appId = "your_project_app_id";

  return admin.appCheck().createToken(appId)
      .then(function(appCheckToken) {
        const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60;

        const token = {
          token: appCheckToken.token,
          expiresAt: expiresAt,
          ttlMillis: appCheckToken.ttlMillis,
        };

        firebasefunctions.logger.info(token, {structuredData: true});

        return token;
      })
      .catch(function(err) {
        console.error("Unable to create App Check token.");
        console.error(err);
        return "error";
      });
});

I'm not sure why I'm unable to experience the same behavior as yours, maybe something is missing on my setup. Could you provide a minimal reproducible example instead, so I can investigate this further?

Also can you point me to the code snippet in the SDK, where you think this is being done? So I can consult with an engineer regarding your question.

App Check Provider is checking if the token is expired by the expiration time in mills inside the token itself

Thanks in advance!

argzdev avatar Aug 16 '22 10:08 argzdev

Hey @im12345dev. We need more information to resolve this issue but there hasn't been an update in 5 weekdays. I'm marking the issue as stale and if there are no new updates in the next 5 days I will close it automatically.

If you have more information that will help us get to the bottom of this, just add a comment!

google-oss-bot avatar Aug 23 '22 01:08 google-oss-bot

Sorry for the late response @argzdev , here is a minimal reproducible example: MainActivity onCreate where fab2 is the button that retrieve the token

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        FirebaseApp firebaseApp = FirebaseApp.initializeApp(/*context=*/ this);
        FirebaseFirestore db = FirebaseFirestore.getInstance();
        firebaseAppCheck = FirebaseAppCheck.getInstance();
        firebaseAppCheck.installAppCheckProviderFactory(YourCustomAppCheckProviderFactory.getInstance(this));

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());


        NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
        appBarConfiguration = new AppBarConfiguration.Builder(navController.getGraph()).build();
        NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration);

        binding.fab2.setOnClickListener(view -> {
            firebaseAppCheck.getToken(true).addOnCompleteListener(task -> {
                if(task.isSuccessful()){
                    String token = task.getResult().getToken();
                    Log.d(TAG, "token received: " + token);
                } else {
                    Log.d(TAG, "token retrieval failed: ");
                }
            });
        });
    }

My custom provider factory:

public class YourCustomAppCheckProviderFactory implements AppCheckProviderFactory {

    private static YourCustomAppCheckProviderFactory yourCustomAppCheckProviderFactory;
    private final Context applicationContext;

    public YourCustomAppCheckProviderFactory(Context applicationContext) {
        this.applicationContext = applicationContext;
    }

    public static YourCustomAppCheckProviderFactory getInstance(Context applicationContext){
        if (yourCustomAppCheckProviderFactory == null)
            yourCustomAppCheckProviderFactory = new YourCustomAppCheckProviderFactory(applicationContext);
        return yourCustomAppCheckProviderFactory;
    }

    @NonNull
    @Override
    public AppCheckProvider create(@NonNull FirebaseApp firebaseApp) {
        // Create and return an AppCheckProvider object.
        return new MyAppCheckProvider(firebaseApp);
    }
}

The app check provider:

public class YourCustomAppCheckProvider implements AppCheckProvider {

    private final Context applicationContext;

    public YourCustomAppCheckProvider(Context applicationContext) {
        this.applicationContext = applicationContext;
    }

    @Override
    public Task<AppCheckToken> getToken() {

        Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("{BASE_URL}")
        .addConverterFactory(GsonConverterFactory.create())
        .build();
        MainActivity.APIService retroService = retrofit.create(MainActivity.APIService.class);

        Call<ResponseBody> call = retroService.appToken();
        TaskCompletionSource<AppCheckToken> taskCompletionSource = new TaskCompletionSource<>();

        call.enqueue(new Callback<ResponseBody>() {
            @Override
            public void onResponse(Call<okhttp3.ResponseBody> call, Response<ResponseBody> response) {
                try {
                    JSONObject jsonObject = new JSONObject(response.body().string());
                    String token = jsonObject.getJSONObject("result").getString("token");
                    int expirationFromServer = jsonObject.getJSONObject("result").getInt("expiresAt");
                    long expMillis = expirationFromServer * 1000L - 60000;
                    
                    taskCompletionSource.setResult(new CustomAppCheckToken(token,expMillis));
                } catch (IOException | JSONException e) {
                    taskCompletionSource.setException(e);
                }
            }
            @Override
            public void onFailure(Call<ResponseBody> call, Throwable t) {
                Log.d(MainActivity.TAG, "onFailure: " + t.getMessage());
            }
        });

        return taskCompletionSource.getTask();
    }
}

The app check token object:

public class CustomAppCheckToken extends AppCheckToken {
    private final String token;
    private final long expiration;

    CustomAppCheckToken(String token, long expiration) {
        this.token = token;
        this.expiration = expiration;
    }

    @NonNull
    @Override
    public String getToken() {
        return token;
    }

    @Override
    public long getExpireTimeMillis() {
        return expiration;
    }


}

For the firebase functions I used the same code as you used:

exports.fetchAppCheckToken = firebasefunctions.https.onCall((data, context) => {
  const appId = "your_project_app_id";

  return admin.appCheck().createToken(appId)
      .then(function(appCheckToken) {
        const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60;

        const token = {
          token: appCheckToken.token,
          expiresAt: expiresAt,
          ttlMillis: appCheckToken.ttlMillis,
        };

        firebasefunctions.logger.info(token, {structuredData: true});

        return token;
      })
      .catch(function(err) {
        console.error("Unable to create App Check token.");
        console.error(err);
        return "error";
      });
});

im12345dev avatar Aug 26 '22 19:08 im12345dev

Since there haven't been any recent updates here, I am going to close this issue.

@im12345dev if you're still experiencing this problem and want to continue the discussion just leave a comment here and we are happy to re-open this.

google-oss-bot avatar Aug 30 '22 01:08 google-oss-bot

Thanks for the extra details, @im12345dev. I noticed that you're using MyAppCheckProvider instead of the YourCustomAppCheckProvider? Can you share your code from that file?

Since the getToken() goes to an infinite loop, does your Cloud function spams the logs with multiple requests from fetching the token? e.g. fetchAppCheckToken

Upon further investigation, when the time is tempered, the AppCheckListener will keep refreshing itself to retrieve a new token.

firebaseAppCheck.addAppCheckListener(token -> {
            Log.d(TAG, "onAppCheckTokenChanged: " + token.getToken());
            Log.d(TAG, "onAppCheckTokenChanged: " + token.getExpireTimeMillis());
        });

I think this is similar to what you're experiencing. I'll inform our engineers with these extra findings.

argzdev avatar Aug 30 '22 10:08 argzdev

Returning MyAppCheckProvider instead of YourCustomAppCheckProvider was indeed an overlook by my part, unfortunately changing it didn't solve the issue.

As for your questions, yes the Cloud function spam the logs with each token created/refreshed.

I am glad you managed to reproduce it, let me know if you need more information, hopefully it can be patched quickly in the next release as this is the only thing preventing us from migrating to App Check

im12345dev avatar Aug 30 '22 15:08 im12345dev

Hey @argzdev, any news regarding this issue?

im12345dev avatar Oct 21 '22 02:10 im12345dev

HI @im12345dev, sorry for the lack of response here. Our engineers are still in the process of brainstorming on how to deal with this type of use case. There hasn't been any significant progress yet. We'll respond back here once we get an update.

This is internally tracked at: b/241889048

Thank you for your patience.

argzdev avatar Oct 21 '22 09:10 argzdev

Hey @argzdev, any news regarding the issue?

im12345dev avatar Dec 22 '22 18:12 im12345dev

@argzdev haven't heard back in a while, any news?

im12345dev avatar Feb 13 '23 03:02 im12345dev

@rizafran @argzdev @sheepmaster It's been 6 months now and I haven't heard back at all, this type of bug should prevent anyone from using the custom providers as any misconfigured device can trigger millions of unnecessary cloud functions calls, is there any news?

im12345dev avatar Apr 06 '23 16:04 im12345dev

Hi @im12345dev, thanks for following up on us. We had some investigations and discussions regarding this, and it seems like were taking a step back, note that App Check does not support tampering with device time. Doing so signals that the app has been tampered with.

That said, you've mentioned that misconfigured device can trigger millions of unnecessary cloud functions calls. I wasn't able to reproduce this behavior that you've mentioned.

Upon further investigation, when the time is tempered, the following behavior is experienced.

  • Cloud function fetchAppCheckToken is only called once.
  • getToken() is only called once.
  • firebaseAppCheck.addAppCheckListener however, is triggered continuously.

I also noticed that it's been sometime since this issue has been filed, and com.google.firebase:firebase-bom version 30.2.0 was being used. Could you try upgrading to the latest version 31.4.0, and see if you'll experience a different behavior, specifically that the getToken() should only be called once.

If you're still experiencing the same behavior after the update, it might mean we're missing something. Please upload your code into your repository and share it with us so we can investigate this further.

argzdev avatar Apr 06 '23 19:04 argzdev

@argzdev Do you disable your auto token refresh? (firebaseAppCheck.setTokenAutoRefreshEnabled(false);)

im12345dev avatar Apr 06 '23 19:04 im12345dev

No, I didn't disable auto token refresh. Does using the latest version 31.4.0 have any effect?

argzdev avatar Apr 07 '23 09:04 argzdev

31.4.0 does not have any affect, disabling the auto refresh solves the issue (kinda), but I would just want to make sure, if the auto refresh is disabled do I need to manually refresh the token? or the getToken() will be called every time there will be an Firestore operation with invalid token? (resulting in correct refresh)

im12345dev avatar Apr 07 '23 11:04 im12345dev

the auto refresh is disabled do I need to manually refresh the token?

I'm not sure if that is the correct strategy for this. If App Check does will not support tampering devices, then I think we should atleast have a logic strategy handling these types of issues. I'll discuss with our engineers to see what we can do.

Also after further investigating, I was able to reproduce the infinite calling of getToken(). Given with this much evidence, I can probably justify an engineer’s time to dig into this further.

argzdev avatar Apr 07 '23 15:04 argzdev

That's great news, thank you for informing me. In the meanwhile we are performing tests with firebaseAppCheck.setTokenAutoRefreshEnabled(false); with a large set of beta users, although the solution isn't perfect, the App Check library seems to refresh the token when there is a Firestore read/write and the token is invalid, the refresh happens correctly regardless of the time of the device (and happens only once).

We believe that combining the above mentioned with long TTL (24 hours) for the tokens might reduce/eliminate any invalid tokens.

The devices in question are Amazon's devices, as we have a huge set of users using Amazon tablets and as you know the official Play Integrity provider isn't supported on them.

im12345dev avatar Apr 09 '23 06:04 im12345dev

Hey @argzdev, I can see that version 17 of app check was released, was this issue addressed in that version?

im12345dev avatar May 20 '23 17:05 im12345dev

Hi @im12345dev, apologies for the lack of updates here. Upon checking the internal bug indicates that the investigation is still on going. So I believe this issue was not addressed in the latest version. I'll bump up the internal bug again to get some feed back.

argzdev avatar May 22 '23 09:05 argzdev

@argzdev Any updates on this? I'm running into the same issue; Even with firebaseAppCheck.setTokenAutoRefreshEnabled(false), and using a super long TTL from my custom provider, getToken() is getting called non-stop for me

Detached-BebHEAD avatar Jun 06 '23 14:06 Detached-BebHEAD

@Detached-BebHEAD with firebaseAppCheck.setTokenAutoRefreshEnabled(false) the SDK should ask for a new token only once every time there is a use of the Firestore SDK (read or write), resulting in X calls per X actions (read/write), in our case it's tolerate able although we can already see cases of execsive spam to our Firebase Function handling generating the token, so far we can live with it but obviously this isn't ideal (or the right way of doing it) and I am shocked this wasn't considered in the first place as it's one of the firsts test we came up with while implementing App Check in our projects.

im12345dev avatar Jun 10 '23 08:06 im12345dev

@im12345dev, @argzdev, @rizafran

Title: Solving Issues with expireTimeMillis in Firebase's App Check Token

If you are encountering issues when setting expireTimeMillis in Firebase's App Check Token, the problem could be due to a misunderstanding of what the expireTimeMillis value should represent.

expireTimeMillis should be the exact Unix timestamp (in milliseconds) when the token will expire. It's not the "time to live" (TTL) or duration until the token expires, but rather the exact point in future time when the token becomes invalid.

Thus, if you're getting the token's TTL (time to live) from your server and attempting to set expireTimeMillis directly as ttlMillis * 1000, you might run into problems because you're not accounting for the current time.

To fix this, you should calculate expireTimeMillis by adding the current Unix timestamp to the TTL value from your server (converting to milliseconds if needed). Here's a sample calculation:

const now = Date.now();  // Current time in milliseconds
const ttlMillis = ttlFromServer * 1000;  // Convert TTL from server to milliseconds
const expireTimeMillis = now + ttlMillis;  // Future Unix timestamp when the token will expire

By setting expireTimeMillis this way, you define an exact point in future time when the token will become invalid, and it should resolve the issues you're facing.

I hope this helps! Please let me know if you have any other questions.

belcrod5 avatar Jul 10 '23 04:07 belcrod5