CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 8m44s
Step 1 of moving the desktop app's login out of the webview's plaintext localStorage: keep a verified copy of the tokens in the OS keychain (Windows Credential Manager, via cinny-desktop's new secure_session_* commands). The session is still read from localStorage exactly as before, so nothing about login changes and a keychain problem can't log anyone out. Step 2 (a later release, once this has run on real installs) switches reads to the keychain and drops the tokens from localStorage. - sessions.ts: onSessionPersisted — listeners told about every session write (login, token rotation) and removal (logout); a throwing listener can't break the write. - keychainMirror.ts: desktop only. Mirrors userId/deviceId/accessToken/ refreshToken (not the rest of the session); reads first and writes only when the copy differs, then verifies by reading back; serialized, 5 s timeouts; no session → clear (also covers a logout whose reload beat the clear). Every failure is a status, never an exception. A desktop build without the commands reads as "unsupported", so this can ship before the desktop side. - Settings → General (desktop): "Login in the system keychain" status. Tested: unit tests (fake keychain: store, no rewrite when current, rotation, clear, unsupported, missing commands, denied write, read-back mismatch, timeout); a simulated desktop app with a fake keychain, 14/14 (login mirrors only the secrets, Settings status, reload verifies without rewriting, logout clears, an existing session is mirrored after upgrade, Linux/denied show an honest status and stay logged in); the real Linux desktop binary (commands answer "unsupported", login unaffected, Settings says so). Unit 1295 pass, Playwright 20 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
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<string, unknown>) => Promise<unknown>;
|
|
|
|
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<KeychainTokens>;
|
|
return (
|
|
t.userId === b.userId &&
|
|
t.deviceId === b.deviceId &&
|
|
t.accessToken === b.accessToken &&
|
|
(t.refreshToken ?? undefined) === (b.refreshToken ?? undefined)
|
|
);
|
|
};
|
|
|
|
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
|
|
new Promise<T>((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<KeychainMirrorStatus> => {
|
|
// 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<unknown> = 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;
|
|
};
|