« Phase 08 · Warmup · Track Overview
Lab 01 — The Identity Fabric
The problem
A relationship manager asks a question in Teams. An orchestrator agent takes it, delegates to a payments-investigation agent, which calls core banking to read a payment.
Three hops. At the last one, core banking must be able to answer: who is doing this, on whose behalf, with what authority, and can I prove it?
The tempting answer is a service account with the union of every permission any agent might need. It works on day one and it is the finding an examiner writes up: every action is attributed to "the platform", nothing is bounded by what the user may do, and a leaked credential is permanent and unlimited.
You build the alternative: credentials that are derived from a verified assertion, narrowed at every hop, chained so the whole path is visible, and short-lived enough that revocation is a timeout rather than a process.
What you build
| # | Component | What it does |
|---|---|---|
| 1 | b64url_*, Claims, Signer | JWS with canonical JSON, and the claim set every check reads |
| 2 | _nest_actors / _flatten_actors | RFC 8693's nested act chain, and its counter-intuitive ordering |
| 3 | Verifier, ReplayCache | ten checks in a deliberate order, each stopping a named attack |
| 4 | narrow_scopes, require_no_escalation | the narrowing algebra — structurally incapable of widening |
| 5 | code_challenge, AuthorizationServer | OAuth 2.1: authorization code + mandatory PKCE, client credentials, OIDC ID tokens |
| 6 | TokenExchange | RFC 8693 — narrow the audience, subset the scope, append the actor, shorten the life |
| 7 | SpiffeID, SpireServer, mtls_authorize | workload identity from attested selectors; mTLS with explicit federation |
| 8 | IdentityRegistry, NHIState | the non-human identity lifecycle, with a human owner |
| 9 | JitCredentialBroker | mint at the moment of use, bound to a key, expiring in seconds |
Key concepts
| Concept | Where | Why it matters |
|---|---|---|
| Audience validation | Verifier.verify | the confused-deputy defence — a token for us must not work elsewhere |
alg from the signer, never the token | verify_signature | "alg: none" and algorithm confusion are the two classic JWT breaks |
| Constant-time compare | hmac.compare_digest | == leaks a signature byte by byte |
| Derived, not asserted | TokenExchange.exchange | the chain comes from a verified subject token, never from the request |
| Narrowing is structural | narrow_scopes | the function cannot return a scope it was not given |
| Refuse, don't silently drop | require_no_escalation | a caller that thinks it has authority it lacks fails far from the cause |
| Chain cycles | chain_cycle | A→B→A is a loop across two owners with nobody able to see it |
| Never outlive the parent | lifetime min(...) | a derived credential with a longer life is a privilege escalation in time |
| Impersonation erases the chain | delegation=False | which is exactly why a regulated platform disables it |
| Secret-less identity | SpireServer.attest | the workload presents nothing; the platform observes and issues |
| All selectors must match | RegistrationEntry.matches | a subset match lets a workload claim a narrower identity |
| Ambiguity is refused | ambiguous_attestation | guessing assigns identity nondeterministically |
| Federation is explicit | mtls_authorize | cross-domain trust is never a default |
| Every NHI has a human owner | IdentityRegistry.register | the standard identity-audit finding |
| Revocation latency = TTL | JitCredentialBroker | why 60 seconds is a design decision, not a default |
Files
| File | Role |
|---|---|
| lab.py | your implementation |
| solution.py | reference; python solution.py runs a six-part worked session |
| test_lab.py | 104 tests |
| requirements.txt | pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
python solution.py
Success criteria
-
All 104 tests green against your
lab.py. -
Base64url round-trips for every padding case, and emits no
=. -
A token whose header says
alg: noneis refused withinvalid_alg. - A token minted for another audience is refused — even with a valid signature.
- Clock skew is tolerated in both directions, and has a limit.
-
actnests with the most recent actor outermost, and round-trips earliest-first. -
narrow_scopescan never return a scope outsideheld, for any input. -
PKCE is mandatory,
S256only, and aplainchallenge raises. - An authorization code is single-use, exact-redirect-matched, and client-bound.
- An exchange that does not narrow the audience is refused.
- An agent already in the chain — or the user itself — cannot be appended.
- A derived token never outlives its parent, even when a longer life is requested.
- Attestation with a subset of the registered selectors fails.
-
Two matching registration entries produce
ambiguous_attestation, not a guess. - A suspended identity cannot receive a credential.
- A token bound to a key is useless without it.
How this maps to the real stack
| This lab | The real thing | What we simplified |
|---|---|---|
Signer (HS256) | RS256/ES256 with a JWKS endpoint and key rotation | symmetric signing means the verifier could mint; real deployments must not allow that |
Verifier | your API gateway, a resource-server library, or Entra's validation middleware | no JWKS fetching, caching or rollover |
AuthorizationServer | Microsoft Entra ID, Okta, Auth0, Keycloak | no consent, no refresh tokens, no discovery document, no DCR |
TokenExchange | Entra's on-behalf-of flow; Keycloak and Auth0 token exchange; an in-house STS | no requested_token_type, no actor-token parameter, no impersonation policy engine |
SpireServer | SPIRE, with node and workload attestors (k8s, AWS, GCP, Docker) | no attestation plugins, no X.509 SVIDs, no Workload API socket, no trust-bundle rotation |
mtls_authorize | Istio/Linkerd mTLS with SPIFFE identities, or Envoy RBAC | no TLS at all — the authorization logic is the point |
IdentityRegistry | an NHI governance product, or an internal service | no discovery of unmanaged identities, no attestation of ownership |
JitCredentialBroker | HashiCorp Vault dynamic secrets, cloud STS AssumeRole, Entra managed identity | no secret engines, no lease renewal |
Honest limits. No asymmetric signing, so nothing here exercises key distribution — which is most of the operational work. No refresh tokens, and therefore no refresh-token rotation or reuse-detection, which is where a lot of OAuth 2.1's remaining subtlety lives. No revocation list — the lab's revocation is expiry plus registry state, which is the right default and not the whole story. And the delegation chain is carried in a claim; a real deployment must also decide what happens when a hop crosses into a system that cannot read it.
Extensions
- Asymmetric signing and a JWKS endpoint. Then rotate the key with an overlap window and watch which of your tests break. Key rollover is where identity systems actually fail.
- Refresh tokens with rotation and reuse detection. A reused refresh token means it was stolen; the correct response is to revoke the whole family. Implement it and test the race.
- DPoP (RFC 9449). Replace the lab's
cnfthumbprint with a real proof-of-possession: a signed JWT per request, with a nonce and replay protection. - Continuous authorization. Re-verify mid-task (Phase 09): the user's entitlements change while a four-hour task is parked. What must interrupt?
- A revocation channel. Suspension currently takes effect at the next credential request. Add a kill switch that beats the TTL, and decide what it costs you in availability.
- Cross-domain federation. Two SPIRE trust domains with a federation bundle. What must be re-verified rather than trusted?
- The parked-task problem. A task waiting four hours for an approval outlives every sensible credential. Implement the correct shape: hold a reference, re-mint on resume, re-evaluate policy at that moment.
Interview / resume bullets
- "Built the platform's agent identity model: every credential is derived from a verified assertion via RFC 8693 token exchange, narrowed in audience and scope at each hop, appended to an unforgeable delegation chain, and issued with a lifetime of seconds — so core banking can see that user U asked, orchestrator O delegated, and agent A acted."
- "Replaced shared service accounts with SPIFFE-style workload identity: the platform attests verifiable properties of a running workload and issues a short-lived SVID, which removed long-lived secrets from the agent fleet entirely."
- "Made privilege escalation structurally impossible in the exchange path — the narrowing function cannot return a scope it was not given — and made silent under-granting an error rather than a surprise."
- "Implemented an NHI lifecycle with a mandatory human owner and a declared state machine, so 'which agents exist, who owns them, and are they still needed' became a query rather than an investigation."