From f4bec0e396b9ce132572bf333eda42fd412ac035 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Fri, 4 Sep 2026 14:06:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=EA=B8=B0=EA=B8=B0=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=EB=8F=84=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20+=20=EC=A3=BD=EC=9D=80=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EC=A0=84=EC=86=A1=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=AA=A8=EB=93=88=20=EC=82=AC=EB=B3=B8=20=EB=91=98?= =?UTF-8?q?=EC=9D=98=20=EC=98=9B=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9D=B4=20GoTrue=20=EA=B0=80=EC=A1=B1=20=ED=8F=90?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=B6=80=EB=A5=B4=EB=8D=98=20=EC=82=AC?= =?UTF-8?q?=EB=A7=9D=20=EC=9B=90=EC=9D=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Next는 src/devicesession.mjs를 instrumentation(동기화 루프)과 API 라우트에 따로 번들한다(상주 .next/server에 사본 2개 실측). 라우트 사본의 낡은 캐시가 회전 8분 뒤 옛 리프레시 토큰으로 갱신을 보냈고, GoTrue는 재사용 간격(10초) 밖 재사용을 RevokeTokenFamily로 처리해 새 토큰까지 죽였다(2026-09-03 15:11 회전 → 15:19 라우트 재사용 → 16:10 Already Used, 인증 로그 대조). → 캐시는 파일 mtime+size 도장이 같을 때만 쓴다(사본·프로세스 간 회전 즉시 반영). - 사망 마커에 거절된 토큰 지문(sha256 앞 16자)을 남기고, 같은 토큰이면 네트워크 없이 null. 8초 동기화 루프가 죽은 토큰을 계속 보내 기기당 하루 약 1만 회, 24기기 11만 회/일의 "Possible abuse attempt"가 쌓이고 있었다(2026-09-03~04 auth_logs). 토큰이 바뀌면(재로그인·다른 사본 회전) 지문이 달라져 게이트가 열린다. - 테스트: 도장 무효화·게이트·구형 마커 호환 3건 추가, 반복 거절 count 계약은 토큰이 바뀐 재거절로 유지. 변이 5종 red. Co-Authored-By: Claude Fable 5.1 --- src/devicesession.mjs | 35 ++++++++++++++---- test/devicesession-dead-marker.test.mjs | 47 +++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/devicesession.mjs b/src/devicesession.mjs index f75a301b..13b1d370 100644 --- a/src/devicesession.mjs +++ b/src/devicesession.mjs @@ -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); @@ -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; } @@ -83,6 +93,8 @@ 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 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 ?? '')); @@ -108,7 +120,7 @@ export function deviceSessionDeadInfo({ root = WS_ROOT } = {}) { } /** 마커 기록 — 같은 사유가 반복되면 최초 시각(at)을 보존하고 count만 올린다(동기화가 8초마다 다시 부딪친다). */ -async function markDead(root, err, retried) { +async function markDead(root, err, retried, sess) { const reason = mask(err?.message ?? 'no session'); const prev = deviceSessionDeadInfo({ root }); const same = prev?.reason === reason; @@ -116,6 +128,7 @@ async function markDead(root, err, retried) { 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) @@ -125,7 +138,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 { @@ -154,6 +167,14 @@ 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도 마커를 지운다. + const dead = deviceSessionDeadInfo({ root }); + if (dead?.tokenTag && dead.tokenTag === tokenTag(sess.refresh_token)) { + await logLine(root, { ev: 'skipped', reason: 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) { @@ -192,7 +213,7 @@ export async function getFreshDeviceSession({ root = WS_ROOT, _mkClient = create 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; } diff --git a/test/devicesession-dead-marker.test.mjs b/test/devicesession-dead-marker.test.mjs index 80724c7e..29075ec3 100644 --- a/test/devicesession-dead-marker.test.mjs +++ b/test/devicesession-dead-marker.test.mjs @@ -11,7 +11,7 @@ import { mkdtemp } from './helpers/tmp.mjs'; import { existsSync, readFileSync } 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({ @@ -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'); @@ -221,3 +222,45 @@ 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', '캐시 적재'); + await writeFile(join(root, '.device-session.json'), sessJson('rt2', -5, 'at2')); // 다른 사본/프로세스가 회전해 파일이 바뀜(크기·mtime) + const calls = []; + const out = await getFreshDeviceSession({ root, _mkClient: clientWith(async ({ refresh_token }) => { calls.push(refresh_token); return ok('at3', 'rt3'); }) }); + assert.deepEqual(calls, ['rt2'], '갱신은 디스크의 현재 토큰으로만 — 옛 토큰(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'], '교체된 마커부터 게이트 작동'); +}); From 9ca7a1e3dbbdf1fddc8b2d7c87539fef9d437b95 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Fri, 4 Sep 2026 14:20:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20=EA=B2=80=EC=88=98=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EA=B2=B0=20kind=EB=A7=8C=C2=B71=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=83=88=EC=B6=9C=20=EB=B0=B8=EB=B8=8C,=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=ED=9A=8C=EC=A0=84=20=ED=94=BD=EC=8A=A4?= =?UTF-8?q?=EC=B2=98,=20=EC=9E=90=EA=B0=80=20=EC=B9=98=EC=9C=A0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EB=8F=84=EC=9E=A5,=20skipped=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=EB=A7=88=EC=8A=A4=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MEDIUM-1: 실운영 회전은 파일 길이가 같아 mtime만이 판별자 → 픽스처를 같은 바이트 수 회전으로(size만 도장 변이 red) - MEDIUM-2: 게이트를 reused·revoked·expired에 한정하고 마지막 시도 1시간 뒤 1회 재시도(하루 24회 자가 치유, 난사 1/450) - LOW-1: 거절 후 재독 캐시에 도장(stat → read 순서) · LOW-3: skipped 로그 reason mask · LOW-4: count·lastAt 계약 주석 - 변이 8종 red(추가: size만 도장·밸브 제거·kind 한정 제거), 전체 스위트 1450/0 Co-Authored-By: Claude Fable 5.1 --- src/devicesession.mjs | 17 +++++++++---- test/devicesession-dead-marker.test.mjs | 32 ++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/devicesession.mjs b/src/devicesession.mjs index 13b1d370..583b934b 100644 --- a/src/devicesession.mjs +++ b/src/devicesession.mjs @@ -94,6 +94,8 @@ export async function clearDeviceSession({ root = WS_ROOT } = {}) { 마커·로그·콘솔·/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 ?? '')); @@ -119,7 +121,8 @@ export function deviceSessionDeadInfo({ root = WS_ROOT } = {}) { } catch { return null; } } -/** 마커 기록 — 같은 사유가 반복되면 최초 시각(at)을 보존하고 count만 올린다(동기화가 8초마다 다시 부딪친다). */ +/** 마커 기록 — 같은 사유가 반복되면 최초 시각(at)을 보존하고 count만 올린다. + 게이트 도입 후 count·lastAt은 "서버까지 간 거절"만 센다(같은 토큰은 1시간에 1회, 토큰이 바뀐 재거절은 즉시). */ async function markDead(root, err, retried, sess) { const reason = mask(err?.message ?? 'no session'); const prev = deviceSessionDeadInfo({ root }); @@ -169,10 +172,13 @@ export async function getFreshDeviceSession({ root = WS_ROOT, _mkClient = create if ((sess.expires_at ?? 0) * 1000 - Date.now() > 60_000) return sess; // 사망 게이트 — 마커가 "바로 이 토큰"의 거절이면 다시 보내지 않는다. 8초 동기화 루프가 죽은 토큰을 // 계속 보내 기기당 하루 1만 회(24기기 11만 회/일)의 "Possible abuse attempt"를 만들었다(2026-09-04 인증 로그 실측). - // 디스크 토큰이 바뀌면(재로그인·다른 사본 회전) 지문이 달라져 게이트가 열린다 — persist도 마커를 지운다. + // 게이트는 모든 사본에 걸리므로 자동 회생은 두 가지뿐이다: 재로그인(persist가 마커 제거·토큰 교체)과 + // 아래 탈출 밸브 — 종결 kind(reused·revoked·expired)만 막고, 마지막 시도가 1시간 지났으면 1회 다시 보낸다 + // (오분류·서버 측 복구를 하루 24회로 자가 치유, 난사는 1/450). 그 외 kind는 게이트 없이 종전처럼 재시도. const dead = deviceSessionDeadInfo({ root }); - if (dead?.tokenTag && dead.tokenTag === tokenTag(sess.refresh_token)) { - await logLine(root, { ev: 'skipped', reason: dead.reason }); + 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 } }); @@ -204,8 +210,9 @@ 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; diff --git a/test/devicesession-dead-marker.test.mjs b/test/devicesession-dead-marker.test.mjs index 29075ec3..44f3be29 100644 --- a/test/devicesession-dead-marker.test.mjs +++ b/test/devicesession-dead-marker.test.mjs @@ -8,7 +8,7 @@ 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, loadDeviceSession, deviceSessionDead, deviceSessionDeadInfo, rejectionKind } from '../src/devicesession.mjs'; @@ -227,10 +227,12 @@ test('console.warn(stderr)에도 토큰 모양은 가린다 — plist stderr 키 test('캐시는 디스크 도장(mtime+size)이 바뀌면 무효 — 다른 사본이 회전한 뒤 옛 토큰으로 갱신을 보내지 않는다', async () => { const root = await mkRoot(3600); assert.equal(loadDeviceSession({ root })?.refresh_token, 'rt', '캐시 적재'); - await writeFile(join(root, '.device-session.json'), sessJson('rt2', -5, 'at2')); // 다른 사본/프로세스가 회전해 파일이 바뀜(크기·mtime) + 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, ['rt2'], '갱신은 디스크의 현재 토큰으로만 — 옛 토큰(rt) 재사용은 GoTrue 가족 폐기'); + assert.deepEqual(calls, ['rx'], '갱신은 디스크의 현재 토큰으로만 — 옛 토큰(rt) 재사용은 GoTrue 가족 폐기'); assert.equal(out?.access_token, 'at3'); }); @@ -264,3 +266,27 @@ test('지문 없는 구형 마커(발행본 v0.1.60 이하)는 게이트 없음 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가 아니면 종전처럼 재시도'); +});