By Mahmoud Consultancy (built by GloryLabs) Last verified against the repo: 2026-06-01
Companion docs: README.md · api.md (repo) · setup.md (repo) · deployment.md · repo
CLAUDE.md
ValideerLeeftijd is a multi-method age-verification API. A relying party (webshop, platform) calls a single endpoint to ask "is this user at least N years old?" and gets back a plain ageVerified: true/false. The backend picks the right verification method, drives the user through it, and returns the boolean.
The defining constraint is data minimisation: birth dates are never persisted. Only the boolean outcome and a little flow state live in Redis, with a 15-minute TTL. There is no PostgreSQL and no PII at rest. This is what lets the service stay GDPR-light and avoid being a data-breach target.
┌──────────────────────────────────────────────┐
Relying party ──────►│ ingress-nginx (api.valideerleeftijd.nl) │
(server-to-server │ routes by path: /api → backend / → portal │
+ browser redirect) └───────────────┬───────────────┬──────────────┘
│ │
/api ▼ ▼ /
┌───────────────────────────┐ ┌───────────────────────┐
│ Backend — Spring Boot │ │ Developer Portal │
│ Java 25 LTS, port 8095 │ │ Angular 20, nginx │
│ │ │ (unprivileged, :8080) │
│ Controller→Service→… │ │ - API key display │
│ X-API-Key auth filter │ │ - method picker UI │
│ Bucket4j rate limit │ │ - Yivi QR display │
└───────┬───────────┬────────┘ └───────────────────────┘
│ │
session │ │ outbound to verification providers
state ▼ ▼
┌──────────────┐ ┌───────────────────────────────────────┐
│ Redis │ │ PSD2/iDIN (Rabobank) · Yivi (IRMA) │
│ 15-min TTL │ │ EUDI Wallet (OpenID4VP) · sandbox/sim │
│ no birthdate │ └───────────────────────────────────────┘
└──────────────┘
nl.mahmoudconsultancy.valideerleeftijd) — standard layered Spring Boot: controller → service → (model/dto), with filter (API-key auth, rate limit), config (binding + beans), and exception (single ApplicationException base + GlobalExceptionHandler). No repository layer — Redis is accessed through a service, not Spring Data JPA.nginxinc/nginx-unprivileged (uid 101, listens on 8080, read-only root FS). Shows masked API keys, the method picker, and the Yivi QR flow.local-path). Holds vl:session:* entries: resolved method, PKCE verifier, status, and the final ageVerified boolean — never a date of birth.The method field on /verify/initiate selects the flow; AUTO (default) resolves by minimumAge:
| Request | AUTO resolves to | Why |
|---|---|---|
| minimumAge ≤ 18 | PSD2 | A bank grants account/consent only to an adult holder, so successful consent ⇒ ≥18 ("consent-implies-18"). No birth date needed. |
| minimumAge > 18 | iDIN | The only Dutch bank method that actually returns a date of birth, so an arbitrary age threshold can be checked. |
| explicit PSD2 / IDIN / YIVI / EUDI | that method | Caller override. |
min_age ≤ 18. For min_age > 18 the AISP response carries no birth date → status BIRTH_DATE_UNAVAILABLE (HTTP 422), telling the caller to use iDIN instead.> 18 case.over18/over21 attribute; no bank account required. Never chosen by AUTO, because it depends on an IRMA server being reachable at initiation time — callers opt in explicitly.EudiController under /api/v1/eudi/*, wired into /verify/initiate.ReceiptService issues a signed JWT receipt as proof of verification.Roadmap (not built): ID-document scan (Onfido/Veriff), face/age-estimation (Yoti), NL ID-wallet, digitaal rijbewijs — see the repo VERIFICATION_METHODS_PLAN.md.
POST /api/v1/verify/initiate (auth X-API-Key). Backend creates a session in Redis (15-min TTL), resolves the method, and returns either a bankRedirect (PSD2/iDIN/EUDI) or a qrPayload + yiviToken (Yivi).…/callback; the backend exchanges the code, derives the boolean (consent-implies-18, or compare returned DoB to minimumAge), stores only the result, and redirects the browser to the caller's redirectUri.GET /api/v1/verify/{sessionId} returns status (PENDING|COMPLETED|EXPIRED|BIRTH_DATE_UNAVAILABLE) and ageVerified. 200 for pending/completed, 404 for expired/unknown, 422 for birth-date-unavailable.See repo docs/api.md / CLAUDE.md for full request/response schemas.
| Decision | Reason |
|---|---|
| No PostgreSQL — Redis only | Sessions are ephemeral; a TTL'd cache is sufficient and avoids storing any PII at rest. |
| Birth dates never stored | Only the ageVerified boolean persists — minimises breach blast-radius and GDPR scope. |
| Consent-implies-18 | A PSD2 bank only grants consent to an adult account holder, so it is a legally valid proxy for ≥18 without revealing a birth date. |
| iDIN for > 18 | The single Dutch bank method that returns a date of birth, so any threshold can be checked. |
| Yivi never in AUTO | Avoids a hard runtime dependency on IRMA-server availability at initiation. |
| Client-triggered Yivi poll (POST /yivi/result/{token}) | No inbound webhook needed — the same flow works in local dev and CI without ngrok/tunnels. |
| BIRTH_DATE_UNAVAILABLE = HTTP 422 | The caller must distinguish "method can't tell you" from a generic failure, so it can fall back to iDIN. |
| PKCE on every OAuth2/OIDC flow | PSD2 security best practice; harmless even where a provider ignores code_challenge. |
| vl_live_* / vl_test_* key prefixes | Visibly separate production from sandbox credentials. |
| SIMULATION_ENABLED gate | The demo /bank/simulate endpoint is completely invisible unless explicitly enabled — never reachable in production. |
/actuator/health requires the X-API-Key header (auth filter in filter/).redirectUri validation — must be https:// or http://localhost; rejected on initiate.CORS_ALLOWED_ORIGINS (default http://localhost:4305).X-Forwarded-For only honoured when TRUST_PROXY=true.GlobalExceptionHandler returns generic 5xx without stack traces; health details only when_authorized; no birth dates in logs, responses, or storage.readOnlyRootFilesystem, all Linux capabilities dropped; nginx writable paths backed by emptyDir scratch mounts.PSD2_TLS_CERT_PATH / PSD2_TLS_KEY_PATH).| Layer | Technology |
|---|---|
| Backend | Spring Boot (Maven), runtime baseline Java 25 LTS (eclipse-temurin:25-jre-alpine) |
| Frontend | Angular 20 (standalone components), served by nginx-unprivileged |
| Session store | Redis (Bitnami subchart, standalone, 15-min TTL, no PII) |
| Infrastructure | k3s on a shared TransIP VPS, one namespace per environment |
| Ingress | shared ingress-nginx, routed by Host: + path |
| CI/CD | GitHub Actions → GHCR → Helm upgrade over SSH |
| Secrets | 1Password → Bitnami Sealed Secrets → k3s |
Deployment topology, namespaces, and the release pipeline are documented in deployment.md.
Reacties