mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(login): add device code fallback in TUI
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user