-
Notifications
You must be signed in to change notification settings - Fork 340
Revoke refresh token #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Revoke refresh token #123
Changes from 27 commits
55420df
f9f1ba3
ab14dfe
cdfffa9
40daf4e
82e4ef3
8f0695c
883fec0
c1ff5ed
11920d8
13a513f
91c8b4b
b56197e
a7db23c
e2b0c80
fe0011b
70cef7c
64c2e93
4025957
5138956
44a6bef
639b425
2bd5ec5
2f4e156
589fd10
0293bb5
393c1ab
e4fa1e7
d52c49e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,20 @@ | ||
# Unreleased | ||
- | ||
### Token revokaction | ||
- [added] The ['verify_id_token(...)'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#verify_id_token) | ||
method now accepts an optional `check_revoked` parameter. When `True`, an | ||
additional check is performed to see whether the token has been revoked. | ||
- [added] A new method ['auth.revoke_refresh_tokens(uid)'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#revoke_refresh_tokens) | ||
has been added to invalidate all tokens issued to a user before the current second. | ||
- [added] A new property `tokens_valid_after_timestamp` has been added to the | ||
['UserRecord'](https://firebase.google.com/docs/reference/admin/python/firebase_admin.auth#userrecord), | ||
it denotes the time in epoch milliseconds before which tokens are not valid. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. which denotes... |
||
|
||
# v2.8.0 | ||
|
||
### Initialization | ||
|
||
- [added] The [`initialize_app()`](https://firebase.google.com/docs/reference/admin/node/admin#.initializeApp) | ||
- [added] The [`initialize_app()`](https://firebase.google.com/docs/reference/admin/python/firebase_admin#initialize_app) | ||
method can now be invoked without any arguments. This initializes an app | ||
using Google Application Default Credentials, and other | ||
options loaded from the `FIREBASE_CONFIG` environment variable. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -113,6 +113,15 @@ def validate_photo_url(cls, photo_url): | |
except Exception: | ||
raise ValueError('Malformed photo URL: "{0}".'.format(photo_url)) | ||
|
||
@classmethod | ||
def validate_valid_since(cls, valid_since): | ||
# isinstance(True, int) is True hence the extra check | ||
if valid_since is None or isinstance(valid_since, bool) or not isinstance(valid_since, int): | ||
raise ValueError( | ||
'Invalid time string for: "{0}". Valid Since must be an int'.format(valid_since)) | ||
if int(valid_since) <= 0: | ||
raise ValueError('Valid Since: must be a positive interger. {0}'.format(valid_since)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
@classmethod | ||
def validate_disabled(cls, disabled): | ||
if not isinstance(disabled, bool): | ||
|
@@ -184,6 +193,7 @@ class UserManager(object): | |
'password' : _Validator.validate_password, | ||
'phoneNumber' : _Validator.validate_phone, | ||
'photoUrl' : _Validator.validate_photo_url, | ||
'validSince' : _Validator.validate_valid_since, | ||
} | ||
|
||
_CREATE_USER_FIELDS = { | ||
|
@@ -206,6 +216,7 @@ class UserManager(object): | |
'password' : 'password', | ||
'disabled' : 'disableUser', | ||
'custom_claims' : 'customAttributes', | ||
'valid_since' : 'validSince', | ||
} | ||
|
||
_REMOVABLE_FIELDS = { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -36,6 +36,7 @@ | |
_request = transport.requests.Request() | ||
|
||
_AUTH_ATTRIBUTE = '_auth' | ||
_ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED' | ||
|
||
|
||
def _get_auth_service(app): | ||
|
@@ -76,7 +77,7 @@ def create_custom_token(uid, developer_claims=None, app=None): | |
return token_generator.create_custom_token(uid, developer_claims) | ||
|
||
|
||
def verify_id_token(id_token, app=None): | ||
def verify_id_token(id_token, app=None, check_revoked=False): | ||
"""Verifies the signature and data for the provided JWT. | ||
|
||
Accepts a signed token string, verifies that it is current, and issued | ||
|
@@ -85,17 +86,38 @@ def verify_id_token(id_token, app=None): | |
Args: | ||
id_token: A string of the encoded JWT. | ||
app: An App instance (optional). | ||
check_revoked: Boolean, If true, checks whether the token has been revoked (optional). | ||
|
||
Returns: | ||
dict: A dictionary of key-value pairs parsed from the decoded JWT. | ||
|
||
Raises: | ||
ValueError: If the JWT was found to be invalid, or if the App was not | ||
initialized with a credentials.Certificate. | ||
AuthError: If check_revoked is requested and the token was revoked. | ||
""" | ||
token_generator = _get_auth_service(app).token_generator | ||
return token_generator.verify_id_token(id_token) | ||
|
||
verified_claims = token_generator.verify_id_token(id_token) | ||
if check_revoked: | ||
user = get_user(verified_claims.get('uid'), app) | ||
if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: | ||
raise AuthError(_ID_TOKEN_REVOKED, 'The Firebase ID token has been revoked.') | ||
return verified_claims | ||
|
||
def revoke_refresh_tokens(uid, app=None): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add docstring. Also update the docstring of |
||
"""Revokes all refresh tokens for an existing user. | ||
|
||
revoke_refresh_tokens updates the user's tokens_valid_after_timestamp to the current UTC | ||
in seconds since the epoch. It is important that the server on which this is called has its | ||
clock set correctly and synchronized. | ||
|
||
While this revokes all sessions for a specified user and disables 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 | ||
`verify_id_token(idToken, check_revoked=true)`. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
""" | ||
user_manager = _get_auth_service(app).user_manager | ||
user_manager.update_user(uid, valid_since=int(time.time())) | ||
|
||
def get_user(uid, app=None): | ||
"""Gets the user data corresponding to the specified user ID. | ||
|
@@ -246,6 +268,8 @@ def update_user(uid, **kwargs): | |
disabled: A boolean indicating whether or not the user account is disabled (optional). | ||
custom_claims: A dictionary or a JSON string contining the custom claims to be set on the | ||
user account (optional). | ||
valid_since: An integer signifying the seconds since the epoch. This field is set by | ||
`revoke_refresh_tokens` and it is discouraged to set this field directly. | ||
|
||
Returns: | ||
UserRecord: An updated UserRecord instance for the user. | ||
|
@@ -430,6 +454,21 @@ def disabled(self): | |
""" | ||
return bool(self._data.get('disabled')) | ||
|
||
@property | ||
def tokens_valid_after_timestamp(self): | ||
"""Returns the time, in milliseconds since the epoch, before which tokens are invalid. | ||
|
||
Note: this is truncated to 1 second accuracy. | ||
|
||
Returns: | ||
int: Timestamp in milliseconds since the epoch, truncated to the second. | ||
All tokens issued before that time are considered revoked. | ||
""" | ||
valid_since = self._data.get('validSince') | ||
if valid_since is not None: | ||
return 1000 * int(valid_since) | ||
return None | ||
|
||
@property | ||
def user_metadata(self): | ||
"""Returns additional metadata associated with this user. | ||
|
@@ -476,12 +515,22 @@ def __init__(self, data): | |
|
||
@property | ||
def creation_timestamp(self): | ||
""" Creation timestamp in milliseconds since the epoch. | ||
|
||
Returns: | ||
integer: The user creation timestamp in milliseconds since the epoch. | ||
""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the |
||
if 'createdAt' in self._data: | ||
return int(self._data['createdAt']) | ||
return None | ||
|
||
@property | ||
def last_sign_in_timestamp(self): | ||
""" Last sign in timestamp in milliseconds since the epoch. | ||
|
||
Returns: | ||
integer: The last sign in timestamp in milliseconds since the epoch. | ||
""" | ||
if 'lastLoginAt' in self._data: | ||
return int(self._data['lastLoginAt']) | ||
return None | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,7 +23,7 @@ | |
"passwordUpdatedAt" : 1.494364393E+12, | ||
"validSince" : "1494364393", | ||
"disabled" : false, | ||
"createdAt" : "1234567890", | ||
"createdAt" : "1234567890000", | ||
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" | ||
}, { | ||
"localId" : "testuser1", | ||
|
@@ -49,7 +49,7 @@ | |
"passwordUpdatedAt" : 1.494364393E+12, | ||
"validSince" : "1494364393", | ||
"disabled" : false, | ||
"createdAt" : "1234567890", | ||
"createdAt" : "1234567890000", | ||
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}" | ||
} ] | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not really related to your change, but lets add a new line here. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,10 +45,18 @@ | |
INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] | ||
INVALID_BOOLS = [None, '', 'foo', 0, 1, list(), tuple(), dict()] | ||
INVALID_DICTS = [None, 'foo', 0, 1, True, False, list(), tuple()] | ||
INVALID_POSITIVE_NUMS = [None, 'foo', 0, -1, True, False, list(), tuple(), dict()] | ||
|
||
|
||
MOCK_GET_USER_RESPONSE = testutils.resource('get_user.json') | ||
MOCK_LIST_USERS_RESPONSE = testutils.resource('list_users.json') | ||
|
||
def _revoked_tokens_response(): | ||
mock_user = json.loads(testutils.resource('get_user.json')) | ||
mock_user['users'][0]['validSince'] = str(int(time.time())+100) | ||
return json.dumps(mock_user) | ||
|
||
MOCK_GET_USER_REVOKED_TOKENS_RESPONSE = _revoked_tokens_response() | ||
|
||
class AuthFixture(object): | ||
def __init__(self, name=None): | ||
|
@@ -60,14 +68,13 @@ def __init__(self, name=None): | |
def create_custom_token(self, *args): | ||
if self.app: | ||
return auth.create_custom_token(*args, app=self.app) | ||
else: | ||
return auth.create_custom_token(*args) | ||
return auth.create_custom_token(*args) | ||
|
||
def verify_id_token(self, *args): | ||
# Using **kwargs to pass along the check_revoked if passed. | ||
def verify_id_token(self, *args, **kwargs): | ||
if self.app: | ||
return auth.verify_id_token(*args, app=self.app) | ||
else: | ||
return auth.verify_id_token(*args) | ||
return auth.verify_id_token(*args, app=self.app, **kwargs) | ||
return auth.verify_id_token(*args, **kwargs) | ||
|
||
def setup_module(): | ||
firebase_admin.initialize_app(MOCK_CREDENTIAL) | ||
|
@@ -160,7 +167,6 @@ def get_id_token(payload_overrides=None, header_overrides=None): | |
|
||
TEST_ID_TOKEN = get_id_token() | ||
|
||
|
||
class TestCreateCustomToken(object): | ||
|
||
valid_args = { | ||
|
@@ -244,8 +250,42 @@ def test_valid_token(self, authtest, id_token): | |
assert claims['admin'] is True | ||
assert claims['uid'] == claims['sub'] | ||
|
||
@pytest.mark.parametrize('id_token', invalid_tokens.values(), | ||
ids=list(invalid_tokens)) | ||
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) | ||
def test_valid_token_check_revoked(self, user_mgt_app, id_token): | ||
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_RESPONSE) | ||
claims = auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) | ||
assert claims['admin'] is True | ||
assert claims['uid'] == claims['sub'] | ||
|
||
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) | ||
def test_revoked_token_check_revoked(self, user_mgt_app, id_token): | ||
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE) | ||
|
||
with pytest.raises(auth.AuthError) as excinfo: | ||
auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) | ||
|
||
assert excinfo.value.code == 'ID_TOKEN_REVOKED' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check against the constant once you have it |
||
assert str(excinfo.value) == 'The Firebase ID token has been revoked.' | ||
|
||
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) | ||
def test_revoked_token_do_not_check_revoked(self, user_mgt_app, id_token): | ||
_instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE) | ||
claims = auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=False) | ||
assert claims['admin'] is True | ||
assert claims['uid'] == claims['sub'] | ||
|
||
def test_revoke_refresh_tokens(self, user_mgt_app): | ||
_, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') | ||
before_time = time.time() | ||
auth.revoke_refresh_tokens('testuser', app=user_mgt_app) | ||
after_time = time.time() | ||
|
||
request = json.loads(recorder[0].body.decode()) | ||
assert request['localId'] == 'testuser' | ||
assert int(request['validSince']) >= int(before_time) | ||
assert int(request['validSince']) <= int(after_time) | ||
|
||
@pytest.mark.parametrize('id_token', invalid_tokens.values(), ids=list(invalid_tokens)) | ||
def test_invalid_token(self, authtest, id_token): | ||
with pytest.raises(ValueError): | ||
authtest.verify_id_token(id_token) | ||
|
@@ -283,7 +323,8 @@ def test_certificate_request_failure(self, authtest): | |
|
||
@pytest.fixture(scope='module') | ||
def user_mgt_app(): | ||
app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt') | ||
app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt', | ||
options={'projectId': 'mock-project-id'}) | ||
yield app | ||
firebase_admin.delete_app(app) | ||
|
||
|
@@ -305,7 +346,7 @@ def _check_user_record(user, expected_uid='testuser'): | |
assert user.photo_url == 'http://www.example.com/testuser/photo.png' | ||
assert user.disabled is False | ||
assert user.email_verified is True | ||
assert user.user_metadata.creation_timestamp == 1234567890 | ||
assert user.user_metadata.creation_timestamp == 1234567890000 | ||
assert user.user_metadata.last_sign_in_timestamp is None | ||
assert user.provider_id == 'firebase' | ||
|
||
|
@@ -594,6 +635,11 @@ def test_invalid_property(self): | |
with pytest.raises(ValueError): | ||
auth.update_user('user', unsupported='arg') | ||
|
||
@pytest.mark.parametrize('arg', INVALID_POSITIVE_NUMS) | ||
def test_invalid_valid_since(self, arg): | ||
with pytest.raises(ValueError): | ||
auth.update_user('user', valid_since=arg) | ||
|
||
def test_update_user(self, user_mgt_app): | ||
user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') | ||
user_mgt.update_user('testuser') | ||
|
@@ -630,6 +676,12 @@ def test_update_user_error(self, user_mgt_app): | |
assert excinfo.value.code == _user_mgt.USER_UPDATE_ERROR | ||
assert '{"error":"test"}' in str(excinfo.value) | ||
|
||
def test_update_user_valid_since(self, user_mgt_app): | ||
user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') | ||
user_mgt.update_user('testuser', valid_since=1) | ||
request = json.loads(recorder[0].body.decode()) | ||
assert request == {'localId': 'testuser', 'validSince': 1} | ||
|
||
|
||
class TestSetCustomUserClaims(object): | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
which denotes..