test(bff): endpoints, JWT auth and public-safe projection (refs #8)
Failing tests for the BFF walking-skeleton endpoints: - POST /self-service/registrations rejects missing/malformed/wrong-key/expired tokens (401) and, with a valid digid token, forwards the bsn to the domain and returns 202 (WebApplicationFactory + a local test signing key, ADR-0010). - GET /openbaar/register serves public-safe rows anonymously (never the bsn) and filters by q. - OpenbaarProjection.PublicView (pure) filters by id and maps to id+status only. Endpoints and PublicView are stubs so the tests compile and fail on their assertions; the green commit implements them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,4 +6,10 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</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>
|
||||
|
||||
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) ?? [];
|
||||
}
|
||||
15
services/bff/Bff.Api/OpenbaarProjection.cs
Normal file
15
services/bff/Bff.Api/OpenbaarProjection.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
{
|
||||
// STUB (red): returns nothing until the green commit implements filter + public-safe mapping.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(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<OpenbaarEntry>()));
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -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/" }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user