use crate::output::{OutputConfig, OutputFormat};
use std::io::{BufRead, Write};
use std::process::{Command, Stdio};
#[derive(Debug)]
pub enum LoginError {
NoBrowserUnsupported(String),
MissingRedirectUrl,
InvalidStep1Output(String),
XurlFailed { step: u8, message: String },
Io(std::io::Error),
}
impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoginError::NoBrowserUnsupported(msg) => write!(
f,
"xurl does not support --no-browser; install xurl-rs (xr) >= 1.2.0: {}",
msg
),
LoginError::MissingRedirectUrl => write!(
f,
"no redirect URL received on stdin; expected the URL from your browser's address bar"
),
LoginError::InvalidStep1Output(msg) => {
write!(f, "could not parse authorization URL from xurl: {}", msg)
}
LoginError::XurlFailed { step, message } => {
write!(f, "xurl auth step {} failed: {}", step, message)
}
LoginError::Io(e) => write!(f, "I/O error driving xurl: {}", e),
}
}
}
impl std::error::Error for LoginError {}
impl From<std::io::Error> for LoginError {
fn from(e: std::io::Error) -> Self {
LoginError::Io(e)
}
}
#[derive(clap::Args, Debug, Clone)]
pub struct HeadlessAuthArgs {
#[arg(long = "no-browser", alias = "headless")]
pub no_browser: bool,
}
pub fn run_oauth2_authenticate_headless(
out: &OutputConfig,
stdout: &mut dyn Write,
xurl_path: &std::path::Path,
username: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut step1_args: Vec<String> = vec![
"auth".into(),
"oauth2".into(),
"--no-browser".into(),
"--step".into(),
"1".into(),
"--output".into(),
"json".into(),
];
if let Some(u) = username {
step1_args.push(u.into());
}
let step1 = Command::new(xurl_path)
.args(step1_args.iter().map(String::as_str))
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(LoginError::from)?;
if !step1.status.success() {
let stderr = String::from_utf8_lossy(&step1.stderr);
let trimmed = stderr.trim();
if trimmed.to_ascii_lowercase().contains("unexpected argument")
|| trimmed.to_ascii_lowercase().contains("unrecognized")
|| trimmed.to_ascii_lowercase().contains("unknown flag")
{
return Err(Box::new(LoginError::NoBrowserUnsupported(trimmed.into())));
}
return Err(Box::new(LoginError::XurlFailed {
step: 1,
message: trimmed.to_string(),
}));
}
let step1_stdout = String::from_utf8_lossy(&step1.stdout);
let last_line = step1_stdout
.lines()
.rfind(|line| line.trim_start().starts_with('{'))
.ok_or_else(|| LoginError::InvalidStep1Output(step1_stdout.to_string()))?;
let parsed: serde_json::Value = serde_json::from_str(last_line)
.map_err(|e| LoginError::InvalidStep1Output(format!("{}: {}", e, last_line)))?;
let auth_url = parsed
.get("auth_url")
.and_then(|v| v.as_str())
.ok_or_else(|| LoginError::InvalidStep1Output("missing auth_url field".into()))?;
let state = parse_oauth2_authorize_url_state(auth_url).unwrap_or_default();
match out.format {
OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson => {
let envelope = serde_json::json!({
"data": {
"auth_url": auth_url,
"state": state,
},
"meta": {
"awaiting": "callback_url_on_stdin",
},
});
writeln!(stdout, "{}", envelope)?;
}
OutputFormat::Text => {
writeln!(stdout, "Open this URL in any browser:\n")?;
writeln!(stdout, " {}\n", auth_url)?;
writeln!(
stdout,
"After authorizing, paste the full redirect URL from your browser here and press Enter:"
)?;
}
}
stdout.flush().ok();
let mut redirect_url = String::new();
let bytes = std::io::stdin().lock().read_line(&mut redirect_url)?;
let trimmed = redirect_url.trim();
if bytes == 0 || trimmed.is_empty() {
return Err(Box::new(LoginError::MissingRedirectUrl));
}
let trimmed = trimmed.to_string();
let mut step2_args: Vec<String> = vec![
"auth".into(),
"oauth2".into(),
"--no-browser".into(),
"--step".into(),
"2".into(),
"--auth-url".into(),
"-".into(),
];
if let Some(u) = username {
step2_args.push(u.into());
}
let mut step2 = Command::new(xurl_path)
.args(step2_args.iter().map(String::as_str))
.env("NO_COLOR", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(LoginError::from)?;
if let Some(mut stdin) = step2.stdin.take() {
stdin.write_all(trimmed.as_bytes())?;
stdin.write_all(b"\n")?;
}
let step2_out = step2.wait_with_output().map_err(LoginError::from)?;
if !step2_out.status.success() {
let stderr = String::from_utf8_lossy(&step2_out.stderr);
return Err(Box::new(LoginError::XurlFailed {
step: 2,
message: stderr.trim().to_string(),
}));
}
match out.format {
OutputFormat::Json | OutputFormat::Jsonl | OutputFormat::Ndjson => {
let envelope = serde_json::json!({
"data": {
"status": "authenticated",
},
"meta": {},
});
writeln!(stdout, "{}", envelope)?;
}
OutputFormat::Text => {
let step2_stdout = String::from_utf8_lossy(&step2_out.stdout);
let trimmed = crate::output::strip_ansi_lines(&step2_stdout);
let trimmed = trimmed.trim();
if trimmed.is_empty() {
writeln!(stdout, "OAuth2 authentication successful.")?;
} else {
writeln!(stdout, "{}", trimmed)?;
}
}
}
Ok(())
}
fn parse_oauth2_authorize_url_state(url_str: &str) -> Option<String> {
extract_query_param(url_str, "state")
}
fn extract_query_param(url_str: &str, key: &str) -> Option<String> {
let url = url::Url::parse(url_str).ok()?;
url.query_pairs()
.find_map(|(k, v)| if k == key { Some(v.into_owned()) } else { None })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_state_param_basic() {
let url =
"https://x.com/i/oauth2/authorize?response_type=code&state=abc123&code_challenge=xyz";
assert_eq!(extract_query_param(url, "state").as_deref(), Some("abc123"));
}
#[test]
fn extract_state_param_urlencoded() {
let url = "https://x.com/i/oauth2/authorize?state=y7pn7CS90LON5frx%2BTIJBi2dXxz71gAA%3D";
assert_eq!(
extract_query_param(url, "state").as_deref(),
Some("y7pn7CS90LON5frx+TIJBi2dXxz71gAA=")
);
}
#[test]
fn extract_state_param_missing() {
let url = "https://x.com/i/oauth2/authorize?response_type=code";
assert_eq!(extract_query_param(url, "state"), None);
}
#[test]
fn extract_state_param_invalid_url() {
assert_eq!(extract_query_param("not a url", "state"), None);
}
#[test]
fn login_error_display_unsupported() {
let e = LoginError::NoBrowserUnsupported("error: unrecognized flag --no-browser".into());
let msg = e.to_string();
assert!(msg.contains("xurl does not support --no-browser"));
assert!(msg.contains("xr"));
}
#[test]
fn login_error_display_missing_url() {
let e = LoginError::MissingRedirectUrl;
assert!(e.to_string().contains("no redirect URL"));
}
#[test]
fn login_error_display_xurl_failed() {
let e = LoginError::XurlFailed {
step: 2,
message: "state mismatch".into(),
};
let s = e.to_string();
assert!(s.contains("step 2"));
assert!(s.contains("state mismatch"));
}
}