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
+76 -18
View File
@@ -297,13 +297,15 @@ pub fn render_device_auth_popup(
frame: &mut Frame,
area: Rect,
url: &str,
code: &str,
_code: &str,
error: Option<&str>,
) {
let popup_area = centered_rect(75, 11, area);
let popup_area = centered_rect(80, 13, area);
let border_color = if error.is_some() {
Color::Red
} else if url == "starting..." {
Color::Yellow
} else if url.is_empty() {
POPUP_BORDER
} else {
@@ -314,7 +316,7 @@ pub fn render_device_auth_popup(
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(border_color))
.title(" Google OAuth ")
.title(" Google Tasks - Authorization ")
.title_alignment(Alignment::Left);
let mut lines = Vec::new();
@@ -322,28 +324,52 @@ pub fn render_device_auth_popup(
if let Some(err) = error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" ERROR: {} ", err),
" Authorization Error ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let wrapped = textwrap(&err, 50);
for line in wrapped {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(Color::White),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press Enter to retry or Esc to cancel ",
" Press Enter to retry | Esc to cancel ",
Style::default().fg(Color::DarkGray),
)));
} else if url == "starting..." {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Starting authorization... ",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" A browser tab will open or you can copy the URL manually. ",
Style::default().fg(Color::DarkGray),
)));
} else if url.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Google Tasks Authorization Required ",
" Google Tasks Authorization ",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars ",
" This app needs access to your Google Tasks. ",
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Set env vars GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET ",
Style::default().fg(Color::Yellow),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press Enter to start authorization ",
" Press Enter to start ",
Style::default().fg(Color::Cyan),
)));
lines.push(Line::from(Span::styled(
@@ -353,33 +379,65 @@ pub fn render_device_auth_popup(
} else {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Visit the following URL to authorize: ",
Style::default().fg(Color::White),
" Authorize in your browser: ",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
url,
Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED),
)));
// Show a shortened version of the URL for readability
if url.len() > 70 {
let short_url: String = url.chars().take(67).collect();
lines.push(Line::from(Span::styled(
format!(" {}", short_url),
Style::default().fg(Color::Cyan),
)));
lines.push(Line::from(Span::styled(
" ... (full URL in terminal log) ",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(Span::styled(
format!(" {}", url),
Style::default().fg(Color::Cyan),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" Enter code: {} ", code),
" Waiting for browser authorization... ",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Waiting for authorization... (Esc to cancel) ",
" (Esc to cancel) ",
Style::default().fg(Color::DarkGray),
)));
}
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Center);
.alignment(Alignment::Left);
frame.render_widget(paragraph, popup_area);
}
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
fn textwrap(text: &str, max_width: usize) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.len() + word.len() + 1 > max_width && !current.is_empty() {
result.push(current.clone());
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
result.push(current);
}
result
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)