Skip to content

Commit ccca65b

Browse files
committed
feat: add clockSkewInSeconds
per feedback in firebase#625 (comment) adds unit and integration tests as well. unit tests and lint pass.
1 parent aef52be commit ccca65b

File tree

6 files changed

+100
-17
lines changed

6 files changed

+100
-17
lines changed

firebase_admin/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929

3030
_DEFAULT_APP_NAME = '[DEFAULT]'
3131
_FIREBASE_CONFIG_ENV_VAR = 'FIREBASE_CONFIG'
32-
_CONFIG_VALID_KEYS = ['databaseAuthVariableOverride', 'databaseURL', 'httpTimeout', 'projectId',
33-
'storageBucket']
32+
_CONFIG_VALID_KEYS = ['clockSkewInSeconds', 'databaseAuthVariableOverride', 'databaseURL',
33+
'httpTimeout', 'projectId', 'storageBucket']
3434

3535
def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME):
3636
"""Initializes and returns a new App instance.
@@ -49,9 +49,11 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME):
4949
credential: A credential object used to initialize the SDK (optional). If none is provided,
5050
Google Application Default Credentials are used.
5151
options: A dictionary of configuration options (optional). Supported options include
52-
``databaseURL``, ``storageBucket``, ``projectId``, ``databaseAuthVariableOverride``,
53-
``serviceAccountId`` and ``httpTimeout``. If ``httpTimeout`` is not set, the SDK
54-
uses a default timeout of 120 seconds.
52+
``clockSkewInSeconds``, ``databaseURL``, ``storageBucket``, ``projectId``,
53+
``databaseAuthVariableOverride``, ``serviceAccountId`` and ``httpTimeout``. If
54+
``httpTimeout`` is not set, the SDK uses a default timeout of 120 seconds. If
55+
``clockSkewInSeconds`` is not set, 0 is used when verifying a token or cookie.
56+
5557
name: Name of the app (optional).
5658
Returns:
5759
App: A newly initialized instance of App.

firebase_admin/_auth_client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def create_custom_token(self, uid, developer_claims=None):
9292
return self._token_generator.create_custom_token(
9393
uid, developer_claims, tenant_id=self.tenant_id)
9494

95-
def verify_id_token(self, id_token, check_revoked=False):
95+
def verify_id_token(self, id_token, check_revoked=False, clock_skew_in_seconds=0):
9696
"""Verifies the signature and data for the provided JWT.
9797
9898
Accepts a signed token string, verifies that it is current, was issued
@@ -102,6 +102,7 @@ def verify_id_token(self, id_token, check_revoked=False):
102102
id_token: A string of the encoded JWT.
103103
check_revoked: Boolean, If true, checks whether the token has been revoked or
104104
the user disabled (optional).
105+
clock_skew_in_seconds: The number of seconds to tolerate when checking the token
105106
106107
Returns:
107108
dict: A dictionary of key-value pairs parsed from the decoded JWT.
@@ -124,7 +125,7 @@ def verify_id_token(self, id_token, check_revoked=False):
124125
raise ValueError('Illegal check_revoked argument. Argument must be of type '
125126
' bool, but given "{0}".'.format(type(check_revoked)))
126127

127-
verified_claims = self._token_verifier.verify_id_token(id_token)
128+
verified_claims = self._token_verifier.verify_id_token(id_token, clock_skew_in_seconds)
128129
if self.tenant_id:
129130
token_tenant_id = verified_claims.get('firebase', {}).get('tenant')
130131
if self.tenant_id != token_tenant_id:

firebase_admin/_token_gen.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,11 @@ def __init__(self, app):
289289
invalid_token_error=InvalidSessionCookieError,
290290
expired_token_error=ExpiredSessionCookieError)
291291

292-
def verify_id_token(self, id_token):
293-
return self.id_token_verifier.verify(id_token, self.request)
292+
def verify_id_token(self, id_token, clock_skew_in_seconds=0):
293+
return self.id_token_verifier.verify(id_token, self.request, clock_skew_in_seconds)
294294

295-
def verify_session_cookie(self, cookie):
296-
return self.cookie_verifier.verify(cookie, self.request)
295+
def verify_session_cookie(self, cookie, clock_skew_in_seconds=0):
296+
return self.cookie_verifier.verify(cookie, self.request, clock_skew_in_seconds)
297297

298298

299299
class _JWTVerifier:
@@ -313,7 +313,7 @@ def __init__(self, **kwargs):
313313
self._invalid_token_error = kwargs.pop('invalid_token_error')
314314
self._expired_token_error = kwargs.pop('expired_token_error')
315315

316-
def verify(self, token, request):
316+
def verify(self, token, request, clock_skew_in_seconds=0):
317317
"""Verifies the signature and data for the provided JWT."""
318318
token = token.encode('utf-8') if isinstance(token, str) else token
319319
if not isinstance(token, bytes) or not token:
@@ -393,7 +393,8 @@ def verify(self, token, request):
393393
token,
394394
request=request,
395395
audience=self.project_id,
396-
certs_url=self.cert_url)
396+
certs_url=self.cert_url,
397+
clock_skew_in_seconds=clock_skew_in_seconds)
397398
verified_claims['uid'] = verified_claims['sub']
398399
return verified_claims
399400
except google.auth.exceptions.TransportError as error:

firebase_admin/auth.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def create_custom_token(uid, developer_claims=None, app=None):
191191
return client.create_custom_token(uid, developer_claims)
192192

193193

194-
def verify_id_token(id_token, app=None, check_revoked=False):
194+
def verify_id_token(id_token, app=None, check_revoked=False, clock_skew_in_seconds=0):
195195
"""Verifies the signature and data for the provided JWT.
196196
197197
Accepts a signed token string, verifies that it is current, and issued
@@ -202,6 +202,7 @@ def verify_id_token(id_token, app=None, check_revoked=False):
202202
app: An App instance (optional).
203203
check_revoked: Boolean, If true, checks whether the token has been revoked or
204204
the user disabled (optional).
205+
clock_skew_in_seconds: The number of seconds to tolerate when checking the token.
205206
206207
Returns:
207208
dict: A dictionary of key-value pairs parsed from the decoded JWT.
@@ -217,7 +218,8 @@ def verify_id_token(id_token, app=None, check_revoked=False):
217218
record is disabled.
218219
"""
219220
client = _get_client(app)
220-
return client.verify_id_token(id_token, check_revoked=check_revoked)
221+
return client.verify_id_token(
222+
id_token, check_revoked=check_revoked, clock_skew_in_seconds=clock_skew_in_seconds)
221223

222224

223225
def create_session_cookie(id_token, expires_in, app=None):
@@ -243,7 +245,7 @@ def create_session_cookie(id_token, expires_in, app=None):
243245
return client._token_generator.create_session_cookie(id_token, expires_in)
244246

245247

246-
def verify_session_cookie(session_cookie, check_revoked=False, app=None):
248+
def verify_session_cookie(session_cookie, check_revoked=False, app=None, clock_skew_in_seconds=0):
247249
"""Verifies a Firebase session cookie.
248250
249251
Accepts a session cookie string, verifies that it is current, and issued
@@ -254,6 +256,7 @@ def verify_session_cookie(session_cookie, check_revoked=False, app=None):
254256
check_revoked: Boolean, if true, checks whether the cookie has been revoked or the
255257
user disabled (optional).
256258
app: An App instance (optional).
259+
clock_skew_in_seconds: The number of seconds to tolerate when checking the cookie
257260
258261
Returns:
259262
dict: A dictionary of key-value pairs parsed from the decoded JWT.
@@ -270,7 +273,8 @@ def verify_session_cookie(session_cookie, check_revoked=False, app=None):
270273
"""
271274
client = _get_client(app)
272275
# pylint: disable=protected-access
273-
verified_claims = client._token_verifier.verify_session_cookie(session_cookie)
276+
verified_claims = client._token_verifier.verify_session_cookie(
277+
session_cookie, clock_skew_in_seconds)
274278
if check_revoked:
275279
client._check_jwt_revoked_or_disabled(
276280
verified_claims, RevokedSessionCookieError, 'session cookie')

integration/test_auth.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ def test_session_cookies(api_key):
160160
estimated_exp = int(time.time() + expires_in.total_seconds())
161161
assert abs(claims['exp'] - estimated_exp) < 5
162162

163+
def test_session_cookies_with_tolerance(api_key):
164+
dev_claims = {'premium' : True, 'subscription' : 'silver'}
165+
custom_token = auth.create_custom_token('user3', dev_claims)
166+
id_token = _sign_in(custom_token, api_key)
167+
expires_in = datetime.timedelta(seconds=1)
168+
session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in)
169+
time.sleep(2)
170+
# expect this to fail because the cookie is expired
171+
with pytest.raises(auth.ExpiredSessionCookieError):
172+
auth.verify_session_cookie(session_cookie)
173+
174+
# expect this to succeed because we're within the tolerance
175+
claims = auth.verify_session_cookie(session_cookie, check_revoked=False, tolerance=2)
176+
assert claims['uid'] == 'user3'
177+
assert claims['premium'] is True
178+
assert claims['subscription'] == 'silver'
179+
assert claims['iss'].startswith('https://session.firebase.google.com')
180+
163181
def test_session_cookie_error():
164182
expires_in = datetime.timedelta(days=1)
165183
with pytest.raises(auth.InvalidIdTokenError):
@@ -577,6 +595,22 @@ def test_verify_id_token_revoked(new_user, api_key):
577595
claims = auth.verify_id_token(id_token, check_revoked=True)
578596
assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp
579597

598+
def test_verify_id_token_tolerance(new_user, api_key):
599+
# create token that expires in 1 second.
600+
custom_token = auth.create_custom_token(new_user.uid, expires_in=datetime.timedelta(seconds=1))
601+
id_token = _sign_in(custom_token, api_key)
602+
time.sleep(1)
603+
604+
# verify with tolerance of 0 seconds. This should fail.
605+
with pytest.raises(auth.ExpiredIdTokenError) as excinfo:
606+
auth.verify_id_token(id_token, check_revoked=False, max_age=datetime.timedelta(seconds=0))
607+
assert str(excinfo.value) == 'The Firebase ID token has expired.'
608+
609+
# verify with tolerance of 2 seconds. This should succeed.
610+
claims = auth.verify_id_token(id_token, check_revoked=False, max_age=datetime.timedelta(seconds=2))
611+
assert claims['sub'] == new_user.uid
612+
613+
580614
def test_verify_id_token_disabled(new_user, api_key):
581615
custom_token = auth.create_custom_token(new_user.uid)
582616
id_token = _sign_in(custom_token, api_key)
@@ -617,6 +651,19 @@ def test_verify_session_cookie_revoked(new_user, api_key):
617651
claims = auth.verify_session_cookie(session_cookie, check_revoked=True)
618652
assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp
619653

654+
def test_verify_session_cookie_tolerance(new_user, api_key):
655+
expired_session_cookie = auth.create_session_cookie(_sign_in(auth.create_custom_token(new_user.uid), api_key), expires_in=datetime.timedelta(seconds=1))
656+
time.sleep(1)
657+
# Verify the session cookie with a tolerance of 0 seconds. This should
658+
# raise an exception because the cookie is expired.
659+
with pytest.raises(auth.InvalidSessionCookieError) as excinfo:
660+
auth.verify_session_cookie(expired_session_cookie, check_revoked=False, clock_skew_in_seconds=0)
661+
assert str(excinfo.value) == 'The Firebase session cookie is expired.'
662+
663+
# Verify the session cookie with a tolerance of 2 seconds. This should
664+
# not raise an exception because the cookie is within the tolerance.
665+
auth.verify_session_cookie(expired_session_cookie, check_revoked=False, clock_skew_in_seconds=2)
666+
620667
def test_verify_session_cookie_disabled(new_user, api_key):
621668
custom_token = auth.create_custom_token(new_user.uid)
622669
id_token = _sign_in(custom_token, api_key)

tests/test_token_gen.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,20 @@ def test_expired_token(self, user_mgt_app):
554554
assert 'Token expired' in str(excinfo.value)
555555
assert excinfo.value.cause is not None
556556
assert excinfo.value.http_response is None
557+
558+
def test_expired_token_with_tolerance(self, user_mgt_app):
559+
_overwrite_cert_request(user_mgt_app, MOCK_REQUEST)
560+
id_token = self.invalid_tokens['ExpiredToken']
561+
if _is_emulated():
562+
self._assert_valid_token(id_token, user_mgt_app)
563+
return
564+
claims = auth.verify_id_token(id_token, app=user_mgt_app,
565+
clock_skew_in_seconds=3700)
566+
assert claims['admin'] is True
567+
assert claims['uid'] == claims['sub']
568+
with pytest.raises(auth.ExpiredIdTokenError) as excinfo:
569+
auth.verify_id_token(id_token, app=user_mgt_app,
570+
clock_skew_in_seconds=3500)
557571

558572
def test_project_id_option(self):
559573
app = firebase_admin.initialize_app(
@@ -715,6 +729,20 @@ def test_expired_cookie(self, user_mgt_app):
715729
assert excinfo.value.cause is not None
716730
assert excinfo.value.http_response is None
717731

732+
def test_expired_cookie_with_tolerance(self, user_mgt_app):
733+
_overwrite_cert_request(user_mgt_app, MOCK_REQUEST)
734+
cookie = self.invalid_cookies['ExpiredCookie']
735+
if _is_emulated():
736+
self._assert_valid_cookie(cookie, user_mgt_app)
737+
return
738+
claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False,
739+
clock_skew_in_seconds=7200)
740+
assert claims['admin'] is True
741+
assert claims['uid'] == claims['sub']
742+
with pytest.raises(auth.ExpiredSessionCookieError) as excinfo:
743+
auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False,
744+
clock_skew_in_seconds=3500)
745+
718746
def test_project_id_option(self):
719747
app = firebase_admin.initialize_app(
720748
testutils.MockCredential(), options={'projectId': 'mock-project-id'}, name='myApp')

0 commit comments

Comments
 (0)