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;
|
||
|
|
};
|