Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions src/devicesession.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import { join } from 'node:path';
import { createClient } from '@supabase/supabase-js';
import { WS_ROOT } from './workspace.mjs';
import { withLock } from './mutex.mjs';
import { createHash } from 'node:crypto';

const FILE = '.device-session.json';
const LOG = '.device-session.log';
const LOG_MAX = 256 * 1024; // 넘치면 .1로 한 세대만 밀어낸다
let cache = null; // { root, sess }
let cache = null; // { root, sess, stamp } — stamp = 디스크 mtime+size. 다른 모듈 사본·프로세스가 회전해 파일이 바뀌면 캐시는 무효다
let epoch = 0;
export const deviceEpoch = () => epoch;
const fileOf = (root) => join(root, FILE);
Expand All @@ -32,11 +33,20 @@ function readDisk(root) {
return null;
}

/** 기기 세션 또는 null (프로세스 캐시 — persist/clear가 비운다). */
/** 디스크 파일의 정체 도장(mtime+size). 없음 = 'none'. */
function diskStamp(root) {
try { const st = statSync(fileOf(root)); return `${st.mtimeMs}:${st.size}`; } catch { return 'none'; }
}

/** 기기 세션 또는 null. 캐시는 디스크 도장(mtime+size)이 같을 때만 쓴다 — Next는 instrumentation(동기화 루프)과
API 라우트에 이 모듈을 **따로 번들**하므로 한 프로세스에 캐시가 둘이다. 도장 없는 캐시는 한쪽이 회전한 뒤
다른 쪽이 옛 토큰으로 갱신을 보내게 했고, GoTrue는 재사용 간격(10초) 밖 재사용을 "가족 폐기"로 처리해
새 토큰까지 죽였다(2026-09-03 실사고 — 라우트 사본이 8분 뒤 옛 토큰 재사용 → 다음 회전 Already Used). */
export function loadDeviceSession({ root = WS_ROOT } = {}) {
if (cache && cache.root === root) return cache.sess;
const stamp = diskStamp(root);
if (cache && cache.root === root && cache.stamp === stamp) return cache.sess;
const sess = readDisk(root);
cache = { root, sess };
cache = { root, sess, stamp };
return sess;
}

Expand Down Expand Up @@ -83,6 +93,10 @@ export async function clearDeviceSession({ root = WS_ROOT } = {}) {
/** 사유 문구의 토큰 모양(20자 이상 base64url 런 — JWT 헤더 세그먼트 20자·리프레시 토큰 22자를 덮는다)을 가린다 —
마커·로그·콘솔·/api/me 응답에 실리는 값 전부 이 함수를 거친다. */
const mask = (s) => String(s ?? '').replace(/[A-Za-z0-9_-]{20,}/g, '***').slice(0, 300);
/** 리프레시 토큰 지문(sha256 앞 16자) — 마커에 "어느 토큰이 거절됐나"를 값 노출 없이 남긴다. */
const DEAD_GATE_KINDS = new Set(['reused', 'revoked', 'expired']); // 종결 거절만 게이트 — catch-all 'rejected'는 재시도 유지
const DEAD_RETRY_MS = 60 * 60_000; // 게이트 탈출 밸브 — 같은 토큰 재시도 최소 간격
const tokenTag = (rt) => createHash('sha256').update(String(rt ?? '')).digest('hex').slice(0, 16);
/** 리프레시 토큰 거절인가(Invalid Refresh Token 계열) — 네트워크 실패·5xx는 거절이 아니다. */
const isRejection = (err) => /refresh token/i.test(String(err?.message ?? ''));

Expand All @@ -107,15 +121,17 @@ export function deviceSessionDeadInfo({ root = WS_ROOT } = {}) {
} catch { return null; }
}

/** 마커 기록 — 같은 사유가 반복되면 최초 시각(at)을 보존하고 count만 올린다(동기화가 8초마다 다시 부딪친다). */
async function markDead(root, err, retried) {
/** 마커 기록 — 같은 사유가 반복되면 최초 시각(at)을 보존하고 count만 올린다.
게이트 도입 후 count·lastAt은 "서버까지 간 거절"만 센다(같은 토큰은 1시간에 1회, 토큰이 바뀐 재거절은 즉시). */
async function markDead(root, err, retried, sess) {
const reason = mask(err?.message ?? 'no session');
const prev = deviceSessionDeadInfo({ root });
const same = prev?.reason === reason;
const now = new Date().toISOString();
const info = {
at: same ? prev.at : now, lastAt: now, count: same ? (prev.count ?? 0) + 1 : 1,
kind: rejectionKind(reason), reason, status: err?.status ?? null, code: err?.code ?? null, retried,
tokenTag: tokenTag(sess?.refresh_token), // 이 토큰이 거절됐다 — 같은 토큰이면 다시 보내지 않는다(아래 게이트)
};
await writeFile(deadMarkerOf(root), JSON.stringify(info), { mode: 0o600 }).catch(() => {});
await chmod(deadMarkerOf(root), 0o600).catch(() => {}); // 발행본이 mode 없이 쓴 구형 마커(0644) 위에 덮어도 승격(검수 LOW-2)
Expand All @@ -125,7 +141,7 @@ async function markDead(root, err, retried) {
const lastLogKey = new Map(); // root → 마지막 줄 키. 같은 사유의 연속 반복(rejected/error)은 한 줄만 — rotated·reread는 항상 남긴다(회전 이력이 진단의 핵심)
async function logLine(root, entry) {
const key = `${entry.ev}|${entry.reason ?? ''}`;
if ((entry.ev === 'rejected' || entry.ev === 'error') && key === lastLogKey.get(root)) return;
if ((entry.ev === 'rejected' || entry.ev === 'error' || entry.ev === 'skipped') && key === lastLogKey.get(root)) return;
const file = join(root, LOG);
try { if (statSync(file).size > LOG_MAX) await rename(file, `${file}.1`); } catch { /* 없음 */ }
try {
Expand Down Expand Up @@ -154,6 +170,17 @@ export async function getFreshDeviceSession({ root = WS_ROOT, _mkClient = create
for (;;) {
if (!sess) return null;
if ((sess.expires_at ?? 0) * 1000 - Date.now() > 60_000) return sess;
// 사망 게이트 — 마커가 "바로 이 토큰"의 거절이면 다시 보내지 않는다. 8초 동기화 루프가 죽은 토큰을
// 계속 보내 기기당 하루 1만 회(24기기 11만 회/일)의 "Possible abuse attempt"를 만들었다(2026-09-04 인증 로그 실측).
// 게이트는 모든 사본에 걸리므로 자동 회생은 두 가지뿐이다: 재로그인(persist가 마커 제거·토큰 교체)과
// 아래 탈출 밸브 — 종결 kind(reused·revoked·expired)만 막고, 마지막 시도가 1시간 지났으면 1회 다시 보낸다
// (오분류·서버 측 복구를 하루 24회로 자가 치유, 난사는 1/450). 그 외 kind는 게이트 없이 종전처럼 재시도.
const dead = deviceSessionDeadInfo({ root });
if (dead?.tokenTag && dead.tokenTag === tokenTag(sess.refresh_token) && DEAD_GATE_KINDS.has(dead.kind)
&& Date.now() - (Date.parse(dead.lastAt) || 0) < DEAD_RETRY_MS) {
await logLine(root, { ev: 'skipped', reason: mask(dead.reason) });
return null;
}
const sb = _mkClient(sess.url, sess.anonKey, { auth: { persistSession: false, autoRefreshToken: false } });
const { data, error } = await sb.auth.refreshSession({ refresh_token: sess.refresh_token });
if (!error && data?.session) {
Expand Down Expand Up @@ -183,16 +210,17 @@ export async function getFreshDeviceSession({ root = WS_ROOT, _mkClient = create
// 디스크가 바뀌었을 수 있다 — 캐시를 무시하고 디스크를 재독해 토큰이 다르면 그 세션으로 딱 한 번
// 다시 간다(자가 치유). 디스크도 같은 토큰(=진짜 사망)이거나 재시도까지 거절될 때만 마커를 남긴다.
if (!retried) {
const stamp = diskStamp(root); // 도장은 읽기보다 먼저 — 뒤집으면 "새 도장 + 옛 내용"이 영구 캐시
const disk = readDisk(root);
cache = { root, sess: disk };
cache = { root, sess: disk, stamp };
if (!disk || disk.refresh_token !== sess.refresh_token) {
await logLine(root, { ev: 'reread', disk: disk ? 'changed' : 'gone' });
retried = true;
sess = disk;
continue;
}
}
const info = await markDead(root, error, retried);
const info = await markDead(root, error, retried, sess);
await logLine(root, { ev: 'rejected', kind: info.kind, reason: info.reason, status: info.status, code: info.code, retried, count: info.count });
return null;
}
Expand Down
75 changes: 72 additions & 3 deletions test/devicesession-dead-marker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeFile, readFile, rm } from 'node:fs/promises';
import { mkdtemp } from './helpers/tmp.mjs';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { getFreshDeviceSession, deviceSessionDead, deviceSessionDeadInfo, rejectionKind } from '../src/devicesession.mjs';
import { getFreshDeviceSession, loadDeviceSession, deviceSessionDead, deviceSessionDeadInfo, rejectionKind } from '../src/devicesession.mjs';

const nowSec = () => Math.floor(Date.now() / 1000);
const sessJson = (rt, expiresInSec, at = 'at') => JSON.stringify({
Expand Down Expand Up @@ -86,12 +86,13 @@ test('거절 마커는 사유 JSON — kind·reason(토큰 마스킹)·status·c
assert.equal(deviceSessionDead({ root }), true, '판정 계약은 그대로');
});

test('같은 사유가 반복되면 at(최초 시각) 보존 + count 증가, 로그는 한 줄만(동기화 8초 주기 방어)', async () => {
test('같은 사유가 반복되면(토큰이 바뀐 재거절) at(최초 시각) 보존 + count 증가, 로그는 한 줄만', async () => {
const root = await mkRoot(-10);
const client = clientWith(async () => reject('Invalid Refresh Token: Refresh Token Not Found'));
await getFreshDeviceSession({ root, _mkClient: client });
const first = deviceSessionDeadInfo({ root });
await new Promise((r) => setTimeout(r, 5));
await writeFile(join(root, '.device-session.json'), sessJson('rt2', -10)); // 재로그인했는데 그 토큰도 죽어 있음
await getFreshDeviceSession({ root, _mkClient: client });
const second = deviceSessionDeadInfo({ root });
assert.equal(second.kind, 'revoked');
Expand Down Expand Up @@ -221,3 +222,71 @@ test('console.warn(stderr)에도 토큰 모양은 가린다 — plist stderr 키
assert.ok(line, '경고 줄이 남는다');
assert.ok(!line.includes(token) && line.includes('***'), `콘솔 원문에 토큰 없음: ${line}`);
});

// ── ⑤ 2026-09-03 실사고: 모듈 사본 둘(instrumentation·라우트 번들)의 낡은 캐시 + 8초 루프의 죽은 토큰 난사 ──
test('캐시는 디스크 도장(mtime+size)이 바뀌면 무효 — 다른 사본이 회전한 뒤 옛 토큰으로 갱신을 보내지 않는다', async () => {
const root = await mkRoot(3600);
assert.equal(loadDeviceSession({ root })?.refresh_token, 'rt', '캐시 적재');
const before = statSync(join(root, '.device-session.json')).size;
await writeFile(join(root, '.device-session.json'), sessJson('rx', -5)); // 다른 사본/프로세스가 회전 — 실운영 회전은 길이가 같다(토큰 22자·exp 10자리 고정) → mtime만이 판별자(검수 MEDIUM-1)
assert.equal(statSync(join(root, '.device-session.json')).size, before, '전제 — 회전 전후 크기 동일');
const calls = [];
const out = await getFreshDeviceSession({ root, _mkClient: clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return ok('at3', 'rt3'); }) });
assert.deepEqual(calls, ['rx'], '갱신은 디스크의 현재 토큰으로만 — 옛 토큰(rt) 재사용은 GoTrue 가족 폐기');
assert.equal(out?.access_token, 'at3');
});

test('사망 게이트 — 마커가 같은 토큰의 거절이면 네트워크 없이 null(로그 skipped 한 줄), 토큰이 바뀌면 다시 시도', async () => {
const root = await mkRoot(-10);
const calls = [];
const client = clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return reject('Invalid Refresh Token: Already Used'); });
assert.equal(await getFreshDeviceSession({ root, _mkClient: client }), null);
const info = deviceSessionDeadInfo({ root });
assert.equal(typeof info.tokenTag, 'string');
assert.equal(info.tokenTag.length, 16);
assert.ok(!readFileSync(marker(root), 'utf8').includes('"rt"'), '마커에 토큰 원문 없음(지문만)');
assert.equal(await getFreshDeviceSession({ root, _mkClient: client }), null);
assert.equal(await getFreshDeviceSession({ root, _mkClient: client }), null);
assert.deepEqual(calls, ['rt'], '같은 토큰은 한 번만 서버에 간다');
assert.equal(deviceSessionDeadInfo({ root }).count, 1);
assert.equal(deviceSessionDead({ root }), true, '판정은 그대로 사망');
assert.deepEqual((await readLog(root)).map((l) => l.ev), ['rejected', 'skipped'], 'skipped는 연속 중복 제거');
await writeFile(join(root, '.device-session.json'), sessJson('rt2', -10)); // 재로그인(다른 토큰) — persist가 아닌 외부 쓰기라 마커는 남아 있음
assert.equal(await getFreshDeviceSession({ root, _mkClient: client }), null);
assert.deepEqual(calls, ['rt', 'rt2'], '토큰이 바뀌면 게이트가 열린다');
});

test('지문 없는 구형 마커(발행본 v0.1.60 이하)는 게이트 없음 — 1회 시도 후 지문 달린 마커로 교체', async () => {
const root = await mkRoot(-10);
await writeFile(marker(root), JSON.stringify({ at: 'x', lastAt: 'x', count: 3, kind: 'reused', reason: 'Invalid Refresh Token: Already Used' }));
const calls = [];
await getFreshDeviceSession({ root, _mkClient: clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return reject('Invalid Refresh Token: Already Used'); }) });
assert.deepEqual(calls, ['rt']);
assert.equal(deviceSessionDeadInfo({ root }).tokenTag?.length, 16);
await getFreshDeviceSession({ root, _mkClient: clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return reject('Invalid Refresh Token: Already Used'); }) });
assert.deepEqual(calls, ['rt'], '교체된 마커부터 게이트 작동');
});

test('게이트 탈출 밸브 — 마지막 시도가 1시간 지났으면 같은 토큰도 1회 재시도(count·lastAt 갱신), catch-all kind는 게이트 없음', async () => {
const root = await mkRoot(-10);
const calls = [];
const client = clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return reject('Invalid Refresh Token: Already Used'); });
await getFreshDeviceSession({ root, _mkClient: client });
const first = deviceSessionDeadInfo({ root });
await writeFile(marker(root), JSON.stringify({ ...first, lastAt: new Date(Date.now() - 2 * 3600_000).toISOString() })); // 1시간 넘게 잠잠
await getFreshDeviceSession({ root, _mkClient: client });
assert.deepEqual(calls, ['rt', 'rt'], '밸브 열림 — 1회 재시도');
const second = deviceSessionDeadInfo({ root });
assert.equal(second.count, 2);
assert.ok(Date.parse(second.lastAt) > Date.now() - 10_000, 'lastAt 갱신');
await getFreshDeviceSession({ root, _mkClient: client });
assert.deepEqual(calls, ['rt', 'rt'], '갱신 직후엔 다시 닫힘');
// catch-all 'rejected'(모르는 문구) — 오분류 폭발반경을 영구 사망으로 키우지 않는다(검수 MEDIUM-2)
const root2 = await mkRoot(-10);
const calls2 = [];
const odd = clientWith(async ({ refresh_token }) => { calls2.push(refresh_token); return reject('refresh token service temporarily unavailable'); });
await getFreshDeviceSession({ root: root2, _mkClient: odd });
assert.equal(deviceSessionDeadInfo({ root: root2 }).kind, 'rejected');
await getFreshDeviceSession({ root: root2, _mkClient: odd });
assert.deepEqual(calls2, ['rt', 'rt'], '종결 kind가 아니면 종전처럼 재시도');
});
Loading