use std::{process::Stdio, sync::Mutex, time::Duration};
use anyhow::{Result, anyhow};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::Command,
task::AbortHandle,
time::timeout,
};
use crate::models::ProviderKind;
use super::{claude, codex};
const LOGIN_TIMEOUT: Duration = Duration::from_secs(180);
const LOGOUT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const LOGIN_PROGRESS_EVENT: &str = "burnrate-login-progress";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LoginProgress {
pub id: String,
pub line: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct LoginOutcome {
pub email: Option<String>,
}
#[derive(Default)]
pub(crate) struct LoginManager {
active: Mutex<Option<ActiveLogin>>,
}
struct ActiveLogin {
account_id: String,
is_reauth: bool,
abort: Option<AbortHandle>,
}
impl LoginManager {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn reserve(&self, account_id: &str, is_reauth: bool) -> Result<()> {
let mut guard = self.active.lock().expect("login manager lock");
if let Some(active) = guard.as_ref() {
return Err(anyhow!(
"A sign-in is already in progress for {}.",
active.account_id
));
}
*guard = Some(ActiveLogin {
account_id: account_id.to_string(),
is_reauth,
abort: None,
});
Ok(())
}
pub(crate) fn attach(&self, account_id: &str, abort: AbortHandle) {
let mut guard = self.active.lock().expect("login manager lock");
if let Some(active) = guard.as_mut()
&& active.account_id == account_id
{
active.abort = Some(abort);
}
}
pub(crate) fn finish(&self, account_id: &str) {
let mut guard = self.active.lock().expect("login manager lock");
if guard
.as_ref()
.is_some_and(|active| active.account_id == account_id)
{
*guard = None;
}
}
pub(crate) fn cancel(&self, account_id: &str) -> Option<bool> {
let mut guard = self.active.lock().expect("login manager lock");
let active = guard.as_ref()?;
if active.account_id != account_id {
return None;
}
let is_reauth = active.is_reauth;
if let Some(abort) = active.abort.as_ref() {
abort.abort();
}
*guard = None;
Some(is_reauth)
}
}
pub(crate) async fn run_login(
app: AppHandle,
provider: ProviderKind,
account_id: String,
config_dir: Option<String>,
email_hint: Option<String>,
) -> Result<LoginOutcome> {
let (binary, args, env_key) = login_command(provider, email_hint.as_deref())?;
let id = account_id.clone();
let mut on_progress = move |line: &str, url: Option<&str>| {
let _ = app.emit(
LOGIN_PROGRESS_EVENT,
LoginProgress {
id: id.clone(),
line: line.to_string(),
url: url.map(str::to_string),
},
);
};
run_login_inner(
&binary,
&args,
env_key,
config_dir.as_deref(),
true,
&mut on_progress,
)
.await?;
let email = verify(provider, config_dir.as_deref()).await?;
Ok(LoginOutcome { email })
}
#[cfg(target_os = "macos")]
pub(crate) fn delete_claude_keychain(account: &crate::models::AccountConfig) {
claude::delete_keychain_credentials(account);
}
#[cfg(target_os = "macos")]
pub(crate) fn delete_claude_keychain_for_dir(config_dir: Option<&str>) {
claude::delete_keychain_credentials_for_dir(config_dir);
}
pub(crate) async fn run_logout(provider: ProviderKind, config_dir: Option<&str>) -> Result<()> {
let (binary, args, env_key) = logout_command(provider)?;
let resolved = super::resolve_cli(&binary);
let mut command = Command::new(&resolved);
command
.args(&args)
.env("PATH", super::augmented_path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
if let Some(dir) = config_dir.map(str::trim).filter(|dir| !dir.is_empty()) {
command.env(env_key, dir);
}
let status = timeout(LOGOUT_TIMEOUT, command.status())
.await
.map_err(|_| anyhow!("Sign-out timed out."))?
.map_err(|err| anyhow!("Failed to run sign-out: {err}"))?;
if !status.success() {
return Err(anyhow!("{} sign-out did not complete.", provider.as_str()));
}
Ok(())
}
async fn run_login_inner(
binary: &str,
args: &[String],
env_key: &str,
config_dir: Option<&str>,
open_browser: bool,
on_progress: &mut (dyn FnMut(&str, Option<&str>) + Send),
) -> Result<()> {
let resolved = super::resolve_cli(binary);
let mut command = Command::new(&resolved);
command
.args(args)
.env("PATH", super::augmented_path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(dir) = config_dir.filter(|dir| !dir.trim().is_empty()) {
command.env(env_key, dir);
}
let mut child = command.spawn().map_err(|err| {
anyhow!(
"Failed to launch `{binary}`: {err}. Set BURNRATE_CLAUDE_BIN / BURNRATE_CODEX_BIN if the CLI lives elsewhere."
)
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("no stdout from sign-in process"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow!("no stderr from sign-in process"))?;
let mut out = BufReader::new(stdout).lines();
let mut err = BufReader::new(stderr).lines();
let mut opened = false;
let mut last_status: Option<String> = None;
let status = timeout(LOGIN_TIMEOUT, async {
let (mut out_done, mut err_done) = (false, false);
while !(out_done && err_done) {
let line = tokio::select! {
res = out.next_line(), if !out_done => match res? {
Some(line) => Some(line),
None => { out_done = true; None }
},
res = err.next_line(), if !err_done => match res? {
Some(line) => Some(line),
None => { err_done = true; None }
},
};
let Some(line) = line else { continue };
if let Some(url) = extract_url(&line) {
if open_browser && !opened {
open_url(&url);
}
opened = true;
on_progress("Opening your browser to finish signing in…", Some(&url));
} else if let Some(safe) = sanitize_line(&line) {
last_status = Some(safe.clone());
on_progress(&safe, None);
}
}
Ok::<_, anyhow::Error>(child.wait().await?)
})
.await
.map_err(|_| {
anyhow!(
"Sign-in timed out after {} seconds.",
LOGIN_TIMEOUT.as_secs()
)
})??;
if !status.success() {
return Err(anyhow!(
"Sign-in did not complete: {}",
last_status.unwrap_or_else(|| "the CLI exited before authenticating".to_string())
));
}
Ok(())
}
fn login_command(
provider: ProviderKind,
email_hint: Option<&str>,
) -> Result<(String, Vec<String>, &'static str)> {
match provider {
ProviderKind::ClaudeCode => Ok((
claude::claude_binary(),
claude::claude_login_args(email_hint),
"CLAUDE_CONFIG_DIR",
)),
ProviderKind::Codex => Ok((
codex::codex_binary(),
codex::codex_login_args(),
"CODEX_HOME",
)),
_ => Err(anyhow!(
"Interactive sign-in is only available for Claude Code and Codex."
)),
}
}
fn logout_command(provider: ProviderKind) -> Result<(String, Vec<String>, &'static str)> {
match provider {
ProviderKind::ClaudeCode => Ok((
claude::claude_binary(),
claude::claude_logout_args(),
"CLAUDE_CONFIG_DIR",
)),
ProviderKind::Codex => Ok((
codex::codex_binary(),
codex::codex_logout_args(),
"CODEX_HOME",
)),
_ => Err(anyhow!(
"Sign-out is only available for Claude Code and Codex."
)),
}
}
async fn verify(provider: ProviderKind, config_dir: Option<&str>) -> Result<Option<String>> {
match provider {
ProviderKind::ClaudeCode => claude::login_verify(config_dir).await,
ProviderKind::Codex => codex::login_verify(config_dir),
_ => Ok(None),
}
}
fn extract_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let rest = &line[start..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let url = rest[..end].trim_end_matches(['.', ',', ')', ']', '}', '"', '\'']);
(url.len() > "https://".len()).then(|| url.to_string())
}
fn contains_secret(line: &str) -> bool {
line.contains("sk-ant-") || line.contains("eyJ") || has_long_token_run(line)
}
fn has_long_token_run(line: &str) -> bool {
let mut run = 0usize;
for ch in line.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
run += 1;
if run >= 40 {
return true;
}
} else {
run = 0;
}
}
false
}
fn sanitize_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || contains_secret(trimmed) {
return None;
}
Some(trimmed.chars().take(200).collect())
}
fn open_url(url: &str) {
#[cfg(target_os = "macos")]
let program = "open";
#[cfg(target_os = "linux")]
let program = "xdg-open";
#[cfg(target_os = "windows")]
let program = "explorer";
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let program = "open";
let _ = std::process::Command::new(program).arg(url).spawn();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_https_url_and_trims_punctuation() {
assert_eq!(
extract_url("Visit https://auth.openai.com/device?code=ABC to continue."),
Some("https://auth.openai.com/device?code=ABC".to_string())
);
assert_eq!(
extract_url("Open (https://claude.ai/oauth/authorize?x=1)"),
Some("https://claude.ai/oauth/authorize?x=1".to_string())
);
assert_eq!(extract_url("no link here"), None);
assert_eq!(extract_url("http://insecure.example"), None);
}
#[test]
fn detects_secret_shaped_lines() {
assert!(contains_secret("token: sk-ant-oat01-deadbeef"));
assert!(contains_secret("id_token eyJhbGciOiJSUzI1NiJ9.payload"));
assert!(contains_secret(&format!("blob {}", "a".repeat(40))));
assert!(!contains_secret(
"Waiting for you to authorize in the browser"
));
}
#[test]
fn sanitize_line_drops_secrets_and_empties_keeps_status() {
assert_eq!(sanitize_line(" "), None);
assert_eq!(sanitize_line("sk-ant-oat01-secretvalue"), None);
assert_eq!(
sanitize_line(" Waiting for authorization "),
Some("Waiting for authorization".to_string())
);
}
#[test]
fn login_manager_is_single_flight() {
let manager = LoginManager::new();
manager.reserve("claude-code-a", false).unwrap();
assert!(manager.reserve("codex-b", false).is_err());
manager.finish("claude-code-a");
manager.reserve("codex-b", true).unwrap();
assert_eq!(manager.cancel("claude-code-a"), None);
assert_eq!(manager.cancel("codex-b"), Some(true));
manager.reserve("claude-code-c", false).unwrap();
assert_eq!(manager.cancel("claude-code-c"), Some(false));
}
#[test]
fn login_command_rejects_non_cli_providers() {
assert!(login_command(ProviderKind::OpenRouter, None).is_err());
assert!(logout_command(ProviderKind::Runpod).is_err());
let (_, args, env_key) = login_command(ProviderKind::Codex, None).unwrap();
assert_eq!(args, vec!["login"]);
assert_eq!(env_key, "CODEX_HOME");
let (_, args, env_key) = login_command(ProviderKind::ClaudeCode, Some("a@b.com")).unwrap();
assert_eq!(env_key, "CLAUDE_CONFIG_DIR");
assert!(args.contains(&"--claudeai".to_string()));
}
#[cfg(unix)]
#[tokio::test]
async fn run_login_inner_streams_url_and_passes_config_dir() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("login-fixture");
std::fs::write(
&binary,
r#"#!/bin/sh
echo "token sk-ant-oat01-supersecretvalue"
echo "Open https://auth.example/device?code=XYZ in your browser"
echo "configdir=$CODEX_HOME" > "$CODEX_HOME/seen"
exit 0
"#,
)
.unwrap();
let mut perms = std::fs::metadata(&binary).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary, perms).unwrap();
let home = dir.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let mut events: Vec<(String, Option<String>)> = Vec::new();
let mut on_progress = |line: &str, url: Option<&str>| {
events.push((line.to_string(), url.map(str::to_string)))
};
run_login_inner(
&binary.to_string_lossy(),
&["login".to_string()],
"CODEX_HOME",
Some(home.to_str().unwrap()),
false,
&mut on_progress,
)
.await
.unwrap();
assert!(
events
.iter()
.any(|(_, url)| url.as_deref() == Some("https://auth.example/device?code=XYZ"))
);
assert!(events.iter().all(|(line, _)| !line.contains("sk-ant-")));
let seen = std::fs::read_to_string(home.join("seen")).unwrap();
assert!(seen.contains(home.to_str().unwrap()));
}
#[cfg(unix)]
#[tokio::test]
async fn run_login_inner_errors_on_nonzero_exit() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("fail-fixture");
std::fs::write(
&binary,
"#!/bin/sh\necho \"could not reach the auth server\"\nexit 1\n",
)
.unwrap();
let mut perms = std::fs::metadata(&binary).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary, perms).unwrap();
let mut on_progress = |_: &str, _: Option<&str>| {};
let error = run_login_inner(
&binary.to_string_lossy(),
&["login".to_string()],
"CODEX_HOME",
Some(dir.path().to_str().unwrap()),
false,
&mut on_progress,
)
.await
.unwrap_err()
.to_string();
assert!(error.contains("Sign-in did not complete"));
}
}