TT Lab
Get started
Learn Learning paths Courses

Keycloak and Enterprise Identity

Adding Token Verification Middleware to a FastAPI App

Continue in TT Lab

Goal

Attach JWT verification middleware to a FastAPI app yourself, and handle no token, expired, wrong audience, and insufficient permission each with the correct status code.

Why it matters

Token verification middleware is mostly solved with a library, but if you give one option wrong, it is silently breached. And the distinction of status codes is also often wrong — if the token is missing or invalid, it is 401, and if the token is valid but the permission is insufficient, it is 403. If you blur the two, the client cannot tell "must I log in again?" from "must I request permission?". The JWKS cache in step 3 also matters in practice. If you ask the authentication server for the public key on every request, the advantage of local verification disappears. But if you cache indefinitely, all verification fails right after a key rotation. Caching but refreshing once when the kid is not found is the standard implementation, and in this lab you build that yourself.

Steps

  1. Start /root/app/main.py on 127.0.0.1:8160. The verification target is the pre-imported realm labhub and the expected aud is lab-api. The users are dev1 (order-reader) and admin1 (order-admin), and the passwords are in /opt/fixtures/kc/realm-info.env. GET /public returns 200 without authentication.
  2. If you call GET /me without a token, it is 401 and the response must have a WWW-Authenticate header.
  3. Cache the JWKS in memory. GET /_debug/jwks returns {"cached":true,"keys":<n>,"fetches":<n>}, and after calling /me 5 times, fetches must still be 2 or less. From this step on, save the access token of dev1 to /root/app/token.txt and that of admin1 to /root/app/admin_token.txt, one line each.
  4. If you call GET /me with a valid token, it returns 200 and {"sub":"<값>","username":"dev1"} (the value of sub).
  5. If you call with an expired token, it is 401 and the body must contain expired. The expired token is in /opt/fixtures/kc/expired.jwt.
  6. If you call with a token whose aud differs, it is 401 and the body must contain audience. Such a token is in /opt/fixtures/kc/wrongaud.jwt.

The failure-case tokens for steps 5 and 6 are made by /opt/fixtures/kc/gen-tokens.py when the Pod starts. If you do not see the two files, run that script once yourself — if the fixture directory is read-only, they are created under /tmp/lab-kc/. 7. GET /admin is 200 only with the order-admin role, and 403 without it. It must be 403, not 401. 8. With /root/app/e2e.sh, test the five cases (no token, valid, expired, wrong aud, insufficient permission) in order, and in /root/app/e2e.out write no_token=401 valid=200 expired=401 bad_aud=401 forbidden=403.

Notes

Start the app and check the public endpoint

Start /root/app/main.py on 127.0.0.1:8160. The verification target is the pre-imported realm labhub and the expected aud is lab-api. The users are dev1 (order-reader) and admin1 (order-admin), and the passwords are in /opt/fixtures/kc/realm-info.env. GET /public returns 200 without authentication.

There must also be one path that needs no authentication, so that health checks work.

Give 401 when there is no token

If you call GET /me without a token, it is 401 and the response must have a WWW-Authenticate header.

A 401 must come with a header telling which authentication is needed.

Implement the JWKS cache

Cache the JWKS in memory. GET /_debug/jwks returns {"cached":true,"keys":<n>,"fetches":<n>}, and after calling /me 5 times, fetches must still be 2 or less. From this step on, save the access token of dev1 to /root/app/token.txt and that of admin1 to /root/app/admin_token.txt, one line each.

If you ask the authentication server on every request, that is the bottleneck. Cache, but refresh if you cannot find the key.

Let a valid token through

If you call GET /me with a valid token, it returns 200 and {"sub":"<값>","username":"dev1"} (the value of sub).

You must do both signature verification and claim checks. Include the subject identifier in the response.

Reject an expired token

If you call with an expired token, it is 401 and the body must contain expired. The expired token is in /opt/fixtures/kc/expired.jwt.

Test with a token whose expiry time has passed. Keep the allowance for clock skew very small.

Reject a token for the wrong audience

If you call with a token whose aud differs, it is 401 and the body must contain audience. Such a token is in /opt/fixtures/kc/wrongaud.jwt.

Even if the same server issued it, you must reject it if it is not for our API.

Attach role-based authorization

GET /admin is 200 only with the order-admin role, and 403 without it. It must be 403, not 401.

Authentication at the entry point, authorization at the resource point. If there is no permission, it is 403, not 401.

Verify the five cases at once

With /root/app/e2e.sh, test the five cases (no token, valid, expired, wrong aud, insufficient permission) in order, and in /root/app/e2e.out write no_token=401 valid=200 expired=401 bad_aud=401 forbidden=403.

Bundle the previous cases into one script and compare with the expected status codes.