Compare commits
9 Commits
feat/6-dom
...
5a4331a416
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a4331a416 | |||
| 96d447832f | |||
| a07d8277d6 | |||
| 69d6e80378 | |||
| 5d32d4f15e | |||
| d767430ad7 | |||
| 751ca006a7 | |||
| fea806848b | |||
| 2f5d656b54 |
@@ -79,6 +79,13 @@ jobs:
|
|||||||
name: domain-mutation-report
|
name: domain-mutation-report
|
||||||
path: services/domain/StrykerOutput/**/reports/mutation-report.html
|
path: services/domain/StrykerOutput/**/reports/mutation-report.html
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
- uses: https://github.com/actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: bff-mutation-report
|
||||||
|
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
# One stage for every check that needs the live stack. On the single self-hosted
|
# One stage for every check that needs the live stack. On the single self-hosted
|
||||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||||
@@ -101,6 +108,8 @@ jobs:
|
|||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
|
- name: BFF → Keycloak + domain + projection
|
||||||
|
run: make verify-bff
|
||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -64,14 +64,14 @@ unit:
|
|||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
||||||
# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json,
|
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
|
||||||
# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json.
|
|
||||||
# Scores never regress below baseline.
|
# Scores never regress below baseline.
|
||||||
mutation:
|
mutation:
|
||||||
dotnet tool restore
|
dotnet tool restore
|
||||||
cd services/acl && dotnet stryker
|
cd services/acl && dotnet stryker
|
||||||
cd services/event-subscriber && dotnet stryker
|
cd services/event-subscriber && dotnet stryker
|
||||||
cd services/domain && dotnet stryker
|
cd services/domain && dotnet stryker
|
||||||
|
cd services/bff && dotnet stryker
|
||||||
|
|
||||||
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
|
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
|
||||||
# SEED populates the external config volumes first (upstream images used verbatim;
|
# SEED populates the external config volumes first (upstream images used verbatim;
|
||||||
@@ -143,6 +143,11 @@ verify-projection:
|
|||||||
verify-domain:
|
verify-domain:
|
||||||
bash infra/run-domain-check.sh
|
bash infra/run-domain-check.sh
|
||||||
|
|
||||||
|
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
|
||||||
|
## + anonymous public-safe openbaar register (ADR-0010).
|
||||||
|
verify-bff:
|
||||||
|
bash infra/run-bff-check.sh
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
@@ -154,7 +159,8 @@ verify:
|
|||||||
&& bash infra/run-acl-integration.sh \
|
&& bash infra/run-acl-integration.sh \
|
||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
&& bash infra/run-domain-check.sh || rc=$$?; \
|
&& bash infra/run-domain-check.sh \
|
||||||
|
&& bash infra/run-bff-check.sh || rc=$$?; \
|
||||||
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
||||||
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
||||||
exit $$rc'
|
exit $$rc'
|
||||||
|
|||||||
74
docs/architecture/adr-0010-bff-oidc.md
Normal file
74
docs/architecture/adr-0010-bff-oidc.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-01
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-07 (#8) adds the **BFF (Backend-for-Frontend)** — the single backend the Angular portals talk
|
||||||
|
to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services
|
||||||
|
already built:
|
||||||
|
|
||||||
|
- `POST /self-service/registrations` → Domain Service `POST /registrations` (S-05).
|
||||||
|
- `GET /openbaar/register?q=…` → projection-api `GET /register` (S-06).
|
||||||
|
|
||||||
|
It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new
|
||||||
|
dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BFF is the portals' only backend; it validates Keycloak `digid`-realm JWTs on the
|
||||||
|
self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and
|
||||||
|
projection over typed HTTP clients.**
|
||||||
|
|
||||||
|
- **Auth model.** `POST /self-service/registrations` requires a valid `digid`-realm bearer token;
|
||||||
|
the BFF reads the `bsn` claim and forwards it to the domain. Missing / invalid / expired token →
|
||||||
|
**401**. `GET /openbaar/register` is **anonymous** — the openbaar register is a public lookup
|
||||||
|
(S-09), so no token is required.
|
||||||
|
- **Portals talk only to the BFF (§8.3).** They never call the Domain Service, ACL, projection, or
|
||||||
|
OpenZaak directly. The BFF orchestrates via typed `HttpClient`s whose base URLs come from config.
|
||||||
|
Downstream calls are unauthenticated on the internal network for the walking skeleton; a
|
||||||
|
service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
|
||||||
|
- **Validation is `Microsoft.AspNetCore.Authentication.JwtBearer`** pointed at the Keycloak `digid`
|
||||||
|
realm authority. **New dependency justification:** it gives us standards-based OIDC/JWT validation
|
||||||
|
(signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation
|
||||||
|
would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
|
||||||
|
- **Tests mint their own tokens.** `WebApplicationFactory` tests override the bearer options with a
|
||||||
|
**test signing key**, so valid / invalid / expired tokens are minted in-process without a live
|
||||||
|
Keycloak. Real Keycloak validation is exercised by a live-stack `verify-bff` check.
|
||||||
|
- **OpenAPI is generated and committed** (`services/bff/openapi.json`) from .NET's built-in OpenAPI,
|
||||||
|
so S-08's Angular client is generated from the spec, never hand-written (§10).
|
||||||
|
|
||||||
|
## Known wrinkle — container OIDC issuer mismatch
|
||||||
|
|
||||||
|
Keycloak stamps tokens with an `iss` equal to its **browser-facing** URL (what the portal used to
|
||||||
|
log in), which differs from the BFF's **in-container** authority (`http://keycloak:8080/realms/digid`).
|
||||||
|
Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key).
|
||||||
|
`verify-bff` handles it by aligning the configured authority/issuer with the token's `iss` (and, if
|
||||||
|
needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Positive:** the walking skeleton gains its front door; §8.3 holds with all portal traffic going
|
||||||
|
through one backend; token validation is standard and testable without infra; the committed
|
||||||
|
OpenAPI unblocks S-08.
|
||||||
|
- **Negative / deferred:**
|
||||||
|
- Downstream service-to-service auth is deferred (internal-network trust for now).
|
||||||
|
- The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays
|
||||||
|
anonymous but the projection query narrows.
|
||||||
|
- The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the
|
||||||
|
browser and internal issuer URLs instead.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Token-gate the openbaar endpoint too** — rejected: the openbaar register is public by design
|
||||||
|
(S-09); requiring a login would contradict the slice's intent.
|
||||||
|
- **Validate tokens by calling Keycloak's introspection endpoint per request** — rejected: adds a
|
||||||
|
network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via
|
||||||
|
the realm's JWKS is the standard, faster choice.
|
||||||
|
- **Hand-written JWT parsing** — rejected: security-sensitive code we shouldn't own when a
|
||||||
|
first-party validator exists.
|
||||||
|
- **Generate the OpenAPI client by hand / keep the spec uncommitted** — rejected: §10 requires a
|
||||||
|
generated client from a committed spec.
|
||||||
@@ -5,6 +5,43 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-07 — BFF: the portals' single backend
|
||||||
|
|
||||||
|
**Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the
|
||||||
|
bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the
|
||||||
|
front door the portals (S-08/S-09) will talk to.
|
||||||
|
|
||||||
|
**The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and
|
||||||
|
BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring the full stack up.
|
||||||
|
make up
|
||||||
|
|
||||||
|
# 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar).
|
||||||
|
make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..."
|
||||||
|
|
||||||
|
# 3. Try it by hand (BFF on host port 8080).
|
||||||
|
# a) A digid access token for the mock user jan-burger (bsn 123456782):
|
||||||
|
tok=$(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 \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
||||||
|
|
||||||
|
# b) Submit — without the token it is 401; with it, 202:
|
||||||
|
curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations
|
||||||
|
curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \
|
||||||
|
-H "Authorization: Bearer $tok"
|
||||||
|
|
||||||
|
# c) The openbaar register is anonymous and exposes only id + status (never the bsn):
|
||||||
|
curl -fsS http://localhost:8080/openbaar/register | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
> The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no
|
||||||
|
> token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built
|
||||||
|
> from it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-05 — BIG Domain Service: submit a registration
|
## S-05 — BIG Domain Service: submit a registration
|
||||||
|
|
||||||
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
|
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
|
||||||
|
|||||||
@@ -344,6 +344,13 @@ services:
|
|||||||
context: ../services/bff
|
context: ../services/bff
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/bff:dev
|
image: register-referentie/bff:dev
|
||||||
|
environment:
|
||||||
|
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
|
||||||
|
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||||
|
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||||
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -352,6 +359,13 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
domain:
|
||||||
|
condition: service_healthy
|
||||||
|
projection-api:
|
||||||
|
condition: service_healthy
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||||
|
|||||||
58
infra/run-bff-check.sh
Executable file
58
infra/run-bff-check.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Verify the BFF end-to-end (S-07) against an ALREADY-RUNNING full stack. The BFF is the portals'
|
||||||
|
# only backend (§8.3): it validates Keycloak digid tokens on the self-service submit and serves the
|
||||||
|
# openbaar register anonymously with only public-safe fields (ADR-0010).
|
||||||
|
#
|
||||||
|
# Checks, in-network (services reached by container IP; Keycloak by its service name so the token's
|
||||||
|
# host-derived issuer matches the BFF's authority — see the compose bff env and ADR-0010):
|
||||||
|
# 1. POST /self-service/registrations without a token -> 401
|
||||||
|
# 2. mint a real digid access token (direct grant) and POST it -> 202 (forwarded to the domain)
|
||||||
|
# 3. GET /openbaar/register (anonymous) -> 200 JSON array, never a bsn
|
||||||
|
#
|
||||||
|
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown). Plain docker primitives.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||||
|
|
||||||
|
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
|
||||||
|
kc="$(docker ps -q --filter 'name=keycloak' | head -1)"
|
||||||
|
[ -n "$bff" ] || { echo "ERROR: no running bff container — bring the stack up first" >&2; exit 1; }
|
||||||
|
[ -n "$kc" ] || { echo "ERROR: no running keycloak container — bring the stack up first" >&2; exit 1; }
|
||||||
|
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
|
||||||
|
bff_ip="$(ip "$bff")"
|
||||||
|
echo ">> bff=$bff_ip network=$net"
|
||||||
|
|
||||||
|
# Helper: run curl inside a throwaway container on the stack network (reaches services by name/IP).
|
||||||
|
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
|
||||||
|
|
||||||
|
echo ">> 1. self-service submit without a token must be 401"
|
||||||
|
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d '{}')"
|
||||||
|
echo " -> $code"; [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> 2. minting a digid token (direct grant) via keycloak:8080 (host-consistent issuer)"
|
||||||
|
token=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
token="$(net_curl -s -X POST "http://keycloak:8080/realms/digid/protocol/openid-connect/token" \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
--data-urlencode 'grant_type=password' --data-urlencode 'client_id=big-portal' \
|
||||||
|
--data-urlencode 'username=jan-burger' --data-urlencode 'password=test123' \
|
||||||
|
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
|
||||||
|
[ -n "$token" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ -n "$token" ] || { echo "FAIL: could not obtain a digid access token" >&2; exit 1; }
|
||||||
|
echo " -> got a token"
|
||||||
|
|
||||||
|
echo ">> 2b. self-service submit with the token must be 202"
|
||||||
|
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
|
||||||
|
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}')"
|
||||||
|
echo " -> $code"; [ "$code" = "202" ] || { echo "FAIL: expected 202, got $code" >&2; docker logs "$bff" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> 3. openbaar register (anonymous) must be 200 JSON, never a bsn"
|
||||||
|
body="$(net_curl -s "http://$bff_ip:8080/openbaar/register")"
|
||||||
|
echo "$body" | grep -q '^\[' || { echo "FAIL: openbaar did not return a JSON array: $body" >&2; exit 1; }
|
||||||
|
if echo "$body" | grep -q '"bsn"'; then echo "FAIL: openbaar leaked a bsn field" >&2; exit 1; fi
|
||||||
|
|
||||||
|
echo "OK — BFF: 401 without token, 202 with a digid token, anonymous public-safe openbaar register"
|
||||||
@@ -31,6 +31,7 @@ nav:
|
|||||||
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md
|
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md
|
||||||
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
|
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
|
||||||
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
|
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
|
||||||
|
- "ADR-0010: BFF OIDC validation": architecture/adr-0010-bff-oidc.md
|
||||||
- Working in Gitea: gitea-workflow.md
|
- Working in Gitea: gitea-workflow.md
|
||||||
- Demo script: demo-script.md
|
- Demo script: demo-script.md
|
||||||
- Runbooks:
|
- Runbooks:
|
||||||
|
|||||||
@@ -6,4 +6,10 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
47
services/bff/Bff.Api/DownstreamClients.cs
Normal file
47
services/bff/Bff.Api/DownstreamClients.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Bff.Api;
|
||||||
|
|
||||||
|
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
||||||
|
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||||
|
|
||||||
|
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||||
|
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary>
|
||||||
|
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
||||||
|
|
||||||
|
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||||
|
public sealed record OpenbaarEntry(string Id, string Status);
|
||||||
|
|
||||||
|
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||||
|
public interface IDomainClient
|
||||||
|
{
|
||||||
|
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Port to the read projection.</summary>
|
||||||
|
public interface IProjectionClient
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||||
|
public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||||
|
{
|
||||||
|
public async Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync("registrations", new { bsn }, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
|
||||||
|
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Calls the projection-api's <c>GET /register</c>.</summary>
|
||||||
|
public sealed class ProjectionClient(HttpClient http) : IProjectionClient
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
|
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
|
||||||
|
}
|
||||||
18
services/bff/Bff.Api/OpenbaarProjection.cs
Normal file
18
services/bff/Bff.Api/OpenbaarProjection.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Bff.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The public view of the read projection: filters rows by the openbaar search term and maps each to
|
||||||
|
/// a public-safe <see cref="OpenbaarEntry"/> (only <c>id</c> + <c>status</c> — bsn/naam never leave the
|
||||||
|
/// BFF). Pure so it is unit- and mutation-tested directly (ADR-0010).
|
||||||
|
/// </summary>
|
||||||
|
public static class OpenbaarProjection
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<OpenbaarEntry> PublicView(IReadOnlyList<ProjectionEntry> entries, string? q)
|
||||||
|
{
|
||||||
|
var filtered = string.IsNullOrWhiteSpace(q)
|
||||||
|
? entries
|
||||||
|
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,72 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bff.Api;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||||
|
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
|
||||||
|
|
||||||
|
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
|
||||||
|
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
|
||||||
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = keycloakAuthority;
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
options.TokenValidationParameters.ValidateAudience = false;
|
||||||
|
});
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
||||||
|
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||||
|
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
|
||||||
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
|
||||||
|
// the doc was generated from (the client sets its own base URL). Keeps the drift guard deterministic.
|
||||||
|
builder.Services.AddOpenApi(options =>
|
||||||
|
options.AddDocumentTransformer((document, _, _) =>
|
||||||
|
{
|
||||||
|
document.Servers?.Clear();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapGet("/", () => "BFF placeholder");
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
|
||||||
|
// and is forwarded to the domain (ADR-0010). Returns 202 — the zaak is opened asynchronously (S-05).
|
||||||
|
app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
|
||||||
|
var accepted = await domain.SubmitRegistrationAsync(bsn, ct);
|
||||||
|
return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted);
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<SubmitAccepted>(StatusCodes.Status202Accepted)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||||
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var entries = await projection.GetRegisterAsync(ct);
|
||||||
|
return Results.Ok(OpenbaarProjection.PublicView(entries, q));
|
||||||
|
})
|
||||||
|
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,12 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"Keycloak": {
|
||||||
|
"Authority": "http://localhost:8180/realms/digid"
|
||||||
|
},
|
||||||
|
"Downstream": {
|
||||||
|
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||||
|
"Projection": { "BaseUrl": "http://localhost:8120/" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
services/bff/Bff.Tests/BffFactory.cs
Normal file
78
services/bff/Bff.Tests/BffFactory.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Bff.Api;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test host for the BFF. It swaps the downstream clients for in-memory fakes and reconfigures the
|
||||||
|
/// JWT bearer to validate against a local test key (no live Keycloak) — so token validation is
|
||||||
|
/// exercised in-process with tokens the tests mint (ADR-0010).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
public static readonly SymmetricSecurityKey TestSigningKey =
|
||||||
|
new(Encoding.UTF8.GetBytes("bff-test-signing-key-that-is-at-least-256-bits-long!"));
|
||||||
|
|
||||||
|
public FakeDomainClient Domain { get; } = new();
|
||||||
|
public FakeProjectionClient Projection { get; } = new();
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||||
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||||
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDomainClient>(Domain);
|
||||||
|
services.AddSingleton<IProjectionClient>(Projection);
|
||||||
|
|
||||||
|
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
// Validate locally against the test key; never reach out for OIDC metadata.
|
||||||
|
options.Authority = null;
|
||||||
|
options.MetadataAddress = null!;
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
options.Configuration = new OpenIdConnectConfiguration();
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = TestSigningKey,
|
||||||
|
ClockSkew = TimeSpan.Zero,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures the bsn the BFF forwarded and returns a canned acceptance.</summary>
|
||||||
|
internal sealed class FakeDomainClient : IDomainClient
|
||||||
|
{
|
||||||
|
public string? SubmittedBsn { get; private set; }
|
||||||
|
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||||
|
|
||||||
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SubmittedBsn = bsn;
|
||||||
|
return Task.FromResult(Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||||
|
internal sealed class FakeProjectionClient : IProjectionClient
|
||||||
|
{
|
||||||
|
public List<ProjectionEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
|
}
|
||||||
34
services/bff/Bff.Tests/OpenApiSpecTests.cs
Normal file
34
services/bff/Bff.Tests/OpenApiSpecTests.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the committed OpenAPI contract (<c>services/bff/openapi.json</c>) against drift: it must
|
||||||
|
/// equal the document the running BFF serves. S-08's Angular client is generated from this file, so a
|
||||||
|
/// stale spec is a bug. To regenerate: run the BFF and save <c>/openapi/v1.json</c> over the file.
|
||||||
|
/// </summary>
|
||||||
|
public class OpenApiSpecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Committed_openapi_spec_matches_the_served_document()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var served = await factory.CreateClient().GetStringAsync("/openapi/v1.json");
|
||||||
|
var committed = await File.ReadAllTextAsync(SpecPath());
|
||||||
|
|
||||||
|
Assert.Equal(Canonical(committed), Canonical(served));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformat both sides identically so the comparison is about content, not whitespace.
|
||||||
|
private static string Canonical(string json)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The committed spec sits at services/bff/openapi.json — one level up from this test file.
|
||||||
|
private static string SpecPath([CallerFilePath] string thisFile = "")
|
||||||
|
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "openapi.json");
|
||||||
|
}
|
||||||
38
services/bff/Bff.Tests/OpenbaarEndpointTests.cs
Normal file
38
services/bff/Bff.Tests/OpenbaarEndpointTests.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
public class OpenbaarEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Serves_public_safe_rows_anonymously()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
|
||||||
|
|
||||||
|
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
|
||||||
|
var response = await factory.CreateClient().GetAsync("/openbaar/register");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("abc-111", body);
|
||||||
|
Assert.Contains("INGEDIEND", body);
|
||||||
|
// The bsn must never appear in a public response.
|
||||||
|
Assert.DoesNotContain("123456782", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Filters_by_the_query_parameter()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
|
||||||
|
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
|
||||||
|
|
||||||
|
var rows = await factory.CreateClient()
|
||||||
|
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
|
||||||
|
|
||||||
|
Assert.Equal("abc-111", Assert.Single(rows!).Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
services/bff/Bff.Tests/OpenbaarProjectionTests.cs
Normal file
48
services/bff/Bff.Tests/OpenbaarProjectionTests.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Bff.Api;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
public class OpenbaarProjectionTests
|
||||||
|
{
|
||||||
|
private static readonly ProjectionEntry[] Sample =
|
||||||
|
[
|
||||||
|
new("abc-111", "INGEDIEND", "123456782", "Jan"),
|
||||||
|
new("def-222", "INGEDIEND", "987654321", "Piet"),
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Maps_every_row_to_public_safe_fields_when_no_query()
|
||||||
|
{
|
||||||
|
var view = OpenbaarProjection.PublicView(Sample, null);
|
||||||
|
|
||||||
|
Assert.Equal(2, view.Count);
|
||||||
|
Assert.Equal("abc-111", view[0].Id);
|
||||||
|
Assert.Equal("INGEDIEND", view[0].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Public_view_exposes_only_id_and_status()
|
||||||
|
{
|
||||||
|
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
|
||||||
|
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
|
||||||
|
Assert.Equal(["Id", "Status"], props);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("abc", 1)]
|
||||||
|
[InlineData("ABC", 1)]
|
||||||
|
[InlineData("2", 1)]
|
||||||
|
[InlineData("zzz", 0)]
|
||||||
|
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
|
||||||
|
{
|
||||||
|
var view = OpenbaarProjection.PublicView(Sample, q);
|
||||||
|
|
||||||
|
Assert.Equal(expected, view.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blank_query_is_treated_as_no_filter()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
services/bff/Bff.Tests/SelfServiceEndpointTests.cs
Normal file
75
services/bff/Bff.Tests/SelfServiceEndpointTests.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
public class SelfServiceEndpointTests
|
||||||
|
{
|
||||||
|
private static HttpRequestMessage Submit(string? bearer)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { }),
|
||||||
|
};
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_request_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.SendAsync(Submit(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
Assert.Null(factory.Domain.SubmittedBsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not-a-jwt")]
|
||||||
|
public async Task Rejects_a_malformed_token(string bearer)
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
var response = await factory.CreateClient().SendAsync(Submit(bearer));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_token_signed_with_the_wrong_key()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.WrongKey("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_an_expired_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.Expired("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Accepts_a_valid_token_and_forwards_the_bsn_to_the_domain()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.SendAsync(Submit(TestTokens.Valid("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||||
|
Assert.Equal("123456782", factory.Domain.SubmittedBsn);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<SubmitAcceptedDto>();
|
||||||
|
Assert.Equal("reg-123", body!.RegistrationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
|
}
|
||||||
30
services/bff/Bff.Tests/TestTokens.cs
Normal file
30
services/bff/Bff.Tests/TestTokens.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
/// <summary>Mints JWTs for the BFF tests — signed with the factory's test key (valid) or otherwise
|
||||||
|
/// (a wrong key / expired) to exercise the bearer validation the BFF configures.</summary>
|
||||||
|
internal static class TestTokens
|
||||||
|
{
|
||||||
|
public static string Valid(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: false);
|
||||||
|
|
||||||
|
public static string Expired(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: true);
|
||||||
|
|
||||||
|
public static string WrongKey(string bsn) => Create(
|
||||||
|
bsn,
|
||||||
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||||
|
expired: false);
|
||||||
|
|
||||||
|
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||||
|
{
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
return handler.CreateToken(new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Claims = new Dictionary<string, object> { ["bsn"] = bsn },
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(expired ? -5 : 30),
|
||||||
|
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
104
services/bff/openapi.json
Normal file
104
services/bff/openapi.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.1",
|
||||||
|
"info": {
|
||||||
|
"title": "Bff.Api | v1",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/self-service/registrations": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Accepted",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SubmitAccepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openbaar/register": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "q",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/OpenbaarEntry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"OpenbaarEntry": {
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SubmitAccepted": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Bff.Api"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
16
services/bff/stryker-config.json
Normal file
16
services/bff/stryker-config.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"stryker-config": {
|
||||||
|
"solution": "Bff.slnx",
|
||||||
|
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
||||||
|
"reporters": ["progress", "html"],
|
||||||
|
"mutate": [
|
||||||
|
"!**/Program.cs",
|
||||||
|
"!**/DownstreamClients.cs"
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"high": 95,
|
||||||
|
"low": 90,
|
||||||
|
"break": 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<!-- The BFF acceptance scenario drives the real HTTP endpoints in-process (ADR-0010). -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.4" />
|
<PackageReference Include="Reqnroll.xUnit" Version="3.3.4" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
22
tests/acceptance/Features/BffToegang.feature
Normal file
22
tests/acceptance/Features/BffToegang.feature
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-07 (#8). The BFF is the portals' only backend (§8.3): it validates Keycloak digid tokens
|
||||||
|
# on the self-service submit and serves the openbaar register anonymously with only public-safe
|
||||||
|
# fields (ADR-0010). Tokens are minted with a local test key; real Keycloak validation is verify-bff.
|
||||||
|
Feature: Toegang via de BFF
|
||||||
|
Als portaal wil ik uitsluitend via de BFF met de backend praten
|
||||||
|
zodat tokenvalidatie en veilige velden centraal geregeld zijn.
|
||||||
|
|
||||||
|
Scenario: Indienen met een geldig token wordt doorgezet naar het domein
|
||||||
|
Given a zorgprofessional with a valid DigiD token for BSN "123456782"
|
||||||
|
When they submit a registration via the BFF
|
||||||
|
Then the BFF accepts it and forwards BSN "123456782" to the domain
|
||||||
|
|
||||||
|
Scenario: Indienen zonder token wordt geweigerd
|
||||||
|
Given a caller without a token
|
||||||
|
When they submit a registration via the BFF
|
||||||
|
Then the BFF rejects it as unauthorized
|
||||||
|
|
||||||
|
Scenario: Het openbaar register is anoniem en toont geen BSN
|
||||||
|
Given the projection contains a zaak "abc-111" for BSN "123456782"
|
||||||
|
When the openbaar register is queried anonymously
|
||||||
|
Then the response lists "abc-111" without exposing the BSN
|
||||||
83
tests/acceptance/Steps/BffToegangSteps.cs
Normal file
83
tests/acceptance/Steps/BffToegangSteps.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Acceptance.Support;
|
||||||
|
using Bff.Api;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>BffToegang.feature</c> (S-07). Drives the real BFF over HTTP with a
|
||||||
|
/// fake domain + projection and locally-minted tokens (ADR-0010). One host per scenario.</summary>
|
||||||
|
[Binding]
|
||||||
|
public sealed class BffToegangSteps : IDisposable
|
||||||
|
{
|
||||||
|
private readonly BffAcceptanceHost _host = new();
|
||||||
|
private HttpClient _client = null!;
|
||||||
|
private string? _token;
|
||||||
|
private HttpResponseMessage? _response;
|
||||||
|
|
||||||
|
[Given("a zorgprofessional with a valid DigiD token for BSN \"(.*)\"")]
|
||||||
|
public void GivenAValidToken(string bsn)
|
||||||
|
{
|
||||||
|
_token = BffAcceptanceHost.MintToken(bsn);
|
||||||
|
_client = _host.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("a caller without a token")]
|
||||||
|
public void GivenNoToken() => _client = _host.CreateClient();
|
||||||
|
|
||||||
|
[Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
|
||||||
|
public void GivenTheProjectionContains(string zaakId, string bsn)
|
||||||
|
{
|
||||||
|
_host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null));
|
||||||
|
_client = _host.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("they submit a registration via the BFF")]
|
||||||
|
public async Task WhenTheySubmit()
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { }),
|
||||||
|
};
|
||||||
|
if (_token is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||||||
|
_response = await _client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("the openbaar register is queried anonymously")]
|
||||||
|
public async Task WhenTheOpenbaarRegisterIsQueried()
|
||||||
|
=> _response = await _client.GetAsync("/openbaar/register");
|
||||||
|
|
||||||
|
[Then("the BFF accepts it and forwards BSN \"(.*)\" to the domain")]
|
||||||
|
public void ThenAcceptedAndForwarded(string bsn)
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, _response!.StatusCode);
|
||||||
|
Assert.Equal(bsn, _host.Domain.SubmittedBsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the BFF rejects it as unauthorized")]
|
||||||
|
public void ThenUnauthorized()
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode);
|
||||||
|
Assert.Null(_host.Domain.SubmittedBsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the response lists \"(.*)\" without exposing the BSN")]
|
||||||
|
public async Task ThenListsWithoutBsn(string zaakId)
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.OK, _response!.StatusCode);
|
||||||
|
var body = await _response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains(zaakId, body);
|
||||||
|
Assert.DoesNotContain("123456782", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_response?.Dispose();
|
||||||
|
_client?.Dispose();
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/acceptance/Support/BffAcceptanceHost.cs
Normal file
80
tests/acceptance/Support/BffAcceptanceHost.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Bff.Api;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Acceptance.Support;
|
||||||
|
|
||||||
|
/// <summary>Hosts the real BFF in-process for the acceptance scenario, with fake downstreams and a
|
||||||
|
/// local test signing key so tokens can be minted without a live Keycloak (ADR-0010).</summary>
|
||||||
|
public sealed class BffAcceptanceHost : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
private static readonly SymmetricSecurityKey TestKey =
|
||||||
|
new(Encoding.UTF8.GetBytes("acceptance-bff-signing-key-at-least-256-bits-long!!"));
|
||||||
|
|
||||||
|
public CapturingDomainClient Domain { get; } = new();
|
||||||
|
public StubProjectionClient Projection { get; } = new();
|
||||||
|
|
||||||
|
public static string MintToken(string bsn) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Claims = new Dictionary<string, object> { ["bsn"] = bsn },
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(30),
|
||||||
|
SigningCredentials = new SigningCredentials(TestKey, SecurityAlgorithms.HmacSha256),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||||
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||||
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDomainClient>(Domain);
|
||||||
|
services.AddSingleton<IProjectionClient>(Projection);
|
||||||
|
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||||
|
{
|
||||||
|
options.Authority = null;
|
||||||
|
options.MetadataAddress = null!;
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
options.Configuration = new OpenIdConnectConfiguration();
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = TestKey,
|
||||||
|
ClockSkew = TimeSpan.Zero,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records the bsn the BFF forwarded.</summary>
|
||||||
|
public sealed class CapturingDomainClient : IDomainClient
|
||||||
|
{
|
||||||
|
public string? SubmittedBsn { get; private set; }
|
||||||
|
|
||||||
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
SubmittedBsn = bsn;
|
||||||
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serves configurable projection rows.</summary>
|
||||||
|
public sealed class StubProjectionClient : IProjectionClient
|
||||||
|
{
|
||||||
|
public List<ProjectionEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user