Skip to content

Commit d524595

Browse files
committed
Tolerate ID token time errors
1 parent 72b853d commit d524595

File tree

3 files changed

+89
-14
lines changed

3 files changed

+89
-14
lines changed

oauth2cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
__version__ = "0.4.0"
22

3-
from .oidc import Client
3+
from .oidc import Client, IdTokenError
44
from .assertion import JwtAssertionCreator
55
from .assertion import JwtSigner # Obsolete. For backward compatibility.
66
from .authcode import AuthCodeReceiver

oauth2cli/oidc.py

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
import string
66
import warnings
77
import hashlib
8+
import logging
89

910
from . import oauth2
1011

12+
13+
logger = logging.getLogger(__name__)
14+
1115
def decode_part(raw, encoding="utf-8"):
1216
"""Decode a part of the JWT.
1317
@@ -32,6 +36,45 @@ def decode_part(raw, encoding="utf-8"):
3236

3337
base64decode = decode_part # Obsolete. For backward compatibility only.
3438

39+
def _epoch_to_local(epoch):
40+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epoch))
41+
42+
class IdTokenError(RuntimeError): # We waised RuntimeError before, so keep it
43+
"""In unlikely event of an ID token is malformed, this exception will be raised."""
44+
def __init__(self, reason, now, claims):
45+
super(IdTokenError, self).__init__(
46+
"%s Current epoch = %s. The id_token was approximately: %s" % (
47+
reason, _epoch_to_local(now), json.dumps(dict(
48+
claims,
49+
iat=_epoch_to_local(claims["iat"]) if claims.get("iat") else None,
50+
exp=_epoch_to_local(claims["exp"]) if claims.get("exp") else None,
51+
), indent=2)))
52+
53+
class _IdTokenTimeError(IdTokenError): # This is not intended to be raised and caught
54+
_SUGGESTION = "Make sure your computer's time and time zone are both correct."
55+
def __init__(self, reason, now, claims):
56+
super(_IdTokenTimeError, self).__init__(reason+ " " + self._SUGGESTION, now, claims)
57+
def log(self):
58+
# Influenced by JWT specs https://tools.ietf.org/html/rfc7519#section-4.1.5
59+
# and OIDC specs https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
60+
# We used to raise this error, but now we just log it as warning, because:
61+
# 1. If it is caused by incorrect local machine time,
62+
# then the token(s) are still correct and probably functioning,
63+
# so, there is no point to error out.
64+
# 2. If it is caused by incorrect IdP time, then it is IdP's fault,
65+
# There is not much a client can do, so, we might as well return the token(s)
66+
# and let downstream components to decide what to do.
67+
logger.warning(str(self))
68+
69+
class IdTokenIssuerError(IdTokenError):
70+
pass
71+
72+
class IdTokenAudienceError(IdTokenError):
73+
pass
74+
75+
class IdTokenNonceError(IdTokenError):
76+
pass
77+
3578
def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None):
3679
"""Decodes and validates an id_token and returns its claims as a dictionary.
3780
@@ -41,41 +84,52 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
4184
`maybe more <https://openid.net/specs/openid-connect-core-1_0.html#Claims>`_
4285
"""
4386
decoded = json.loads(decode_part(id_token.split('.')[1]))
44-
err = None # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
87+
# Based on https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
4588
_now = int(now or time.time())
4689
skew = 120 # 2 minutes
47-
TIME_SUGGESTION = "Make sure your computer's time and time zone are both correct."
90+
4891
if _now + skew < decoded.get("nbf", _now - 1): # nbf is optional per JWT specs
4992
# This is not an ID token validation, but a JWT validation
5093
# https://tools.ietf.org/html/rfc7519#section-4.1.5
51-
err = "0. The ID token is not yet valid. " + TIME_SUGGESTION
94+
_IdTokenTimeError("0. The ID token is not yet valid.", _now, decoded).log()
95+
5296
if issuer and issuer != decoded["iss"]:
5397
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
54-
err = ('2. The Issuer Identifier for the OpenID Provider, "%s", '
98+
raise IdTokenIssuerError(
99+
'2. The Issuer Identifier for the OpenID Provider, "%s", '
55100
"(which is typically obtained during Discovery), "
56-
"MUST exactly match the value of the iss (issuer) Claim.") % issuer
101+
"MUST exactly match the value of the iss (issuer) Claim." % issuer,
102+
_now,
103+
decoded)
104+
57105
if client_id:
58106
valid_aud = client_id in decoded["aud"] if isinstance(
59107
decoded["aud"], list) else client_id == decoded["aud"]
60108
if not valid_aud:
61-
err = (
109+
raise IdTokenAudienceError(
62110
"3. The aud (audience) claim must contain this client's client_id "
63111
'"%s", case-sensitively. Was your client_id in wrong casing?'
64112
# Some IdP accepts wrong casing request but issues right casing IDT
65-
) % client_id
113+
% client_id,
114+
_now,
115+
decoded)
116+
66117
# Per specs:
67118
# 6. If the ID Token is received via direct communication between
68119
# the Client and the Token Endpoint (which it is during _obtain_token()),
69120
# the TLS server validation MAY be used to validate the issuer
70121
# in place of checking the token signature.
122+
71123
if _now - skew > decoded["exp"]:
72-
err = "9. The ID token already expires. " + TIME_SUGGESTION
124+
_IdTokenTimeError("9. The ID token already expires.", _now, decoded).log()
125+
73126
if nonce and nonce != decoded.get("nonce"):
74-
err = ("11. Nonce must be the same value "
75-
"as the one that was sent in the Authentication Request.")
76-
if err:
77-
raise RuntimeError("%s Current epoch = %s. The id_token was: %s" % (
78-
err, _now, json.dumps(decoded, indent=2)))
127+
raise IdTokenNonceError(
128+
"11. Nonce must be the same value "
129+
"as the one that was sent in the Authentication Request.",
130+
_now,
131+
decoded)
132+
79133
return decoded
80134

81135

tests/test_oidc.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from tests import unittest
2+
3+
import oauth2cli
4+
5+
6+
class TestIdToken(unittest.TestCase):
7+
EXPIRED_ID_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJpc3N1ZXIiLCJpYXQiOjE3MDY1NzA3MzIsImV4cCI6MTY3NDk0ODMzMiwiYXVkIjoiZm9vIiwic3ViIjoic3ViamVjdCJ9.wyWNFxnE35SMP6FpxnWZmWQAy4KD0No_Q1rUy5bNnLs"
8+
9+
def test_id_token_should_tolerate_time_error(self):
10+
self.assertEqual(oauth2cli.oidc.decode_id_token(self.EXPIRED_ID_TOKEN), {
11+
"iss": "issuer",
12+
"iat": 1706570732,
13+
"exp": 1674948332, # 2023-1-28
14+
"aud": "foo",
15+
"sub": "subject",
16+
}, "id_token is decoded correctly, without raising exception")
17+
18+
def test_id_token_should_error_out_on_client_id_error(self):
19+
with self.assertRaises(oauth2cli.IdTokenError):
20+
oauth2cli.oidc.decode_id_token(self.EXPIRED_ID_TOKEN, client_id="not foo")
21+

0 commit comments

Comments
 (0)