From c904c645972f8403aaa338ce6d92c4a596ac257f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 3 Jun 2026 14:16:49 +0000 Subject: [PATCH] feat(infra): Keycloak with four mock realms (closes #3) (#43) --- Makefile | 19 ++++++- docs/runbooks/keycloak.md | 37 ++++++++++++ docs/synthetic-data.md | 35 ++++++++++++ infra/keycloak/check_realms.py | 60 ++++++++++++++++++++ infra/keycloak/docker-compose.yml | 29 ++++++++++ infra/keycloak/realms/digid-realm.json | 43 ++++++++++++++ infra/keycloak/realms/eherkenning-realm.json | 43 ++++++++++++++ infra/keycloak/realms/eidas-realm.json | 43 ++++++++++++++ infra/keycloak/realms/medewerker-realm.json | 44 ++++++++++++++ 9 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 docs/runbooks/keycloak.md create mode 100644 docs/synthetic-data.md create mode 100644 infra/keycloak/check_realms.py create mode 100644 infra/keycloak/docker-compose.yml create mode 100644 infra/keycloak/realms/digid-realm.json create mode 100644 infra/keycloak/realms/eherkenning-realm.json create mode 100644 infra/keycloak/realms/eidas-realm.json create mode 100644 infra/keycloak/realms/medewerker-realm.json diff --git a/Makefile b/Makefile index 793c6ca..6bbe262 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ OZ_COMPOSE := infra/openzaak/docker-compose.yml OZ_BASE := http://localhost:8000 NRC_COMPOSE := infra/opennotificaties/docker-compose.yml NRC_BASE := http://localhost:8001 +KC_COMPOSE := infra/keycloak/docker-compose.yml +KC_BASE := http://localhost:8180 STACK_FILES := -f $(OZ_COMPOSE) -f $(NRC_COMPOSE) # On a rootless Podman dev box, point Docker CLI/Compose at the Podman socket — @@ -24,7 +26,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down help +.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down help ## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions) ci: lint build unit smoke @@ -107,6 +109,21 @@ stack-smoke: stack-up stack-down: docker compose $(STACK_FILES) down --volumes +## keycloak-up: start Keycloak with the four imported realms +keycloak-up: + docker compose -f $(KC_COMPOSE) up -d + +## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim +keycloak-smoke: keycloak-up + @bash -c 'for i in $$(seq 1 60); do \ + c=$$(curl -s -o /dev/null -w "%{http_code}" $(KC_BASE)/realms/digid/.well-known/openid-configuration || true); \ + [ "$$c" = "200" ] && break; sleep 3; done; echo "Keycloak ready ($$c)"' + python3 infra/keycloak/check_realms.py + +## keycloak-down: stop and remove Keycloak +keycloak-down: + docker compose -f $(KC_COMPOSE) down --volumes + ## help: list available targets help: @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## //' diff --git a/docs/runbooks/keycloak.md b/docs/runbooks/keycloak.md new file mode 100644 index 0000000..df43d93 --- /dev/null +++ b/docs/runbooks/keycloak.md @@ -0,0 +1,37 @@ +# Keycloak runbook + +Keycloak (`infra/keycloak/docker-compose.yml`) runs in dev mode with four realms +imported at boot from `infra/keycloak/realms/`: **digid**, **eherkenning**, **eidas**, +**medewerker**. It mocks the Dutch identity brokers so portals can do real OIDC logins +locally. Host port **:8180**. + +## Quick test (`make`) + +```bash +make keycloak-up # start Keycloak + import realms (~30-60s first boot) +make keycloak-smoke # start + verify every realm logs in and returns its claim +make keycloak-down # stop + wipe +``` + +`make keycloak-smoke` runs `infra/keycloak/check_realms.py`, which does a password-grant +login per realm and asserts the identifying claim: + +| Realm | User | Claim asserted | +|---|---|---| +| digid | jan-burger | `bsn` | +| eherkenning | acme-ondernemer | `kvk` | +| eidas | pierre-dupont | `eidas_id` | +| medewerker | merel-behandelaar | role `behandelaar` | + +All test users / credentials are in [../synthetic-data.md](../synthetic-data.md). + +## Notes + +- **Admin console:** — `admin` / `admin` (dev only). +- **Client `big-portal`** is public with `standardFlowEnabled` (browser redirect login) + *and* `directAccessGrantsEnabled` (password grant, used by the smoke test). +- **Dev store:** in-memory H2 via `start-dev`; realms re-import on each boot, so changes + made in the admin UI don't persist. Edit the realm JSONs to make durable changes. +- **Image** pinned to `quay.io/keycloak/keycloak:26.1`. +- Claims are injected by OIDC protocol mappers on `big-portal` (user attribute → token + claim); `medewerker` roles come through `realm_access.roles`. diff --git a/docs/synthetic-data.md b/docs/synthetic-data.md new file mode 100644 index 0000000..c145825 --- /dev/null +++ b/docs/synthetic-data.md @@ -0,0 +1,35 @@ +# Synthetic data + +All credentials here are **dev-only** synthetic test data — never real personal data, +never used outside local development. + +## Keycloak realms (S-02) + +Keycloak runs at (admin console: **admin / admin**). Four realms +are imported at boot from `infra/keycloak/realms/`. Each has a public OIDC client +**`big-portal`** (standard flow + direct access grants enabled, redirect URIs `*` for dev). + +All test users share the password **`test123`**. + +| Realm | Mimics | User | Identifying claim | +|---|---|---|---| +| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` | +| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` | +| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` | +| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` | +| `medewerker` | Internal staff | `tom-teamlead` | roles `behandelaar`, `teamlead` | + +The identifying claims are injected via OIDC protocol mappers on `big-portal` +(user-attribute → token claim); `medewerker` roles appear in `realm_access.roles`. + +## Get a token (for testing) + +```bash +curl -s -X POST \ + http://localhost:8180/realms/digid/protocol/openid-connect/token \ + -d grant_type=password -d client_id=big-portal \ + -d username=jan-burger -d password=test123 -d scope=openid | jq -r .access_token +``` + +Decode the JWT payload to see the `bsn` claim. `make keycloak-smoke` checks every realm +automatically. diff --git a/infra/keycloak/check_realms.py b/infra/keycloak/check_realms.py new file mode 100644 index 0000000..e338d29 --- /dev/null +++ b/infra/keycloak/check_realms.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Smoke-check the Keycloak realms: each realm's OIDC login works (password grant) +and returns its expected identifying claim. Stdlib only. Exits non-zero on failure. +""" +import base64, json, sys, urllib.error, urllib.parse, urllib.request + +BASE = "http://localhost:8180" +CLIENT = "big-portal" +PWD = "test123" + +# realm, user, claim ("__roles__" => check realm_access.roles), expected-contains +CHECKS = [ + ("digid", "jan-burger", "bsn", "123456782"), + ("eherkenning", "acme-ondernemer", "kvk", "12345678"), + ("eidas", "pierre-dupont", "eidas_id", "FR/NL"), + ("medewerker", "merel-behandelaar", "__roles__", "behandelaar"), +] + + +def decode(jwt): + p = jwt.split(".")[1] + p += "=" * (-len(p) % 4) + return json.loads(base64.urlsafe_b64decode(p)) + + +def grant(realm, user): + data = urllib.parse.urlencode({ + "grant_type": "password", "client_id": CLIENT, + "username": user, "password": PWD, "scope": "openid", + }).encode() + req = urllib.request.Request( + f"{BASE}/realms/{realm}/protocol/openid-connect/token", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}) + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + + +def main(): + ok = True + for realm, user, claim, expect in CHECKS: + try: + at = decode(grant(realm, user)["access_token"]) + if claim == "__roles__": + val = at.get("realm_access", {}).get("roles", []) + good = expect in val + else: + val = at.get(claim) + good = val is not None and expect in str(val) + print(f"{realm:12} {user:18} login OK | {claim} = {val} " + f"[{'OK' if good else 'UNEXPECTED'}]") + ok = ok and good + except urllib.error.HTTPError as e: + ok = False + print(f"{realm:12} {user:18} LOGIN FAILED {e.code}: {e.read()[:200]!r}") + print("keycloak smoke OK" if ok else "keycloak smoke FAILED") + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/infra/keycloak/docker-compose.yml b/infra/keycloak/docker-compose.yml new file mode 100644 index 0000000..b47dcc9 --- /dev/null +++ b/infra/keycloak/docker-compose.yml @@ -0,0 +1,29 @@ +# Keycloak (S-02) with four pre-seeded realms imported at boot: +# digid · eherkenning · eidas · medewerker +# Dev mode, H2 in-memory store, realm JSONs imported from ./realms. +# +# docker compose -f infra/keycloak/docker-compose.yml up -d +# curl -s -o /dev/null -w '%{http_code}\n' \ +# http://localhost:8180/realms/digid/.well-known/openid-configuration # -> 200 +# +# Admin console: http://localhost:8180/ (admin / admin — dev only) +services: + keycloak: + image: quay.io/keycloak/keycloak:26.1 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + # Older var names too, harmless on 26.x: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + KC_HTTP_ENABLED: "true" + ports: + - "8180:8080" + volumes: + - ./realms:/opt/keycloak/data/import:ro,z + networks: [cg] + +networks: + cg: diff --git a/infra/keycloak/realms/digid-realm.json b/infra/keycloak/realms/digid-realm.json new file mode 100644 index 0000000..28f1c51 --- /dev/null +++ b/infra/keycloak/realms/digid-realm.json @@ -0,0 +1,43 @@ +{ + "realm": "digid", + "enabled": true, + "displayName": "Mock DigiD", + "clients": [ + { + "clientId": "big-portal", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocolMappers": [ + { + "name": "bsn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "bsn", + "claim.name": "bsn", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "jan-burger", + "enabled": true, + "firstName": "Jan", + "lastName": "Burger", + "email": "jan.burger@example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "bsn": ["123456782"] } + } + ] +} diff --git a/infra/keycloak/realms/eherkenning-realm.json b/infra/keycloak/realms/eherkenning-realm.json new file mode 100644 index 0000000..256d600 --- /dev/null +++ b/infra/keycloak/realms/eherkenning-realm.json @@ -0,0 +1,43 @@ +{ + "realm": "eherkenning", + "enabled": true, + "displayName": "Mock eHerkenning", + "clients": [ + { + "clientId": "big-portal", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocolMappers": [ + { + "name": "kvk", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "kvk", + "claim.name": "kvk", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "acme-ondernemer", + "enabled": true, + "firstName": "Anita", + "lastName": "Ondernemer", + "email": "anita@acme.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "kvk": ["12345678"] } + } + ] +} diff --git a/infra/keycloak/realms/eidas-realm.json b/infra/keycloak/realms/eidas-realm.json new file mode 100644 index 0000000..2a2e066 --- /dev/null +++ b/infra/keycloak/realms/eidas-realm.json @@ -0,0 +1,43 @@ +{ + "realm": "eidas", + "enabled": true, + "displayName": "Mock eIDAS", + "clients": [ + { + "clientId": "big-portal", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocolMappers": [ + { + "name": "eidas_id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "eidas_id", + "claim.name": "eidas_id", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "pierre-dupont", + "enabled": true, + "firstName": "Pierre", + "lastName": "Dupont", + "email": "pierre.dupont@example.fr", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "eidas_id": ["FR/NL/AB-1234-5678"] } + } + ] +} diff --git a/infra/keycloak/realms/medewerker-realm.json b/infra/keycloak/realms/medewerker-realm.json new file mode 100644 index 0000000..40ab79a --- /dev/null +++ b/infra/keycloak/realms/medewerker-realm.json @@ -0,0 +1,44 @@ +{ + "realm": "medewerker", + "enabled": true, + "displayName": "Medewerkers", + "roles": { + "realm": [ + { "name": "behandelaar", "description": "Behandelt registratieaanvragen" }, + { "name": "teamlead", "description": "Teamleider behandeling" } + ] + }, + "clients": [ + { + "clientId": "big-portal", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"] + } + ], + "users": [ + { + "username": "merel-behandelaar", + "enabled": true, + "firstName": "Merel", + "lastName": "Behandelaar", + "email": "merel@big.example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "realmRoles": ["behandelaar"] + }, + { + "username": "tom-teamlead", + "enabled": true, + "firstName": "Tom", + "lastName": "Teamlead", + "email": "tom@big.example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "realmRoles": ["behandelaar", "teamlead"] + } + ] +}