fix: replace device flow with loopback ip redirect flow (RFC 8252)

- Device Flow only works with 'TV and Limited Input devices' OAuth client type
- Desktop app type requires Authorization Code flow with localhost redirect
- New flow: start local TCP server on random port, open browser with auth URL,
  catch redirect containing authorization code, exchange for tokens
- Uses webbrowser crate to auto-open the browser
- Self-contained: no separate HTTP server framework needed, uses std::net
- Popup shows auth URL and waits for browser authorization
- Support for refresh_token for long-lived access
This commit is contained in:
Ruben Rosario
2026-06-20 20:55:08 +01:00
parent 64993b127c
commit 0cbf9262c7
5 changed files with 471 additions and 173 deletions
+36 -20
View File
@@ -29,14 +29,14 @@ pub struct App {
pub api_client: Arc<ApiClient>,
pub needs_auth: bool,
pub auth_error: Option<String>,
pub auth_url: String,
auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
}
enum AuthEvent {
DeviceCode(String, String),
Complete,
Ready,
Error(String),
}
@@ -87,6 +87,7 @@ impl App {
api_client,
needs_auth: !has_token,
auth_error: None,
auth_url: String::new(),
auth_tx,
auth_rx,
sync_tx,
@@ -96,27 +97,38 @@ impl App {
pub fn start_auth_process(&mut self) {
let api = self.api_client.clone();
let tx = self.auth_tx.clone();
self.auth_error = None;
self.auth_url.clear();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
match api.authenticate().await {
Ok((url, code)) => {
let _ = tx.send(AuthEvent::DeviceCode(url, code));
match api.start_auth_flow().await {
Ok((auth_url, _port)) => {
// Try to open browser, but it's OK if it fails
ApiClient::open_browser(&auth_url);
// Poll until token is ready
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
if api.token_file_exists() {
let _ = tx.send(AuthEvent::Complete);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
if api.token_is_ready().await {
let _ = tx.send(AuthEvent::Ready);
break;
}
}
}
Err(e) => {
let msg = match &e {
crate::infrastructure::api::ApiError::Network(s) => format!("Network: {}", s),
crate::infrastructure::api::ApiError::Auth(s) => format!("Auth: {}", s),
crate::infrastructure::api::ApiError::Api(s) => format!("API: {}", s),
crate::infrastructure::api::ApiError::Network(s) => {
format!("Network: {}", s)
}
crate::infrastructure::api::ApiError::Auth(s) => {
format!("Auth: {}", s)
}
crate::infrastructure::api::ApiError::Api(s) => {
format!("API: {}", s)
}
};
let _ = tx.send(AuthEvent::Error(msg));
}
@@ -131,21 +143,14 @@ impl App {
}
while let Ok(event) = self.auth_rx.try_recv() {
match event {
AuthEvent::DeviceCode(url, code) => {
self.show_popup = Some(Popup::DeviceAuth { url, code });
}
AuthEvent::Complete => {
AuthEvent::Ready => {
self.needs_auth = false;
self.auth_error = None;
self.show_popup = None;
let _ = self.sync_tx.try_send(SyncCommand::InitialSync);
}
AuthEvent::Error(msg) => {
self.auth_error = Some(msg.clone());
self.show_popup = Some(Popup::DeviceAuth {
url: String::new(),
code: String::new(),
});
self.auth_error = Some(msg);
}
}
}
@@ -270,6 +275,17 @@ impl App {
KeyCode::Enter => {
if url.is_empty() && self.auth_error.is_none() {
self.start_auth_process();
self.show_popup = Some(Popup::DeviceAuth {
url: "starting...".to_string(),
code: String::new(),
});
} else if self.auth_error.is_some() {
self.auth_error = None;
self.start_auth_process();
self.show_popup = Some(Popup::DeviceAuth {
url: "starting...".to_string(),
code: String::new(),
});
}
}
KeyCode::Esc => {