Lab 01 — OAuth2 / OIDC / JWT Engine
Phase: 03 — Microsoft Entra ID: OAuth2 / OIDC / JWT | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every authenticated call into Azure — and into the APIs you build on it — carries a JWT that Microsoft Entra ID minted, and the only thing standing between your service and a forged or replayed token is a validator that runs its checks in the right order and fails closed. This lab builds that machine: the
base64urlcodec every token field uses, the PKCE proof that lets a secret-less public client log a user in safely, an authorization server that runs the auth-code+PKCE and client-credentials grants offline, and a JWT validator that checks signature →iss→aud→ time → scope/role in exactly the order that prevents the confused-deputy attack. Strip away TLS and the RS256 keypair, and what's left is HMAC, SHA-256, and a careful sequence ofifstatements — the part interviewers probe and breaches turn on.
What you build
b64url_encode/b64url_decode— URL-safe base64 without padding (RFC 7515). Every JWT segment and the PKCEcode_challengeare encoded this way; using standard base64 or leaving padding on is the classic "works in my test, 401s in Entra" bug.pkce_challenge(verifier, method)—S256=BASE64URL(SHA256(verifier)), plus theplaindowngrade. This is the one-way function that lets a public client prove, at the token endpoint, that it's the app that started the login — without ever holding a secret.make_jwt/decode_jwt_unverified— sign a JWT with HS256 overbase64url(header).base64url(payload)(deterministic), and split/decode one without trusting it (to read thekidbefore you verify).validate_jwt(...)— the heart of the lab: verify the signature with a constant-time HMAC compare, theniss, thenaud(string or list), thennbf/expwith leeway, thenrequired_scopes⊆scpand/orrequired_roles⊆roles— raising aValueErrorthat names which check failed.AuthorizationServer— Entra's/authorizeand/token, offline:authorize(...)mints a one-time PKCE-bound code;token_authorization_code(...)exchanges code + verifier for access + ID tokens (only if the verifier hashes to the stored challenge);token_client_credentials(...)issues an app-only token carryingroles(no user, no ID token) after a constant-time secret check.
Key concepts
| Concept | What to understand |
|---|---|
| base64url, no padding | the JOSE alphabet (-/_, no =); every token field uses it |
PKCE S256 | challenge = BASE64URL(SHA256(verifier)); defeats auth-code interception for public clients |
| access vs ID token | access token = aud is the API, carries scp/roles (authorization); ID token = aud is the client, proves who the user is (authentication) |
scp vs roles | scp = space-delimited delegated scopes (a user is present); roles = app roles (daemon, no user) |
| validation order | structure/alg → signature → iss → aud → nbf/exp → scope/role; you never read a claim you haven't verified |
| confused deputy | skip aud and a token minted for service B is accepted by your service A — the canonical token-replay bug |
alg: none | reject any algorithm but the one you signed with, or an attacker strips the signature |
| leeway / clock skew | ±300 s absorbs NTP drift between issuer and validator (Entra's own allowance) |
| single-use code | the auth code is popped on redemption (success or failure) so an intercepted code can't be replayed |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py runs a full PKCE flow, a validation, and a client-credentials flow |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 32 tests pass against your implementation.
- You can recite the validation order and explain why signature must come before any
claim read (an unverified payload is attacker-controlled JSON), and why
audmust be checked at all (the confused deputy). - You can explain why
test_pkce_s256_known_answer_vectorrecomputes the challenge withhashlib.sha256in the test — and why the solution's example printsE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM(it's the RFC 7636 Appendix B test vector, a free correctness check that your PKCE matches the standard). - You can explain why
test_validate_rejects_alg_none_attackexists and what it would mean for a validator to acceptalg: none. - You can explain why
test_authcode_is_single_usematters: PKCE stops an attacker who intercepts the code, and single-use stops one who replays it. - You can state, without looking, the difference between
scpandrolesand which grant produces each.
How this maps to real Azure (Microsoft Entra ID)
| The lab | Real Entra ID |
|---|---|
AuthorizationServer | the tenant's v2.0 endpoints: …/{tenant}/oauth2/v2.0/authorize and …/oauth2/v2.0/token |
RegisteredClient | an app registration (client id, redirect URIs, a secret/cert for confidential clients, exposed scopes, app roles) and its service principal in the tenant |
authorize + PKCE code | the front-channel redirect that authenticates the user (where Conditional Access runs) and returns a one-time code |
token_authorization_code | the back-channel POST /token with grant_type=authorization_code + code_verifier; returns access + ID (+ refresh) tokens |
token_client_credentials | grant_type=client_credentials with a secret/cert; returns an app-only token with roles |
| HS256 signing | Entra signs RS256 (asymmetric); you verify with the tenant's public key from JWKS, picked by the token's kid |
validate_jwt | what every protected API (and API Management) does on each request, against the keys at the tenant's /.well-known/openid-configuration → jwks_uri |
expected_iss / expected_aud | the tenant issuer (https://login.microsoftonline.com/{tid}/v2.0) and your API's Application ID URI / client id |
What the miniature leaves out (and why it's fine): real Entra signs RS256 with a
private key you never see and rotates its JWKS, so production validators fetch and
cache the JWKS by kid; it runs Conditional Access (device, location, risk, MFA) at
/authorize; it issues refresh tokens and supports the on-behalf-of and device-code
grants; and the whole thing is over TLS. None of that changes the validation order or the
claims — which is exactly the part interviewers probe and incidents turn on. We use HS256
(symmetric) and an injected clock purely to stay offline and deterministic; swap the
signature primitive for RSA-verify-against-JWKS and the logic is the real thing.
Extensions (build these in your own tenant)
- RS256 + JWKS: replace HS256 with an RSA keypair; publish a JWKS document
(
{"keys":[{"kid":..., "n":..., "e":...}]}); havevalidate_jwtselect the key by the token'skidand verify with the public key. Now you've modeled real Entra exactly. - Discovery: serve a
/.well-known/openid-configurationJSON withissuer,jwks_uri,authorization_endpoint,token_endpoint— and have the client discover endpoints instead of hard-coding them (what every Entra SDK does on startup). - On-behalf-of (OBO): add a grant where a middle-tier API exchanges an incoming user access token (audience = the API) for a downstream token (audience = a second API), preserving the user identity — the flow that powers API-calls-API with the user's context.
- Device code: add the
device_authorizationgrant (return auser_code+verification_uri, poll/tokenuntil the user approves) — the flow for TVs, CLIs, and IoT with no browser. - Refresh tokens: issue and redeem a refresh token; enforce single-use rotation and a longer expiry than the access token.
- Wire it to a real tenant:
az ad app create, grab a token withaz account get-access-token, decode it on jwt.ms, and check yourvalidate_jwtaccepts it (with RS256/JWKS) and rejects it with the wrongaud.
Interview / resume
- Talking points: "Walk me through the authorization-code + PKCE flow and what PKCE
defends against." / "Exactly what does a resource API check on an incoming JWT, and in
what order?" / "What's the difference between
scpandroles, and which grant produces each?" / "What's the confused-deputy attack and which single check prevents it?" / "Access token vs ID token — what's each for and who's theaud?" - Resume bullet: Built an OAuth2/OIDC authorization-server and JWT-validation engine
(authorization-code + PKCE and client-credentials grants, constant-time signature
verification, ordered
iss/aud/exp/nbfandscp/rolesclaim checks with clock skew) modeling Microsoft Entra ID token issuance and resource-API validation.