Skip to main content

dhan_rs/api/
auth.rs

1//! Authentication endpoint implementations.
2//!
3//! These methods hit the **auth.dhan.co** domain (not the regular API base URL)
4//! except for `renew_token` which uses the standard `/v2/RenewToken` endpoint.
5
6use reqwest::header::HeaderValue;
7use serde_json::Value;
8
9use crate::client::{DhanClient, required_query_value};
10use crate::constants::AUTH_BASE_URL;
11use crate::error::{ApiErrorBody, DhanError, Result};
12use crate::types::auth::{AppConsentResponse, PartnerConsentResponse, TokenResponse};
13
14impl DhanClient {
15    // -----------------------------------------------------------------------
16    // Direct token generation (TOTP)
17    // -----------------------------------------------------------------------
18
19    /// Generate an access token using client credentials and TOTP.
20    ///
21    /// Requires TOTP to be enabled on the Dhan account.
22    ///
23    /// **Endpoint:** `POST https://auth.dhan.co/app/generateAccessToken`
24    ///
25    /// # Arguments
26    ///
27    /// * `client_id` — The Dhan Client ID.
28    /// * `pin` — 6-digit Dhan PIN.
29    /// * `totp` — 6-digit TOTP code from an authenticator app.
30    ///
31    /// # Example
32    ///
33    /// ```no_run
34    /// # use dhan_rs::client::DhanClient;
35    /// # #[tokio::main]
36    /// # async fn main() -> dhan_rs::error::Result<()> {
37    /// let client = DhanClient::new("1000000001", "");
38    /// let token = DhanClient::generate_access_token("1000000001", "123456", "654321").await?;
39    /// println!("Access token: {}", token.access_token);
40    /// # Ok(())
41    /// # }
42    /// ```
43    pub async fn generate_access_token(
44        client_id: &str,
45        pin: &str,
46        totp: &str,
47    ) -> Result<TokenResponse> {
48        let client_id = required_query_value("client_id", client_id)?;
49        let pin = required_query_value("pin", pin)?;
50        let totp = required_query_value("totp", totp)?;
51        let url = format!(
52            "{}/app/generateAccessToken?dhanClientId={}&pin={}&totp={}",
53            AUTH_BASE_URL, client_id, pin, totp
54        );
55
56        tracing::debug!("POST generate_access_token");
57
58        let http = auth_http_client()?;
59        let resp = http
60            .post(&url)
61            .send()
62            .await
63            .map_err(sanitize_auth_http_error)?;
64
65        let status = resp.status();
66        let body = resp
67            .text()
68            .await
69            .map_err(|source| DhanError::ResponseBody {
70                status,
71                source: source.without_url(),
72            })?;
73
74        if status.is_success() {
75            serde_json::from_str(&body).map_err(DhanError::Json)
76        } else {
77            if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(&body) {
78                if api_err.error_code.is_some() || api_err.error_message.is_some() {
79                    return Err(DhanError::Api(api_err));
80                }
81            }
82            Err(DhanError::HttpStatus { status, body })
83        }
84    }
85
86    // -----------------------------------------------------------------------
87    // Token renewal
88    // -----------------------------------------------------------------------
89
90    /// Renew the current access token for another 24 hours.
91    ///
92    /// Only works for tokens generated from Dhan Web that are still active.
93    /// This expires the current token and returns a new one.
94    ///
95    /// **Endpoint:** `GET /v2/RenewToken`
96    ///
97    /// # Note
98    ///
99    /// The RenewToken endpoint uses `dhanClientId` as its client
100    /// identification header, unlike most other endpoints that use `client-id`.
101    /// This method handles the difference automatically.
102    pub async fn renew_token(&mut self) -> Result<TokenResponse> {
103        let url = format!("{}/v2/RenewToken", self.base_url());
104
105        tracing::debug!("GET renew_token");
106
107        let mut access_token = HeaderValue::from_str(self.access_token())?;
108        access_token.set_sensitive(true);
109        let mut client_id = HeaderValue::from_str(self.client_id())?;
110        client_id.set_sensitive(true);
111
112        let resp = self
113            .http()
114            .get(&url)
115            .header("access-token", access_token)
116            .header("dhanClientId", client_id)
117            .header("Content-Type", "application/json")
118            .header("Accept", "application/json")
119            .send()
120            .await
121            .map_err(sanitize_auth_http_error)?;
122
123        let status = resp.status();
124        let bytes = resp
125            .bytes()
126            .await
127            .map_err(|source| DhanError::ResponseBody {
128                status,
129                source: source.without_url(),
130            })?;
131
132        if status.is_success() {
133            let token: TokenResponse = serde_json::from_slice(&bytes).map_err(DhanError::Json)?;
134            // Update the client's token so subsequent calls use the new one.
135            self.try_set_access_token(&token.access_token)?;
136            Ok(token)
137        } else {
138            let body = String::from_utf8_lossy(&bytes);
139            Err(self.parse_error_body(status, &body))
140        }
141    }
142
143    // -----------------------------------------------------------------------
144    // Individual — API key & secret OAuth flow
145    // -----------------------------------------------------------------------
146
147    /// **Step 1:** Generate a consent session for API key-based login.
148    ///
149    /// Validates the `app_id` and `app_secret` and creates a new session.
150    ///
151    /// **Endpoint:** `POST https://auth.dhan.co/app/generate-consent?client_id={dhanClientId}`
152    ///
153    /// Returns an [`AppConsentResponse`] containing a `consent_app_id` to be
154    /// used in the browser redirect step.
155    pub async fn generate_consent(
156        client_id: &str,
157        app_id: &str,
158        app_secret: &str,
159    ) -> Result<AppConsentResponse> {
160        let client_id = required_query_value("client_id", client_id)?;
161        let url = format!(
162            "{}/app/generate-consent?client_id={}",
163            AUTH_BASE_URL, client_id
164        );
165
166        tracing::debug!("POST generate_consent");
167
168        let http = auth_http_client()?;
169        let resp = http
170            .post(&url)
171            .header("app_id", sensitive_header_value(app_id)?)
172            .header("app_secret", sensitive_header_value(app_secret)?)
173            .send()
174            .await
175            .map_err(sanitize_auth_http_error)?;
176
177        let status = resp.status();
178        let body = resp
179            .text()
180            .await
181            .map_err(|source| DhanError::ResponseBody {
182                status,
183                source: source.without_url(),
184            })?;
185
186        if status.is_success() {
187            serde_json::from_str(&body).map_err(DhanError::Json)
188        } else {
189            Self::parse_auth_error(status, &body)
190        }
191    }
192
193    /// **Step 2:** Build the browser login URL for user consent.
194    ///
195    /// Open this URL in a browser. After the user authenticates, they will be
196    /// redirected to the redirect URL configured for the API key, with a
197    /// `tokenId` query parameter appended.
198    ///
199    /// ```
200    /// use dhan_rs::DhanClient;
201    /// let url = DhanClient::consent_login_url("940b0ca1-3ff4-4476-b46e-03a3ce7dc55d");
202    /// // → "https://auth.dhan.co/login/consentApp-login?consentAppId=940b0ca1-..."
203    /// ```
204    pub fn consent_login_url(consent_app_id: &str) -> String {
205        let consent_app_id =
206            url::form_urlencoded::byte_serialize(consent_app_id.as_bytes()).collect::<String>();
207        format!(
208            "{}/login/consentApp-login?consentAppId={}",
209            AUTH_BASE_URL, consent_app_id
210        )
211    }
212
213    /// **Step 3:** Consume the consent to obtain an access token.
214    ///
215    /// Uses the `token_id` obtained from the browser redirect after the user
216    /// logged in.
217    ///
218    /// **Endpoint:** `POST https://auth.dhan.co/app/consumeApp-consent?tokenId={tokenId}`
219    pub async fn consume_consent(
220        token_id: &str,
221        app_id: &str,
222        app_secret: &str,
223    ) -> Result<TokenResponse> {
224        let token_id = required_query_value("token_id", token_id)?;
225        let url = format!(
226            "{}/app/consumeApp-consent?tokenId={}",
227            AUTH_BASE_URL, token_id
228        );
229
230        tracing::debug!("POST consume_consent");
231
232        let http = auth_http_client()?;
233        let resp = http
234            .post(&url)
235            .header("app_id", sensitive_header_value(app_id)?)
236            .header("app_secret", sensitive_header_value(app_secret)?)
237            .send()
238            .await
239            .map_err(sanitize_auth_http_error)?;
240
241        let status = resp.status();
242        let body = resp
243            .text()
244            .await
245            .map_err(|source| DhanError::ResponseBody {
246                status,
247                source: source.without_url(),
248            })?;
249
250        if status.is_success() {
251            serde_json::from_str(&body).map_err(DhanError::Json)
252        } else {
253            Self::parse_auth_error(status, &body)
254        }
255    }
256
257    // -----------------------------------------------------------------------
258    // Partner — OAuth flow
259    // -----------------------------------------------------------------------
260
261    /// **Step 1 (Partner):** Generate a partner consent session.
262    ///
263    /// **Endpoint:** `POST https://auth.dhan.co/partner/generate-consent`
264    pub async fn partner_generate_consent(
265        partner_id: &str,
266        partner_secret: &str,
267    ) -> Result<PartnerConsentResponse> {
268        let url = format!("{}/partner/generate-consent", AUTH_BASE_URL);
269
270        tracing::debug!("POST partner_generate_consent");
271
272        let http = auth_http_client()?;
273        let resp = http
274            .post(&url)
275            .header("partner_id", sensitive_header_value(partner_id)?)
276            .header("partner_secret", sensitive_header_value(partner_secret)?)
277            .send()
278            .await
279            .map_err(sanitize_auth_http_error)?;
280
281        let status = resp.status();
282        let body = resp
283            .text()
284            .await
285            .map_err(|source| DhanError::ResponseBody {
286                status,
287                source: source.without_url(),
288            })?;
289
290        if status.is_success() {
291            serde_json::from_str(&body).map_err(DhanError::Json)
292        } else {
293            Self::parse_auth_error(status, &body)
294        }
295    }
296
297    /// **Step 2 (Partner):** Build the browser login URL for partner consent.
298    ///
299    /// Open this URL in a browser. After the user authenticates, they will be
300    /// redirected with a `tokenId` query parameter.
301    pub fn partner_consent_login_url(consent_id: &str) -> String {
302        let consent_id =
303            url::form_urlencoded::byte_serialize(consent_id.as_bytes()).collect::<String>();
304        format!("{}/consent-login?consentId={consent_id}", AUTH_BASE_URL)
305    }
306
307    /// **Step 3 (Partner):** Consume the partner consent to obtain an access token.
308    ///
309    /// **Endpoint:** `POST https://auth.dhan.co/partner/consume-consent?tokenId={tokenId}`
310    pub async fn partner_consume_consent(
311        token_id: &str,
312        partner_id: &str,
313        partner_secret: &str,
314    ) -> Result<TokenResponse> {
315        let token_id = required_query_value("token_id", token_id)?;
316        let url = format!(
317            "{}/partner/consume-consent?tokenId={}",
318            AUTH_BASE_URL, token_id
319        );
320
321        tracing::debug!("POST partner_consume_consent");
322
323        let http = auth_http_client()?;
324        let resp = http
325            .post(&url)
326            .header("partner_id", sensitive_header_value(partner_id)?)
327            .header("partner_secret", sensitive_header_value(partner_secret)?)
328            .send()
329            .await
330            .map_err(sanitize_auth_http_error)?;
331
332        let status = resp.status();
333        let body = resp
334            .text()
335            .await
336            .map_err(|source| DhanError::ResponseBody {
337                status,
338                source: source.without_url(),
339            })?;
340
341        if status.is_success() {
342            serde_json::from_str(&body).map_err(DhanError::Json)
343        } else {
344            Self::parse_auth_error(status, &body)
345        }
346    }
347
348    // -----------------------------------------------------------------------
349    // Private helpers for auth endpoints
350    // -----------------------------------------------------------------------
351
352    /// Parse an error response from an auth endpoint.
353    fn parse_auth_error<T>(status: reqwest::StatusCode, body: &str) -> Result<T> {
354        if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(body) {
355            if api_err.error_code.is_some() || api_err.error_message.is_some() {
356                return Err(DhanError::Api(api_err));
357            }
358        }
359        // Some auth endpoints may return a simple JSON with a "status" key.
360        if let Ok(val) = serde_json::from_str::<Value>(body) {
361            if let Some(status_str) = val.get("status").and_then(|v| v.as_str()) {
362                return Err(DhanError::HttpStatus {
363                    status,
364                    body: format!("auth error: {status_str}"),
365                });
366            }
367        }
368        Err(DhanError::HttpStatus {
369            status,
370            body: body.to_owned(),
371        })
372    }
373}
374
375fn sensitive_header_value(value: &str) -> Result<HeaderValue> {
376    let mut value = HeaderValue::from_str(value)?;
377    value.set_sensitive(true);
378    Ok(value)
379}
380
381fn auth_http_client() -> Result<reqwest::Client> {
382    reqwest::Client::builder()
383        .redirect(reqwest::redirect::Policy::none())
384        .build()
385        .map_err(DhanError::Http)
386}
387
388fn sanitize_auth_http_error(error: reqwest::Error) -> DhanError {
389    DhanError::Http(error.without_url())
390}
391
392#[cfg(test)]
393mod tests {
394    use super::sanitize_auth_http_error;
395
396    #[tokio::test]
397    async fn auth_transport_errors_do_not_retain_sensitive_urls() {
398        let pin = "1234";
399        let totp = "654321";
400        let token_id = "sensitive-consent-token";
401        let url = format!(
402            "ftp://127.0.0.1/app/generateAccessToken?dhanClientId=1&pin={pin}&totp={totp}&tokenId={token_id}"
403        );
404        let error = reqwest::Client::new()
405            .get(url)
406            .send()
407            .await
408            .expect_err("reqwest must reject the unsupported URL scheme");
409        let error = sanitize_auth_http_error(error);
410        let display = error.to_string();
411        let debug = format!("{error:?}");
412        for secret in [pin, totp, token_id] {
413            assert!(!display.contains(secret));
414            assert!(!debug.contains(secret));
415        }
416    }
417}