/ adafruit_jwt.py
adafruit_jwt.py
1 # The MIT License (MIT) 2 # 3 # Copyright (c) 2019 Brent Rubell for Adafruit Industries 4 # 5 # Permission is hereby granted, free of charge, to any person obtaining a copy 6 # of this software and associated documentation files (the "Software"), to deal 7 # in the Software without restriction, including without limitation the rights 8 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 # copies of the Software, and to permit persons to whom the Software is 10 # furnished to do so, subject to the following conditions: 11 # 12 # The above copyright notice and this permission notice shall be included in 13 # all copies or substantial portions of the Software. 14 # 15 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 # THE SOFTWARE. 22 """ 23 `adafruit_jwt` 24 ================================================================================ 25 26 JSON Web Token Authentication 27 28 * Author(s): Brent Rubell 29 30 Implementation Notes 31 -------------------- 32 33 **Hardware:** 34 35 **Software and Dependencies:** 36 37 * Adafruit CircuitPython firmware for the supported boards: 38 https://github.com/adafruit/circuitpython/releases 39 40 * Adafruit's RSA library: 41 https://github.com/adafruit/Adafruit_CircuitPython_RSA 42 43 * Adafruit's binascii library: 44 https://github.com/adafruit/Adafruit_CircuitPython_RSA 45 46 """ 47 import io 48 import json 49 from adafruit_rsa import PrivateKey, sign 50 51 from adafruit_binascii import b2a_base64, a2b_base64 52 53 54 __version__ = "0.0.0-auto.0" 55 __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_JWT.git" 56 57 # pylint: disable=no-member 58 class JWT: 59 """JSON Web Token helper for CircuitPython. Warning: JWTs are 60 credentials, which can grant access to resources. Be careful 61 where you paste them! 62 :param str algo: Encryption algorithm used for claims. Can be None. 63 64 """ 65 66 @staticmethod 67 def validate(jwt): 68 """Validates a provided JWT. Does not support validating 69 nested signing. Returns JOSE Header and claim set. 70 :param str jwt: JSON Web Token. 71 :returns: The message's decoded JOSE header and claims. 72 :rtype: tuple 73 """ 74 # Verify JWT contains at least one period ('.') 75 if jwt.find(".") == -1: 76 raise ValueError("ProvidedJWT must have at least one period") 77 # Attempt to decode JOSE header 78 try: 79 jose_header = STRING_TOOLS.urlsafe_b64decode(jwt.split(".")[0]) 80 except UnicodeError: 81 raise UnicodeError("Unable to decode JOSE header.") 82 # Check for typ and alg in decoded JOSE header 83 if "typ" not in jose_header: 84 raise TypeError("JOSE Header does not contain required type key.") 85 if "alg" not in jose_header: 86 raise TypeError("Jose Header does not contain an alg key.") 87 # Attempt to decode claim set 88 try: 89 claims = json.loads(STRING_TOOLS.urlsafe_b64decode(jwt.split(".")[1])) 90 except UnicodeError: 91 raise UnicodeError("Invalid claims encoding.") 92 if not hasattr(claims, "keys"): 93 raise TypeError("Provided claims is not a JSON dict. object") 94 return (jose_header, claims) 95 96 @staticmethod 97 def generate(claims, private_key_data=None, algo=None): 98 """Generates and returns a new JSON Web Token. 99 :param dict claims: JWT claims set 100 :param str private_key_data: Decoded RSA private key data. 101 :rtype: str 102 """ 103 # Allow for unencrypted JWTs 104 if algo is not None: 105 priv_key = PrivateKey(*private_key_data) 106 else: 107 algo = "none" 108 # Create the JOSE Header 109 # https://tools.ietf.org/html/rfc7519#section-5 110 jose_header = {"typ": "JWT", "alg": algo} 111 payload = "{}.{}".format( 112 STRING_TOOLS.urlsafe_b64encode(json.dumps(jose_header).encode("utf-8")), 113 STRING_TOOLS.urlsafe_b64encode(json.dumps(claims).encode("utf-8")), 114 ) 115 # Compute the signature 116 if algo == "none": 117 jwt = "{}.{}".format(jose_header, claims) 118 return jwt 119 if algo == "RS256": 120 signature = STRING_TOOLS.urlsafe_b64encode( 121 sign(payload, priv_key, "SHA-256") 122 ) 123 elif algo == "RS384": 124 signature = STRING_TOOLS.urlsafe_b64encode( 125 sign(payload, priv_key, "SHA-384") 126 ) 127 elif algo == "RS512": 128 signature = STRING_TOOLS.urlsafe_b64encode( 129 sign(payload, priv_key, "SHA-512") 130 ) 131 else: 132 raise TypeError( 133 "Adafruit_JWT is currently only compatible with algorithms within" 134 "the Adafruit_RSA module." 135 ) 136 jwt = payload + "." + signature 137 return jwt 138 139 140 # pylint: disable=invalid-name 141 class STRING_TOOLS: 142 """Tools and helpers for URL-safe string encoding. 143 """ 144 145 # Some strings for ctype-style character classification 146 whitespace = " \t\n\r\v\f" 147 ascii_lowercase = "abcdefghijklmnopqrstuvwxyz" 148 ascii_uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 149 ascii_letters = ascii_lowercase + ascii_uppercase 150 digits = "0123456789" 151 hexdigits = digits + "abcdef" + "ABCDEF" 152 octdigits = "01234567" 153 punctuation = r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" 154 printable = digits + ascii_letters + punctuation + whitespace 155 156 @staticmethod 157 def urlsafe_b64encode(payload): 158 """Encode bytes-like object using the URL- and filesystem-safe alphabet, 159 which substitutes - instead of + and _ instead of / in 160 the standard Base64 alphabet, and return the encoded bytes. 161 :param bytes payload: bytes-like object. 162 """ 163 return STRING_TOOLS.translate( 164 b2a_base64(payload)[:-1].decode("utf-8"), {ord("+"): "-", ord("/"): "_"} 165 ) 166 167 @staticmethod 168 def urlsafe_b64decode(payload): 169 """Decode bytes-like object or ASCII string using the URL 170 and filesystem-safe alphabet 171 :param bytes payload: bytes-like object or ASCII string 172 """ 173 return a2b_base64(STRING_TOOLS._bytes_from_decode_data(payload)).decode("utf-8") 174 175 @staticmethod 176 def _bytes_from_decode_data(str_data): 177 # Types acceptable as binary data 178 bit_types = (bytes, bytearray) 179 if isinstance(str_data, str): 180 try: 181 return str_data.encode("ascii") 182 except: 183 raise ValueError("string argument should contain only ASCII characters") 184 elif isinstance(str_data, bit_types): 185 return str_data 186 else: 187 raise TypeError( 188 "argument should be bytes or ASCII string, not %s" 189 % str_data.__class__.__name__ 190 ) 191 192 # Port of CPython str.translate to Pure-Python by Johan Brichau, 2019 193 # https://github.com/jbrichau/TrackingPrototype/blob/master/Device/lib/string.py 194 @staticmethod 195 def translate(s, table): 196 """Return a copy of the string in which each character 197 has been mapped through the given translation table. 198 :param string s: String to-be-character-table. 199 :param dict table: Translation table. 200 """ 201 sb = io.StringIO() 202 for c in s: 203 v = ord(c) 204 if v in table: 205 v = table[v] 206 if isinstance(v, int): 207 sb.write(chr(v)) 208 elif v is not None: 209 sb.write(v) 210 else: 211 sb.write(c) 212 return sb.getvalue()