Repository: firebase/firebase-admin-java
Pull request #366 (2020-02-12 23:00:09)

Error handling revamp for the custom token creation API
This finalizes the error handling revamp for the `FirebaseAuth` API.


Patch:
diff --git a/src/main/java/com/google/firebase/auth/AuthErrorHandler.java b/src/main/java/com/google/firebase/auth/AuthErrorHandler.java
index a6cea39..6ed3f50 100644
--- a/src/main/java/com/google/firebase/auth/AuthErrorHandler.java
+++ b/src/main/java/com/google/firebase/auth/AuthErrorHandler.java
@@ -99,12 +99,7 @@ final class AuthErrorHandler extends AbstractHttpErrorHandler<FirebaseAuthExcept
           errorInfo.getAuthErrorCode());
     }
 
-    return new FirebaseAuthException(
-        base.getErrorCodeNew(),
-        base.getMessage(),
-        base.getCause(),
-        base.getHttpResponse(),
-        null);
+    return new FirebaseAuthException(base);
   }
 
   private String getResponse(FirebaseException base) {
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index f7f6231..a7c3603 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -41,7 +41,6 @@ import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
 
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -58,8 +57,6 @@ public class FirebaseAuth {
 
   private static final String SERVICE_ID = FirebaseAuth.class.getName();
 
-  private static final String ERROR_CUSTOM_TOKEN = "ERROR_CUSTOM_TOKEN";
-
   private final Object lock = new Object();
   private final AtomicBoolean destroyed = new AtomicBoolean(false);
 
@@ -331,12 +328,7 @@ public class FirebaseAuth {
     return new CallableOperation<String, FirebaseAuthException>() {
       @Override
       public String execute() throws FirebaseAuthException {
-        try {
-          return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims);
-        } catch (IOException e) {
-          throw new FirebaseAuthException(ERROR_CUSTOM_TOKEN,
-              "Failed to generate a custom token", e);
-        }
+        return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims);
       }
     };
   }
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java
index f50bc1f..b1f84df 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java
@@ -16,9 +16,6 @@
 
 package com.google.firebase.auth;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Strings;
 import com.google.firebase.ErrorCode;
 import com.google.firebase.FirebaseException;
 import com.google.firebase.IncomingHttpResponse;
@@ -33,7 +30,7 @@ public class FirebaseAuthException extends FirebaseException {
 
   private final AuthErrorCode errorCode;
 
-  FirebaseAuthException(
+  public FirebaseAuthException(
       @NonNull ErrorCode errorCode,
       @NonNull String message,
       Throwable cause,
@@ -43,12 +40,13 @@ public class FirebaseAuthException extends FirebaseException {
     this.errorCode = authErrorCode;
   }
 
-  @Deprecated
   public FirebaseAuthException(
-      @NonNull String errorCode, @NonNull String detailMessage, Throwable throwable) {
-    super(detailMessage, throwable);
-    checkArgument(!Strings.isNullOrEmpty(errorCode));
-    this.errorCode = null;
+      @NonNull ErrorCode errorCode, @NonNull String message, Throwable throwable) {
+    this(errorCode, message, throwable, null, null);
+  }
+
+  public FirebaseAuthException(FirebaseException base) {
+    this(base.getErrorCodeNew(), base.getMessage(), base.getCause(), base.getHttpResponse(), null);
   }
 
   @Nullable
diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java
index 3036f9f..2ff30a2 100644
--- a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java
+++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java
@@ -16,6 +16,7 @@
 
 package com.google.firebase.auth.internal;
 
+import com.google.firebase.auth.FirebaseAuthException;
 import com.google.firebase.internal.NonNull;
 import java.io.IOException;
 
@@ -32,10 +33,10 @@ interface CryptoSigner {
    *
    * @param payload Data to be signed
    * @return Signature as a byte array
-   * @throws IOException If an error occurs during signing
+   * @throws FirebaseAuthException If an error occurs during signing
    */
   @NonNull
-  byte[] sign(@NonNull byte[] payload) throws IOException;
+  byte[] sign(@NonNull byte[] payload) throws FirebaseAuthException;
 
   /**
    * Returns the client email of the service account used to sign payloads.
diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
index 6ea70b8..c20c82a 100644
--- a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
+++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
@@ -9,9 +9,8 @@ import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.http.HttpResponseInterceptor;
 import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.GenericJson;
 import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.JsonObjectParser;
-import com.google.api.client.util.Key;
 import com.google.api.client.util.StringUtils;
 import com.google.auth.ServiceAccountSigner;
 import com.google.auth.oauth2.GoogleCredentials;
@@ -21,9 +20,13 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.io.BaseEncoding;
 import com.google.common.io.ByteStreams;
 import com.google.firebase.FirebaseApp;
-import com.google.firebase.FirebaseOptions;
+import com.google.firebase.FirebaseException;
 import com.google.firebase.ImplFirebaseTrampolines;
-import com.google.firebase.internal.FirebaseRequestInitializer;
+import com.google.firebase.auth.FirebaseAuthException;
+import com.google.firebase.internal.AbstractPlatformErrorHandler;
+import com.google.firebase.internal.ApiClientUtils;
+import com.google.firebase.internal.ErrorHandlingHttpClient;
+import com.google.firebase.internal.HttpRequestInfo;
 import com.google.firebase.internal.NonNull;
 import java.io.IOException;
 import java.util.Map;
@@ -34,7 +37,9 @@ import java.util.Map;
 public class CryptoSigners {
 
   private static final String METADATA_SERVICE_URL =
-      "http://metadata/computeMetadata/v1/instance/service-accounts/default/email";
+      "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email";
+
+  private CryptoSigners() { }
 
   /**
    * A {@link CryptoSigner} implementation that uses service account credentials or equivalent
@@ -69,19 +74,22 @@ public class CryptoSigners {
     private static final String IAM_SIGN_BLOB_URL =
         "https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob";
 
-    private final HttpRequestFactory requestFactory;
-    private final JsonFactory jsonFactory;
     private final String serviceAccount;
+    private final JsonFactory jsonFactory;
+    private final ErrorHandlingHttpClient<FirebaseAuthException> httpClient;
     private HttpResponseInterceptor interceptor;
 
     IAMCryptoSigner(
         @NonNull HttpRequestFactory requestFactory,
         @NonNull JsonFactory jsonFactory,
         @NonNull String serviceAccount) {
-      this.requestFactory = checkNotNull(requestFactory);
-      this.jsonFactory = checkNotNull(jsonFactory);
       checkArgument(!Strings.isNullOrEmpty(serviceAccount));
       this.serviceAccount = serviceAccount;
+      this.jsonFactory = checkNotNull(jsonFactory);
+      this.httpClient = new ErrorHandlingHttpClient<>(
+          requestFactory,
+          jsonFactory,
+          new IAMErrorHandler(jsonFactory));
     }
 
     void setInterceptor(HttpResponseInterceptor interceptor) {
@@ -89,28 +97,15 @@ public class CryptoSigners {
     }
 
     @Override
-    public byte[] sign(byte[] payload) throws IOException {
-      String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount);
-      HttpResponse response = null;
+    public byte[] sign(byte[] payload) throws FirebaseAuthException {
       String encodedPayload = BaseEncoding.base64().encode(payload);
       Map<String, String> content = ImmutableMap.of("bytesToSign", encodedPayload);
-      try {
-        HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(encodedUrl),
-            new JsonHttpContent(jsonFactory, content));
-        request.setParser(new JsonObjectParser(jsonFactory));
-        request.setResponseInterceptor(interceptor);
-        response = request.execute();
-        SignBlobResponse parsed = response.parseAs(SignBlobResponse.class);
-        return BaseEncoding.base64().decode(parsed.signature);
-      } finally {
-        if (response != null) {
-          try {
-            response.disconnect();
-          } catch (IOException ignored) {
-            // Ignored
-          }
-        }
-      }
+      String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount);
+      HttpRequestInfo requestInfo = HttpRequestInfo
+          .buildPostRequest(encodedUrl, new JsonHttpContent(jsonFactory, content))
+          .setResponseInterceptor(interceptor);
+      GenericJson parsed = httpClient.sendAndParse(requestInfo, GenericJson.class);
+      return BaseEncoding.base64().decode((String) parsed.get("signature"));
     }
 
     @Override
@@ -119,9 +114,17 @@ public class CryptoSigners {
     }
   }
 
-  public static class SignBlobResponse {
-    @Key("signature")
-    private String signature;
+  private static class IAMErrorHandler
+      extends AbstractPlatformErrorHandler<FirebaseAuthException> {
+
+    IAMErrorHandler(JsonFactory jsonFactory) {
+      super(jsonFactory);
+    }
+
+    @Override
+    protected FirebaseAuthException createException(FirebaseException base) {
+      return new FirebaseAuthException(base);
+    }
   }
 
   /**
@@ -136,14 +139,12 @@ public class CryptoSigners {
       return new ServiceAccountCryptoSigner((ServiceAccountCredentials) credentials);
     }
 
-    FirebaseOptions options = firebaseApp.getOptions();
-    HttpRequestFactory requestFactory = options.getHttpTransport().createRequestFactory(
-        new FirebaseRequestInitializer(firebaseApp));
-    JsonFactory jsonFactory = options.getJsonFactory();
+    HttpRequestFactory requestFactory = ApiClientUtils.newAuthorizedRequestFactory(firebaseApp);
+    JsonFactory jsonFactory = firebaseApp.getOptions().getJsonFactory();
 
     // If the SDK was initialized with a service account email, use it with the IAM service
     // to sign bytes.
-    String serviceAccountId = options.getServiceAccountId();
+    String serviceAccountId = firebaseApp.getOptions().getServiceAccountId();
     if (!Strings.isNullOrEmpty(serviceAccountId)) {
       return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId);
     }
@@ -156,15 +157,22 @@ public class CryptoSigners {
 
     // Attempt to discover a service account email from the local Metadata service. Use it
     // with the IAM service to sign bytes.
-    HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(METADATA_SERVICE_URL));
+    serviceAccountId = discoverServiceAccountId(firebaseApp);
+    return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId);
+  }
+
+  private static String discoverServiceAccountId(FirebaseApp firebaseApp) throws IOException {
+    HttpRequestFactory metadataRequestFactory =
+        ApiClientUtils.newUnauthorizedRequestFactory(firebaseApp);
+    HttpRequest request = metadataRequestFactory.buildGetRequest(
+        new GenericUrl(METADATA_SERVICE_URL));
     request.getHeaders().set("Metadata-Flavor", "Google");
     HttpResponse response = request.execute();
     try {
       byte[] output = ByteStreams.toByteArray(response.getContent());
-      serviceAccountId = StringUtils.newStringUtf8(output).trim();
-      return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId);
+      return StringUtils.newStringUtf8(output).trim();
     } finally {
-      response.disconnect();
+      ApiClientUtils.disconnectQuietly(response);
     }
   }
 }
diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
index 95d3131..56be201 100644
--- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
+++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
@@ -25,8 +25,8 @@ import com.google.api.client.json.webtoken.JsonWebSignature;
 import com.google.api.client.util.Base64;
 import com.google.api.client.util.Clock;
 import com.google.api.client.util.StringUtils;
-
 import com.google.common.base.Strings;
+import com.google.firebase.auth.FirebaseAuthException;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Map;
@@ -48,12 +48,12 @@ public class FirebaseTokenFactory {
     this.signer = checkNotNull(signer);
   }
 
-  String createSignedCustomAuthTokenForUser(String uid) throws IOException {
+  String createSignedCustomAuthTokenForUser(String uid) throws FirebaseAuthException {
     return createSignedCustomAuthTokenForUser(uid, null);
   }
 
   public String createSignedCustomAuthTokenForUser(
-      String uid, Map<String, Object> developerClaims) throws IOException {
+      String uid, Map<String, Object> developerClaims) throws FirebaseAuthException {
     checkArgument(!Strings.isNullOrEmpty(uid), "Uid must be provided.");
     checkArgument(uid.length() <= 128, "Uid must be shorter than 128 characters.");
 
@@ -77,20 +77,33 @@ public class FirebaseTokenFactory {
               String.format("developerClaims must not contain a reserved key: %s", key));
         }
       }
+
       GenericJson jsonObject = new GenericJson();
       jsonObject.putAll(developerClaims);
       payload.setDeveloperClaims(jsonObject);
     }
+
     return signPayload(header, payload);
   }
 
-  private String signPayload(JsonWebSignature.Header header,
-      FirebaseCustomAuthToken.Payload payload) throws IOException {
-    String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header));
-    String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload));
-    String content = headerString + "." + payloadString;
+  private String signPayload(
+      JsonWebSignature.Header header,
+      FirebaseCustomAuthToken.Payload payload) throws FirebaseAuthException {
+    String content = encodePayload(header, payload);
     byte[] contentBytes = StringUtils.getBytesUtf8(content);
     String signature = Base64.encodeBase64URLSafeString(signer.sign(contentBytes));
     return content + "." + signature;
   }
+
+  private String encodePayload(
+      JsonWebSignature.Header header, FirebaseCustomAuthToken.Payload payload) {
+    try {
+      String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header));
+      String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload));
+      return headerString + "." + payloadString;
+    } catch (IOException e) {
+      throw new IllegalArgumentException(
+          "Failed to encode JWT with the given claims: " + e.getMessage(), e);
+    }
+  }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java b/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java
index bc98add..9c80d8b 100644
--- a/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java
+++ b/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java
@@ -18,10 +18,14 @@ package com.google.firebase.auth.internal;
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpStatusCodes;
 import com.google.api.client.testing.http.MockHttpTransport;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
 import com.google.auth.ServiceAccountSigner;
@@ -29,21 +33,22 @@ import com.google.auth.oauth2.ServiceAccountCredentials;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.BaseEncoding;
+import com.google.firebase.ErrorCode;
 import com.google.firebase.FirebaseApp;
 import com.google.firebase.FirebaseOptions;
 import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.FirebaseAuthException;
 import com.google.firebase.auth.MockGoogleCredentials;
 import com.google.firebase.testing.MultiRequestMockHttpTransport;
 import com.google.firebase.testing.ServiceAccount;
 import com.google.firebase.testing.TestResponseInterceptor;
-import java.io.IOException;
 import org.junit.After;
 import org.junit.Test;
 
 public class CryptoSignersTest {
 
   @Test
-  public void testServiceAccountCryptoSigner() throws IOException {
+  public void testServiceAccountCryptoSigner() throws Exception {
     ServiceAccountCredentials credentials = ServiceAccountCredentials.fromStream(
         ServiceAccount.EDITOR.asStream());
     byte[] expected = credentials.sign("foo".getBytes());
@@ -63,7 +68,7 @@ public class CryptoSignersTest {
   }
 
   @Test
-  public void testIAMCryptoSigner() throws IOException {
+  public void testIAMCryptoSigner() throws Exception {
     String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
     String response = Utils.getDefaultJsonFactory().toString(
         ImmutableMap.of("signature", signature));
@@ -84,6 +89,29 @@ public class CryptoSignersTest {
     assertEquals(url, interceptor.getResponse().getRequest().getUrl().toString());
   }
 
+  @Test
+  public void testIAMCryptoSignerHttpError() {
+    String error = "{\"error\": {\"status\":\"INTERNAL\", \"message\": \"Test error\"}}";
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(new MockLowLevelHttpResponse()
+            .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+            .setContent(error))
+        .build();
+    CryptoSigners.IAMCryptoSigner signer = new CryptoSigners.IAMCryptoSigner(
+        transport.createRequestFactory(),
+        Utils.getDefaultJsonFactory(),
+        "test-service-account@iam.gserviceaccount.com");
+    try {
+      signer.sign("foo".getBytes());
+    } catch (FirebaseAuthException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Test error", e.getMessage());
+      assertNotNull(e.getCause());
+      assertNotNull(e.getHttpResponse());
+      assertNull(e.getAuthErrorCode());
+    }
+  }
+
   @Test
   public void testInvalidIAMCryptoSigner() {
     try {
@@ -119,7 +147,7 @@ public class CryptoSignersTest {
   }
 
   @Test
-  public void testMetadataService() throws IOException {
+  public void testMetadataService() throws Exception {
     String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
     String response = Utils.getDefaultJsonFactory().toString(
         ImmutableMap.of("signature", signature));
@@ -142,11 +170,13 @@ public class CryptoSignersTest {
     assertArrayEquals("signed-bytes".getBytes(), data);
     final String url = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/"
         + "metadata-server@iam.gserviceaccount.com:signBlob";
-    assertEquals(url, interceptor.getResponse().getRequest().getUrl().toString());
+    HttpRequest request = interceptor.getResponse().getRequest();
+    assertEquals(url, request.getUrl().toString());
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
   }
 
   @Test
-  public void testExplicitServiceAccountEmail() throws IOException {
+  public void testExplicitServiceAccountEmail() throws Exception {
     String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
     String response = Utils.getDefaultJsonFactory().toString(
         ImmutableMap.of("signature", signature));
@@ -170,11 +200,13 @@ public class CryptoSignersTest {
     assertArrayEquals("signed-bytes".getBytes(), data);
     final String url = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/"
         + "explicit-service-account@iam.gserviceaccount.com:signBlob";
-    assertEquals(url, interceptor.getResponse().getRequest().getUrl().toString());
+    HttpRequest request = interceptor.getResponse().getRequest();
+    assertEquals(url, request.getUrl().toString());
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
   }
 
   @Test
-  public void testCredentialsWithSigner() throws IOException {
+  public void testCredentialsWithSigner() throws Exception {
     // Should fall back to signing-enabled credential
     FirebaseOptions options = new FirebaseOptions.Builder()
         .setCredentials(new MockGoogleCredentialsWithSigner("test-token"))
diff --git a/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java b/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java
index 71b95be..7c75f64 100644
--- a/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java
+++ b/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java
@@ -28,8 +28,9 @@ import com.google.api.client.util.SecurityUtils;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.auth.FirebaseAuthException;
 import com.google.firebase.testing.TestUtils;
-import java.io.IOException;
 import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
@@ -138,12 +139,12 @@ public class FirebaseTokenFactoryTest {
     }
 
     @Override
-    public byte[] sign(byte[] payload) throws IOException {
+    public byte[] sign(byte[] payload) throws FirebaseAuthException {
       try {
         return SecurityUtils.sign(SecurityUtils.getSha256WithRsaSignatureAlgorithm(),
             privateKey, payload);
       } catch (GeneralSecurityException e) {
-        throw new IOException(e);
+        throw new FirebaseAuthException(ErrorCode.UNKNOWN, "Failed to sign token", e, null, null);
       }
     }