Skip to main content

max / audiofiles

Auto-complete OAuth callback in start_auth via background task start_auth now returns the auth URL directly and spawns a background task that awaits the loopback callback, validates state, and completes authentication without a separate complete_auth call. Removes the caller's need to thread an AuthSession through UI state. Also adds Access-Control-Allow-Origin to the loopback callback responses so browsers don't block the redirect display, and pulls in keyring backend updates (linux-keyutils, expanded security-framework) for cross-platform keychain support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-14 19:23 UTC
Commit: ba4fcbba67c15380b9b2e257d2e17112f76f0f69
Parent: d55bbd2
4 files changed, +100 insertions, -63 deletions
M Cargo.lock +37 -3
@@ -671,6 +671,12 @@ dependencies = [
671 671 ]
672 672
673 673 [[package]]
674 + name = "byteorder"
675 + version = "1.5.0"
676 + source = "registry+https://github.com/rust-lang/crates.io-index"
677 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
678 +
679 + [[package]]
674 680 name = "byteorder-lite"
675 681 version = "0.1.0"
676 682 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2598,7 +2604,12 @@ version = "3.6.3"
2598 2604 source = "registry+https://github.com/rust-lang/crates.io-index"
2599 2605 checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
2600 2606 dependencies = [
2607 + "byteorder",
2608 + "linux-keyutils",
2601 2609 "log",
2610 + "security-framework 2.11.1",
2611 + "security-framework 3.7.0",
2612 + "windows-sys 0.60.2",
2602 2613 "zeroize",
2603 2614 ]
2604 2615
@@ -2723,6 +2734,16 @@ dependencies = [
2723 2734 ]
2724 2735
2725 2736 [[package]]
2737 + name = "linux-keyutils"
2738 + version = "0.2.5"
2739 + source = "registry+https://github.com/rust-lang/crates.io-index"
2740 + checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
2741 + dependencies = [
2742 + "bitflags 2.11.0",
2743 + "libc",
2744 + ]
2745 +
2746 + [[package]]
2726 2747 name = "linux-raw-sys"
2727 2748 version = "0.4.15"
2728 2749 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2942,7 +2963,7 @@ dependencies = [
2942 2963 "openssl-probe",
2943 2964 "openssl-sys",
2944 2965 "schannel",
2945 - "security-framework",
2966 + "security-framework 3.7.0",
2946 2967 "security-framework-sys",
2947 2968 "tempfile",
2948 2969 ]
@@ -4297,9 +4318,9 @@ dependencies = [
4297 4318
4298 4319 [[package]]
4299 4320 name = "rustls-webpki"
4300 - version = "0.103.11"
4321 + version = "0.103.13"
4301 4322 source = "registry+https://github.com/rust-lang/crates.io-index"
4302 - checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
4323 + checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
4303 4324 dependencies = [
4304 4325 "ring",
4305 4326 "rustls-pki-types",
@@ -4350,6 +4371,19 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
4350 4371
4351 4372 [[package]]
4352 4373 name = "security-framework"
4374 + version = "2.11.1"
4375 + source = "registry+https://github.com/rust-lang/crates.io-index"
4376 + checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
4377 + dependencies = [
4378 + "bitflags 2.11.0",
4379 + "core-foundation 0.9.4",
4380 + "core-foundation-sys",
4381 + "libc",
4382 + "security-framework-sys",
4383 + ]
4384 +
4385 + [[package]]
4386 + name = "security-framework"
4353 4387 version = "3.7.0"
4354 4388 source = "registry+https://github.com/rust-lang/crates.io-index"
4355 4389 checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
@@ -1,7 +1,7 @@
1 1 //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2 2
3 3 use egui;
4 - use tracing::{debug, error, warn};
4 + use tracing::{error, warn};
5 5
6 6 use audiofiles_sync::{SyncManager, SyncState, SyncStatus, TierInfo};
7 7
@@ -239,19 +239,13 @@ fn draw_disconnected(
239 239 ui.add_space(12.0);
240 240 if ui.button("Connect").clicked() {
241 241 match sync.start_auth() {
242 - Ok(session) => {
243 - // Try to open browser
244 - // Open browser (macOS: open, Linux: xdg-open, Windows: start)
242 + Ok(auth_url) => {
245 243 #[cfg(target_os = "macos")]
246 - let _ = std::process::Command::new("open").arg(&session.auth_url).spawn();
244 + let _ = std::process::Command::new("open").arg(&auth_url).spawn();
247 245 #[cfg(target_os = "linux")]
248 - let _ = std::process::Command::new("xdg-open").arg(&session.auth_url).spawn();
246 + let _ = std::process::Command::new("xdg-open").arg(&auth_url).spawn();
249 247 #[cfg(target_os = "windows")]
250 - let _ = std::process::Command::new("cmd").args(["/c", "start", &session.auth_url]).spawn();
251 - // Store session for later — we need it when completing auth.
252 - // For simplicity, we don't store the full session object here;
253 - // the manual code entry path handles the case where the callback
254 - // doesn't fire.
248 + let _ = std::process::Command::new("cmd").args(["/c", "start", &auth_url]).spawn();
255 249 state.sync.auth_code_input.clear();
256 250 }
257 251 Err(e) => {
@@ -264,25 +258,18 @@ fn draw_disconnected(
264 258 /// Authenticating state: waiting for OAuth callback.
265 259 fn draw_authenticating(
266 260 ui: &mut egui::Ui,
267 - state: &mut BrowserState,
261 + _state: &mut BrowserState,
268 262 _sync: &SyncManager,
269 263 ) {
270 - ui.label("Opening browser for authentication...");
264 + ui.label("Waiting for authentication in your browser...");
271 265 ui.add_space(8.0);
272 266 ui.spinner();
273 - ui.add_space(12.0);
274 -
275 - // Manual code entry fallback
276 - ui.collapsing("Enter code manually", |ui| {
277 - ui.horizontal(|ui| {
278 - ui.text_edit_singleline(&mut state.sync.auth_code_input);
279 - if ui.button("Submit").clicked() && !state.sync.auth_code_input.is_empty() {
280 - // Manual code entry would need the AuthSession stored somewhere.
281 - // For now, this is a placeholder — the primary flow uses the callback server.
282 - debug!("Manual code entry: {}", state.sync.auth_code_input);
283 - }
284 - });
285 - });
267 + ui.add_space(8.0);
268 + ui.label(
269 + egui::RichText::new("The app will update automatically once you sign in.")
270 + .small()
271 + .weak(),
272 + );
286 273 }
287 274
288 275 /// NeedsEncryption state: password setup.
@@ -128,7 +128,7 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
128 128 tracing::warn!("OAuth callback state mismatch — rejecting");
129 129 let body = "<html><body><h1>Authentication failed</h1><p>CSRF state mismatch. Please try again.</p></body></html>";
130 130 let response = format!(
131 - "HTTP/1.1 403 Forbidden\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
131 + "HTTP/1.1 403 Forbidden\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
132 132 body.len(),
133 133 body
134 134 );
@@ -138,7 +138,7 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> {
138 138
139 139 let body = "<html><body><h1>Authentication successful</h1><p>You can close this tab and return to audiofiles.</p></body></html>";
140 140 let response = format!(
141 - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
141 + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
142 142 body.len(),
143 143 body
144 144 );
@@ -18,7 +18,6 @@ use synckit_client::SyncKitClient;
18 18 use tokio::runtime::Handle;
19 19 use tokio::sync::mpsc;
20 20
21 - use auth::AuthSession;
22 21 use error::Result;
23 22 use scheduler::SyncCommand;
24 23
@@ -112,54 +111,71 @@ impl SyncManager {
112 111 }
113 112
114 113 /// Start the OAuth2 PKCE authentication flow.
115 - /// Returns an `AuthSession` with the auth URL to open in a browser.
114 + /// Opens the callback server and returns the auth URL. A background task
115 + /// automatically awaits the callback and completes authentication.
116 116 #[instrument(skip_all)]
117 - pub fn start_auth(&self) -> Result<AuthSession> {
117 + pub fn start_auth(&self) -> Result<String> {
118 118 self.status.lock().state = SyncState::Authenticating;
119 - auth::start_auth(&self.client)
120 - }
121 -
122 - /// Complete authentication after the OAuth callback is received.
123 - #[instrument(skip_all)]
124 - pub fn complete_auth(&self, code: String, state: String, session: &AuthSession) {
125 - if state != session.expected_state {
126 - let mut s = self.status.lock();
127 - s.last_error = Some("CSRF state mismatch".to_string());
128 - s.state = SyncState::Disconnected;
129 - return;
130 - }
119 + let session = auth::start_auth(&self.client)?;
120 + let auth_url = session.auth_url.clone();
131 121
122 + // Spawn background task to await callback and auto-complete auth
132 123 let client = self.client.clone();
133 - let port = session.port;
134 - let verifier = session.code_verifier.clone();
135 124 let status = self.status.clone();
136 125 let db_path = self.db_path.clone();
126 + let expected_state = session.expected_state.clone();
127 + let code_verifier = session.code_verifier.clone();
128 + let port = session.port;
137 129
138 130 self.runtime.spawn(async move {
139 - match client.authenticate_with_code(&code, &verifier, port).await {
140 - Ok(_) => {
141 - // Try to load key from keychain (may already exist on this device)
142 - let has_key = client.try_load_key_from_keychain().unwrap_or(false);
143 - if has_key {
131 + match session.code_rx.await {
132 + Ok(result) => {
133 + if result.state != expected_state {
144 134 let mut s = status.lock();
145 - s.state = SyncState::Ready;
146 - load_sync_settings_into_status(&db_path, &mut s);
147 - } else {
148 - // Check if server has an encrypted key
149 - let has_server_key = client.has_server_key().await.unwrap_or(false);
150 - status.lock().state =
151 - SyncState::NeedsEncryption { has_server_key };
135 + s.last_error = Some("CSRF state mismatch".to_string());
136 + s.state = SyncState::Disconnected;
137 + return;
138 + }
139 +
140 + match client
141 + .authenticate_with_code(&result.code, &code_verifier, port)
142 + .await
143 + {
144 + Ok(_) => {
145 + let has_key = client.try_load_key_from_keychain().unwrap_or(false);
146 + if has_key {
147 + let mut s = status.lock();
148 + s.state = SyncState::Ready;
149 + load_sync_settings_into_status(&db_path, &mut s);
150 + } else {
151 + let has_server_key =
152 + client.has_server_key().await.unwrap_or(false);
153 + status.lock().state =
154 + SyncState::NeedsEncryption { has_server_key };
155 + }
156 + }
157 + Err(e) => {
158 + let mut s = status.lock();
159 + s.state = SyncState::Disconnected;
160 + s.last_error = Some(format!("Auth failed: {e}"));
161 + }
152 162 }
153 163 }
154 - Err(e) => {
164 + Err(_) => {
165 + // Callback server timed out or was dropped
155 166 let mut s = status.lock();
156 - s.state = SyncState::Disconnected;
157 - s.last_error = Some(format!("Auth failed: {e}"));
167 + if s.state == SyncState::Authenticating {
168 + s.state = SyncState::Disconnected;
169 + s.last_error = Some("Authentication timed out".to_string());
170 + }
158 171 }
159 172 }
160 173 });
174 +
175 + Ok(auth_url)
161 176 }
162 177
178 +
163 179 /// Set up encryption (new or existing, depending on `is_new`).
164 180 #[instrument(skip_all)]
165 181 pub fn setup_encryption(&self, password: String, is_new: bool) {