0%

State and Identity · practice

Bearer Tokens and the 403 Line

A login now knows who a user is. The books do not care yet: POST, PUT, and DELETE still accept anyone. Run the helper’s tokens scenario:

python pl_http.py tokens

It sends the same POST /books, with a body and its Content-Length, four times: with no credentials, with a token nobody issued, with a token that may only read, and with a token that may write. Then it deletes book 3 with the writer’s token. Today all four POSTs answer HTTP/1.0 201 Created, with location: /books/3, /books/4, /books/5, and /books/6: four new books, three of them from clients that should not have been able to add anything.

A token instead of a password

Clients that change the send a token: Authorization: Bearer <token> (RFC 6750). A token is one long secret , and it is the whole credential. Whoever holds it, whoever “bears” it, gets in, which is where the name comes from. There is no user name to send and no password to check.

Tokens have two advantages over sending a password with every write. The password stays home: only the login ever sends it. And a token can be limited and withdrawn on its own: this one may only read, that one stops working tomorrow, with no password changed. Here, tokens are issued in advance, like the API keys a service hands its users, and sample_state() holds two:

tokens = {
    "tok-ana-writes-7Qx2": {"user": "ana", "can_write": True},
    "tok-ben-reads-K9pz": {"user": "ben", "can_write": False},
}

How a real service issues tokens, often through a protocol called OAuth, is outside this course.

bearer_token(request) reads the header the way basic_credentials did: exactly one authorization , partitioned at the first space, the scheme bearer in any case, and the rest stripped. When the scheme is wrong or the rest is empty, there is no token, and it returns None.

What does this print?

print("Bearer  tok".partition(" "))

The token comes back as ' tok', with the extra space in front, so strip it before you look it up. Tokens themselves are case-sensitive: TOK-ana-writes-7Qx2 is not the same token, and gets no special treatment.

401, 403, 429

Three statuses refuse a request because of who sent it, or when:

RequestReplyWhat it says
POST /books, no token or an unknown one401 Unauthorized, with www-authenticate: Bearer“Who are you? Send a token.”
POST /books, a token that may only read403 Forbidden“I know who you are, and no.”
too many requests in a short time429 Too Many Requests“Not now. Try again later.”

401 means the server does not know who you are: there was no credential, or one it does not recognize. It carries a challenge saying how to authenticate, here the bare scheme name, www-authenticate: Bearer, built with your unauthorized("Bearer"). 403 means the server knows exactly who you are and refuses anyway. Sending the same token again will not change the answer, so it needs no challenge. 429, which you met in Python in Practice, is about timing, not identity: the same request may succeed later. Keeping them apart tells a client what to do next: get a credential, give up, or wait.

POST /books arrives with no authorization header. Which reply?

A known token that may only read sends DELETE /books/7. Which status?

write_error(request, state) makes that decision. Get the token with bearer_token; when there is one, look it up with state["tokens"].get(token). No token or no entry: return unauthorized("Bearer"). An entry whose can_write is false: return a 403 JSON error. Otherwise return None: this request may write.

Where the check goes

SERVICE_CONTRACT.md fixes the order of every check on a write, and the token takes the third place:

  1. Route and , in handle: 404 and 405.

  2. Framing, in read_body, before handle runs: 411, 400, 413.

  3. Credentials: 401 and 403.

  4. The book id: 404.

  5. The body: 415, 400, 422.

Credentials before the book id means a client with no valid token learns nothing about which books exist: PUT /books/99 gets the same 401 whether book 99 exists or not. Credentials before the body means a stranger’s write is refused without being parsed. A refused write changes nothing in the state. One cost of this order: framing comes first, so a client with no token can still make the server read up to 10000 bytes before it is refused. The contract says so.

PUT /books/99, where no book 99 exists, arrives with no token. Which status?

In handle, the POST branch of /books calls write_error first, and checks the body only when it returns None. In the /books/{id} branch, a PUT or DELETE calls write_error before the book id is checked. GET and HEAD need no token, and a method a path does not allow still gets 405.

Your task

In reading_server.py, write bearer_token(request) and write_error(request, state), and call write_error before each of the three writes as described. Then run the scenario again:

python pl_http.py tokens

The first two POSTs now answer HTTP/1.0 401 Unauthorized, each with this line under it:

  www-authenticate: Bearer

The reader’s token gets HTTP/1.0 403 Forbidden, and only the writer’s POST creates a book, location: /books/3, which the DELETE then removes with HTTP/1.0 204 No Content. Run python -m pytest -q too: your tests still pass, because every write in tests/test_server.py sends the sample writer token.

This server finds a token with a lookup. A service that compares one secret with one expected value uses hmac.compare_digest, which takes the same time whether the first character or the last one differs; attacks that measure that time are outside this course.

Then press Check my work. Every write is now guarded, and the next lesson, the chapter’s project, lets a session end.

Task

Protect every write with a bearer token, in reading_server.py.

  • bearer_token(request) returns the token from an authorization header, or None. There must be exactly one authorization , its scheme must be bearer in any case, and the token is the rest, stripped. An empty token is None.

  • write_error(request, state) returns None when the request may write. With no token, or a token that is not in state["tokens"], it returns unauthorized("Bearer"): a 401 with www-authenticate: Bearer. With a known token whose can_write is false, it returns a 403 error. Tokens are compared exactly, case included.

  • In handle, call write_error for POST /books, PUT /books/{id}, and DELETE /books/{id} after the route and are known and before the book id or the body is checked. When it returns a response, answer with it and change nothing.

  • GET and HEAD need no token, and a method a path does not take still gets 405.

  • No reply repeats a token. Word the errors any way you like.

Run python pl_http.py tokens in the to see the 401s, the 403, and the one write that succeeds, and python -m pytest -q to see your tests still pass.

Then press Check my work. It calls bearer_token with headers you have not seen, and handle with a state of its own and tokens you have not seen: POST, PUT, and DELETE with no header, an unknown token, the writer’s token in another case, a malformed header, the reader’s token, and the writer’s token. It checks each status, the www-authenticate scheme, that the state is unchanged after every 401 and 403, the order against a missing book id and a bad body, that reads need no token, and that no reply repeats a token. Nothing from your terminal is graded.