Repository: firebase/firebase-admin-java
Pull request #183 (2018-05-31 19:55:56)

Support for creating custom tokens without service account credentials
Currently the SDK must be initialized with service account credentials in order to be able to call `FirebaseAuth.createCustomToken()`. With this PR, the SDK will attempt to sign custom tokens by calling the IAM service in the cloud when the service account credentials are not provided.

```
// Following will work in GCP managed runtimes where the Metadata service is present
FirebaseApp.initializeApp();
String token = FirebaseAuth.getInstance().createCustomToken(uid);
```

```
// Following will work anywhere
FirebaseOptions options = new FirebaseOptions.Builder()
    // need to only specify the service account email here...
    .setServiceAccount("test-service-account@project-id.iam.gserviceaccount.com")
    .build();
FirebaseApp.initializeApp(options);
String token = FirebaseAuth.getInstance().createCustomToken(uid);
```

go/firebase-admin-sign
go/firebase-admin-iam-sign

hiranya911: Resolves #59 
hiranya911: Thanks for the review. Made the suggested changes. Over to @schmidt-sebastian for another look.
hiranya911: @schmidt-sebastian PTAL at my last commit. This renames `serviceAccount` to `serviceAccountId` due to an amendment made to the API proposal.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7bf3e8..da38156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Unreleased
 
+- [added] Implemented the ability to create custom tokens without
+  service account credentials.
+- [added] Added the `setServiceAccount()` method to the
+  `FirebaseOptions.Builder` API.
 - [added] The SDK can now read the Firebase/GCP project ID from both
   `GCLOUD_PROJECT` and `GOOGLE_CLOUD_PROJECT` environment variables.
 
diff --git a/src/main/java/com/google/firebase/FirebaseOptions.java b/src/main/java/com/google/firebase/FirebaseOptions.java
index 0cdf87b..ae9b233 100644
--- a/src/main/java/com/google/firebase/FirebaseOptions.java
+++ b/src/main/java/com/google/firebase/FirebaseOptions.java
@@ -60,6 +60,7 @@ public final class FirebaseOptions {
   private final GoogleCredentials credentials;
   private final Map<String, Object> databaseAuthVariableOverride;
   private final String projectId;
+  private final String serviceAccountId;
   private final HttpTransport httpTransport;
   private final int connectTimeout;
   private final int readTimeout;
@@ -77,6 +78,11 @@ public final class FirebaseOptions {
       checkArgument(!builder.storageBucket.startsWith("gs://"),
           "StorageBucket must not include 'gs://' prefix.");
     }
+    if (!Strings.isNullOrEmpty(builder.serviceAccountId)) {
+      this.serviceAccountId = builder.serviceAccountId;
+    } else {
+      this.serviceAccountId = null;
+    }
     this.storageBucket = builder.storageBucket;
     this.httpTransport = checkNotNull(builder.httpTransport,
         "FirebaseOptions must be initialized with a non-null HttpTransport.");
@@ -131,6 +137,16 @@ public final class FirebaseOptions {
     return projectId;
   }
 
+  /**
+   * Returns the client email address of the service account.
+   *
+   * @return The client email of the service account set via
+   *     {@link Builder#setServiceAccountId(String)}
+   */
+  public String getServiceAccountId() {
+    return serviceAccountId;
+  }
+
   /**
    * Returns the <code>HttpTransport</code> used to call remote HTTP endpoints. This transport is
    * used by all services of the SDK, except for FirebaseDatabase.
@@ -192,6 +208,9 @@ public final class FirebaseOptions {
     
     @Key("storageBucket")
     private String storageBucket;
+
+    @Key("serviceAccountId")
+    private String serviceAccountId;
     
     private GoogleCredentials credentials;
     private HttpTransport httpTransport = Utils.getDefaultTransport();
@@ -310,6 +329,24 @@ public final class FirebaseOptions {
       return this;
     }
 
+    /**
+     * Sets the client email address of the service account that should be associated with an app.
+     *
+     * <p>This is used to <a href="https://firebase.google.com/docs/auth/admin/create-custom-tokens">
+     * create custom auth tokens</a> when service account credentials are not available. The client
+     * email address of a service account can be found in the {@code client_email} field of the
+     * service account JSON.
+     *
+     * @param serviceAccountId A service account email address string.
+     * @return This <code>Builder</code> instance is returned so subsequent calls can be chained.
+     */
+    public Builder setServiceAccountId(@NonNull String serviceAccountId) {
+      checkArgument(!Strings.isNullOrEmpty(serviceAccountId),
+          "Service account ID must not be null or empty");
+      this.serviceAccountId = serviceAccountId;
+      return this;
+    }
+
     /**
      * Sets the <code>HttpTransport</code> used to make remote HTTP calls. A reasonable default
      * is used if not explicitly set. The transport specified by calling this method is
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index 47dd3ae..eb25895 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -23,8 +23,6 @@ import static com.google.common.base.Preconditions.checkState;
 import com.google.api.client.json.JsonFactory;
 import com.google.api.client.util.Clock;
 import com.google.api.core.ApiFuture;
-import com.google.auth.oauth2.GoogleCredentials;
-import com.google.auth.oauth2.ServiceAccountCredentials;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.firebase.FirebaseApp;
@@ -42,10 +40,10 @@ import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.NonNull;
 import com.google.firebase.internal.Nullable;
 import java.io.IOException;
-import java.security.GeneralSecurityException;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * This class is the entry point for all server-side Firebase Authentication actions.
@@ -65,10 +63,10 @@ public class FirebaseAuth {
 
   private final FirebaseApp firebaseApp;
   private final KeyManagers keyManagers;
-  private final GoogleCredentials credentials;
   private final String projectId;
   private final JsonFactory jsonFactory;
   private final FirebaseUserManager userManager;
+  private final AtomicReference<FirebaseTokenFactory> tokenFactory;
   private final AtomicBoolean destroyed;
   private final Object lock;
 
@@ -85,10 +83,10 @@ public class FirebaseAuth {
     this.firebaseApp = checkNotNull(firebaseApp);
     this.keyManagers = checkNotNull(keyManagers);
     this.clock = checkNotNull(clock);
-    this.credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp);
     this.projectId = ImplFirebaseTrampolines.getProjectId(firebaseApp);
     this.jsonFactory = firebaseApp.getOptions().getJsonFactory();
     this.userManager = new FirebaseUserManager(firebaseApp);
+    this.tokenFactory = new AtomicReference<>(null);
     this.destroyed = new AtomicBoolean(false);
     this.lock = new Object();
   }
@@ -287,8 +285,21 @@ public class FirebaseAuth {
    * <a href="/docs/auth/admin/create-custom-tokens#sign_in_using_custom_tokens_on_clients">signInWithCustomToken</a>
    * authentication API.
    *
-   * <p>{@link FirebaseApp} must have been initialized with service account credentials to use
-   * call this method.
+   * <p>This method attempts to generate a token using:
+   * <ol>
+   *   <li>the private key of {@link FirebaseApp}'s service account credentials, if provided at
+   *   initialization.
+   *   <li>the <a href="https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob">IAM service</a>
+   *   if a service account email was specified via
+   *   {@link com.google.firebase.FirebaseOptions.Builder#setServiceAccountId(String)}.
+   *   <li>the <a href="https://cloud.google.com/appengine/docs/standard/java/appidentity/">App Identity
+   *   service</a> if the code is deployed in the Google App Engine standard environment.
+   *   <li>the <a href="https://cloud.google.com/compute/docs/storing-retrieving-metadata">
+   *   local Metadata server</a> if the code is deployed in a different GCP-managed environment
+   *   like Google Compute Engine.
+   * </ol>
+   *
+   * <p>This method throws an exception when all the above fail.
    *
    * @param uid The UID to store in the token. This identifies the user to other Firebase services
    *     (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters.
@@ -296,8 +307,9 @@ public class FirebaseAuth {
    *     security rules in Database, Storage, etc.). These must be able to be serialized to JSON
    *     (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.)
    * @return A Firebase custom token string.
-   * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not
-   *     been initialized with service account credentials.
+   * @throws IllegalArgumentException If the specified uid is null or empty.
+   * @throws IllegalStateException If the SDK fails to discover a viable approach for signing
+   *     tokens.
    * @throws FirebaseAuthException If an error occurs while generating the custom token.
    */
   public String createCustomToken(@NonNull String uid,
@@ -342,21 +354,13 @@ public class FirebaseAuth {
       final String uid, final Map<String, Object> developerClaims) {
     checkNotDestroyed();
     checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty");
-    checkArgument(credentials instanceof ServiceAccountCredentials,
-        "Must initialize FirebaseApp with a service account credential to call "
-            + "createCustomToken()");
+    final FirebaseTokenFactory tokenFactory = ensureTokenFactory();
     return new CallableOperation<String, FirebaseAuthException>() {
       @Override
       public String execute() throws FirebaseAuthException {
-        final ServiceAccountCredentials serviceAccount = (ServiceAccountCredentials) credentials;
-        FirebaseTokenFactory tokenFactory = FirebaseTokenFactory.getInstance();
         try {
-          return tokenFactory.createSignedCustomAuthTokenForUser(
-              uid,
-              developerClaims,
-              serviceAccount.getClientEmail(),
-              serviceAccount.getPrivateKey());
-        } catch (GeneralSecurityException | IOException e) {
+          return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims);
+        } catch (IOException e) {
           throw new FirebaseAuthException(ERROR_CUSTOM_TOKEN,
               "Failed to generate a custom token", e);
         }
@@ -364,6 +368,29 @@ public class FirebaseAuth {
     };
   }
 
+  private FirebaseTokenFactory ensureTokenFactory() {
+    FirebaseTokenFactory result = this.tokenFactory.get();
+    if (result == null) {
+      synchronized (lock) {
+        result = this.tokenFactory.get();
+        if (result == null) {
+          try {
+            result = FirebaseTokenFactory.fromApp(firebaseApp, clock);
+            this.tokenFactory.set(result);
+          } catch (IOException e) {
+            throw new IllegalStateException(
+                "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK "
+                    + "with service account credentials or specify a service account "
+                    + "ID with iam.serviceAccounts.signBlob permission. Please refer to "
+                    + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more "
+                    + "details on creating custom tokens.", e);
+          }
+        }
+      }
+    }
+    return result;
+  }
+
   /**
    * Parses and verifies a Firebase ID Token.
    *
diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java
new file mode 100644
index 0000000..3036f9f
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.auth.internal;
+
+import com.google.firebase.internal.NonNull;
+import java.io.IOException;
+
+/**
+ * Represents an  object that can be used to cryptographically sign data. Mainly used for signing
+ * custom JWT tokens issued to Firebase users.
+ *
+ * <p>See {@link com.google.firebase.auth.FirebaseAuth#createCustomToken(String)}.
+ */
+interface CryptoSigner {
+
+  /**
+   * Signs the given payload.
+   *
+   * @param payload Data to be signed
+   * @return Signature as a byte array
+   * @throws IOException If an error occurs during signing
+   */
+  @NonNull
+  byte[] sign(@NonNull byte[] payload) throws IOException;
+
+  /**
+   * Returns the client email of the service account used to sign payloads.
+   *
+   * @return A service account client email
+   */
+  @NonNull
+  String getAccount();
+}
diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
new file mode 100644
index 0000000..3447f05
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java
@@ -0,0 +1,170 @@
+package com.google.firebase.auth.internal;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpRequest;
+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.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;
+import com.google.auth.oauth2.ServiceAccountCredentials;
+import com.google.common.base.Strings;
+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.ImplFirebaseTrampolines;
+import com.google.firebase.internal.FirebaseRequestInitializer;
+import com.google.firebase.internal.NonNull;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * A set of {@link CryptoSigner} implementations and utilities for interacting with them.
+ */
+class CryptoSigners {
+
+  private static final String METADATA_SERVICE_URL =
+      "http://metadata/computeMetadata/v1/instance/service-accounts/default/email";
+
+  /**
+   * A {@link CryptoSigner} implementation that uses service account credentials or equivalent
+   * crypto-capable credentials for signing data.
+   */
+  static class ServiceAccountCryptoSigner implements CryptoSigner {
+
+    private final ServiceAccountSigner signer;
+
+    ServiceAccountCryptoSigner(@NonNull ServiceAccountSigner signer) {
+      this.signer = checkNotNull(signer);
+    }
+
+    @Override
+    public byte[] sign(byte[] payload) {
+      return signer.sign(payload);
+    }
+
+    @Override
+    public String getAccount() {
+      return signer.getAccount();
+    }
+  }
+
+  /**
+   * @ {@link CryptoSigner} implementation that uses the
+   * <a href="https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob">
+   * Google IAM service</a> to sign data.
+   */
+  static class IAMCryptoSigner implements CryptoSigner {
+
+    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 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;
+    }
+
+    void setInterceptor(HttpResponseInterceptor interceptor) {
+      this.interceptor = interceptor;
+    }
+
+    @Override
+    public byte[] sign(byte[] payload) throws IOException {
+      String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount);
+      HttpResponse response = null;
+      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
+          }
+        }
+      }
+    }
+
+    @Override
+    public String getAccount() {
+      return serviceAccount;
+    }
+  }
+
+  public static class SignBlobResponse {
+    @Key("signature")
+    private String signature;
+  }
+
+  /**
+   * Initializes a {@link CryptoSigner} instance for the given Firebase app. Follows the protocol
+   * documented at go/firebase-admin-sign.
+   */
+  static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException {
+    GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp);
+
+    // If the SDK was initialized with a service account, use it to sign bytes.
+    if (credentials instanceof ServiceAccountCredentials) {
+      return new ServiceAccountCryptoSigner((ServiceAccountCredentials) credentials);
+    }
+
+    FirebaseOptions options = firebaseApp.getOptions();
+    HttpRequestFactory requestFactory = options.getHttpTransport().createRequestFactory(
+        new FirebaseRequestInitializer(firebaseApp));
+    JsonFactory jsonFactory = options.getJsonFactory();
+
+    // If the SDK was initialized with a service account email, use it with the IAM service
+    // to sign bytes.
+    String serviceAccountId = options.getServiceAccountId();
+    if (!Strings.isNullOrEmpty(serviceAccountId)) {
+      return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId);
+    }
+
+    // If the SDK was initialized with some other credential type that supports signing
+    // (e.g. GAE credentials), use it to sign bytes.
+    if (credentials instanceof ServiceAccountSigner) {
+      return new ServiceAccountCryptoSigner((ServiceAccountSigner) credentials);
+    }
+
+    // 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));
+    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);
+    } finally {
+      response.disconnect();
+    }
+  }
+}
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 f9d7233..72ef917 100644
--- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
+++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java
@@ -16,64 +16,56 @@
 
 package com.google.firebase.auth.internal;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.api.client.json.GenericJson;
 import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.gson.GsonFactory;
 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.common.base.Preconditions;
+import com.google.api.client.util.StringUtils;
 
+import com.google.common.base.Strings;
+import com.google.firebase.FirebaseApp;
 import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.security.PrivateKey;
 import java.util.Collection;
 import java.util.Map;
 
 /**
- * Provides helper methods to simplify the creation of FirebaseCustomAuthTokens.
+ * Provides helper methods to simplify the creation of Firebase custom auth tokens.
  *
  * <p>This class is designed to hide underlying implementation details from a Firebase developer.
  */
 public class FirebaseTokenFactory {
 
-  private static FirebaseTokenFactory instance;
-
-  private JsonFactory factory;
-  private Clock clock;
+  private final JsonFactory jsonFactory;
+  private final Clock clock;
+  private final CryptoSigner signer;
 
-  public FirebaseTokenFactory(JsonFactory factory, Clock clock) {
-    this.factory = factory;
-    this.clock = clock;
+  FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) {
+    this.jsonFactory = checkNotNull(jsonFactory);
+    this.clock = checkNotNull(clock);
+    this.signer = checkNotNull(signer);
   }
 
-  public static FirebaseTokenFactory getInstance() {
-    if (null == instance) {
-      instance = new FirebaseTokenFactory(new GsonFactory(), Clock.SYSTEM);
-    }
-
-    return instance;
-  }
-
-  public String createSignedCustomAuthTokenForUser(String uid, String issuer, PrivateKey privateKey)
-      throws GeneralSecurityException, IOException {
-    return createSignedCustomAuthTokenForUser(uid, null, issuer, privateKey);
+  String createSignedCustomAuthTokenForUser(String uid) throws IOException {
+    return createSignedCustomAuthTokenForUser(uid, null);
   }
 
   public String createSignedCustomAuthTokenForUser(
-      String uid, Map<String, Object> developerClaims, String issuer, PrivateKey privateKey)
-      throws GeneralSecurityException, IOException {
-    Preconditions.checkState(uid != null, "Uid must be provided.");
-    Preconditions.checkState(issuer != null && !"".equals(issuer), "Must provide an issuer.");
-    Preconditions.checkState(uid.length() <= 128, "Uid must be shorter than 128 characters.");
+      String uid, Map<String, Object> developerClaims) throws IOException {
+    checkArgument(!Strings.isNullOrEmpty(uid), "Uid must be provided.");
+    checkArgument(uid.length() <= 128, "Uid must be shorter than 128 characters.");
 
     JsonWebSignature.Header header = new JsonWebSignature.Header().setAlgorithm("RS256");
 
-    long issuedAt = clock.currentTimeMillis() / 1000;
+    final long issuedAt = clock.currentTimeMillis() / 1000;
     FirebaseCustomAuthToken.Payload payload =
         new FirebaseCustomAuthToken.Payload()
             .setUid(uid)
-            .setIssuer(issuer)
-            .setSubject(issuer)
+            .setIssuer(signer.getAccount())
+            .setSubject(signer.getAccount())
             .setAudience(FirebaseCustomAuthToken.FIREBASE_AUDIENCE)
             .setIssuedAtTimeSeconds(issuedAt)
             .setExpirationTimeSeconds(issuedAt + FirebaseCustomAuthToken.TOKEN_DURATION_SECONDS);
@@ -90,7 +82,24 @@ public class FirebaseTokenFactory {
       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;
+    byte[] contentBytes = StringUtils.getBytesUtf8(content);
+    String signature = Base64.encodeBase64URLSafeString(signer.sign(contentBytes));
+    return content + "." + signature;
+  }
 
-    return JsonWebSignature.signUsingRsaSha256(privateKey, factory, header, payload);
+  public static FirebaseTokenFactory fromApp(
+      FirebaseApp firebaseApp, Clock clock) throws IOException {
+    return new FirebaseTokenFactory(
+        firebaseApp.getOptions().getJsonFactory(),
+        clock,
+        CryptoSigners.getCryptoSigner(firebaseApp));
   }
 }


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
index 99171d8..54c2259 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -35,11 +35,16 @@ import com.google.api.client.json.JsonObjectParser;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.ApiFutureCallback;
 import com.google.api.core.ApiFutures;
+import com.google.auth.ServiceAccountSigner;
+import com.google.auth.oauth2.AccessToken;
+import com.google.auth.oauth2.GoogleCredentials;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.io.BaseEncoding;
 import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.ImplFirebaseTrampolines;
 import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.hash.Scrypt;
@@ -364,6 +369,31 @@ public class FirebaseAuthIT {
     assertEquals("user1", decoded.getUid());
   }
 
+  @Test
+  public void testCustomTokenWithIAM() throws Exception {
+    FirebaseApp masterApp = IntegrationTestUtils.ensureDefaultApp();
+    GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(masterApp);
+    AccessToken token = credentials.getAccessToken();
+    if (token == null) {
+      token = credentials.refreshAccessToken();
+    }
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(GoogleCredentials.of(token))
+        .setServiceAccountId(((ServiceAccountSigner) credentials).getAccount())
+        .setProjectId(IntegrationTestUtils.getProjectId())
+        .build();
+    FirebaseApp customApp = FirebaseApp.initializeApp(options, "tempApp");
+    try {
+      FirebaseAuth auth = FirebaseAuth.getInstance(customApp);
+      String customToken = auth.createCustomTokenAsync("user1").get();
+      String idToken = signInWithCustomToken(customToken);
+      FirebaseToken decoded = auth.verifyIdTokenAsync(idToken).get();
+      assertEquals("user1", decoded.getUid());
+    } finally {
+      customApp.delete();
+    }
+  }
+
   @Test
   public void testVerifyIdToken() throws Exception {
     String customToken = auth.createCustomTokenAsync("user2").get();
@@ -541,5 +571,4 @@ public class FirebaseAuthIT {
       assertEquals("uid-already-exists", ((FirebaseAuthException) e.getCause()).getErrorCode());
     }
   }
-
 }
diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
index bb430d2..1a4ccd4 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthTest.java
@@ -301,10 +301,13 @@ public class FirebaseAuthTest {
     try {
       FirebaseAuth.getInstance(app).createCustomTokenAsync("foo").get();
       fail("Expected exception.");
-    } catch (IllegalArgumentException expected) {
+    } catch (IllegalStateException expected) {
       Assert.assertEquals(
-          "Must initialize FirebaseApp with a service account credential to call "
-              + "createCustomToken()",
+          "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK with "
+              + "service account credentials or specify a service account ID with "
+              + "iam.serviceAccounts.signBlob permission. Please refer to "
+              + "https://firebase.google.com/docs/auth/admin/create-custom-tokens for more details "
+              + "on creating custom tokens.",
           expected.getMessage());
     }
   }
diff --git a/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java b/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java
new file mode 100644
index 0000000..bc98add
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/internal/CryptoSignersTest.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2018 Google Inc.
+ *
+ * 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.auth.internal;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+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.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.auth.ServiceAccountSigner;
+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.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+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 {
+    ServiceAccountCredentials credentials = ServiceAccountCredentials.fromStream(
+        ServiceAccount.EDITOR.asStream());
+    byte[] expected = credentials.sign("foo".getBytes());
+    CryptoSigner signer = new CryptoSigners.ServiceAccountCryptoSigner(credentials);
+    byte[] data = signer.sign("foo".getBytes());
+    assertArrayEquals(expected, data);
+  }
+
+  @Test
+  public void testInvalidServiceAccountCryptoSigner() {
+    try {
+      new CryptoSigners.ServiceAccountCryptoSigner(null);
+      fail("No error thrown for null service account signer");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testIAMCryptoSigner() throws IOException {
+    String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
+    String response = Utils.getDefaultJsonFactory().toString(
+        ImmutableMap.of("signature", signature));
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(new MockLowLevelHttpResponse().setContent(response))
+        .build();
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    CryptoSigners.IAMCryptoSigner signer = new CryptoSigners.IAMCryptoSigner(
+        transport.createRequestFactory(),
+        Utils.getDefaultJsonFactory(),
+        "test-service-account@iam.gserviceaccount.com");
+    signer.setInterceptor(interceptor);
+
+    byte[] data = signer.sign("foo".getBytes());
+    assertArrayEquals("signed-bytes".getBytes(), data);
+    final String url = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/"
+        + "test-service-account@iam.gserviceaccount.com:signBlob";
+    assertEquals(url, interceptor.getResponse().getRequest().getUrl().toString());
+  }
+
+  @Test
+  public void testInvalidIAMCryptoSigner() {
+    try {
+      new CryptoSigners.IAMCryptoSigner(null, Utils.getDefaultJsonFactory(), "test");
+      fail("No error thrown for null request factory");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+
+    MockHttpTransport transport = new MockHttpTransport();
+    try {
+      new CryptoSigners.IAMCryptoSigner(transport.createRequestFactory(), null, "test");
+      fail("No error thrown for null json factory");
+    } catch (NullPointerException expected) {
+      // expected
+    }
+
+    try {
+      new CryptoSigners.IAMCryptoSigner(transport.createRequestFactory(),
+          Utils.getDefaultJsonFactory(), null);
+      fail("No error thrown for null service account");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new CryptoSigners.IAMCryptoSigner(transport.createRequestFactory(),
+          Utils.getDefaultJsonFactory(), "");
+      fail("No error thrown for empty service account");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testMetadataService() throws IOException {
+    String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
+    String response = Utils.getDefaultJsonFactory().toString(
+        ImmutableMap.of("signature", signature));
+    MockHttpTransport transport = new MultiRequestMockHttpTransport(
+        ImmutableList.of(
+            new MockLowLevelHttpResponse().setContent("metadata-server@iam.gserviceaccount.com"),
+            new MockLowLevelHttpResponse().setContent(response)));
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("test-token"))
+        .setHttpTransport(transport)
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+    CryptoSigner signer = CryptoSigners.getCryptoSigner(app);
+
+    assertTrue(signer instanceof CryptoSigners.IAMCryptoSigner);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    ((CryptoSigners.IAMCryptoSigner) signer).setInterceptor(interceptor);
+
+    byte[] data = signer.sign("foo".getBytes());
+    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());
+  }
+
+  @Test
+  public void testExplicitServiceAccountEmail() throws IOException {
+    String signature = BaseEncoding.base64().encode("signed-bytes".getBytes());
+    String response = Utils.getDefaultJsonFactory().toString(
+        ImmutableMap.of("signature", signature));
+
+    // Explicit service account should get precedence
+    MockHttpTransport transport = new MultiRequestMockHttpTransport(
+        ImmutableList.of(
+            new MockLowLevelHttpResponse().setContent(response)));
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setServiceAccountId("explicit-service-account@iam.gserviceaccount.com")
+        .setCredentials(new MockGoogleCredentialsWithSigner("test-token"))
+        .setHttpTransport(transport)
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options);
+    CryptoSigner signer = CryptoSigners.getCryptoSigner(app);
+    assertTrue(signer instanceof CryptoSigners.IAMCryptoSigner);
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    ((CryptoSigners.IAMCryptoSigner) signer).setInterceptor(interceptor);
+
+    byte[] data = signer.sign("foo".getBytes());
+    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());
+  }
+
+  @Test
+  public void testCredentialsWithSigner() throws IOException {
+    // Should fall back to signing-enabled credential
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentialsWithSigner("test-token"))
+        .build();
+    FirebaseApp app = FirebaseApp.initializeApp(options, "customApp");
+    CryptoSigner signer = CryptoSigners.getCryptoSigner(app);
+    assertTrue(signer instanceof CryptoSigners.ServiceAccountCryptoSigner);
+    assertEquals("credential-signer@iam.gserviceaccount.com", signer.getAccount());
+    byte[] data = signer.sign("foo".getBytes());
+    assertArrayEquals("local-signed-bytes".getBytes(), data);
+  }
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  private static class MockGoogleCredentialsWithSigner extends MockGoogleCredentials implements
+      ServiceAccountSigner {
+
+    MockGoogleCredentialsWithSigner(String tokenValue) {
+      super(tokenValue);
+    }
+
+    @Override
+    public String getAccount() {
+      return "credential-signer@iam.gserviceaccount.com";
+    }
+
+    @Override
+    public byte[] sign(byte[] toSign) {
+      return "local-signed-bytes".getBytes();
+    }
+  }
+}
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 501ddf7..71b95be 100644
--- a/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java
+++ b/src/test/java/com/google/firebase/auth/internal/FirebaseTokenFactoryTest.java
@@ -16,6 +16,7 @@
 
 package com.google.firebase.auth.internal;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 
@@ -23,12 +24,16 @@ import com.google.api.client.json.GenericJson;
 import com.google.api.client.json.JsonFactory;
 import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.testing.http.FixedClock;
+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.testing.TestUtils;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
 import java.util.Map;
 import org.junit.Rule;
 import org.junit.Test;
@@ -55,19 +60,25 @@ public class FirebaseTokenFactoryTest {
 
     FixedClock clock = new FixedClock(2002L);
 
-    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock);
-
-    String jwt =
-        tokenFactory.createSignedCustomAuthTokenForUser(
-            USER_ID, EXTRA_CLAIMS, ISSUER, keys.getPrivate());
+    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock,
+        new TestCryptoSigner(keys.getPrivate()));
 
+    String jwt = tokenFactory.createSignedCustomAuthTokenForUser(USER_ID, EXTRA_CLAIMS);
     FirebaseCustomAuthToken signedJwt = FirebaseCustomAuthToken.parse(FACTORY, jwt);
     assertEquals("RS256", signedJwt.getHeader().getAlgorithm());
     assertEquals(ISSUER, signedJwt.getPayload().getIssuer());
     assertEquals(ISSUER, signedJwt.getPayload().getSubject());
     assertEquals(USER_ID, signedJwt.getPayload().getUid());
     assertEquals(2L, signedJwt.getPayload().getIssuedAtTimeSeconds().longValue());
+    assertTrue(TestUtils.verifySignature(signedJwt, ImmutableList.of(keys.getPublic())));
 
+    jwt = tokenFactory.createSignedCustomAuthTokenForUser(USER_ID);
+    signedJwt = FirebaseCustomAuthToken.parse(FACTORY, jwt);
+    assertEquals("RS256", signedJwt.getHeader().getAlgorithm());
+    assertEquals(ISSUER, signedJwt.getPayload().getIssuer());
+    assertEquals(ISSUER, signedJwt.getPayload().getSubject());
+    assertEquals(USER_ID, signedJwt.getPayload().getUid());
+    assertEquals(2L, signedJwt.getPayload().getIssuedAtTimeSeconds().longValue());
     assertTrue(TestUtils.verifySignature(signedJwt, ImmutableList.of(keys.getPublic())));
   }
 
@@ -79,10 +90,11 @@ public class FirebaseTokenFactoryTest {
 
     FixedClock clock = new FixedClock(2002L);
 
-    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock);
+    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock,
+        new TestCryptoSigner(keys.getPrivate()));
 
-    thrown.expect(IllegalStateException.class);
-    tokenFactory.createSignedCustomAuthTokenForUser(null, ISSUER, keys.getPrivate());
+    thrown.expect(IllegalArgumentException.class);
+    tokenFactory.createSignedCustomAuthTokenForUser(null);
   }
 
   @Test
@@ -93,40 +105,51 @@ public class FirebaseTokenFactoryTest {
 
     FixedClock clock = new FixedClock(2002L);
 
-    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock);
+    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock,
+        new TestCryptoSigner(keys.getPrivate()));
 
-    thrown.expect(IllegalStateException.class);
+    thrown.expect(IllegalArgumentException.class);
     tokenFactory.createSignedCustomAuthTokenForUser(
-        Strings.repeat("a", 129), ISSUER, keys.getPrivate());
+        Strings.repeat("a", 129));
   }
 
   @Test
-  public void failsWhenIssuerIsNull() throws Exception {
+  public void failsWhenExtraClaimsContainsReservedKey() throws Exception {
     KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
     keyGen.initialize(512);
     KeyPair keys = keyGen.genKeyPair();
 
     FixedClock clock = new FixedClock(2002L);
 
-    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock);
+    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock,
+        new TestCryptoSigner(keys.getPrivate()));
 
-    thrown.expect(IllegalStateException.class);
-    tokenFactory.createSignedCustomAuthTokenForUser(USER_ID, null, keys.getPrivate());
+    Map<String, Object> extraClaims = ImmutableMap.<String, Object>of("iss", "repeat issuer");
+    thrown.expect(IllegalArgumentException.class);
+    tokenFactory.createSignedCustomAuthTokenForUser(USER_ID, extraClaims);
   }
 
-  @Test
-  public void failsWhenExtraClaimsContainsReservedKey() throws Exception {
-    KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
-    keyGen.initialize(512);
-    KeyPair keys = keyGen.genKeyPair();
+  private static class TestCryptoSigner implements CryptoSigner {
 
-    FixedClock clock = new FixedClock(2002L);
+    private final PrivateKey privateKey;
 
-    FirebaseTokenFactory tokenFactory = new FirebaseTokenFactory(FACTORY, clock);
+    TestCryptoSigner(PrivateKey privateKey) {
+      this.privateKey = checkNotNull(privateKey);
+    }
 
-    Map<String, Object> extraClaims = ImmutableMap.<String, Object>of("iss", "repeat issuer");
-    thrown.expect(IllegalArgumentException.class);
-    tokenFactory.createSignedCustomAuthTokenForUser(
-        USER_ID, extraClaims, ISSUER, keys.getPrivate());
+    @Override
+    public byte[] sign(byte[] payload) throws IOException {
+      try {
+        return SecurityUtils.sign(SecurityUtils.getSha256WithRsaSignatureAlgorithm(),
+            privateKey, payload);
+      } catch (GeneralSecurityException e) {
+        throw new IOException(e);
+      }
+    }
+
+    @Override
+    public String getAccount() {
+      return ISSUER;
+    }
   }
 }
diff --git a/src/test/java/com/google/firebase/snippets/FirebaseAppSnippets.java b/src/test/java/com/google/firebase/snippets/FirebaseAppSnippets.java
index 3713164..f4b76e7 100644
--- a/src/test/java/com/google/firebase/snippets/FirebaseAppSnippets.java
+++ b/src/test/java/com/google/firebase/snippets/FirebaseAppSnippets.java
@@ -120,4 +120,13 @@ public class FirebaseAppSnippets {
     FirebaseDatabase otherDatabase = FirebaseDatabase.getInstance(otherApp);
     // [END access_services_nondefault]
   }
+
+  public void initializeWithServiceAccountId() {
+    // [START initialize_sdk_with_service_account_id]
+    FirebaseOptions options = new FirebaseOptions.Builder()
+        .setServiceAccountId("my-client-id@my-project-id.iam.gserviceaccount.com")
+        .build();
+    FirebaseApp.initializeApp(options);
+    // [END initialize_sdk_with_service_account_id]
+  }
 }