diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f857747..8501935 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -79,6 +79,13 @@ jobs: name: domain-mutation-report path: services/domain/StrykerOutput/**/reports/mutation-report.html 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 # runner jobs run sequentially, so booting OpenZaak once (instead of once per job) @@ -101,6 +108,8 @@ jobs: run: make verify-projection - name: Domain → Flowable → ACL → OpenZaak run: make verify-domain + - name: BFF → Keycloak + domain + projection + run: make verify-bff # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() diff --git a/Makefile b/Makefile index b6fba46..55879a5 100644 --- a/Makefile +++ b/Makefile @@ -64,14 +64,14 @@ unit: ## 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` # 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, -# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json. +# threshold (the ratchet, CLAUDE.md §5): each services//stryker-config.json. # Scores never regress below baseline. mutation: dotnet tool restore cd services/acl && dotnet stryker cd services/event-subscriber && 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 # SEED populates the external config volumes first (upstream images used verbatim; @@ -143,6 +143,11 @@ verify-projection: verify-domain: 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, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. @@ -154,7 +159,8 @@ verify: && bash infra/run-acl-integration.sh \ && bash infra/run-notification-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 volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ exit $$rc' diff --git a/docs/architecture/adr-0010-bff-oidc.md b/docs/architecture/adr-0010-bff-oidc.md new file mode 100644 index 0000000..d812298 --- /dev/null +++ b/docs/architecture/adr-0010-bff-oidc.md @@ -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. diff --git a/docs/demo-script.md b/docs/demo-script.md index e570024..fdaf9c5 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -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 **Outcome:** submitting a registration starts a Flowable process; the external-task worker diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 02a4e90..0c10982 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -344,6 +344,13 @@ services: context: ../services/bff dockerfile: Dockerfile 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: - "8080:8080" healthcheck: @@ -352,6 +359,13 @@ services: timeout: 3s retries: 5 start_period: 10s + depends_on: + domain: + condition: service_healthy + projection-api: + condition: service_healthy + keycloak: + condition: service_started networks: [cg] # ── Read projection (S-06) ──────────────────────────────────────────────── diff --git a/infra/run-bff-check.sh b/infra/run-bff-check.sh new file mode 100755 index 0000000..3114699 --- /dev/null +++ b/infra/run-bff-check.sh @@ -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" diff --git a/mkdocs.yml b/mkdocs.yml index 73a9a55..51895e5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - "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-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 - Demo script: demo-script.md - Runbooks: diff --git a/services/bff/Bff.Api/Bff.Api.csproj b/services/bff/Bff.Api/Bff.Api.csproj index a3a34b6..b97a2fc 100644 --- a/services/bff/Bff.Api/Bff.Api.csproj +++ b/services/bff/Bff.Api/Bff.Api.csproj @@ -6,4 +6,10 @@ enable + + + + + + diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs new file mode 100644 index 0000000..2b66096 --- /dev/null +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; + +namespace Bff.Api; + +/// What the self-service submit returns to the portal (the domain's registration id + status). +public sealed record SubmitAccepted(string RegistrationId, string Status); + +/// A projection row as the projection-api serves it. Bsn/NaamPlaceholder are +/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). +public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder); + +/// A public-safe openbaar register row — only non-sensitive fields leave the BFF. +public sealed record OpenbaarEntry(string Id, string Status); + +/// Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out). +public interface IDomainClient +{ + Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default); +} + +/// Port to the read projection. +public interface IProjectionClient +{ + Task> GetRegisterAsync(CancellationToken ct = default); +} + +/// Calls the Domain Service's POST /registrations. +public sealed class DomainClient(HttpClient http) : IDomainClient +{ + public async Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default) + { + using var response = await http.PostAsJsonAsync("registrations", new { bsn }, ct); + response.EnsureSuccessStatusCode(); + var dto = await response.Content.ReadFromJsonAsync(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); +} + +/// Calls the projection-api's GET /register. +public sealed class ProjectionClient(HttpClient http) : IProjectionClient +{ + public async Task> GetRegisterAsync(CancellationToken ct = default) + => await http.GetFromJsonAsync>("register", ct) ?? []; +} diff --git a/services/bff/Bff.Api/OpenbaarProjection.cs b/services/bff/Bff.Api/OpenbaarProjection.cs new file mode 100644 index 0000000..7f1ae33 --- /dev/null +++ b/services/bff/Bff.Api/OpenbaarProjection.cs @@ -0,0 +1,18 @@ +namespace Bff.Api; + +/// +/// The public view of the read projection: filters rows by the openbaar search term and maps each to +/// a public-safe (only id + status — bsn/naam never leave the +/// BFF). Pure so it is unit- and mutation-tested directly (ADR-0010). +/// +public static class OpenbaarProjection +{ + public static IReadOnlyList PublicView(IReadOnlyList 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))]; + } +} diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 633eec0..d705bd5 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -1,10 +1,72 @@ +using System.Security.Claims; +using Bff.Api; +using Microsoft.AspNetCore.Authentication.JwtBearer; + 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(c => c.BaseAddress = new Uri(domainBaseUrl)); +builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(projectionBaseUrl)); + 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(); -app.MapGet("/", () => "BFF placeholder"); +app.UseAuthentication(); +app.UseAuthorization(); + 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(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>(StatusCodes.Status200OK); app.Run(); diff --git a/services/bff/Bff.Api/appsettings.json b/services/bff/Bff.Api/appsettings.json index 10f68b8..7fbe3c0 100644 --- a/services/bff/Bff.Api/appsettings.json +++ b/services/bff/Bff.Api/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Keycloak": { + "Authority": "http://localhost:8180/realms/digid" + }, + "Downstream": { + "Domain": { "BaseUrl": "http://localhost:8130/" }, + "Projection": { "BaseUrl": "http://localhost:8120/" } + } } diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs new file mode 100644 index 0000000..510357b --- /dev/null +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -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; + +/// +/// 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). +/// +internal sealed class BffFactory : WebApplicationFactory +{ + 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(Domain); + services.AddSingleton(Projection); + + services.Configure(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, + }; + }); + }); + } +} + +/// Captures the bsn the BFF forwarded and returns a canned acceptance. +internal sealed class FakeDomainClient : IDomainClient +{ + public string? SubmittedBsn { get; private set; } + public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend"); + + public Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default) + { + SubmittedBsn = bsn; + return Task.FromResult(Result); + } +} + +/// Serves a configurable set of projection rows. +internal sealed class FakeProjectionClient : IProjectionClient +{ + public List Entries { get; } = []; + + public Task> GetRegisterAsync(CancellationToken ct = default) + => Task.FromResult>(Entries); +} diff --git a/services/bff/Bff.Tests/OpenApiSpecTests.cs b/services/bff/Bff.Tests/OpenApiSpecTests.cs new file mode 100644 index 0000000..93db800 --- /dev/null +++ b/services/bff/Bff.Tests/OpenApiSpecTests.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Bff.Tests; + +/// +/// Guards the committed OpenAPI contract (services/bff/openapi.json) 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 /openapi/v1.json over the file. +/// +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"); +} diff --git a/services/bff/Bff.Tests/OpenbaarEndpointTests.cs b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs new file mode 100644 index 0000000..0fcb165 --- /dev/null +++ b/services/bff/Bff.Tests/OpenbaarEndpointTests.cs @@ -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>("/openbaar/register?q=abc"); + + Assert.Equal("abc-111", Assert.Single(rows!).Id); + } +} diff --git a/services/bff/Bff.Tests/OpenbaarProjectionTests.cs b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs new file mode 100644 index 0000000..a385808 --- /dev/null +++ b/services/bff/Bff.Tests/OpenbaarProjectionTests.cs @@ -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); + } +} diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs new file mode 100644 index 0000000..fed9825 --- /dev/null +++ b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs @@ -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(); + Assert.Equal("reg-123", body!.RegistrationId); + } + + private sealed record SubmitAcceptedDto(string RegistrationId, string Status); +} diff --git a/services/bff/Bff.Tests/TestTokens.cs b/services/bff/Bff.Tests/TestTokens.cs new file mode 100644 index 0000000..b5788a5 --- /dev/null +++ b/services/bff/Bff.Tests/TestTokens.cs @@ -0,0 +1,30 @@ +using System.Text; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Bff.Tests; + +/// 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. +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 { ["bsn"] = bsn }, + Expires = DateTime.UtcNow.AddMinutes(expired ? -5 : 30), + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), + }); + } +} diff --git a/services/bff/openapi.json b/services/bff/openapi.json new file mode 100644 index 0000000..f1305b2 --- /dev/null +++ b/services/bff/openapi.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/services/bff/stryker-config.json b/services/bff/stryker-config.json new file mode 100644 index 0000000..fc3fe3c --- /dev/null +++ b/services/bff/stryker-config.json @@ -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 + } + } +} diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj index 08d182b..50d99ab 100644 --- a/tests/acceptance/Acceptance.csproj +++ b/tests/acceptance/Acceptance.csproj @@ -9,6 +9,8 @@ + + @@ -20,6 +22,7 @@ + diff --git a/tests/acceptance/Features/BffToegang.feature b/tests/acceptance/Features/BffToegang.feature new file mode 100644 index 0000000..85c0e50 --- /dev/null +++ b/tests/acceptance/Features/BffToegang.feature @@ -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 diff --git a/tests/acceptance/Steps/BffToegangSteps.cs b/tests/acceptance/Steps/BffToegangSteps.cs new file mode 100644 index 0000000..7e19e04 --- /dev/null +++ b/tests/acceptance/Steps/BffToegangSteps.cs @@ -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; + +/// Bindings for BffToegang.feature (S-07). Drives the real BFF over HTTP with a +/// fake domain + projection and locally-minted tokens (ADR-0010). One host per scenario. +[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(); + } +} diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs new file mode 100644 index 0000000..dcd94a2 --- /dev/null +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -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; + +/// 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). +public sealed class BffAcceptanceHost : WebApplicationFactory +{ + 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 { ["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(Domain); + services.AddSingleton(Projection); + services.Configure(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, + }; + }); + }); + } +} + +/// Records the bsn the BFF forwarded. +public sealed class CapturingDomainClient : IDomainClient +{ + public string? SubmittedBsn { get; private set; } + + public Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default) + { + SubmittedBsn = bsn; + return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); + } +} + +/// Serves configurable projection rows. +public sealed class StubProjectionClient : IProjectionClient +{ + public List Entries { get; } = []; + + public Task> GetRegisterAsync(CancellationToken ct = default) + => Task.FromResult>(Entries); +}