101 lines
3.4 KiB
TypeScript
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()
|
|
}
|