Skip to content

fix(auth): 기기 세션 캐시 디스크 도장 검증 + 죽은 토큰 재전송 게이트 — 모듈 사본 둘의 재사용이 부르던 세션 사망 제거 - #429

Merged
beyondworks merged 2 commits into
mainfrom
fix/devsess-stale-cache-family-revoke
Sep 4, 2026
Merged

Conversation

@beyondworks

@beyondworks beyondworks commented Sep 4, 2026

Copy link
Copy Markdown
Owner

무엇

상주·앱 기기 세션이 "Invalid Refresh Token: Already Used"로 죽는 재발 원인 두 가지를 닫는다.

  1. 캐시를 디스크 도장(mtime+size)으로 검증. Next가 src/devicesession.mjs를 instrumentation(동기화 루프)과 API 라우트 번들에 따로 복제한다(상주 .next/server에서 devsess: 문자열 사본 2개 실측: chunks/6022.js = instrumentation, chunks/7921.js = 라우트 54개). 모듈 전역 cache·인메모리 withLock이 사본마다 따로라, 라우트 사본이 옛 토큰으로 갱신을 보냈다.
    • 실측 타임라인(2026-09-03, Supabase auth_logs × .device-session.log): 15:11:23Z 동기화 사본 회전(200) → 15:19:46Z 라우트 사본(/api/me 기기 폴백)이 옛 토큰으로 갱신 → GoTrue RevokeTokenFamily(재사용 간격 10초 밖, "Possible abuse attempt") → 디스크 재독으로 겉보기 자가 치유 → 16:10:24Z 정상 회전이 Already Used → 사망.
  2. 죽은 토큰 재전송 게이트(종결 kind 한정 + 1시간 탈출 밸브). 마커에 거절된 토큰 지문(sha256 앞 16자, 값 노출 없음)을 남기고 같은 토큰이면 네트워크 없이 null. 8초 동기화 루프가 죽은 토큰을 계속 보내 기기당 하루 약 1만 회, 24개 IP에서 11만 회/일의 400이 쌓이고 있었다(auth_logs 24h: already_used 111,323건 vs 정상 갱신 674건). 토큰이 바뀌면(재로그인) 지문이 달라져 게이트가 열리고, persist는 마커를 지운다. 게이트는 모든 사본에 걸리므로 자동 회생은 밸브뿐이다: reused·revoked·expired만 막고, 마지막 시도 1시간 뒤 같은 토큰을 1회 다시 보낸다(하루 24회, 오분류·서버 복구 자가 치유). catch-all rejected는 종전처럼 재시도.

영향 반경(문맥 행렬)

  • 러너·플랜·OS 축 무관. 걸치는 축 = 셸(상주 :3001·Tauri 사이드카) × 인증(기기 세션).
  • 검증함: 단위 18건(추가 4: 도장 무효화[같은 크기 회전]·게이트·탈출 밸브/kind 한정·구형 마커 호환) + 전체 스위트 1450 pass/0 fail(exit 0) + 변이 8종 red(도장 비교 제거·size만 도장·게이트 제거·지문 무시·지문 미기록·밸브 제거·kind 한정 제거·dedupe 제거).
  • 분리 검수(code-reviewer) 조건부 승인 → MEDIUM-1·2 반영, LOW-1·3·4 반영. 이월(후속 PR): MEDIUM-3 크로스 사본 mkdir 파일 락(두 사본이 같은 순간 갱신하면 같은 토큰이 두 번 가나 0ms 간격이라 GoTrue 재사용 간격 안 — 안전, 10초 이상 지연 시 재발 가능). LOW-2(/api/me 마커 화이트리스트)·LOW-5(sync EXCLUDE 접두 비교)는 선택.
  • 미검증: 라이브(상주 배포 후 auth_logs에서 이 IP의 갱신 400이 8초 간격에서 멎는지) — 머지·상주 배포 뒤 확인 예정. 윈도우 사이드카는 코드 동일성 추론.
  • 소비자 전수: loadDeviceSession 호출부 6곳(sync.mjs 5·market.mjs·app/auth.mjs)은 필드만 읽어 statSync 1회 추가 외 동작 동일.

발행본

main 머지 ≠ 반영. 상주 재배포 + **다음 발행(v0.1.61 후보)**에 실어야 설치 기기에 닿는다. 이미 죽은 기기는 재로그인이 필요하다(마커는 그대로 사망 판정, 게이트는 난사만 멈춘다).

🤖 Generated with Claude Code

beyondworks and others added 2 commits September 4, 2026 14:06
… GoTrue 가족 폐기를 부르던 사망 원인 제거

- 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 <noreply@anthropic.com>
…, skipped 사유 마스킹

- 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 <noreply@anthropic.com>
@beyondworks
beyondworks merged commit 1b30a74 into main Sep 4, 2026
2 checks passed
@beyondworks
beyondworks deleted the fix/devsess-stale-cache-family-revoke branch September 4, 2026 05:29
beyondworks added a commit that referenced this pull request Sep 5, 2026
윈도우 CI 실패로 드러난 잔여 창(#429 후속): 실운영 회전은 파일 크기가 같아 도장의 판별자가 mtime 하나뿐인데,
파일시스템 시각 해상도에 따라 두 쓰기가 같은 mtime을 받을 수 있다. 그러면 낡은 캐시가 살아남아 옛 리프레시
토큰으로 갱신을 보내고, GoTrue가 토큰 가족을 폐기한다(#429가 없애려던 바로 그 사슬).

getFreshDeviceSession은 락 안에서 사이클당 1회만 도니 디스크 1회 읽기가 도장 비교보다 싸고 확실하다.
loadDeviceSession의 도장 캐시는 user.id만 읽는 잦은 호출부(sync·market·auth)를 위해 그대로 둔다.

핀: 같은 크기 + utimes로 mtime까지 동일하게 만든 회전에서 새 토큰을 보내는지(윈도우 조건 결정적 재현).
캐시 복귀 변이 red. 전체 1464/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beyondworks added a commit that referenced this pull request Sep 5, 2026
…430)

* fix(runners): 호환 러너 SDK 격리를 main에 반영 + 엔드포인트 404를 "모델 문제"로 오역하던 안내 교정

실사용 제보(2026-09-05, VPS 셀프호스트): OpenRouter 턴이 "There's an issue with the selected model
(qwen/qwen3.7-max). It may not exist or you may not have access to it."로 실패. 잔액 있음, 모델을 바꿔도 동일.

- 문구 정체 확정(로컬 스텁 상태코드 대조, SDK 0.3.258): **404면 본문과 무관하게** 이 문구가 나오고,
  400은 원문(`API Error: 400 …`)이 그대로 나온다. 즉 404 전용 신호이며 모델과 무관하다. 두 모델 id는
  OpenRouter에 실재하고 우리 키로 200(스모크 재확인). CLI는 모델명을 변형 없이 보낸다(로컬 프록시 캡처).
  → 원문은 보존한 채 404 확인 순서(데이터 정책·base URL·아웃바운드 프록시)를 러너별로 덧붙인다.
- 호환 러너(openrouter·glm·kimi·grok) SDK 격리를 main에 반영(abfcf06 재상륙 — 메신저 브랜치에만 있어
  v0.1.60 발행본에 빠져 있었다). 없으면 호스트 ~/.claude 로그인 시 401, CLI 2.1 도구 검색은 비 Anthropic
  엔드포인트에서 400. 실행 경로 E2E: 격리 전 400(deferred tools) → 격리 후 400 소멸(남는 건 계정 잔액 402).

테스트 9건(404 판정·러너별 안내·chat 배선 구간 불변식 + 격리 4건), 변이 6종 red, 전체 1459/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(runners): 검수 반영 — 안내 재부착 가드·원인 단정 완화·oneshot 패리티·매처 음성 배터리·스코프 접두

- MEDIUM-1: 재시도 프레임(fresh-retry·crash-retry·인증 자가치유)이 안쪽 안내를 다시 감싸 원문이 문장 중간에서
  잘리고 안내가 두 번 붙던 결함 — `!e?.endpointNotFound` 가드(아래 인증 갈래의 !e?.authError와 같은 계약)
- MEDIUM-2: 404의 원인을 단정하지 않는다. 벤더가 모델 슬러그를 내려도 404이고(:free는 수시로 내려간다) 그때는
  모델 교체가 정답이다 — "바꿔도 소용없다"를 빼고 후보 ④(모델 회수, 다른 모델 1회 시험)를 추가
- MEDIUM-3: oneshot(온보딩·영입·루틴·기억정리)에도 같은 안내. base URL이 어긋난 셀프호스트가 가장 먼저
  만나는 화면이 여기다. 러너별 원인 대장은 보존하고 안내만 덧붙인다
- MEDIUM-4: 음성 배터리에 근접 문자열 4종 추가(매처를 "There's an issue"까지 넓히는 변이가 통과하던 구멍)
- LOW-1: 러너 env를 조립하는 테스트의 HOME 임시화 + **규칙 스위프**(목록이 아니라 규칙으로 — 새 테스트도 자동 적용)
- LOW-2: 스코프 네임스페이스 분리(ws-<id> / hostfb-<runner>) — 슬러그가 hostfb-glm인 회사가 호스트 폴백
  버킷(세션 전사본)을 함께 쓰던 충돌 차단
- LOW-3·4·5: 호스트 폴백이 배포 전역 스코프인 이유 주석, glm·kimi·grok은 미검증 축임을 명시, mkdir 실패 1회 경고

변이 10종 red(재부착 가드·단정 부활·oneshot 제거·매처 과확장·접두 제거·grok만 누락 포함), 전체 1463/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(runners): 2차 검수 잔여 — 스위프에 sdkEnvFor 추가(미탐 사각), 셀프호스트 문서에 base URL 정본 표

- 스위프 패턴에 sdkEnvFor 누락: 자격만 심고 sdkEnvFor를 부르는 테스트가 실 홈에 폴더를 만들면서도 통과했다
  (2차 검수 실측). 토큰 추가 후 usage-billing.test.mjs가 실제로 걸려 HOME 임시화까지 반영
- docs/selfhost.md: 러너 base URL 정본 4종 표 + "끝에 /v1 금지"(이번 장애의 후보 ②) + 데이터 정책·프록시 후보.
  지금까지 이 값의 유일한 출처가 오류 안내뿐이었다

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): 기기 세션 회전 경로는 캐시를 쓰지 않는다 — 도장(mtime+size)이 같아도 옛 토큰을 보내지 않게

윈도우 CI 실패로 드러난 잔여 창(#429 후속): 실운영 회전은 파일 크기가 같아 도장의 판별자가 mtime 하나뿐인데,
파일시스템 시각 해상도에 따라 두 쓰기가 같은 mtime을 받을 수 있다. 그러면 낡은 캐시가 살아남아 옛 리프레시
토큰으로 갱신을 보내고, GoTrue가 토큰 가족을 폐기한다(#429가 없애려던 바로 그 사슬).

getFreshDeviceSession은 락 안에서 사이클당 1회만 도니 디스크 1회 읽기가 도장 비교보다 싸고 확실하다.
loadDeviceSession의 도장 캐시는 user.id만 읽는 잦은 호출부(sync·market·auth)를 위해 그대로 둔다.

핀: 같은 크기 + utimes로 mtime까지 동일하게 만든 회전에서 새 토큰을 보내는지(윈도우 조건 결정적 재현).
캐시 복귀 변이 red. 전체 1464/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant