import { useEffect, useState } from 'react'; import { getFallbackSession, onSessionPersisted, Session } from './sessions'; /** * [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain. * * The session is still read from localStorage exactly as before; this only * keeps a copy in the keychain (Windows Credential Manager) and checks it by * reading it back, so real installs prove the keychain works before step 2 * switches reads over to it. Nothing here can log anyone out: every failure * just records a status for Settings. * * Writes are serialized (a token rotation right after login must not race * the login's write) and time out, so a hung credential store can't pile up. * When there's no session the keychain entry is cleared, which also covers a * logout whose page reload beat the clear. */ export type KeychainTokens = { userId: string; deviceId: string; accessToken: string; refreshToken?: string; }; export type KeychainMirrorStatus = | { state: 'idle' } | { state: 'unsupported' } | { state: 'ok' } | { state: 'cleared' } | { state: 'error'; error: string }; export type KeychainInvoke = (cmd: string, args?: Record) => Promise; export const KEYCHAIN_TIMEOUT_MS = 5000; export const tokensOf = (session: Session): KeychainTokens => ({ userId: session.userId, deviceId: session.deviceId, accessToken: session.accessToken, ...(session.refreshToken ? { refreshToken: session.refreshToken } : {}), }); export const sameTokens = (a: unknown, b: KeychainTokens): boolean => { if (!a || typeof a !== 'object') return false; const t = a as Partial; return ( t.userId === b.userId && t.deviceId === b.deviceId && t.accessToken === b.accessToken && (t.refreshToken ?? undefined) === (b.refreshToken ?? undefined) ); }; const withTimeout = (p: Promise, ms: number): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('keychain timed out')), ms); p.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); }, ); }); const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)).slice(0, 200); /** * Bring the keychain in line with `session` (null = no session). Reads first * and only writes when the stored copy differs, then verifies by reading back. */ export const syncKeychain = async ( invoke: KeychainInvoke, session: Session | null, timeoutMs = KEYCHAIN_TIMEOUT_MS, ): Promise => { // A desktop build without the commands (older than this feature) rejects // the call: that is "not supported", not an error to show the user. let supported: unknown; try { supported = await withTimeout(invoke('secure_session_supported'), timeoutMs); } catch { supported = false; } if (supported !== true) return { state: 'unsupported' }; try { if (!session) { await withTimeout(invoke('secure_session_clear'), timeoutMs); return { state: 'cleared' }; } const tokens = tokensOf(session); const stored = await withTimeout(invoke('secure_session_get'), timeoutMs); if (sameTokens(stored, tokens)) return { state: 'ok' }; await withTimeout(invoke('secure_session_set', { tokens }), timeoutMs); const back = await withTimeout(invoke('secure_session_get'), timeoutMs); if (!sameTokens(back, tokens)) return { state: 'error', error: 'read-back did not match' }; return { state: 'ok' }; } catch (e) { return { state: 'error', error: errorText(e) }; } }; let status: KeychainMirrorStatus = { state: 'idle' }; const statusListeners = new Set<(s: KeychainMirrorStatus) => void>(); const setStatus = (next: KeychainMirrorStatus): void => { status = next; statusListeners.forEach((cb) => cb(next)); }; export const getKeychainMirrorStatus = (): KeychainMirrorStatus => status; let started = false; /** Start mirroring (desktop only; call once at boot). */ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void => { if (!invoke || started) return; started = true; let chain: Promise = Promise.resolve(); const enqueue = (session: Session | null) => { chain = chain.then(async () => setStatus(await syncKeychain(invoke, session))); }; enqueue(getFallbackSession() ?? null); onSessionPersisted((session) => enqueue(session)); }; export const useKeychainMirrorStatus = (): KeychainMirrorStatus => { const [value, setValue] = useState(status); useEffect(() => { setValue(status); statusListeners.add(setValue); return () => { statusListeners.delete(setValue); }; }, []); return value; };