BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: (rs) OAuth and session mgmt

+717 -117
+15 -12
docs/tasks/02-auth.md
··· 4 4 5 5 ## Steps 6 6 7 - - [ ] Implement `PersistentAuthStore` backed by SQLite (impl `jacquard::oauth::authstore` trait) 8 - - [ ] Create Tauri command `login(handle: String)`: 7 + - [x] Implement `PersistentAuthStore` backed by SQLite (impl `jacquard::oauth::authstore` trait) 8 + - [x] Create Tauri command `login(handle: String)`: 9 9 - Resolve handle → authorization server 10 10 - Build `AtprotoClientMetadata` for Lazurite 11 11 - Start loopback OAuth via `LoopbackConfig` 12 12 - Store session tokens, insert into `accounts` table 13 13 - Return account info to frontend 14 - - [ ] Create Tauri command `logout(did: String)` — revoke tokens, remove from DB 15 - - [ ] Create Tauri command `switch_account(did: String)` — swap active `OAuthSession` in state 16 - - [ ] Create Tauri command `list_accounts()` → `Vec<Account>` 17 - - [ ] On app launch: restore sessions from DB, auto-refresh tokens for active account 18 - - [ ] Register `at://` scheme via deep-link plugin in `tauri.conf.json` 19 - - [ ] Handle deep-link events: parse `at://` URI, emit Tauri event to frontend for navigation 20 - - [ ] **Frontend**: login form with `Motion` spring shake on invalid handle 21 - - [ ] **Frontend**: account switcher dropdown in sidebar with `Presence` avatar enter/exit 22 - - [ ] **Frontend**: skeleton shimmer on profile card during session restore 23 - - [ ] **Frontend**: inline re-auth prompt with pulse animation on session expiry 14 + - [x] Create Tauri command `logout(did: String)` — revoke tokens, remove from DB 15 + - [x] Create Tauri command `switch_account(did: String)` — swap active `OAuthSession` in state 16 + - [x] Create Tauri command `list_accounts()` → `Vec<Account>` 17 + - [x] On app launch: restore sessions from DB, auto-refresh tokens for active account 18 + - [x] Register `at://` scheme via deep-link plugin in `tauri.conf.json` 19 + - [x] Handle deep-link events: parse `at://` URI, emit Tauri event to frontend for navigation 20 + 21 + ### Frontend 22 + 23 + - [ ] login form with `Motion` spring shake on invalid handle 24 + - [ ] account switcher dropdown in sidebar with `Presence` avatar enter/exit 25 + - [ ] skeleton shimmer on profile card during session restore 26 + - [ ] inline re-auth prompt with pulse animation on session expiry
+446
src-tauri/src/auth.rs
··· 1 + use super::db::DbPool; 2 + use super::error::AppError; 3 + use super::state::{AccountSummary, ActiveSession}; 4 + use jacquard::api::com_atproto::server::get_session::GetSession; 5 + use jacquard::common::session::SessionStoreError; 6 + use jacquard::oauth::atproto::AtprotoClientMetadata; 7 + use jacquard::oauth::authstore::ClientAuthStore; 8 + use jacquard::oauth::client::{OAuthClient, OAuthSession}; 9 + use jacquard::oauth::loopback::{handle_localhost_callback, one_shot_server, try_open_in_browser}; 10 + use jacquard::oauth::loopback::{CallbackHandle, LoopbackConfig, LoopbackPort}; 11 + use jacquard::oauth::session::{AuthRequestData, ClientData, ClientSessionData}; 12 + use jacquard::oauth::types::AuthorizeOptions; 13 + use jacquard::types::{aturi::AtUri, did::Did}; 14 + use jacquard::xrpc::XrpcClient; 15 + use jacquard::IntoStatic; 16 + use rusqlite::{params, OptionalExtension}; 17 + use serde::Serialize; 18 + use std::collections::HashMap; 19 + use std::net::SocketAddr; 20 + use std::sync::{MutexGuard, RwLock}; 21 + use tauri::{AppHandle, Emitter}; 22 + 23 + pub const ACCOUNT_SWITCHED_EVENT: &str = "auth:account-switched"; 24 + pub const AT_URI_OPEN_EVENT: &str = "navigation:open-at-uri"; 25 + const CLIENT_NAME: &str = "Lazurite"; 26 + 27 + pub type LazuriteOAuthClient = OAuthClient<jacquard::identity::JacquardResolver, PersistentAuthStore>; 28 + pub type LazuriteOAuthSession = OAuthSession<jacquard::identity::JacquardResolver, PersistentAuthStore>; 29 + 30 + #[derive(Clone)] 31 + pub struct PersistentAuthStore { 32 + db_pool: DbPool, 33 + } 34 + 35 + #[derive(Clone, Debug)] 36 + pub struct StoredAccount { 37 + pub did: String, 38 + pub session_id: Option<String>, 39 + pub handle: String, 40 + pub pds_url: String, 41 + pub active: bool, 42 + } 43 + 44 + #[derive(Clone, Debug, Serialize)] 45 + #[serde(rename_all = "camelCase")] 46 + pub struct AtUriNavigation { 47 + pub uri: String, 48 + } 49 + 50 + impl PersistentAuthStore { 51 + pub fn new(db_pool: DbPool) -> Self { 52 + Self { db_pool } 53 + } 54 + 55 + pub fn lock_connection(&self) -> Result<MutexGuard<'_, rusqlite::Connection>, AppError> { 56 + self.db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool")) 57 + } 58 + 59 + pub fn load_accounts(&self) -> Result<Vec<StoredAccount>, AppError> { 60 + let connection = self.lock_connection()?; 61 + let mut statement = connection.prepare( 62 + " 63 + SELECT 64 + did, 65 + session_id, 66 + COALESCE(handle, ''), 67 + COALESCE(pds_url, ''), 68 + active 69 + FROM accounts 70 + ORDER BY active DESC, handle COLLATE NOCASE ASC 71 + ", 72 + )?; 73 + 74 + let rows = statement.query_map([], |row| { 75 + Ok(StoredAccount { 76 + did: row.get(0)?, 77 + session_id: row.get(1)?, 78 + handle: row.get(2)?, 79 + pds_url: row.get(3)?, 80 + active: row.get::<_, i64>(4)? == 1, 81 + }) 82 + })?; 83 + 84 + let mut accounts = Vec::new(); 85 + for row in rows { 86 + accounts.push(row?); 87 + } 88 + 89 + Ok(accounts) 90 + } 91 + 92 + pub fn get_account(&self, did: &str) -> Result<Option<StoredAccount>, AppError> { 93 + let connection = self.lock_connection()?; 94 + connection 95 + .query_row( 96 + " 97 + SELECT 98 + did, 99 + session_id, 100 + COALESCE(handle, ''), 101 + COALESCE(pds_url, ''), 102 + active 103 + FROM accounts 104 + WHERE did = ?1 105 + ", 106 + params![did], 107 + |row| { 108 + Ok(StoredAccount { 109 + did: row.get(0)?, 110 + session_id: row.get(1)?, 111 + handle: row.get(2)?, 112 + pds_url: row.get(3)?, 113 + active: row.get::<_, i64>(4)? == 1, 114 + }) 115 + }, 116 + ) 117 + .optional() 118 + .map_err(AppError::from) 119 + } 120 + 121 + pub fn upsert_account( 122 + &self, account: &AccountSummary, session_id: &str, make_active: bool, 123 + ) -> Result<(), AppError> { 124 + let mut connection = self.lock_connection()?; 125 + let transaction = connection.transaction()?; 126 + 127 + if make_active { 128 + transaction.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 129 + } 130 + 131 + transaction.execute( 132 + " 133 + INSERT INTO accounts(did, handle, pds_url, session_id, active) 134 + VALUES (?1, ?2, ?3, ?4, ?5) 135 + ON CONFLICT(did) DO UPDATE SET 136 + handle = excluded.handle, 137 + pds_url = excluded.pds_url, 138 + session_id = excluded.session_id, 139 + active = excluded.active 140 + ", 141 + params![ 142 + account.did, 143 + account.handle, 144 + account.pds_url, 145 + session_id, 146 + if make_active { 1_i64 } else { 0_i64 } 147 + ], 148 + )?; 149 + 150 + transaction.execute( 151 + "DELETE FROM oauth_sessions WHERE did = ?1 AND session_id <> ?2", 152 + params![account.did, session_id], 153 + )?; 154 + transaction.commit()?; 155 + 156 + Ok(()) 157 + } 158 + 159 + pub fn set_active_account(&self, did: &str) -> Result<(), AppError> { 160 + let mut connection = self.lock_connection()?; 161 + let transaction = connection.transaction()?; 162 + transaction.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 163 + let rows_updated = transaction.execute("UPDATE accounts SET active = 1 WHERE did = ?1", params![did])?; 164 + 165 + if rows_updated == 0 { 166 + return Err(AppError::Validation(format!( 167 + "cannot activate unknown account did: {did}" 168 + ))); 169 + } 170 + 171 + transaction.commit()?; 172 + Ok(()) 173 + } 174 + 175 + pub fn clear_active_account(&self) -> Result<(), AppError> { 176 + let connection = self.lock_connection()?; 177 + connection.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 178 + Ok(()) 179 + } 180 + 181 + pub fn delete_account(&self, did: &str) -> Result<Option<String>, AppError> { 182 + let mut connection = self.lock_connection()?; 183 + let transaction = connection.transaction()?; 184 + 185 + let was_active = transaction 186 + .query_row("SELECT active FROM accounts WHERE did = ?1", params![did], |row| { 187 + row.get::<_, i64>(0) 188 + }) 189 + .optional()? 190 + .unwrap_or_default() 191 + == 1; 192 + 193 + transaction.execute("DELETE FROM accounts WHERE did = ?1", params![did])?; 194 + 195 + let next_active = if was_active { 196 + let next = transaction 197 + .query_row( 198 + "SELECT did FROM accounts ORDER BY handle COLLATE NOCASE ASC LIMIT 1", 199 + [], 200 + |row| row.get::<_, String>(0), 201 + ) 202 + .optional()?; 203 + 204 + if let Some(next_did) = &next { 205 + transaction.execute("UPDATE accounts SET active = 1 WHERE did = ?1", params![next_did])?; 206 + } 207 + 208 + next 209 + } else { 210 + None 211 + }; 212 + 213 + transaction.commit()?; 214 + Ok(next_active) 215 + } 216 + } 217 + 218 + impl ClientAuthStore for PersistentAuthStore { 219 + async fn get_session( 220 + &self, did: &Did<'_>, session_id: &str, 221 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 222 + let connection = self.lock_connection().map_err(app_to_store_error)?; 223 + let payload: Option<String> = connection 224 + .query_row( 225 + " 226 + SELECT session_json 227 + FROM oauth_sessions 228 + WHERE did = ?1 AND session_id = ?2 229 + ", 230 + params![did.as_str(), session_id], 231 + |row| row.get(0), 232 + ) 233 + .optional() 234 + .map_err(sqlite_to_store_error)?; 235 + 236 + payload 237 + .map(|json| serde_json::from_str::<ClientSessionData<'_>>(&json).map(IntoStatic::into_static)) 238 + .transpose() 239 + .map_err(SessionStoreError::from) 240 + } 241 + 242 + async fn upsert_session(&self, session: ClientSessionData<'_>) -> Result<(), SessionStoreError> { 243 + let connection = self.lock_connection().map_err(app_to_store_error)?; 244 + let payload = serde_json::to_string(&session).map_err(SessionStoreError::from)?; 245 + 246 + connection 247 + .execute( 248 + " 249 + INSERT INTO oauth_sessions(did, session_id, session_json, updated_at) 250 + VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP) 251 + ON CONFLICT(did, session_id) DO UPDATE SET 252 + session_json = excluded.session_json, 253 + updated_at = CURRENT_TIMESTAMP 254 + ", 255 + params![session.account_did.as_str(), session.session_id.as_ref(), payload], 256 + ) 257 + .map_err(sqlite_to_store_error)?; 258 + 259 + Ok(()) 260 + } 261 + 262 + async fn delete_session(&self, did: &Did<'_>, session_id: &str) -> Result<(), SessionStoreError> { 263 + let connection = self.lock_connection().map_err(app_to_store_error)?; 264 + connection 265 + .execute( 266 + "DELETE FROM oauth_sessions WHERE did = ?1 AND session_id = ?2", 267 + params![did.as_str(), session_id], 268 + ) 269 + .map_err(sqlite_to_store_error)?; 270 + Ok(()) 271 + } 272 + 273 + async fn get_auth_req_info(&self, state: &str) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 274 + let connection = self.lock_connection().map_err(app_to_store_error)?; 275 + let payload: Option<String> = connection 276 + .query_row( 277 + "SELECT auth_request_json FROM oauth_auth_requests WHERE state = ?1", 278 + params![state], 279 + |row| row.get(0), 280 + ) 281 + .optional() 282 + .map_err(sqlite_to_store_error)?; 283 + 284 + payload 285 + .map(|json| serde_json::from_str::<AuthRequestData<'_>>(&json).map(IntoStatic::into_static)) 286 + .transpose() 287 + .map_err(SessionStoreError::from) 288 + } 289 + 290 + async fn save_auth_req_info(&self, auth_req_info: &AuthRequestData<'_>) -> Result<(), SessionStoreError> { 291 + let connection = self.lock_connection().map_err(app_to_store_error)?; 292 + let payload = serde_json::to_string(auth_req_info).map_err(SessionStoreError::from)?; 293 + 294 + connection 295 + .execute( 296 + " 297 + INSERT INTO oauth_auth_requests(state, auth_request_json) 298 + VALUES (?1, ?2) 299 + ON CONFLICT(state) DO UPDATE SET 300 + auth_request_json = excluded.auth_request_json, 301 + created_at = CURRENT_TIMESTAMP 302 + ", 303 + params![auth_req_info.state.as_ref(), payload], 304 + ) 305 + .map_err(sqlite_to_store_error)?; 306 + 307 + Ok(()) 308 + } 309 + 310 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 311 + let connection = self.lock_connection().map_err(app_to_store_error)?; 312 + connection 313 + .execute("DELETE FROM oauth_auth_requests WHERE state = ?1", params![state]) 314 + .map_err(sqlite_to_store_error)?; 315 + Ok(()) 316 + } 317 + } 318 + 319 + pub fn build_oauth_client(store: PersistentAuthStore) -> LazuriteOAuthClient { 320 + let client_data = ClientData::new_public(default_client_metadata()); 321 + OAuthClient::new(store, client_data) 322 + } 323 + 324 + pub fn default_client_metadata() -> AtprotoClientMetadata<'static> { 325 + AtprotoClientMetadata::default_localhost().with_prod_info(CLIENT_NAME, None, None, None) 326 + } 327 + 328 + pub async fn login_with_loopback( 329 + oauth_client: &LazuriteOAuthClient, identifier: &str, 330 + ) -> Result<LazuriteOAuthSession, AppError> { 331 + let config = LoopbackConfig::default(); 332 + let options = AuthorizeOptions::default(); 333 + let bind_addr = loopback_bind_addr(&config)?; 334 + let (local_addr, callback_handle) = one_shot_server(bind_addr); 335 + let flow_client = build_loopback_client(oauth_client, &config, &options, local_addr); 336 + 337 + let auth_url = flow_client.start_auth(identifier, options).await?; 338 + let _ = try_open_in_browser(&auth_url); 339 + 340 + complete_loopback_login(flow_client, callback_handle, config).await 341 + } 342 + 343 + pub async fn fetch_account_summary(session: &LazuriteOAuthSession, active: bool) -> Result<AccountSummary, AppError> { 344 + let response = session 345 + .send(GetSession) 346 + .await 347 + .map_err(|error| AppError::Validation(format!("failed to query account session: {error}")))?; 348 + let output = response 349 + .into_output() 350 + .map_err(|error| AppError::Validation(format!("failed to parse account session: {error}")))?; 351 + 352 + Ok(AccountSummary { 353 + did: output.did.to_string(), 354 + handle: output.handle.to_string(), 355 + pds_url: session.endpoint().await.to_string(), 356 + active, 357 + }) 358 + } 359 + 360 + pub fn restore_session_from_data( 361 + oauth_client: &LazuriteOAuthClient, session_data: ClientSessionData<'static>, 362 + ) -> LazuriteOAuthSession { 363 + OAuthSession::new(oauth_client.registry.clone(), oauth_client.client.clone(), session_data) 364 + } 365 + 366 + pub fn normalize_at_uri(raw: &str) -> Result<String, AppError> { 367 + Ok(AtUri::new(raw)?.to_string()) 368 + } 369 + 370 + pub fn emit_account_switch(app: &AppHandle, active_session: Option<ActiveSession>) -> Result<(), AppError> { 371 + app.emit(ACCOUNT_SWITCHED_EVENT, active_session)?; 372 + Ok(()) 373 + } 374 + 375 + pub fn emit_at_uri_navigation(app: &AppHandle, raw: &str) -> Result<(), AppError> { 376 + let uri = normalize_at_uri(raw)?; 377 + app.emit(AT_URI_OPEN_EVENT, AtUriNavigation { uri })?; 378 + Ok(()) 379 + } 380 + 381 + pub fn active_session_from_accounts(accounts: &[StoredAccount]) -> Option<ActiveSession> { 382 + accounts 383 + .iter() 384 + .find(|account| account.active) 385 + .map(|account| ActiveSession { did: account.did.clone(), handle: account.handle.clone() }) 386 + } 387 + 388 + pub fn account_summaries(accounts: &[StoredAccount]) -> Vec<AccountSummary> { 389 + accounts 390 + .iter() 391 + .map(|account| AccountSummary { 392 + did: account.did.clone(), 393 + handle: account.handle.clone(), 394 + pds_url: account.pds_url.clone(), 395 + active: account.active, 396 + }) 397 + .collect() 398 + } 399 + 400 + pub fn remove_cached_session( 401 + sessions: &RwLock<HashMap<String, std::sync::Arc<LazuriteOAuthSession>>>, did: &str, 402 + ) -> Result<(), AppError> { 403 + sessions 404 + .write() 405 + .map_err(|_| AppError::StatePoisoned("sessions"))? 406 + .remove(did); 407 + Ok(()) 408 + } 409 + 410 + fn build_loopback_client( 411 + oauth_client: &LazuriteOAuthClient, config: &LoopbackConfig, options: &AuthorizeOptions<'_>, local_addr: SocketAddr, 412 + ) -> LazuriteOAuthClient { 413 + let mut client_data = oauth_client.build_localhost_client_data(config, options, local_addr); 414 + client_data.config = client_data.config.with_prod_info(CLIENT_NAME, None, None, None); 415 + 416 + OAuthClient::new_with_shared( 417 + oauth_client.registry.store.clone(), 418 + oauth_client.client.clone(), 419 + client_data, 420 + ) 421 + } 422 + 423 + async fn complete_loopback_login( 424 + flow_client: LazuriteOAuthClient, callback_handle: CallbackHandle, config: LoopbackConfig, 425 + ) -> Result<LazuriteOAuthSession, AppError> { 426 + Ok(handle_localhost_callback(callback_handle, &flow_client, &config).await?) 427 + } 428 + 429 + fn loopback_bind_addr(config: &LoopbackConfig) -> Result<SocketAddr, AppError> { 430 + let port = match config.port { 431 + LoopbackPort::Fixed(port) => port, 432 + LoopbackPort::Ephemeral => 0, 433 + }; 434 + 435 + format!("0.0.0.0:{port}") 436 + .parse() 437 + .map_err(|error| AppError::Validation(format!("invalid loopback bind address: {error}"))) 438 + } 439 + 440 + fn sqlite_to_store_error(error: rusqlite::Error) -> SessionStoreError { 441 + SessionStoreError::Other(Box::new(error)) 442 + } 443 + 444 + fn app_to_store_error(error: AppError) -> SessionStoreError { 445 + SessionStoreError::Other(Box::new(error)) 446 + }
+20 -6
src-tauri/src/commands.rs
··· 1 - use tauri::State; 2 - 3 - use crate::error::AppError; 4 - use crate::state::{AccountSummary, AppBootstrap, AppState}; 1 + use super::error::AppError; 2 + use super::state::{AccountSummary, AppBootstrap, AppState}; 3 + use tauri::{AppHandle, State}; 5 4 6 5 #[tauri::command] 7 6 pub fn get_app_bootstrap(state: State<'_, AppState>) -> Result<AppBootstrap, AppError> { ··· 14 13 } 15 14 16 15 #[tauri::command] 17 - pub fn set_active_account(did: String, state: State<'_, AppState>) -> Result<(), AppError> { 18 - state.set_active_account(&did) 16 + pub async fn login(handle: String, app: AppHandle, state: State<'_, AppState>) -> Result<AccountSummary, AppError> { 17 + state.login(&app, handle).await 18 + } 19 + 20 + #[tauri::command] 21 + pub async fn logout(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 22 + state.logout(&app, &did).await 23 + } 24 + 25 + #[tauri::command] 26 + pub async fn switch_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 27 + state.switch_account(&app, &did).await 28 + } 29 + 30 + #[tauri::command] 31 + pub async fn set_active_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 32 + state.switch_account(&app, &did).await 19 33 }
+10 -2
src-tauri/src/db.rs
··· 18 18 sql: &'static str, 19 19 } 20 20 21 - const MIGRATIONS: &[Migration] = 22 - &[Migration { version: 1, name: "initial_schema", sql: include_str!("migrations/001_initial.sql") }]; 21 + impl Migration { 22 + const fn new(version: i64, name: &'static str, sql: &'static str) -> Self { 23 + Self { version, name, sql } 24 + } 25 + } 26 + 27 + const MIGRATIONS: &[Migration] = &[ 28 + Migration::new(1, "initial_schema", include_str!("migrations/001_initial.sql")), 29 + Migration::new(2, "oauth_storage", include_str!("migrations/002_auth_storage.sql")), 30 + ]; 23 31 24 32 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { 25 33 // Registers sqlite-vec for all future rusqlite connections.
+30 -5
src-tauri/src/error.rs
··· 1 - use serde::ser::Serializer; 2 - use thiserror::Error; 3 - 4 - #[derive(Debug, Error)] 1 + #[derive(Debug, thiserror::Error)] 5 2 pub enum AppError { 6 3 #[error("database error: {0}")] 7 4 Database(#[from] rusqlite::Error), 5 + 6 + #[error("auth flow error: {0}")] 7 + OAuth(#[from] jacquard::oauth::error::OAuthError), 8 + 9 + #[error("session error: {0}")] 10 + OAuthSession(#[from] jacquard::oauth::session::Error), 11 + 12 + #[error("session store error: {0}")] 13 + SessionStore(#[from] jacquard::common::session::SessionStoreError), 14 + 8 15 #[error("io error: {0}")] 9 16 Io(#[from] std::io::Error), 17 + 18 + #[error("serialization error: {0}")] 19 + SerdeJson(#[from] serde_json::Error), 20 + 21 + #[error("invalid atproto identifier: {0}")] 22 + AtIdentifier(#[from] jacquard::types::string::AtStrError), 23 + 24 + #[error("uri parse error: {0}")] 25 + UriParse(#[from] jacquard::deps::fluent_uri::ParseError), 26 + 27 + #[error("tauri error: {0}")] 28 + Tauri(#[from] tauri::Error), 29 + 30 + #[error("deep-link error: {0}")] 31 + DeepLink(#[from] tauri_plugin_deep_link::Error), 32 + 10 33 #[error("path resolution failed: {0}")] 11 34 PathResolve(String), 35 + 12 36 #[error("state lock poisoned: {0}")] 13 37 StatePoisoned(&'static str), 38 + 14 39 #[error("{0}")] 15 40 Validation(String), 16 41 } ··· 18 43 impl serde::Serialize for AppError { 19 44 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 20 45 where 21 - S: Serializer, 46 + S: serde::ser::Serializer, 22 47 { 23 48 serializer.serialize_str(&self.to_string()) 24 49 }
+23 -3
src-tauri/src/lib.rs
··· 1 + mod auth; 1 2 mod commands; 2 3 mod db; 3 4 mod error; 4 5 mod state; 5 6 6 - use commands::{get_app_bootstrap, list_accounts, set_active_account}; 7 + use auth::emit_at_uri_navigation; 8 + use commands::{get_app_bootstrap, list_accounts, login, logout, set_active_account, switch_account}; 7 9 use db::initialize_database; 8 10 use state::AppState; 9 11 use tauri::Manager; 12 + use tauri_plugin_deep_link::DeepLinkExt; 10 13 11 14 #[cfg_attr(mobile, tauri::mobile_entry_point)] 12 15 pub fn run() { ··· 14 17 .setup(|app| { 15 18 let db_pool = 16 19 initialize_database(app.handle()).expect("database initialization should succeed during startup"); 17 - let app_state = 18 - AppState::bootstrap(db_pool).expect("application state should be bootstrapped from database"); 20 + let app_state = tauri::async_runtime::block_on(AppState::bootstrap(db_pool)) 21 + .expect("application state should be bootstrapped from database"); 19 22 20 23 app.manage(app_state); 24 + 25 + let app_handle = app.handle().clone(); 26 + app.deep_link().on_open_url(move |event| { 27 + for url in event.urls() { 28 + let _ = emit_at_uri_navigation(&app_handle, url.as_str()); 29 + } 30 + }); 31 + 32 + if let Some(urls) = app.deep_link().get_current()? { 33 + for url in urls { 34 + emit_at_uri_navigation(app.handle(), url.as_str())?; 35 + } 36 + } 37 + 21 38 Ok(()) 22 39 }) 23 40 .plugin(tauri_plugin_notification::init()) ··· 31 48 .invoke_handler(tauri::generate_handler![ 32 49 get_app_bootstrap, 33 50 list_accounts, 51 + login, 52 + logout, 53 + switch_account, 34 54 set_active_account 35 55 ]) 36 56 .run(tauri::generate_context!())
+20
src-tauri/src/migrations/002_auth_storage.sql
··· 1 + ALTER TABLE accounts ADD COLUMN session_id TEXT; 2 + 3 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 4 + did TEXT NOT NULL, 5 + session_id TEXT NOT NULL, 6 + session_json TEXT NOT NULL, 7 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 + PRIMARY KEY (did, session_id), 10 + FOREIGN KEY (did) REFERENCES accounts(did) ON DELETE CASCADE 11 + ); 12 + 13 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 14 + state TEXT PRIMARY KEY, 15 + auth_request_json TEXT NOT NULL, 16 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 17 + ); 18 + 19 + CREATE INDEX IF NOT EXISTS idx_accounts_active_handle 20 + ON accounts(active DESC, handle COLLATE NOCASE ASC);
+149 -69
src-tauri/src/state.rs
··· 1 - use std::sync::RwLock; 2 - 3 - use rusqlite::params; 1 + use super::auth::{ 2 + account_summaries, active_session_from_accounts, build_oauth_client, emit_account_switch, fetch_account_summary, 3 + remove_cached_session, restore_session_from_data, 4 + }; 5 + use super::auth::{login_with_loopback, LazuriteOAuthClient, LazuriteOAuthSession}; 6 + use super::auth::{PersistentAuthStore, StoredAccount}; 7 + use super::db::DbPool; 8 + use super::error::AppError; 9 + use jacquard::oauth::authstore::ClientAuthStore; 10 + use jacquard::types::did::Did; 11 + use jacquard::IntoStatic; 4 12 use serde::Serialize; 5 - 6 - use crate::db::DbPool; 7 - use crate::error::AppError; 13 + use std::collections::HashMap; 14 + use std::sync::{Arc, RwLock}; 15 + use tauri::AppHandle; 8 16 9 17 #[derive(Clone, Debug, Serialize)] 10 18 #[serde(rename_all = "camelCase")] 11 - pub struct AccountSummary { 19 + pub struct ActiveSession { 12 20 pub did: String, 13 21 pub handle: String, 14 - pub pds_url: String, 15 - pub active: bool, 16 22 } 17 23 18 24 #[derive(Clone, Debug, Serialize)] 19 25 #[serde(rename_all = "camelCase")] 20 - pub struct ActiveSession { 21 - pub did: String, 22 - pub handle: String, 26 + pub struct AppBootstrap { 27 + pub active_session: Option<ActiveSession>, 28 + pub account_list: Vec<AccountSummary>, 23 29 } 24 30 25 31 #[derive(Clone, Debug, Serialize)] 26 32 #[serde(rename_all = "camelCase")] 27 - pub struct AppBootstrap { 28 - pub active_session: Option<ActiveSession>, 29 - pub account_list: Vec<AccountSummary>, 33 + pub struct AccountSummary { 34 + pub did: String, 35 + pub handle: String, 36 + pub pds_url: String, 37 + pub active: bool, 30 38 } 31 39 32 40 pub struct AppState { 33 - pub db_pool: DbPool, 41 + pub auth_store: PersistentAuthStore, 42 + pub oauth_client: LazuriteOAuthClient, 34 43 pub active_session: RwLock<Option<ActiveSession>>, 35 44 pub account_list: RwLock<Vec<AccountSummary>>, 45 + pub sessions: RwLock<HashMap<String, Arc<LazuriteOAuthSession>>>, 36 46 } 37 47 38 48 impl AppState { 39 - pub fn bootstrap(db_pool: DbPool) -> Result<Self, AppError> { 40 - let account_list = load_accounts(&db_pool)?; 41 - let active_session = account_list 42 - .iter() 43 - .find(|account| account.active) 44 - .map(|account| ActiveSession { did: account.did.clone(), handle: account.handle.clone() }); 49 + pub async fn bootstrap(db_pool: DbPool) -> Result<Self, AppError> { 50 + let auth_store = PersistentAuthStore::new(db_pool.clone()); 51 + let oauth_client = build_oauth_client(auth_store.clone()); 52 + let accounts = auth_store.load_accounts()?; 53 + let app_state = Self { 54 + auth_store, 55 + oauth_client, 56 + active_session: RwLock::new(active_session_from_accounts(&accounts)), 57 + account_list: RwLock::new(account_summaries(&accounts)), 58 + sessions: RwLock::new(HashMap::new()), 59 + }; 60 + 61 + app_state.restore_sessions().await?; 62 + app_state.refresh_account_cache()?; 45 63 46 - Ok(Self { db_pool, active_session: RwLock::new(active_session), account_list: RwLock::new(account_list) }) 64 + Ok(app_state) 47 65 } 48 66 49 67 pub fn snapshot(&self) -> Result<AppBootstrap, AppError> { ··· 69 87 .clone()) 70 88 } 71 89 72 - pub fn set_active_account(&self, did: &str) -> Result<(), AppError> { 73 - { 74 - let mut connection = self.db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool"))?; 90 + pub async fn login(&self, app: &AppHandle, identifier: String) -> Result<AccountSummary, AppError> { 91 + let session = Arc::new(login_with_loopback(&self.oauth_client, identifier.trim()).await?); 92 + let (did, session_id) = session.session_info().await; 93 + let account_summary = fetch_account_summary(&session, true).await?; 75 94 76 - let transaction = connection.transaction()?; 77 - transaction.execute("UPDATE accounts SET active = 0 WHERE active = 1", [])?; 78 - let rows_updated = transaction.execute("UPDATE accounts SET active = 1 WHERE did = ?1", params![did])?; 95 + self.auth_store 96 + .upsert_account(&account_summary, session_id.as_ref(), true)?; 97 + self.sessions 98 + .write() 99 + .map_err(|_| AppError::StatePoisoned("sessions"))? 100 + .insert(did.to_string(), session); 79 101 80 - if rows_updated == 0 { 81 - return Err(AppError::Validation(format!( 82 - "cannot activate unknown account did: {did}" 83 - ))); 84 - } 102 + self.refresh_account_cache()?; 103 + emit_account_switch(app, self.current_active_session()?)?; 104 + 105 + Ok(account_summary) 106 + } 107 + 108 + pub async fn logout(&self, app: &AppHandle, did: &str) -> Result<(), AppError> { 109 + let account = self 110 + .auth_store 111 + .get_account(did)? 112 + .ok_or_else(|| AppError::Validation(format!("cannot logout unknown account did: {did}")))?; 85 113 86 - transaction.commit()?; 87 - } 114 + let session = self.ensure_session(&account, false).await?; 115 + session.logout().await?; 88 116 89 - let refreshed_accounts = load_accounts(&self.db_pool)?; 90 - let refreshed_session = refreshed_accounts 91 - .iter() 92 - .find(|account| account.active) 93 - .map(|account| ActiveSession { did: account.did.clone(), handle: account.handle.clone() }); 117 + self.auth_store.delete_account(did)?; 118 + remove_cached_session(&self.sessions, did)?; 119 + self.refresh_account_cache()?; 120 + emit_account_switch(app, self.current_active_session()?)?; 94 121 122 + Ok(()) 123 + } 124 + 125 + pub async fn switch_account(&self, app: &AppHandle, did: &str) -> Result<(), AppError> { 126 + let account = self 127 + .auth_store 128 + .get_account(did)? 129 + .ok_or_else(|| AppError::Validation(format!("cannot activate unknown account did: {did}")))?; 130 + self.ensure_session(&account, true).await?; 131 + self.auth_store.set_active_account(did)?; 132 + self.refresh_account_cache()?; 133 + emit_account_switch(app, self.current_active_session()?)?; 134 + Ok(()) 135 + } 136 + 137 + fn refresh_account_cache(&self) -> Result<(), AppError> { 138 + let accounts = self.auth_store.load_accounts()?; 95 139 *self 96 140 .account_list 97 141 .write() 98 - .map_err(|_| AppError::StatePoisoned("account_list"))? = refreshed_accounts; 142 + .map_err(|_| AppError::StatePoisoned("account_list"))? = account_summaries(&accounts); 99 143 *self 100 144 .active_session 101 145 .write() 102 - .map_err(|_| AppError::StatePoisoned("active_session"))? = refreshed_session; 146 + .map_err(|_| AppError::StatePoisoned("active_session"))? = active_session_from_accounts(&accounts); 103 147 104 148 Ok(()) 105 149 } 106 - } 150 + 151 + fn current_active_session(&self) -> Result<Option<ActiveSession>, AppError> { 152 + Ok(self 153 + .active_session 154 + .read() 155 + .map_err(|_| AppError::StatePoisoned("active_session"))? 156 + .clone()) 157 + } 158 + 159 + async fn ensure_session( 160 + &self, account: &StoredAccount, refresh: bool, 161 + ) -> Result<Arc<LazuriteOAuthSession>, AppError> { 162 + if let Some(existing) = self 163 + .sessions 164 + .read() 165 + .map_err(|_| AppError::StatePoisoned("sessions"))? 166 + .get(&account.did) 167 + .cloned() 168 + { 169 + return Ok(existing); 170 + } 107 171 108 - fn load_accounts(db_pool: &DbPool) -> Result<Vec<AccountSummary>, AppError> { 109 - let connection = db_pool.lock().map_err(|_| AppError::StatePoisoned("db_pool"))?; 172 + let session_id = account.session_id.as_deref().ok_or_else(|| { 173 + AppError::Validation(format!("account {} does not have a stored oauth session", account.did)) 174 + })?; 110 175 111 - let mut statement = connection.prepare( 112 - " 113 - SELECT 114 - did, 115 - COALESCE(handle, ''), 116 - COALESCE(pds_url, ''), 117 - active 118 - FROM accounts 119 - ORDER BY active DESC, handle COLLATE NOCASE ASC 120 - ", 121 - )?; 176 + let did = Did::new(&account.did)?; 177 + let session = if refresh { 178 + Arc::new(self.oauth_client.restore(&did, session_id).await?) 179 + } else { 180 + let session_data = self.auth_store.get_session(&did, session_id).await?.ok_or_else(|| { 181 + AppError::Validation(format!("missing persisted oauth session for account {}", account.did)) 182 + })?; 183 + Arc::new(restore_session_from_data( 184 + &self.oauth_client, 185 + session_data.into_static(), 186 + )) 187 + }; 122 188 123 - let rows = statement.query_map([], |row| { 124 - Ok(AccountSummary { 125 - did: row.get(0)?, 126 - handle: row.get(1)?, 127 - pds_url: row.get(2)?, 128 - active: row.get::<_, i64>(3)? == 1, 129 - }) 130 - })?; 189 + self.sessions 190 + .write() 191 + .map_err(|_| AppError::StatePoisoned("sessions"))? 192 + .insert(account.did.clone(), session.clone()); 131 193 132 - let mut accounts = Vec::new(); 133 - for row in rows { 134 - accounts.push(row?); 194 + Ok(session) 135 195 } 136 196 137 - Ok(accounts) 197 + async fn restore_sessions(&self) -> Result<(), AppError> { 198 + let accounts = self.auth_store.load_accounts()?; 199 + 200 + for account in accounts { 201 + if account.session_id.is_none() { 202 + continue; 203 + } 204 + 205 + let restored = self.ensure_session(&account, account.active).await; 206 + if restored.is_ok() { 207 + continue; 208 + } 209 + 210 + remove_cached_session(&self.sessions, &account.did)?; 211 + if account.active { 212 + self.auth_store.clear_active_account()?; 213 + } 214 + } 215 + 216 + Ok(()) 217 + } 138 218 }
+4 -20
src-tauri/tauri.conf.json
··· 9 9 "beforeBuildCommand": "pnpm build", 10 10 "frontendDist": "../dist" 11 11 }, 12 - "app": { 13 - "windows": [ 14 - { 15 - "title": "lazurite-desktop", 16 - "width": 800, 17 - "height": 600 18 - } 19 - ], 20 - "security": { 21 - "csp": null 22 - } 23 - }, 12 + "app": { "windows": [{ "title": "lazurite-desktop", "width": 800, "height": 600 }], "security": { "csp": null } }, 24 13 "bundle": { 25 14 "active": true, 26 15 "targets": "all", 27 - "icon": [ 28 - "icons/32x32.png", 29 - "icons/128x128.png", 30 - "icons/128x128@2x.png", 31 - "icons/icon.icns", 32 - "icons/icon.ico" 33 - ] 34 - } 16 + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] 17 + }, 18 + "plugins": { "deep-link": { "desktop": { "schemes": ["at"] } } } 35 19 }