1use std::sync::Arc;
7
8use reqwest::header::CONTENT_TYPE;
9use reqwest::multipart::{Form, Part};
10use reqwest::Method;
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13use sha2::{Digest, Sha256};
14
15use crate::error::{ApiError, Error};
16use crate::signer::{CredentialAssertion, UserActionChallenge, UserActionSigner};
17
18#[derive(Debug, Clone)]
20pub struct MultipartFile {
21 pub file_name: String,
22 pub bytes: Vec<u8>,
23}
24
25pub struct Options {
27 pub base_url: String,
28 pub auth_token: String,
29 pub signer: Option<Arc<dyn UserActionSigner>>,
30 pub http: Option<reqwest::Client>,
31}
32
33#[derive(Serialize)]
35struct InitRequest {
36 #[serde(rename = "userActionPayload")]
37 user_action_payload: String,
38 #[serde(rename = "userActionHttpMethod")]
39 user_action_http_method: String,
40 #[serde(rename = "userActionHttpPath")]
41 user_action_http_path: String,
42 #[serde(rename = "userActionServerKind")]
43 user_action_server_kind: String,
44}
45
46#[derive(Serialize)]
48struct CompleteRequest<'a> {
49 #[serde(rename = "challengeIdentifier")]
50 challenge_identifier: String,
51 #[serde(rename = "firstFactor")]
52 first_factor: &'a CredentialAssertion,
53}
54
55#[derive(serde::Deserialize)]
56struct CompleteResponse {
57 #[serde(rename = "userAction")]
58 user_action: String,
59}
60
61#[derive(Clone)]
63pub struct Client {
64 inner: Arc<ClientInner>,
65}
66
67struct ClientInner {
68 base_url: String,
69 auth_token: String,
70 signer: Option<Arc<dyn UserActionSigner>>,
71 http: reqwest::Client,
72}
73
74impl Client {
75 pub fn new(opts: Options) -> Result<Self, Error> {
76 let mut base_url = opts.base_url;
77 if base_url.is_empty() {
78 base_url = "https://api.dfns.io".to_string();
79 }
80
81 let parsed = reqwest::Url::parse(&base_url)
85 .map_err(|e| Error::Config(format!("invalid BaseURL: {e}")))?;
86 if parsed.scheme() != "https" && !is_loopback_host(&parsed) {
87 return Err(Error::Config(
88 "BaseURL must use https scheme (http allowed only for loopback)".to_string(),
89 ));
90 }
91
92 if opts.auth_token.is_empty() {
93 return Err(Error::Config("AuthToken is required".to_string()));
94 }
95
96 let http = match opts.http {
97 Some(client) => client,
98 None => reqwest::Client::builder()
99 .timeout(std::time::Duration::from_secs(30))
100 .build()?,
101 };
102
103 Ok(Client {
104 inner: Arc::new(ClientInner {
105 base_url: base_url.trim_end_matches('/').to_string(),
106 auth_token: opts.auth_token,
107 signer: opts.signer,
108 http,
109 }),
110 })
111 }
112
113 pub async fn request<R: DeserializeOwned>(
115 &self,
116 method: Method,
117 path: &str,
118 body: Option<&serde_json::Value>,
119 requires_user_action: bool,
120 ) -> Result<R, Error> {
121 let bytes = self.send(method, path, body, requires_user_action).await?;
122 Ok(serde_json::from_slice(&bytes)?)
123 }
124
125 pub async fn request_no_content(
127 &self,
128 method: Method,
129 path: &str,
130 body: Option<&serde_json::Value>,
131 requires_user_action: bool,
132 ) -> Result<(), Error> {
133 self.send(method, path, body, requires_user_action).await?;
134 Ok(())
135 }
136
137 pub async fn request_multipart<R: DeserializeOwned>(
139 &self,
140 method: Method,
141 path: &str,
142 body: Option<&serde_json::Value>,
143 file: MultipartFile,
144 requires_user_action: bool,
145 ) -> Result<R, Error> {
146 let bytes = self
147 .send_multipart(method, path, body, file, requires_user_action)
148 .await?;
149 Ok(serde_json::from_slice(&bytes)?)
150 }
151
152 pub async fn request_multipart_no_content(
154 &self,
155 method: Method,
156 path: &str,
157 body: Option<&serde_json::Value>,
158 file: MultipartFile,
159 requires_user_action: bool,
160 ) -> Result<(), Error> {
161 self.send_multipart(method, path, body, file, requires_user_action)
162 .await?;
163 Ok(())
164 }
165
166 async fn send(
167 &self,
168 method: Method,
169 path: &str,
170 body: Option<&serde_json::Value>,
171 requires_user_action: bool,
172 ) -> Result<Vec<u8>, Error> {
173 let payload: Vec<u8> = match body {
176 Some(b) => serde_json::to_vec(b)?,
177 None => Vec::new(),
178 };
179
180 let url = format!("{}{}", self.inner.base_url, path);
181 let mut req = self
182 .inner
183 .http
184 .request(method.clone(), &url)
185 .bearer_auth(&self.inner.auth_token);
186 if body.is_some() {
187 req = req
188 .header(CONTENT_TYPE, "application/json")
189 .body(payload.clone());
190 }
191
192 if requires_user_action {
193 let token = self.user_action_token(&method, path, &payload).await?;
194 req = req.header("X-DFNS-USERACTION", token);
195 }
196
197 Self::handle_response(req.send().await?).await
198 }
199
200 async fn send_multipart(
201 &self,
202 method: Method,
203 path: &str,
204 body: Option<&serde_json::Value>,
205 file: MultipartFile,
206 requires_user_action: bool,
207 ) -> Result<Vec<u8>, Error> {
208 let mut data = serde_json::Map::new();
210 if let Some(serde_json::Value::Object(m)) = body {
211 data = m.clone();
212 }
213 let checksum = hex::encode(Sha256::digest(&file.bytes));
214 data.insert(
215 "fileChecksum".to_string(),
216 serde_json::Value::String(checksum),
217 );
218 let data_bytes = serde_json::to_vec(&serde_json::Value::Object(data))?;
219
220 let mut file_name = file.file_name.replace(['\r', '\n'], "");
222 if file_name.is_empty() {
223 file_name = "upload.bin".to_string();
224 }
225
226 let form = Form::new()
227 .text("data", String::from_utf8_lossy(&data_bytes).into_owned())
228 .part("file", Part::bytes(file.bytes).file_name(file_name));
229
230 let url = format!("{}{}", self.inner.base_url, path);
231 let mut req = self
232 .inner
233 .http
234 .request(method.clone(), &url)
235 .bearer_auth(&self.inner.auth_token)
236 .multipart(form);
237
238 if requires_user_action {
240 let token = self.user_action_token(&method, path, &data_bytes).await?;
241 req = req.header("X-DFNS-USERACTION", token);
242 }
243
244 Self::handle_response(req.send().await?).await
245 }
246
247 async fn user_action_token(
249 &self,
250 method: &Method,
251 path: &str,
252 payload: &[u8],
253 ) -> Result<String, Error> {
254 let signer = self.inner.signer.as_ref().ok_or(Error::SignerRequired)?;
255
256 let init = InitRequest {
257 user_action_payload: String::from_utf8_lossy(payload).into_owned(),
258 user_action_http_method: method.as_str().to_string(),
259 user_action_http_path: canonical_request_path(path),
260 user_action_server_kind: "Api".to_string(),
261 };
262 let challenge: UserActionChallenge = self.post_json("/auth/action/init", &init).await?;
263
264 let assertion = signer.sign(&challenge).await?;
265
266 let complete = CompleteRequest {
267 challenge_identifier: challenge.challenge_identifier,
268 first_factor: &assertion,
269 };
270 let resp: CompleteResponse = self.post_json("/auth/action", &complete).await?;
271 Ok(resp.user_action)
272 }
273
274 async fn post_json<B, R>(&self, path: &str, body: &B) -> Result<R, Error>
276 where
277 B: Serialize + ?Sized,
278 R: DeserializeOwned,
279 {
280 let value = serde_json::to_value(body)?;
281 let bytes = Box::pin(self.send(Method::POST, path, Some(&value), false)).await?;
283 Ok(serde_json::from_slice(&bytes)?)
284 }
285
286 async fn handle_response(resp: reqwest::Response) -> Result<Vec<u8>, Error> {
287 let status = resp.status();
288 let bytes = resp.bytes().await?.to_vec();
289
290 if !status.is_success() {
291 return Err(match serde_json::from_slice::<ApiError>(&bytes) {
292 Ok(body) => Error::Api {
293 status: status.as_u16(),
294 body,
295 },
296 Err(_) => Error::ApiRaw {
297 status: status.as_u16(),
298 raw: String::from_utf8_lossy(&bytes).into_owned(),
299 },
300 });
301 }
302
303 Ok(bytes)
304 }
305}
306
307fn is_loopback_host(url: &reqwest::Url) -> bool {
309 match url.host_str() {
310 Some("localhost") => true,
311 Some(host) => host
312 .parse::<std::net::IpAddr>()
313 .map(|ip| ip.is_loopback())
314 .unwrap_or(false),
315 None => false,
316 }
317}
318
319fn canonical_request_path(path: &str) -> String {
323 match path.split_once('?') {
324 Some((head, _)) => head.to_string(),
325 None => path.to_string(),
326 }
327}