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..48d9576
--- /dev/null
+++ b/services/bff/Bff.Api/OpenbaarProjection.cs
@@ -0,0 +1,15 @@
+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)
+ {
+ // STUB (red): returns nothing until the green commit implements filter + public-safe mapping.
+ return [];
+ }
+}
diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs
index 633eec0..36a44fc 100644
--- a/services/bff/Bff.Api/Program.cs
+++ b/services/bff/Bff.Api/Program.cs
@@ -1,10 +1,44 @@
+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();
+builder.Services.AddOpenApi();
var app = builder.Build();
-app.MapGet("/", () => "BFF placeholder");
+app.UseAuthentication();
+app.UseAuthorization();
+
app.MapHealthChecks("/health");
+app.MapOpenApi();
+
+// STUB (red): no auth requirement, no downstream call, no filtering.
+app.MapPost("/self-service/registrations", () => Results.Ok());
+app.MapGet("/openbaar/register", (string? q) => Results.Ok(Array.Empty()));
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/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),
+ });
+ }
+}