Outlook-plugin
Dit document legt uit hoe de Tapster Outlook-plugin (Koppel) werkt: de OAuth-koppeling met Exact Online, hoe sessies worden beheerd, hoe tokens worden gerefresht en hoe de plugin zich herstelt na inactiviteit. Voor de UI-tabs (Contact, Tijdschrijven, Archief, CRM) zie de inline README op apps/api/src/api/outlook-plugin/README.md. Voor het concrete debuggen van een refresh-probleem, zie de how-to debug token-refresh.
Wat de plugin doet
De plugin is een Outlook-add-in (Microsoft 365) die naast een open e-mail of afspraak een taakvenster opent. Vanuit dat taakvenster kan de gebruiker contactgegevens in Exact Online opzoeken, uren registreren, e-mails archiveren als documenten en verkoopactiviteiten loggen. De plugin draait volledig client-side in de Outlook-WebView; de Tapster API levert HTML, JS en de Exact-koppeling.
Elke gebruiker heeft zijn eigen Exact Online-koppeling. Bij eerste gebruik logt de gebruiker in via OAuth bij Exact (force-login pagina); daarna onthoudt de server de tokens zodat de plugin in volgende sessies meteen werkt.
Architectuur in één oogopslag
Outlook-WebView Tapster API Exact Online
───────────────── ───────────── ─────────────
outlook-plugin.html
outlook-plugin.js ──── HTTP ────► /exact/outlook/auth-url ──────► /oauth2/auth
outlook-plugin- (force-login)
session.js │
outlook-plugin- ▼
auth.js ◄── postMessage /exact/callback ◄── redirect ─── (code)
│
▼
OutlookExactToken
(encrypted at-rest)
│
────fetchWithAuth──► /outlook-plugin/* ───────► /api/v1/...
(Authorization: (plugin-session │
Bearer <jwt>) middleware resolves │
per-user access_token) ▼
Contacts,
Accounts,
Documents,
TimeTransactions
Drie lagen:
-
Client (Outlook-WebView): pure HTML + JavaScript. Bewaart geen Exact-tokens, alleen een plugin-JWT in
localStorage(keytapsterOutlookSession). Roept Tapster-endpoints aan viafetchWithAuthdie het JWT in de Authorization-header zet. -
Tapster API: server-side OAuth-callback, JWT-issuer, en
pluginSessionMiddlewaredie voor elk plugin-API-request het JWT verifieert en het bijhorende Exact access_token uit de vault opvist en opreq.pluginSession.accessTokenhangt. -
Exact Online: standaard OAuth2 + API. De Tapster-server is de OAuth-client; eindgebruikers loggen direct bij Exact in.
De plugin-JWT vault
Het hart van het ontwerp is een server-side token vault: Exact-tokens worden nooit naar de client gestuurd. De client krijgt alleen een Tapster-plugin-JWT (een gewone JWT met exactUserId + tenantUuid in de payload). De server houdt de plain tokens versleuteld in OutlookExactToken (AES-GCM via tokenCrypto).
Drie redenen:
- Geen tokens in browser: localStorage is leesbaar door extensies, geheugen-dumps en XSS (in theorie via een misconfigured CSP). Exact-tokens daar zetten zou een Outlook-add-in tot een token-uitstellaar maken.
- Per-user-token-resolutie: één tenant kan meerdere Tapster-gebruikers hebben, elk met hun eigen Exact-account. De JWT identificeert welke gebruiker, server resolvet daarop het juiste token.
- Centraal kunnen revoken: een admin (of background-job) zet
revokedAtop één doc; alle plugin-sessies van die Exact-user falen onmiddellijk metEXACT_REAUTH_REQUIRED.
Zie ADR 0003 — Plugin-JWT vault voor Outlook-tokens voor het volledige afwegings-verhaal.
Sessie-levenscyclus
Een plugin-sessie heeft een aantal natuurlijke fases. De client-module
outlook-plugin-auth.js regelt de overgangen.
1. Eerste koppeling (OAuth)
- Plugin opent in Outlook,
localStorageis leeg. - Gebruiker klikt op “Koppel Exact Online” (auth-btn).
- Plugin doet
GET /exact/outlook/auth-url?email=<outlook-email>om de Exact-OAuth-URL op te halen. State-parameter:<tenantUuid>:outlook[:base64url(email)]. Email wordt meegegeven zodat de server bij callback weet welk mailbox-adres aan dit token hangt. Office.context.ui.displayDialogAsync()opent een dialog met Exact’s/oauth2/auth?force_login=1&...-URL.- Gebruiker logt in bij Exact, accepteert scope, wordt terug-geredirect naar
/exact/callback?code=...&state=.... - Server wisselt de code in voor access + refresh + expiresIn via
/oauth2/token. - Server haalt
UserIDop via/api/v1/current/Me, en upsert eenOutlookExactToken-doc (encrypted) met de combinatieexactUserId,tenantUuid,outlookEmail. - Server genereert een plugin-JWT (HS256, 30 dagen geldig) en stuurt die als
postMessagenaar de plugin via een minimale HTML-response diemessageParentaanroept. - Plugin ontvangt het JWT, slaat het op in
localStorage, en is klaar voor gebruik.
2. Authenticated gebruik
Elke API-call vanuit de plugin gaat door fetchWithAuth (in outlook-plugin-auth.js):
- Lees JWT uit
localStorage. - Als JWT binnen 7 dagen verloopt: trigger silent slide-refresh (zie hieronder).
- Zet
Authorization: Bearer <jwt>op het request. - Als response 401 is met body-code
EXACT_REAUTH_REQUIREDofPLUGIN_SESSION_INVALID: wis sessie + toon login. - Anders return de response normaal.
Op de server vangt requirePluginSession middleware het request op, verifieert het JWT, vist het access_token uit de vault (refresht zo nodig), en hangt het tuple op req.pluginSession. Vanaf daar werken de plugin-endpoints zoals elk ander Exact-API-endpoint.
3. Slide-refresh van het plugin-JWT
Een JWT van 30 dagen zou zich aanvoelen als een logout-elke-maand. Daarom vernieuwt de plugin het JWT automatisch zodra de huidige nog binnen 7 dagen verloopt (JWT_SLIDE_THRESHOLD_MS). Mechanisme:
ensureFreshSessionchecktshouldSlideRefresh(expiresAt, now).- Triggert
POST /exact/outlook/session/refreshmet het oude JWT in de Authorization-header. - Server verifieert het oude JWT (mag tot 60 dagen na expiry, zie grace hieronder), checkt dat
OutlookExactTokenniet revoked is en de transient-failure-counter onder de drempel zit, en geeft een nieuw JWT terug. - Twee parallelle calls binnen de slide-window delen één refresh-request via een in-flight lock (
pendingSlideRefresh).
Met deze sliding-window logica blijft een actieve gebruiker effectief permanent ingelogd — de cadans van het herinneren-aan-inloggen-bij-Exact wordt bepaald door inactiviteit, niet door JWT-leeftijd.
4. Server-side session-recovery
Twee scenario’s waarin de client zijn JWT kwijt is maar de server-side koppeling nog werkt:
- Verlopen JWT binnen grace-window: JWT is verlopen maar nog geen 60 dagen oud (
JWT_RECOVERY_GRACE_MS).verifyPluginJwtWithRecoveryGraceaccepteert dat zodatPOST /exact/outlook/session/refreshalsnog werkt zolang server-side het token niet revoked is. Veel comfortabele “ik kom na de vakantie terug”-cases. - Lege localStorage: andere browser, OWA-profiel, gewiste site-data. Plugin probeert
POST /exact/outlook/session/recovermet het Outlook-emailadres dat in de plugin-context bekend is. Server zoekt opoutlookEmail(case-insensitive) inOutlookExactTokenen — als er een geldig niet-revoked doc bestaat — geeft een vers JWT terug.
Beide flow zitten in tryServerSideSessionRecovery en draaien tijdens init() voordat de “Koppel Exact Online”-knop verschijnt. Lukt geen van beide, dan toont de plugin de OAuth-flow alsof het de eerste koppeling is.
5. Logout / revoke
logout() doet drie dingen:
POST /exact/outlook/session/revokemet het JWT — server zetrevokedAtop het doc.POST /koppel/disconnectmet het emailadres — Microsoft Marketplace-vereiste, verwijdert de plugin-licentie-activatie.- Wist
localStorage.tapsterOutlookSessionen reset de UI naar de activatie-/login-staat.
Beide netwerk-calls zijn idempotent en niet-fataal; lokaal wissen gebeurt sowieso.
Exact token refresh
Exact-access_tokens leven 10 minuten. De plugin-flow refreshet ze op twee manieren:
On-demand refresh
exactService.getAccessTokenForOutlook(exactUserId) (gebruikt door pluginSessionMiddleware) checkt of het token nog minimaal 60 seconden geldig is. Zo niet, wordt refreshOutlookToken gestart. Resultaat:
- success: nieuw access + refresh-token opgeslagen via
updateAfterRefresh(optimistic-lock oplastRefreshedAt). - race-loss (stale): andere refresher heeft net gewonnen, lees fresh doc en gebruik diens access_token.
- rate_limited: Exact zegt “access_token nog niet verlopen” (400 access_denied “not expired”). We geven het bestaande access_token terug.
- invalid_grant: refresh_token permanent kapot. Doc wordt direct revoked, gebruiker krijgt EXACT_REAUTH_REQUIRED.
- transient (5xx):
refreshFailureCount+1; bij hit vanREVOKE_THRESHOLD(30) auto-revoke.
Background refresh
BullMQ-cron refresh-outlook-exact-tokens draait elke minuut. Werking:
findExpiringSoon(cutoff = now + 30s)— alle docs die binnen 30s verlopen en niet revoked zijn.- Voor elk:
refreshOutlookToken(doc)met dezelfde semantiek als on-demand. - Summary-log
outlook.exact.refresh.batchmet counts per kind.
De cron en on-demand kunnen elkaar tegenkomen (cron pakt iets op terwijl on-demand al begonnen is). De lastRefreshedAt optimistic-lock en de race-detection in refreshOutlookToken garanderen dat één wint en de ander stale teruggeeft zonder schade.
Cleanup
cleanup-outlook-exact-tokens cron draait dagelijks om 03:30:
revokeStale— zetrevokedAtop docs die 60+ dagen niet zijn gebruikt (lastUsedAt).deleteOldRevoked— hard-delete van docs metrevokedAt < 90 dagen geleden.
Beveiligingsmodel
- Tokens encrypted at-rest (
tokenCrypto, AES-256-GCM). De encryptie-sleutel staat inOUTLOOK_TOKEN_ENC_KEYenv-var. - JWT-signing met
OUTLOOK_PLUGIN_JWT_SECRET(HS256, 32 bytes). - CSP: de plugin-HTML heeft een strikte CSP zonder
unsafe-inline. Alle JS staat in externe files; de i18n-bundle wordt geleverd als<script type="application/json">-block dat de plugin parset (geen executable script). Inlineonclick-handlers zijn vervangen dooraddEventListener-bindings. - postMessage origin: bij de OAuth-callback wordt
postMessagegepostmessaged naar de API-origin (geen wildcard). - Rate-limits op alle public endpoints (
/exact/outlook/session/*) viapublicRateLimit(). - Test-mode is alleen actief op
localhost,*.dev.*of*.ngrokhostnames — productie-builds weigeren test-mode URL-params.
Wanneer raak je dit aan?
De Outlook-plugin zit op meerdere lagen tegelijk; raak ze niet alle in één PR.
| Vraag | Welke laag |
|---|---|
| Bug in de UI (welke tab, welk veld) | apps/api/public/assets/js/outlook-plugin.js + view in apps/api/views/public/outlook-plugin.hbs |
| Endpoint of payload-validatie aanpassen | apps/api/src/api/outlook-plugin/outlookPluginController.ts |
| Token-refresh of revoke-gedrag | apps/api/src/common/services/exact-service/exactService.ts (refreshOutlookToken, getAccessTokenForOutlook) |
| JWT-lifetime of recovery-grace | apps/api/src/api/outlook-plugin/pluginSessionJwt.ts |
| Token-opslag (encryptie, indexen) | apps/api/src/api/outlook-plugin/outlookExactTokenRepository.ts + outlookExactTokenModel.ts |
| Background-cron (refresh of cleanup) | apps/api/src/common/services/bullmq-service/processors/refresh-outlook-exact-tokens.ts resp. cleanup-outlook-exact-tokens.ts |
| Client-side auth-flow (refresh, recovery, fetchWithAuth) | apps/api/public/assets/js/outlook-plugin-auth.js |
| Test-mode-gedrag buiten Outlook | apps/api/public/assets/js/outlook-plugin.js (zoek op urlParams.get('test_email')) — zie ook how-to test buiten Outlook |