~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
226 lines · 12.0 KB Raw History
// Who a command acts as, always explicit, never read from the environment:
//   --as <handle|did>                 the user's OAuth session, saved by `atlibs login` (the normal path)
//   --app-password <handle>           an app password (the simple / testing case), from
//     --password-file <path|->          a file (- : stdin), or
//     --password-env <NAME>             the variable NAMED here (the password is never in argv)
//     [--pds <url>]                     where to createSession (default https://bsky.social)
// No flag: no session (signed out). These flags work with every command (taken before it parses its own).
//
//   atlibs login <handle|did> [--scope "atproto transition:generic"] [--no-open]
//   atlibs logout <handle|did>
// login is the loopback OAuth client, as knotless's (crates/knotless/src/oauth.rs start/finish): a
// listener on 127.0.0.1:<any port>, the browser sent to the account's authorization server (ATLIBS_BROWSER,
// a command the URL is added to, else open / xdg-open; the URL is printed either way), the code taken
// back on /oauth/callback and redeemed (atlibs_host::oauth_client). The session is saved per DID,
// sealed, in the state dir; runs refresh it (one refresh per process and across processes).

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};

use atlibs_host::Signal;
use atlibs_host::host::{Session, SessionConfig};
use atlibs_host::oauth_client::{self, Client};
use serde_json::{Map, Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

use crate::{Args, Cli};

pub const DEFAULT_SCOPE: &str = "atproto transition:generic";

static CONFIG: OnceLock<Option<SessionConfig>> = OnceLock::new();

/// argv without the session flags (and the flags' config kept for Cli::session). Must run before a
/// command parses its own flags.
pub fn take_flags(argv: &[String]) -> Result<Vec<String>, String> {
    let (config, rest) = parse_flags(argv, |name| std::env::var(name).ok(), |path| match path {
        "-" => std::io::read_to_string(std::io::stdin()),
        path => std::fs::read_to_string(path),
    })?;
    let _ = CONFIG.set(config);
    Ok(rest)
}

const FLAGS: [&str; 5] = ["as", "app-password", "password-file", "password-env", "pds"];

/// The session flags in argv (`--flag value` or `--flag=value`) as a SessionConfig, and the rest.
pub fn parse_flags(argv: &[String], var: impl Fn(&str) -> Option<String>, read: impl Fn(&str) -> std::io::Result<String>) -> Result<(Option<SessionConfig>, Vec<String>), String> {
    let (mut found, mut rest) = (HashMap::new(), vec![]);
    let mut it = argv.iter();
    while let Some(arg) = it.next() {
        let flag = arg.strip_prefix("--").map(|f| f.split_once('=').map_or((f, None), |(k, v)| (k, Some(v.to_string()))));
        match flag {
            Some((name, inline)) if FLAGS.contains(&name) => {
                let value = match inline {
                    Some(v) => v,
                    None => it.next().ok_or(format!("--{name} needs a value"))?.clone(),
                };
                if found.insert(name, value).is_some() {
                    return Err(format!("--{name} given twice"));
                }
            }
            _ => rest.push(arg.clone()),
        }
    }
    let password = match (found.get("password-file"), found.get("password-env")) {
        (Some(path), None) => Some(read(path).map_err(|e| format!("--password-file {path}: {e}"))?.lines().next().unwrap_or_default().trim().to_string()),
        (None, Some(name)) => Some(var(name).ok_or(format!("--password-env {name}: {name} is not set"))?),
        (None, None) => None,
        _ => return Err("give one of --password-file and --password-env".into()),
    };
    if password.as_ref().is_some_and(String::is_empty) {
        return Err("the app password is empty".into());
    }
    let config = match (found.get("as"), found.get("app-password")) {
        (Some(_), Some(_)) => return Err("--as (an OAuth session) and --app-password are two accounts: give one".into()),
        (Some(who), None) if password.is_none() && !found.contains_key("pds") => Some(SessionConfig::OAuth { did: who.clone() }),
        (Some(_), None) => return Err("--as takes no password: sign in with atlibs login".into()),
        (None, Some(handle)) => Some(SessionConfig::AppPassword { identifier: handle.clone(), password, service: found.get("pds").cloned().unwrap_or_else(|| "https://bsky.social".into()) }),
        (None, None) if password.is_some() || found.contains_key("pds") => return Err("--password-file / --password-env / --pds go with --app-password <handle>".into()),
        (None, None) => None,
    };
    Ok((config, rest))
}

/// The DID `--as` / login / logout name: a DID as is; a handle by the state dir's index (atlibs login
/// keeps it), else resolved.
async fn did_of(cli: &Cli, who: &str) -> Result<String, String> {
    if who.starts_with("did:") {
        return Ok(who.into());
    }
    let who = who.trim_start_matches('@');
    match cli.store()?.get("atlibs-server:kv", &format!("oauth-did:{who}"))?.and_then(|v| v["did"].as_str().map(String::from)) {
        Some(did) => Ok(did),
        None => cli.net.resolve_handle(who).await,
    }
}

/// The handle the session flags named (--as / --app-password), unless they named a DID: how output shows
/// this account's records with no lookup.
pub fn handle() -> Option<String> {
    let who = match CONFIG.get()?.as_ref()? {
        SessionConfig::OAuth { did } => did,
        SessionConfig::AppPassword { identifier, .. } => identifier,
    };
    let who = who.trim_start_matches('@');
    (!who.starts_with("did:")).then(|| who.to_string())
}

/// The session the flags named (Cli::session); None without one.
pub async fn session(cli: &Cli) -> Result<Option<Arc<Session>>, String> {
    let config = match CONFIG.get().cloned().flatten() {
        Some(SessionConfig::OAuth { did }) => SessionConfig::OAuth { did: did_of(cli, &did).await? },
        Some(other) => other,
        None => return Ok(None),
    };
    config.open(cli.fetch.clone(), Some(cli.store()?.clone())).await.map(Some)
}

#[cfg(target_os = "macos")]
const OPENER: &str = "open";
#[cfg(not(target_os = "macos"))]
const OPENER: &str = "xdg-open";

fn open(url: &str) {
    let command = crate::env("ATLIBS_BROWSER").unwrap_or_else(|| OPENER.into());
    let mut words = command.split_whitespace();
    if let Some(program) = words.next() {
        let _ = tokio::process::Command::new(program).args(words).arg(url).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).spawn();
    }
}

const PAGE: &str = "<!doctype html><meta charset=utf-8><title>atlibs</title><body style=\"font:16px system-ui;display:grid;place-items:center;min-height:90vh\"><p>";

/// The query the browser comes back to /oauth/callback with; anything else it asks for is not found.
async fn callback(listener: &TcpListener) -> Result<Map<String, Value>, String> {
    loop {
        let (mut stream, _) = listener.accept().await.map_err(|e| e.to_string())?;
        let mut buf = vec![0; 16 * 1024];
        let n = stream.read(&mut buf).await.map_err(|e| e.to_string())?;
        let head = String::from_utf8_lossy(&buf[..n]);
        let target = head.split(' ').nth(1).unwrap_or_default();
        let Some(url) = url::Url::parse(&format!("http://127.0.0.1{target}")).ok().filter(|u| u.path() == "/oauth/callback") else {
            let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n").await;
            continue;
        };
        let params: Map<String, Value> = url.query_pairs().map(|(k, v)| (k.into_owned(), json!(v))).collect();
        let line = if params.contains_key("code") { "Signed in. You can close this tab and go back to your terminal." } else { "The sign-in did not go through: see your terminal." };
        let body = format!("{PAGE}{line}");
        let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).as_bytes()).await;
        return Ok(params);
    }
}

pub async fn login(cli: &Cli, argv: &[String], signal: Signal) -> Result<(), String> {
    let args = Args::parse(argv, &["no-open"])?;
    args.only(&["scope", "no-open"], "login")?;
    let [who] = &args.pos[..] else { return Err(crate::usage("login")) };
    let who = who.trim_start_matches('@');
    let did = if who.starts_with("did:") { who.to_string() } else { cli.net.resolve_handle(who).await? };
    let pds = cli.net.pds(&did).await?;
    let listener = TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
    let client = Client::loopback(&format!("http://127.0.0.1:{port}/oauth/callback"), args.flag("scope").unwrap_or(DEFAULT_SCOPE));
    let (url, pending) = oauth_client::begin(cli.fetch.as_ref(), &client, &did, &pds, who).await?;
    eprintln!("atlibs login: sign in as {who} at\n  {url}");
    if !args.has("no-open") {
        open(&url);
    }
    let params = tokio::select! {
        params = callback(&listener) => params?,
        reason = signal.aborted() => return Err(reason),
    };
    let mut saved = oauth_client::complete(cli.fetch.as_ref(), &client, &pending, &params).await?;
    let store = cli.store()?;
    if !who.starts_with("did:") {
        saved["handle"] = json!(who);
        store.put("atlibs-server:kv", &format!("oauth-did:{who}"), &json!({ "did": did }))?;
    }
    store.set_setting(&oauth_client::setting(&did), &saved)?;
    out!("signed in as {who} ({did}); act as it with --as {who}");
    Ok(())
}

pub async fn logout(cli: &Cli, argv: &[String]) -> Result<(), String> {
    let args = Args::parse(argv, &[])?;
    args.only(&[], "logout")?;
    let [who] = &args.pos[..] else { return Err(crate::usage("logout")) };
    let did = did_of(cli, who).await?;
    let store = cli.store()?;
    let had = oauth_client::sign_out(cli.fetch.as_ref(), store, &did).await;
    if !who.starts_with("did:") {
        store.delete("atlibs-server:kv", &format!("oauth-did:{}", who.trim_start_matches('@')))?;
    }
    match had? {
        true => out!("signed out {who} ({did}): its tokens are revoked and the session forgotten"),
        false => out!("{who} ({did}) had no OAuth session here"),
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(argv: &[&str]) -> Result<(Option<SessionConfig>, Vec<String>), String> {
        let argv: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
        parse_flags(&argv, |n| (n == "PW").then(|| "secret".into()), |p| if p == "pw.txt" { Ok("filepw\nignored".into()) } else { Err(std::io::Error::other("no such file")) })
    }

    #[test]
    fn sessions_are_explicit_flags_only() {
        let (config, rest) = parse(&["run", "x", "--as", "alice.test", "e"]).unwrap();
        assert!(matches!(config, Some(SessionConfig::OAuth { did }) if did == "alice.test"));
        assert_eq!(rest, ["run", "x", "e"]);
        let (config, _) = parse(&["--app-password=bob.test", "--password-env", "PW"]).unwrap();
        assert!(matches!(config, Some(SessionConfig::AppPassword { identifier, password: Some(p), service }) if identifier == "bob.test" && p == "secret" && service == "https://bsky.social"));
        let (config, _) = parse(&["--app-password", "bob.test", "--password-file", "pw.txt", "--pds", "http://pds"]).unwrap();
        assert!(matches!(config, Some(SessionConfig::AppPassword { password: Some(p), service, .. }) if p == "filepw" && service == "http://pds"));
        let (config, _) = parse(&["--app-password", "bob.test"]).unwrap();
        assert!(matches!(config, Some(SessionConfig::AppPassword { password: None, .. })), "the saved session of bob.test");
        assert!(parse(&["run", "x"]).unwrap().0.is_none(), "no flags, no session (the environment is never read)");
        for bad in [&["--password-env", "PW"][..], &["--as", "a", "--app-password", "b"], &["--app-password", "b", "--password-env", "NOPE"], &["--app-password", "b", "--password-env", "PW", "--password-file", "pw.txt"], &["--as"], &["--as", "a", "--as", "b"], &["--app-password", "b", "--password-file", "missing"]] {
            assert!(parse(bad).is_err(), "{bad:?}");
        }
    }
}