d3ro-voice/apps/desktop/src/main/services/sync/session-token-store.ts

101 lines
3.4 KiB
TypeScript

// 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<S extends AuthSessionLike> = (
callback: (event: string, session: S | null) => void
) => { unsubscribe(): void }
/**
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
*/
export function bindAuthEvents<S extends AuthSessionLike>(
subscribe: AuthStateSubscribe<S>,
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()
}