From 991bef89ba6b4adc8e973459e5c3a1deef810622 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Sun, 19 Feb 2023 19:38:43 -0800 Subject: cli: crude login-via-user-pass flow --- adenosine-cli/src/bin/adenosine.rs | 213 +++++++++++++++++++++++++------------ adenosine/src/xrpc.rs | 65 ++++++++--- 2 files changed, 196 insertions(+), 82 deletions(-) diff --git a/adenosine-cli/src/bin/adenosine.rs b/adenosine-cli/src/bin/adenosine.rs index 86b43eb..f3429b6 100644 --- a/adenosine-cli/src/bin/adenosine.rs +++ b/adenosine-cli/src/bin/adenosine.rs @@ -38,6 +38,24 @@ struct Opt { )] auth_token: Option, + /// Authentication handle (username), for commands that need it + #[structopt( + global = true, + long = "--auth-handle", + env = "ATP_AUTH_HANDLE", + hide_env_values = true + )] + auth_handle: Option, + + /// Authentication password, for commands that need it + #[structopt( + global = true, + long = "--auth-password", + env = "ATP_AUTH_PASSWORD", + hide_env_values = true + )] + auth_password: Option, + /// Log more messages. Pass multiple times for ever more verbosity /// /// By default, it'll only report errors. Passing `-v` one time also prints @@ -280,8 +298,28 @@ fn print_result_json(result: Option) -> Result<()> { Ok(()) } +/// Helper for endpoints that require authentication. +/// +/// If an author token already exists, use it to refresh the session. If no auth token is provided, +/// tries using handle/password to login, resulting in a session auth token. +/// +/// Returns DID passed from session token if auth was successful, otherwise an Error. +fn require_auth_did(opt: &Opt, xrpc_client: &mut XrpcClient) -> Result { + if opt.auth_token.is_some() { + // TODO: currently clobbers session + //xrpc_client.auth_refresh()?; + } else if let (Some(handle), Some(passwd)) = (&opt.auth_handle, &opt.auth_password) { + xrpc_client.auth_login(handle, passwd)?; + } else { + return Err(anyhow!( + "command requires auth, but have neither token orhandle/password" + )); + } + xrpc_client.auth_did() +} + fn run(opt: Opt) -> Result<()> { - let xrpc_client = XrpcClient::new(opt.atp_host.clone(), opt.auth_token.clone())?; + let mut xrpc_client = XrpcClient::new(opt.atp_host.clone(), opt.auth_token.clone())?; let mut params: HashMap = HashMap::new(); let jwt_did: Option = if let Some(ref token) = opt.auth_token { Some(parse_did_from_jwt(token)?) @@ -366,9 +404,12 @@ fn run(opt: Opt) -> Result<()> { } None } - Command::Create { collection, fields } => { - let did = jwt_did.ok_or(anyhow!("need auth token"))?; - let val = value_from_fields(fields); + Command::Create { + ref collection, + ref fields, + } => { + let did = require_auth_did(&opt, &mut xrpc_client)?; + let val = value_from_fields(fields.clone()); xrpc_client.post( &Nsid::from_str("com.atproto.repo.createRecord")?, None, @@ -380,10 +421,17 @@ fn run(opt: Opt) -> Result<()> { })), )? } - Command::Update { uri, fields } => { + Command::Update { + ref uri, + ref fields, + } => { + require_auth_did(&opt, &mut xrpc_client)?; let did = uri.repository.to_string(); - let collection = uri.collection.ok_or(anyhow!("collection required"))?; - let rkey = uri.record.ok_or(anyhow!("record key required"))?; + let collection = uri + .collection + .clone() + .ok_or(anyhow!("collection required"))?; + let rkey = uri.record.clone().ok_or(anyhow!("record key required"))?; params.insert("did".to_string(), did.clone()); params.insert("collection".to_string(), collection.clone()); params.insert("rkey".to_string(), rkey.clone()); @@ -391,7 +439,7 @@ fn run(opt: Opt) -> Result<()> { let mut record = xrpc_client .get(&Nsid::from_str("com.atproto.repo.getRecord")?, Some(params))? .unwrap_or(json!({})); - update_value_from_fields(fields, &mut record); + update_value_from_fields(fields.clone(), &mut record); xrpc_client.post( &Nsid::from_str("com.atproto.repo.putRecord")?, None, @@ -403,10 +451,14 @@ fn run(opt: Opt) -> Result<()> { })), )? } - Command::Delete { uri } => { + Command::Delete { ref uri } => { + require_auth_did(&opt, &mut xrpc_client)?; let did = uri.repository.to_string(); - let collection = uri.collection.ok_or(anyhow!("collection required"))?; - let rkey = uri.record.ok_or(anyhow!("record key required"))?; + let collection = uri + .collection + .clone() + .ok_or(anyhow!("collection required"))?; + let rkey = uri.record.clone().ok_or(anyhow!("record key required"))?; xrpc_client.post( &Nsid::from_str("com.atproto.repo.deleteRecord")?, None, @@ -418,15 +470,18 @@ fn run(opt: Opt) -> Result<()> { )? } Command::Xrpc { - method, - nsid, - fields, + ref method, + ref nsid, + ref fields, } => { update_params_from_fields(&fields, &mut params); - let body = value_from_fields(fields); + let body = value_from_fields(fields.clone()); match method { XrpcMethod::Get => xrpc_client.get(&nsid, Some(params))?, - XrpcMethod::Post => xrpc_client.post(&nsid, Some(params), Some(body))?, + XrpcMethod::Post => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.post(&nsid, Some(params), Some(body))? + } } } Command::Account { @@ -538,66 +593,84 @@ fn run(opt: Opt) -> Result<()> { } Command::Bsky { cmd: BskyCommand::Timeline, - } => xrpc_client.get(&Nsid::from_str("app.bsky.feed.getTimeline")?, None)?, + } => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.get(&Nsid::from_str("app.bsky.feed.getTimeline")?, None)? + } Command::Bsky { cmd: BskyCommand::Notifications, - } => xrpc_client.get(&Nsid::from_str("app.bsky.notifications.get")?, None)?, + } => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.get(&Nsid::from_str("app.bsky.notifications.get")?, None)? + } Command::Bsky { - cmd: BskyCommand::Post { text }, - } => xrpc_client.post( - &Nsid::from_str("com.atproto.repo.createRecord")?, - None, - Some(json!({ - "did": jwt_did.ok_or(anyhow!("need auth token"))?, - "collection": "app.bsky.feed.post", - "record": { - "text": text, - "createdAt": created_at_now(), - }, - })), - )?, + cmd: BskyCommand::Post { ref text }, + } => { + let did = require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.post( + &Nsid::from_str("com.atproto.repo.createRecord")?, + None, + Some(json!({ + "did": did, + "collection": "app.bsky.feed.post", + "record": { + "text": text, + "createdAt": created_at_now(), + }, + })), + )? + } Command::Bsky { - cmd: BskyCommand::Repost { uri }, - } => xrpc_client.post( - &Nsid::from_str("com.atproto.repo.createRecord")?, - None, - Some(json!({ - "did": jwt_did.ok_or(anyhow!("need auth token"))?, - "collection": "app.bsky.feed.repost", - "record": { - "subject": uri.to_string(), - "createdAt": created_at_now(), - } - })), - )?, + cmd: BskyCommand::Repost { ref uri }, + } => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.post( + &Nsid::from_str("com.atproto.repo.createRecord")?, + None, + Some(json!({ + "did": jwt_did.ok_or(anyhow!("need auth token"))?, + "collection": "app.bsky.feed.repost", + "record": { + "subject": uri.to_string(), + "createdAt": created_at_now(), + } + })), + )? + } Command::Bsky { - cmd: BskyCommand::Like { uri }, - } => xrpc_client.post( - &Nsid::from_str("com.atproto.repo.createRecord")?, - None, - Some(json!({ - "did": jwt_did.ok_or(anyhow!("need auth token"))?, - "collection": "app.bsky.feed.like", - "record": { - "subject": { "uri": uri.to_string(), "cid": "TODO" }, - "createdAt": created_at_now(), - }, - })), - )?, + cmd: BskyCommand::Like { ref uri }, + } => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.post( + &Nsid::from_str("com.atproto.repo.createRecord")?, + None, + Some(json!({ + "did": jwt_did.ok_or(anyhow!("need auth token"))?, + "collection": "app.bsky.feed.like", + "record": { + "subject": { "uri": uri.to_string(), "cid": "TODO" }, + "createdAt": created_at_now(), + }, + })), + )? + } Command::Bsky { - cmd: BskyCommand::Follow { uri }, - } => xrpc_client.post( - &Nsid::from_str("com.atproto.repo.createRecord")?, - None, - Some(json!({ - "did": jwt_did.ok_or(anyhow!("need auth token"))?, - "collection": "app.bsky.graph.follow", - "record": { - "subject": { "did": uri.to_string() }, - "createdAt": created_at_now(), - } - })), - )?, + cmd: BskyCommand::Follow { ref uri }, + } => { + require_auth_did(&opt, &mut xrpc_client)?; + xrpc_client.post( + &Nsid::from_str("com.atproto.repo.createRecord")?, + None, + Some(json!({ + "did": jwt_did.ok_or(anyhow!("need auth token"))?, + "collection": "app.bsky.graph.follow", + "record": { + "subject": { "did": uri.to_string() }, + "createdAt": created_at_now(), + } + })), + )? + } Command::Bsky { cmd: BskyCommand::Profile { name }, } => { diff --git a/adenosine/src/xrpc.rs b/adenosine/src/xrpc.rs index 97caa4d..d958d5e 100644 --- a/adenosine/src/xrpc.rs +++ b/adenosine/src/xrpc.rs @@ -1,11 +1,12 @@ -use crate::identifiers::Nsid; +use crate::identifiers::{Did, Nsid}; +use crate::auth::parse_did_from_jwt; use anyhow::anyhow; pub use anyhow::Result; use reqwest::header; -use serde_json::Value; use std::collections::HashMap; use std::str::FromStr; use std::time::Duration; +use serde_json::{json, Value}; static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); @@ -30,26 +31,64 @@ impl FromStr for XrpcMethod { pub struct XrpcClient { http_client: reqwest::blocking::Client, host: String, + auth_token: Option, + refresh_token: Option, } impl XrpcClient { pub fn new(host: String, auth_token: Option) -> Result { - let mut headers = header::HeaderMap::new(); - if let Some(token) = &auth_token { - let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {token}"))?; - auth_value.set_sensitive(true); - headers.insert(header::AUTHORIZATION, auth_value); - }; let http_client = reqwest::blocking::Client::builder() - .default_headers(headers) .user_agent(APP_USER_AGENT) .timeout(Duration::from_secs(30)) //.danger_accept_invalid_certs(true) .build() .expect("ERROR :: Could not build reqwest client"); - Ok(XrpcClient { http_client, host }) + Ok(XrpcClient { http_client, host, auth_token: auth_token.clone(), refresh_token: auth_token }) + } + + fn auth_headers(&self) -> reqwest::header::HeaderMap { + let mut headers = header::HeaderMap::new(); + if let Some(token) = &self.auth_token { + let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {token}")).expect("header formatting"); + auth_value.set_sensitive(true); + headers.insert(header::AUTHORIZATION, auth_value); + }; + headers + } + + /// Creates a new session, and updates current client auth tokens with the result + pub fn auth_login(&mut self, handle: &str, password: &str) -> Result <()> { + let resp = self.post( + &Nsid::from_str("com.atproto.session.create")?, + None, + Some(json!({ + "handle": handle, + "password": password, + })))?; + let resp = resp.ok_or(anyhow!("missing session auth info"))?; + self.auth_token = resp["accessJwt"].as_str().map(|s| s.to_string()); + self.refresh_token = resp["refreshJwt"].as_str().map(|s| s.to_string()); + Ok(()) + } + + /// Uses refresh token to update auth token + pub fn auth_refresh(&mut self) -> Result<()> { + self.auth_token = self.refresh_token.clone(); + let resp = self.post(&Nsid::from_str("com.atproto.session.refresh")?, None, None)?; + let resp = resp.ok_or(anyhow!("missing session auth info"))?; + self.auth_token = resp["accessJwt"].as_str().map(|s| s.to_string()); + self.refresh_token = resp["refreshJwt"].as_str().map(|s| s.to_string()); + Ok(()) + } + + pub fn auth_did(&self) -> Result { + if let Some(token) = &self.auth_token { + return Did::from_str(&parse_did_from_jwt(&token)?) + } else { + Err(anyhow!("no auth token configured")) + } } pub fn get( @@ -62,6 +101,7 @@ impl XrpcClient { let res = self .http_client .get(format!("{}/xrpc/{nsid}", self.host)) + .headers(self.auth_headers()) .query(¶ms) .send()?; // TODO: refactor this error handling stuff into single method @@ -92,6 +132,7 @@ impl XrpcClient { let res = self .http_client .get(format!("{}/xrpc/{}", self.host, nsid)) + .headers(self.auth_headers()) .query(¶ms) .send()?; if res.status() == 400 { @@ -127,6 +168,7 @@ impl XrpcClient { let mut req = self .http_client .post(format!("{}/xrpc/{}", self.host, nsid)) + .headers(self.auth_headers()) .query(¶ms); req = if let Some(b) = body { req.json(&b) @@ -167,6 +209,7 @@ impl XrpcClient { let res = self .http_client .post(format!("{}/xrpc/{}", self.host, nsid)) + .headers(self.auth_headers()) .query(¶ms) .header(reqwest::header::CONTENT_TYPE, "application/cbor") .body(buf) @@ -181,6 +224,4 @@ impl XrpcClient { let res = res.error_for_status()?; Ok(res.json()?) } - - // reqwest::blocking::Body } -- cgit v1.2.3