Repository: firebase/firebase-admin-java
Pull request #133 (2018-01-31 18:54:13)

Revoke Refresh Tokens
initial PR, adding comments.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdd5b1f..e87544e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # Unreleased
 
+### Token revokaction
+- [added] The ['verifyIdToken(...)'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdTokenAsync)
+  method has an added signature that accepts a boolean `checkRevoked` parameter. When `true`, an 
+  additional check is performed to see whether the token has been revoked. 
+- [added] A new method ['FirebaseAuth.revokeRefreshTokens(uid)'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#revokeRefreshTokens)
+  has been added to invalidate all tokens issued to a user before the current second.
+- [added] A new getter `getTokensValidAfterMillis` has been added to the   
+  ['UserRecord'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord),
+  which denotes the time in epoch milliseconds before which tokens are not valid. This is truncated to 1000 milliseconds.
 ### Initialization
 
 - [fixed] The [`FirebaseOptions.Builder.setStorageBucket()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setStorageBucket(java.lang.String))
diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
index 5b07850..6278cdd 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java
@@ -40,7 +40,7 @@ import com.google.firebase.internal.FirebaseService;
 import com.google.firebase.internal.Nullable;
 import com.google.firebase.internal.TaskToApiFuture;
 import com.google.firebase.tasks.Task;
-
+import java.util.Date;
 import java.util.Map;
 import java.util.concurrent.Callable;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -201,6 +201,10 @@ public class FirebaseAuth {
    * @deprecated Use {@link #verifyIdTokenAsync(String)}
    */
   public Task<FirebaseToken> verifyIdToken(final String token) {
+    return verifyIdToken(token, false);
+  }
+ 
+  private Task<FirebaseToken> verifyIdToken(final String token, final boolean checkRevoked) {
     checkNotDestroyed();
     checkState(!Strings.isNullOrEmpty(projectId),
         "Must initialize FirebaseApp with a project ID to call verifyIdToken()");
@@ -217,12 +221,54 @@ public class FirebaseAuth {
 
         // This will throw a FirebaseAuthException with details on how the token is invalid.
         firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken());
-
+        
+        if (checkRevoked) {
+          String uid = firebaseToken.getUid();
+          UserRecord user = userManager.getUserById(uid);
+          long issuedAt = (long) firebaseToken.getClaims().get("iat");
+          if (user.getTokensValidAfterTimestamp() > issuedAt * 1000) {
+              throw new FirebaseAuthException(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR,
+                                              "Firebase auth token revoked");
+          }        
+        }       
         return firebaseToken;
       }
     });
   }
 
+  private Task<Void> revokeRefreshTokens(String uid) {
+    checkNotDestroyed();
+    int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000);
+    final UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds);
+    return call(new Callable<Void>() {
+      @Override
+      public Void call() throws Exception {
+        userManager.updateUser(request, jsonFactory);
+        return null;
+      }
+    });
+  }
+
+  /**
+   * Revokes all refresh tokens for the specified user.
+   * 
+   * <p>Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in
+   * milliseconds since the epoch and truncated to 1 second accuracy. It is important that the
+   * server on which this is called has its clock set correctly and synchronized.
+   * 
+   * <p>While this will revoke all sessions for a specified user and disable any new ID tokens for
+   * existing sessions from getting minted, existing ID tokens may remain active until their
+   * natural expiration (one hour). 
+   * To verify that ID tokens are revoked, use `verifyIdToken(idToken, true)`.
+   * 
+   * @param uid The user id for which tokens are revoked.
+   * @return An {@code ApiFuture} which will complete successfully or if updating the user fails,
+   *     unsuccessfully with the failure Exception.
+   */
+  public ApiFuture<Void> revokeRefreshTokensAsync(String uid) {
+    return new TaskToApiFuture<>(revokeRefreshTokens(uid));
+  }
+
   /**
    * Parses and verifies a Firebase ID Token.
    *
@@ -238,13 +284,46 @@ public class FirebaseAuth {
    * <p>If the token is valid, the returned Future will complete successfully and provide a
    * parsed version of the token from which the UID and other claims in the token can be inspected.
    * If the token is invalid, the future throws an exception indicating the failure.
+   * 
+   * <p>This does not check whether a token has been revoked. 
+   * See `verifyIdTokenAsync(token, checkRevoked)` below.
    *
    * @param token A Firebase ID Token to verify and parse.
    * @return An {@code ApiFuture} which will complete successfully with the parsed token, or
    *     unsuccessfully with the failure Exception.
    */
   public ApiFuture<FirebaseToken> verifyIdTokenAsync(final String token) {
-    return new TaskToApiFuture<>(verifyIdToken(token));
+    return verifyIdTokenAsync(token, false);
+  }
+
+  /**
+   * Parses and verifies a Firebase ID Token and if requested, checks whether it was revoked.
+   *
+   * <p>A Firebase application can identify itself to a trusted backend server by sending its
+   * Firebase ID Token (accessible via the getToken API in the Firebase Authentication client) with
+   * its request.
+   *
+   * <p>The backend server can then use the verifyIdToken() method to verify the token is valid,
+   * meaning: the token is properly signed, has not expired, and it was issued for the project
+   * associated with this FirebaseAuth instance (which by default is extracted from your service
+   * account)
+   * 
+   * <p>If `checkRevoked` is true, additionally checks if the token has been revoked.
+   * 
+   * <p>If the token is valid, and not revoked, the returned Future will complete successfully and
+   * provide a parsed version of the token from which the UID and other claims in the token can be
+   * inspected.
+   * If the token is invalid or has been revoked, the future throws an exception indicating the
+   * failure.
+   *
+   * @param token A Firebase ID Token to verify and parse.
+   * @param checkRevoked A boolean denoting whether to check if the tokens were revoked.
+   * @return An {@code ApiFuture} which will complete successfully with the parsed token, or
+   *     unsuccessfully with the failure Exception.
+   */
+  public ApiFuture<FirebaseToken> verifyIdTokenAsync(final String token,
+                                                     final boolean checkRevoked) {
+    return new TaskToApiFuture<>(verifyIdToken(token, checkRevoked));
   }
 
   /**
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index a1a78c7..5044764 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -58,6 +58,7 @@ class FirebaseUserManager {
 
   static final String USER_NOT_FOUND_ERROR = "user-not-found";
   static final String INTERNAL_ERROR = "internal-error";
+  static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked";
 
   // Map of server-side error codes to SDK error codes.
   // SDK error codes defined at: https://firebase.google.com/docs/auth/admin/errors
diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java
index e6d0927..cc27bd1 100644
--- a/src/main/java/com/google/firebase/auth/UserRecord.java
+++ b/src/main/java/com/google/firebase/auth/UserRecord.java
@@ -21,6 +21,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.api.client.json.JsonFactory;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -32,6 +33,7 @@ import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -57,6 +59,7 @@ public class UserRecord implements UserInfo {
   private final String photoUrl;
   private final boolean disabled;
   private final ProviderUserInfo[] providers;
+  private final long tokensValidAfterTimestamp;
   private final UserMetadata userMetadata;
   private final Map<String, Object> customClaims;
 
@@ -79,6 +82,7 @@ public class UserRecord implements UserInfo {
         this.providers[i] = new ProviderUserInfo(response.getProviders()[i]);
       }
     }
+    this.tokensValidAfterTimestamp = response.getValidSince() * 1000;
     this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt());
     this.customClaims = parseCustomClaims(response.getCustomClaims(), jsonFactory);
   }
@@ -188,6 +192,17 @@ public class UserRecord implements UserInfo {
     return providers;
   }
 
+  /**
+   * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. 
+   * Tokens minted before this timestamp are considered invalid.
+   * 
+   * @return Timestamp in milliseconds since the epoch. Tokens minted before this timestamp are
+   *     considered invalid.
+   */
+  public long getTokensValidAfterTimestamp() {
+    return tokensValidAfterTimestamp;
+  }
+
   /**
    * Returns additional metadata associated with this user.
    *
@@ -245,6 +260,11 @@ public class UserRecord implements UserInfo {
     }
   }
 
+  private static void checkValidSince(long epochSeconds) {
+    checkArgument(epochSeconds > 0, "validSince (seconds since epoch) must be greater than 0: "
+        + Long.toString(epochSeconds));
+  }
+
   private static String serializeCustomClaims(Map customClaims, JsonFactory jsonFactory) {
     checkNotNull(jsonFactory, "JsonFactory must not be null");
     if (customClaims == null || customClaims.isEmpty()) {
@@ -499,6 +519,12 @@ public class UserRecord implements UserInfo {
       return this;
     }
 
+    UpdateRequest setValidSince(long epochSeconds) {
+      checkValidSince(epochSeconds);
+      properties.put("validSince", epochSeconds);
+      return this;
+    }
+
     Map<String, Object> getProperties(JsonFactory jsonFactory) {
       Map<String, Object> copy = new HashMap<>(properties);
       List<String> remove = new ArrayList<>();
diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
index 9c53303..3d17c50 100644
--- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
+++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java
@@ -73,6 +73,9 @@ public final class GetAccountInfoResponse {
     @Key("lastLoginAt")
     private long lastLoginAt;
 
+    @Key("validSince")
+    private long validSince;
+
     @Key("customAttributes")
     private String customClaims;
 
@@ -116,6 +119,10 @@ public final class GetAccountInfoResponse {
       return lastLoginAt;
     }
 
+    public long getValidSince() {
+      return validSince;
+    }
+    
     public String getCustomClaims() {
       return customClaims;
     }


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 ea5e8b7..2995fe3 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
@@ -357,6 +357,32 @@ public class FirebaseAuthIT {
     assertEquals("user1", decoded.getUid());
   }
 
+  @Test
+  public void testVerifyIdToken() throws Exception {
+    String customToken = auth.createCustomTokenAsync("user2").get();
+    String idToken = signInWithCustomToken(customToken);
+    FirebaseToken decoded = auth.verifyIdTokenAsync(idToken).get();
+    assertEquals("user2", decoded.getUid());
+    decoded = auth.verifyIdTokenAsync(idToken, true).get();
+    assertEquals("user2", decoded.getUid());
+    Thread.sleep(1000);
+    auth.revokeRefreshTokensAsync("user2").get();
+    decoded = auth.verifyIdTokenAsync(idToken, false).get();
+    assertEquals("user2", decoded.getUid());
+    try {
+      decoded = auth.verifyIdTokenAsync(idToken, true).get();
+      fail("expecting exception");
+    } catch (ExecutionException e) {
+      assertTrue(e.getCause() instanceof FirebaseAuthException);
+      assertEquals(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR,
+                   ((FirebaseAuthException) e.getCause()).getErrorCode());
+    }
+    idToken = signInWithCustomToken(customToken);
+    decoded = auth.verifyIdTokenAsync(idToken, true).get();
+    assertEquals("user2", decoded.getUid());    
+    auth.deleteUserAsync("user2");
+  }
+
   @Test
   public void testCustomTokenWithClaims() throws Exception {
     Map<String, Object> devClaims = ImmutableMap.<String, Object>of(
diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
index 85a8d7f..f1e92ce 100644
--- a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
+++ b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java
@@ -776,6 +776,7 @@ public class FirebaseUserManagerTest {
     assertEquals(2, userRecord.getProviderData().length);
     assertFalse(userRecord.isDisabled());
     assertTrue(userRecord.isEmailVerified());
+    assertEquals(1494364393000L, userRecord.getTokensValidAfterTimestamp());
 
     UserInfo provider = userRecord.getProviderData()[0];
     assertEquals("testuser@example.com", provider.getUid());
diff --git a/src/test/java/com/google/firebase/auth/UserRecordTest.java b/src/test/java/com/google/firebase/auth/UserRecordTest.java
index ce187a4..22911d5 100644
--- a/src/test/java/com/google/firebase/auth/UserRecordTest.java
+++ b/src/test/java/com/google/firebase/auth/UserRecordTest.java
@@ -9,6 +9,7 @@ import com.google.api.client.googleapis.util.Utils;
 import com.google.api.client.json.JsonFactory;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.internal.DownloadAccountResponse;
 import com.google.firebase.auth.internal.GetAccountInfoResponse;
 
@@ -164,6 +165,7 @@ public class UserRecordTest {
     assertEquals(0, userRecord.getProviderData().length);
     assertNull(userRecord.getPasswordHash());
     assertNull(userRecord.getPasswordSalt());
+    assertEquals(0L, userRecord.getTokensValidAfterTimestamp());
   }
 
   @Test
@@ -193,4 +195,16 @@ public class UserRecordTest {
         .parseAndClose(stream, Charset.defaultCharset(), DownloadAccountResponse.User.class);
     return new ExportedUserRecord(user, JSON_FACTORY);
   }
+
+
+  @Test
+  public void testInvalidVaidSince() {
+    UpdateRequest update = new UpdateRequest("test");
+    try {
+      update.setValidSince(-1);
+      fail("No error thrown for negative time");
+    } catch (Exception ignore) {
+      // expected
+    }
+  }
 }