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
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
// localStorage stand-in (these modules read it at call time).
|
|
const store = new Map<string, string>();
|
|
(globalThis as { localStorage?: unknown }).localStorage = {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => {
|
|
store.set(k, v);
|
|
},
|
|
removeItem: (k: string) => {
|
|
store.delete(k);
|
|
},
|
|
};
|
|
|
|
const sessions = await import('./sessions');
|
|
const { bootKeychainSession } = await import('./keychainSession');
|
|
const { syncKeychain, tokensOf } = await import('./keychainMirror');
|
|
|
|
type Invoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
const keychain = (opts: { getThrows?: boolean; setThrows?: boolean } = {}) => {
|
|
let stored: unknown = null;
|
|
const invoke: Invoke = async (cmd, args) => {
|
|
if (cmd === 'secure_session_supported') return true;
|
|
if (cmd === 'secure_session_get') {
|
|
if (opts.getThrows) throw new Error('Element not found.');
|
|
return stored;
|
|
}
|
|
if (cmd === 'secure_session_set') {
|
|
if (opts.setThrows) throw new Error('Access is denied.');
|
|
stored = args?.tokens;
|
|
return null;
|
|
}
|
|
if (cmd === 'secure_session_clear') {
|
|
stored = null;
|
|
return null;
|
|
}
|
|
throw new Error(cmd);
|
|
};
|
|
return {
|
|
invoke,
|
|
get: () => stored,
|
|
set: (v: unknown) => {
|
|
stored = v;
|
|
},
|
|
};
|
|
};
|
|
const blob = () => JSON.parse(store.get('cinny_session_v1') ?? 'null');
|
|
const tokensOnDisk = () =>
|
|
[...store.entries()].some(([, v]) => v.includes('syt_') || v.includes('mar_'));
|
|
const login = (access: string, refresh?: string) =>
|
|
sessions.setFallbackSession(access, 'DEV1', '@alice:example.org', 'https://hs.example', {
|
|
refreshToken: refresh,
|
|
expiresInMs: 300_000,
|
|
});
|
|
/** A fresh page load: drop the in-memory state, keep storage and keychain. */
|
|
const reload = () => sessions.leaveKeychainMode();
|
|
/** What the mirror does after a write: verify in the keychain, then strip. */
|
|
const mirror = async (invoke: Invoke) => {
|
|
const s = sessions.getFallbackSession() ?? null;
|
|
const r = await syncKeychain(invoke, s);
|
|
if (r.state === 'ok' && s) sessions.stripTokensFromStorage(tokensOf(s));
|
|
return r;
|
|
};
|
|
|
|
test('[#105] web / no keychain: localStorage stays authoritative', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_web', 'mar_web');
|
|
assert.deepEqual(await bootKeychainSession(undefined), { mode: 'off' });
|
|
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_web');
|
|
assert.equal(blob().accessToken, 'syt_web');
|
|
});
|
|
|
|
test('[#105] migration: existing install moves its tokens to the keychain, then off disk', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_old', 'mar_old'); // a pre-step-2 install
|
|
const kc = keychain();
|
|
const boot = await bootKeychainSession(kc.invoke);
|
|
assert.equal(boot.mode, 'keychain');
|
|
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old');
|
|
assert.equal(tokensOnDisk(), true, 'not stripped before the keychain is verified');
|
|
assert.equal((await mirror(kc.invoke)).state, 'ok');
|
|
assert.equal(tokensOnDisk(), false, 'tokens gone from localStorage');
|
|
assert.equal(blob().tokensIn, 'keychain');
|
|
assert.equal(store.get('cinny_access_token'), undefined);
|
|
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_old', 'still signed in');
|
|
|
|
// Next launch: shell + keychain -> the same session.
|
|
reload();
|
|
const again = await bootKeychainSession(kc.invoke);
|
|
assert.equal(again.mode, 'keychain');
|
|
const s = sessions.getFallbackSession();
|
|
assert.equal(s?.accessToken, 'syt_old');
|
|
assert.equal(s?.refreshToken, 'mar_old');
|
|
assert.equal(s?.baseUrl, 'https://hs.example');
|
|
assert.ok((s?.expiresInMs ?? 0) > 0, 'expiry kept in the shell');
|
|
});
|
|
|
|
test('[#105] token refresh: written in full first, stripped once the keychain has it', async () => {
|
|
// continues from the previous state: keychain mode, shell on disk
|
|
const kc = keychain();
|
|
kc.set({
|
|
userId: '@alice:example.org',
|
|
deviceId: 'DEV1',
|
|
accessToken: 'syt_old',
|
|
refreshToken: 'mar_old',
|
|
});
|
|
login('syt_new', 'mar_new');
|
|
assert.equal(blob().accessToken, 'syt_new', 'durable on disk until the keychain confirms');
|
|
assert.equal(sessions.getFallbackSession()?.accessToken, 'syt_new');
|
|
await mirror(kc.invoke);
|
|
assert.equal(tokensOnDisk(), false);
|
|
assert.equal((kc.get() as { accessToken: string }).accessToken, 'syt_new');
|
|
});
|
|
|
|
test('[#105] a failing keychain write keeps the tokens in localStorage', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_keep', 'mar_keep');
|
|
const kc = keychain({ setThrows: true });
|
|
await bootKeychainSession(kc.invoke);
|
|
assert.equal((await mirror(kc.invoke)).state, 'error');
|
|
assert.equal(blob().accessToken, 'syt_keep', 'nothing stripped');
|
|
reload();
|
|
await bootKeychainSession(kc.invoke);
|
|
assert.equal(
|
|
sessions.getFallbackSession()?.accessToken,
|
|
'syt_keep',
|
|
'still signed in after a restart',
|
|
);
|
|
});
|
|
|
|
test('[#105] shell but no keychain entry: "missing", never a silent logout', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_x');
|
|
const kc = keychain();
|
|
await bootKeychainSession(kc.invoke);
|
|
await mirror(kc.invoke);
|
|
kc.set(null); // someone deleted the Credential Manager entry
|
|
reload();
|
|
const boot = await bootKeychainSession(kc.invoke);
|
|
assert.deepEqual(boot, { mode: 'missing', userId: '@alice:example.org' });
|
|
assert.equal(blob().tokensIn, 'keychain', 'shell kept for Retry');
|
|
});
|
|
|
|
test('[#105] shell but the keychain read fails: "error", shell kept', async () => {
|
|
// continues: shell on disk
|
|
reload();
|
|
const boot = await bootKeychainSession(keychain({ getThrows: true }).invoke);
|
|
assert.equal(boot.mode, 'error');
|
|
assert.equal(blob().tokensIn, 'keychain');
|
|
});
|
|
|
|
test('[#105] a shell for another device never pairs with the keychain', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_a');
|
|
const kc = keychain();
|
|
await bootKeychainSession(kc.invoke);
|
|
await mirror(kc.invoke);
|
|
kc.set({ userId: '@alice:example.org', deviceId: 'OTHER', accessToken: 'syt_b' });
|
|
reload();
|
|
assert.equal((await bootKeychainSession(kc.invoke)).mode, 'missing');
|
|
});
|
|
|
|
test('[#105] logout clears memory, disk and (via the mirror) the keychain', async () => {
|
|
store.clear();
|
|
reload();
|
|
login('syt_bye', 'mar_bye');
|
|
const kc = keychain();
|
|
await bootKeychainSession(kc.invoke);
|
|
await mirror(kc.invoke);
|
|
sessions.removeFallbackSession();
|
|
assert.equal(sessions.getFallbackSession(), undefined);
|
|
assert.equal(store.size, 0);
|
|
await mirror(kc.invoke);
|
|
assert.equal(kc.get(), null);
|
|
reload();
|
|
assert.deepEqual(await bootKeychainSession(kc.invoke), { mode: 'keychain', session: undefined });
|
|
});
|