Skip to content

Commit 7b410b3

Browse files
authored
Revoke Refresh Tokens (#133)
* add revokeRefreshTokens, verifyIdToke (checkRevoked), and the tokensValidAfterTime property.
1 parent 9aed5b2 commit 7b410b3

File tree

8 files changed

+166
-3
lines changed

8 files changed

+166
-3
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Unreleased
22

3+
### Token revokaction
4+
- [added] The ['verifyIdToken(...)'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#verifyIdTokenAsync)
5+
method has an added signature that accepts a boolean `checkRevoked` parameter. When `true`, an
6+
additional check is performed to see whether the token has been revoked.
7+
- [added] A new method ['FirebaseAuth.revokeRefreshTokens(uid)'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/FirebaseAuth.html#revokeRefreshTokens)
8+
has been added to invalidate all tokens issued to a user before the current second.
9+
- [added] A new getter `getTokensValidAfterMillis` has been added to the
10+
['UserRecord'](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/auth/UserRecord),
11+
which denotes the time in epoch milliseconds before which tokens are not valid. This is truncated to 1000 milliseconds.
312
### Initialization
413

514
- [fixed] The [`FirebaseOptions.Builder.setStorageBucket()`](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/FirebaseOptions.Builder.html#setStorageBucket(java.lang.String))

src/main/java/com/google/firebase/auth/FirebaseAuth.java

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import com.google.firebase.internal.Nullable;
4141
import com.google.firebase.internal.TaskToApiFuture;
4242
import com.google.firebase.tasks.Task;
43-
43+
import java.util.Date;
4444
import java.util.Map;
4545
import java.util.concurrent.Callable;
4646
import java.util.concurrent.atomic.AtomicBoolean;
@@ -201,6 +201,10 @@ public ApiFuture<String> createCustomTokenAsync(
201201
* @deprecated Use {@link #verifyIdTokenAsync(String)}
202202
*/
203203
public Task<FirebaseToken> verifyIdToken(final String token) {
204+
return verifyIdToken(token, false);
205+
}
206+
207+
private Task<FirebaseToken> verifyIdToken(final String token, final boolean checkRevoked) {
204208
checkNotDestroyed();
205209
checkState(!Strings.isNullOrEmpty(projectId),
206210
"Must initialize FirebaseApp with a project ID to call verifyIdToken()");
@@ -217,12 +221,54 @@ public FirebaseToken call() throws Exception {
217221

218222
// This will throw a FirebaseAuthException with details on how the token is invalid.
219223
firebaseTokenVerifier.verifyTokenAndSignature(firebaseToken.getToken());
220-
224+
225+
if (checkRevoked) {
226+
String uid = firebaseToken.getUid();
227+
UserRecord user = userManager.getUserById(uid);
228+
long issuedAt = (long) firebaseToken.getClaims().get("iat");
229+
if (user.getTokensValidAfterTimestamp() > issuedAt * 1000) {
230+
throw new FirebaseAuthException(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR,
231+
"Firebase auth token revoked");
232+
}
233+
}
221234
return firebaseToken;
222235
}
223236
});
224237
}
225238

239+
private Task<Void> revokeRefreshTokens(String uid) {
240+
checkNotDestroyed();
241+
int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000);
242+
final UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds);
243+
return call(new Callable<Void>() {
244+
@Override
245+
public Void call() throws Exception {
246+
userManager.updateUser(request, jsonFactory);
247+
return null;
248+
}
249+
});
250+
}
251+
252+
/**
253+
* Revokes all refresh tokens for the specified user.
254+
*
255+
* <p>Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in
256+
* milliseconds since the epoch and truncated to 1 second accuracy. It is important that the
257+
* server on which this is called has its clock set correctly and synchronized.
258+
*
259+
* <p>While this will revoke all sessions for a specified user and disable any new ID tokens for
260+
* existing sessions from getting minted, existing ID tokens may remain active until their
261+
* natural expiration (one hour).
262+
* To verify that ID tokens are revoked, use `verifyIdToken(idToken, true)`.
263+
*
264+
* @param uid The user id for which tokens are revoked.
265+
* @return An {@code ApiFuture} which will complete successfully or if updating the user fails,
266+
* unsuccessfully with the failure Exception.
267+
*/
268+
public ApiFuture<Void> revokeRefreshTokensAsync(String uid) {
269+
return new TaskToApiFuture<>(revokeRefreshTokens(uid));
270+
}
271+
226272
/**
227273
* Parses and verifies a Firebase ID Token.
228274
*
@@ -238,13 +284,46 @@ public FirebaseToken call() throws Exception {
238284
* <p>If the token is valid, the returned Future will complete successfully and provide a
239285
* parsed version of the token from which the UID and other claims in the token can be inspected.
240286
* If the token is invalid, the future throws an exception indicating the failure.
287+
*
288+
* <p>This does not check whether a token has been revoked.
289+
* See `verifyIdTokenAsync(token, checkRevoked)` below.
241290
*
242291
* @param token A Firebase ID Token to verify and parse.
243292
* @return An {@code ApiFuture} which will complete successfully with the parsed token, or
244293
* unsuccessfully with the failure Exception.
245294
*/
246295
public ApiFuture<FirebaseToken> verifyIdTokenAsync(final String token) {
247-
return new TaskToApiFuture<>(verifyIdToken(token));
296+
return verifyIdTokenAsync(token, false);
297+
}
298+
299+
/**
300+
* Parses and verifies a Firebase ID Token and if requested, checks whether it was revoked.
301+
*
302+
* <p>A Firebase application can identify itself to a trusted backend server by sending its
303+
* Firebase ID Token (accessible via the getToken API in the Firebase Authentication client) with
304+
* its request.
305+
*
306+
* <p>The backend server can then use the verifyIdToken() method to verify the token is valid,
307+
* meaning: the token is properly signed, has not expired, and it was issued for the project
308+
* associated with this FirebaseAuth instance (which by default is extracted from your service
309+
* account)
310+
*
311+
* <p>If `checkRevoked` is true, additionally checks if the token has been revoked.
312+
*
313+
* <p>If the token is valid, and not revoked, the returned Future will complete successfully and
314+
* provide a parsed version of the token from which the UID and other claims in the token can be
315+
* inspected.
316+
* If the token is invalid or has been revoked, the future throws an exception indicating the
317+
* failure.
318+
*
319+
* @param token A Firebase ID Token to verify and parse.
320+
* @param checkRevoked A boolean denoting whether to check if the tokens were revoked.
321+
* @return An {@code ApiFuture} which will complete successfully with the parsed token, or
322+
* unsuccessfully with the failure Exception.
323+
*/
324+
public ApiFuture<FirebaseToken> verifyIdTokenAsync(final String token,
325+
final boolean checkRevoked) {
326+
return new TaskToApiFuture<>(verifyIdToken(token, checkRevoked));
248327
}
249328

250329
/**

src/main/java/com/google/firebase/auth/FirebaseUserManager.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class FirebaseUserManager {
5858

5959
static final String USER_NOT_FOUND_ERROR = "user-not-found";
6060
static final String INTERNAL_ERROR = "internal-error";
61+
static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked";
6162

6263
// Map of server-side error codes to SDK error codes.
6364
// SDK error codes defined at: https://firebase.google.com/docs/auth/admin/errors

src/main/java/com/google/firebase/auth/UserRecord.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static com.google.common.base.Preconditions.checkState;
2222

2323
import com.google.api.client.json.JsonFactory;
24+
import com.google.common.annotations.VisibleForTesting;
2425
import com.google.common.base.Strings;
2526
import com.google.common.collect.ImmutableList;
2627
import com.google.common.collect.ImmutableMap;
@@ -32,6 +33,7 @@
3233
import java.net.MalformedURLException;
3334
import java.net.URL;
3435
import java.util.ArrayList;
36+
import java.util.Date;
3537
import java.util.HashMap;
3638
import java.util.List;
3739
import java.util.Map;
@@ -57,6 +59,7 @@ public class UserRecord implements UserInfo {
5759
private final String photoUrl;
5860
private final boolean disabled;
5961
private final ProviderUserInfo[] providers;
62+
private final long tokensValidAfterTimestamp;
6063
private final UserMetadata userMetadata;
6164
private final Map<String, Object> customClaims;
6265

@@ -79,6 +82,7 @@ public class UserRecord implements UserInfo {
7982
this.providers[i] = new ProviderUserInfo(response.getProviders()[i]);
8083
}
8184
}
85+
this.tokensValidAfterTimestamp = response.getValidSince() * 1000;
8286
this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt());
8387
this.customClaims = parseCustomClaims(response.getCustomClaims(), jsonFactory);
8488
}
@@ -188,6 +192,17 @@ public UserInfo[] getProviderData() {
188192
return providers;
189193
}
190194

195+
/**
196+
* Returns a timestamp in milliseconds since epoch, truncated down to the closest second.
197+
* Tokens minted before this timestamp are considered invalid.
198+
*
199+
* @return Timestamp in milliseconds since the epoch. Tokens minted before this timestamp are
200+
* considered invalid.
201+
*/
202+
public long getTokensValidAfterTimestamp() {
203+
return tokensValidAfterTimestamp;
204+
}
205+
191206
/**
192207
* Returns additional metadata associated with this user.
193208
*
@@ -245,6 +260,11 @@ private static void checkCustomClaims(Map<String,Object> customClaims) {
245260
}
246261
}
247262

263+
private static void checkValidSince(long epochSeconds) {
264+
checkArgument(epochSeconds > 0, "validSince (seconds since epoch) must be greater than 0: "
265+
+ Long.toString(epochSeconds));
266+
}
267+
248268
private static String serializeCustomClaims(Map customClaims, JsonFactory jsonFactory) {
249269
checkNotNull(jsonFactory, "JsonFactory must not be null");
250270
if (customClaims == null || customClaims.isEmpty()) {
@@ -499,6 +519,12 @@ public UpdateRequest setCustomClaims(Map<String,Object> customClaims) {
499519
return this;
500520
}
501521

522+
UpdateRequest setValidSince(long epochSeconds) {
523+
checkValidSince(epochSeconds);
524+
properties.put("validSince", epochSeconds);
525+
return this;
526+
}
527+
502528
Map<String, Object> getProperties(JsonFactory jsonFactory) {
503529
Map<String, Object> copy = new HashMap<>(properties);
504530
List<String> remove = new ArrayList<>();

src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public static class User {
7373
@Key("lastLoginAt")
7474
private long lastLoginAt;
7575

76+
@Key("validSince")
77+
private long validSince;
78+
7679
@Key("customAttributes")
7780
private String customClaims;
7881

@@ -116,6 +119,10 @@ public long getLastLoginAt() {
116119
return lastLoginAt;
117120
}
118121

122+
public long getValidSince() {
123+
return validSince;
124+
}
125+
119126
public String getCustomClaims() {
120127
return customClaims;
121128
}

src/test/java/com/google/firebase/auth/FirebaseAuthIT.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,32 @@ public void testCustomToken() throws Exception {
357357
assertEquals("user1", decoded.getUid());
358358
}
359359

360+
@Test
361+
public void testVerifyIdToken() throws Exception {
362+
String customToken = auth.createCustomTokenAsync("user2").get();
363+
String idToken = signInWithCustomToken(customToken);
364+
FirebaseToken decoded = auth.verifyIdTokenAsync(idToken).get();
365+
assertEquals("user2", decoded.getUid());
366+
decoded = auth.verifyIdTokenAsync(idToken, true).get();
367+
assertEquals("user2", decoded.getUid());
368+
Thread.sleep(1000);
369+
auth.revokeRefreshTokensAsync("user2").get();
370+
decoded = auth.verifyIdTokenAsync(idToken, false).get();
371+
assertEquals("user2", decoded.getUid());
372+
try {
373+
decoded = auth.verifyIdTokenAsync(idToken, true).get();
374+
fail("expecting exception");
375+
} catch (ExecutionException e) {
376+
assertTrue(e.getCause() instanceof FirebaseAuthException);
377+
assertEquals(FirebaseUserManager.ID_TOKEN_REVOKED_ERROR,
378+
((FirebaseAuthException) e.getCause()).getErrorCode());
379+
}
380+
idToken = signInWithCustomToken(customToken);
381+
decoded = auth.verifyIdTokenAsync(idToken, true).get();
382+
assertEquals("user2", decoded.getUid());
383+
auth.deleteUserAsync("user2");
384+
}
385+
360386
@Test
361387
public void testCustomTokenWithClaims() throws Exception {
362388
Map<String, Object> devClaims = ImmutableMap.<String, Object>of(

src/test/java/com/google/firebase/auth/FirebaseUserManagerTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,7 @@ private void checkUserRecord(UserRecord userRecord) {
776776
assertEquals(2, userRecord.getProviderData().length);
777777
assertFalse(userRecord.isDisabled());
778778
assertTrue(userRecord.isEmailVerified());
779+
assertEquals(1494364393000L, userRecord.getTokensValidAfterTimestamp());
779780

780781
UserInfo provider = userRecord.getProviderData()[0];
781782
assertEquals("[email protected]", provider.getUid());

src/test/java/com/google/firebase/auth/UserRecordTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.google.api.client.json.JsonFactory;
1010
import com.google.common.collect.ImmutableList;
1111
import com.google.common.collect.ImmutableMap;
12+
import com.google.firebase.auth.UserRecord.UpdateRequest;
1213
import com.google.firebase.auth.internal.DownloadAccountResponse;
1314
import com.google.firebase.auth.internal.GetAccountInfoResponse;
1415

@@ -164,6 +165,7 @@ public void testExportedUserUidOnly() throws IOException {
164165
assertEquals(0, userRecord.getProviderData().length);
165166
assertNull(userRecord.getPasswordHash());
166167
assertNull(userRecord.getPasswordSalt());
168+
assertEquals(0L, userRecord.getTokensValidAfterTimestamp());
167169
}
168170

169171
@Test
@@ -193,4 +195,16 @@ private ExportedUserRecord parseExportedUser(String json) throws IOException {
193195
.parseAndClose(stream, Charset.defaultCharset(), DownloadAccountResponse.User.class);
194196
return new ExportedUserRecord(user, JSON_FACTORY);
195197
}
198+
199+
200+
@Test
201+
public void testInvalidVaidSince() {
202+
UpdateRequest update = new UpdateRequest("test");
203+
try {
204+
update.setValidSince(-1);
205+
fail("No error thrown for negative time");
206+
} catch (Exception ignore) {
207+
// expected
208+
}
209+
}
196210
}

0 commit comments

Comments
 (0)