feat(login): add device code fallback in TUI

This commit is contained in:
James Peter
2025-11-14 07:54:07 +10:00
parent 2e47735812
commit 5279dd8858
6 changed files with 315 additions and 56 deletions
+82 -41
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::pkce::PkceCodes;
use crate::server::ServerOptions;
use crate::server::{persist_tokens_async, exchange_code_for_tokens, ServerOptions};
use std::io::Write;
use std::io::{self};
@@ -147,50 +147,91 @@ fn print_colored_warning_device_code() {
/// Full device code login flow.
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
let client = reqwest::Client::new();
let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
print_colored_warning_device_code();
println!("⏳ Generating a new 9-digit device code for authentication...\n");
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
let session = DeviceCodeSession::start(opts).await?;
println!(
"To authenticate, visit: {}/deviceauth/authorize and enter code: {}",
api_base_url, uc.user_code
"To authenticate, visit: {} and enter code: {}",
session.authorize_url(),
session.user_code()
);
let code_resp = poll_for_token(
&client,
&api_base_url,
&uc.device_auth_id,
&uc.user_code,
uc.interval,
)
.await?;
let pkce = PkceCodes {
code_verifier: code_resp.code_verifier,
code_challenge: code_resp.code_challenge,
};
println!("authorization code received");
let redirect_uri = format!("{base_url}/deviceauth/callback");
let tokens = crate::server::exchange_code_for_tokens(
base_url,
&opts.client_id,
&redirect_uri,
&pkce,
&code_resp.authorization_code,
)
.await
.map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?;
crate::server::persist_tokens_async(
&opts.code_home,
None,
tokens.id_token,
tokens.access_token,
tokens.refresh_token,
)
.await
session
.wait_for_tokens()
.await
.map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))
}
pub struct DeviceCodeSession {
client: reqwest::Client,
opts: ServerOptions,
api_base_url: String,
base_url: String,
device_auth_id: String,
user_code: String,
interval: u64,
}
impl DeviceCodeSession {
pub async fn start(opts: ServerOptions) -> std::io::Result<Self> {
let client = reqwest::Client::new();
let base_url = opts.issuer.trim_end_matches('/').to_string();
let api_base_url = format!("{}/api/accounts", base_url);
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
Ok(Self {
client,
api_base_url,
base_url,
device_auth_id: uc.device_auth_id,
user_code: uc.user_code,
interval: uc.interval,
opts,
})
}
pub fn authorize_url(&self) -> String {
format!("{}/deviceauth/authorize", self.api_base_url)
}
pub fn user_code(&self) -> &str {
&self.user_code
}
pub async fn wait_for_tokens(self) -> std::io::Result<()> {
let code_resp = poll_for_token(
&self.client,
&self.api_base_url,
&self.device_auth_id,
&self.user_code,
self.interval,
)
.await?;
let pkce = PkceCodes {
code_verifier: code_resp.code_verifier,
code_challenge: code_resp.code_challenge,
};
let redirect_uri = format!("{}/deviceauth/callback", self.base_url);
let tokens = exchange_code_for_tokens(
&self.base_url,
&self.opts.client_id,
&redirect_uri,
&pkce,
&code_resp.authorization_code,
)
.await
.map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?;
persist_tokens_async(
&self.opts.code_home,
None,
tokens.id_token,
tokens.access_token,
tokens.refresh_token,
)
.await
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ mod device_code_auth;
mod pkce;
mod server;
pub use device_code_auth::run_device_code_login;
pub use device_code_auth::{run_device_code_login, DeviceCodeSession};
pub use server::LoginServer;
pub use server::ServerOptions;
pub use server::ShutdownHandle;
+85 -6
View File
@@ -175,7 +175,7 @@ impl FrameTimer {
}
struct LoginFlowState {
shutdown: code_login::ShutdownHandle,
shutdown: Option<code_login::ShutdownHandle>,
join_handle: tokio::task::JoinHandle<()>,
}
@@ -2725,7 +2725,9 @@ impl App<'_> {
}
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
flow.join_handle.abort();
}
@@ -2747,7 +2749,10 @@ impl App<'_> {
.map_err(|e| e.to_string());
tx.send(AppEvent::LoginChatGptComplete { result });
});
self.login_flow = Some(LoginFlowState { shutdown, join_handle });
self.login_flow = Some(LoginFlowState {
shutdown: Some(shutdown),
join_handle,
});
}
Err(err) => {
widget.notify_login_chatgpt_failed(format!(
@@ -2757,18 +2762,60 @@ impl App<'_> {
}
}
}
AppEvent::LoginStartDeviceCode => {
if let AppState::Chat { widget } = &mut self.app_state {
if !widget.login_add_view_active() {
continue 'main;
}
if let Some(flow) = self.login_flow.take() {
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
flow.join_handle.abort();
}
widget.notify_login_device_code_pending();
let opts = ServerOptions::new(
self.config.code_home.clone(),
code_login::CLIENT_ID.to_string(),
self.config.responses_originator_header.clone(),
);
let tx = self.app_event_tx.clone();
let join_handle = tokio::spawn(async move {
match code_login::DeviceCodeSession::start(opts).await {
Ok(session) => {
let authorize_url = session.authorize_url();
let user_code = session.user_code().to_string();
let _ = tx.send(AppEvent::LoginDeviceCodeReady { authorize_url, user_code });
let result = session.wait_for_tokens().await.map_err(|e| e.to_string());
let _ = tx.send(AppEvent::LoginDeviceCodeComplete { result });
}
Err(err) => {
let _ = tx.send(AppEvent::LoginDeviceCodeFailed { message: err.to_string() });
}
}
});
self.login_flow = Some(LoginFlowState { shutdown: None, join_handle });
}
}
AppEvent::LoginCancelChatGpt => {
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
flow.join_handle.abort();
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_chatgpt_cancelled();
widget.notify_login_flow_cancelled();
}
}
AppEvent::LoginChatGptComplete { result } => {
if let Some(flow) = self.login_flow.take() {
flow.shutdown.shutdown();
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
// Allow the task to finish naturally; if still running, abort.
if !flow.join_handle.is_finished() {
flow.join_handle.abort();
@@ -2779,6 +2826,38 @@ impl App<'_> {
widget.notify_login_chatgpt_complete(result);
}
}
AppEvent::LoginDeviceCodeReady { authorize_url, user_code } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_device_code_ready(authorize_url, user_code);
}
}
AppEvent::LoginDeviceCodeFailed { message } => {
if let Some(flow) = self.login_flow.take() {
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
if !flow.join_handle.is_finished() {
flow.join_handle.abort();
}
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_device_code_failed(message);
}
}
AppEvent::LoginDeviceCodeComplete { result } => {
if let Some(flow) = self.login_flow.take() {
if let Some(shutdown) = flow.shutdown {
shutdown.shutdown();
}
if !flow.join_handle.is_finished() {
flow.join_handle.abort();
}
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.notify_login_device_code_complete(result);
}
}
AppEvent::LoginUsingChatGptChanged { using_chatgpt_auth } => {
self.handle_login_mode_change(using_chatgpt_auth);
}
+8
View File
@@ -395,10 +395,18 @@ pub(crate) enum AppEvent {
/// Begin ChatGPT login flow from the in-app login manager.
LoginStartChatGpt,
/// Begin device code login flow from the in-app login manager.
LoginStartDeviceCode,
/// Cancel an in-progress ChatGPT login flow triggered via `/login`.
LoginCancelChatGpt,
/// ChatGPT login flow has completed (success or failure).
LoginChatGptComplete { result: Result<(), String> },
/// Device code login flow produced a user code/link.
LoginDeviceCodeReady { authorize_url: String, user_code: String },
/// Device code login flow failed before completion.
LoginDeviceCodeFailed { message: String },
/// Device code login flow completed (success or failure).
LoginDeviceCodeComplete { result: Result<(), String> },
/// The active authentication mode changed (e.g., switched accounts).
LoginUsingChatGptChanged { using_chatgpt_auth: bool },
@@ -632,6 +632,20 @@ enum AddStep {
Choose { selected: usize },
ApiKey { field: FormTextField },
Waiting { auth_url: Option<String> },
DeviceCode(DeviceCodeState),
}
#[derive(Debug)]
struct DeviceCodeState {
authorize_url: Option<String>,
user_code: Option<String>,
status: DeviceCodeStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeviceCodeStatus {
Generating,
WaitingForApproval,
}
pub(crate) struct LoginAddAccountState {
@@ -728,7 +742,21 @@ impl LoginAddAccountState {
let _ = field.handle_key(key_event);
}
},
AddStep::Waiting { .. } => {
AddStep::Waiting { .. } => match key_event.code {
KeyCode::Esc => {
self.app_event_tx.send(AppEvent::LoginCancelChatGpt);
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.feedback = Some(Feedback {
message: "Switching to code authentication…".to_string(),
is_error: false,
});
self.step = AddStep::DeviceCode(DeviceCodeState::generating());
self.app_event_tx.send(AppEvent::LoginStartDeviceCode);
}
_ => {}
},
AddStep::DeviceCode(_) => {
if matches!(key_event.code, KeyCode::Esc) {
self.app_event_tx.send(AppEvent::LoginCancelChatGpt);
}
@@ -764,6 +792,9 @@ impl LoginAddAccountState {
lines += 1;
}
}
AddStep::DeviceCode(_) => {
lines += 6;
}
}
lines.max(10) + 2
@@ -840,6 +871,13 @@ impl LoginAddAccountState {
}
AddStep::Waiting { auth_url } => {
lines.push(Line::from("Finish signing in with ChatGPT in your browser."));
lines.push(Line::from(vec![
Span::styled("Not seeing a browser? ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
"Press C to switch to code authentication.",
Style::default().fg(crate::colors::primary()),
),
]));
if let Some(url) = auth_url {
for chunk in wrap_url_segments(url, content_width) {
lines.push(Line::from(vec![Span::styled(
@@ -854,6 +892,40 @@ impl LoginAddAccountState {
Span::styled(" Cancel login", Style::default().fg(crate::colors::text_dim())),
]));
}
AddStep::DeviceCode(state) => {
lines.push(Line::from("Complete sign-in using a verification code."));
match state.status {
DeviceCodeStatus::Generating => {
lines.push(Line::from("Generating a secure code and link…"));
}
DeviceCodeStatus::WaitingForApproval => {
if let Some(code) = &state.user_code {
lines.push(Line::from(vec![
Span::styled("Code: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
code.clone(),
Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD),
),
]));
}
if let Some(url) = &state.authorize_url {
lines.push(Line::from("Visit this link on any device:"));
for chunk in wrap_url_segments(url, content_width) {
lines.push(Line::from(vec![Span::styled(
chunk,
Style::default().fg(crate::colors::info()),
)]));
}
}
lines.push(Line::from("Keep this code private. It expires after 15 minutes."));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Esc", Style::default().fg(crate::colors::error()).add_modifier(Modifier::BOLD)),
Span::styled(" Cancel login", Style::default().fg(crate::colors::text_dim())),
]));
}
}
Paragraph::new(lines)
@@ -876,6 +948,29 @@ impl LoginAddAccountState {
self.feedback = Some(Feedback { message: error, is_error: true });
}
pub fn begin_device_code_flow(&mut self) {
if !matches!(self.step, AddStep::DeviceCode(_)) {
self.step = AddStep::DeviceCode(DeviceCodeState::generating());
}
self.feedback = Some(Feedback {
message: "Use the on-screen code to finish signing in.".to_string(),
is_error: false,
});
}
pub fn set_device_code_ready(&mut self, authorize_url: String, user_code: String) {
self.step = AddStep::DeviceCode(DeviceCodeState::with_details(authorize_url, user_code));
self.feedback = Some(Feedback {
message: "Enter the code in your browser to continue.".to_string(),
is_error: false,
});
}
pub fn on_device_code_failed(&mut self, error: String) {
self.step = AddStep::Choose { selected: 0 };
self.feedback = Some(Feedback { message: error, is_error: true });
}
pub fn on_chatgpt_complete(&mut self, result: Result<(), String>) {
match result {
Ok(()) => {
@@ -892,12 +987,14 @@ impl LoginAddAccountState {
}
}
pub fn cancel_chatgpt_wait(&mut self) {
pub fn cancel_active_flow(&mut self) {
let message = match self.step {
AddStep::DeviceCode(_) => "Cancelled code authentication",
AddStep::Waiting { .. } => "Cancelled ChatGPT login",
_ => "Cancelled login",
};
self.step = AddStep::Choose { selected: 0 };
self.feedback = Some(Feedback {
message: "Cancelled ChatGPT login".to_string(),
is_error: false,
});
self.feedback = Some(Feedback { message: message.to_string(), is_error: false });
}
fn finish_and_show_accounts(&mut self) {
@@ -910,6 +1007,24 @@ impl LoginAddAccountState {
}
}
impl DeviceCodeState {
fn generating() -> Self {
Self {
authorize_url: None,
user_code: None,
status: DeviceCodeStatus::Generating,
}
}
fn with_details(authorize_url: String, user_code: String) -> Self {
Self {
authorize_url: Some(authorize_url),
user_code: Some(user_code),
status: DeviceCodeStatus::WaitingForApproval,
}
}
}
impl AccountRow {
fn from(account: StoredAccount, active_id: Option<&str>) -> Self {
let id = account.id.clone();
+18 -2
View File
@@ -13680,12 +13680,28 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn notify_login_chatgpt_cancelled(&mut self) {
if self.with_login_add_view(|state| state.cancel_chatgpt_wait()) {
pub(crate) fn notify_login_device_code_pending(&mut self) {
let _ = self.with_login_add_view(|state| state.begin_device_code_flow());
}
pub(crate) fn notify_login_device_code_ready(&mut self, authorize_url: String, user_code: String) {
let _ = self.with_login_add_view(|state| state.set_device_code_ready(authorize_url.clone(), user_code.clone()));
}
pub(crate) fn notify_login_device_code_failed(&mut self, error: String) {
let _ = self.with_login_add_view(|state| state.on_device_code_failed(error.clone()));
}
pub(crate) fn notify_login_device_code_complete(&mut self, result: Result<(), String>) {
if self.with_login_add_view(|state| state.on_chatgpt_complete(result.clone())) {
return;
}
}
pub(crate) fn notify_login_flow_cancelled(&mut self) {
let _ = self.with_login_add_view(|state| state.cancel_active_flow());
}
pub(crate) fn login_add_view_active(&self) -> bool {
self.login_add_view_state
.as_ref()