PortfolioTech Notebook
Menu
patterns

oidc-keycloak-zero-trust

OIDC, Keycloak & Zero-Trust JWT

The problem: who is calling, and can we trust the network?

The SPA must log a user in, and every service must know which user is behind a request — not just "someone authenticated at the edge." The naive model ("the gateway checked the token, so anything inside the cluster is trusted") fails the moment a second internal service, a misconfigured route, or a compromised pod can reach account-service directly. We want every service to independently prove the caller's identity, and we want that identity to ride the whole async saga.

The pattern

Two patterns stacked:

OIDC (OpenID Connect) — an identity layer over OAuth 2.0. An identity provider (Keycloak) authenticates the user and issues a signed JWT access token. The SPA gets the token via the Authorization Code flow with PKCE (the browser-safe flow: no client secret, an exchange code bound to a one-time verifier). The token is a signed bag of claims — sub (user id), preferred_username, exp — that anyone can verify with Keycloak's public keys but no one can forge.

Zero trust — never trust the network, always verify the token. The gateway validates it and each service validates it again against Keycloak's JWKS (public signing keys). A request that sneaks past the gateway still hits a closed door.

                 Authorization Code + PKCE
  browser ──────────────────────────────────► Keycloak (realm: banking)
     │  ◄──────────── signed JWT ─────────────────┘
     │
     ▼  Bearer <JWT>
  Gateway ── validates JWT (JWKS) ──► forwards token downstream
     │
     ├──► account-service  ── validates JWT *again* (AddJwtBearer)
     └──► transfer-service ── validates JWT *again* (jwks-rsa guard)
                                   │
                                   └─ stamps sub into metadata.userId ─► every saga event

Where it lives in our code

  • Realm: keycloak/realm-banking.json — imported on startup (--import-realm). Public client banking-web (PKCE S256, standard flow for the SPA + direct-access-grants so e2e/tests can password-grant), users alice/bob, realm role customer. The basic client scope is in defaultClientScopeswithout it the access token has no sub claim and every downstream "no subject" check fails.
  • SPA login: frontend/src/app/app.config.ts (angular-auth-oidc-client, PKCE) + frontend/src/app/auth/api-auth.interceptor.ts (attaches the bearer token + correlation id to every gateway call).
  • Gateway validation: gateway/src/Gateway/Program.csAddJwtBearer with MetadataAddress + ValidIssuers, plus the authenticated policy on /api/* routes.
  • Service re-validation (zero trust):
    • account-service: account-service/src/AccountService/Program.csAddJwtBearer gated on Auth:Enabled (default true; false in integration tests), RequireAuthorization() on the endpoints.
    • transfer-service: transfer-service/src/auth/jwt-auth.guard.ts — a Nest guard verifying the JWT against the JWKS with jwks-rsa, AUTH_ENABLED env (default true).
  • Identity rides the saga: transfer-service stamps the authenticated sub into metadata.userId (transfers.service.ts), and account-service consumers copy incoming metadata onto every event they publish — so the initiating user is attributable on every message in the flow.

The dual-issuer wrinkle (a real cross-network gotcha)

The same Keycloak realm has two addresses: http://localhost:8180/... from the browser/host and http://keycloak:8080/... from inside the compose network. A JWT's iss claim is whatever URL the caller used to get it — so a browser token says localhost:8180, an in-network token says keycloak:8080. Same signing keys, different issuer string. The fix: every validator accepts both ValidIssuers and pins the JWKS via MetadataAddress, instead of deriving everything from a single Authority. This is exactly the kind of thing that "works on my machine" and breaks in Docker if you don't anticipate it.

Trade-offs

  • Re-validating in every service costs a little CPU and a JWKS fetch (cached). The payoff is that no service has to trust the network or the gateway. Worth it.
  • ValidateAudience = false (documented in the code): Keycloak puts its own aud in tokens by default; a production setup adds an audience mapper for this API and validates it so a token minted for another app can't be replayed here. We skip it for demo simplicity and say so out loud.
  • Public client + PKCE, no secret: correct for a SPA (a browser can't keep a secret). PKCE is what makes the secret-less Authorization Code flow safe against code interception.

Interview Q&A

Q: Authorization Code + PKCE vs. the implicit flow — why PKCE? The implicit flow returned tokens directly in the URL fragment — leaky (browser history, referrer headers) and now deprecated. Authorization Code returns a short-lived code that's exchanged for tokens over a back channel; PKCE binds that code to a one-time code_verifier the SPA generated, so an intercepted code is useless without the verifier. It's the OAuth working group's current recommendation for public clients.

Q: Access token vs. ID token — which goes to the API? The access token. The ID token is about the authentication event and is for the client (the SPA) to learn who logged in; sending it to APIs is a common mistake. The access token is the bearer credential APIs validate and authorize on.

Q: How does a service validate a JWT without calling Keycloak per request? It fetches Keycloak's JWKS (public keys) once from the discovery document and caches it, then verifies the token's RS256 signature locally. No per-request round trip — verification is offline math. Key rotation is handled by re-fetching JWKS when an unknown kid appears.

Q: The gateway validated the token. Isn't re-validating in services redundant? That redundancy is zero trust. The gateway is one enforcement point reachable from the internet; inside the cluster, services must not assume every caller passed through it. Defense in depth means the security boundary is each service, not the perimeter.

Q: Why carry userId in event metadata instead of re-deriving it downstream? Because the async consumers (debit, credit, compensate) run with no HTTP request and no token — the saga is event-driven. Stamping the authenticated sub into metadata.userId at the synchronous edge makes every downstream event attributable to a real user, which is what an audit trail needs.

Q: Where does authorization (not just authentication) happen? Authentication ("who are you") is the JWT signature + issuer check, everywhere. Authorization ("may you do this") is the authenticated policy at the gateway and RequireAuthorization()/the guard at the services. Richer rules (role customer, owns-this-account checks) would hang off the same validated claims — the realm already ships a customer role to build on.

Try to break it

  1. curl http://localhost:8080/api/accounts (no token) → 401 at the edge. Now hit account-service directly with no token — still 401. The service doesn't trust the network.
  2. Grab a token (scripts/e2e.sh shows the password-grant curl, note scope=openid), tamper one character of the signature, replay it → 401 (signature check fails locally, no Keycloak call needed).
  3. Forge a token signed with your own RSA key and the right issuer → 401 (the kid isn't in Keycloak's JWKS). transfer-service/src/auth/jwt-auth.guard.spec.ts proves exactly this with a local keypair.
  4. Remove "basic" from defaultClientScopes in the realm, recreate Keycloak, request a token → it has no sub, and the services reject it with "no subject" — the gotcha that cost real debugging time.
  5. Submit a transfer as alice, then inspect the published event in the RabbitMQ UI (localhost:15672): metadata.userId is alice's sub UUID, carried with no HTTP context in sight.