// Who a command acts as, always explicit, never read from the environment: // --as the user's OAuth session, saved by `atlibs login` (the normal path) // --app-password an app password (the simple / testing case), from // --password-file a file (- : stdin), or // --password-env the variable NAMED here (the password is never in argv) // [--pds ] 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 [--scope "atproto transition:generic"] [--no-open] // atlibs logout // login is the loopback OAuth client, as knotless's (crates/knotless/src/oauth.rs start/finish): a // listener on 127.0.0.1:, 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> = 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, 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, read: impl Fn(&str) -> std::io::Result) -> Result<(Option, Vec), 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 ".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 { 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 { 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>, 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 = "atlibs

"; /// The query the browser comes back to /oauth/callback with; anything else it asks for is not found. async fn callback(listener: &TcpListener) -> Result, 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 = 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, ¶ms).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, Vec), String> { let argv: Vec = 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:?}"); } } }