CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m30s
On the desktop app with a working keychain (Windows Credential Manager), the tokens leave localStorage: it keeps a token-less shell blob (tokensIn: keychain) and the full session lives in memory, read from the keychain at boot before anything reads the session, so getFallbackSession stays synchronous. Web, Linux and older desktop builds are unchanged. - A new session (login, token refresh) is written to localStorage in full first; the mirror verifies the keychain copy and only then strips the tokens, so one durable store always holds the newest tokens. - Existing installs migrate on first boot the same way; a failing keychain write keeps the tokens in localStorage. - If a shell is there but the keychain entry is missing or unreadable, the app shows Retry / Sign in again instead of logging out, and the mirror is not started (it would clear the entry). Fixes #105 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
77 lines
2.6 KiB
TypeScript
77 lines
2.6 KiB
TypeScript
import {
|
|
enterKeychainMode,
|
|
hasKeychainShell,
|
|
KeychainBoot,
|
|
KeychainSessionTokens,
|
|
} from './sessions';
|
|
import { KEYCHAIN_TIMEOUT_MS, KeychainInvoke } from './keychainMirror';
|
|
|
|
/**
|
|
* [Gitea #105] Desktop, step 2: at boot, before the app renders, read the login
|
|
* tokens from the OS keychain into memory (see sessions.ts). Returns:
|
|
* - `off`: not the desktop app, or no keychain here (Linux, older builds) —
|
|
* localStorage stays authoritative, exactly as before;
|
|
* - a KeychainBoot from enterKeychainMode: `keychain` (go on), or `missing`
|
|
* (localStorage says the tokens are in the keychain but they can't be read;
|
|
* the caller shows Retry / Sign in again and must NOT start the mirror,
|
|
* which would clear the keychain entry it couldn't read).
|
|
*/
|
|
export type KeychainBootResult = { mode: 'off' } | KeychainBoot | { mode: 'error'; error: string };
|
|
|
|
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 asTokens = (v: unknown): KeychainSessionTokens | null => {
|
|
if (!v || typeof v !== 'object') return null;
|
|
const t = v as Partial<KeychainSessionTokens>;
|
|
if (typeof t.userId !== 'string' || typeof t.deviceId !== 'string') return null;
|
|
if (typeof t.accessToken !== 'string') return null;
|
|
return {
|
|
userId: t.userId,
|
|
deviceId: t.deviceId,
|
|
accessToken: t.accessToken,
|
|
...(typeof t.refreshToken === 'string' ? { refreshToken: t.refreshToken } : {}),
|
|
};
|
|
};
|
|
|
|
export async function bootKeychainSession(
|
|
invoke: KeychainInvoke | undefined,
|
|
timeoutMs = KEYCHAIN_TIMEOUT_MS,
|
|
): Promise<KeychainBootResult> {
|
|
if (!invoke) return { mode: 'off' };
|
|
let supported: unknown;
|
|
try {
|
|
supported = await withTimeout(invoke('secure_session_supported'), timeoutMs);
|
|
} catch {
|
|
supported = false;
|
|
}
|
|
if (supported !== true) {
|
|
// A shell without a keychain to read from can only mean the store went
|
|
// away (e.g. a downgrade of the desktop build): say so, don't log out.
|
|
return hasKeychainShell()
|
|
? { mode: 'error', error: 'keychain not available' }
|
|
: { mode: 'off' };
|
|
}
|
|
let stored: unknown;
|
|
try {
|
|
stored = await withTimeout(invoke('secure_session_get'), timeoutMs);
|
|
} catch (e) {
|
|
if (hasKeychainShell())
|
|
return { mode: 'error', error: (e instanceof Error ? e.message : String(e)).slice(0, 200) };
|
|
return { mode: 'off' };
|
|
}
|
|
return enterKeychainMode(asTokens(stored));
|
|
}
|