Skip to content

Commit a85865c

Browse files
authored
Revoke refresh token (#123)
* adding revoke_refresh_tokens verify_id_token(check_revoked), and the tokens_valid_after_timestamp preoperty
1 parent ef397a0 commit a85865c

File tree

7 files changed

+174
-19
lines changed

7 files changed

+174
-19
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
# Unreleased
22
-
3+
### Token revokaction
4+
- [added] The ['verify_id_token(...)'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#verify_id_token)
5+
method now accepts an optional `check_revoked` parameter. When `True`, an
6+
additional check is performed to see whether the token has been revoked.
7+
- [added] A new method ['auth.revoke_refresh_tokens(uid)'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#revoke_refresh_tokens)
8+
has been added to invalidate all tokens issued to a user before the current second.
9+
- [added] A new property `tokens_valid_after_timestamp` has been added to the
10+
['UserRecord'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#userrecord),
11+
which denotes the time in epoch milliseconds before which tokens are not valid.
312

413
# v2.8.0
514

615
### Initialization
716

8-
- [added] The [`initialize_app()`](https://firebase.google.com/docs/reference/admin/node/admin#.initializeApp)
17+
- [added] The [`initialize_app()`](https://firebase.google.com/docs/reference/admin/python/firebase_admin#initialize_app)
918
method can now be invoked without any arguments. This initializes an app
1019
using Google Application Default Credentials, and other
1120
options loaded from the `FIREBASE_CONFIG` environment variable.

firebase_admin/_user_mgt.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,16 @@ def validate_photo_url(cls, photo_url):
113113
except Exception:
114114
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url))
115115

116+
@classmethod
117+
def validate_valid_since(cls, valid_since):
118+
# isinstance(True, int) is True hence the extra check
119+
if valid_since is None or isinstance(valid_since, bool) or not isinstance(valid_since, int):
120+
raise ValueError(
121+
'Invalid time string for: "{0}". Valid Since must be an int'.format(valid_since))
122+
if int(valid_since) <= 0:
123+
raise ValueError(
124+
'Invalid valid_since: must be a positive interger. {0}'.format(valid_since))
125+
116126
@classmethod
117127
def validate_disabled(cls, disabled):
118128
if not isinstance(disabled, bool):
@@ -184,6 +194,7 @@ class UserManager(object):
184194
'password' : _Validator.validate_password,
185195
'phoneNumber' : _Validator.validate_phone,
186196
'photoUrl' : _Validator.validate_photo_url,
197+
'validSince' : _Validator.validate_valid_since,
187198
}
188199

189200
_CREATE_USER_FIELDS = {
@@ -206,6 +217,7 @@ class UserManager(object):
206217
'password' : 'password',
207218
'disabled' : 'disableUser',
208219
'custom_claims' : 'customAttributes',
220+
'valid_since' : 'validSince',
209221
}
210222

211223
_REMOVABLE_FIELDS = {

firebase_admin/auth.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
_request = transport.requests.Request()
3737

3838
_AUTH_ATTRIBUTE = '_auth'
39+
_ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED'
3940

4041

4142
def _get_auth_service(app):
@@ -76,7 +77,7 @@ def create_custom_token(uid, developer_claims=None, app=None):
7677
return token_generator.create_custom_token(uid, developer_claims)
7778

7879

79-
def verify_id_token(id_token, app=None):
80+
def verify_id_token(id_token, app=None, check_revoked=False):
8081
"""Verifies the signature and data for the provided JWT.
8182
8283
Accepts a signed token string, verifies that it is current, and issued
@@ -85,17 +86,38 @@ def verify_id_token(id_token, app=None):
8586
Args:
8687
id_token: A string of the encoded JWT.
8788
app: An App instance (optional).
89+
check_revoked: Boolean, If true, checks whether the token has been revoked (optional).
8890
8991
Returns:
9092
dict: A dictionary of key-value pairs parsed from the decoded JWT.
9193
9294
Raises:
9395
ValueError: If the JWT was found to be invalid, or if the App was not
9496
initialized with a credentials.Certificate.
97+
AuthError: If check_revoked is requested and the token was revoked.
9598
"""
9699
token_generator = _get_auth_service(app).token_generator
97-
return token_generator.verify_id_token(id_token)
98-
100+
verified_claims = token_generator.verify_id_token(id_token)
101+
if check_revoked:
102+
user = get_user(verified_claims.get('uid'), app)
103+
if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp:
104+
raise AuthError(_ID_TOKEN_REVOKED, 'The Firebase ID token has been revoked.')
105+
return verified_claims
106+
107+
def revoke_refresh_tokens(uid, app=None):
108+
"""Revokes all refresh tokens for an existing user.
109+
110+
revoke_refresh_tokens updates the user's tokens_valid_after_timestamp to the current UTC
111+
in seconds since the epoch. It is important that the server on which this is called has its
112+
clock set correctly and synchronized.
113+
114+
While this revokes all sessions for a specified user and disables any new ID tokens for
115+
existing sessions from getting minted, existing ID tokens may remain active until their
116+
natural expiration (one hour). To verify that ID tokens are revoked, use
117+
`verify_id_token(idToken, check_revoked=True)`.
118+
"""
119+
user_manager = _get_auth_service(app).user_manager
120+
user_manager.update_user(uid, valid_since=int(time.time()))
99121

100122
def get_user(uid, app=None):
101123
"""Gets the user data corresponding to the specified user ID.
@@ -246,6 +268,8 @@ def update_user(uid, **kwargs):
246268
disabled: A boolean indicating whether or not the user account is disabled (optional).
247269
custom_claims: A dictionary or a JSON string contining the custom claims to be set on the
248270
user account (optional).
271+
valid_since: An integer signifying the seconds since the epoch. This field is set by
272+
`revoke_refresh_tokens` and it is discouraged to set this field directly.
249273
250274
Returns:
251275
UserRecord: An updated UserRecord instance for the user.
@@ -430,6 +454,21 @@ def disabled(self):
430454
"""
431455
return bool(self._data.get('disabled'))
432456

457+
@property
458+
def tokens_valid_after_timestamp(self):
459+
"""Returns the time, in milliseconds since the epoch, before which tokens are invalid.
460+
461+
Note: this is truncated to 1 second accuracy.
462+
463+
Returns:
464+
int: Timestamp in milliseconds since the epoch, truncated to the second.
465+
All tokens issued before that time are considered revoked.
466+
"""
467+
valid_since = self._data.get('validSince')
468+
if valid_since is not None:
469+
return 1000 * int(valid_since)
470+
return None
471+
433472
@property
434473
def user_metadata(self):
435474
"""Returns additional metadata associated with this user.
@@ -476,12 +515,22 @@ def __init__(self, data):
476515

477516
@property
478517
def creation_timestamp(self):
518+
""" Creation timestamp in milliseconds since the epoch.
519+
520+
Returns:
521+
integer: The user creation timestamp in milliseconds since the epoch.
522+
"""
479523
if 'createdAt' in self._data:
480524
return int(self._data['createdAt'])
481525
return None
482526

483527
@property
484528
def last_sign_in_timestamp(self):
529+
""" Last sign in timestamp in milliseconds since the epoch.
530+
531+
Returns:
532+
integer: The last sign in timestamp in milliseconds since the epoch.
533+
"""
485534
if 'lastLoginAt' in self._data:
486535
return int(self._data['lastLoginAt'])
487536
return None

integration/test_auth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Integration tests for firebase_admin.auth module."""
1616
import random
17+
import time
1718
import uuid
1819

1920
import pytest
@@ -238,3 +239,35 @@ def test_delete_user():
238239
with pytest.raises(auth.AuthError) as excinfo:
239240
auth.get_user(user.uid)
240241
assert excinfo.value.code == 'USER_NOT_FOUND_ERROR'
242+
243+
def test_revoke_refresh_tokens(new_user):
244+
user = auth.get_user(new_user.uid)
245+
old_valid_after = user.tokens_valid_after_timestamp
246+
time.sleep(1)
247+
auth.revoke_refresh_tokens(new_user.uid)
248+
user = auth.get_user(new_user.uid)
249+
new_valid_after = user.tokens_valid_after_timestamp
250+
assert new_valid_after > old_valid_after
251+
252+
def test_verify_id_token_revoked(new_user, api_key):
253+
custom_token = auth.create_custom_token(new_user.uid)
254+
id_token = _sign_in(custom_token, api_key)
255+
claims = auth.verify_id_token(id_token)
256+
assert claims['iat'] * 1000 >= new_user.tokens_valid_after_timestamp
257+
258+
time.sleep(1)
259+
auth.revoke_refresh_tokens(new_user.uid)
260+
claims = auth.verify_id_token(id_token, check_revoked=False)
261+
user = auth.get_user(new_user.uid)
262+
# verify_id_token succeeded because it didn't check revoked.
263+
assert claims['iat'] * 1000 < user.tokens_valid_after_timestamp
264+
265+
with pytest.raises(auth.AuthError) as excinfo:
266+
claims = auth.verify_id_token(id_token, check_revoked=True)
267+
assert excinfo.value.code == auth._ID_TOKEN_REVOKED
268+
assert str(excinfo.value) == 'The Firebase ID token has been revoked.'
269+
270+
# Sign in again, verify works.
271+
id_token = _sign_in(custom_token, api_key)
272+
claims = auth.verify_id_token(id_token, check_revoked=True)
273+
assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp

tests/data/get_user.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"passwordUpdatedAt" : 1.494364393E+12,
2424
"validSince" : "1494364393",
2525
"disabled" : false,
26-
"createdAt" : "1234567890",
26+
"createdAt" : "1234567890000",
2727
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
2828
} ]
2929
}

tests/data/list_users.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"passwordUpdatedAt" : 1.494364393E+12,
2424
"validSince" : "1494364393",
2525
"disabled" : false,
26-
"createdAt" : "1234567890",
26+
"createdAt" : "1234567890000",
2727
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
2828
}, {
2929
"localId" : "testuser1",
@@ -49,7 +49,7 @@
4949
"passwordUpdatedAt" : 1.494364393E+12,
5050
"validSince" : "1494364393",
5151
"disabled" : false,
52-
"createdAt" : "1234567890",
52+
"createdAt" : "1234567890000",
5353
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
5454
} ]
55-
}
55+
}

tests/test_auth.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,18 @@
4545
INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()]
4646
INVALID_BOOLS = [None, '', 'foo', 0, 1, list(), tuple(), dict()]
4747
INVALID_DICTS = [None, 'foo', 0, 1, True, False, list(), tuple()]
48+
INVALID_POSITIVE_NUMS = [None, 'foo', 0, -1, True, False, list(), tuple(), dict()]
49+
4850

4951
MOCK_GET_USER_RESPONSE = testutils.resource('get_user.json')
5052
MOCK_LIST_USERS_RESPONSE = testutils.resource('list_users.json')
5153

54+
def _revoked_tokens_response():
55+
mock_user = json.loads(testutils.resource('get_user.json'))
56+
mock_user['users'][0]['validSince'] = str(int(time.time())+100)
57+
return json.dumps(mock_user)
58+
59+
MOCK_GET_USER_REVOKED_TOKENS_RESPONSE = _revoked_tokens_response()
5260

5361
class AuthFixture(object):
5462
def __init__(self, name=None):
@@ -60,14 +68,13 @@ def __init__(self, name=None):
6068
def create_custom_token(self, *args):
6169
if self.app:
6270
return auth.create_custom_token(*args, app=self.app)
63-
else:
64-
return auth.create_custom_token(*args)
71+
return auth.create_custom_token(*args)
6572

66-
def verify_id_token(self, *args):
73+
# Using **kwargs to pass along the check_revoked if passed.
74+
def verify_id_token(self, *args, **kwargs):
6775
if self.app:
68-
return auth.verify_id_token(*args, app=self.app)
69-
else:
70-
return auth.verify_id_token(*args)
76+
return auth.verify_id_token(*args, app=self.app, **kwargs)
77+
return auth.verify_id_token(*args, **kwargs)
7178

7279
def setup_module():
7380
firebase_admin.initialize_app(MOCK_CREDENTIAL)
@@ -160,7 +167,6 @@ def get_id_token(payload_overrides=None, header_overrides=None):
160167

161168
TEST_ID_TOKEN = get_id_token()
162169

163-
164170
class TestCreateCustomToken(object):
165171

166172
valid_args = {
@@ -244,8 +250,42 @@ def test_valid_token(self, authtest, id_token):
244250
assert claims['admin'] is True
245251
assert claims['uid'] == claims['sub']
246252

247-
@pytest.mark.parametrize('id_token', invalid_tokens.values(),
248-
ids=list(invalid_tokens))
253+
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens))
254+
def test_valid_token_check_revoked(self, user_mgt_app, id_token):
255+
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_RESPONSE)
256+
claims = auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True)
257+
assert claims['admin'] is True
258+
assert claims['uid'] == claims['sub']
259+
260+
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens))
261+
def test_revoked_token_check_revoked(self, user_mgt_app, id_token):
262+
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE)
263+
264+
with pytest.raises(auth.AuthError) as excinfo:
265+
auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True)
266+
267+
assert excinfo.value.code == 'ID_TOKEN_REVOKED'
268+
assert str(excinfo.value) == 'The Firebase ID token has been revoked.'
269+
270+
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens))
271+
def test_revoked_token_do_not_check_revoked(self, user_mgt_app, id_token):
272+
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE)
273+
claims = auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=False)
274+
assert claims['admin'] is True
275+
assert claims['uid'] == claims['sub']
276+
277+
def test_revoke_refresh_tokens(self, user_mgt_app):
278+
_, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}')
279+
before_time = time.time()
280+
auth.revoke_refresh_tokens('testuser', app=user_mgt_app)
281+
after_time = time.time()
282+
283+
request = json.loads(recorder[0].body.decode())
284+
assert request['localId'] == 'testuser'
285+
assert int(request['validSince']) >= int(before_time)
286+
assert int(request['validSince']) <= int(after_time)
287+
288+
@pytest.mark.parametrize('id_token', invalid_tokens.values(), ids=list(invalid_tokens))
249289
def test_invalid_token(self, authtest, id_token):
250290
with pytest.raises(ValueError):
251291
authtest.verify_id_token(id_token)
@@ -283,7 +323,8 @@ def test_certificate_request_failure(self, authtest):
283323

284324
@pytest.fixture(scope='module')
285325
def user_mgt_app():
286-
app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt')
326+
app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt',
327+
options={'projectId': 'mock-project-id'})
287328
yield app
288329
firebase_admin.delete_app(app)
289330

@@ -305,7 +346,7 @@ def _check_user_record(user, expected_uid='testuser'):
305346
assert user.photo_url == 'http://www.example.com/testuser/photo.png'
306347
assert user.disabled is False
307348
assert user.email_verified is True
308-
assert user.user_metadata.creation_timestamp == 1234567890
349+
assert user.user_metadata.creation_timestamp == 1234567890000
309350
assert user.user_metadata.last_sign_in_timestamp is None
310351
assert user.provider_id == 'firebase'
311352

@@ -594,6 +635,11 @@ def test_invalid_property(self):
594635
with pytest.raises(ValueError):
595636
auth.update_user('user', unsupported='arg')
596637

638+
@pytest.mark.parametrize('arg', INVALID_POSITIVE_NUMS)
639+
def test_invalid_valid_since(self, arg):
640+
with pytest.raises(ValueError):
641+
auth.update_user('user', valid_since=arg)
642+
597643
def test_update_user(self, user_mgt_app):
598644
user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}')
599645
user_mgt.update_user('testuser')
@@ -630,6 +676,12 @@ def test_update_user_error(self, user_mgt_app):
630676
assert excinfo.value.code == _user_mgt.USER_UPDATE_ERROR
631677
assert '{"error":"test"}' in str(excinfo.value)
632678

679+
def test_update_user_valid_since(self, user_mgt_app):
680+
user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}')
681+
user_mgt.update_user('testuser', valid_since=1)
682+
request = json.loads(recorder[0].body.decode())
683+
assert request == {'localId': 'testuser', 'validSince': 1}
684+
633685

634686
class TestSetCustomUserClaims(object):
635687

0 commit comments

Comments
 (0)