feat(desktop): keep the login tokens only in the OS keychain (#105 step 2)
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
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4120ccdbcc
commit
5f3c5d370a
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Button, Text, config } from 'folds';
|
||||||
|
import { removeFallbackSession } from '../state/sessions';
|
||||||
|
import type { KeychainBootResult } from '../state/keychainSession';
|
||||||
|
import type { KeychainInvoke } from '../state/keychainMirror';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #105] Shown instead of the app when the desktop app's sign-in is kept
|
||||||
|
* in the OS keychain but couldn't be read this time. Never a silent logout:
|
||||||
|
* Retry boots again; signing in again is an explicit choice.
|
||||||
|
*/
|
||||||
|
export function KeychainUnavailable({
|
||||||
|
boot,
|
||||||
|
invoke,
|
||||||
|
}: {
|
||||||
|
boot: KeychainBootResult;
|
||||||
|
invoke: KeychainInvoke | undefined;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const missing = boot.mode === 'missing';
|
||||||
|
const signInAgain = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
removeFallbackSession();
|
||||||
|
await invoke?.('secure_session_clear').catch(() => undefined);
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="400"
|
||||||
|
style={{ minHeight: '100vh', padding: config.space.S700, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<Text size="H3">Couldn't read your sign-in</Text>
|
||||||
|
<Text size="T300" style={{ maxWidth: 460 }}>
|
||||||
|
{missing
|
||||||
|
? 'Lotus Chat keeps your sign-in in Windows Credential Manager, but the entry is missing. If you removed it, sign in again.'
|
||||||
|
: 'Lotus Chat keeps your sign-in in Windows Credential Manager and couldn’t read it just now. This is usually temporary.'}
|
||||||
|
</Text>
|
||||||
|
{boot.mode === 'error' && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{boot.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<Text size="B400">Retry</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={signInAgain}
|
||||||
|
>
|
||||||
|
<Text size="B400">Sign in again</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getFallbackSession, onSessionPersisted, Session } from './sessions';
|
import {
|
||||||
|
getFallbackSession,
|
||||||
|
onSessionPersisted,
|
||||||
|
Session,
|
||||||
|
stripTokensFromStorage,
|
||||||
|
} from './sessions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Gitea #105] Desktop, step 1: mirror the login tokens into the OS keychain.
|
* [Gitea #105] Desktop: keep the login tokens in the OS keychain.
|
||||||
*
|
*
|
||||||
* The session is still read from localStorage exactly as before; this only
|
* Step 1 mirrored them here and verified by reading back. Step 2 (sessions.ts,
|
||||||
* keeps a copy in the keychain (Windows Credential Manager) and checks it by
|
* keychainSession.ts) reads them from here at boot; once a write is verified,
|
||||||
* reading it back, so real installs prove the keychain works before step 2
|
* the tokens are dropped from localStorage. Nothing here can log anyone out:
|
||||||
* switches reads over to it. Nothing here can log anyone out: every failure
|
* a failure keeps the tokens in localStorage and records a status for
|
||||||
* just records a status for Settings.
|
* Settings.
|
||||||
*
|
*
|
||||||
* Writes are serialized (a token rotation right after login must not race
|
* 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.
|
* the login's write) and time out, so a hung credential store can't pile up.
|
||||||
@@ -122,7 +127,13 @@ export const startKeychainMirror = (invoke: KeychainInvoke | undefined): void =>
|
|||||||
started = true;
|
started = true;
|
||||||
let chain: Promise<unknown> = Promise.resolve();
|
let chain: Promise<unknown> = Promise.resolve();
|
||||||
const enqueue = (session: Session | null) => {
|
const enqueue = (session: Session | null) => {
|
||||||
chain = chain.then(async () => setStatus(await syncKeychain(invoke, session)));
|
chain = chain.then(async () => {
|
||||||
|
const result = await syncKeychain(invoke, session);
|
||||||
|
setStatus(result);
|
||||||
|
// [Gitea #105] step 2: the keychain verifiably holds these tokens, so
|
||||||
|
// localStorage can drop them (only if they're still the current ones).
|
||||||
|
if (result.state === 'ok' && session) stripTokensFromStorage(tokensOf(session));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
enqueue(getFallbackSession() ?? null);
|
enqueue(getFallbackSession() ?? null);
|
||||||
onSessionPersisted((session) => enqueue(session));
|
onSessionPersisted((session) => enqueue(session));
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -51,6 +51,21 @@ const OIDC_KEYS = {
|
|||||||
// `_v1` suffix.
|
// `_v1` suffix.
|
||||||
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
const SESSION_BLOB_KEY = 'cinny_session_v1';
|
||||||
|
|
||||||
|
// [Gitea #105] Desktop, step 2: the login tokens live in the OS keychain.
|
||||||
|
// localStorage then holds a SHELL blob (everything but the tokens, marked
|
||||||
|
// `tokensIn: 'keychain'`), and the full session is kept in memory, filled at
|
||||||
|
// boot (bootKeychainSession) before the app renders, so every reader of
|
||||||
|
// getFallbackSession stays synchronous. A new session is written to
|
||||||
|
// localStorage in full first; the tokens are stripped only once the keychain
|
||||||
|
// write was read back (stripTokensFromStorage), so at every instant one durable
|
||||||
|
// store holds the newest tokens (a token refresh invalidates the old ones).
|
||||||
|
let keychainMode = false;
|
||||||
|
let memorySession: PersistedSession | undefined;
|
||||||
|
|
||||||
|
type ShellSession = Omit<PersistedSession, 'accessToken' | 'refreshToken'> & {
|
||||||
|
tokensIn: 'keychain';
|
||||||
|
};
|
||||||
|
|
||||||
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
|
// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE
|
||||||
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
|
// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to
|
||||||
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
|
// the legacy `cinny_expires_at` semantics — so reads stay drift-free.
|
||||||
@@ -266,6 +281,7 @@ export function setFallbackSession(
|
|||||||
extra?: FallbackSessionExtra,
|
extra?: FallbackSessionExtra,
|
||||||
) {
|
) {
|
||||||
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
||||||
|
if (keychainMode) memorySession = persisted;
|
||||||
// ONE setItem — the blob can never be observed half-written.
|
// ONE setItem — the blob can never be observed half-written.
|
||||||
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
||||||
// Dual-write the legacy keys (removal of this half is a future release).
|
// Dual-write the legacy keys (removal of this half is a future release).
|
||||||
@@ -276,6 +292,7 @@ export function setFallbackSession(
|
|||||||
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
|
// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring
|
||||||
// or legacy-fallback) can resurrect a logged-out session.
|
// or legacy-fallback) can resurrect a logged-out session.
|
||||||
export const removeFallbackSession = () => {
|
export const removeFallbackSession = () => {
|
||||||
|
memorySession = undefined;
|
||||||
localStorage.removeItem(SESSION_BLOB_KEY);
|
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||||
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||||
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||||
@@ -286,7 +303,23 @@ export const removeFallbackSession = () => {
|
|||||||
// corrupt/partial we transparently assemble from the legacy keys (migration);
|
// corrupt/partial we transparently assemble from the legacy keys (migration);
|
||||||
// the next setFallbackSession then persists the blob. When both exist the blob
|
// the next setFallbackSession then persists the blob. When both exist the blob
|
||||||
// wins by construction.
|
// wins by construction.
|
||||||
|
// The stored session with its tokens, choosing blob vs legacy as below.
|
||||||
|
const readStoredPersisted = (): PersistedSession | undefined => {
|
||||||
|
const blob = readSessionBlob();
|
||||||
|
const legacy = readLegacyKeys();
|
||||||
|
if (
|
||||||
|
blob &&
|
||||||
|
legacy &&
|
||||||
|
typeof legacy.expiresAt === 'number' &&
|
||||||
|
(typeof blob.expiresAt !== 'number' || legacy.expiresAt > blob.expiresAt)
|
||||||
|
) {
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return blob ?? legacy;
|
||||||
|
};
|
||||||
|
|
||||||
export const getFallbackSession = (): Session | undefined => {
|
export const getFallbackSession = (): Session | undefined => {
|
||||||
|
if (keychainMode) return memorySession ? sessionFromPersisted(memorySession) : undefined;
|
||||||
const blob = readSessionBlob();
|
const blob = readSessionBlob();
|
||||||
const legacy = readLegacyKeys();
|
const legacy = readLegacyKeys();
|
||||||
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
// Prefer the atomic blob, EXCEPT when the legacy keys carry a later expiry: a
|
||||||
@@ -305,6 +338,112 @@ export const getFallbackSession = (): Session | undefined => {
|
|||||||
if (!persisted) return undefined;
|
if (!persisted) return undefined;
|
||||||
return sessionFromPersisted(persisted);
|
return sessionFromPersisted(persisted);
|
||||||
};
|
};
|
||||||
|
// --- [Gitea #105] desktop keychain session -----------------------------------
|
||||||
|
|
||||||
|
export type KeychainSessionTokens = {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The shell blob written while the tokens are in the keychain, if any. */
|
||||||
|
const readShell = (): Omit<ShellSession, 'tokensIn'> | undefined => {
|
||||||
|
const raw = localStorage.getItem(SESSION_BLOB_KEY);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(raw) as Partial<ShellSession>;
|
||||||
|
if (
|
||||||
|
p?.tokensIn !== 'keychain' ||
|
||||||
|
typeof p.deviceId !== 'string' ||
|
||||||
|
typeof p.userId !== 'string' ||
|
||||||
|
typeof p.baseUrl !== 'string'
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const { tokensIn: _drop, ...rest } = p as ShellSession;
|
||||||
|
return rest;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A session in localStorage that still carries its tokens (blob or legacy). */
|
||||||
|
export const readStoredSessionWithTokens = (): Session | undefined => {
|
||||||
|
const persisted = readStoredPersisted();
|
||||||
|
return persisted ? sessionFromPersisted(persisted) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether localStorage holds a token-less shell (tokens are in the keychain). */
|
||||||
|
export const hasKeychainShell = (): boolean => readShell() !== undefined;
|
||||||
|
|
||||||
|
export type KeychainBoot =
|
||||||
|
/** Use the keychain: memory holds `session` (or none = signed out). */
|
||||||
|
| { mode: 'keychain'; session: Session | undefined }
|
||||||
|
/** A shell says the tokens are in the keychain, but they can't be read. */
|
||||||
|
| { mode: 'missing'; userId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the boot session from the keychain's tokens and localStorage, and
|
||||||
|
* switch this tab to keychain mode. `tokens` is what the keychain returned
|
||||||
|
* (null = no entry). Pure apart from reading localStorage and setting the
|
||||||
|
* in-memory state.
|
||||||
|
*/
|
||||||
|
export const enterKeychainMode = (tokens: KeychainSessionTokens | null): KeychainBoot => {
|
||||||
|
const withTokens = readStoredPersisted();
|
||||||
|
const shell = readShell();
|
||||||
|
keychainMode = true;
|
||||||
|
if (withTokens) {
|
||||||
|
// Pre-step-2 install, or a write not yet confirmed: localStorage has the
|
||||||
|
// full session and stays authoritative until the mirror verifies it.
|
||||||
|
memorySession = withTokens;
|
||||||
|
return { mode: 'keychain', session: sessionFromPersisted(withTokens) };
|
||||||
|
}
|
||||||
|
if (shell) {
|
||||||
|
if (tokens && tokens.userId === shell.userId && tokens.deviceId === shell.deviceId) {
|
||||||
|
memorySession = {
|
||||||
|
...shell,
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
|
||||||
|
};
|
||||||
|
return { mode: 'keychain', session: sessionFromPersisted(memorySession) };
|
||||||
|
}
|
||||||
|
// Never treat this as a logout: the shell stays, the caller offers Retry.
|
||||||
|
memorySession = undefined;
|
||||||
|
return { mode: 'missing', userId: shell.userId };
|
||||||
|
}
|
||||||
|
memorySession = undefined;
|
||||||
|
return { mode: 'keychain', session: undefined };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Leave keychain mode (the keychain failed): localStorage is authoritative again. */
|
||||||
|
export const leaveKeychainMode = (): void => {
|
||||||
|
keychainMode = false;
|
||||||
|
memorySession = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keychain now verifiably holds `tokens`: if they're still the current
|
||||||
|
* session's, drop the tokens from localStorage (shell blob, no legacy keys).
|
||||||
|
*/
|
||||||
|
export const stripTokensFromStorage = (tokens: KeychainSessionTokens): boolean => {
|
||||||
|
const m = memorySession;
|
||||||
|
if (
|
||||||
|
!keychainMode ||
|
||||||
|
!m ||
|
||||||
|
m.userId !== tokens.userId ||
|
||||||
|
m.deviceId !== tokens.deviceId ||
|
||||||
|
m.accessToken !== tokens.accessToken ||
|
||||||
|
(m.refreshToken ?? undefined) !== (tokens.refreshToken ?? undefined)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const { accessToken: _a, refreshToken: _r, ...rest } = m;
|
||||||
|
const shell: ShellSession = { ...rest, tokensIn: 'keychain' };
|
||||||
|
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(shell));
|
||||||
|
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||||
|
Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End of migration code for old session
|
* End of migration code for old session
|
||||||
*/
|
*/
|
||||||
|
|||||||
+85
-67
@@ -18,75 +18,18 @@ import './app/i18n';
|
|||||||
import { pushSessionToSW } from './sw-session';
|
import { pushSessionToSW } from './sw-session';
|
||||||
import { getFallbackSession } from './app/state/sessions';
|
import { getFallbackSession } from './app/state/sessions';
|
||||||
import { startKeychainMirror } from './app/state/keychainMirror';
|
import { startKeychainMirror } from './app/state/keychainMirror';
|
||||||
|
import { bootKeychainSession, KeychainBootResult } from './app/state/keychainSession';
|
||||||
|
import { KeychainUnavailable } from './app/pages/KeychainUnavailable';
|
||||||
import { tauriInvoke } from './app/hooks/useTauri';
|
import { tauriInvoke } from './app/hooks/useTauri';
|
||||||
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
|
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
|
||||||
|
|
||||||
document.body.classList.add(configClass, varsClass);
|
document.body.classList.add(configClass, varsClass);
|
||||||
|
|
||||||
// [Gitea #105] Desktop: keep a copy of the login tokens in the OS keychain
|
// [Gitea #105] Desktop: the login tokens live in the OS keychain. Read them
|
||||||
// (step 1: mirror only; the session is still read from localStorage).
|
// into memory BEFORE anything reads the session (step 2), then keep the
|
||||||
startKeychainMirror(tauriInvoke());
|
// keychain in line with every login / refresh / logout. If the keychain should
|
||||||
|
// hold them but can't be read, show Retry instead of the app, and don't start
|
||||||
// Register Service Worker
|
// the mirror: it would clear the entry it couldn't read.
|
||||||
// Service workers only register on http(s) pages. The desktop app loads from
|
|
||||||
// `tauri://localhost` in debug builds (and on any platform where the localhost
|
|
||||||
// plugin isn't used), where register() rejects: that surfaced in Sentry as an
|
|
||||||
// unhandled "must be called with a script URL whose protocol is either HTTP or
|
|
||||||
// HTTPS". Skip it there; the app works without a SW, only authenticated media
|
|
||||||
// falls back to the client's own fetch.
|
|
||||||
const swProtocolOk = window.location.protocol === 'https:' || window.location.protocol === 'http:';
|
|
||||||
if ('serviceWorker' in navigator && swProtocolOk) {
|
|
||||||
const swUrl =
|
|
||||||
import.meta.env.MODE === 'production'
|
|
||||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
|
||||||
: `/dev-sw.js?dev-sw`;
|
|
||||||
|
|
||||||
const sendSessionToSW = () => {
|
|
||||||
const session = getFallbackSession();
|
|
||||||
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The dev worker is an ES module (it imports workbox), so it must be
|
|
||||||
// registered as one — otherwise "script evaluation failed" and the dev
|
|
||||||
// client has no SW: authenticated media 401s and notification clicks
|
|
||||||
// never route. Production sw.js is a classic bundled script.
|
|
||||||
navigator.serviceWorker
|
|
||||||
.register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' })
|
|
||||||
.then(sendSessionToSW)
|
|
||||||
.catch((err) => {
|
|
||||||
// e.g. a private window with SWs disabled; the app still works.
|
|
||||||
console.warn('Service worker registration failed', err);
|
|
||||||
});
|
|
||||||
navigator.serviceWorker.ready.then(sendSessionToSW);
|
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', (ev) => {
|
|
||||||
const { type } = ev.data ?? {};
|
|
||||||
|
|
||||||
if (type === 'requestSession') {
|
|
||||||
sendSessionToSW();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request persistent storage so the browser can't evict the IndexedDB
|
|
||||||
// rust-crypto store under storage pressure. Eviction (while the localStorage
|
|
||||||
// session/device-id survives) resurrects the device with a blank crypto store,
|
|
||||||
// which then re-uploads OTKs the server already holds → the "one time key
|
|
||||||
// already exists" upload storm and E2EE breakage. Only ask for sessions worth
|
|
||||||
// protecting (skip anonymous/landing visitors to avoid a needless Firefox
|
|
||||||
// prompt); check persisted() first so we don't re-prompt. Best-effort.
|
|
||||||
// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another
|
|
||||||
// tab still held the DB), finish it now that no session exists.
|
|
||||||
if (!getFallbackSession()) {
|
|
||||||
cleanupSearchCacheIfSignedOut().catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigator.storage?.persist && getFallbackSession()) {
|
|
||||||
navigator.storage
|
|
||||||
.persisted()
|
|
||||||
.then((already) => (already ? undefined : navigator.storage.persist()))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload once if a lazy-loaded chunk is missing (stale deployment)
|
// Reload once if a lazy-loaded chunk is missing (stale deployment)
|
||||||
window.addEventListener('vite:preloadError', () => {
|
window.addEventListener('vite:preloadError', () => {
|
||||||
@@ -121,7 +64,72 @@ window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload-at
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mountApp = () => {
|
// Everything that reads the session runs once the keychain step decided it.
|
||||||
|
const startSessionDependent = () => {
|
||||||
|
// Register Service Worker
|
||||||
|
// Service workers only register on http(s) pages. The desktop app loads from
|
||||||
|
// `tauri://localhost` in debug builds (and on any platform where the localhost
|
||||||
|
// plugin isn't used), where register() rejects: that surfaced in Sentry as an
|
||||||
|
// unhandled "must be called with a script URL whose protocol is either HTTP or
|
||||||
|
// HTTPS". Skip it there; the app works without a SW, only authenticated media
|
||||||
|
// falls back to the client's own fetch.
|
||||||
|
const swProtocolOk =
|
||||||
|
window.location.protocol === 'https:' || window.location.protocol === 'http:';
|
||||||
|
if ('serviceWorker' in navigator && swProtocolOk) {
|
||||||
|
const swUrl =
|
||||||
|
import.meta.env.MODE === 'production'
|
||||||
|
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||||
|
: `/dev-sw.js?dev-sw`;
|
||||||
|
|
||||||
|
const sendSessionToSW = () => {
|
||||||
|
const session = getFallbackSession();
|
||||||
|
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dev worker is an ES module (it imports workbox), so it must be
|
||||||
|
// registered as one — otherwise "script evaluation failed" and the dev
|
||||||
|
// client has no SW: authenticated media 401s and notification clicks
|
||||||
|
// never route. Production sw.js is a classic bundled script.
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl, import.meta.env.MODE === 'production' ? undefined : { type: 'module' })
|
||||||
|
.then(sendSessionToSW)
|
||||||
|
.catch((err) => {
|
||||||
|
// e.g. a private window with SWs disabled; the app still works.
|
||||||
|
console.warn('Service worker registration failed', err);
|
||||||
|
});
|
||||||
|
navigator.serviceWorker.ready.then(sendSessionToSW);
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('message', (ev) => {
|
||||||
|
const { type } = ev.data ?? {};
|
||||||
|
|
||||||
|
if (type === 'requestSession') {
|
||||||
|
sendSessionToSW();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request persistent storage so the browser can't evict the IndexedDB
|
||||||
|
// rust-crypto store under storage pressure. Eviction (while the localStorage
|
||||||
|
// session/device-id survives) resurrects the device with a blank crypto store,
|
||||||
|
// which then re-uploads OTKs the server already holds → the "one time key
|
||||||
|
// already exists" upload storm and E2EE breakage. Only ask for sessions worth
|
||||||
|
// protecting (skip anonymous/landing visitors to avoid a needless Firefox
|
||||||
|
// prompt); check persisted() first so we don't re-prompt. Best-effort.
|
||||||
|
// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another
|
||||||
|
// tab still held the DB), finish it now that no session exists.
|
||||||
|
if (!getFallbackSession()) {
|
||||||
|
cleanupSearchCacheIfSignedOut().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.storage?.persist && getFallbackSession()) {
|
||||||
|
navigator.storage
|
||||||
|
.persisted()
|
||||||
|
.then((already) => (already ? undefined : navigator.storage.persist()))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountApp = (keychainBoot: KeychainBootResult, keychainBlocked: boolean) => {
|
||||||
const rootContainer = document.getElementById('root');
|
const rootContainer = document.getElementById('root');
|
||||||
|
|
||||||
if (rootContainer === null) {
|
if (rootContainer === null) {
|
||||||
@@ -130,7 +138,17 @@ const mountApp = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = createRoot(rootContainer);
|
const root = createRoot(rootContainer);
|
||||||
root.render(<App />);
|
root.render(
|
||||||
|
keychainBlocked ? <KeychainUnavailable boot={keychainBoot} invoke={tauriInvoke()} /> : <App />,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
mountApp();
|
bootKeychainSession(tauriInvoke())
|
||||||
|
// Whatever happens, the app mounts: an unexpected failure means "no keychain".
|
||||||
|
.catch((): KeychainBootResult => ({ mode: 'off' }))
|
||||||
|
.then((keychainBoot) => {
|
||||||
|
const keychainBlocked = keychainBoot.mode === 'missing' || keychainBoot.mode === 'error';
|
||||||
|
if (!keychainBlocked) startKeychainMirror(tauriInvoke());
|
||||||
|
startSessionDependent();
|
||||||
|
mountApp(keychainBoot, keychainBlocked);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user