feat(infra): Keycloak with mock DigiD/eHerkenning/eIDAS/medewerker realms (closes #3)
Add infra/keycloak/docker-compose.yml (Keycloak 26.1, dev mode, --import-realm) with four realms imported at boot (infra/keycloak/realms/*.json): digid, eherkenning, eidas, medewerker. Each has a public big-portal OIDC client (standard flow + direct access grants) and test users; protocol mappers inject the identifying claims (bsn / kvk / eidas_id) and medewerker realm roles. Add `make keycloak-up/keycloak-smoke/keycloak-down`; keycloak-smoke runs infra/keycloak/check_realms.py (password-grant login per realm, asserts the claim). Document credentials in docs/synthetic-data.md and a runbook. Verified: `make keycloak-smoke` green — all four realms log in and return the expected claims (bsn 123456782, kvk 12345678, eidas_id FR/NL..., role behandelaar). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
60
infra/keycloak/check_realms.py
Normal file
60
infra/keycloak/check_realms.py
Normal file
@@ -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()
|
||||
29
infra/keycloak/docker-compose.yml
Normal file
29
infra/keycloak/docker-compose.yml
Normal file
@@ -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:
|
||||
43
infra/keycloak/realms/digid-realm.json
Normal file
43
infra/keycloak/realms/digid-realm.json
Normal file
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
43
infra/keycloak/realms/eherkenning-realm.json
Normal file
43
infra/keycloak/realms/eherkenning-realm.json
Normal file
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
43
infra/keycloak/realms/eidas-realm.json
Normal file
43
infra/keycloak/realms/eidas-realm.json
Normal file
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
44
infra/keycloak/realms/medewerker-realm.json
Normal file
44
infra/keycloak/realms/medewerker-realm.json
Normal file
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user