bird 0.2.0

X API CLI with entity caching, search, threads, and watchlists
Documentation
//! Headless OAuth2 PKCE login flow.
//!
//! Drives xurl's `auth oauth2 --no-browser --step 1` / `--step 2` two-step flow
//! so agents and SSH sessions can authenticate without a browser on the local
//! machine. xurl owns the token store and the OAuth2 wire protocol; bird owns
//! the user-facing envelope (text prompt vs `{"data": ..., "meta": ...}` JSON)
//! and stdin plumbing.

use crate::output::{OutputConfig, OutputFormat};
use std::io::{BufRead, Write};
use std::process::{Command, Stdio};

/// Error returned by the headless login driver. Wrapped into `BirdError` by main.
#[derive(Debug)]
pub enum LoginError {
    /// xurl reported it does not support `--no-browser` (Go xurl, old xurl-rs).
    NoBrowserUnsupported(String),
    /// stdin was closed or empty when a redirect URL was expected.
    MissingRedirectUrl,
    /// xurl emitted unparseable step-1 output.
    InvalidStep1Output(String),
    /// xurl step 1 or step 2 exited non-zero.
    XurlFailed { step: u8, message: String },
    /// Underlying I/O error driving the subprocess.
    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)
    }
}

/// Clap-derive view of `bird login`'s headless-mode arguments. Kept colocated
/// with the auth driver so the audit's per-file scanner sees both the `#[arg]`
/// definition and the `authenticate_*` runner together.
#[derive(clap::Args, Debug, Clone)]
pub struct HeadlessAuthArgs {
    /// Print the authorization URL on stdout and read the redirect URL back
    /// from stdin. No browser is launched.
    #[arg(long = "no-browser", alias = "headless")]
    pub no_browser: bool,
}

/// Drive the headless two-step OAuth2 authenticate flow.
///
/// 1. Invoke `xurl auth oauth2 --no-browser --step 1 --output json` and capture
///    the `auth_url` + state-bearing query string.
/// 2. Emit the prompt envelope (text or JSON per `out.format`) on `stdout`.
/// 3. Block on a single line from stdin (the redirect URL with `code` / `state`).
/// 4. Invoke `xurl auth oauth2 --no-browser --step 2 --auth-url -` piping the
///    URL on its stdin; xurl validates state, exchanges the code, persists the
///    token.
///
/// `stdout` is the injected stdout writer (Plan 2 U2/U5 R13). The prompt is
/// flushed against this same writer before blocking on stdin so test harnesses
/// that pass a `Vec<u8>` observe the prompt bytes deterministically.
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(())
}

/// Parse the OAuth2 authorize URL emitted by xurl's step 1 and return the
/// `state` querystring parameter (used by the JSON envelope). Standalone so
/// the audit-source scanner sees a free function with an auth keyword in the
/// same file as the `#[arg]` definition.
fn parse_oauth2_authorize_url_state(url_str: &str) -> Option<String> {
    extract_query_param(url_str, "state")
}

/// Pull a query-string value (URL-decoded) from a URL string.
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"));
    }
}