feat(desktop): mirror the login tokens into the OS keychain (#105, step 1)
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
91f82d60e3
commit
9b9f33b270
@@ -0,0 +1,141 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user