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 base64url codec 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 → issaud → 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 of if statements — 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 PKCE code_challenge are 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 the plain downgrade. 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 over base64url(header).base64url(payload) (deterministic), and split/decode one without trusting it (to read the kid before you verify).
  • validate_jwt(...) — the heart of the lab: verify the signature with a constant-time HMAC compare, then iss, then aud (string or list), then nbf/exp with leeway, then required_scopesscp and/or required_rolesroles — raising a ValueError that names which check failed.
  • AuthorizationServer — Entra's /authorize and /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 carrying roles (no user, no ID token) after a constant-time secret check.

Key concepts

ConceptWhat to understand
base64url, no paddingthe JOSE alphabet (-/_, no =); every token field uses it
PKCE S256challenge = BASE64URL(SHA256(verifier)); defeats auth-code interception for public clients
access vs ID tokenaccess token = aud is the API, carries scp/roles (authorization); ID token = aud is the client, proves who the user is (authentication)
scp vs rolesscp = space-delimited delegated scopes (a user is present); roles = app roles (daemon, no user)
validation orderstructure/algsignatureissaudnbf/exp → scope/role; you never read a claim you haven't verified
confused deputyskip aud and a token minted for service B is accepted by your service A — the canonical token-replay bug
alg: nonereject 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 codethe auth code is popped on redemption (success or failure) so an intercepted code can't be replayed

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py runs a full PKCE flow, a validation, and a client-credentials flow
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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 aud must be checked at all (the confused deputy).
  • You can explain why test_pkce_s256_known_answer_vector recomputes the challenge with hashlib.sha256 in the test — and why the solution's example prints E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-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_attack exists and what it would mean for a validator to accept alg: none.
  • You can explain why test_authcode_is_single_use matters: PKCE stops an attacker who intercepts the code, and single-use stops one who replays it.
  • You can state, without looking, the difference between scp and roles and which grant produces each.

How this maps to real Azure (Microsoft Entra ID)

The labReal Entra ID
AuthorizationServerthe tenant's v2.0 endpoints: …/{tenant}/oauth2/v2.0/authorize and …/oauth2/v2.0/token
RegisteredClientan 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 codethe front-channel redirect that authenticates the user (where Conditional Access runs) and returns a one-time code
token_authorization_codethe back-channel POST /token with grant_type=authorization_code + code_verifier; returns access + ID (+ refresh) tokens
token_client_credentialsgrant_type=client_credentials with a secret/cert; returns an app-only token with roles
HS256 signingEntra signs RS256 (asymmetric); you verify with the tenant's public key from JWKS, picked by the token's kid
validate_jwtwhat every protected API (and API Management) does on each request, against the keys at the tenant's /.well-known/openid-configurationjwks_uri
expected_iss / expected_audthe 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":...}]}); have validate_jwt select the key by the token's kid and verify with the public key. Now you've modeled real Entra exactly.
  • Discovery: serve a /.well-known/openid-configuration JSON with issuer, 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_authorization grant (return a user_code + verification_uri, poll /token until 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 with az account get-access-token, decode it on jwt.ms, and check your validate_jwt accepts it (with RS256/JWKS) and rejects it with the wrong aud.

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 scp and roles, 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 the aud?"
  • 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/nbf and scp/roles claim checks with clock skew) modeling Microsoft Entra ID token issuance and resource-API validation.