feat(event-subscriber): enforce the callback bearer before reading the body (refs #7)

Check the Authorization header before deserializing the notification, and read/parse the
body manually. NRC probes a new abonnement's callback with a request that has neither the
configured auth nor a valid notification body, and refuses to register unless it gets a 401
(not a 400) — ADR-0007. Mirrors the verify harness's sink contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:11:14 +02:00
parent a111e5cc20
commit 06d8d13e19

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using EventSubscriber.Application; using EventSubscriber.Application;
using Projection.ReadModel; using Projection.ReadModel;
@@ -22,18 +23,23 @@ await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy"); app.MapGet("/health", () => "Healthy");
// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it. // The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it.
// Auth-on-callback is mandatory: without the configured Authorization, return 401 (NRC's // Auth-on-callback is mandatory: the auth check runs *before* the body is read, so NRC's
// registration probe depends on this — ADR-0007). // registration probe (a POST without the configured Authorization, and without a valid
// notification body) gets the 401 it requires rather than a 400 (ADR-0007).
app.MapPost("/notifications", async ( app.MapPost("/notifications", async (
HttpRequest request, HttpRequest request,
NotificationDto body,
NotificationProjector projector, NotificationProjector projector,
CancellationToken ct) => CancellationToken ct) =>
{ {
if (request.Headers.Authorization != webhookToken) if (request.Headers.Authorization != webhookToken)
return Results.Unauthorized(); return Results.Unauthorized();
await projector.HandleAsync(body.ToNotification(), ct); var dto = await JsonSerializer.DeserializeAsync<NotificationDto>(
request.Body, SerializerOptions, ct);
if (dto is null)
return Results.BadRequest();
await projector.HandleAsync(dto.ToNotification(), ct);
return Results.NoContent(); return Results.NoContent();
}); });
@@ -53,4 +59,8 @@ public sealed record NotificationDto(string Kanaal, string Resource, string Acti
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
} }
public partial class Program; public partial class Program
{
// NRC sends camelCase JSON; match it case-insensitively.
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
}