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 pub async fn create_user_action_challenge(
169 &self,
170 method: Method,
171 path: &str,
172 body: Option<&serde_json::Value>,
173 ) -> Result<UserActionChallenge, Error> {
174 let payload: Vec<u8> = match body {
175 Some(b) => serde_json::to_vec(b)?,
176 None => Vec::new(),
177 };
178 self.init_user_action_challenge(&method, path, &payload)
179 .await
180 }
181
182 pub async fn complete_user_action_signing(
184 &self,
185 challenge_identifier: String,
186 assertion: &CredentialAssertion,
187 ) -> Result<String, Error> {
188 let complete = CompleteRequest {
189 challenge_identifier,
190 first_factor: assertion,
191 };
192 let resp: CompleteResponse = self.post_json("/auth/action", &complete).await?;
193 Ok(resp.user_action)
194 }
195
196 pub async fn request_with_user_action<R: DeserializeOwned>(
198 &self,
199 method: Method,
200 path: &str,
201 body: Option<&serde_json::Value>,
202 user_action_token: &str,
203 ) -> Result<R, Error> {
204 let payload: Vec<u8> = match body {
205 Some(b) => serde_json::to_vec(b)?,
206 None => Vec::new(),
207 };
208 let bytes = self
209 .dispatch(
210 method,
211 path,
212 body.is_some().then_some(payload),
213 Some(user_action_token),
214 )
215 .await?;
216 Ok(serde_json::from_slice(&bytes)?)
217 }
218
219 pub async fn request_no_content_with_user_action(
221 &self,
222 method: Method,
223 path: &str,
224 body: Option<&serde_json::Value>,
225 user_action_token: &str,
226 ) -> Result<(), Error> {
227 let payload: Vec<u8> = match body {
228 Some(b) => serde_json::to_vec(b)?,
229 None => Vec::new(),
230 };
231 self.dispatch(
232 method,
233 path,
234 body.is_some().then_some(payload),
235 Some(user_action_token),
236 )
237 .await?;
238 Ok(())
239 }
240
241 async fn send(
242 &self,
243 method: Method,
244 path: &str,
245 body: Option<&serde_json::Value>,
246 requires_user_action: bool,
247 ) -> Result<Vec<u8>, Error> {
248 let payload: Vec<u8> = match body {
251 Some(b) => serde_json::to_vec(b)?,
252 None => Vec::new(),
253 };
254
255 let token = if requires_user_action {
256 Some(self.user_action_token(&method, path, &payload).await?)
257 } else {
258 None
259 };
260
261 self.dispatch(
262 method,
263 path,
264 body.is_some().then_some(payload),
265 token.as_deref(),
266 )
267 .await
268 }
269
270 async fn dispatch(
271 &self,
272 method: Method,
273 path: &str,
274 payload: Option<Vec<u8>>,
275 user_action_token: Option<&str>,
276 ) -> Result<Vec<u8>, Error> {
277 let url = format!("{}{}", self.inner.base_url, path);
278 let mut req = self
279 .inner
280 .http
281 .request(method, &url)
282 .bearer_auth(&self.inner.auth_token);
283 if let Some(payload) = payload {
284 req = req.header(CONTENT_TYPE, "application/json").body(payload);
285 }
286 if let Some(token) = user_action_token {
287 req = req.header("X-DFNS-USERACTION", token);
288 }
289 Self::handle_response(req.send().await?).await
290 }
291
292 async fn send_multipart(
293 &self,
294 method: Method,
295 path: &str,
296 body: Option<&serde_json::Value>,
297 file: MultipartFile,
298 requires_user_action: bool,
299 ) -> Result<Vec<u8>, Error> {
300 let mut data = serde_json::Map::new();
302 if let Some(serde_json::Value::Object(m)) = body {
303 data = m.clone();
304 }
305 let checksum = hex::encode(Sha256::digest(&file.bytes));
306 data.insert(
307 "fileChecksum".to_string(),
308 serde_json::Value::String(checksum),
309 );
310 let data_bytes = serde_json::to_vec(&serde_json::Value::Object(data))?;
311
312 let mut file_name = file.file_name.replace(['\r', '\n'], "");
314 if file_name.is_empty() {
315 file_name = "upload.bin".to_string();
316 }
317
318 let form = Form::new()
319 .text("data", String::from_utf8_lossy(&data_bytes).into_owned())
320 .part("file", Part::bytes(file.bytes).file_name(file_name));
321
322 let url = format!("{}{}", self.inner.base_url, path);
323 let mut req = self
324 .inner
325 .http
326 .request(method.clone(), &url)
327 .bearer_auth(&self.inner.auth_token)
328 .multipart(form);
329
330 if requires_user_action {
332 let token = self.user_action_token(&method, path, &data_bytes).await?;
333 req = req.header("X-DFNS-USERACTION", token);
334 }
335
336 Self::handle_response(req.send().await?).await
337 }
338
339 async fn user_action_token(
341 &self,
342 method: &Method,
343 path: &str,
344 payload: &[u8],
345 ) -> Result<String, Error> {
346 let signer = self.inner.signer.as_ref().ok_or(Error::SignerRequired)?;
347 let challenge = self
348 .init_user_action_challenge(method, path, payload)
349 .await?;
350 let assertion = signer.sign(&challenge).await?;
351 self.complete_user_action_signing(challenge.challenge_identifier, &assertion)
352 .await
353 }
354
355 async fn init_user_action_challenge(
356 &self,
357 method: &Method,
358 path: &str,
359 payload: &[u8],
360 ) -> Result<UserActionChallenge, Error> {
361 let init = InitRequest {
362 user_action_payload: String::from_utf8_lossy(payload).into_owned(),
363 user_action_http_method: method.as_str().to_string(),
364 user_action_http_path: canonical_request_path(path),
365 user_action_server_kind: "Api".to_string(),
366 };
367 self.post_json("/auth/action/init", &init).await
368 }
369
370 async fn post_json<B, R>(&self, path: &str, body: &B) -> Result<R, Error>
372 where
373 B: Serialize + ?Sized,
374 R: DeserializeOwned,
375 {
376 let value = serde_json::to_value(body)?;
377 let bytes = Box::pin(self.send(Method::POST, path, Some(&value), false)).await?;
379 Ok(serde_json::from_slice(&bytes)?)
380 }
381
382 async fn handle_response(resp: reqwest::Response) -> Result<Vec<u8>, Error> {
383 let status = resp.status();
384 let bytes = resp.bytes().await?.to_vec();
385
386 if !status.is_success() {
387 return Err(match serde_json::from_slice::<ApiError>(&bytes) {
388 Ok(body) => Error::Api {
389 status: status.as_u16(),
390 body,
391 },
392 Err(_) => Error::ApiRaw {
393 status: status.as_u16(),
394 raw: String::from_utf8_lossy(&bytes).into_owned(),
395 },
396 });
397 }
398
399 Ok(bytes)
400 }
401}
402
403fn is_loopback_host(url: &reqwest::Url) -> bool {
405 match url.host_str() {
406 Some("localhost") => true,
407 Some(host) => host
408 .parse::<std::net::IpAddr>()
409 .map(|ip| ip.is_loopback())
410 .unwrap_or(false),
411 None => false,
412 }
413}
414
415fn canonical_request_path(path: &str) -> String {
419 match path.split_once('?') {
420 Some((head, _)) => head.to_string(),
421 None => path.to_string(),
422 }
423}