// src/main/services/sync/session-token-store.ts // refresh token 영속화(OS 암호화 저장소 + 파일). Supabase·Electron을 직접 알지 않도록 포트로 받는다. // // supabase-js는 메인 프로세스에서 refresh token을 약 1시간마다 회전시킨다. 회전된 토큰을 저장하지 않으면 // 재시작 때 이미 쓰인 토큰으로 복원하다 로그아웃된다 — bindAuthEvents 가 회전 이벤트마다 저장한다. export interface TokenCipher { isEncryptionAvailable(): boolean encryptString(plain: string): Buffer decryptString(encrypted: Buffer): string } export interface TokenFileSystem { existsSync(path: string): boolean readFileSync(path: string): Buffer writeFileSync(path: string, data: Buffer): void unlinkSync(path: string): void } export interface SessionTokenStoreLogger { warn(message: string): void } export interface SessionTokenStore { load(): string | null save(refreshToken: string): void clear(): void } export class EncryptedFileTokenStore implements SessionTokenStore { constructor( private readonly filePath: string, private readonly cipher: TokenCipher, private readonly fs: TokenFileSystem, private readonly logger: SessionTokenStoreLogger ) {} load(): string | null { try { if (!this.fs.existsSync(this.filePath)) return null if (!this.cipher.isEncryptionAvailable()) return null return this.cipher.decryptString(this.fs.readFileSync(this.filePath)) } catch (err) { this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`) return null } } save(refreshToken: string): void { try { if (!refreshToken) return if (this.cipher.isEncryptionAvailable()) { this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken)) } } catch (err) { this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`) } } clear(): void { try { if (this.fs.existsSync(this.filePath)) this.fs.unlinkSync(this.filePath) } catch { // 지울 파일이 없거나 잠겨 있어도 로그아웃은 계속한다. } } } /** supabase auth 이벤트 중 세션 회전을 뜻하는 것 */ const ROTATION_EVENTS = new Set(['TOKEN_REFRESHED', 'SIGNED_IN', 'USER_UPDATED']) export interface AuthSessionLike { refresh_token: string user: { id: string } } export type AuthStateSubscribe = ( callback: (event: string, session: S | null) => void ) => { unsubscribe(): void } /** * auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다. * - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시). * - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다. * onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다. */ export function bindAuthEvents( subscribe: AuthStateSubscribe, store: SessionTokenStore, activeUserId: () => string | null, onSession: (session: S) => void ): () => void { const subscription = subscribe((event, session) => { if (!session || !ROTATION_EVENTS.has(event)) return const userId = activeUserId() if (!userId || session.user.id !== userId) return store.save(session.refresh_token) onSession(session) }) return () => subscription.unsubscribe() }