-
Notifications
You must be signed in to change notification settings - Fork 1
Move GitHub access token from SQLite to OS credential store #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63a4990
5734749
5e09220
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, String> { | ||
| 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<String> = 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<Option<String>, 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}")), | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,22 +25,26 @@ 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, | ||
| }; | ||
| } | ||
|
|
||
| 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]); | ||
|
|
||
| const token = await invoke<string | null>("get_github_token"); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice work
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback! |
||
| if (!token) return undefined; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we really wanna be returning undefined?
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback! |
||
|
|
||
| return rowToRecord(rows[0], token); | ||
| } | ||
|
|
||
| export async function saveAuth(params: { | ||
|
|
@@ -54,24 +57,27 @@ export async function saveAuth(params: { | |
| const db = await getDb(); | ||
| const id = await invoke<string>("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, | ||
| ], | ||
| ); | ||
| } | ||
|
|
||
| export async function clearAuth(): Promise<void> { | ||
| const db = await getDb(); | ||
| await db.execute("DELETE FROM auth"); | ||
| await invoke("clear_github_token"); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here - migration 13 is vague
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the feedback!