Skip to main content

dfns_sdk_rust/
client.rs

1//! Core HTTP transport shared by every domain client.
2//!
3//! Owns auth headers, base URL, user-action signing and the request/response
4//! plumbing the domain methods call into.
5
6use 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/// A file part for multipart uploads.
19#[derive(Debug, Clone)]
20pub struct MultipartFile {
21    pub file_name: String,
22    pub bytes: Vec<u8>,
23}
24
25/// Configuration for the transport client.
26pub 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/// Request body for `POST /auth/action/init`.
34#[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/// Request body for `POST /auth/action`.
47#[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/// The shared transport. Cheap to clone (all state is Arc-backed).
62#[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        // https is required so the bearer token never crosses the wire in cleartext.
82        // Plain http is allowed only for loopback hosts (local development and tests),
83        // where nothing leaves the machine.
84        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    /// Perform a request and decode a typed response.
114    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    /// Perform a request that returns no meaningful body.
126    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    /// Perform a multipart/form-data upload and decode a typed response.
138    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    /// Perform a multipart/form-data upload that returns no meaningful body.
153    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    /// Returns the challenge to sign for (method, path, body). The request must later
167    /// be issued with the same (method, path, body).
168    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    /// Submits an externally-signed assertion and returns the user action token.
183    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    /// Performs a request with an already obtained user action token.
197    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    /// Same but without decoding a response body.
220    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        // Serialize the body once and send exactly those bytes, so the signed
249        // userActionPayload byte-matches what the server receives.
250        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        // The "data" part is the JSON body plus the file checksum the API expects.
301        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        // Strip CR/LF from the caller-supplied name to prevent header injection.
313        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        // User action signing covers the "data" payload, matching the JSON body path.
331        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    /// Run the three-step user-action dance and return the resulting token.
340    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    /// POST a JSON body and decode a typed response, without user-action signing.
371    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        // Box::pin breaks the send -> user_action_token -> post_json -> send async recursion cycle.
378        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
403/// Whether the URL points at a loopback host, for which plain http is tolerated.
404fn 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
415/// Removes the transport query from the path bound into a user-action challenge.
416/// Query parameters are transported on the real request but are not part of the
417/// canonical Dfns userActionHttpPath.
418fn canonical_request_path(path: &str) -> String {
419    match path.split_once('?') {
420        Some((head, _)) => head.to_string(),
421        None => path.to_string(),
422    }
423}