Skip to content

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

Merged
merged 29 commits into from
Feb 13, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
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),
which denotes the time in epoch milliseconds before which tokens are not valid.

# 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.
Expand Down
12 changes: 12 additions & 0 deletions firebase_admin/_user_mgt.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ 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(
'Invalid valid_since: must be a positive interger. {0}'.format(valid_since))

@classmethod
def validate_disabled(cls, disabled):
if not isinstance(disabled, bool):
Expand Down Expand Up @@ -184,6 +194,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 = {
Expand All @@ -206,6 +217,7 @@ class UserManager(object):
'password' : 'password',
'disabled' : 'disableUser',
'custom_claims' : 'customAttributes',
'valid_since' : 'validSince',
}

_REMOVABLE_FIELDS = {
Expand Down
55 changes: 52 additions & 3 deletions firebase_admin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
_request = transport.requests.Request()

_AUTH_ATTRIBUTE = '_auth'
_ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED'


def _get_auth_service(app):
Expand Down Expand Up @@ -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
Expand All @@ -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):
"""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)`.
"""
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
"""
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
Expand Down
33 changes: 33 additions & 0 deletions integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

"""Integration tests for firebase_admin.auth module."""
import random
import time
import uuid

import pytest
Expand Down Expand Up @@ -238,3 +239,35 @@ def test_delete_user():
with pytest.raises(auth.AuthError) as excinfo:
auth.get_user(user.uid)
assert excinfo.value.code == 'USER_NOT_FOUND_ERROR'

def test_revoke_refresh_tokens(new_user):
user = auth.get_user(new_user.uid)
old_valid_after = user.tokens_valid_after_timestamp
time.sleep(1)
auth.revoke_refresh_tokens(new_user.uid)
user = auth.get_user(new_user.uid)
new_valid_after = user.tokens_valid_after_timestamp
assert new_valid_after > old_valid_after

def test_verify_id_token_revoked(new_user, api_key):
custom_token = auth.create_custom_token(new_user.uid)
id_token = _sign_in(custom_token, api_key)
claims = auth.verify_id_token(id_token)
assert claims['iat'] * 1000 >= new_user.tokens_valid_after_timestamp

time.sleep(1)
auth.revoke_refresh_tokens(new_user.uid)
claims = auth.verify_id_token(id_token, check_revoked=False)
user = auth.get_user(new_user.uid)
# verify_id_token succeeded because it didn't check revoked.
assert claims['iat'] * 1000 < user.tokens_valid_after_timestamp

with pytest.raises(auth.AuthError) as excinfo:
claims = auth.verify_id_token(id_token, check_revoked=True)
assert excinfo.value.code == auth._ID_TOKEN_REVOKED
assert str(excinfo.value) == 'The Firebase ID token has been revoked.'

# Sign in again, verify works.
id_token = _sign_in(custom_token, api_key)
claims = auth.verify_id_token(id_token, check_revoked=True)
assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp
2 changes: 1 addition & 1 deletion tests/data/get_user.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"passwordUpdatedAt" : 1.494364393E+12,
"validSince" : "1494364393",
"disabled" : false,
"createdAt" : "1234567890",
"createdAt" : "1234567890000",
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
} ]
}
6 changes: 3 additions & 3 deletions tests/data/list_users.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"passwordUpdatedAt" : 1.494364393E+12,
"validSince" : "1494364393",
"disabled" : false,
"createdAt" : "1234567890",
"createdAt" : "1234567890000",
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
}, {
"localId" : "testuser1",
Expand All @@ -49,7 +49,7 @@
"passwordUpdatedAt" : 1.494364393E+12,
"validSince" : "1494364393",
"disabled" : false,
"createdAt" : "1234567890",
"createdAt" : "1234567890000",
"customAttributes" : "{\"admin\": true, \"package\": \"gold\"}"
} ]
}
}
74 changes: 63 additions & 11 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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'
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)
Expand Down Expand Up @@ -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)

Expand All @@ -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'

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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):

Expand Down