Athena — valideerleeftijd/README.md

ValideerLeeftijd — Age Verification API

By Mahmoud Consultancy

Last updated: 2026-04-15


Overview

ValideerLeeftijd is a multi-method age verification API. Companies integrate a single API endpoint to verify a user's age. The backend routes the request to the right verification method (PSD2, iDIN, or Yivi) and returns a simple ageVerified: true/false result. Birth dates are never stored — only the boolean result lives in Redis for 15 minutes.

GitHub: https://github.com/mahmoudholding/valideerleeftijd


Tech Stack

| Layer | Technology | |-------|-----------| | Backend | Spring Boot 3 (Java 21, Maven) | | Frontend | Angular 20 (standalone components) | | Session store | Redis (15-min TTL, no PII stored) | | Infrastructure | k3s on TransIP VPS | | CI/CD | GitHub Actions → GHCR → Helm |


Architecture

┌────────────────────────────────────────────────────────────────────┐
│                      ValideerLeeftijd Platform                      │
├──────────────────────────┬─────────────────────────────────────────┤
│   Developer Portal       │  REST API                               │
│   Angular 20             │  Spring Boot 3                          │
│   port 4305              │  port 8095                              │
│                          │                                         │
│  - API key display       │  POST /api/v1/verify/initiate           │
│  - Docs / quick start    │  GET  /api/v1/verify/{sessionId}        │
│  - Method picker UI      │  GET  /api/v1/bank/authorize            │
│  - Yivi QR display       │  GET  /api/v1/bank/callback             │
│                          │  GET  /api/v1/idin/authorize            │
│                          │  GET  /api/v1/idin/callback             │
│                          │  POST /api/v1/yivi/result/{token}       │
└──────────────────────────┴─────────────────────────────────────────┘
                                   │
               ┌───────────────────┼──────────────────────┐
               ▼                   ▼                      ▼
        PSD2 (Rabobank)        iDIN (Rabobank)       Yivi (IRMA)
        OAuth2 PKCE            OIDC PKCE             Selective disclosure
        consent = ≥18          returns birth date    over18/over21 attribute
        min_age ≤ 18           min_age > 18          no bank account needed

Verification Method Routing

| Request | AUTO resolves to | Why | |---------|-----------------|-----| | minimumAge ≤ 18 | PSD2 | Bank consent implies adult account holder | | minimumAge > 18 | IDIN | Only method that returns birth date | | method: "PSD2" | PSD2 | Explicit override | | method: "IDIN" | IDIN | Explicit override | | method: "YIVI" | YIVI | Explicit — never chosen by AUTO |

YIVI is never chosen by AUTO because it depends on an IRMA server being reachable at initiation time. Clients opt in explicitly.


API Reference

POST /api/v1/verify/initiate

Auth: X-API-Key header required

Request:

{
  "minimumAge": 18,
  "redirectUri": "https://yourapp.com/callback",
  "method": "AUTO"
}
  • method: AUTO (default), PSD2, IDIN, or YIVI

Response — PSD2 / iDIN:

{
  "sessionId": "uuid",
  "bankRedirect": "https://oauth.rabobank.nl/openapi/oauth2/authorize?...",
  "method": "PSD2",
  "qrPayload": null,
  "yiviToken": null
}

Response — Yivi:

{
  "sessionId": "uuid",
  "bankRedirect": null,
  "method": "YIVI",
  "qrPayload": "https://irma-server.example.com/irma/session/TOKEN",
  "yiviToken": "TOKEN"
}

GET /api/v1/verify/{sessionId}

Auth: X-API-Key header required

{
  "sessionId": "uuid",
  "status": "PENDING|COMPLETED|EXPIRED|BIRTH_DATE_UNAVAILABLE",
  "ageVerified": true,
  "minimumAge": 18,
  "method": "PSD2|IDIN|YIVI",
  "expiresAt": "2026-04-15T14:00:00Z"
}
  • HTTP 200 for PENDING / COMPLETED
  • HTTP 404 for EXPIRED or unknown session
  • HTTP 422 for BIRTH_DATE_UNAVAILABLE (min_age > 18 via PSD2 Rabobank AISP)

POST /api/v1/yivi/result/{yiviToken}

Auth: X-API-Key header required
Client-triggered poll: backend polls IRMA server and updates session status.

{ "irmaStatus": "DONE|CONNECTED|CANCELLED|TIMEOUT|ERROR" }
  • HTTP 404 if yiviToken is unknown
  • HTTP 500 with irmaStatus: "ERROR" if IRMA server is unreachable

Yivi polling flow:

  1. Client receives qrPayload + yiviToken from initiate
  2. User scans QR with Yivi app and approves disclosure
  3. Client polls POST /api/v1/yivi/result/{yiviToken} until status ≠ CONNECTED
  4. Client polls GET /api/v1/verify/{sessionId} for final ageVerified result

Implementation Status

| Component | Status | Notes | |-----------|--------|-------| | Spring Boot 3 backend | ✅ Done | 189 tests (31 Cucumber), all passing | | Angular 20 developer portal | ✅ Done | 80 tests, 95.45% branch coverage | | PSD2 Rabobank PKCE flow | ✅ Done | Sandbox pre-configured | | iDIN OIDC flow | ✅ Done | IdinService + IdinController | | Yivi/IRMA integration | ✅ Done | YiviService + YiviController; QR flow in portal (PR #32) | | Multi-method picker (API + UI) | ✅ Done | AUTO/PSD2/IDIN/YIVI; card picker in portal | | Cucumber BDD E2E tests | ✅ Done | 31 scenarios; WireMock IRMA mock for full Yivi flow | | API key auth + rate limiting | ✅ Done | Bucket4j 100 req/min/IP | | Redis session management | ✅ Done | 15-min TTL, no birth date stored | | Helm chart / CI/CD | ✅ Done | k3s on TransIP VPS | | iDIN sandbox credentials | ⏳ Pending | Register at developer.rabobank.nl (issue #27) | | Yivi IRMA server (prod setup) | ⏳ Pending | Self-host or Yivi-hosted; configure app.yivi.server-url | | ID document upload | Not started | Phase 3 — Onfido/Veriff | | Face scan (Yoti) | Not started | Phase 4 | | EUDI Wallet | Not started | Phase 4–5 |


Local Development

# Full stack (recommended)
make docker-up
# backend  → http://localhost:8095
# frontend → http://localhost:4305
# redis    → localhost:6383

# Tests
make test-backend   # 189 tests (31 Cucumber scenarios)
make test-frontend  # 80 Angular Karma tests

# Quick API smoke test — PSD2
curl -X POST http://localhost:8095/api/v1/verify/initiate \
  -H "X-API-Key: vl_test_local123" \
  -H "Content-Type: application/json" \
  -d '{"minimumAge": 18, "redirectUri": "http://localhost:4305/callback"}'

# Quick API smoke test — Yivi (needs irma server running on :8088)
curl -X POST http://localhost:8095/api/v1/verify/initiate \
  -H "X-API-Key: vl_test_local123" \
  -H "Content-Type: application/json" \
  -d '{"minimumAge": 18, "redirectUri": "http://localhost:4305/callback", "method": "YIVI"}'

Yivi local setup:

# Install irma CLI and start local IRMA server
irma server --no-auth --url http://localhost:8088
# Backend auto-connects via app.yivi.server-url=http://localhost:8088

Ports

| Service | Port | |---------|------| | Backend API | 8095 | | Frontend dev | 4305 | | Redis (host) | 6383 | | IRMA server (local) | 8088 |


Key Design Decisions

| Decision | Reason | |----------|--------| | No PostgreSQL | Sessions only — Redis TTL is sufficient, avoids storing PII | | Consent-implies-18 | PSD2 consent requires adult account holder — legally valid | | iDIN for min_age > 18 | Only Dutch bank method that returns birth date | | YIVI never in AUTO | Avoids hard dependency on IRMA server availability at initiation time | | Client-triggered Yivi poll | No inbound webhook required — works in local dev and CI without ngrok | | BIRTH_DATE_UNAVAILABLE (HTTP 422) | Client needs to distinguish unavailable vs failure | | vl_live_* / vl_test_* key format | Distinguish prod from sandbox keys | | PKCE on all OAuth2 flows | PSD2 security best practice |


Roadmap

  • [x] v1.0 — PSD2 age verification, Redis sessions, API key auth
  • [x] v1.1 — iDIN backend, multi-method picker API, Cucumber BDD tests
  • [x] v1.2 — Frontend method picker UI (card-based MethodPickerComponent)
  • [x] v1.3 — Yivi/IRMA selective-disclosure wallet (backend + portal QR flow + full E2E tests)
  • [ ] v1.4 — iDIN sandbox credentials + real Rabobank sandbox E2E test
  • [ ] v2.0 — ID document upload (Onfido/Veriff)
  • [ ] v3.0 — Face scan (Yoti), EUDI Wallet (OpenID4VP)

Reacties

Nog geen reacties