Files
Linus Rath 204cd09951 fix: end office editor sessions when the user signs out
A WOPI token carries the user's credentials to the editor server and
stayed valid for its whole six-hour lifetime, so an editor opened
before signing out could still read and save the document afterwards.

Each token now records the browser (a random id in an HttpOnly cookie,
hashed) and account slot that opened it. Signing out records a
revocation time for that browser and slot, or for every slot on a full
sign-out, and WOPI calls with a token minted before it are refused.
Signing out on another device leaves this browser's editors alone.
Revocations live in the state directory, so they survive a restart and
reach replicas sharing the state volume, and are dropped once the
tokens they cover have expired.
2026-09-26 16:40:46 +02:00

38 lines
1.6 KiB
TypeScript

import type { NextRequest } from 'next/server';
import { isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import type { WopiJmapContext } from '@/lib/wopi/files';
import { verifyWopiToken, type WopiTokenPayload } from '@/lib/wopi/token';
import { isWopiTokenRevoked } from '@/lib/wopi/revocation';
/**
* Authenticate an incoming WOPI request (called by the editor
* server-to-server, no session cookie) from its `access_token` query
* parameter, scoped to the document id in the URL (see wopiDocumentId).
*/
export async function wopiContext(
request: NextRequest,
documentId: string,
): Promise<{ payload: WopiTokenPayload; ctx: WopiJmapContext } | null> {
const payload = verifyWopiToken(request.nextUrl.searchParams.get('access_token'), documentId);
if (!payload) return null;
// Signed out since the editor was opened.
if (await isWopiTokenRevoked(payload)) return null;
// Trust is re-derived from the current config, not persisted in the token
// (mirrors lib/stalwart/server-fetch.ts): removing a server from the config
// immediately drops its tokens to the rebinding-safe fetch path. A server on
// the webmail's own origin (the dev mock) is this process itself - the
// public-URL guard would refuse localhost, so it counts as trusted.
const sameOrigin = (() => {
try {
return new URL(payload.serverUrl).origin === payload.origin;
} catch {
return false;
}
})();
const trusted = sameOrigin || await isTrustedJmapServerUrl(payload.serverUrl);
return {
payload,
ctx: { serverUrl: payload.serverUrl, authHeader: payload.authHeader, trusted },
};
}