ADR 0003: Plugin-JWT vault voor Outlook-tokens
- Status: geaccepteerd
- Datum: 2026-04-03
- Beslissers: Roy Milder (engineering), security review intern
Context
De Outlook-plugin koppelt aan Exact Online via OAuth2. Per gebruiker zijn er
een access_token (~10 min geldig) en een refresh_token (~maanden geldig)
nodig om Exact API-calls te doen. De plugin draait volledig client-side in een
Outlook-WebView.
Twee security-eigenschappen van die WebView zijn relevant:
- De plugin loopt in een Office-add-in sandbox;
localStorageis wel beschikbaar maar staat in een ander origin dan de host-Outlook. - Een gecompromitteerde browser-extensie, een geslaagde XSS, of een
geheugen-dump kan in theorie de inhoud van
localStoragelezen.
Tegelijk is er een ergonomic-eis: de gebruiker mag niet bij elke plugin-opening opnieuw hoeven inloggen bij Exact. Een sessie moet maandenlang overleven en zichzelf stilletjes herstellen na inactiviteit.
De vraag: waar bewaren we de Exact-tokens, en hoe houdt de plugin de gebruiker ingelogd?
Beslissing
We slaan Exact-tokens uitsluitend server-side op, encrypted-at-rest in een
nieuwe OutlookExactToken-collectie (AES-256-GCM via tokenCrypto). De
client krijgt alleen een Tapster-plugin-JWT in localStorage. Dat JWT
identificeert de gebruiker (exactUserId + tenantUuid in de payload, HS256
signed met OUTLOOK_PLUGIN_JWT_SECRET).
Bij elke plugin-API-call zet de client de JWT in Authorization: Bearer ….
Een server-side requirePluginSession middleware verifieert het JWT, leest de
bijbehorende OutlookExactToken-doc, refresht het access_token zo nodig, en
hangt het tuple op req.pluginSession. Vanaf daar werkt de plugin-API zoals
elke andere Exact-API-call.
Het plugin-JWT zelf leeft 30 dagen, met een client-side slide-refresh-window van 7 dagen (vernieuwt automatisch tijdens normaal gebruik) en een server-side recovery-grace-window van 60 dagen (accepteert een verlopen JWT zolang de koppeling server-side nog geldig is).
Gevolgen
Positief
- Geen Exact-tokens in browser: een gecompromitteerde plugin-omgeving kan het Tapster-plugin-JWT lekken, maar dat geeft alleen toegang tot Tapster’s geverifieerde Exact-API-paden, niet directe Exact-access. Plugin-JWT’s zijn bovendien per-gebruiker en server-side revokeable.
- Server kan tokens roteren zonder client-actie. De cron-flow refresht proactief; de client weet er niets van.
- Centraal revoken: één
$set { revokedAt: <date> }op het doc en alle plugin-sessies van die Exact-user falen onmiddellijk metEXACT_REAUTH_REQUIRED. - Encryptie-sleutel-rotatie is mogelijk via
encKeyVersionop het doc — oude tokens blijven leesbaar tijdens een rollout. - Slide-refresh + recovery-grace geven een effectieve permanent-ingelogd- beleving voor actieve gebruikers, zonder JWT-lifetime onbeperkt op te rekken.
Negatief
- Server-side state: een
OutlookExactToken-collectie met encryptie-sleutel-management, indexen, en een eigen backup-overweging. Niet triviaal vergeleken met “alles in localStorage”. - Geen offline-werking: client kan geen Exact-call doen zonder Tapster als proxy. Niet bedoeld als feature, maar wel een limitatie.
- Server moet altijd bereikbaar zijn: een Tapster-API-uitval breekt de plugin volledig, ook al kan de browser zelf nog naar Exact praten.
Risico’s
- Sleutel-lek: als
OUTLOOK_TOKEN_ENC_KEYlekt, zijn alle tokens decryptbaar. Mitigatie: sleutel in Kubernetes Secret, niet in code-repo. Sleutel-rotatie viaencKeyVersionis beschikbaar maar nog niet getest in productie. - Grace-window misbruik: een gestolen, recent-verlopen JWT kan binnen 60
dagen worden ingewisseld voor een vers JWT — mits het
OutlookExactToken-doc nog geldig is. Mitigatie: revoke-via-admin ($set revokedAt) verbreekt dit onmiddellijk. - Cron-collision: refresh-cron en on-demand refresh kunnen elkaar
tegenkomen op één doc. Mitigatie:
lastRefreshedAtoptimistic-lock opupdateAfterRefresh; één wint, de ander krijgtstaleen gebruikt het verse doc.
Alternatieven
Tokens client-side in localStorage: de OAuth-tokens (access + refresh)
direct in de browser opslaan en de plugin praat rechtstreeks met Exact. Geen
server-side state nodig. Afgewezen omdat refresh_tokens in browser-storage
een aantrekkelijk doel zijn (lange levensduur, directe Exact-API-toegang) en
er geen centraal revoke-mechanisme zou bestaan.
Cookies met HttpOnly; Secure; SameSite=None: tokens in een cookie
geserveerd vanaf de Tapster-API-origin. Office-WebView en cross-origin
cookies werken alleen met een specifieke set headers en zijn fragiel tussen
OWA en Outlook desktop. Plus: cookies werken slecht voor de Office-dialog-
flow (postMessage) en zijn niet inspecteerbaar vanuit de plugin-JS, wat
client-side error-handling moeilijker maakt.
Plain-text tokens in MongoDB: een variant op de gekozen oplossing maar
zonder encryptie. Afgewezen omdat een DB-backup-lek dan direct Exact-API-
toegang oplevert. Encryptie-at-rest is met tokenCrypto triviaal.
Auth0 / Cognito als token-broker: een externe identity-provider tussen Tapster en Exact. Afgewezen vanwege extra dependency, kosten, en omdat Exact’s OAuth2-flow al goed werkt met onze server als client; we hebben geen federation-eisen die een broker rechtvaardigen.
Implementatie-snippets
- Vault:
apps/api/src/api/outlook-plugin/outlookExactTokenModel.ts+outlookExactTokenRepository.ts - JWT:
apps/api/src/api/outlook-plugin/pluginSessionJwt.ts - Middleware:
apps/api/src/api/outlook-plugin/pluginSessionMiddleware.ts - Client-side flow:
apps/api/public/assets/js/outlook-plugin-auth.js - Encryptie:
apps/api/src/common/utils/tokenCrypto.ts
Vervolgkeuzes (niet in deze ADR)
- Sleutel-rotatie naar
encKeyVersion: 2in productie (afhankelijk van rotatie-procedure). - Hard-delete-policy op gerevokede docs (huidig: 90 dagen, mogelijk lager).
Gerelateerd
- Explanation: Outlook-plugin — volledig architectuur-overzicht inclusief refresh-cron en cleanup-cron.