Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ tokio = { version = "1", features = ["process", "sync", "time", "fs"] }
log = "0.4"
walkdir = "2"
rusqlite = { version = "0.31", features = ["bundled"] }
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }

[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
Expand Down
32 changes: 32 additions & 0 deletions src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
// We use the SQL plugin from the frontend for most DB operations,
// but this command provides UUID generation for auth records.

use keyring::Entry;

const KEYCHAIN_SERVICE: &str = "app.sustn.desktop";
const KEYCHAIN_USER: &str = "github_access_token";

fn get_entry() -> Result<Entry, String> {
Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER).map_err(|e| format!("keychain init error: {e}"))
}

#[tauri::command]
pub fn generate_auth_id() -> String {
uuid::Uuid::new_v4().to_string()
}

#[tauri::command]
pub fn keychain_set_token(token: String) -> Result<(), String> {
get_entry()?.set_password(&token).map_err(|e| format!("keychain write error: {e}"))
}

#[tauri::command]
pub fn keychain_get_token() -> Result<Option<String>, String> {
match get_entry()?.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("keychain read error: {e}")),
}
}

#[tauri::command]
pub fn keychain_delete_token() -> Result<(), String> {
match get_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("keychain delete error: {e}")),
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ pub fn run() {
command::greet,
command::open_in_app,
auth::generate_auth_id,
auth::keychain_set_token,
auth::keychain_get_token,
auth::keychain_delete_token,
preflight::check_git_installed,
preflight::check_claude_installed,
preflight::check_claude_authenticated,
Expand Down
37 changes: 27 additions & 10 deletions src/core/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,37 @@ async function getDb() {
return await Database.load(config.dbUrl);
}

function rowToRecord(row: AuthRow): AuthRecord {
export async function getAuth(): Promise<AuthRecord | undefined> {
const db = await getDb();
const rows = await db.select<AuthRow[]>("SELECT * FROM auth LIMIT 1");
if (rows.length === 0) return undefined;

const row = rows[0];

// Migrate plaintext token from SQLite to OS keychain on first read
if (row.github_access_token) {
await invoke("keychain_set_token", {
token: row.github_access_token,
});
await db.execute(
"UPDATE auth SET github_access_token = '' WHERE id = $1",
[row.id],
);
}

const token = await invoke<string | null>("keychain_get_token");
if (!token) return undefined;

return {
id: row.id,
githubId: row.github_id,
username: row.github_username,
avatarUrl: row.github_avatar_url ?? undefined,
email: row.github_email ?? undefined,
accessToken: row.github_access_token,
accessToken: token,
};
}

export async function getAuth(): Promise<AuthRecord | undefined> {
const db = await getDb();
const rows = await db.select<AuthRow[]>("SELECT * FROM auth LIMIT 1");
if (rows.length === 0) return undefined;
return rowToRecord(rows[0]);
}

export async function saveAuth(params: {
githubId: number;
username: string;
Expand All @@ -57,6 +70,9 @@ export async function saveAuth(params: {
// Delete any existing auth record (single-user app)
await db.execute("DELETE FROM auth");

// Store token in OS keychain, not in SQLite
await invoke("keychain_set_token", { token: params.accessToken });

await db.execute(
`INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email, github_access_token)
VALUES ($1, $2, $3, $4, $5, $6)`,
Expand All @@ -66,12 +82,13 @@ export async function saveAuth(params: {
params.username,
params.avatarUrl ?? null,
params.email ?? null,
params.accessToken,
"", // empty — token is in OS keychain
],
);
}

export async function clearAuth(): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM auth");
await invoke("keychain_delete_token");
}
Loading