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; localStorage is 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 localStorage lezen.

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 met EXACT_REAUTH_REQUIRED.
  • Encryptie-sleutel-rotatie is mogelijk via encKeyVersion op 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_KEY lekt, zijn alle tokens decryptbaar. Mitigatie: sleutel in Kubernetes Secret, niet in code-repo. Sleutel-rotatie via encKeyVersion is 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: lastRefreshedAt optimistic-lock op updateAfterRefresh; één wint, de ander krijgt stale en 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: 2 in productie (afhankelijk van rotatie-procedure).
  • Hard-delete-policy op gerevokede docs (huidig: 90 dagen, mogelijk lager).

Gerelateerd