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), + }); + } +}