diff --git a/lib/__tests__/shared-store-import-auth.test.ts b/lib/__tests__/shared-store-import-auth.test.ts new file mode 100644 index 000000000..e8f6a7a9a --- /dev/null +++ b/lib/__tests__/shared-store-import-auth.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { steps } from '@/lib/shared-store/import/auth'; +import { limitsEnforcedSince, resetLimitsEnforcedSince } from '@/lib/auth/session-age'; +import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token'; +import { isWopiTokenRevoked } from '@/lib/wopi/revocation'; +import { switchableSharedStore } from './helpers/shared-store-down'; + +// Moving a single instance to the shared store (#1066) must carry over the +// auth state that outlives a restart, and never touch the local files. + +let stateDir: string; +let sharedDir: string; + +const run = async () => { + const counts: Record = {}; + for (const step of steps) counts[step.name] = await step.run(); + return counts; +}; + +beforeEach(() => { + stateDir = mkdtempSync(path.join(tmpdir(), 'bw-import-auth-')); + sharedDir = mkdtempSync(path.join(tmpdir(), 'bw-import-store-')); + vi.stubEnv('ADMIN_STATE_DIR', stateDir); + resetLocalStoreForTests(); + resetLimitsEnforcedSince(); + delete (globalThis as Record)[Symbol.for('bulwark.wopiRevocations')]; + setStoreForTests(switchableSharedStore({ sharedDir })); +}); + +afterEach(() => { + setStoreForTests(null); + resetLocalStoreForTests(); + resetLimitsEnforcedSince(); + vi.unstubAllEnvs(); + rmSync(stateDir, { recursive: true, force: true }); + rmSync(sharedDir, { recursive: true, force: true }); +}); + +describe('import steps of the auth stores', () => { + it('copies session-limits-since and the WOPI revocations once, leaving the local files alone', async () => { + const revokedAt = Date.now() - 60_000; + writeFileSync(path.join(stateDir, 'session-limits-since'), '1700000000000'); + writeFileSync(path.join(stateDir, 'wopi-revocations.json'), JSON.stringify({ 'browser-a:0': revokedAt })); + const before = [ + readFileSync(path.join(stateDir, 'session-limits-since')), + readFileSync(path.join(stateDir, 'wopi-revocations.json')), + ]; + + expect(await run()).toEqual({ 'session-limits-since': 1, 'wopi-revocations': 1 }); + expect(await run()).toEqual({ 'session-limits-since': 0, 'wopi-revocations': 0 }); + + expect(readFileSync(path.join(stateDir, 'session-limits-since'))).toEqual(before[0]); + expect(readFileSync(path.join(stateDir, 'wopi-revocations.json'))).toEqual(before[1]); + + // What the replicas now read comes from the store, not the old volume. + rmSync(stateDir, { recursive: true, force: true }); + expect(await limitsEnforcedSince()).toBe(1_700_000_000_000); + const payload = { bid: 'browser-a', slot: 0, exp: revokedAt - 1000 + WOPI_TOKEN_TTL_MS } as WopiTokenPayload; + expect(await isWopiTokenRevoked(payload)).toBe(true); + }); + + it('writes nothing for an install that has neither', async () => { + expect(await run()).toEqual({ 'session-limits-since': 0, 'wopi-revocations': 0 }); + }); +}); diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts index 50ef61536..d21ddcec1 100644 --- a/lib/auth/session-age.ts +++ b/lib/auth/session-age.ts @@ -32,7 +32,7 @@ import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { logger } from '@/lib/logger'; -import { getStore, isSharedMode } from '@/lib/shared-store'; +import { getStore, isSharedMode, type CollectionOptions } from '@/lib/shared-store'; export { SESSION_TOO_OLD } from '@/lib/session-policy/constants'; @@ -90,8 +90,12 @@ function memo(): { since: Promise | null } { * The time in the shared store (#1066). Its local layout is today's file in * the state dir, which local mode keeps writing itself (with O_EXCL). */ -export function sessionLimitsCollection() { - return getStore().collection('session-limits', { local: { dir: () => getStateDir() } }); +export const SESSION_LIMITS_COLLECTION = 'session-limits'; +export const SESSION_LIMITS_ID = ENFORCED_SINCE_FILE; +export const sessionLimitsLayout: CollectionOptions = { local: { dir: () => getStateDir() } }; + +function sessionLimitsCollection() { + return getStore().collection(SESSION_LIMITS_COLLECTION, sessionLimitsLayout); } function parseTime(text: string): number | null { diff --git a/lib/shared-store/import/auth.ts b/lib/shared-store/import/auth.ts new file mode 100644 index 000000000..725c8bb20 --- /dev/null +++ b/lib/shared-store/import/auth.ts @@ -0,0 +1,45 @@ +import { SESSION_LIMITS_COLLECTION, SESSION_LIMITS_ID, sessionLimitsLayout } from '@/lib/auth/session-age'; +import { REVOCATIONS_ID, WOPI_COLLECTION, wopiRevocationsLayout } from '@/lib/wopi/revocation'; +import { getStore } from '../index'; +import type { ImportStep } from '../import'; +import { localCollection } from '../local'; +import type { CollectionOptions } from '../types'; + +/** + * Import steps of part `auth` (#1066): the auth state that outlives a + * restart. + * + * - session-limits-since: when the server first enforced the maximum + * session length. Without it the shared store would record a new time and + * legacy sessions would get a fresh allowance. + * - WOPI revocations: office editor tokens revoked at sign-out, for up to + * six hours. + * + * Not imported, because they don't outlive the switch in any way that + * matters: + * - pairing grants, codes and the hand-off codes: in memory only, gone with + * the restart that turns on the shared store, as on any restart (they last + * two to five minutes); + * - impersonation markers: they refuse a used hand-off link for at most 420 + * seconds, and the links themselves live five minutes at most. Moving an + * install takes the deployment down for longer than that. + * - sign-in budgets: counters of 15 minutes to a day; they start empty, as + * they do after every restart today. + */ + +function copyStep(name: string, collection: string, layout: CollectionOptions, id: string): ImportStep { + return { + name, + async run() { + const doc = await localCollection(collection, layout).get(id); + if (!doc) return 0; + const written = await getStore().collection(collection, layout).put(id, doc.data, { ifVersion: null }); + return written.ok ? 1 : 0; + }, + }; +} + +export const steps: ImportStep[] = [ + copyStep('session-limits-since', SESSION_LIMITS_COLLECTION, sessionLimitsLayout, SESSION_LIMITS_ID), + copyStep('wopi-revocations', WOPI_COLLECTION, wopiRevocationsLayout, REVOCATIONS_ID), +]; diff --git a/lib/wopi/revocation.ts b/lib/wopi/revocation.ts index f8a0c9c1a..ecd8a6054 100644 --- a/lib/wopi/revocation.ts +++ b/lib/wopi/revocation.ts @@ -4,7 +4,7 @@ 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 } from '@/lib/shared-store'; +import { getStore, isSharedMode, type CollectionOptions } from '@/lib/shared-store'; import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token'; /** @@ -29,18 +29,21 @@ type CookieStore = Awaited>; export const WOPI_BROWSER_COOKIE = 'bulwark_wopi'; const REVOCATIONS_FILE = 'wopi-revocations.json'; -const REVOCATIONS_ID = '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 function wopiRevocationsCollection() { - return getStore().collection('wopi', { - local: { - dir: () => getStateDir(), - file: () => REVOCATIONS_FILE, - idOf: (file) => (file === REVOCATIONS_FILE ? REVOCATIONS_ID : null), - tmpName: (target) => `${target}.${process.pid}.tmp`, - }, - }); +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 {