1use async_trait::async_trait;
4use reqwest::{Client, StatusCode};
5
6use crate::{
7 config::Config,
8 error::AuthError,
9 session::interface::{IgAuthenticator, IgSession},
10 session::response::{AccountSwitchRequest, AccountSwitchResponse, SessionResp},
11};
12
13pub struct IgAuth<'a> {
15 pub(crate) cfg: &'a Config,
16 http: Client,
17}
18
19impl<'a> IgAuth<'a> {
20 pub fn new(cfg: &'a Config) -> Self {
28 Self {
29 cfg,
30 http: Client::builder()
31 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
32 .build()
33 .expect("reqwest client"),
34 }
35 }
36
37 fn rest_url(&self, path: &str) -> String {
39 format!(
40 "{}/{}",
41 self.cfg.rest_api.base_url.trim_end_matches('/'),
42 path.trim_start_matches('/')
43 )
44 }
45
46 #[allow(dead_code)]
57 fn get_client(&self) -> &Client {
58 &self.http
59 }
60}
61
62#[async_trait]
63impl IgAuthenticator for IgAuth<'_> {
64 async fn login(&self) -> Result<IgSession, AuthError> {
65 let url = self.rest_url("session");
67
68 let api_key = self.cfg.credentials.api_key.trim();
70 let username = self.cfg.credentials.username.trim();
71 let password = self.cfg.credentials.password.trim();
72
73 tracing::info!("Login request to URL: {}", url);
75 tracing::info!("Using API key (length): {}", api_key.len());
76 tracing::info!("Using username: {}", username);
77
78 let body = serde_json::json!({
80 "identifier": username,
81 "password": password,
82 "encryptedPassword": false
83 });
84
85 tracing::debug!(
86 "Request body: {}",
87 serde_json::to_string(&body).unwrap_or_default()
88 );
89
90 let client = Client::builder()
92 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
93 .build()
94 .expect("reqwest client");
95
96 let resp = client
98 .post(url)
99 .header("X-IG-API-KEY", api_key)
100 .header("Content-Type", "application/json; charset=UTF-8")
101 .header("Accept", "application/json; charset=UTF-8")
102 .header("Version", "2")
103 .json(&body)
104 .send()
105 .await?;
106
107 tracing::info!("Login response status: {}", resp.status());
109 tracing::debug!("Response headers: {:#?}", resp.headers());
110
111 match resp.status() {
112 StatusCode::OK => {
113 let cst = match resp.headers().get("CST") {
115 Some(value) => {
116 let cst_str = value
117 .to_str()
118 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
119 tracing::info!(
120 "Successfully obtained CST token of length: {}",
121 cst_str.len()
122 );
123 cst_str.to_owned()
124 }
125 None => {
126 tracing::error!("CST header not found in response");
127 return Err(AuthError::Unexpected(StatusCode::OK));
128 }
129 };
130
131 let token = match resp.headers().get("X-SECURITY-TOKEN") {
132 Some(value) => {
133 let token_str = value
134 .to_str()
135 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
136 tracing::info!(
137 "Successfully obtained X-SECURITY-TOKEN of length: {}",
138 token_str.len()
139 );
140 token_str.to_owned()
141 }
142 None => {
143 tracing::error!("X-SECURITY-TOKEN header not found in response");
144 return Err(AuthError::Unexpected(StatusCode::OK));
145 }
146 };
147
148 let json: SessionResp = resp.json().await?;
150 tracing::info!("Account ID: {}", json.account_id);
151
152 Ok(IgSession {
153 cst,
154 token,
155 account_id: json.account_id,
156 })
157 }
158 StatusCode::UNAUTHORIZED => {
159 tracing::error!("Authentication failed with UNAUTHORIZED");
160 let body = resp
161 .text()
162 .await
163 .unwrap_or_else(|_| "Could not read response body".to_string());
164 tracing::error!("Response body: {}", body);
165 Err(AuthError::BadCredentials)
166 }
167 StatusCode::FORBIDDEN => {
168 tracing::error!("Authentication failed with FORBIDDEN");
169 let body = resp
170 .text()
171 .await
172 .unwrap_or_else(|_| "Could not read response body".to_string());
173 tracing::error!("Response body: {}", body);
174 Err(AuthError::BadCredentials)
175 }
176 other => {
177 tracing::error!("Authentication failed with unexpected status: {}", other);
178 let body = resp
179 .text()
180 .await
181 .unwrap_or_else(|_| "Could not read response body".to_string());
182 tracing::error!("Response body: {}", body);
183 Err(AuthError::Unexpected(other))
184 }
185 }
186 }
187
188 async fn refresh(&self, sess: &IgSession) -> Result<IgSession, AuthError> {
189 let url = self.rest_url("session/refresh-token");
190
191 let api_key = self.cfg.credentials.api_key.trim();
193
194 tracing::info!("Refresh request to URL: {}", url);
196 tracing::info!("Using API key (length): {}", api_key.len());
197 tracing::info!("Using CST token (length): {}", sess.cst.len());
198 tracing::info!("Using X-SECURITY-TOKEN (length): {}", sess.token.len());
199
200 let client = Client::builder()
202 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
203 .build()
204 .expect("reqwest client");
205
206 let resp = client
207 .post(url)
208 .header("X-IG-API-KEY", api_key)
209 .header("CST", &sess.cst)
210 .header("X-SECURITY-TOKEN", &sess.token)
211 .header("Version", "3")
212 .header("Content-Type", "application/json; charset=UTF-8")
213 .header("Accept", "application/json; charset=UTF-8")
214 .send()
215 .await?;
216
217 tracing::info!("Refresh response status: {}", resp.status());
219 tracing::debug!("Response headers: {:#?}", resp.headers());
220
221 match resp.status() {
222 StatusCode::OK => {
223 let cst = match resp.headers().get("CST") {
225 Some(value) => {
226 let cst_str = value
227 .to_str()
228 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
229 tracing::info!(
230 "Successfully obtained refreshed CST token of length: {}",
231 cst_str.len()
232 );
233 cst_str.to_owned()
234 }
235 None => {
236 tracing::error!("CST header not found in refresh response");
237 return Err(AuthError::Unexpected(StatusCode::OK));
238 }
239 };
240
241 let token = match resp.headers().get("X-SECURITY-TOKEN") {
242 Some(value) => {
243 let token_str = value
244 .to_str()
245 .map_err(|_| AuthError::Unexpected(StatusCode::OK))?;
246 tracing::info!(
247 "Successfully obtained refreshed X-SECURITY-TOKEN of length: {}",
248 token_str.len()
249 );
250 token_str.to_owned()
251 }
252 None => {
253 tracing::error!("X-SECURITY-TOKEN header not found in refresh response");
254 return Err(AuthError::Unexpected(StatusCode::OK));
255 }
256 };
257
258 let json: SessionResp = resp.json().await?;
260 tracing::info!("Refreshed session for Account ID: {}", json.account_id);
261
262 Ok(IgSession {
263 cst,
264 token,
265 account_id: json.account_id,
266 })
267 }
268 other => {
269 tracing::error!("Session refresh failed with status: {}", other);
270 let body = resp
271 .text()
272 .await
273 .unwrap_or_else(|_| "Could not read response body".to_string());
274 tracing::error!("Response body: {}", body);
275 Err(AuthError::Unexpected(other))
276 }
277 }
278 }
279
280 async fn switch_account(
281 &self,
282 session: &IgSession,
283 account_id: &str,
284 default_account: Option<bool>,
285 ) -> Result<IgSession, AuthError> {
286 if session.account_id == account_id {
288 tracing::info!("Already on account ID: {}. No need to switch.", account_id);
289 return Ok(IgSession {
291 cst: session.cst.clone(),
292 token: session.token.clone(),
293 account_id: session.account_id.clone(),
294 });
295 }
296
297 let url = self.rest_url("session");
298
299 let api_key = self.cfg.credentials.api_key.trim();
301
302 tracing::info!("Account switch request to URL: {}", url);
304 tracing::info!("Using API key (length): {}", api_key.len());
305 tracing::info!("Switching to account ID: {}", account_id);
306 tracing::info!("Set as default account: {:?}", default_account);
307
308 let body = AccountSwitchRequest {
310 account_id: account_id.to_string(),
311 default_account,
312 };
313
314 tracing::debug!(
315 "Request body: {}",
316 serde_json::to_string(&body).unwrap_or_default()
317 );
318
319 let client = Client::builder()
321 .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
322 .build()
323 .expect("reqwest client");
324
325 let resp = client
327 .put(url)
328 .header("X-IG-API-KEY", api_key)
329 .header("CST", &session.cst)
330 .header("X-SECURITY-TOKEN", &session.token)
331 .header("Version", "1")
332 .header("Content-Type", "application/json; charset=UTF-8")
333 .header("Accept", "application/json; charset=UTF-8")
334 .json(&body)
335 .send()
336 .await?;
337
338 tracing::info!("Account switch response status: {}", resp.status());
340 tracing::debug!("Response headers: {:#?}", resp.headers());
341
342 match resp.status() {
343 StatusCode::OK => {
344 let switch_response: AccountSwitchResponse = resp.json().await?;
346 tracing::info!("Account switch successful");
347 tracing::debug!("Account switch response: {:?}", switch_response);
348
349 Ok(IgSession {
352 cst: session.cst.clone(),
353 token: session.token.clone(),
354 account_id: account_id.to_string(),
355 })
356 }
357 other => {
358 tracing::error!("Account switch failed with status: {}", other);
359 let body = resp
360 .text()
361 .await
362 .unwrap_or_else(|_| "Could not read response body".to_string());
363 tracing::error!("Response body: {}", body);
364
365 if other == StatusCode::UNAUTHORIZED {
368 tracing::warn!(
369 "Cannot switch to account ID: {}. The account might not exist or you don't have permission.",
370 account_id
371 );
372 }
373
374 Err(AuthError::Unexpected(other))
375 }
376 }
377 }
378}