diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a6f3e6..fd3b274 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -506,7 +506,7 @@ dependencies = [ "bitflags 2.11.0", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "foreign-types", "libc", @@ -521,7 +521,7 @@ checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ "bitflags 2.11.0", "block", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "objc", ] @@ -587,6 +587,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -610,7 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -623,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -2178,6 +2188,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2293,6 +2318,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3815,7 +3850,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3833,7 +3868,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3842,7 +3877,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3952,6 +3987,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3959,7 +4007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4636,6 +4684,7 @@ dependencies = [ "chrono", "cocoa", "dirs", + "keyring", "log", "objc", "rusqlite", @@ -4742,7 +4791,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c78392..ab84f39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 79d11ab..65dadd3 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,7 +1,99 @@ // We use the SQL plugin from the frontend for most DB operations, -// but this command provides UUID generation for auth records. +// but this module also provides keyring-backed credential storage +// for the GitHub access token and UUID generation for auth records. + +use std::path::Path; + +use keyring::Entry; +use rusqlite::Connection; + +const KEYRING_SERVICE: &str = "dev.sustn.app"; +const KEYRING_USER: &str = "github_access_token"; + +fn token_entry() -> Result { + Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| format!("keyring error: {e}")) +} + +/// Migrate an existing GitHub access token from the SQLite `auth` table +/// into the OS credential store. Must run **before** the SQL migration that +/// runs `ALTER TABLE auth DROP COLUMN github_access_token` (migration 13 in +/// `migrations.rs`), otherwise the token is lost. +/// +/// This is intentionally lenient: if the DB doesn't exist, the column is +/// already gone, or the table is empty, it simply returns Ok(()). +pub fn migrate_token_to_keyring(db_path: &Path) { + if !db_path.exists() { + return; + } + + let conn = match Connection::open(db_path) { + Ok(c) => c, + Err(e) => { + eprintln!("[auth] migrate_token_to_keyring — failed to open DB: {e}"); + return; + } + }; + + // Check whether the column still exists (idempotent). + let has_column: bool = conn + .prepare("SELECT github_access_token FROM auth LIMIT 0") + .is_ok(); + + if !has_column { + return; // Column already dropped — nothing to migrate. + } + + let token: Option = conn + .query_row( + "SELECT github_access_token FROM auth LIMIT 1", + [], + |row| row.get(0), + ) + .ok(); + + if let Some(ref token) = token { + if !token.is_empty() { + match token_entry().and_then(|e| { + e.set_password(token) + .map_err(|e| format!("failed to store token: {e}")) + }) { + Ok(()) => { + println!("[auth] migrated GitHub token to OS credential store"); + } + Err(e) => { + eprintln!("[auth] migrate_token_to_keyring — keyring error: {e}"); + // Don't proceed — keep the token in the DB so we can retry. + return; + } + } + } + } +} #[tauri::command] pub fn generate_auth_id() -> String { uuid::Uuid::new_v4().to_string() } + +#[tauri::command] +pub fn set_github_token(token: String) -> Result<(), String> { + token_entry()?.set_password(&token).map_err(|e| format!("failed to store token: {e}")) +} + +#[tauri::command] +pub fn get_github_token() -> Result, String> { + match token_entry()?.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("failed to retrieve token: {e}")), + } +} + +#[tauri::command] +pub fn clear_github_token() -> Result<(), String> { + match token_entry()?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), // already gone — not an error + Err(e) => Err(format!("failed to clear token: {e}")), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2afac0d..5b227d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,13 @@ const DB_URL: &str = "sqlite:sustn.db"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Migrate any existing GitHub token from SQLite → OS credential store + // before the SQL plugin applies migration 13 (which drops the column). + if let Some(data_dir) = dirs::data_dir() { + let db_path = data_dir.join("app.sustn.desktop").join("sustn.db"); + auth::migrate_token_to_keyring(&db_path); + } + let migrations = migrations::migrations(); let engine_state = engine::EngineState::new(); @@ -73,6 +80,9 @@ pub fn run() { command::greet, command::open_in_app, auth::generate_auth_id, + auth::set_github_token, + auth::get_github_token, + auth::clear_github_token, preflight::check_git_installed, preflight::check_claude_installed, preflight::check_claude_authenticated, diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 318dd1d..b949162 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -251,5 +251,13 @@ pub fn migrations() -> Vec { "#, kind: MigrationKind::Up, }, + Migration { + version: 13, + description: "move github_access_token to OS credential store", + sql: r#" + ALTER TABLE auth DROP COLUMN github_access_token; + "#, + kind: MigrationKind::Up, + }, ] } diff --git a/src/core/db/auth.ts b/src/core/db/auth.ts index 126d25f..4fd2b7f 100644 --- a/src/core/db/auth.ts +++ b/src/core/db/auth.ts @@ -8,7 +8,6 @@ interface AuthRow { github_username: string; github_avatar_url: string | null; github_email: string | null; - github_access_token: string; created_at: string; updated_at: string; } @@ -26,14 +25,14 @@ async function getDb() { return await Database.load(config.dbUrl); } -function rowToRecord(row: AuthRow): AuthRecord { +function rowToRecord(row: AuthRow, accessToken: string): AuthRecord { 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, }; } @@ -41,7 +40,11 @@ export async function getAuth(): Promise { const db = await getDb(); const rows = await db.select("SELECT * FROM auth LIMIT 1"); if (rows.length === 0) return undefined; - return rowToRecord(rows[0]); + + const token = await invoke("get_github_token"); + if (!token) return undefined; + + return rowToRecord(rows[0], token); } export async function saveAuth(params: { @@ -54,19 +57,21 @@ export async function saveAuth(params: { const db = await getDb(); const id = await invoke("generate_auth_id"); + // Store the token in the OS credential store + await invoke("set_github_token", { token: params.accessToken }); + // Delete any existing auth record (single-user app) await db.execute("DELETE FROM auth"); 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)`, + `INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email) + VALUES ($1, $2, $3, $4, $5)`, [ id, params.githubId, params.username, params.avatarUrl ?? null, params.email ?? null, - params.accessToken, ], ); } @@ -74,4 +79,5 @@ export async function saveAuth(params: { export async function clearAuth(): Promise { const db = await getDb(); await db.execute("DELETE FROM auth"); + await invoke("clear_github_token"); }