Athena — valideerleeftijd/architecture.md

ValideerLeeftijd — Architecture

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


1. What the system is

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.


2. Component topology

                         ┌──────────────────────────────────────────────┐
   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 │   └───────────────────────────────────────┘
                  └──────────────┘
  • Backend (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.
  • Developer portal — Angular 20 standalone components served by nginxinc/nginx-unprivileged (uid 101, listens on 8080, read-only root FS). Shows masked API keys, the method picker, and the Yivi QR flow.
  • Redis — session store only. Deployed as the Bitnami Redis subchart (standalone, persistence 1Gi on k3s local-path). Holds vl:session:* entries: resolved method, PKCE verifier, status, and the final ageVerified boolean — never a date of birth.
  • External providers — Rabobank (PSD2 + iDIN), an IRMA/Yivi server, and EUDI Wallet verifier endpoints. All are outbound calls; no provider calls back via inbound webhook (see §5).

3. Verification methods & routing

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. |

  • PSD2 (Rabobank AISP) — OAuth2 + PKCE. Consent-implies-18 for 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.
  • iDIN (Rabobank Identity Services) — OIDC + PKCE; returns date of birth, solving the > 18 case.
  • Yivi / IRMA — selective disclosure of an 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.
  • EUDI Wallet (OpenID4VP) — SD-JWT VC + ISO 18013-5 mdoc (CBOR); sandbox and production modes; EudiController under /api/v1/eudi/*, wired into /verify/initiate.
  • DSA Article 28b receipts — on a completed session, 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.


4. Session lifecycle

  1. InitiatePOST /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).
  2. User action — browser is redirected to the bank/wallet (OAuth2/OIDC/OpenID4VP) or the user scans the Yivi QR and approves disclosure.
  3. Provider returns — bank/wallet flows hit a public …/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.
  4. Poll resultGET /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.
  5. Expiry — Redis TTL drops the session after 15 minutes; nothing to clean up.

See repo docs/api.md / CLAUDE.md for full request/response schemas.


5. Key design decisions (the "why")

| 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. |


6. Security posture

  • API auth — every endpoint except the public OAuth callbacks and /actuator/health requires the X-API-Key header (auth filter in filter/).
  • Rate limiting — Bucket4j, 100 req/min/IP, with LRU eviction (max ~10k buckets) to bound memory in a long-running process.
  • redirectUri validation — must be https:// or http://localhost; rejected on initiate.
  • CORS — restricted to CORS_ALLOWED_ORIGINS (default http://localhost:4305).
  • Proxy trustX-Forwarded-For only honoured when TRUST_PROXY=true.
  • No leakageGlobalExceptionHandler returns generic 5xx without stack traces; health details only when_authorized; no birth dates in logs, responses, or storage.
  • Pod hardening — non-root (backend uid 1000, frontend uid 101), readOnlyRootFilesystem, all Linux capabilities dropped; nginx writable paths backed by emptyDir scratch mounts.
  • mTLS — optional in the Rabobank sandbox, required in production (PSD2_TLS_CERT_PATH / PSD2_TLS_KEY_PATH).

7. Tech stack

| 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

Nog geen reacties