5
5
import string
6
6
import warnings
7
7
import hashlib
8
+ import logging
8
9
9
10
from . import oauth2
10
11
12
+
13
+ logger = logging .getLogger (__name__ )
14
+
11
15
def decode_part (raw , encoding = "utf-8" ):
12
16
"""Decode a part of the JWT.
13
17
@@ -32,6 +36,45 @@ def decode_part(raw, encoding="utf-8"):
32
36
33
37
base64decode = decode_part # Obsolete. For backward compatibility only.
34
38
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
+
35
78
def decode_id_token (id_token , client_id = None , issuer = None , nonce = None , now = None ):
36
79
"""Decodes and validates an id_token and returns its claims as a dictionary.
37
80
@@ -41,41 +84,52 @@ def decode_id_token(id_token, client_id=None, issuer=None, nonce=None, now=None)
41
84
`maybe more <https://openid.net/specs/openid-connect-core-1_0.html#Claims>`_
42
85
"""
43
86
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
45
88
_now = int (now or time .time ())
46
89
skew = 120 # 2 minutes
47
- TIME_SUGGESTION = "Make sure your computer's time and time zone are both correct."
90
+
48
91
if _now + skew < decoded .get ("nbf" , _now - 1 ): # nbf is optional per JWT specs
49
92
# This is not an ID token validation, but a JWT validation
50
93
# 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
+
52
96
if issuer and issuer != decoded ["iss" ]:
53
97
# 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", '
55
100
"(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
+
57
105
if client_id :
58
106
valid_aud = client_id in decoded ["aud" ] if isinstance (
59
107
decoded ["aud" ], list ) else client_id == decoded ["aud" ]
60
108
if not valid_aud :
61
- err = (
109
+ raise IdTokenAudienceError (
62
110
"3. The aud (audience) claim must contain this client's client_id "
63
111
'"%s", case-sensitively. Was your client_id in wrong casing?'
64
112
# Some IdP accepts wrong casing request but issues right casing IDT
65
- ) % client_id
113
+ % client_id ,
114
+ _now ,
115
+ decoded )
116
+
66
117
# Per specs:
67
118
# 6. If the ID Token is received via direct communication between
68
119
# the Client and the Token Endpoint (which it is during _obtain_token()),
69
120
# the TLS server validation MAY be used to validate the issuer
70
121
# in place of checking the token signature.
122
+
71
123
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
+
73
126
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
+
79
133
return decoded
80
134
81
135
0 commit comments