leetcode-tui-auth 0.6.1

LeetCode authentication helpers for the leetui terminal UI
//! Credential handling for the LeetCode TUI.
//!
//! LeetCode's login page is protected by reCAPTCHA v3 and Cloudflare Turnstile,
//! so a plain HTTP POST with an email and password cannot work. Instead we run
//! the real login page inside an embedded webview and capture the session
//! cookies it produces.

use leetcode_tui_config::{AuthConfig, CONFIG};

pub mod persist;

#[cfg(feature = "webview-login")]
pub mod webview;

pub const SESSION_COOKIE: &str = "LEETCODE_SESSION";
pub const CSRF_COOKIE: &str = "csrftoken";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
    Env,
    Config,
    Webview,
    None,
}

impl Source {
    pub fn as_str(&self) -> &str {
        match self {
            Source::Env => "environment variables",
            Source::Config => "config file",
            Source::Webview => "login window",
            Source::None => "none",
        }
    }
}

impl std::fmt::Display for Source {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

#[derive(Debug, Clone)]
pub struct Credentials {
    pub session: String,
    pub csrf: String,
    pub source: Source,
}

impl Credentials {
    pub fn new(session: impl Into<String>, csrf: impl Into<String>, source: Source) -> Self {
        Self {
            session: session.into(),
            csrf: csrf.into(),
            source,
        }
    }

    /// Credentials without a session, used to keep the TUI usable with the
    /// cached database when signing in is not possible.
    pub fn anonymous() -> Self {
        Self::new(String::new(), String::new(), Source::None)
    }

    /// Makes sure the values are usable as cookie/header values.
    pub fn is_valid(&self) -> bool {
        !self.session.trim().is_empty() && !self.csrf.trim().is_empty()
    }
}

#[derive(Debug, thiserror::Error)]
pub enum AuthError {
    #[error("No LeetCode credentials are available. Run `leetui login` to sign in.")]
    NoCredentials,

    #[error("The login window was closed before signing in.")]
    LoginCancelled,

    #[error("Could not open the login window: {0}")]
    Webview(String),

    #[error("Could not save the session: {0}")]
    Persist(String),

    #[error(transparent)]
    Io(#[from] std::io::Error),
}

pub type AuthResult<T> = Result<T, AuthError>;

/// Reads credentials from `LEETCODE_SESSION` / `LEETCODE_CSRFTOKEN`.
pub fn from_env() -> Option<Credentials> {
    let session = std::env::var(SESSION_COOKIE).ok()?;
    let csrf = std::env::var("LEETCODE_CSRFTOKEN")
        .ok()
        .or_else(|| std::env::var("CSRFTOKEN").ok())?;
    let credentials = Credentials::new(session, csrf, Source::Env);
    credentials.is_valid().then_some(credentials)
}

/// Reads credentials that were persisted in the config file.
pub fn from_config() -> Option<Credentials> {
    let config = CONFIG.as_ref();
    let credentials = Credentials::new(
        config.lc_session.as_ref()?,
        config.csrftoken.as_ref()?,
        Source::Config,
    );
    credentials.is_valid().then_some(credentials)
}

/// Reads credentials straight from the config file on disk.
///
/// Unlike [`from_config`] this picks up a session saved by another process,
/// which is what the TUI needs after `leetui login` ran in a child process.
pub fn from_config_file() -> Option<Credentials> {
    let contents =
        std::fs::read_to_string(leetcode_tui_config::utils::get_config_file_path()).ok()?;
    let document = contents.parse::<toml_edit::DocumentMut>().ok()?;

    let session = document.get("lc_session")?.as_str()?;
    let csrf = document.get("csrftoken")?.as_str()?;

    let credentials = Credentials::new(session, csrf, Source::Config);
    credentials.is_valid().then_some(credentials)
}

/// Credentials available without any user interaction.
pub fn stored() -> Option<Credentials> {
    from_env().or_else(from_config_file).or_else(from_config)
}

/// Removes the embedded browser profile so the next login starts signed out.
/// Needed to sign in with a different account: while the webview profile has a
/// session, LeetCode redirects the login page straight to the dashboard.
pub fn reset_webview_profile(auth_config: &AuthConfig) -> AuthResult<()> {
    #[cfg(feature = "webview-login")]
    {
        webview::reset_profile(auth_config)
    }
    #[cfg(not(feature = "webview-login"))]
    {
        let _ = auth_config;
        Ok(())
    }
}

#[derive(Debug, Default, Clone)]
pub struct LoginOptions;

/// Opens the login window and returns the captured credentials.
/// The credentials are *not* persisted, see [`persist::save`].
///
/// `previous` is the session that was stored before this attempt. Cookies that
/// match it are ignored, so a webview profile that is still signed in does not
/// get captured before the user can interact with the page.
pub fn login(_options: LoginOptions, previous: Option<&Credentials>) -> AuthResult<Credentials> {
    let auth_config = CONFIG.as_ref().auth.clone();

    #[cfg(feature = "webview-login")]
    {
        if auth_config.webview {
            return match webview::run(&auth_config, previous) {
                Ok(credentials) => Ok(credentials),
                Err(AuthError::LoginCancelled) => Err(AuthError::LoginCancelled),
                Err(error) => {
                    log::warn!("login window failed: {error}");
                    Err(AuthError::Webview(error.to_string()))
                }
            };
        }
    }

    Err(AuthError::NoCredentials)
}