Skip to content

getAuthToken error handling for 401 & 404 response code #961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 21, 2019
28 changes: 27 additions & 1 deletion firebase-installations/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package com.google.firebase.installations {
}

public enum FirebaseInstallationsException.Status {
enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status AUTHENTICATION_ERROR;
enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status CLIENT_ERROR;
enum_constant public static final com.google.firebase.installations.FirebaseInstallationsException.Status SDK_INTERNAL_ERROR;
}
Expand Down Expand Up @@ -81,7 +82,7 @@ package com.google.firebase.installations.remote {
ctor public FirebaseInstallationServiceClient(@NonNull Context);
method @NonNull public com.google.firebase.installations.remote.InstallationResponse createFirebaseInstallation(@NonNull String, @NonNull String, @NonNull String, @NonNull String);
method @NonNull public void deleteFirebaseInstallation(@NonNull String, @NonNull String, @NonNull String, @NonNull String);
method @NonNull public InstallationTokenResult generateAuthToken(@NonNull String, @NonNull String, @NonNull String, @NonNull String);
method @NonNull public com.google.firebase.installations.remote.TokenResult generateAuthToken(@NonNull String, @NonNull String, @NonNull String, @NonNull String);
}

public abstract class InstallationResponse {
Expand Down Expand Up @@ -110,5 +111,30 @@ package com.google.firebase.installations.remote {
enum_constant public static final com.google.firebase.installations.remote.InstallationResponse.ResponseCode SERVER_ERROR;
}

public abstract class TokenResult {
ctor public TokenResult();
method @NonNull public static com.google.firebase.installations.remote.TokenResult.Builder builder();
method @Nullable public abstract com.google.firebase.installations.remote.TokenResult.ResponseCode getResponseCode();
method @Nullable public abstract String getToken();
method @NonNull public abstract long getTokenExpirationTimestamp();
method public boolean isErrored();
method public boolean isSuccessful();
method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder toBuilder();
}

public abstract static class TokenResult.Builder {
ctor public TokenResult.Builder();
method @NonNull public abstract com.google.firebase.installations.remote.TokenResult build();
method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setResponseCode(@NonNull com.google.firebase.installations.remote.TokenResult.ResponseCode);
method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setToken(@NonNull String);
method @NonNull public abstract com.google.firebase.installations.remote.TokenResult.Builder setTokenExpirationTimestamp(long);
}

public enum TokenResult.ResponseCode {
enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode FID_ERROR;
enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode OK;
enum_constant public static final com.google.firebase.installations.remote.TokenResult.ResponseCode REFRESH_TOKEN_ERROR;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we outsource this to a dedicated model class?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is auto-generated api.txt file.

}

Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_FID_1;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTALLATION_RESPONSE;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTALLATION_RESPONSE_WITH_IID;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTALLATION_TOKEN_RESULT;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_INSTANCE_ID_1;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_PROJECT_ID;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_REFRESH_TOKEN;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_EXPIRATION_TIMESTAMP_2;
import static com.google.firebase.installations.FisAndroidTestConstants.TEST_TOKEN_RESULT;
import static com.google.firebase.installations.local.PersistedInstallationEntrySubject.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
Expand All @@ -59,6 +59,7 @@
import com.google.firebase.installations.local.PersistedInstallation.RegistrationStatus;
import com.google.firebase.installations.local.PersistedInstallationEntry;
import com.google.firebase.installations.remote.FirebaseInstallationServiceClient;
import com.google.firebase.installations.remote.TokenResult;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
Expand Down Expand Up @@ -174,7 +175,7 @@ public void setUp() throws FirebaseException {
// Mocks successful auth token generation
when(backendClientReturnsOk.generateAuthToken(
anyString(), anyString(), anyString(), anyString()))
.thenReturn(TEST_INSTALLATION_TOKEN_RESULT);
.thenReturn(TEST_TOKEN_RESULT);

when(persistedInstallationReturnsError.insertOrUpdatePersistedInstallationEntry(any()))
.thenReturn(false);
Expand Down Expand Up @@ -588,6 +589,41 @@ public void testGetAuthToken_unregisteredFid_fetchedNewTokenFromFIS() throws Exc
.createFirebaseInstallation(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_APP_ID_1);
}

@Test
public void testGetAuthToken_fidError_persistedInstallationCleared() throws Exception {
// Update local storage with an expired installation entry to ensure that generate auth token
// is called.
persistedInstallation.insertOrUpdatePersistedInstallationEntry(EXPIRED_AUTH_TOKEN_ENTRY);
// Mocks error during auth token generation
when(backendClientReturnsOk.generateAuthToken(
anyString(), anyString(), anyString(), anyString()))
.thenReturn(
TokenResult.builder().setResponseCode(TokenResult.ResponseCode.FID_ERROR).build());
when(mockUtils.isAuthTokenExpired(REGISTERED_INSTALLATION_ENTRY)).thenReturn(true, false);
Copy link
Contributor

Choose a reason for hiding this comment

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

can you please add comments here to make it easier to understand what these values are:
e.g.

then Return( true /* isExpired /, false / isValid */)


FirebaseInstallations firebaseInstallations = getFirebaseInstallations();

// Expect exception
try {
Tasks.await(firebaseInstallations.getAuthToken(FirebaseInstallationsApi.FORCE_REFRESH));
fail("getAuthToken() failed due to Server Error.");
} catch (ExecutionException expected) {
assertWithMessage("Exception class doesn't match")
.that(expected)
.hasCauseThat()
.isInstanceOf(FirebaseInstallationsException.class);
assertWithMessage("Exception status doesn't match")
.that(((FirebaseInstallationsException) expected.getCause()).getStatus())
.isEqualTo(FirebaseInstallationsException.Status.AUTHENTICATION_ERROR);
}

verify(backendClientReturnsOk, times(1))
.generateAuthToken(TEST_API_KEY, TEST_FID_1, TEST_PROJECT_ID, TEST_REFRESH_TOKEN);
PersistedInstallationEntry updatedInstallationEntry =
persistedInstallation.readPersistedInstallationEntryValue();
assertThat(updatedInstallationEntry).hasRegistrationStatus(RegistrationStatus.NOT_GENERATED);
}

@Test
public void testGetAuthToken_serverError_failure() throws Exception {
when(mockPersistedInstallation.readPersistedInstallationEntryValue())
Expand Down Expand Up @@ -662,19 +698,19 @@ public void testGetAuthToken_multipleCallsForceRefresh_fetchedNewTokenTwice() th
AdditionalAnswers.answersWithDelay(
500,
(unused) ->
InstallationTokenResult.builder()
TokenResult.builder()
.setToken(TEST_AUTH_TOKEN_3)
.setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP)
.setTokenCreationTimestamp(TEST_CREATION_TIMESTAMP_1)
.setResponseCode(TokenResult.ResponseCode.OK)
.build()))
.doAnswer(
AdditionalAnswers.answersWithDelay(
500,
(unused) ->
InstallationTokenResult.builder()
TokenResult.builder()
.setToken(TEST_AUTH_TOKEN_4)
.setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP)
.setTokenCreationTimestamp(TEST_CREATION_TIMESTAMP_1)
.setResponseCode(TokenResult.ResponseCode.OK)
.build()))
.when(backendClientReturnsOk)
.generateAuthToken(anyString(), anyString(), anyString(), anyString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.google.firebase.installations.local.PersistedInstallationEntry;
import com.google.firebase.installations.remote.InstallationResponse;
import com.google.firebase.installations.remote.InstallationResponse.ResponseCode;
import com.google.firebase.installations.remote.TokenResult;

public final class FisAndroidTestConstants {
public static final String TEST_FID_1 = "cccccccccccccccccccccc";
Expand Down Expand Up @@ -75,11 +76,11 @@ public final class FisAndroidTestConstants {
.setResponseCode(ResponseCode.OK)
.build();

public static final InstallationTokenResult TEST_INSTALLATION_TOKEN_RESULT =
InstallationTokenResult.builder()
public static final TokenResult TEST_TOKEN_RESULT =
TokenResult.builder()
.setToken(TEST_AUTH_TOKEN_2)
.setTokenExpirationTimestamp(TEST_TOKEN_EXPIRATION_TIMESTAMP)
.setTokenCreationTimestamp(TEST_CREATION_TIMESTAMP_1)
.setResponseCode(TokenResult.ResponseCode.OK)
.build();

public static final InstallationResponse SERVER_ERROR_INSTALLATION_RESPONSE =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import com.google.firebase.installations.remote.FirebaseInstallationServiceClient;
import com.google.firebase.installations.remote.InstallationResponse;
import com.google.firebase.installations.remote.InstallationResponse.ResponseCode;
import com.google.firebase.installations.remote.TokenResult;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
Expand Down Expand Up @@ -270,6 +271,18 @@ private final void doRegistration() {
}
}

// If persisted installation entry is in NOT_GENERATED state, it was cleared due to
// authentication error during auth token generation.
if (persistedInstallationEntry.isNotGenerated()) {
triggerOnException(
persistedInstallationEntry,
new FirebaseInstallationsException(
"Failed to generate auth token for this Firebase Installation. Call getId() "
+ "to recreate a new Fid and a valid auth token.",
FirebaseInstallationsException.Status.AUTHENTICATION_ERROR));
return;
}

triggerOnStateReached(persistedInstallationEntry);
} catch (Exception e) {
PersistedInstallationEntry persistedInstallationEntry =
Expand Down Expand Up @@ -345,28 +358,30 @@ private Void registerAndSaveFid(PersistedInstallationEntry persistedInstallation
}

/** Calls the FIS servers to generate an auth token for this Firebase installation. */
private InstallationTokenResult fetchAuthTokenFromServer(
PersistedInstallationEntry persistedInstallationEntry) throws FirebaseInstallationsException {
private void fetchAuthTokenFromServer(PersistedInstallationEntry persistedInstallationEntry)
throws FirebaseInstallationsException {
try {
long creationTime = utils.currentTimeInSecs();
InstallationTokenResult tokenResult =
TokenResult tokenResult =
serviceClient.generateAuthToken(
/*apiKey= */ firebaseApp.getOptions().getApiKey(),
/*fid= */ persistedInstallationEntry.getFirebaseInstallationId(),
/*projectID= */ firebaseApp.getOptions().getProjectId(),
/*refreshToken= */ persistedInstallationEntry.getRefreshToken());

persistedInstallation.insertOrUpdatePersistedInstallationEntry(
PersistedInstallationEntry.builder()
.setFirebaseInstallationId(persistedInstallationEntry.getFirebaseInstallationId())
.setRegistrationStatus(RegistrationStatus.REGISTERED)
.setAuthToken(tokenResult.getToken())
.setRefreshToken(persistedInstallationEntry.getRefreshToken())
.setExpiresInSecs(tokenResult.getTokenExpirationTimestamp())
.setTokenCreationEpochInSecs(creationTime)
.build());

return tokenResult;
if (tokenResult.isSuccessful()) {
persistedInstallation.insertOrUpdatePersistedInstallationEntry(
persistedInstallationEntry
.toBuilder()
.setRegistrationStatus(RegistrationStatus.REGISTERED)
.setAuthToken(tokenResult.getToken())
.setExpiresInSecs(tokenResult.getTokenExpirationTimestamp())
.setTokenCreationEpochInSecs(creationTime)
.build());
} else if (tokenResult.isErrored()) {
persistedInstallation.clear();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

How about this:
if (tokenResult.isErrored()) {
persistedInstallation.clear();
return;
}

if (tokenResult.isSuccessful()) {
persistedInstallation.insertOrUpdatePersistedInstallationEntry(
persistedInstallationEntry
.toBuilder()
.setRegistrationStatus(RegistrationStatus.REGISTERED)
.setAuthToken(tokenResult.getToken())
.setExpiresInSecs(tokenResult.getTokenExpirationTimestamp())
.setTokenCreationEpochInSecs(creationTime)
.build());
return;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.


} catch (FirebaseException exception) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Which line does throw the FirebaseException?
It looks like currently the entire method is inside a try-catch block...
If that is the case, can we throw the exception and catch it outside?
or maybe (preferably) can we catch only the lines that can cause the exception to be thrown?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a TODO to improve Exception handling in general.

throw new FirebaseInstallationsException(
"Failed to generate auth token for a Firebase Installation.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
/** The class for all Exceptions thrown by {@link FirebaseInstallations}. */
public class FirebaseInstallationsException extends FirebaseException {

// TODO(ankitagj): Improve clear exception handling.
public enum Status {
SDK_INTERNAL_ERROR,

CLIENT_ERROR
CLIENT_ERROR,

AUTHENTICATION_ERROR
}

@NonNull private final Status status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public boolean onStateReached(
@Override
public boolean onException(
PersistedInstallationEntry persistedInstallationEntry, Exception exception) {
if (persistedInstallationEntry.isErrored()) {
if (persistedInstallationEntry.isErrored() || persistedInstallationEntry.isNotGenerated()) {
resultTaskCompletionSource.trySetException(exception);
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ public void deleteFirebaseInstallation(
* @param refreshToken a token used to authenticate FIS requests
*/
@NonNull
public InstallationTokenResult generateAuthToken(
public TokenResult generateAuthToken(
@NonNull String apiKey,
@NonNull String fid,
@NonNull String projectID,
Expand Down Expand Up @@ -228,6 +228,17 @@ public InstallationTokenResult generateAuthToken(
if (httpResponseCode == 200) {
return readGenerateAuthTokenResponse(httpsURLConnection);
Copy link
Contributor

Choose a reason for hiding this comment

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

is there a reason to not just return the response?

}

if (httpResponseCode == 401) {
return TokenResult.builder()
.setResponseCode(TokenResult.ResponseCode.REFRESH_TOKEN_ERROR)
.build();
}
Comment on lines +231 to +235
Copy link
Contributor

Choose a reason for hiding this comment

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

So, the request was not authenticated, what is happening next?
Are we deleting the PersistedInstallationEntry?
If yes, where is this done?
Can you please add a comment what REFRES_TOKEN_ERROR will trigger elsewhere?


if (httpResponseCode == 404) {
return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.FID_ERROR).build();
}
Comment on lines +237 to +239
Copy link
Contributor

Choose a reason for hiding this comment

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

question: why does this not trigger a deletion of the PersistedInstallationEntry?
and if that is done somewhere else, could you please add a comment here, that the FID_ERROR will trigger a reset of the FIS SDK elsewhere?


// Usually the FIS server recovers from errors: retry one time before giving up.
if (httpResponseCode >= 500 && httpResponseCode < 600) {
retryCount++;
Expand Down Expand Up @@ -295,10 +306,9 @@ private InstallationResponse readCreateResponse(HttpsURLConnection conn) throws
}

// Read the response from the generateAuthToken FirebaseInstallation API.
private InstallationTokenResult readGenerateAuthTokenResponse(HttpsURLConnection conn)
throws IOException {
private TokenResult readGenerateAuthTokenResponse(HttpsURLConnection conn) throws IOException {
JsonReader reader = new JsonReader(new InputStreamReader(conn.getInputStream(), UTF_8));
InstallationTokenResult.Builder builder = InstallationTokenResult.builder();
TokenResult.Builder builder = TokenResult.builder();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
Expand All @@ -312,7 +322,7 @@ private InstallationTokenResult readGenerateAuthTokenResponse(HttpsURLConnection
}
reader.endObject();

return builder.build();
return builder.setResponseCode(TokenResult.ResponseCode.OK).build();
}

// Read the error message from the response.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.installations.remote;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.auto.value.AutoValue;

/** This class represents a set of values describing a FIS Auth Token Result. */
@AutoValue
public abstract class TokenResult {
Copy link
Contributor

Choose a reason for hiding this comment

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

As you added this TokenResult, should we hide the proto class InstallationTokenResult only in the remote package?

Because I noticed that FirebaseInstallations class uses both.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both classes are needed in FirebaseInstallations.

InstallationTokenResult as the response of the FIS SDK generateAUthToken API.
TokenResult as the response of FIS Service Client.


public enum ResponseCode {
// Returned on success
OK,
// Auth token cannot be generated for this FID in the request. Because it is not
// registered/found on the FIS server. Recreate a new fid to fetch a valid auth token.
FID_ERROR,
// Refresh token in this request in not accepted by the FIS server. Either it has been blocked
// or changed. Recreate a new fid to fetch a valid auth token.
REFRESH_TOKEN_ERROR,
}

public boolean isSuccessful() {
return getResponseCode() == ResponseCode.OK;
}

public boolean isErrored() {
return getResponseCode() == ResponseCode.FID_ERROR
|| getResponseCode() == ResponseCode.REFRESH_TOKEN_ERROR;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

why is this not the following?
return !isSuccessful();


Copy link
Contributor

Choose a reason for hiding this comment

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

Will this always be a bi-state? If yes, then I prefer only having isSuccessful()

otherwise when people read the code
if (isSuccessful)
else if (isErrored)
it's a bit confusing that is there another status?

Copy link
Contributor

Choose a reason for hiding this comment

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

+1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed offline, not exactly a bi-state. Updated the method name.

/** A new FIS Auth-Token, created for this Firebase Installation. */
@Nullable
public abstract String getToken();
/**
* The amount of time, in seconds, before the auth-token expires for this Firebase Installation.
*/
@NonNull
public abstract long getTokenExpirationTimestamp();
Copy link
Contributor

Choose a reason for hiding this comment

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

Here is a contradiction:
The JavaDoc indicates this is a duration (a.k.a. "expires_in"), but the name of the variable indicates it is a timestamp (i.e. "expiration_date").
Can you please be more precise and fix either JavaDoc or variable name


@Nullable
public abstract ResponseCode getResponseCode();

@NonNull
public abstract Builder toBuilder();

/** Returns a default Builder object to create an InstallationResponse object */
@NonNull
public static TokenResult.Builder builder() {
return new AutoValue_TokenResult.Builder().setTokenExpirationTimestamp(0);
}

@AutoValue.Builder
public abstract static class Builder {
@NonNull
public abstract Builder setToken(@NonNull String value);

@NonNull
public abstract Builder setTokenExpirationTimestamp(long value);

@NonNull
public abstract Builder setResponseCode(@NonNull ResponseCode value);

@NonNull
public abstract TokenResult build();
}
}