diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd5b1f2a..e87544e81 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 5b078504d..6278cddab 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.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 ApiFuture createCustomTokenAsync( * @deprecated Use {@link #verifyIdTokenAsync(String)} */ public Task verifyIdToken(final String token) { + return verifyIdToken(token, false); + } + + private Task 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 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 revokeRefreshTokens(String uid) { + checkNotDestroyed(); + int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); + final UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds); + return call(new Callable() { + @Override + public Void call() throws Exception { + userManager.updateUser(request, jsonFactory); + return null; + } + }); + } + + /** + * Revokes all refresh tokens for the specified user. + * + *

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. + * + *

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 revokeRefreshTokensAsync(String uid) { + return new TaskToApiFuture<>(revokeRefreshTokens(uid)); + } + /** * Parses and verifies a Firebase ID Token. * @@ -238,13 +284,46 @@ public FirebaseToken call() throws Exception { *

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. + * + *

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 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. + * + *

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. + * + *

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) + * + *

If `checkRevoked` is true, additionally checks if the token has been revoked. + * + *

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 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 a1a78c7a6..504476400 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 e6d09278e..cc27bd197 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.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.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 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 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. * @@ -245,6 +260,11 @@ private static void checkCustomClaims(Map 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()) { @@ -499,6 +519,12 @@ public UpdateRequest setCustomClaims(Map customClaims) { return this; } + UpdateRequest setValidSince(long epochSeconds) { + checkValidSince(epochSeconds); + properties.put("validSince", epochSeconds); + return this; + } + Map getProperties(JsonFactory jsonFactory) { Map copy = new HashMap<>(properties); List 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 9c5330359..3d17c50f6 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 static class User { @Key("lastLoginAt") private long lastLoginAt; + @Key("validSince") + private long validSince; + @Key("customAttributes") private String customClaims; @@ -116,6 +119,10 @@ public long getLastLoginAt() { return lastLoginAt; } + public long getValidSince() { + return validSince; + } + public String getCustomClaims() { return customClaims; } diff --git a/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java b/src/test/java/com/google/firebase/auth/FirebaseAuthIT.java index ea5e8b7c3..2995fe35a 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 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 devClaims = ImmutableMap.of( diff --git a/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java b/src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java index 85a8d7fed..f1e92ce7f 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 @@ 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("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 ce187a48b..22911d537 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.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 void testExportedUserUidOnly() throws IOException { assertEquals(0, userRecord.getProviderData().length); assertNull(userRecord.getPasswordHash()); assertNull(userRecord.getPasswordSalt()); + assertEquals(0L, userRecord.getTokensValidAfterTimestamp()); } @Test @@ -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 + } + } }