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:

  1. Client (Outlook-WebView): pure HTML + JavaScript. Bewaart geen Exact-tokens, alleen een plugin-JWT in localStorage (key tapsterOutlookSession). Roept Tapster-endpoints aan via fetchWithAuth die het JWT in de Authorization-header zet.

  2. Tapster API: server-side OAuth-callback, JWT-issuer, en pluginSessionMiddleware die voor elk plugin-API-request het JWT verifieert en het bijhorende Exact access_token uit de vault opvist en op req.pluginSession.accessToken hangt.

  3. 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 revokedAt op één doc; alle plugin-sessies van die Exact-user falen onmiddellijk met EXACT_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)

  1. Plugin opent in Outlook, localStorage is leeg.
  2. Gebruiker klikt op “Koppel Exact Online” (auth-btn).
  3. 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.
  4. Office.context.ui.displayDialogAsync() opent een dialog met Exact’s /oauth2/auth?force_login=1&...-URL.
  5. Gebruiker logt in bij Exact, accepteert scope, wordt terug-geredirect naar /exact/callback?code=...&state=....
  6. Server wisselt de code in voor access + refresh + expiresIn via /oauth2/token.
  7. Server haalt UserID op via /api/v1/current/Me, en upsert een OutlookExactToken-doc (encrypted) met de combinatie exactUserId, tenantUuid, outlookEmail.
  8. Server genereert een plugin-JWT (HS256, 30 dagen geldig) en stuurt die als postMessage naar de plugin via een minimale HTML-response die messageParent aanroept.
  9. 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):

  1. Lees JWT uit localStorage.
  2. Als JWT binnen 7 dagen verloopt: trigger silent slide-refresh (zie hieronder).
  3. Zet Authorization: Bearer <jwt> op het request.
  4. Als response 401 is met body-code EXACT_REAUTH_REQUIRED of PLUGIN_SESSION_INVALID: wis sessie + toon login.
  5. 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:

  • ensureFreshSession checkt shouldSlideRefresh(expiresAt, now).
  • Triggert POST /exact/outlook/session/refresh met het oude JWT in de Authorization-header.
  • Server verifieert het oude JWT (mag tot 60 dagen na expiry, zie grace hieronder), checkt dat OutlookExactToken niet 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). verifyPluginJwtWithRecoveryGrace accepteert dat zodat POST /exact/outlook/session/refresh alsnog 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/recover met het Outlook-emailadres dat in de plugin-context bekend is. Server zoekt op outlookEmail (case-insensitive) in OutlookExactToken en — 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:

  1. POST /exact/outlook/session/revoke met het JWT — server zet revokedAt op het doc.
  2. POST /koppel/disconnect met het emailadres — Microsoft Marketplace-vereiste, verwijdert de plugin-licentie-activatie.
  3. Wist localStorage.tapsterOutlookSession en 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 op lastRefreshedAt).
  • 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 van REVOKE_THRESHOLD (30) auto-revoke.

Background refresh

BullMQ-cron refresh-outlook-exact-tokens draait elke minuut. Werking:

  1. findExpiringSoon(cutoff = now + 30s) — alle docs die binnen 30s verlopen en niet revoked zijn.
  2. Voor elk: refreshOutlookToken(doc) met dezelfde semantiek als on-demand.
  3. Summary-log outlook.exact.refresh.batch met 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 — zet revokedAt op docs die 60+ dagen niet zijn gebruikt (lastUsedAt).
  • deleteOldRevoked — hard-delete van docs met revokedAt < 90 dagen geleden.

Beveiligingsmodel

  • Tokens encrypted at-rest (tokenCrypto, AES-256-GCM). De encryptie-sleutel staat in OUTLOOK_TOKEN_ENC_KEY env-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). Inline onclick-handlers zijn vervangen door addEventListener-bindings.
  • postMessage origin: bij de OAuth-callback wordt postMessage gepostmessaged naar de API-origin (geen wildcard).
  • Rate-limits op alle public endpoints (/exact/outlook/session/*) via publicRateLimit().
  • Test-mode is alleen actief op localhost, *.dev.* of *.ngrok hostnames — 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

Gerelateerd