mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
import { createHash, randomBytes } from 'node:crypto';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import type { cookies } from 'next/headers';
|
|
import { getStateDir, getStatePath } from '@/lib/admin/paths';
|
|
import { logger } from '@/lib/logger';
|
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|
import { getStore, isSharedMode, type CollectionOptions } from '@/lib/shared-store';
|
|
import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token';
|
|
|
|
/**
|
|
* Signing out ends the office editor sessions opened from that browser.
|
|
*
|
|
* A WOPI token is handed to the editor server and carries the user's
|
|
* credentials, so it used to stay usable for its whole six-hour lifetime
|
|
* after the user signed out. Each token now records which browser minted it
|
|
* (a random id in an HttpOnly cookie, hashed) and for which account slot;
|
|
* signing out records a revocation time for that browser and slot, and any
|
|
* token minted before it is refused. Signing out on another device does not
|
|
* touch this browser's editors.
|
|
*
|
|
* Revocations are kept in the state directory so they survive a restart and
|
|
* reach every replica sharing the state volume; each one is dropped once
|
|
* every token it could refuse has expired anyway. With a shared store
|
|
* (#1066) they are one document there, merged on every write, and read on
|
|
* every check; a check that can't reach the store refuses the token.
|
|
*/
|
|
|
|
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
|
|
|
export const WOPI_BROWSER_COOKIE = 'bulwark_wopi';
|
|
const REVOCATIONS_FILE = 'wopi-revocations.json';
|
|
export const WOPI_COLLECTION = 'wopi';
|
|
export const REVOCATIONS_ID = 'revocations.json';
|
|
|
|
/** The revocations document; locally today's file in the state dir. */
|
|
export const wopiRevocationsLayout: CollectionOptions = {
|
|
local: {
|
|
dir: () => getStateDir(),
|
|
file: () => REVOCATIONS_FILE,
|
|
idOf: (file) => (file === REVOCATIONS_FILE ? REVOCATIONS_ID : null),
|
|
tmpName: (target) => `${target}.${process.pid}.tmp`,
|
|
},
|
|
};
|
|
|
|
function wopiRevocationsCollection() {
|
|
return getStore().collection(WOPI_COLLECTION, wopiRevocationsLayout);
|
|
}
|
|
|
|
function browserBinding(browserId: string): string {
|
|
return createHash('sha256').update(browserId).digest('base64url').slice(0, 22);
|
|
}
|
|
|
|
/** This browser's binding for new tokens, creating its cookie on first use. */
|
|
export function wopiBrowserBinding(cookieStore: CookieStore): string {
|
|
let id = cookieStore.get(WOPI_BROWSER_COOKIE)?.value;
|
|
if (!id) {
|
|
id = randomBytes(16).toString('base64url');
|
|
cookieStore.set(WOPI_BROWSER_COOKIE, id, getCookieOptions());
|
|
}
|
|
return browserBinding(id);
|
|
}
|
|
|
|
interface RevocationState {
|
|
revocations: Map<string, number>;
|
|
loadedMtimeMs: number | null;
|
|
}
|
|
|
|
// One copy per process, not one per bundle.
|
|
const STATE_KEY = Symbol.for('bulwark.wopiRevocations');
|
|
|
|
function state(): RevocationState {
|
|
const g = globalThis as typeof globalThis & { [STATE_KEY]?: RevocationState };
|
|
g[STATE_KEY] ??= { revocations: new Map(), loadedMtimeMs: null };
|
|
return g[STATE_KEY];
|
|
}
|
|
|
|
/** Merge revocations read from disk or the store into memory, keeping the later time. */
|
|
function merge(text: string): void {
|
|
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
const s = state();
|
|
const next = new Map<string, number>();
|
|
for (const [key, at] of Object.entries(parsed)) {
|
|
if (typeof at === 'number') next.set(key, Math.max(at, s.revocations.get(key) ?? 0));
|
|
}
|
|
for (const [key, at] of s.revocations) if (!next.has(key)) next.set(key, at);
|
|
s.revocations = next;
|
|
}
|
|
|
|
/** One process: re-read the file when it changed. */
|
|
async function reloadLocal(): Promise<void> {
|
|
const path = getStatePath(REVOCATIONS_FILE);
|
|
let mtimeMs: number;
|
|
try {
|
|
mtimeMs = (await stat(path)).mtimeMs;
|
|
} catch {
|
|
return; // Nothing revoked yet (or unreadable): keep what memory has.
|
|
}
|
|
if (mtimeMs === state().loadedMtimeMs) return;
|
|
try {
|
|
merge(await readFile(path, 'utf-8'));
|
|
state().loadedMtimeMs = mtimeMs;
|
|
} catch (error) {
|
|
logger.warn('Could not read WOPI revocations', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Shared store: read the document; throws when the store is down. */
|
|
async function reloadShared(): Promise<void> {
|
|
const doc = await wopiRevocationsCollection().get(REVOCATIONS_ID);
|
|
if (!doc) return;
|
|
try {
|
|
merge(doc.data.toString('utf-8'));
|
|
} catch (error) {
|
|
logger.warn('Could not read WOPI revocations', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
function prune(revocations: Map<string, number>, nowMs: number): void {
|
|
for (const [key, at] of revocations) {
|
|
if (nowMs - at > WOPI_TOKEN_TTL_MS) revocations.delete(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refuse every token this browser minted for `slot` (or for every slot)
|
|
* until now. Call before the browser's cookies are cleared.
|
|
*/
|
|
export async function revokeWopiTokens(
|
|
cookieStore: CookieStore,
|
|
slot: number | 'all',
|
|
nowMs: number = Date.now(),
|
|
): Promise<void> {
|
|
const id = cookieStore.get(WOPI_BROWSER_COOKIE)?.value;
|
|
if (!id) return; // This browser never opened an editor.
|
|
if (slot === 'all') cookieStore.delete(WOPI_BROWSER_COOKIE);
|
|
|
|
const key = `${browserBinding(id)}:${slot}`;
|
|
const shared = isSharedMode();
|
|
if (!shared) await reloadLocal();
|
|
const s = state();
|
|
s.revocations.set(key, nowMs);
|
|
prune(s.revocations, nowMs);
|
|
|
|
try {
|
|
// Merged with what is stored now, so a revocation written meanwhile
|
|
// (another tab, another replica) is kept.
|
|
const written = await wopiRevocationsCollection().update(REVOCATIONS_ID, (current) => {
|
|
const merged = new Map(s.revocations);
|
|
if (current) {
|
|
try {
|
|
for (const [k, at] of Object.entries(JSON.parse(current.toString('utf-8')) as Record<string, unknown>)) {
|
|
if (typeof at === 'number') merged.set(k, Math.max(at, merged.get(k) ?? 0));
|
|
}
|
|
} catch {
|
|
// Unreadable: replaced by what this process knows.
|
|
}
|
|
}
|
|
prune(merged, nowMs);
|
|
return JSON.stringify(Object.fromEntries(merged));
|
|
});
|
|
if (written.data) merge(written.data.toString('utf-8'));
|
|
if (!shared) s.loadedMtimeMs = (await stat(getStatePath(REVOCATIONS_FILE))).mtimeMs;
|
|
} catch (error) {
|
|
// Still refused by this process until it restarts.
|
|
logger.warn('Could not persist WOPI revocation', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Whether signing out has revoked this token since it was minted. */
|
|
export async function isWopiTokenRevoked(payload: WopiTokenPayload): Promise<boolean> {
|
|
// Tokens minted before tokens were bound to a browser simply expire.
|
|
if (!payload.bid || typeof payload.slot !== 'number') return false;
|
|
if (isSharedMode()) {
|
|
try {
|
|
await reloadShared();
|
|
} catch (error) {
|
|
// A sign-out on another replica can't be ruled out: refused.
|
|
logger.warn('WOPI token refused: revocations unavailable', {
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
return true;
|
|
}
|
|
} else {
|
|
await reloadLocal();
|
|
}
|
|
const { revocations } = state();
|
|
const mintedAt = payload.exp - WOPI_TOKEN_TTL_MS;
|
|
const revokedAt = Math.max(
|
|
revocations.get(`${payload.bid}:all`) ?? -Infinity,
|
|
revocations.get(`${payload.bid}:${payload.slot}`) ?? -Infinity,
|
|
);
|
|
return revokedAt >= mintedAt;
|
|
}
|