Skip to main content

dfns_sdk_rust/
client.rs

1//! Core HTTP transport shared by every generated domain client.
2//!
3//! Hand-written: owns auth headers, base URL, user-action signing hand-off, and the
4//! request/response plumbing the generated 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    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        // Serialize the body once and send exactly those bytes, so the signed
174        // userActionPayload byte-matches what the server receives.
175        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        // The "data" part is the JSON body plus the file checksum the API expects.
209        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        // Strip CR/LF from the caller-supplied name to prevent header injection.
221        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        // User action signing covers the "data" payload, matching the JSON body path.
239        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    /// Run the three-step user-action dance and return the resulting token.
248    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    /// POST a JSON body and decode a typed response, without user-action signing.
275    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        // Box::pin breaks the send -> user_action_token -> post_json -> send async recursion cycle.
282        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
307/// Whether the URL points at a loopback host, for which plain http is tolerated.
308fn 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
319/// Removes the transport query from the path bound into a user-action challenge.
320/// Query parameters are transported on the real request but are not part of the
321/// canonical Dfns userActionHttpPath.
322fn canonical_request_path(path: &str) -> String {
323    match path.split_once('?') {
324        Some((head, _)) => head.to_string(),
325        None => path.to_string(),
326    }
327}