Repository: firebase/firebase-tools (ts)

Issue:
Listing users is not implemented in the Auth Emulator
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug_report.md
-->

I just found a feature which isn't implemented and I really need this. I thought I'll submit an issue for this.

**firebase-tools:**
v8.14.1

**Platform:**

MacOS 10.15.7

### [REQUIRED] Test case

```
import * as admin from "firebase-admin"
admin.initializeApp()s
admin.auth().listUsers(10)
```

### [REQUIRED] Steps to reproduce

Try to call `admin.auth().listUsers(10)` 

### [REQUIRED] Expected behavior

I'd like to be able to query all users at once (or rather, in batches of 1000) from Firebase Auth.

### [REQUIRED] Actual behavior

It crashes with

```
identitytoolkit.projects.accounts.batchGet is not implemented in the Auth Emulator:
```

```
An internal error has occurred. Raw server response: "{"error":{"code":501,"message":"identitytoolkit.projects.accounts.batchGet is not implemented in the Auth Emulator.","errors":[{"message":"identitytoolkit.projects.accounts.batchGet is not implemented in the Auth Emulator.","reason":"unimplemented"}],"status":"NOT_IMPLEMENTED"}}"
```


Pull request description:
Implement batchGet in Auth Emulator.
<!--

Thank you for contributing to the Firebase community! Please fill out the form below.

Run the linter and test suite
==============================
Run `npm test` to make sure your changes compile properly and the tests all pass on your local machine. We've hooked up this repo with continuous integration to double check those things for you.

-->

### Description

Now you can use `batchGet`.

### Scenarios Tested

See tests added.

### Sample Commands

N/A

Future consideration: It may be useful to allow the CLI to export accounts from the Auth Emulator too, like `FIREBASE_AUTH_EMULATOR_HOST=localhost:9099 firebase auth:export`. That should export from the emulator instead of production.

Fixes #2751.

Patch:
diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts
index 97c20c9f8..a6be154d1 100644
--- a/src/emulator/auth/operations.ts
+++ b/src/emulator/auth/operations.ts
@@ -64,6 +64,7 @@ export const authOperations: AuthOps = {
         query: queryAccounts,
         sendOobCode,
         update: setAccountInfo,
+        batchGet,
       },
     },
   },
@@ -229,6 +230,33 @@ function lookup(
   };
 }
 
+function batchGet(
+  state: ProjectState,
+  reqBody: unknown,
+  ctx: ExegesisContext
+): Schemas["GoogleCloudIdentitytoolkitV1DownloadAccountResponse"] {
+  const limit = Math.min(Math.floor(ctx.params.query.maxResults) || 20, 1000);
+  assert(limit >= 0, "((Auth Emulator: maxResults must not be negative.))");
+
+  const users = state.queryUsers(
+    {},
+    { sortByField: "localId", order: "ASC", startToken: ctx.params.query.nextPageToken }
+  );
+  let newPageToken: string | undefined = undefined;
+  if (users.length > limit) {
+    users.length = limit;
+    if (users.length) {
+      newPageToken = users[users.length - 1].localId;
+    }
+  }
+
+  return {
+    kind: "identitytoolkit#DownloadAccountResponse",
+    users,
+    nextPageToken: newPageToken,
+  };
+}
+
 function createAuthUri(
   state: ProjectState,
   reqBody: Schemas["GoogleCloudIdentitytoolkitV1CreateAuthUriRequest"]
@@ -1278,7 +1306,7 @@ function parseIdToken(
   // TODO: Check JWT expiration here.
   const user = state.getUserByLocalId(decoded.payload.user_id);
   assert(user, "USER_NOT_FOUND");
-  assert(!user.validSince || decoded.payload.iat >= parseInt(user.validSince), "TOKEN_EXPIRED");
+  assert(!user.validSince || decoded.payload.iat >= Number(user.validSince), "TOKEN_EXPIRED");
   assert(!user.disabled, "USER_DISABLED");
 
   const signInProvider = decoded.payload.firebase.sign_in_provider;
diff --git a/src/emulator/auth/server.ts b/src/emulator/auth/server.ts
index 370495c57..a81541405 100644
--- a/src/emulator/auth/server.ts
+++ b/src/emulator/auth/server.ts
@@ -30,6 +30,8 @@ import {
 import { logError } from "./utils";
 import { camelCase } from "lodash";
 import { registerHandlers } from "./handlers";
+import bodyParser = require("body-parser");
+import { URLSearchParams } from "url";
 const apiSpec = apiSpecUntyped as OpenAPIObject;
 
 const API_SPEC_PATH = "/emulator/openapi.json";
@@ -380,15 +382,36 @@ function registerLegacyRoutes(app: express.Express): void {
     throw new NotImplementedError(`signOutUser is not implemented in the Auth Emulator.`);
   });
 
-  // TODO: Need to rewrite paths and put project ID into the URL.
+  // Rewrites that require parsing targetProjectId from request body, e.g.
   // /downloadAccount => /v1/projects/{target_project_id}/accounts:batchGet
-  // /uploadAccount => /v1/projects/{target_project_id}/accounts:batchCreate
-  app.post(`${relyingPartyPrefix}downloadAccount`, () => {
-    throw new NotImplementedError(`downloadAccount is not implemented in the Auth Emulator.`);
-  });
-  app.post(`${relyingPartyPrefix}uploadAccount`, () => {
-    throw new NotImplementedError(`uploadAccount is not implemented in the Auth Emulator.`);
-  });
+  for (const [oldPath, newMethod, newPath] of [
+    ["downloadAccount", "GET", "accounts:batchGet"],
+    ["uploadAccount", "POST", "accounts:batchCreate"],
+  ]) {
+    app.post(`${relyingPartyPrefix}${oldPath}`, bodyParser.json(), (req, res, next) => {
+      req.body = convertKeysToCamelCase(req.body || {});
+      const targetProjectId = req.body.targetProjectId;
+      if (!targetProjectId) {
+        // Matching production behavior when targetProjectId is unspecified.
+        return next(new BadRequestError("INSUFFICIENT_PERMISSION"));
+      }
+
+      delete req.body.targetProjectId;
+      req.method = newMethod;
+      let qs = req.url.split("?", 2)[1] || "";
+      if (newMethod === "GET") {
+        Object.assign(req.query, req.body);
+
+        // Update the URL to match query since exegeisis does its own parsing.
+        const bodyAsQuery = new URLSearchParams(req.body).toString();
+        qs = qs ? `${qs}&${bodyAsQuery}` : bodyAsQuery;
+        delete req.body;
+        delete req.headers["content-type"];
+      }
+      req.url = `${v1Prefix}projects/${encodeURIComponent(targetProjectId)}/${newPath}?${qs}`;
+      next();
+    });
+  }
 }
 
 function toExegesisController(
diff --git a/src/emulator/auth/state.ts b/src/emulator/auth/state.ts
index d06d5a67f..b88bc2270 100644
--- a/src/emulator/auth/state.ts
+++ b/src/emulator/auth/state.ts
@@ -401,18 +401,21 @@ export class ProjectState {
     filter: {
       /* no filter supported yet */
     },
-    sort: {
+    options: {
       order: "ASC" | "DESC";
       sortByField: "localId";
+      startToken?: string;
     }
   ): UserInfo[] {
     const users = [];
     for (const user of this.users.values()) {
-      /* TODO */ filter;
-      users.push(user);
+      if (!options.startToken || user.localId > options.startToken) {
+        /* TODO */ filter;
+        users.push(user);
+      }
     }
     users.sort((a, b) => {
-      if (sort.sortByField === "localId") {
+      if (options.sortByField === "localId") {
         if (a.localId < b.localId) {
           return -1;
         } else if (a.localId > b.localId) {
@@ -421,7 +424,7 @@ export class ProjectState {
       }
       return 0;
     });
-    return sort.order === "DESC" ? users.reverse() : users;
+    return options.order === "DESC" ? users.reverse() : users;
   }
 
   createTemporaryProof(phoneNumber: string): TemporaryProofRecord {


Test patch:
diff --git a/src/test/emulators/auth/batch.spec.ts b/src/test/emulators/auth/batch.spec.ts
new file mode 100644
index 000000000..7a5edcdfb
--- /dev/null
+++ b/src/test/emulators/auth/batch.spec.ts
@@ -0,0 +1,100 @@
+import { expect } from "chai";
+import { describeAuthEmulator } from "./setup";
+import { expectStatusCode, PROJECT_ID, registerAnonUser, registerUser } from "./helpers";
+import { UserInfo } from "../../../emulator/auth/state";
+
+describeAuthEmulator("accounts:batchGet", ({ authApi }) => {
+  it("should allow listing all accounts", async () => {
+    const user1 = await registerAnonUser(authApi());
+    const user2 = await registerUser(authApi(), { email: "foo@example.com", password: "foobar" });
+
+    await authApi()
+      .get(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:batchGet`)
+      .set("Authorization", "Bearer owner")
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.users).to.have.length(2);
+        expect(res.body.users.map((user: UserInfo) => user.localId)).to.have.members([
+          user1.localId,
+          user2.localId,
+        ]);
+
+        // No more accounts after this, so no page token returned.
+        expect(res.body).not.to.have.property("nextPageToken");
+      });
+  });
+
+  it("should allow listing all accounts using legacy endpoint", async () => {
+    const user1 = await registerAnonUser(authApi());
+    const user2 = await registerUser(authApi(), { email: "foo@example.com", password: "foobar" });
+
+    await authApi()
+      .post("/www.googleapis.com/identitytoolkit/v3/relyingparty/downloadAccount")
+      .set("Authorization", "Bearer owner")
+      .send({ targetProjectId: PROJECT_ID })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.users).to.have.length(2);
+        expect(res.body.users.map((user: UserInfo) => user.localId)).to.have.members([
+          user1.localId,
+          user2.localId,
+        ]);
+
+        // No more accounts after this, so no page token returned.
+        expect(res.body).not.to.have.property("nextPageToken");
+      });
+  });
+
+  it("should allow specifying maxResults and pagination", async () => {
+    const user1 = await registerAnonUser(authApi());
+    const user2 = await registerUser(authApi(), { email: "foo@example.com", password: "foobar" });
+
+    const nextPageToken = await authApi()
+      .get(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:batchGet`)
+      .query({ maxResults: 1 }) // Give me the first user only.
+      .set("Authorization", "Bearer owner")
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.users).to.have.length(1);
+        expect(res.body.users[0].localId).to.equal(
+          user1.localId < user2.localId ? user1.localId : user2.localId
+        );
+
+        expect(res.body)
+          .to.have.property("nextPageToken")
+          .which.is.a("string");
+        return res.body.nextPageToken as string;
+      });
+
+    await authApi()
+      .get(`/identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:batchGet`)
+      .query({ nextPageToken })
+      .set("Authorization", "Bearer owner")
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.users).to.have.length(1);
+        expect(res.body.users[0].localId).to.equal(
+          user1.localId > user2.localId ? user1.localId : user2.localId
+        );
+
+        // No more accounts after this, so no page token returned.
+        expect(res.body).not.to.have.property("nextPageToken");
+      });
+
+    // Test the legacy API too to make sure nextPageToken is recognized.
+    await authApi()
+      .post("/www.googleapis.com/identitytoolkit/v3/relyingparty/downloadAccount")
+      .set("Authorization", "Bearer owner")
+      .send({ targetProjectId: PROJECT_ID, nextPageToken })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.users).to.have.length(1);
+        expect(res.body.users[0].localId).to.equal(
+          user1.localId > user2.localId ? user1.localId : user2.localId
+        );
+
+        // No more accounts after this, so no page token returned.
+        expect(res.body).not.to.have.property("nextPageToken");
+      });
+  });
+});