Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
85 changes: 82 additions & 3 deletions src/main/java/com/google/firebase/auth/FirebaseAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
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;
Expand Down Expand Up @@ -201,6 +201,10 @@ public ApiFuture<String> createCustomTokenAsync(
* @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()");
Expand All @@ -217,12 +221,54 @@ public FirebaseToken call() throws Exception {

// 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.
*
Expand All @@ -238,13 +284,46 @@ public FirebaseToken call() throws Exception {
* <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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/google/firebase/auth/UserRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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;
Expand All @@ -32,6 +33,7 @@
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;
Expand All @@ -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;

Expand All @@ -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);
}
Expand Down Expand Up @@ -188,6 +192,17 @@ public UserInfo[] getProviderData() {
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.
*
Expand Down Expand Up @@ -245,6 +260,11 @@ private static void checkCustomClaims(Map<String,Object> customClaims) {
}
}

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()) {
Expand Down Expand Up @@ -499,6 +519,12 @@ public UpdateRequest setCustomClaims(Map<String,Object> customClaims) {
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<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ public static class User {
@Key("lastLoginAt")
private long lastLoginAt;

@Key("validSince")
private long validSince;

@Key("customAttributes")
private String customClaims;

Expand Down Expand Up @@ -116,6 +119,10 @@ public long getLastLoginAt() {
return lastLoginAt;
}

public long getValidSince() {
return validSince;
}

public String getCustomClaims() {
return customClaims;
}
Expand Down
26 changes: 26 additions & 0 deletions src/test/java/com/google/firebase/auth/FirebaseAuthIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,32 @@ public void testCustomToken() throws Exception {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ private void checkUserRecord(UserRecord userRecord) {
assertEquals(2, userRecord.getProviderData().length);
assertFalse(userRecord.isDisabled());
assertTrue(userRecord.isEmailVerified());
assertEquals(1494364393000L, userRecord.getTokensValidAfterTimestamp());

UserInfo provider = userRecord.getProviderData()[0];
assertEquals("[email protected]", provider.getUid());
Expand Down
14 changes: 14 additions & 0 deletions src/test/java/com/google/firebase/auth/UserRecordTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file seem unnecessary (just imports)?

On the other hand, why don't we have unit tests for the change in UserRecord class?

import com.google.firebase.auth.internal.DownloadAccountResponse;
import com.google.firebase.auth.internal.GetAccountInfoResponse;

Expand Down Expand Up @@ -164,6 +165,7 @@ public void testExportedUserUidOnly() throws IOException {
assertEquals(0, userRecord.getProviderData().length);
assertNull(userRecord.getPasswordHash());
assertNull(userRecord.getPasswordSalt());
assertEquals(0L, userRecord.getTokensValidAfterTimestamp());
}

@Test
Expand Down Expand Up @@ -193,4 +195,16 @@ private ExportedUserRecord parseExportedUser(String json) throws IOException {
.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
}
}
}