Testing Firestore Rules from Java without @firebase/rules-unit-testing

We wanted to test Firestore Security Rules in Java, without installing @firebase/rules-unit-testing

Testing Firestore Rules from Java without @firebase/rules-unit-testing

Hey, how about something new: we wanted to use Firestore Security Rules to access a subset of documents from Firestore and enable Nice Shiny Things, like client-side updates.

According to the Firestore documentation, the only way to test these rules is by installing @firebase/rules-unit-testing, which does not sit well with us. Since we test front-end and back-end logic separately and simultaneously, we don't have the Firestore Emulator when testing (TypeScript) front-end logic, only for the back-end. However, there was no documented way of doing that in Java without using the npm module.

Well, there is now.

About Firestore / Firebase Security Rules

Accessing your database from the client side would be irresponsible without setting up security properly. The Firestore Security rules documentation leaves a lot unsaid, so we thought about complementing it with what we learned by experimentation:

  • firestore.rules does not follow JavaScript syntax. The syntax is similar enough to drive confusion, but CEL is a different language. The full language reference is probably not that interesting, but the Firestore part is indispensable to understand what you are building.
  • There is a command for debugging that prints whatever is passed as an argument and returns the same object. The second line in this example does the same as the previous one, but additionally prints the content of row.data to stdout in the emulator:
// no clue what is going on here
return request.auth.uid in row.data.userIds;

// this is the same command, but printing the content of row.data
return request.auth.uid in debug(row.data).userIds;
  • This is not JavaScript. Worth saying it again. Look at the following line of code:
    match /foo/{id} {
      allow read: if (resource.data.status == 'READABLE');
      allow write: if false;
    }

All good, right? Not really. If resource.data does not have a status field, the condition fails with property is undefined on object – access is rejected as intended, but your logs will not spark joy.

Now, why should this line fail? All my foo instances have a status attribute! Well, I'm glad you asked:

  • Queries will go through the same rules used for fetching entities by primary key, but using the WHERE clause as attributes instead. The documentation is vague about this, so let's see an example instead: if you launch a query for foo instances and don't include a status attribute in your query, its value is undefined and your rule will fail. The fix is simple, though:
if ("status" in resource.data && resource.data.status == 'READABLE');

This clause will also work for queries that omit the status field (like, retrieve all foo instances).

  • Setting your foundations: create your Security Rules as soon as possible. In our team, we tend to build the back-end API first, then the front-end (*sigh* front-end work takes much longer). As such, we postponed the Firestore client-side access to Stage 2. The problem is that the syntax used in Firestore Rules is limited, and we ended up having to rebuild our entire security infrastructure to make it fit. Kids, things that affect the whole application should be done first: logs, i18n, security. These are not words that can share a sentence with "Agile" in a positive way.

Bored enough? Good. Let's go to the part that you came here for:

Mocking credentials from Java tests

Let's say that we want to build such a test:

public class SecurityRulesTest {

  private void assertAllowed(Query query) throws Exception {
    query.get().get();
  }

  @Test
  public void testFoos() throws Exception {
    var account = new Account().setUid("123");
    var firestore = BrowserFirestoreClientBuilder.create(account);
    assertAllowed(firestore.collection("foo").orderBy("id"));
  }
  
}

In this example, BrowserFirestoreClientBuilder will create a firestore client that behaves like a browser connection, providing fake user credentials to the Firestore Emulator. This works because the emulator accepts any user passed in the http request.

The first step is to create a Firestore client that can inject the credentials as an additional HTTP Header:

public class BrowserFirestoreClientBuilder {

  public static Firestore create(Account account) {
    return FirestoreOptions
      .getDefaultInstance()
      .toBuilder()
      .setProjectId(Config.GOOGLE_CLOUD_PROJECT)
      .setHost("127.0.0.1:9000")
      .setChannelProvider(
        InstantiatingGrpcChannelProvider
          .newBuilder()
          .setEndpoint(Config.FIRESTORE_EMULATOR_URL)
          .setChannelConfigurator(ManagedChannelBuilder::usePlaintext)
          .build()
      )
      .setCredentialsProvider(
        FixedCredentialsProvider.create(
          account == null ? new AnonymousCredentials() : new FakeCredentials(account)
        )
      )
      .build()
      .getService();
  }

}

Notice that if the account is null, we use an empty implementation called AnonymousCredentials, which behaves like a browser where the user has not logged in (hey, you want to test those too):

  class AnonymousCredentials extends Credentials {

    private final Map<String, List<String>> HEADERS = Map.of();

    @Override
    public String getAuthenticationType() {
      throw new IllegalArgumentException("Not supported");
    }

    @Override
    public Map<String, List<String>> getRequestMetadata(URI uri) {
      return HEADERS;
    }

    @Override
    public boolean hasRequestMetadata() {
      return true;
    }

    @Override
    public boolean hasRequestMetadataOnly() {
      return true;
    }

    @Override
    public void refresh() {}
  }

Now for the mock user credentials. The emulator expects the current user as a JSON Web Token passed as part of the http request. After taking a look at this TypeScript code and translating it to Java:

 class FakeCredentials extends Credentials {

    private final Map<String, List<String>> HEADERS;

    static String encodeBase64(String value) {
      return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8));
    }

    // adapted from @firebase/rules-unit-testing
    // https://github.com/firebase/firebase-js-sdk/blob/7b585e8e7bc6bdc36200681865ec11e91ca7c4a1/packages/rules-unit-testing/src/api/index.ts#L142
    public FakeCredentials(Account account) {
      try {
        // Unsecured JWTs use "none" as the algorithm.
        var header = Map.of("alg", "none", "kid", "fakekid", "type", "JWT");

        // Set all required fields to decent defaults
        var payload = new FirebaseIdToken(account.getId());
        if (account.isAdmin()) {
          payload.adminClaim = true;
        }

        // Unsecured JWTs use the empty string as a signature.
        var signature = "";
        var om = ObjectMapperSingleton.getInstance();
        var token =
          encodeBase64(om.writeValueAsString(header)) +
          "." +
          encodeBase64(om.writeValueAsString(payload)) +
          "." +
          signature;
        HEADERS = Map.of("Authorization", List.of("Bearer " + token));
      } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
      }
    }

    @Override
    public String getAuthenticationType() {
      throw new IllegalArgumentException("Not supported");
    }

    @Override
    public Map<String, List<String>> getRequestMetadata(URI uri) {
      return HEADERS;
    }

    @Override
    public boolean hasRequestMetadata() {
      return true;
    }

    @Override
    public boolean hasRequestMetadataOnly() {
      return true;
    }

    @Override
    public void refresh() {}
  }

  private static class FirebaseIdToken {

    public String iss;
    public String aud;
    public int iat;
    public int exp;
    public int auth_time;
    public String sub;
    public String user_id;
    public Map<String, Object> firebase;

    // custom claims
    public Boolean adminClaim;

    public FirebaseIdToken(String uid) {
      this.iss = "https://securetoken.google.com/" + Config.GOOGLE_CLOUD_PROJECT;
      this.aud = Config.GOOGLE_CLOUD_PROJECT;
      this.iat = 0;
      this.exp = iat + 3600;
      this.auth_time = iat;
      this.sub = uid;
      this.user_id = uid;
      this.firebase = Map.of("sign_in_provider", "custom", "identities", Map.of());
    }
  }
}

One last thing: did you see the adminClaim? You can add your own custom claims to FirebaseIdToken to check in firestore.rules:

if (
  "adminClaim" in request.auth.token && request.auth.token.adminClaim || 
  "status" in resource.data && resource.data.status == 'READABLE'
);

That's it! Let us know if this works for you (or just drop by to say hi) on Twitter.