- CartaNova.KeyStore — Autorisatie-ontwerp
- Twee authenticatieschema's
- Waarom audience-validatie is uitgeschakeld op JWT Bearer
- Autorisatiebeleid
- Rolmapping
- API-endpointpaden
- SendSecret — Intern handboek
- Endpoints
- Hoe te configureren
- 1. Eenmalige Postmark-setup per omgeving
- 2. Applicatieconfiguratie
- 3. Databasemigratie
- 4. Smoketest na deployment
- Probleemoplossing
- Configuratie
Keystore
Hoe het werkt
Verzenderflow
- De verzender opent https://keystore.cartaonline.nl/ in een browser. Cookie+OIDC stuurt door naar Keycloak; na inloggen toont de menubalk Verstuur wachtwoord.
- Het formulier heeft drie bewerkbare velden:
- Jouw e-mail — voorgevuld vanuit de OIDC email-claim, maar bewerkbaar zodat een interne gebruiker namens een collega kan versturen. De From:-regel die de ontvanger ziet, bevat exact wat hier wordt ingevuld.
- Aan e-mail — het ontvangeradres.
- Wachtwoord — de cleartext die getransporteerd wordt. Het servermaximum is SendSecret:MaxPasswordLength tekens.
- Onder het formulier toont een read-only preview de exacte body die verstuurd zal worden.
- Bij Verstuur valideert de server elk veld en verstuurt vervolgens eerst de e-mail. Pas wanneer Postmark een succesvolle MessageID teruggeeft, wordt de versleutelde rij in SharedSecrets weggeschreven. Een afwijzing door Postmark laat de tabel dus ongemoeid — geen weesrijen.
- Bij succes wordt de gebruiker doorgestuurd naar /SendSecret/Sent.
Ontvangerflow
- De ontvanger krijgt een plain-text e-mail van het adres dat de verzender heeft ingevuld, met onderwerp Hierbij het wachtwoord en een link in de vorm https://keystore.cartaonline.nl/ReadSecret/<guid>.
- Het openen van de link triggert een atomaire read-and-delete. De cleartext wordt één keer getoond, met een Kopiëren-knop die navigator.clipboard.writeText gebruikt. De pagina stuurt Cache-Control: no-store mee zodat geen tussenliggende cache of browser-back-knop de cleartext kan reproduceren.
- Een tweede bezoek aan dezelfde link — of aan een verlopen link, of aan een niet-bestaande GUID — toont steeds dezelfde neutrale boodschap: "Deze link is niet (meer) geldig." De drie toestanden zijn bewust niet van elkaar te onderscheiden, zodat een aanvaller niet kan afleiden in welke staat een link verkeert.
Eenmalige-gebruik-garantie
/ReadSecret/{guid} roept ISharedSecretService.ConsumeAsync aan, dat de rij ophaalt en verwijdert binnen één enkele SaveChangesAsync. Optimistic concurrency van EF Core op de verwijderde rij garandeert dat twee gelijktijdige reads van dezelfde GUID niet beide kunnen slagen: de tweede aanroeper krijgt een DbUpdateConcurrencyException en wordt gemapt op null. De 122 bits aan GUID-entropie maken brute-force raden al onpraktisch; de niet-onderscheidbare foutmeldingen zijn dubbele zekerheid.
Versleuteling at rest
Het wachtwoord wordt versleuteld met ASP.NET Core Data Protection (purpose CartaNova.KeyStore.SharedSecrets) tegen dezelfde DB-gepersisteerde key ring die ook door de credentialopslag van gebruikers wordt gebruikt. Een back-up van uitsluitend de SharedSecrets-tabel levert geen cleartext-wachtwoorden op — de key ring staat in DataProtectionKeys.
Opschoning & retentie
SharedSecretCleanupService is een BackgroundService die elke SendSecret:CleanupIntervalMinutes minuten ontwaakt en DELETE FROM SharedSecrets WHERE ExpiresAt < SYSUTCDATETIME() uitvoert (via EF Core ExecuteDeleteAsync, gebruikmakend van de geïndexeerde ExpiresAt-kolom). Verlopen rijen die toevallig via /ReadSecret/{guid} worden geraakt, worden ook opportunistisch verwijderd voordat de neutrale "niet geldig"-pagina wordt teruggegeven. De standaardvervaltijd is SendSecret:ExpiryDays dagen na aanmaken.
Audit logging
Elke verzending schrijft één Serilog Information-regel:
{SenderUserId} sent secret {SecretId} as {SenderEmail} to {RecipientEmail} (Postmark {PostmarkMessageId}); expires {ExpiresAt:O}SenderUserId is altijd de Keycloak sub van de daadwerkelijk ingelogde gebruiker, zelfs wanneer SenderEmail is aangepast naar het adres van een collega. "Namens wie" en "door wie" blijven dus afzonderlijk vastgelegd. De MessageID van Postmark stelt je in staat om een verzending te koppelen aan de Activity-view van het Postmark-dashboard.
Elke read schrijft:
ReadSecret called for {SecretId} from {ClientIp}
Secret {SecretId} consumed (Postmark {PostmarkMessageId})Mislukte reads (onbekend, verlopen, race verloren) loggen alleen de eerste regel. Het cleartext-wachtwoord — versleuteld of ontsleuteld — verschijnt nooit in welke log dan ook.
CartaNova.KeyStore — Autorisatie-ontwerp
Dit document beschrijft de keuzes die zijn gemaakt voor authenticatie en autorisatie in deze service.
Twee authenticatieschema's
De KeyStore draait twee volledig onafhankelijke authenticatieschema's naast elkaar.
Cookie + OpenID Connect is het standaardschema. Dit bedient de Razor Pages-UI. Wanneer een gebruiker de applicatie in een browser opent, wordt hij doorgestuurd naar Keycloak, logt daar in en ontvangt een sessiecookie. Alle Razor Pages (/Keys, /Admin/*) gebruiken dit schema.
JWT Bearer is een tweede schema dat uitsluitend wordt gebruikt door de API-endpoints (GET /keys/{keyName}, GET /keystatus). Aanroepers geven het Keycloak access token van de gebruiker mee in een Authorization: Bearer-header. De KeyStore valideert het token en haalt de identiteit van de gebruiker uit de sub-claim.
De schema's verstoren elkaar niet. Een browserrequest draagt een cookie en bereikt een Razor Page; een service-aanroep draagt een Bearer-token en bereikt een minimal API-endpoint.
Waarom audience-validatie is uitgeschakeld op JWT Bearer
De Keycloak client-ID van de KeyStore zelf is keystore. Een JWT die voor die client is uitgegeven, draagt aud: keystore.
De aanroepers van de API-endpoints zijn echter backend-services (CartaNova.McpTools.Worker) die handelen namens een menselijke gebruiker. Het JWT dat zij doorsturen, is uitgegeven door de browsersessie van de gebruiker tegen de AgenticApp Keycloak-client (cartanova-webclient), en draagt dus aud: cartanova-webclient. Validatie van de audience tegen keystore zou elke legitieme service-aanroep afwijzen.
Validatie van issuer en signing key blijft ingeschakeld, wat afdoende is voor een interne service-mesh. Dit is consistent met dezelfde keuze in CartaNova.McpTools.StreamableHttp.
opts.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = false, // zie hierboven ValidateLifetime = true, ValidateIssuerSigningKey = true };
Autorisatiebeleid
JwtOnly — gebruikt door de API-endpoints. Accepteert uitsluitend JWT Bearer-authenticatie; een sessiecookie volstaat niet. Elke geauthenticeerde gebruiker mag deze endpoints aanroepen; er is geen rolvereiste.
AdminPolicy — gebruikt door /Admin/* Razor Pages. Vereist de Keycloak-rol die is geconfigureerd als Keycloak:AdminRole in appsettings.json (op dit moment admin). Leesbare en gedeelde credentials kunnen alleen via admin-pagina's worden geschreven.
Alle overige Razor Pages (/, /Keys) vereisen authenticatie via het standaardschema (cookie), maar geen specifieke rol.
Rolmapping
Keycloak codeert realm-rollen in een geneste JSON-claim realm_access.roles, niet in een standaard ClaimTypes.Role-claim. KeycloakRoleTransformation (geregistreerd als IClaimsTransformation) leest deze claim bij elk geauthenticeerd request en promoveert elke rol naar een standaard ClaimTypes.Role-claim. Hierdoor werken User.IsInRole() en [Authorize(Roles = "...")] zonder verdere aanpassingen elders.
API-endpointpaden
| Methode | Pad | Auth | Beschrijving |
|---|---|---|---|
| GET | /keylist | Anoniem | Geeft alle geregistreerde credential-definities terug (namen en beschrijvingen, geen waarden) |
| GET | /keystatus | JWT Bearer | Geeft de status (ingesteld/niet ingesteld) terug van alle credentials voor de aanroepende gebruiker |
| GET | /keys/{keyName} | JWT Bearer | Geeft de opgeslagen credentialwaarde terug voor de aanroepende gebruiker |
GET /keystatus gebruikt dat pad (en niet /keys) om een routingconflict met de /Keys Razor Page te voorkomen. Path matching in ASP.NET Core is niet hoofdlettergevoelig, waardoor /keys en /Keys naar dezelfde route resolveren. De Razor Page moet winnen om de browserflow werkend te houden.
SendSecret — Intern handboek
Een tweede functie binnen deze service: eenmalig wachtwoordtransport. Een
geauthenticeerde gebruiker vult een Nederlands formulier in op /SendSecret,
de ontvanger krijgt een e-mail met een eenmalige /ReadSecret/{guid}-link,
en het wachtwoord wordt precies één keer getoond voordat het uit de database
wordt verwijderd.
Deze sectie is het operationele handboek voor deze functie: hoe het end-to-end
werkt en hoe het te configureren is voor een nieuwe omgeving. Beoogde
doelgroep: interne ontwikkelaars en operators.
Endpoints
| Methode | Pad | Auth | Beschrijving |
|---|---|---|---|
| GET | /SendSecret | Cookie+OIDC | Toont het Nederlandse formulier + plain-text e-mailpreview. |
| POST | /SendSecret | Cookie+OIDC | Valideert, verstuurt via Postmark, slaat op bij succes, redirect. |
| GET | /SendSecret/Sent | Cookie+OIDC | Bevestigingspagina. |
| GET | /ReadSecret/{guid:guid} | Anoniem | Eenmalige onthulling — leest + verwijdert de rij in één transactie. |
Hoe te configureren
1. Eenmalige Postmark-setup per omgeving
In het Postmark-dashboard:
- Maak een Server aan voor deze omgeving (bijv. keystore-prod, keystore-acc).
- Voeg een Domain Signature toe voor cartaonline.nl (DKIM + Return-Path geverifieerd). Dit is wat het patroon "namens een collega versturen" mogelijk maakt — Postmark accepteert elk *@cartaonline.nl-adres als From zodra het domein geverifieerd is. Zonder Domain Signature werken alleen individueel bevestigde Sender Signatures.
- Onder Servers → \[server\] → API Tokens, kopieer het Server API Token.
- Verifieer welke Message Stream de server gebruikt (standaard outbound voor transactioneel). Transactioneel is correct voor SendSecret — dit zijn geen broadcast-mails.
2. Applicatieconfiguratie
Alle vereiste sleutels staan in de algemene tabel [Configuratie](#configuratie) hieronder. De blokken SendSecret:* en Postmark:* moeten aanwezig en niet-leeg zijn; ontbrekende waarden veroorzaken een ArgumentException bij startup — er zijn geen defaults.
Plaats voor productie Postmark:ServerToken in een environment-variabele (Postmark__ServerToken) in plaats van in de gecommitte appsettings.json, zodat het token niet in versiebeheer terechtkomt.
Een minimaal appsettings.json-fragment:
json
"SendSecret": {
"PublicBaseUrl": "https://keystore.cartaonline.nl",
"ExpiryDays": 7,
"MaxPasswordLength": 4096,
"CleanupIntervalMinutes": 60
},
"Postmark": {
"ServerToken": "<set-in-environment>",
"MessageStream": "outbound"
}SendSecret:PublicBaseUrl is het URL-prefix dat in de body van de e-mail terechtkomt. Het moet de extern bereikbare host zijn — de ontvanger opent deze link van buiten het netwerk. Geen afsluitende slash.
3. Databasemigratie
De EF-migratie SendSecret (zie Migrations/20260430095444_SendSecret.cs) maakt de tabel SharedSecrets aan met een geïndexeerde ExpiresAt-kolom. Hij wordt automatisch toegepast bij startup via de bestaande app.Database.MigrateAsync()-aanroep in Program.cs. Geen handmatige stap nodig.
4. Smoketest na deployment
- Log in op https://<host>/SendSecret.
- Verstuur een testwachtwoord naar een mailbox onder eigen beheer (bijv. je eigen privéadres).
- Bevestig dat de verzending verschijnt als Sent onder de Activity-view van de Postmark-server, met de bijbehorende MessageID.
- Open de link in de e-mail. De cleartext moet exact één keer verschijnen, met een werkende Kopiëren-knop.
- Herlaad de link. De pagina hoort nu "Deze link is niet (meer) geldig." te tonen.
- Inspecteer de Serilog-output: één ... sent secret ...-regel, één ReadSecret called ...-regel, één Secret ... consumed ...-regel. Het wachtwoord zelf hoort nergens in de logs voor te komen.
Probleemoplossing
Startup faalt met ArgumentException: <key> is required in appsettings.json — die specifieke sleutel ontbreekt of is leeg. Er zijn geen defaults; vul hem in.
Formulierinzending toont "Verzenden mislukt: Postmark afgewezen: ..." — Postmark heeft de verzending afgewezen. Veelvoorkomende oorzaken: het adres in Jouw e-mail valt niet onder een Sender Signature of Domain Signature; het ontvangeradres is misvormd; het servertoken is verkeerd of voor een andere omgeving. De Postmark-foutcode in het bericht en de Serilog-regel Postmark rejected send to ... vertellen welke van deze het is.
Ontvanger krijgt de e-mail wel, maar de link geeft "niet (meer) geldig" — controleer eerst of iemand anders de link al heeft geopend (de eenmaligheid doet dan zijn werk). Verifieer anders dat SendSecret:PublicBaseUrl de extern bereikbare host is: een interne URL in de e-mailbody levert een link op die deze service nooit kan bereiken.
Cleanup-service lijkt niet te draaien — bij startup hoort SharedSecret cleanup service started; interval N min zichtbaar te zijn. Zo niet, controleer of SendSecret:CleanupIntervalMinutes is gezet en > 0, en of de startup niet eerder is gefaald (zoek naar de hierboven genoemde ArgumentException-regels in de log).
Postmark-dashboard toont de verzending, maar de ontvanger heeft hem nooit gekregen — bekijk de views Activity → Bounces en Spam Complaints in Postmark. De mailserver van de ontvanger kan het bericht hebben geweigerd; dit valt buiten de scope van SendSecret en wordt volledig vanuit Postmark gediagnosticeerd.
Configuratie
Alle vereiste sleutels moeten aanwezig zijn in appsettings.json. Ontbrekende waarden veroorzaken een ArgumentException bij startup.
| Sleutel | Beschrijving |
|---|---|
| ConnectionStrings:KeystoreDb | SQL Server-connectionstring |
| Keycloak:Authority | URL van het Keycloak-realm (bijv. https://identity.../realms/master) |
| Keycloak:ClientId | Keycloak client-ID van deze service (gebruikt voor de OIDC-browserflow) |
| Keycloak:ClientSecret | Client secret voor de OIDC code exchange |
| Keycloak:AdminRole | Naam van de Keycloak-rol die toegang geeft tot /Admin/* pagina's |
| SendSecret:PublicBaseUrl | Absolute basis-URL die in de gegenereerde /ReadSecret/{guid}-link in de e-mail terechtkomt |
| SendSecret:ExpiryDays | Aantal dagen waarna een ongelezen gedeeld geheim door de cleanup-service wordt verwijderd |
| SendSecret:MaxPasswordLength | Servermaximum voor de wachtwoordlengte (tekens) |
| SendSecret:CleanupIntervalMinutes | Hoe vaak de cleanup-achtergrondservice ontwaakt |
| Postmark:ServerToken | Postmark-servertoken dat gebruikt wordt om de SendSecret-notificatiemail te versturen |
| Postmark:MessageStream | Postmark message-stream-identifier (bijv. outbound) |
- Last Author
- hans
- Last Edited
- Thu, Apr 30, 12:12 PM