use anyhow::Result;
use atproto_client::client::{
AppPasswordAuth, get_apppassword_json_with_headers, post_apppassword_json_with_headers,
};
use atproto_client::errors::CliError;
use atproto_identity::{
config::{CertificateBundles, DnsNameservers, default_env, optional_env, version},
plc,
resolve::{HickoryDnsResolver, resolve_subject},
web,
};
use clap::Parser;
use reqwest::header::HeaderMap;
use rpassword::read_password;
use secrecy::{ExposeSecret, SecretString};
use std::{
collections::HashMap,
io::{self, Write},
};
#[derive(Parser)]
#[command(
name = "atproto-client-app-password",
version,
about = "Make authenticated XRPC calls using AT Protocol app password authentication",
long_about = "
A command-line tool for making authenticated XRPC calls using app password
authentication. Resolves subjects to DID documents and constructs authenticated
requests to AT Protocol services.
XRPC PATH FORMATS:
query:<path> For GET requests (default if no prefix)
procedure:<path> For POST requests (requires JSON file)
<path> Defaults to GET request
ADDITIONAL ARGUMENTS:
key=value Query parameters (for GET requests)
header=name=value Additional HTTP headers
<file_path> JSON file path (required for procedure calls)
EXAMPLES:
# GET request:
atproto-client-app-password alice.bsky.social eyJ0... \\
com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post
# POST request:
atproto-client-app-password alice.bsky.social eyJ0... \\
procedure:com.atproto.repo.createRecord data.json
ENVIRONMENT VARIABLES:
PLC_HOSTNAME PLC directory hostname (default: plc.directory)
USER_AGENT HTTP user agent string (default: auto-generated)
CERTIFICATE_BUNDLES Additional CA certificate bundles
DNS_NAMESERVERS Custom DNS nameserver addresses
"
)]
struct Args {
subject: String,
#[arg(long, env = "ATPROTO_ACCESS_TOKEN")]
access_token: Option<String>,
xrpc_path: String,
additional_args: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let subject = &args.subject;
let access_token = if let Some(env_token) = args.access_token {
SecretString::new(env_token.into())
} else {
print!("Enter access token: ");
io::stdout().flush()?;
let token = read_password()?;
if token.is_empty() {
anyhow::bail!("Access token cannot be empty");
}
SecretString::new(token.into())
};
let xrpc_path_with_prefix = &args.xrpc_path;
let (is_procedure, xrpc_path) = if let Some(path) = xrpc_path_with_prefix.strip_prefix("query:")
{
(false, path)
} else if let Some(path) = xrpc_path_with_prefix.strip_prefix("procedure:") {
(true, path)
} else {
(false, xrpc_path_with_prefix.as_str())
};
let mut query_params = HashMap::new();
let mut header_params = HashMap::new();
let mut json_data: Option<serde_json::Value> = None;
let mut arg_index = 0;
if is_procedure {
if arg_index >= args.additional_args.len() {
anyhow::bail!("procedure: prefix requires a JSON file path as the next argument");
}
let file_path = &args.additional_args[arg_index];
let file_content =
std::fs::read_to_string(file_path).map_err(|_| CliError::FileReadFailed {
path: file_path.clone(),
})?;
json_data = Some(serde_json::from_str(&file_content).map_err(|_| {
CliError::JsonParseFromFileFailed {
path: file_path.clone(),
}
})?);
arg_index += 1;
}
for arg in &args.additional_args[arg_index..] {
if let Some((key, value)) = arg.split_once('=') {
if key == "header" {
if let Some((header_name, header_value)) = value.split_once('=') {
header_params.insert(header_name.to_string(), header_value.to_string());
} else {
eprintln!("Warning: Ignoring invalid header format: {}", arg);
eprintln!("Expected format: header=name=value");
}
} else if is_procedure {
eprintln!(
"Warning: Query parameters are not supported for procedure calls. Ignoring: {}",
arg
);
} else {
query_params.insert(key.to_string(), value.to_string());
}
} else {
eprintln!("Warning: Ignoring invalid argument format: {}", arg);
eprintln!("Expected format: key=value or header=name=value");
}
}
println!("Making app password authenticated XRPC call");
println!("Subject: {}", subject);
println!(
"Request Type: {}",
if is_procedure {
"POST (procedure)"
} else {
"GET (query)"
}
);
println!("XRPC Path: {}", xrpc_path);
if !query_params.is_empty() {
println!("Query Parameters: {:?}", query_params);
}
if !header_params.is_empty() {
println!("Additional Headers: {:?}", header_params);
}
if let Some(ref data) = json_data {
println!("JSON Data: {}", serde_json::to_string_pretty(data)?);
}
let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?;
let default_user_agent = format!(
"atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)",
version()?
);
let user_agent = default_env("USER_AGENT", &default_user_agent);
let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?;
let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory");
let mut client_builder = reqwest::Client::builder();
for ca_certificate in certificate_bundles.as_ref() {
let cert = std::fs::read(ca_certificate)?;
let cert = reqwest::Certificate::from_pem(&cert)?;
client_builder = client_builder.add_root_certificate(cert);
}
client_builder = client_builder.user_agent(user_agent);
let http_client = client_builder.build()?;
let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref());
println!("Resolving subject: {}", subject);
let did = resolve_subject(&http_client, &dns_resolver, subject).await?;
println!("Resolved DID: {}", did);
let document = if did.starts_with("did:plc:") {
plc::query(&http_client, &plc_hostname, &did).await?
} else if did.starts_with("did:web:") {
web::query(&http_client, &did).await?
} else {
anyhow::bail!("Unsupported DID method: {}", did);
};
println!("Retrieved DID document for: {}", document.id);
let pds_endpoints = document.pds_endpoints();
let pds_endpoint = pds_endpoints
.first()
.ok_or_else(|| CliError::NoPdsEndpointFound { did: did.clone() })?;
println!("Using PDS endpoint: {}", pds_endpoint);
let mut url = format!("{}/xrpc/{}", pds_endpoint, xrpc_path);
if !is_procedure && !query_params.is_empty() {
let query_string: Vec<String> = query_params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect();
url.push('?');
url.push_str(&query_string.join("&"));
}
println!("Request URL: {}", url);
let app_auth = AppPasswordAuth {
access_token: access_token.expose_secret().to_string(),
};
let mut additional_headers = HeaderMap::new();
for (name, value) in header_params {
if let (Ok(header_name), Ok(header_value)) = (
name.parse::<reqwest::header::HeaderName>(),
value.parse::<reqwest::header::HeaderValue>(),
) {
additional_headers.insert(header_name, header_value);
} else {
eprintln!("Warning: Invalid header name or value: {}={}", name, value);
}
}
println!("Making app password authenticated request...");
let response = if is_procedure {
let data = json_data.ok_or_else(|| CliError::NoJsonDataProvided)?;
post_apppassword_json_with_headers(&http_client, &app_auth, &url, data, &additional_headers)
.await?
} else {
get_apppassword_json_with_headers(&http_client, &app_auth, &url, &additional_headers)
.await?
};
println!("Response:");
println!("{}", serde_json::to_string_pretty(&response)?);
Ok(())
}