firebase-android-sdk
firebase-android-sdk copied to clipboard
Custom provider getToken() creates infinite loop when device time is tempered
[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?
Hey @rizafran, did you manage to replicate it? it should be quite straight forward
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 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?
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();
}
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:
- JSON object containing the token & expire time in milliseconds
- 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
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 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
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?
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!
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!
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";
});
});
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.
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.
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
Hey @argzdev, any news regarding this issue?
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.
Hey @argzdev, any news regarding the issue?
@argzdev haven't heard back in a while, any news?
@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?
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 Do you disable your auto token refresh? (firebaseAppCheck.setTokenAutoRefreshEnabled(false);
)
No, I didn't disable auto token refresh. Does using the latest version 31.4.0
have any effect?
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)
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.
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.
Hey @argzdev, I can see that version 17 of app check was released, was this issue addressed in that version?
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 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 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, @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.