1
0

fix test cli oauth

This commit is contained in:
2026-03-01 21:27:37 +09:00
parent 6d3e233ee2
commit 32168f14d0
16 changed files with 1201 additions and 49 deletions

View File

@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::commands::oauth::{self, OAuthSession};
use crate::error::{AppError, AtprotoErrorResponse};
use crate::lexicons::{self, Endpoint};
@@ -10,6 +11,7 @@ use crate::lexicons::{self, Endpoint};
pub struct XrpcClient {
inner: reqwest::Client,
pds_host: String,
is_bot: bool,
}
impl XrpcClient {
@@ -18,6 +20,16 @@ impl XrpcClient {
Self {
inner: reqwest::Client::new(),
pds_host: pds_host.to_string(),
is_bot: false,
}
}
/// Create a new XrpcClient for bot usage
pub fn new_bot(pds_host: &str) -> Self {
Self {
inner: reqwest::Client::new(),
pds_host: pds_host.to_string(),
is_bot: true,
}
}
@@ -26,7 +38,25 @@ impl XrpcClient {
lexicons::url(&self.pds_host, endpoint)
}
/// Authenticated GET query
/// Ensure URL has scheme
fn ensure_scheme(url: &str) -> String {
if url.starts_with("https://") || url.starts_with("http://") {
url.to_string()
} else {
format!("https://{}", url)
}
}
/// Try to load OAuth session
fn try_load_oauth(&self) -> Option<OAuthSession> {
if oauth::has_oauth_session(self.is_bot) {
oauth::load_oauth_session(self.is_bot).ok()
} else {
None
}
}
/// Authenticated GET query (with DPoP nonce retry)
pub async fn query_auth<T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -43,18 +73,24 @@ impl XrpcClient {
url.push_str(&qs.join("&"));
}
let res = self
.inner
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.context("XRPC authenticated query failed")?;
let full_url = Self::ensure_scheme(&url);
self.handle_response(res).await
if let Some(oauth) = self.try_load_oauth() {
self.dpop_request_with_retry(&oauth, token, "GET", &url, &full_url, None::<&()>)
.await
} else {
let res = self
.inner
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.context("XRPC authenticated query failed")?;
self.handle_response(res).await
}
}
/// Authenticated POST call (procedure)
/// Authenticated POST call (procedure, with DPoP nonce retry)
pub async fn call<B: Serialize, T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -62,17 +98,22 @@ impl XrpcClient {
token: &str,
) -> Result<T> {
let url = self.url(endpoint);
let full_url = Self::ensure_scheme(&url);
let res = self
.inner
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(body)
.send()
.await
.context("XRPC call failed")?;
self.handle_response(res).await
if let Some(oauth) = self.try_load_oauth() {
self.dpop_request_with_retry(&oauth, token, "POST", &url, &full_url, Some(body))
.await
} else {
let res = self
.inner
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(body)
.send()
.await
.context("XRPC call failed")?;
self.handle_response(res).await
}
}
/// Unauthenticated POST call (e.g. createSession)
@@ -94,7 +135,7 @@ impl XrpcClient {
self.handle_response(res).await
}
/// Authenticated POST that returns no body (or we ignore the response body)
/// Authenticated POST that returns no body (with DPoP nonce retry)
pub async fn call_no_response<B: Serialize>(
&self,
endpoint: &Endpoint,
@@ -102,24 +143,30 @@ impl XrpcClient {
token: &str,
) -> Result<()> {
let url = self.url(endpoint);
let full_url = Self::ensure_scheme(&url);
let res = self
.inner
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(body)
.send()
.await
.context("XRPC call failed")?;
if let Some(oauth) = self.try_load_oauth() {
self.dpop_no_response_with_retry(&oauth, token, "POST", &url, &full_url, body)
.await
} else {
let res = self
.inner
.post(&url)
.header("Authorization", format!("Bearer {}", token))
.json(body)
.send()
.await
.context("XRPC call failed")?;
if !res.status().is_success() {
return Err(self.parse_error(res).await);
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
Ok(())
}
Ok(())
}
/// Authenticated POST with only a bearer token (no JSON body, e.g. refreshSession)
/// This is always Bearer (legacy refresh), not DPoP.
pub async fn call_bearer<T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -138,13 +185,133 @@ impl XrpcClient {
self.handle_response(res).await
}
/// DPoP-authenticated request with nonce retry (returns deserialized body)
async fn dpop_request_with_retry<B: Serialize, T: DeserializeOwned>(
&self,
oauth: &OAuthSession,
token: &str,
method: &str,
url: &str,
full_url: &str,
body: Option<&B>,
) -> Result<T> {
let mut dpop_nonce: Option<String> = None;
for _attempt in 0..2 {
let dpop_proof = oauth::create_dpop_proof_for_request_with_nonce(
oauth,
method,
full_url,
dpop_nonce.as_deref(),
)?;
let builder = match method {
"GET" => self.inner.get(url),
_ => {
let b = self.inner.post(url);
if let Some(body) = body {
b.json(body)
} else {
b
}
}
};
let res = builder
.header("Authorization", format!("DPoP {}", token))
.header("DPoP", dpop_proof)
.send()
.await
.context("XRPC DPoP request failed")?;
// Check for dpop-nonce requirement
if res.status() == 401 {
if let Some(nonce) = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
{
let body_text = res.text().await.unwrap_or_default();
if body_text.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
return Err(anyhow::anyhow!("XRPC error (401): {}", body_text));
}
}
return self.handle_response(res).await;
}
anyhow::bail!("XRPC DPoP request failed after nonce retry");
}
/// DPoP-authenticated request with nonce retry (no response body)
async fn dpop_no_response_with_retry<B: Serialize>(
&self,
oauth: &OAuthSession,
token: &str,
method: &str,
url: &str,
full_url: &str,
body: &B,
) -> Result<()> {
let mut dpop_nonce: Option<String> = None;
for _attempt in 0..2 {
let dpop_proof = oauth::create_dpop_proof_for_request_with_nonce(
oauth,
method,
full_url,
dpop_nonce.as_deref(),
)?;
let res = self
.inner
.post(url)
.json(body)
.header("Authorization", format!("DPoP {}", token))
.header("DPoP", dpop_proof)
.send()
.await
.context("XRPC DPoP call failed")?;
if res.status() == 401 {
if let Some(nonce) = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
{
let body_text = res.text().await.unwrap_or_default();
if body_text.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
return Err(anyhow::anyhow!("XRPC error (401): {}", body_text));
}
}
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
return Ok(());
}
anyhow::bail!("XRPC DPoP call failed after nonce retry");
}
/// Handle an XRPC response: check status, parse ATProto errors, deserialize body.
async fn handle_response<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
let body = res.json::<T>().await.context("Failed to parse XRPC response body")?;
let body = res
.json::<T>()
.await
.context("Failed to parse XRPC response body")?;
Ok(body)
}