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;
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 url = format!(
49            "{}/app/generateAccessToken?dhanClientId={}&pin={}&totp={}",
50            AUTH_BASE_URL, client_id, pin, totp
51        );
52
53        tracing::debug!(%url, "POST generate_access_token");
54
55        let http = reqwest::Client::new();
56        let resp = http.post(&url).send().await?;
57
58        let status = resp.status();
59        let body = resp.text().await.unwrap_or_default();
60
61        if status.is_success() {
62            serde_json::from_str(&body).map_err(DhanError::Json)
63        } else {
64            if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(&body) {
65                if api_err.error_code.is_some() || api_err.error_message.is_some() {
66                    return Err(DhanError::Api(api_err));
67                }
68            }
69            Err(DhanError::HttpStatus { status, body })
70        }
71    }
72
73    // -----------------------------------------------------------------------
74    // Token renewal
75    // -----------------------------------------------------------------------
76
77    /// Renew the current access token for another 24 hours.
78    ///
79    /// Only works for tokens generated from Dhan Web that are still active.
80    /// This expires the current token and returns a new one.
81    ///
82    /// **Endpoint:** `GET /v2/RenewToken`
83    ///
84    /// # Note
85    ///
86    /// The RenewToken endpoint uses `dhanClientId` as its client
87    /// identification header, unlike most other endpoints that use `client-id`.
88    /// This method handles the difference automatically.
89    pub async fn renew_token(&mut self) -> Result<TokenResponse> {
90        let url = format!("{}/v2/RenewToken", self.base_url());
91
92        tracing::debug!(%url, "GET renew_token");
93
94        let resp = self
95            .http()
96            .get(&url)
97            .header("access-token", self.access_token())
98            .header("dhanClientId", self.client_id())
99            .header("Content-Type", "application/json")
100            .header("Accept", "application/json")
101            .send()
102            .await?;
103
104        let status = resp.status();
105        let bytes = resp.bytes().await.unwrap_or_default();
106
107        if status.is_success() {
108            let token: TokenResponse = serde_json::from_slice(&bytes).map_err(DhanError::Json)?;
109            // Update the client's token so subsequent calls use the new one.
110            self.set_access_token(&token.access_token);
111            Ok(token)
112        } else {
113            let body = String::from_utf8_lossy(&bytes);
114            Err(self.parse_error_body(status, &body))
115        }
116    }
117
118    // -----------------------------------------------------------------------
119    // Individual — API key & secret OAuth flow
120    // -----------------------------------------------------------------------
121
122    /// **Step 1:** Generate a consent session for API key-based login.
123    ///
124    /// Validates the `app_id` and `app_secret` and creates a new session.
125    ///
126    /// **Endpoint:** `POST https://auth.dhan.co/app/generate-consent?client_id={dhanClientId}`
127    ///
128    /// Returns an [`AppConsentResponse`] containing a `consent_app_id` to be
129    /// used in the browser redirect step.
130    pub async fn generate_consent(
131        client_id: &str,
132        app_id: &str,
133        app_secret: &str,
134    ) -> Result<AppConsentResponse> {
135        let url = format!(
136            "{}/app/generate-consent?client_id={}",
137            AUTH_BASE_URL, client_id
138        );
139
140        tracing::debug!(%url, "POST generate_consent");
141
142        let http = reqwest::Client::new();
143        let resp = http
144            .post(&url)
145            .header(
146                "app_id",
147                HeaderValue::from_str(app_id).map_err(|_| {
148                    DhanError::InvalidArgument("app_id contains invalid characters".into())
149                })?,
150            )
151            .header(
152                "app_secret",
153                HeaderValue::from_str(app_secret).map_err(|_| {
154                    DhanError::InvalidArgument("app_secret contains invalid characters".into())
155                })?,
156            )
157            .send()
158            .await?;
159
160        let status = resp.status();
161        let body = resp.text().await.unwrap_or_default();
162
163        if status.is_success() {
164            serde_json::from_str(&body).map_err(DhanError::Json)
165        } else {
166            Self::parse_auth_error(status, &body)
167        }
168    }
169
170    /// **Step 2:** Build the browser login URL for user consent.
171    ///
172    /// Open this URL in a browser. After the user authenticates, they will be
173    /// redirected to the redirect URL configured for the API key, with a
174    /// `tokenId` query parameter appended.
175    ///
176    /// ```
177    /// use dhan_rs::DhanClient;
178    /// let url = DhanClient::consent_login_url("940b0ca1-3ff4-4476-b46e-03a3ce7dc55d");
179    /// // → "https://auth.dhan.co/login/consentApp-login?consentAppId=940b0ca1-..."
180    /// ```
181    pub fn consent_login_url(consent_app_id: &str) -> String {
182        format!(
183            "{}/login/consentApp-login?consentAppId={}",
184            AUTH_BASE_URL, consent_app_id
185        )
186    }
187
188    /// **Step 3:** Consume the consent to obtain an access token.
189    ///
190    /// Uses the `token_id` obtained from the browser redirect after the user
191    /// logged in.
192    ///
193    /// **Endpoint:** `POST https://auth.dhan.co/app/consumeApp-consent?tokenId={tokenId}`
194    pub async fn consume_consent(
195        token_id: &str,
196        app_id: &str,
197        app_secret: &str,
198    ) -> Result<TokenResponse> {
199        let url = format!(
200            "{}/app/consumeApp-consent?tokenId={}",
201            AUTH_BASE_URL, token_id
202        );
203
204        tracing::debug!(%url, "POST consume_consent");
205
206        let http = reqwest::Client::new();
207        let resp = http
208            .post(&url)
209            .header(
210                "app_id",
211                HeaderValue::from_str(app_id).map_err(|_| {
212                    DhanError::InvalidArgument("app_id contains invalid characters".into())
213                })?,
214            )
215            .header(
216                "app_secret",
217                HeaderValue::from_str(app_secret).map_err(|_| {
218                    DhanError::InvalidArgument("app_secret contains invalid characters".into())
219                })?,
220            )
221            .send()
222            .await?;
223
224        let status = resp.status();
225        let body = resp.text().await.unwrap_or_default();
226
227        if status.is_success() {
228            serde_json::from_str(&body).map_err(DhanError::Json)
229        } else {
230            Self::parse_auth_error(status, &body)
231        }
232    }
233
234    // -----------------------------------------------------------------------
235    // Partner — OAuth flow
236    // -----------------------------------------------------------------------
237
238    /// **Step 1 (Partner):** Generate a partner consent session.
239    ///
240    /// **Endpoint:** `POST https://auth.dhan.co/partner/generate-consent`
241    pub async fn partner_generate_consent(
242        partner_id: &str,
243        partner_secret: &str,
244    ) -> Result<PartnerConsentResponse> {
245        let url = format!("{}/partner/generate-consent", AUTH_BASE_URL);
246
247        tracing::debug!(%url, "POST partner_generate_consent");
248
249        let http = reqwest::Client::new();
250        let resp = http
251            .post(&url)
252            .header(
253                "partner_id",
254                HeaderValue::from_str(partner_id).map_err(|_| {
255                    DhanError::InvalidArgument("partner_id contains invalid characters".into())
256                })?,
257            )
258            .header(
259                "partner_secret",
260                HeaderValue::from_str(partner_secret).map_err(|_| {
261                    DhanError::InvalidArgument("partner_secret contains invalid characters".into())
262                })?,
263            )
264            .send()
265            .await?;
266
267        let status = resp.status();
268        let body = resp.text().await.unwrap_or_default();
269
270        if status.is_success() {
271            serde_json::from_str(&body).map_err(DhanError::Json)
272        } else {
273            Self::parse_auth_error(status, &body)
274        }
275    }
276
277    /// **Step 2 (Partner):** Build the browser login URL for partner consent.
278    ///
279    /// Open this URL in a browser. After the user authenticates, they will be
280    /// redirected with a `tokenId` query parameter.
281    pub fn partner_consent_login_url(consent_id: &str) -> String {
282        format!("{}/consent-login?consentId={}", AUTH_BASE_URL, consent_id)
283    }
284
285    /// **Step 3 (Partner):** Consume the partner consent to obtain an access token.
286    ///
287    /// **Endpoint:** `POST https://auth.dhan.co/partner/consume-consent?tokenId={tokenId}`
288    pub async fn partner_consume_consent(
289        token_id: &str,
290        partner_id: &str,
291        partner_secret: &str,
292    ) -> Result<TokenResponse> {
293        let url = format!(
294            "{}/partner/consume-consent?tokenId={}",
295            AUTH_BASE_URL, token_id
296        );
297
298        tracing::debug!(%url, "POST partner_consume_consent");
299
300        let http = reqwest::Client::new();
301        let resp = http
302            .post(&url)
303            .header(
304                "partner_id",
305                HeaderValue::from_str(partner_id).map_err(|_| {
306                    DhanError::InvalidArgument("partner_id contains invalid characters".into())
307                })?,
308            )
309            .header(
310                "partner_secret",
311                HeaderValue::from_str(partner_secret).map_err(|_| {
312                    DhanError::InvalidArgument("partner_secret contains invalid characters".into())
313                })?,
314            )
315            .send()
316            .await?;
317
318        let status = resp.status();
319        let body = resp.text().await.unwrap_or_default();
320
321        if status.is_success() {
322            serde_json::from_str(&body).map_err(DhanError::Json)
323        } else {
324            Self::parse_auth_error(status, &body)
325        }
326    }
327
328    // -----------------------------------------------------------------------
329    // Private helpers for auth endpoints
330    // -----------------------------------------------------------------------
331
332    /// Parse an error response from an auth endpoint.
333    fn parse_auth_error<T>(status: reqwest::StatusCode, body: &str) -> Result<T> {
334        if let Ok(api_err) = serde_json::from_str::<ApiErrorBody>(body) {
335            if api_err.error_code.is_some() || api_err.error_message.is_some() {
336                return Err(DhanError::Api(api_err));
337            }
338        }
339        // Some auth endpoints may return a simple JSON with a "status" key.
340        if let Ok(val) = serde_json::from_str::<Value>(body) {
341            if let Some(status_str) = val.get("status").and_then(|v| v.as_str()) {
342                return Err(DhanError::HttpStatus {
343                    status,
344                    body: format!("auth error: {status_str}"),
345                });
346            }
347        }
348        Err(DhanError::HttpStatus {
349            status,
350            body: body.to_owned(),
351        })
352    }
353}