Repository: firebase/firebase-tools (ts)

Issue:
auth_time doesn't match user's lastSignInTime (always matches iat instead)
### [REQUIRED] Describe your environment

- Operating System version: Windows 10 Home Version 20H2, Build 19042.1052
- Browser version: Chrome Version 91.0.4472.164 (64-bit)
- Firebase SDK version: firebase 9.0.0-beta.6
- Firebase Product: Firestore Emulator, Database Emulator, Storage Emulator

### [REQUIRED] Describe the problem

According to the documentation, auth_time should match the user's last sign in time, and iat should match the time this token was issued. This means auth_time should only be updated once, when the user signed in, and iat should change every hour when a new token is issued. This is not the case however. Despite what the documentation says, auth_time changes every hour at the same time as iat. This means auth_time always matches iat, and can't be used in security rules to check the time the user last signed in

https://firebase.google.com/docs/reference/admin/node/admin.auth.DecodedIdToken#auth_time

#### Steps to reproduce:
1. Create the following firestore.rules file
```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
    allow read, write: if request.auth != null && request.auth.token.auth_time < request.auth.token.iat;
  }
}
```
2. Use ```npm run serve``` to run the new rules in your local emulators
3. Sign in as a user on your client app.

#### Expected Result:

All reads and writes should fail for about the first hour. But after an hour has passed (and firebase has automatically issued the user a new token) the reads and writes should start succeeding

#### Actual Result:

All reads and writes fail forever. This is because auth_time always changes in lock-step with iat, despite the documentation saying that auth_time should remain stable after login and only iat should change every hour.

Pull request:
fix: emulator auth_time (#3608)
### Description

Fixes #3608 so that emulator sets auth_time the same way production does (auth_time should match lastLoginAt in seconds)

### Scenarios Tested

Added unit test to src/test/emulators/auth/misc.spec.ts called `"should populate auth_time to match lastLoginAt (in seconds since epoch)"`

This unit test...
1. Registers a user
2. Exchanges a refresh token for a new token
3. Checks that the new token's auth_time matches the user's lastLoginAt in seconds.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2036dc8b1..c40f7ff63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,2 +1,3 @@
+- Makes auth_time emulator behavior match production (auth_time is now set to user's last sign in time). (#3608)
 - Fixes Auth Emulator errors when importing many users. (#3577)
 - Fixes support for `--except` flag when used for deploying Hosting. (#3397)
diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts
index f50a752ea..f5f98964b 100644
--- a/src/emulator/auth/operations.ts
+++ b/src/emulator/auth/operations.ts
@@ -1726,7 +1726,12 @@ function generateJwt(
     // This field is only set for anonymous sign-in but not for any other
     // provider (such as email or Google) in production. Let's match that.
     provider_id: signInProvider === "anonymous" ? signInProvider : undefined,
-    auth_time: toUnixTimestamp(new Date()),
+    auth_time:
+      user.lastLoginAt != null
+        ? toUnixTimestamp(new Date(user.lastLoginAt))
+        : user.lastRefreshAt != null
+        ? toUnixTimestamp(new Date(user.lastRefreshAt))
+        : toUnixTimestamp(new Date()),
     user_id: user.localId,
     firebase: {
       identities,
@@ -2122,6 +2127,7 @@ export interface FirebaseJwtPayload {
   exp: number; // expiresAt (in seconds since epoch)
   iss: string; // issuer
   aud: string; // audience (=projectId)
+  auth_time: number; // lastLoginAt (in seconds since epoch)
   // ...and other fields that we don't care for now.
 
   // Firebase-specific fields:


Test patch:
diff --git a/src/test/emulators/auth/misc.spec.ts b/src/test/emulators/auth/misc.spec.ts
index db0a34502..c35078f65 100644
--- a/src/test/emulators/auth/misc.spec.ts
+++ b/src/test/emulators/auth/misc.spec.ts
@@ -1,7 +1,12 @@
 import { expect } from "chai";
 import { decode as decodeJwt, JwtHeader } from "jsonwebtoken";
 import { UserInfo } from "../../../emulator/auth/state";
-import { PROJECT_ID, signInWithPhoneNumber, TEST_PHONE_NUMBER } from "./helpers";
+import {
+  getAccountInfoByIdToken,
+  PROJECT_ID,
+  signInWithPhoneNumber,
+  TEST_PHONE_NUMBER,
+} from "./helpers";
 import { describeAuthEmulator } from "./setup";
 import {
   expectStatusCode,
@@ -16,7 +21,7 @@ import {
 } from "../../../emulator/auth/operations";
 import { toUnixTimestamp } from "../../../emulator/auth/utils";
 
-describeAuthEmulator("token refresh", ({ authApi }) => {
+describeAuthEmulator("token refresh", ({ authApi, getClock }) => {
   it("should exchange refresh token for new tokens", async () => {
     const { refreshToken, localId } = await registerAnonUser(authApi());
     await authApi()
@@ -39,6 +44,33 @@ describeAuthEmulator("token refresh", ({ authApi }) => {
       });
   });
 
+  it("should populate auth_time to match lastLoginAt (in seconds since epoch)", async () => {
+    const emailUser = { email: "alice@example.com", password: "notasecret" };
+    const { refreshToken } = await registerUser(authApi(), emailUser);
+
+    getClock().tick(2000); // Wait 2 seconds before refreshing.
+
+    const res = await authApi()
+      .post("/securetoken.googleapis.com/v1/token")
+      .type("form")
+      // snake_case parameters also work, per OAuth 2.0 spec.
+      .send({ refresh_token: refreshToken, grantType: "refresh_token" })
+      .query({ key: "fake-api-key" });
+
+    const idToken = res.body.id_token;
+    const user = await getAccountInfoByIdToken(authApi(), idToken);
+    expect(user.lastLoginAt).not.to.be.undefined;
+    const lastLoginAtSeconds = toUnixTimestamp(new Date(user.lastLoginAt!));
+    const decoded = decodeJwt(idToken, { complete: true }) as {
+      header: JwtHeader;
+      payload: FirebaseJwtPayload;
+    } | null;
+    expect(decoded, "JWT returned by emulator is invalid").not.to.be.null;
+    expect(decoded!.header.alg).to.eql("none");
+    // This should match login time, not token refresh time.
+    expect(decoded!.payload.auth_time).to.equal(lastLoginAtSeconds);
+  });
+
   it("should error if user is disabled", async () => {
     const { refreshToken, localId } = await registerAnonUser(authApi());
     await updateAccountByLocalId(authApi(), localId, { disableUser: true });