게임 토큰 회수를 Redis에 유지하고 재시작과 알림 누락을 방어한다

This commit is contained in:
2026-10-09 05:59:28 +09:00
parent eeda225429
commit 005f88d7ca
17 changed files with 605 additions and 17 deletions
+65 -1
View File
@@ -1,3 +1,10 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
parseSessionRevocationWatermark,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
@@ -8,7 +15,7 @@ export interface GatewayUserFlushEvent {
}
export interface FlushStore {
getFlushedAt(userId: string): Date | null;
getFlushedAt(userId: string): Date | null | Promise<Date | null>;
applyFlush(event: GatewayUserFlushEvent): void;
}
@@ -31,6 +38,63 @@ export class InMemoryFlushStore implements FlushStore {
}
}
interface RevocationRedisClient {
readonly isReady?: boolean;
mGet(keys: string[]): Promise<(string | null)[]>;
withAbortSignal?(signal: AbortSignal): RevocationRedisClient;
}
/** Pub/sub accelerates closure; Redis is checked again at every authorization boundary. */
export class RedisFlushStore implements FlushStore {
private readonly local = new Map<string, Date>();
private readonly commands = new SessionRevocationCommandBudget(64);
constructor(
private readonly client: RevocationRedisClient,
private readonly channel: string
) {}
private commandClient(): RevocationRedisClient {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async assertInitialized(): Promise<void> {
const values = await this.commands.run(() =>
this.commandClient().mGet([buildSessionRevocationBaselineKey(this.channel)])
);
if (values.length !== 1 || !parseSessionRevocationWatermark(values[0]!))
throw new Error('Gateway session revocation must be initialized before game API startup.');
}
async getFlushedAt(userId: string): Promise<Date | null> {
const values = await this.commands.run(() =>
this.commandClient().mGet([
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
])
);
if (values.length !== 2) throw new Error('Session revocation storage is unavailable.');
const baseline = parseSessionRevocationWatermark(values[0]!);
if (!baseline) throw new Error('Session revocation storage is unavailable.');
const persisted = parseSessionRevocationWatermark(values[1]!);
return new Date(
Math.max(baseline.getTime(), persisted?.getTime() ?? 0, this.local.get(userId)?.getTime() ?? 0)
);
}
applyFlush(event: GatewayUserFlushEvent): void {
const parsed = new Date(event.flushedAt);
if (Number.isNaN(parsed.getTime())) return;
const prior = this.local.get(event.userId);
if (!prior || parsed > prior) {
this.local.delete(event.userId);
this.local.set(event.userId, parsed);
}
// 이 캐시는 재시작/eviction 뒤에도 Redis watermark를 확인하므로 권한 근거가 아니다.
if (this.local.size > 4096) this.local.delete(this.local.keys().next().value!);
}
}
export class RedisGatewayFlushSubscriber {
private readonly client: {
subscribe: (channel: string, listener: (message: string) => void) => Promise<void>;
+9 -1
View File
@@ -66,7 +66,15 @@ export const authRouter = router({
message: 'Game access is restricted for this account.',
});
}
const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id);
let flushedAt: Date | null;
try {
flushedAt = await ctx.flushStore.getFlushedAt(payload.user.id);
} catch {
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: 'Authentication protection is unavailable.',
});
}
if (flushedAt && new Date(payload.issuedAt) <= flushedAt) {
throw new TRPCError({
code: 'UNAUTHORIZED',
+27 -4
View File
@@ -4,6 +4,7 @@ import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import path from 'path';
import fs from 'node:fs/promises';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
buildGameEventChannel,
@@ -22,7 +23,7 @@ import {
import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.js';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
import {
consumeRealtimeAccessGrantHeader,
@@ -83,7 +84,12 @@ const resolveAuthFromToken = async (
if (!stored) {
return null;
}
const flushedAt = flushStore.getFlushedAt(stored.user.id);
let flushedAt: Date | null;
try {
flushedAt = await flushStore.getFlushedAt(stored.user.id);
} catch {
throw new TRPCError({ code: 'SERVICE_UNAVAILABLE', message: 'Authentication protection is unavailable.' });
}
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
return null;
}
@@ -142,7 +148,18 @@ export const createGameApiServer = async () => {
requestTimeoutMs: config.battleSimRequestTimeoutMs,
resultTtlSeconds: config.battleSimResultTtlSeconds,
});
const flushStore = new InMemoryFlushStore();
const flushStore = new RedisFlushStore(redis.client, config.flushChannel);
try {
await flushStore.assertInitialized();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation is unavailable; game API startup refused.');
}
const flushSubscriberClient = redis.client.duplicate();
try {
await flushSubscriberClient.connect();
@@ -328,7 +345,13 @@ export const createGameApiServer = async () => {
const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization);
const auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null;
let auth: GameSessionTokenPayload | null = null;
try {
auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null;
} catch {
await reply.status(503).send({ ok: false, error: 'authentication_unavailable' });
return;
}
if (!auth || auth.profile !== config.profileName) {
await reply.status(401).send({ ok: false, error: 'unauthorized' });
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) return;
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
if (keys.length > 0) await redis.client.del(keys);
}
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
if (!created) throw new Error('failed to seed diplomacy HTML access token');
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000);
+84 -1
View File
@@ -1,6 +1,8 @@
import { createServer, type Socket } from 'node:net';
import { createClient } from 'redis';
import { describe, expect, it, vi } from 'vitest';
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
describe('RedisGatewayFlushSubscriber', () => {
it('reports asynchronous flush handler failures with event context', async () => {
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
expect(stopped).toBe(true);
});
});
describe('RedisFlushStore authorization state', () => {
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
let persisted: string | null = null;
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
await store.assertInitialized();
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
persisted = '200';
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
expect(mGet).toHaveBeenCalledTimes(4);
});
it('retains revocation after local cache eviction and a fresh store instance', async () => {
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
const store = new RedisFlushStore(client, 'fixture:flush');
for (let i = 0; i < 4100; i++)
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
});
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
'rejects missing or corrupt initialization %s',
async (baseline) => {
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
await expect(store.assertInitialized()).rejects.toThrow();
}
);
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
});
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
const offlineGet = vi.fn(async () => ['0', null]);
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
expect(offlineGet).not.toHaveBeenCalled();
const mGet = vi.fn(async () => ['0', '100']);
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
});
});
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
const sockets = new Set<Socket>();
let receivedRead = false;
const server = createServer((socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('data', (data) => {
const request = data.toString();
// Real node-redis handshake succeeds; its MGET command receives no reply.
if (request.includes('MGET')) {
receivedRead = true;
return;
}
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
socket.write('+OK\r\n'.repeat(count));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
try {
await client.connect();
expect(client.isReady).toBe(true);
const start = Date.now();
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
expect(receivedRead).toBe(true);
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
expect(Date.now() - start).toBeLessThan(4500);
} finally {
if (client.isOpen) client.destroy();
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
for await (const keys of redis.client.scanIterator({
MATCH: `sammo:game:*:${profileName}:*`,
COUNT: 100,
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
}
accessToken = created.accessToken;
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
}, 30_000);
@@ -173,7 +173,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
if (!redis) {
return;
}
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const keys of redis.client.scanIterator({
MATCH: pattern,
COUNT: 100,
@@ -417,7 +417,7 @@ const readAccessTelemetryState = async () => ({
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
if (!redis) return [];
const keys = new Set<string>();
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
for (const key of batch) keys.add(key);
}
@@ -743,6 +743,8 @@ integration('game API security over HTTP transport', () => {
await redis.connect();
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
server = await createGameApiServer();
baseUrl = await server.app.listen({
host: server.config.host,
@@ -3675,6 +3677,98 @@ integration('game API security over HTTP transport', () => {
}
});
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
const actor = `security-http-durable-missed-${process.pid}`;
const accessToken = await createAccessToken('durable-missed', {}, actor);
try {
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('retains persisted revocation state across an actual API restart', async () => {
const actor = `security-http-durable-restart-${process.pid}`;
const accessToken = await createAccessToken('durable-restart', {}, actor);
try {
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await server!.app.close();
server = await createGameApiServer();
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
method: 'POST',
input: { gatewayToken },
});
expect(exchanged.response.status).toBe(401);
} finally {
await accessTokenStore.revoke(accessToken);
}
});
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
const actor = `security-http-durable-sse-${process.pid}`;
const accessToken = await createAccessToken('durable-sse', {}, actor);
const stream = await openRealtime(accessToken);
try {
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
await redis!.client.publish(
buildGameEventChannel(profileName),
JSON.stringify({ type: 'tournamentChanged' })
);
expect(await stream.reader.read()).toMatchObject({ done: true });
} finally {
stream.abort.abort();
await accessTokenStore.revoke(accessToken);
}
});
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
const actor = `security-http-durable-cutoff-${process.pid}`;
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.set(key, String(Date.now()));
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
const fresh = await accessTokenStore.create({
...buildPayload('fresh-cutoff', {}, actor),
issuedAt: new Date(Date.now() + 1).toISOString(),
});
expect(fresh).not.toBeNull();
try {
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
200
);
} finally {
await accessTokenStore.revoke(fresh!.accessToken);
}
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
const actor = `security-http-durable-missing-${process.pid}`;
const accessToken = await createAccessToken('durable-missing', {}, actor);
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
try {
await redis!.client.del(key);
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(sse.status).toBe(503);
await expect(createGameApiServer()).rejects.toThrow('startup refused');
} finally {
await redis!.client.set(key, '0');
await accessTokenStore.revoke(accessToken);
}
});
// Flush invalidates every token issued before the user watermark. Keep it
// last so this lifecycle assertion cannot invalidate the actor tokens used
// by the transport authorization matrix above.
+52 -3
View File
@@ -1,3 +1,28 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
interface RedisClientLike {
readonly isReady?: boolean;
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
withAbortSignal?(signal: AbortSignal): RedisClientLike;
}
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
const PUBLISH_FLUSH_SCRIPT = `
redis.call('SET', KEYS[1], ARGV[1], 'NX')
local raw = redis.call('GET', KEYS[2])
local prior = tonumber(raw)
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
redis.call('PUBLISH', ARGV[2], ARGV[3])
return 1
`;
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
private readonly channel: string;
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
private readonly client: RedisClientLike;
private readonly commands = new SessionRevocationCommandBudget(32);
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
constructor(client: RedisClientLike, channel: string) {
this.client = client;
this.channel = channel;
}
private commandClient(): RedisClientLike {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async initialize(): Promise<void> {
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
await this.commands.run(() =>
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
NX: true,
})
);
}
async publishUserFlush(
userId: string,
reason?: string,
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
};
await this.client.publish(this.channel, JSON.stringify(payload));
const result = await this.commands.run(() =>
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
keys: [
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
],
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
})
);
if (result !== 1) throw new Error('Session revocation persistence failed.');
}
}
+3 -1
View File
@@ -1056,6 +1056,8 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
const authorizationStartedAt = Date.now();
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
if (!gatewaySession) {
throw new TRPCError({
@@ -1108,7 +1110,7 @@ export const appRouter = router({
});
}
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
const now = new Date(gameSession.issuedAt);
const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt)));
const accountIcon = resolveEffectiveAccountIcon(user);
const accountIcons = await ctx.users.listIcons(user.id);
const payload = {
+11
View File
@@ -73,6 +73,17 @@ export const createGatewayApiServer = async () => {
config.gameTokenSecret
);
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
try {
await flushPublisher.initialize();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation initialization failed.');
}
const kakaoClient = new KakaoOAuthClient({
restKey: config.kakaoRestKey,
adminKey: config.kakaoAdminKey,
+24
View File
@@ -1242,6 +1242,30 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' });
const parent = await sessions.createSession(user);
const issuedAt = new Date().toISOString();
vi.useFakeTimers({ now: new Date(issuedAt) });
const original = users.findById.bind(users);
const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => {
const snapshot = await original(id);
vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot.
return snapshot;
});
try {
const result = await caller.auth.issueGameSession({
sessionToken: parent.sessionToken,
profile: 'che:default',
});
expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt);
} finally {
find.mockRestore();
vi.useRealTimers();
}
});
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
@@ -0,0 +1,79 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
} from '@sammo-ts/common/auth/sessionRevocation';
import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js';
const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
describe.skipIf(!url)('persistent Gateway flush over real Redis', () => {
const client = createClient({ url });
const channel = `revocation-publisher-test:${randomUUID()}:flush`;
const publisher = new RedisGatewayFlushPublisher(client, channel);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 }))
if (keys.length) await client.del(keys);
await client.quit();
});
it('initializes once and preserves the original cutoff across publisher restarts', async () => {
await publisher.initialize();
const baseline = await client.get(buildSessionRevocationBaselineKey(channel));
expect(Number(baseline)).toBeGreaterThan(0);
await new RedisGatewayFlushPublisher(client, channel).initialize();
expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline);
expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1);
});
it('persists a revocation when there are zero subscribers', async () => {
const userId = randomUUID();
await publisher.publishUserFlush(userId, 'logout');
expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0);
expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1);
});
it('makes the watermark readable before delivering flush metadata', async () => {
const subscriber = client.duplicate();
await subscriber.connect();
const userId = randomUUID();
let delivered: { event: Record<string, unknown>; persisted: string | null } | undefined;
await subscriber.subscribe(channel, async (raw) => {
delivered = {
event: JSON.parse(raw) as Record<string, unknown>,
persisted: await client.get(buildUserSessionRevocationKey(channel, userId)),
};
});
try {
await publisher.publishUserFlush(userId, 'identity-updated', {
displayName: 'fixture',
identityRevision: 'revision-1',
});
await vi.waitFor(() => expect(delivered).toBeDefined());
expect(delivered!.event).toMatchObject({
userId,
reason: 'identity-updated',
displayName: 'fixture',
identityRevision: 'revision-1',
});
expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string));
} finally {
await subscriber.unsubscribe(channel);
await subscriber.quit();
}
});
it('never moves a newer watermark backwards under late or concurrent flushes', async () => {
const userId = randomUUID();
const future = String(Date.now() + 60_000);
await client.set(buildUserSessionRevocationKey(channel, userId), future);
await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent')));
expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future);
});
it('fails closed for an unavailable client instead of publishing without storage', async () => {
const offline = client.duplicate();
const closed = new RedisGatewayFlushPublisher(offline, channel);
await expect(closed.initialize()).rejects.toThrow('unavailable');
await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable');
});
});