Keycloak and Enterprise Identity
Adding Token Verification Middleware to a FastAPI App
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
- Start
/root/app/main.pyon 127.0.0.1:8160. The verification target is the pre-imported realmlabhuband the expectedaudislab-api. The users aredev1(order-reader) andadmin1(order-admin), and the passwords are in/opt/fixtures/kc/realm-info.env.GET /publicreturns 200 without authentication. - If you call
GET /mewithout a token, it is 401 and the response must have aWWW-Authenticateheader. - Cache the JWKS in memory.
GET /_debug/jwksreturns{"cached":true,"keys":<n>,"fetches":<n>}, and after calling/me5 times,fetchesmust still be 2 or less. From this step on, save the access token ofdev1to/root/app/token.txtand that ofadmin1to/root/app/admin_token.txt, one line each. - If you call
GET /mewith a valid token, it returns 200 and{"sub":"<값>","username":"dev1"}(the value of sub). - 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. - If you call with a token whose
auddiffers, it is 401 and the body must containaudience. 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
- 401 means "I don't know who you are", and 403 means "I know who you are but you lack permission".
- For the JWKS cache, the standard is to set a TTL but refresh immediately on a
kidmiss. - Keep the clock skew allowance (leeway) very small, within a few seconds.
- Common mistake 1: giving 401 for insufficient permission — the client logs in again unnecessarily.
- Common mistake 2: not passing the expected
issuerandaudienceto the verification library — if only the signature matches, it passes.
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.