Signed Tokens and Secret Keys · practice
Tokens That Expire
The token of Lesson 1 says who it is for and proves that the server wrote it. It also works forever, like the fixed
In this lesson you write the two signing.py: issue_token makes a token, and read_token decides whether to believe one.
Text that fits in a header
A token travels in an Authorization: Bearer header, and that = only at its very end. =, and in payload.tag the padding would land in the middle. So signing.py now has two small helpers, which you can read and never need to change:
def b64u_encode(data):
"""Return bytes as URL-safe base64 text without the = padding."""
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def b64u_decode(text):
"""Return the bytes that b64u_encode turned into text."""
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
b64u_encode takes bytes and returns text with the padding removed. b64u_decode puts the padding back and returns the bytes. Use these two wherever a payload becomes text or text becomes a payload.
Put the expiry inside
The payload gets a third field:
payload = {"user": claims["user"], "can_write": claims["can_write"], "exp": expires}
exp is a time in seconds, the same kind of number that state["clock"]() returns. Build the payload field by field, as shown. claims is a
What matters most is where exp sits: inside the payload, so inside the text that the tag covers. The picture from Lesson 1 still holds, with one more field in the issue_token turns the payload into text with b64u_encode(json.dumps(payload).encode("utf-8")), signs that text with Chapter 2’s sign, and returns the text, a dot, and the tag.
A token is payload.1731240900.tag, and the tag covers the payload only. What can its holder do?
Check the tag, then decode
read_token receives text from a stranger. Until the tag has been checked, that is all it is: a stranger’s bytes. Decoding and parsing them runs your code on input nobody vouched for, and using a field from them is worse.
So the order is fixed. Split the token at the dots. Check the tag over the first part with verify(key, ...), which is Chapter 2’s function and compares with hmac.compare_digest. Only when the tag is right, decode the first part with b64u_decode and parse it with json.loads. Then look at exp.
verify takes the message as bytes and the tag as text, so the first part goes in as parts[0].encode("utf-8"). read_token must never raise for a token, whatever it holds.
read_token decodes the payload to read exp and checks the tag afterward. A token with a wrong tag arrives. What is wrong with that order?
Every wrong token is None
A token can be wrong in several ways: it does not have exactly two parts, its tag is wrong, its tag was made with another key, or its exp has passed. read_token gives one answer for all of them, None, as Chapter 3 gave one answer for every wrong login. The caller learns that the token is not good, and nothing about why. A token that passes every check gives its payload back as a dict, so the caller can read user and can_write from it.
A token expires when now has reached exp: at that second, and at every second after it. now is an read_token, so nothing in signing.py reads a clock. The server will pass state["clock"](), and a test can pass any number it likes.
Fifteen minutes
Lesson 1 named the price of a token the server does not store: nobody can take it back. Expiry is the only way this token ends, so its life has to be short. In this chapter’s project the server sets exp at most 900 seconds ahead, which is 15 minutes. A token that leaks is then a problem for 15 minutes, not for as long as the server runs.
Your task
In signing.py, write issue_token(key, claims, *, expires) and read_token(key, token, *, now). The exercise
python -c "import signing; k = signing.new_key(); t = signing.issue_token(k, {'user': 'ana', 'can_write': True}, expires=2000); print(t); print(signing.read_token(k, t, now=1999)); print(signing.read_token(k, t, now=2001)); print(signing.read_token(signing.new_key(), t, now=1999))"
The first line is the token. Its payload text is eyJ1c2VyIjogImFuYSIsICJjYW5fd3JpdGUiOiB0cnVlLCAiZXhwIjogMjAwMH0, with no = at the end, and after the dot come 64 hex characters that differ on every run, because new_key() makes a new key each time. The other three lines are the same on every run:
{'user': 'ana', 'can_write': True, 'exp': 2000}
None
None
One second before exp the token gives its payload back. One second after, it is None. Read with another key, it is None as well.
Notice where the key came from: signing.new_key(), made on the spot and gone when the command ends. A server cannot work that way. Where its key lives is the subject of the next lesson.
Then press Check my work.
Task
Write the two token signing.py.
Write
issue_token(key, claims, *, expires). Build the payload{"user": claims["user"], "can_write": claims["can_write"], "exp": expires}. Turn it into text withb64u_encode(json.dumps(payload).encode("utf-8")). Return that text, a dot, andsign(key, ...)of the text encoded as. Write
read_token(key, token, *, now). Split the token at dots; anything but two parts isNone. Check the tag withverify(key, ...)over the first part encoded as UTF-8; a wrong tag isNone. Only then decode the first part withb64u_decodeand parse it withjson.loads. Whennowhas reachedexp, returnNone. Otherwise return the payload.
Run the python -c line from the lesson in the
Then press Check my work. It calls issue_token with a key, users, and expiry times you have not seen, and with claims that hold an extra field, which must not reach the payload; it decodes the payload itself and computes the tag itself. It calls read_token with a valid token one second before and one second after its exp, and with tokens that must give None and never an error: a changed payload with the old tag, a token from another key, a shortened tag, an empty tag, and texts that are no token at all. It also counts calls: reading a valid token must compare the tag through verify or hmac.compare_digest, and reading a token with a wrong tag must not call b64u_decode or json.loads. Nothing from your terminal is graded.