The previous two lessons have left Kilometre Zero with RS256 tokens that every service verifies and with encrypted channels for them to travel over. But they have treated as solved something that is not: where identities come from. services/identity/tokens.py signs with a key whose .pub was copied by hand to each service; login is a home-grown form with Argon2; Kilometre Zero's employees have corporate accounts in another system; producers would rather sign in with their Google account; the courier app should never display a long-lived password; and orders needs a machine token to call inventory when it acts on its own (a compensating saga from 03-05, for example) with no user behind it. Solving each case in each service is unworkable and, worse, insecure: every home-made login implementation is an opportunity for error.

This lesson centralises identity in an identity provider (IdP) and explains the protocols that let an identity established there be valid across the whole platform: LDAP as the employee directory, SAML 2.0 for enterprise single sign-on, and OAuth 2.0 with OpenID Connect for web, mobile and machine-to-machine applications. We will look at federation with social identities, the lifecycle of an identity (joining, role changes, leaving), multi-factor authentication and account recovery. And we will get it running: Keycloak as the IdP in docker-compose.yml with a km0 realm, three clients, roles and per-producer groups, and OpenLDAP for the employees; an OIDC client in Python with PKCE, obtaining a machine token for orders, and an LDAP search. Certificate-based service identity (mTLS) and the custody of secrets are 06-04; the edge that validates all of this centrally, 06-05.

Contents

  1. Why centralise identity
  2. Identity provider versus applications
  3. LDAP directories: DIT, dn, ou, searches and bind
  4. SAML 2.0: enterprise single sign-on
  5. OAuth 2.0: delegating access
  6. OpenID Connect: identity on top of OAuth 2.0
  7. LDAP, SAML and OAuth2/OIDC compared
  8. Federation and social identities
  9. Lifecycle, MFA and recovery
  10. Kilometre Zero: Keycloak as the IdP and OpenLDAP for employees
  11. Code: OIDC client with PKCE, client credentials and LDAP search
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. Why centralise identity

Kilometre Zero has four populations with different needs:

Population Examples How they authenticate today What they need
Customers Anna, Mark, Lucy Home-grown form, Argon2 Easy sign-up, login with Google/Apple, password recovery, optional MFA
Producers La Vega Farm, Montblanc Dairy, Roble Alto Winery Same form Several people per producer with different permissions (ABAC from 06-01)
Employees and operators Customer support, operations, finance Corporate accounts (email, wiki, payroll) A single account for everything (SSO), immediate deactivation on leaving the company, mandatory MFA
Services and jobs orders, analytics, the km0_daily_sales DAG, the Flink consumer Nothing, or a password in an environment variable Machine credentials, short-lived, with no human involved

If every service manages its own users, the familiar problems appear: different passwords everywhere (and reused), an employee who leaves the company still has access to analytics because nobody removed them there, MFA on the web but not on the operator dashboard, and four password recovery implementations with four sets of bugs. Centralisation solves this: one source of truth for who exists and what attributes they have, one place to apply MFA and password policies, one deactivation that closes every door, and applications that stop handling credentials.

  1. Identity provider versus applications

The identity provider (IdP) is the system that authenticates subjects and issues proofs of identity (SAML assertions, OIDC tokens) that applications consume. Applications become relying parties (or service providers in SAML): they never see the password, only the signed proof.

flowchart LR
    subgraph IdP["Identity provider (Keycloak, km0 realm)"]
        U[(Users, roles, groups)]
        L[(OpenLDAP: employees)]
        G[Google / Apple]
        U --- L
        U --- G
    end
    W[web-km0] -->|OIDC| IdP
    C[courier-app] -->|OIDC + PKCE| IdP
    O[orders] -->|client credentials| IdP
    D[Operator dashboard] -->|OIDC| IdP
    IdP -->|RS256 tokens + JWKS| S[Services: verify as in 06-01]

Keycloak, the IdP we will use, covers the whole picture: its own user base, federation with LDAP and with social providers, issuing OIDC tokens and SAML assertions, MFA, and an administration console. Alternatives playing the same role: Auth0/Okta, Azure AD (Entra ID), AWS Cognito, Ory, Authentik. The protocols are standard; changing IdP should not change the applications' code.

  1. LDAP directories: DIT, dn, ou, searches and bind

LDAP (Lightweight Directory Access Protocol) is the protocol for querying directories: hierarchical databases optimised for reads, which store people, groups, machines and their attributes. Microsoft's Active Directory is the most widespread implementation in companies (with Kerberos for authentication on Windows); OpenLDAP and 389 Directory Server are the open-source ones. In Kilometre Zero the directory holds the employees, which is what any company tends to have already before the platform exists.

The directory is a tree, the DIT (Directory Information Tree). Each entry has a unique DN (distinguished name), which is the path from the root:

dc=km0,dc=internal                          ← root (domain components)
├── ou=people                               ← organizational unit
│   ├── uid=jhall,ou=people,dc=km0,dc=internal
│   │     cn: Jordan Hall · mail: [email protected] · title: Operations
│   │     memberOf: cn=operators,ou=groups,dc=km0,dc=internal
│   └── uid=mhill,ou=people,dc=km0,dc=internal
│         cn: Martha Hill · mail: [email protected] · title: Customer support
├── ou=groups
│   ├── cn=operators,ou=groups,...           member: uid=jhall,...  member: uid=mhill,...
│   └── cn=finance,ou=groups,...             member: uid=jhall,...
└── cn=keycloak-bind,dc=km0,dc=internal     ← read-only technical account

Concepts:

  • Entry and attributes: each node has attributes with types defined by object classes (inetOrgPerson brings cn, sn, mail, uid; groupOfNames brings member). The schema is strict: you cannot invent an attribute without declaring it.
  • dn, ou, cn, uid, dc: distinguished name, organizational unit, common name, user id, domain component. The dn is the primary key; the rest are attributes with conventions.
  • Search: you give a base (ou=people,dc=km0,dc=internal), a scope (base, one, subtree) and a filter in prefix syntax: (&(objectClass=inetOrgPerson)(memberOf=cn=operators,ou=groups,dc=km0,dc=internal)) returns the operators; (|(mail=j*)(uid=j*)) those beginning with j.
  • Bind: LDAP authentication. A simple bind sends a dn and a password; the server checks them against the stored hash (SSHA or, better, Argon2 in modern OpenLDAP). Authenticating a user "against LDAP" means: (1) with the technical account, search for the dn whose uid or mail matches; (2) attempt a bind with that dn and the password the user typed. If the bind succeeds, the password is correct.
  • LDAPS / StartTLS: plain LDAP sends passwords in the clear; always use TLS (port 636) or StartTLS over 389.

LDAP is not an SSO protocol: every application that authenticates against LDAP sees the user's password. That is why LDAP's role in a modern platform is to be the source of users and groups the IdP queries, not something applications touch directly. Only the IdP binds.

  1. SAML 2.0: enterprise single sign-on

SAML (Security Assertion Markup Language, 2005) is the classic protocol for SSO between companies: an employee logs in once at the corporate IdP and accesses dozens of third-party applications (email, payroll, CRM) without authenticating again. It is signed XML, carried by browser redirects.

Actors and pieces:

  • IdP: authenticates and issues assertions: signed XML documents stating "the subject [email protected] authenticated at 10:02 with password and MFA, and has these attributes (groups, department)".
  • SP (service provider): the application, which trusts the IdP because it has its signing certificate, and which consumes the assertion.
  • Metadata: each party publishes an XML document with its URLs and certificates; they are exchanged once when setting up the trust.
  • Bindings: how the message travels: HTTP-Redirect (in the URL), HTTP-POST (auto-submitted form). Everything goes through the browser; IdP and SP do not talk to each other directly.

The SP-initiated flow (the user starts at the application):

sequenceDiagram
    participant B as Browser (Jordan)
    participant SP as Operator dashboard (SP)
    participant IdP as Corporate IdP (SAML)
    B->>SP: GET /dashboard (no session)
    SP-->>B: 302 to the IdP with AuthnRequest (signed, compressed in the URL)
    B->>IdP: GET /sso?SAMLRequest=...
    IdP->>B: Login form (+ MFA)
    B->>IdP: Credentials
    IdP-->>B: Page with hidden form: SAMLResponse (signed XML assertion)
    B->>SP: POST /saml/acs (Assertion Consumer Service) with SAMLResponse
    SP->>SP: Verifies signature with the IdP's certificate, audience, expiry, InResponseTo
    SP-->>B: Session cookie + 302 /dashboard

The IdP-initiated flow starts at an IdP portal with application icons; it is less secure (there is no InResponseTo tying the response to a request) and is discouraged unless necessary. SAML remains mandatory when a corporate customer's IdP only speaks SAML; for new applications, OIDC is simpler and better suited to APIs and mobile. Keycloak speaks both, so Kilometre Zero's operator dashboard could use SAML if it is integrated with an external corporate IdP tomorrow, and OIDC today.

  1. OAuth 2.0: delegating access

OAuth 2.0 (RFC 6749, 2012) was born for a problem other than authentication: delegating access. Anna wants a third-party app to read her Kilometre Zero orders without giving it her password. OAuth defines how that app obtains a limited access token (restricted to certain scopes, for a certain time) with Anna's consent. Its later use as the basis for authentication is the work of OpenID Connect (section 6).

Roles:

Role Who it is In Kilometre Zero
Resource owner The owner of the data Anna
Client The application that wants access web-km0, courier-app, orders
Authorization server Issues tokens after authenticating and asking for consent Keycloak, km0 realm
Resource server The API that accepts tokens orders, inventory, catalog (they verify as in 06-01)

Scopes are strings that bound the permission: orders:read, orders:create, delivery:position. The token carries the granted scopes and the resource server checks them. The client types: confidential (can keep a secret: a backend service) and public (cannot: an SPA in the browser, a mobile app; any embedded secret can be extracted).

The flows (grants)

Flow For whom How Status
Authorization code Web applications with a backend The browser goes to the IdP and comes back with a single-use code; the backend exchanges it for tokens using its secret Recommended
Authorization code + PKCE Public clients (SPA, mobile) The same, but without a secret: the client generates a random code_verifier, sends its hash (code_challenge) at the start, and the verifier when exchanging; whoever intercepts the code cannot exchange it Recommended; mandatory for everyone in OAuth 2.1
Client credentials Machine to machine, no user The client presents its client_id and client_secret (or a certificate/signed JWT) and receives a token with sub = the client itself Recommended
Refresh token Any client with a long session Exchanges a refresh token for a new access token (06-01, section 6); with rotation for public clients Recommended
Device code Devices without a keyboard (TV, terminal) The device displays a code; the user enters it on another device Niche
Implicit SPAs (historical) The token came back directly in the URL fragment Discouraged: token in the history, no PKCE, no refresh
Resource owner password "Trusted" apps The app collects the username and password and sends them to the IdP Discouraged: defeats the purpose of OAuth, no MFA, trains users to hand their password to apps

The reason for PKCE deserves a pause. On a mobile phone, the IdP returns the code to the app through a custom URL (km0://callback?code=...), and another, malicious app could register the same scheme and capture it. With PKCE, the code is only of use together with the original code_verifier, which never left the legitimate app. In an SPA, it protects in the same way against a code stolen by a script or through a proxy's logs.

  1. OpenID Connect: identity on top of OAuth 2.0

OpenID Connect (OIDC, 2014) adds to OAuth 2.0 what it lacked in order to authenticate: an ID token (a JWT, exactly those from 06-01, signed by the IdP) with claims about who the user is and how they authenticated, a userinfo endpoint for fetching more attributes, and automatic discovery of the configuration.

  • ID token: claims iss, sub, aud (= client_id), exp, iat, plus auth_time, nonce (anti-replay, generated by the client), amr (methods: pwd, otp), and profile claims (email, name, preferred_username) depending on the scopes openid profile email. It is addressed to the client: it tells it who has logged in.
  • Access token: still the OAuth one, addressed to the resource servers (aud: [orders, inventory]). Keycloak also issues it as an RS256 JWT with the realm roles, which is what the AuthInterceptor from 06-01 verifies. You must not use the ID token to call the APIs, nor the access token to find out who is on the web.
  • Discovery: GET https://identity.km0/realms/km0/.well-known/openid-configuration returns a JSON document with all the endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint) and the supported algorithms.
  • JWKS (JSON Web Key Set): jwks_uri publishes the IdP's signing public keys, each with its kid. This replaces the hand-copied .pub file from 06-01: services download the keys, cache them, and refresh them when they see an unknown kid. Signing key rotation becomes automatic.

The complete flow with authorization code + PKCE, in Kilometre Zero:

sequenceDiagram
    participant B as Browser (Anna)
    participant W as web-km0 (backend)
    participant K as Keycloak (km0 realm)
    participant O as orders
    B->>W: GET /login
    W->>W: generates state, nonce, code_verifier; challenge = SHA256(verifier)
    W-->>B: 302 K/authorize?client_id=web-km0&response_type=code&scope=openid profile<br/>&redirect_uri=...&state&nonce&code_challenge&code_challenge_method=S256
    B->>K: GET /authorize
    K->>B: Login (password + OTP if enabled)
    B->>K: credentials
    K-->>B: 302 redirect_uri?code=abc&state=...
    B->>W: GET /callback?code=abc&state=...
    W->>W: checks state
    W->>K: POST /token (code, code_verifier, client_id, client_secret)
    K-->>W: {id_token, access_token, refresh_token, expires_in}
    W->>K: GET /jwks (cached)
    W->>W: verifies id_token: signature (kid), iss, aud=web-km0, exp, nonce
    W-->>B: HttpOnly session cookie
    B->>W: GET /my-orders
    W->>O: GET /orders  Authorization: Bearer access_token
    O->>O: verifies with JWKS, aud=orders, roles (06-01)
    O-->>W: orders of u-anna

Note that web-km0 is a confidential client (it has a backend and a secret) and uses PKCE even so: it costs nothing and OAuth 2.1 requires it. courier-app is public and uses PKCE alone.

  1. LDAP, SAML and OAuth2/OIDC compared

LDAP SAML 2.0 OAuth 2.0 / OIDC
What it is Directory protocol (query and bind) SSO protocol with XML assertions Delegation framework (OAuth) + identity layer (OIDC) with JWT
Year 1993 (v3: 1997) 2005 2012 / 2014
Format Binary (ASN.1/BER) Signed XML (XML-DSig) JSON, JWT
Transport TCP 389/636, direct app → directory connection Browser (redirects, POST) HTTPS, browser for user flows, direct for machines
Does the app see the password? Yes No No
Mobile and APIs Poor Poor (heavy XML, no tokens for APIs) Designed for it
Machine to machine Technical account bind No Client credentials
User attributes Everything in the directory In the assertion Claims in the ID token and userinfo
Role in Kilometre Zero Source of employees and groups for the IdP Integration with external corporate IdPs Everything else: web, app, services

They do not compete: the usual pattern is LDAP behind the IdP, SAML or OIDC in front, and OIDC for anything new.

  1. Federation and social identities

Federating means accepting identities from another IdP. Keycloak acts as a broker: Montblanc Dairy clicks "Sign in with Google", Keycloak redirects to Google (which is in turn an OIDC IdP), receives Google's ID token, verifies it, and creates or links a local account in the km0 realm with its own sub. Kilometre Zero's applications only ever see Keycloak tokens: they neither know nor care whether the user signed in with a password, with Google or with Apple.

Advantages: fewer passwords to manage, one-click sign-up, MFA delegated to the social provider. Precautions: the unverified email from some providers must not be used to link automatically to an existing account (an attacker registers someone else's email with provider X and inherits the account); Keycloak forces it to be verified. And employee accounts are never federated with social providers: they come from the corporate LDAP, with mandatory MFA.

  1. Lifecycle, MFA and recovery

An identity is not a static record:

  • Joining (onboarding): customers self-register (with email verification); producers are approved by an operator (producer role and the producer's group, section 10); employees appear in LDAP from HR and Keycloak synchronises them. SCIM (System for Cross-domain Identity Management) is the REST standard that lets an HR system or an IdP create, modify and delete users in other applications automatically; it is what avoids "someone has to set them up by hand in five places".
  • Role changes: Martha moves from customer support to operations: her group changes in LDAP, Keycloak reflects it at the next synchronisation or the next login, and her next token carries the new roles. With 15-minute tokens (06-01), that is the maximum latency.
  • Leaving (offboarding): deactivate in LDAP or in Keycloak, revoke sessions and refresh tokens. Live access tokens expire within minutes. This is the reason leavers are handled at the IdP and never application by application.
  • Periodic review (access review): list who has operator and confirm it is still true. Permissions only grow if nobody reviews them.
  • MFA: mandatory for operators and producers (TOTP with an app, or WebAuthn/passkeys); optional but encouraged for customers. It is configured once at the IdP and applies to everything. Keycloak enforces it per authentication flow and per role.
  • Recovery: the most attacked point. A single-use link by email with a short expiry, never security questions, additional verification for accounts with MFA (backup codes), and a record of every recovery in the audit log from 06-05. A weak recovery flow undoes the best MFA.

  1. Kilometre Zero: Keycloak as the IdP and OpenLDAP for employees

We add two services to docker-compose.yml. Keycloak needs its own database; OpenLDAP is initialised with an LDIF containing the sample employees:

# km0/docker-compose.yml (fragment). Plaintext passwords for development only: 06-04.
services:
  postgres-keycloak:
    image: postgres:16
    environment: { POSTGRES_USER: keycloak, POSTGRES_PASSWORD: keycloak, POSTGRES_DB: keycloak }
    volumes: [ "pg_keycloak:/var/lib/postgresql/data" ]

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: ["start-dev", "--import-realm", "--hostname=http://localhost:8080"]
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
    volumes:
      - ./identity/realm-km0.json:/opt/keycloak/data/import/realm-km0.json:ro
    ports: [ "8080:8080" ]
    depends_on: [ postgres-keycloak, openldap ]

  openldap:
    image: osixia/openldap:1.5.0
    environment:
      LDAP_ORGANISATION: "Kilometre Zero"
      LDAP_DOMAIN: "km0.internal"
      LDAP_ADMIN_PASSWORD: "admin"
      LDAP_READONLY_USER: "true"
      LDAP_READONLY_USER_USERNAME: "keycloak-bind"
      LDAP_READONLY_USER_PASSWORD: "read-only"
      LDAP_TLS: "true"
    volumes:
      - ./identity/employees.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-employees.ldif:ro
    ports: [ "636:636" ]

volumes:
  pg_keycloak:

start-dev and --hostname=http://localhost:8080 are for development: in production, Keycloak sits behind TLS with start and a real hostname. The identity/employees.ldif file creates the tree from section 3:

dn: ou=people,dc=km0,dc=internal
objectClass: organizationalUnit
ou: people

dn: ou=groups,dc=km0,dc=internal
objectClass: organizationalUnit
ou: groups

dn: uid=jhall,ou=people,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: jhall
cn: Jordan Hall
sn: Hall
mail: [email protected]
title: Operations
userPassword: {SSHA}...   # generated with slappasswd; development only

dn: uid=mhill,ou=people,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: mhill
cn: Martha Hill
sn: Hill
mail: [email protected]
title: Customer support
userPassword: {SSHA}...

dn: cn=operators,ou=groups,dc=km0,dc=internal
objectClass: groupOfNames
cn: operators
member: uid=jhall,ou=people,dc=km0,dc=internal
member: uid=mhill,ou=people,dc=km0,dc=internal

And the realm, exported to identity/realm-km0.json (Keycloak imports it on start-up). The essentials, in table form because the full JSON runs to hundreds of lines:

Element Configuration
Realm km0; 15 min access tokens; 30-day refresh with rotation (revoke refresh token: ON); RS256 signing with key rotation managed by Keycloak (kid in the JWKS)
Realm roles customer, producer, courier, operator (those from 06-01)
Groups /producers/la-vega-farm, /producers/montblanc-dairy, /producers/roble-alto-winery: each group assigns the producer role and the producer_id attribute with the slug; /couriers/van-3 assigns courier
Client web-km0 Confidential; Standard flow (authorization code) with mandatory S256 PKCE; redirect_uris: ["https://km0.example/callback"]; scopes openid profile email + orders catalog
Client courier-app Public; authorization code + PKCE; redirect_uris: ["km0://callback"]; scope delivery
Client orders-service Confidential; service accounts only (client credentials), no user flows; service role svc-orders; audience inventory
Mappers realm roles → roles claim (flat list); group attribute producer_id → producer_id claim; scope orders → adds orders to aud; scope catalog → catalog to aud
User federation OpenLDAP: ldaps://openldap:636, bind dn: cn=keycloak-bind,dc=km0,dc=internal, users dn: ou=people,dc=km0,dc=internal, READ_ONLY mode, periodic synchronisation; group mapper ou=groups → Keycloak groups; the operators group assigns the operator role
Identity providers Google (OIDC), only for users in the local base; "trust email" disabled
Authentication browser flow with mandatory OTP for the operator and producer roles, optional for customer; recovery by email with a 15 min link

With docker compose up -d keycloak openldap, the console at http://localhost:8080 shows the km0 realm, and curl http://localhost:8080/realms/km0/.well-known/openid-configuration returns the discovery document. Jordan and Martha appear as users federated from LDAP, with the operator role inherited from the group; none of their passwords is in Keycloak.

The services/identity/tokens.py from 06-01 is now retired: Keycloak does the issuing. What survives is services/common/auth.py, with a single modification: instead of the .pub file, it uses the JWKS.

  1. Code: OIDC client with PKCE, client credentials and LDAP search

Verification with JWKS (updates services/common/auth.py)

# km0/services/common/auth.py (updated fragment)
import jwt
from jwt import PyJWKClient

ISS = "http://localhost:8080/realms/km0"           # in production: https://identity.km0/realms/km0
_jwks = PyJWKClient(f"{ISS}/protocol/openid-connect/certs", cache_keys=True,
                    lifespan=3600)                 # caches the keys; refreshes if it sees a new kid

def verify(token: str, audience: str) -> dict:
    try:
        key = _jwks.get_signing_key_from_jwt(token).key     # chosen by the header's 'kid'
        return jwt.decode(token, key, algorithms=["RS256"], audience=audience,
                          issuer=ISS, options={"require": ["exp", "iat", "sub"]}, leeway=30)
    except jwt.PyJWTError as e:
        raise InvalidToken(str(e))

The AuthInterceptor in inventory and the requires_role decorator do not change by a single line: Keycloak's tokens carry roles and producer_id thanks to the mappers.

services/identity/oidc_client.py: authorization code + PKCE

This module is the one used by the web-km0 backend (and, with the differences noted in the comments, the courier app). It is implemented with requests so that every step is visible; authlib wraps it in three calls and is what you would use in production.

# km0/services/identity/oidc_client.py
# pip install requests "PyJWT[crypto]"
import base64, hashlib, secrets, urllib.parse
import requests, jwt
from jwt import PyJWKClient

ISSUER = "http://localhost:8080/realms/km0"
CLIENT_ID = "web-km0"
CLIENT_SECRET = "..."                  # confidential: comes from the secrets manager (06-04)
REDIRECT_URI = "http://localhost:5000/callback"

_conf = requests.get(f"{ISSUER}/.well-known/openid-configuration", timeout=5).json()
_jwks = PyJWKClient(_conf["jwks_uri"], cache_keys=True)

def _b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def start_login() -> tuple[str, dict]:
    """Step 1: builds the authorization URL. Returns the URL and what has to be
    kept in the browser's session (state, nonce, verifier) for step 2."""
    state = _b64url(secrets.token_bytes(24))          # anti-CSRF: must come back unchanged
    nonce = _b64url(secrets.token_bytes(24))          # anti-replay: will go inside the id_token
    verifier = _b64url(secrets.token_bytes(32))       # PKCE: single-use secret
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
    params = {
        "client_id": CLIENT_ID, "response_type": "code",
        "scope": "openid profile email orders catalog",
        "redirect_uri": REDIRECT_URI, "state": state, "nonce": nonce,
        "code_challenge": challenge, "code_challenge_method": "S256",
    }
    url = _conf["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
    return url, {"state": state, "nonce": nonce, "verifier": verifier}

def complete_login(query: dict, pending: dict) -> dict:
    """Step 2: at /callback. Checks state, exchanges the code and validates the ID token."""
    if not secrets.compare_digest(query.get("state", ""), pending["state"]):
        raise ValueError("state mismatch: possible CSRF")
    resp = requests.post(_conf["token_endpoint"], data={
        "grant_type": "authorization_code", "code": query["code"],
        "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,               # the courier app does not send it
        "code_verifier": pending["verifier"],         # PKCE: proves we are the one who started
    }, timeout=5)
    resp.raise_for_status()
    tokens = resp.json()

    key = _jwks.get_signing_key_from_jwt(tokens["id_token"]).key
    id_claims = jwt.decode(tokens["id_token"], key, algorithms=["RS256"],
                           audience=CLIENT_ID, issuer=ISSUER, leeway=30)
    if id_claims.get("nonce") != pending["nonce"]:
        raise ValueError("nonce mismatch: id_token reused")

    return {"sub": id_claims["sub"], "email": id_claims.get("email"),
            "name": id_claims.get("name"),
            "access_token": tokens["access_token"],     # for calling orders/catalog
            "refresh_token": tokens["refresh_token"],   # keep on the server, never in the browser
            "expires_in": tokens["expires_in"]}

def refresh(refresh_token: str) -> dict:
    resp = requests.post(_conf["token_endpoint"], data={
        "grant_type": "refresh_token", "refresh_token": refresh_token,
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, timeout=5)
    resp.raise_for_status()
    return resp.json()          # brings a new refresh_token: rotation (store it, discard the old one)

Integrated into Flask, /login calls start_login, stores pending in the session and redirects; /callback calls complete_login, stores sub, access_token and refresh_token in the server-side session (Redis, 06-01) and redirects to the page. The browser only has the session's HttpOnly cookie.

A quick check from the terminal, with a user anna created in the realm:

python -c "from services.identity.oidc_client import start_login; print(start_login()[0])"
# Open the URL in the browser, sign in as anna, and copy the ?code=...&state=... from the redirect

Client credentials: orders obtains its machine token

When orders acts on its own (compensating a saga, nightly reconciliation), there is no user token to forward. It requests its own:

# km0/services/orders/service_token.py
import time, requests

TOKEN_URL = "http://keycloak:8080/realms/km0/protocol/openid-connect/token"
CLIENT_ID, CLIENT_SECRET = "orders-service", "..."     # secret: 06-04

_cache = {"token": None, "exp": 0}

def service_token() -> str:
    """Returns an access token for orders-service, cached until 60 s before it expires."""
    if time.time() < _cache["exp"] - 60:
        return _cache["token"]
    r = requests.post(TOKEN_URL, data={"grant_type": "client_credentials",
                                       "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
                                       "scope": "inventory"}, timeout=5)
    r.raise_for_status()
    t = r.json()
    _cache.update(token=t["access_token"], exp=time.time() + t["expires_in"])
    return _cache["token"]

# Usage in the compensating saga (03-05), with no user behind it:
# stub.ReleaseReservation(req, metadata=(("authorization", f"Bearer {service_token()}"),), timeout=2)

The resulting token has sub: service-account-orders-service, roles: [svc-orders], aud: inventory. In inventory's ROLES_BY_METHOD (06-01), svc-orders is added to ReserveStock and ReleaseReservation. It is the platform's first real machine identity; 06-04 will strengthen it with a certificate instead of a client_secret.

LDAP search with ldap3

Services should not talk to LDAP (Keycloak does that), but an operator does sometimes need to query the directory: for example, an internal script that lists who is in operators for the access review from section 9.

# km0/services/identity/ldap_query.py
# pip install ldap3
from ldap3 import Server, Connection, Tls, SUBTREE, ALL
import ssl

tls = Tls(ca_certs_file="certs/km0-ca.crt", validate=ssl.CERT_REQUIRED)   # LDAPS with the CA from 06-02
server = Server("openldap", port=636, use_ssl=True, tls=tls, get_info=ALL)

BASE = "dc=km0,dc=internal"

def group_members(group: str) -> list[dict]:
    with Connection(server, user=f"cn=keycloak-bind,{BASE}", password="read-only",
                    auto_bind=True) as c:                      # bind with the technical account
        c.search(search_base=f"ou=groups,{BASE}", search_scope=SUBTREE,
                 search_filter=f"(&(objectClass=groupOfNames)(cn={group}))",
                 attributes=["member"])
        if not c.entries:
            return []
        members = []
        for dn in c.entries[0].member.values:
            c.search(search_base=dn, search_scope="BASE", search_filter="(objectClass=*)",
                     attributes=["uid", "cn", "mail", "title"])
            e = c.entries[0]
            members.append({"uid": str(e.uid), "name": str(e.cn),
                            "mail": str(e.mail), "title": str(e.title)})
        return members

def authenticate_employee(uid: str, password: str) -> bool:
    """Only to illustrate the user bind: in Kilometre Zero Keycloak does this, never a service."""
    with Connection(server, user=f"cn=keycloak-bind,{BASE}", password="read-only",
                    auto_bind=True) as c:
        c.search(f"ou=people,{BASE}", f"(uid={uid})", attributes=["dn"])
        if not c.entries:
            return False
        dn = c.entries[0].entry_dn
    try:
        Connection(server, user=dn, password=password, auto_bind=True).unbind()
        return True
    except Exception:
        return False

if __name__ == "__main__":
    for m in group_members("operators"):
        print(m)
    # {'uid': 'jhall', 'name': 'Jordan Hall', 'mail': '[email protected]', 'title': 'Operations'}
    # {'uid': 'mhill', 'name': 'Martha Hill', 'mail': '[email protected]', 'title': 'Customer support'}

Two security details in the code: the search filter is built from controlled values (group comes from a script argument); if it came from user input it would have to be escaped (ldap3.utils.conv.escape_filter_chars) to prevent LDAP injection, the equivalent of SQL injection. And the technical account is read-only: if someone steals read-only, they can list employees but not modify groups.

Common Mistakes and Tips

  • Using the ID token to call the APIs (or the access token to identify the user on the web). The ID token has aud: web-km0 and orders must reject it; the access token may be opaque with other IdPs. Each token for its intended recipient.
  • Omitting state or nonce "because there's PKCE already". They protect against different attacks: state against CSRF on the callback (being logged in to the attacker's account), nonce against the reuse of an ID token, PKCE against interception of the code.
  • redirect_uri with wildcards. The IdP must accept exact URIs only; a wildcard lets the code end up on a domain the attacker controls.
  • Password flow "because it's our own app". It defeats MFA, consent and federation, and gets users used to typing their password into apps. Authorization code + PKCE works for native apps too.
  • Client secrets in mobile apps or SPAs. They are public by definition; register them as public with PKCE.
  • Applications that bind against LDAP directly. Every one of them sees passwords and none of them applies MFA. LDAP behind the IdP.
  • Linking social accounts by unverified email. It is the classic route to account takeover.
  • Offboarding application by application. Offboarding means deactivating at the IdP (or in LDAP) and revoking sessions; if you need to touch five systems, the design is wrong (SCIM exists for that).
  • Copying the JWKS by hand or pinning a single key. The IdP rotates keys; use jwks_uri with caching and a refresh on an unknown kid.
  • Tip: export the Keycloak realm (kc.sh export) and version it in identity/realm-km0.json: identity configuration is code, reviewed and deployed like everything else.
  • Tip: test the flow with a real IdP in development (Keycloak in Compose), not with a "no authentication mode". Differences in authentication between environments are the classic source of surprises in production.
  • Compliance warning. Identity data (emails, names, employee groups, linked social providers) is personal data; its processing, its retention and the MFA and recovery policy must be reviewed with a security and data protection professional.

Exercises

Exercise 1: The intercepted code

A courier has a malicious app installed that registers the km0:// scheme and captures the redirect km0://callback?code=XYZ&state=... before courier-app does. Explain what the malicious app can do with that code (a) if courier-app were a confidential client with the secret embedded in the APK, (b) if it were public without PKCE, (c) with the configuration from section 10. State in each case which Keycloak check fails or does not fail.

Exercise 2: Martha leaves the company

Martha Hill (mhill) leaves Kilometre Zero on a Friday at 17:00. Describe what actions HR/IT take, in which system, and what happens to: her open session on the operator dashboard, her refresh token, an access token issued at 16:55, her account on the internal wiki (which uses SAML against Keycloak) and her permissions in analytics. What would have to exist for all of this to be a single action? And what fails if analytics kept its own users table?

Exercise 3: The Airflow DAG and Flink ask for an identity

km0_daily_sales (05-05) and the Flink consumer of orders.events (05-04) have no user. Design for each: the client in the km0 realm (name, type, flow, scopes/audience), the service roles, how they obtain and renew the token, and what check inventory or the Kafka broker adds. Point out which part of this design 06-04 is going to change and why.

Solutions

Exercise 1.

(a) The malicious app extracts the secret from the APK (it is trivial), exchanges the code with client_id + client_secret and obtains the courier's tokens: Keycloak cannot tell it apart from the legitimate app; no check fails. (b) Without PKCE, a public client exchanges the code with just client_id and redirect_uri; the malicious app does so before the legitimate one and obtains the tokens; the legitimate one gets an error when trying to exchange an already used code, which is the only signal, and a late one. (c) With mandatory S256 PKCE, the exchange requires the code_verifier whose SHA-256 was sent as the code_challenge in the authorization request; the malicious app has the code but not the verifier (it never left courier-app); Keycloak hashes whatever verifier it sends (or rejects its absence) and responds invalid_grant. The code is rendered useless; the legitimate app, if it is the first to exchange, gets in as normal. state plays no part in this attack (it protects the callback against CSRF, not against interception).

Exercise 2.

IT deactivates uid=mhill in OpenLDAP (or HR does it via SCIM/synchronisation from its own system). Keycloak, in READ_ONLY mode with periodic synchronisation, sees the deactivated account on the next cycle (or immediately if IT also deactivates it in Keycloak and revokes her sessions from the console, which is the recommended course). The operator dashboard session: if the dashboard keeps a server-side session with the access token, it keeps working until the token expires (16:55 + 15 min = 17:10 at the latest) or until it tries to refresh; the refresh token was revoked when her sessions were closed in Keycloak, so the refresh fails and the dashboard throws her out. The wiki with SAML: the next time she needs to re-authenticate (or immediately if the wiki receives a single logout), the IdP will not authenticate her; her local session on the wiki lasts as long as its cookie does, which is why sessions in SAML applications are configured to be short. analytics: if it validates Keycloak tokens, there is nothing to do there. For all of this to be a single action, the deactivation has to be done only at the source (LDAP/HR) and flow through synchronisation or SCIM to the IdP, with automatic revocation of sessions. If analytics kept its own users table with passwords, the deactivation would never arrive: Martha would still be getting into analytics on Monday, which is exactly the point of section 1.

Exercise 3.

Airflow: client airflow-service, confidential, service accounts only (client credentials), scope/audience analytics and hdfs (if the lake is protected with tokens; in practice HDFS uses Kerberos, outside this course), service role svc-analytics-write; the DAG obtains the token in an initial PythonOperator (or a hook that caches it like service_token) and, since the tasks last longer than 15 minutes, renews it via client credentials when it expires (no refresh token: client credentials is simply repeated). Flink: client flink-orders-consumer, confidential, client credentials, audience kafka, role svc-orders-consumer; the Kafka broker is configured with SASL_SSL and the OAUTHBEARER mechanism, which validates the JWT against Keycloak's JWKS and applies ACLs: that principal can only read orders.events with its consumer group, and cannot write to anything. inventory adds svc-analytics-... to the read-only methods if Airflow queries it. What 06-04 changes: the client_secret of both currently lives in the configuration of Airflow and of the Flink job, that is, in a file or environment variable; it will be replaced by client certificate authentication (mTLS) or by a secret injected and rotated by Vault, and the DAG's PostgreSQL credentials will become dynamic.

Conclusion

Centralising identity means there is a single place where it is decided who exists, how they authenticate and what attributes they have, and that applications consume signed proofs instead of passwords. LDAP is the directory: a tree of entries with dn, organizational units, groups and bind, ideal as the source of employees behind the IdP and dreadful as an application login protocol. SAML 2.0 is enterprise SSO through XML assertions, still needed to integrate corporate IdPs. OAuth 2.0 delegates access with scopes and flows, of which only three matter today (authorization code with PKCE, client credentials and refresh), and OpenID Connect adds identity to it: an ID token for the application, an access token for the APIs, discovery for self-configuration and JWKS so that the RS256 verification from 06-01 rotates keys with no intervention. Federation with social identities, the lifecycle with SCIM, MFA per role and careful recovery complete the picture. Kilometre Zero now has Keycloak with the km0 realm, the clients web-km0, courier-app and orders-service, roles and per-producer groups, OpenLDAP with Jordan and Martha, oidc_client.py with PKCE and validation against JWKS, service_token() for orders, and an LDAP query for the access review.

And yet two things are still fragile. orders-service identifies itself with a client_secret, which is a machine password stored somewhere; inventory accepts that identity because the token is valid, but it has no way of knowing whether the TLS connection reaching it really comes from the orders container or from any process that has stolen the secret. And the km0_analytics password is still in an environment variable of the DAG, just as 05-05 left it. The next lesson abandons the idea of a perimeter: each service will have its own certificate, every connection will be authenticated in both directions (mTLS), authorization will also be done by service identity, and secrets will stop being written to files and live in Vault instead, with dynamic credentials that expire on their own.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved