From f53ae184ea965ccbbc3ef354357017084dd427f3 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 15 Sep 2026 11:48:36 +0200 Subject: [PATCH] fix: Kalender-Verbindungen ueber user-include liefern verschluesseltes Token Die accessToken/refreshToken-Entschluesselung (Prisma-Extension in lib/prisma.ts) faengt nur direkte calendarConnection-Queries ab, keine ueber user.findUnique({include}) geladenen Relationen. Outlook-Tokens werden bei jedem Refresh neu geschrieben (also verschluesselt) und wurden dadurch als Chiffretext an Microsoft Graph geschickt -> 401 "JWT is not well formed". Apple/Synology blieben unbemerkt, weil ihre statischen App-Passwoerter nie neu geschrieben und damit nie verschluesselt wurden. Betroffen: calendar/events, calendar/sync, calendar/background-sync, calendar/connections. Alle vier laden CalendarConnection jetzt direkt statt ueber user-include. v1.129.3 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 27 +++++++++++++++++++ package.json | 2 +- src/app/api/calendar/background-sync/route.ts | 13 ++++++--- src/app/api/calendar/connections/route.ts | 8 ++++-- src/app/api/calendar/events/route.ts | 8 ++++-- src/app/api/calendar/sync/route.ts | 8 ++++-- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fda5e4..8b235df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project are documented in this file. +## [1.129.3] - 2026-09-15 + +### Fixed +- **"Failed to update event" bei Outlook-Terminen** ("IDX14100: JWT is not well formed" + in den Server-Logs). Die Verschlüsselung von `accessToken`/`refreshToken` in + `CalendarConnection` (siehe [1.x] Security-Härtung) läuft als Prisma-Client-Extension + ausschließlich auf direkten `calendarConnection`-Queries — Next.js/Prisma-Extensions + fangen keine verschachtelten `include`-Relationen ab, die über ein anderes Modell + geladen werden. Vier Routen luden Verbindungen aber genau so: + `prisma.user.findUnique({ include: { calendarConnections: true } })`. Für Apple/Synology + (statisches App-Passwort, nie neu geschrieben) blieb das unbemerkt, weil deren Zeilen nie + verschlüsselt wurden. Outlook-Tokens werden dagegen bei jedem Refresh neu geschrieben und + damit verschlüsselt — ab dann schickte die App den Chiffretext statt des echten Tokens an + Microsoft Graph, die Anfrage lief mit 401 auf. + - Betroffen und korrigiert: `api/calendar/events/route.ts` (Termin erstellen/ändern/ + löschen — der hier gemeldete Fehler), `api/calendar/sync/route.ts` (normale Anzeige- + Synchronisation), `api/calendar/background-sync/route.ts` (Hintergrund-Sync), + `api/calendar/connections/route.ts` (Verbindungsliste, inkl. Apple/Synology- + Farbaktualisierung im Hintergrund). Alle vier laden `CalendarConnection` jetzt per + direktem `calendarConnection.findMany`/`findFirst` statt über `user`-`include`. + +Verifiziert nicht nur mit Typecheck/Build: Wegwerf-Postgres (Docker) aufgesetzt, eine +`CalendarConnection` mit echtem Prisma-Client (inkl. Verschlüsselungs-Extension) +angelegt — Rohwert in der DB trägt das `enc1:`-Präfix. Der alte `include`-Pfad lieferte +exakt den Chiffretext zurück (Reproduktion des Produktionsfehlers), der neue direkte +Query-Pfad das korrekte Klartext-Token. + ## [1.129.2] - 2026-09-14 ### Fixed diff --git a/package.json b/package.json index 0f8a4e2..b1ceca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.129.2", + "version": "1.129.3", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/calendar/background-sync/route.ts b/src/app/api/calendar/background-sync/route.ts index ee3e5e2..c12cd3f 100644 --- a/src/app/api/calendar/background-sync/route.ts +++ b/src/app/api/calendar/background-sync/route.ts @@ -16,10 +16,17 @@ export async function POST(request: NextRequest) { const user = await prisma.user.findUnique({ where: { email: session.user.email }, - include: { calendarConnections: true }, + select: { id: true }, }); - if (!user || user.calendarConnections.length === 0) { + if (!user) { + return NextResponse.json({ queued: 0 }); + } + + // Queried directly (not via user's `include`) so the accessToken/refreshToken + // decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts. + const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } }); + if (connections.length === 0) { return NextResponse.json({ queued: 0 }); } @@ -28,7 +35,7 @@ export async function POST(request: NextRequest) { const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const staleChecks = await Promise.all( - user.calendarConnections.map(async conn => ({ + connections.map(async conn => ({ conn, stale: forceRefresh || await isCacheStale(conn.id, tMin), })) diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index 2c71f70..b4f3df8 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { // Find user by email const user = await prisma.user.findUnique({ where: { email: session.user.email }, - include: { calendarConnections: true } + select: { id: true } }); if (!user) { @@ -31,9 +31,13 @@ export async function GET(request: NextRequest) { ); } + // Queried directly (not via user's `include`) so the accessToken/refreshToken + // decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts. + const rawConnections = await prisma.calendarConnection.findMany({ where: { userId: user.id } }); + // Return calendar connections (without sensitive tokens) // Prune deleted calendars from providers that support live listing - const connections = user.calendarConnections.map((conn) => { + const connections = rawConnections.map((conn) => { let calendars = conn.calendars as any[] | null; // For Apple connections, filter out VTODO/Reminders collections diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index 8272697..811ba8e 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -10,12 +10,16 @@ import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache'; async function findConnectionForCalendar(email: string, calendarId: string) { const user = await prisma.user.findUnique({ where: { email }, - include: { calendarConnections: true } + select: { id: true } }); if (!user) return null; - for (const conn of user.calendarConnections) { + // Queried directly (not via user's `include`) so the accessToken/refreshToken + // decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts. + const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } }); + + for (const conn of connections) { if (conn.calendars && Array.isArray(conn.calendars)) { const calendars = conn.calendars as any[]; if (calendars.some(c => c.id === calendarId)) { diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts index a722bec..c4c5895 100644 --- a/src/app/api/calendar/sync/route.ts +++ b/src/app/api/calendar/sync/route.ts @@ -23,14 +23,18 @@ export async function POST(request: NextRequest) { // Look up by email — session ID can be stale after DB restore/migration const user = await prisma.user.findUnique({ where: { email: session.user.email }, - include: { calendarConnections: true }, + select: { id: true }, }); if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - let connections = user.calendarConnections; + // calendarConnection must be queried directly (not via user's `include`) so the + // accessToken/refreshToken decryption hook in lib/prisma.ts actually runs — Prisma + // client extensions only intercept top-level queries on the model, not nested + // relation loads reached through another model's `include`. + let connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } }); if (connectionId) { connections = connections.filter(c => c.id === connectionId); }