1use std::collections::HashMap;
2
3use base64::Engine;
4use chrono::{DateTime, Utc};
5use rand::Rng;
6use serde::Deserialize;
7
8use crate::client::{Client, TokenInfo};
9use crate::error;
10use crate::http::RequestBody;
11
12#[derive(Debug, Deserialize)]
18pub struct TokenResponse {
19 pub access_token: String,
21 pub token_type: String,
23 pub expires_in: Option<i64>,
25 pub user_id: Option<i64>,
27}
28
29#[derive(Debug, Deserialize)]
31pub struct LongLivedTokenResponse {
32 pub access_token: String,
34 pub token_type: String,
36 pub expires_in: i64,
38}
39
40#[derive(Debug, Deserialize)]
42pub struct DebugTokenResponse {
43 pub data: DebugTokenData,
45}
46
47#[derive(Debug, Deserialize)]
49pub struct DebugTokenData {
50 pub is_valid: bool,
52 pub expires_at: i64,
54 pub issued_at: i64,
56 pub scopes: Vec<String>,
58 pub user_id: String,
60 #[serde(default, rename = "type")]
62 pub token_type: Option<String>,
63 #[serde(default)]
65 pub application: Option<String>,
66 #[serde(default)]
68 pub data_access_expires_at: Option<i64>,
69}
70
71#[derive(Debug, Deserialize)]
73pub struct AppAccessTokenResponse {
74 pub access_token: String,
76 pub token_type: String,
78}
79
80fn app_access_token_shorthand(client_id: &str, client_secret: &str) -> String {
88 if client_id.is_empty() || client_secret.is_empty() {
89 return String::new();
90 }
91 format!("TH|{client_id}|{client_secret}")
92}
93
94fn generate_state() -> String {
96 let bytes: [u8; 32] = rand::rng().random();
97 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
98}
99
100impl Client {
105 pub fn get_auth_url(&self, scopes: &[String]) -> (String, String) {
112 let cfg = self.config();
113 let effective_scopes = if scopes.is_empty() {
114 &cfg.scopes
115 } else {
116 scopes
117 };
118
119 let scope = effective_scopes.join(",");
120 let state = generate_state();
121
122 let mut url = url::Url::parse("https://www.threads.net/oauth/authorize")
123 .expect("static URL is valid");
124
125 url.query_pairs_mut()
126 .append_pair("client_id", &cfg.client_id)
127 .append_pair("redirect_uri", &cfg.redirect_uri)
128 .append_pair("scope", &scope)
129 .append_pair("response_type", "code")
130 .append_pair("state", &state);
131
132 (url.into(), state)
133 }
134
135 pub async fn get_app_access_token(&self) -> crate::Result<AppAccessTokenResponse> {
140 let cfg = self.config();
141
142 let mut params = HashMap::new();
147 params.insert("client_id".into(), cfg.client_id.clone());
148 params.insert("client_secret".into(), cfg.client_secret.clone());
149 params.insert("grant_type".into(), "client_credentials".into());
150
151 let resp = self
152 .http_client
153 .get("/oauth/access_token", params, "")
154 .await?;
155
156 resp.json()
157 }
158
159 pub fn get_app_access_token_shorthand(&self) -> String {
164 let cfg = self.config();
165 app_access_token_shorthand(&cfg.client_id, &cfg.client_secret)
166 }
167
168 pub async fn exchange_code_for_token(&self, code: &str) -> crate::Result<()> {
172 let cfg = self.config().clone();
173
174 let mut form = HashMap::new();
175 form.insert("client_id".into(), cfg.client_id);
176 form.insert("client_secret".into(), cfg.client_secret);
177 form.insert("grant_type".into(), "authorization_code".into());
178 form.insert("redirect_uri".into(), cfg.redirect_uri);
179 form.insert("code".into(), code.to_owned());
180
181 let resp = self
182 .http_client
183 .post("/oauth/access_token", Some(RequestBody::Form(form)), "")
184 .await?;
185
186 let token_resp: TokenResponse = resp.json()?;
187
188 let expires_in = token_resp.expires_in.unwrap_or(3600);
189 let user_id = token_resp
190 .user_id
191 .map(|id| id.to_string())
192 .unwrap_or_default();
193
194 let token_info = TokenInfo {
195 access_token: token_resp.access_token,
196 token_type: token_resp.token_type,
197 expires_at: Utc::now() + chrono::Duration::seconds(expires_in),
198 user_id,
199 created_at: Utc::now(),
200 };
201
202 self.set_token_info(token_info).await
203 }
204
205 pub async fn get_long_lived_token(&self) -> crate::Result<()> {
209 let access_token = self.access_token().await;
210 if access_token.is_empty() {
211 return Err(error::new_authentication_error(
212 401,
213 "No access token available",
214 "Call exchange_code_for_token first",
215 ));
216 }
217
218 let cfg = self.config();
219
220 let mut params = HashMap::new();
224 params.insert("grant_type".into(), "th_exchange_token".into());
225 params.insert("client_secret".into(), cfg.client_secret.clone());
226 params.insert("access_token".into(), access_token.clone());
227
228 let resp = self
229 .http_client
230 .get("/access_token", params, &access_token)
231 .await?;
232
233 let long_resp: LongLivedTokenResponse = resp.json()?;
234
235 let user_id = self.user_id().await;
236
237 let token_info = TokenInfo {
238 access_token: long_resp.access_token,
239 token_type: long_resp.token_type,
240 expires_at: Utc::now() + chrono::Duration::seconds(long_resp.expires_in),
241 user_id,
242 created_at: Utc::now(),
243 };
244
245 self.set_token_info(token_info).await
246 }
247
248 pub async fn refresh_token(&self) -> crate::Result<()> {
252 let access_token = self.access_token().await;
253 if access_token.is_empty() {
254 return Err(error::new_authentication_error(
255 401,
256 "No access token available",
257 "Cannot refresh without a valid token",
258 ));
259 }
260
261 let mut params = HashMap::new();
262 params.insert("grant_type".into(), "th_refresh_token".into());
263 params.insert("access_token".into(), access_token.clone());
264
265 let resp = self
266 .http_client
267 .get("/refresh_access_token", params, &access_token)
268 .await?;
269
270 let long_resp: LongLivedTokenResponse = resp.json()?;
271
272 let user_id = self.user_id().await;
273
274 let token_info = TokenInfo {
275 access_token: long_resp.access_token,
276 token_type: long_resp.token_type,
277 expires_at: Utc::now() + chrono::Duration::seconds(long_resp.expires_in),
278 user_id,
279 created_at: Utc::now(),
280 };
281
282 self.set_token_info(token_info).await
283 }
284
285 pub async fn debug_token(&self, input_token: &str) -> crate::Result<DebugTokenResponse> {
287 let token = self.access_token().await;
288 if token.is_empty() {
289 return Err(crate::error::new_authentication_error(
290 401,
291 "Access token is required to call debug_token",
292 "",
293 ));
294 }
295
296 let mut params = HashMap::new();
297 params.insert("input_token".into(), input_token.to_owned());
298
299 let resp = self.http_client.get("/debug_token", params, &token).await?;
300
301 resp.json()
302 }
303
304 pub async fn validate_token(&self) -> crate::Result<()> {
306 let state = self.get_token_info().await;
307 match state {
308 Some(info) => {
309 if info.access_token.is_empty() {
310 return Err(error::new_authentication_error(401, "Token is empty", ""));
311 }
312 if Utc::now() > info.expires_at {
313 return Err(error::new_authentication_error(
314 401,
315 "Token has expired",
316 "",
317 ));
318 }
319 Ok(())
320 }
321 None => Err(error::new_authentication_error(
322 401,
323 "No token available",
324 "",
325 )),
326 }
327 }
328
329 pub async fn ensure_valid_token(&self) -> crate::Result<()> {
334 match self.validate_token().await {
335 Ok(()) => Ok(()),
336 Err(e) => {
337 if self.is_token_expired().await && self.get_token_info().await.is_some() {
339 self.refresh_token().await
340 } else {
341 Err(e)
342 }
343 }
344 }
345 }
346
347 pub async fn get_token_debug_info(&self) -> HashMap<String, String> {
351 let mut info = HashMap::new();
352 let state = self.get_token_info().await;
353 match state {
354 Some(token_info) => {
355 let masked = if token_info.access_token.len() > 8 {
356 let len = token_info.access_token.len();
357 format!(
358 "{}...{}",
359 &token_info.access_token[..4],
360 &token_info.access_token[len - 4..]
361 )
362 } else {
363 "****".to_owned()
364 };
365 info.insert("access_token".into(), masked);
366 info.insert("token_type".into(), token_info.token_type.clone());
367 info.insert("expires_at".into(), token_info.expires_at.to_rfc3339());
368 info.insert("user_id".into(), token_info.user_id.clone());
369 info.insert("created_at".into(), token_info.created_at.to_rfc3339());
370 info.insert(
371 "is_expired".into(),
372 (Utc::now() > token_info.expires_at).to_string(),
373 );
374 }
375 None => {
376 info.insert("status".into(), "no_token".into());
377 }
378 }
379 info
380 }
381
382 pub async fn load_token_from_storage(&self) -> crate::Result<()> {
384 let loaded = self.token_storage.load().await?;
385 self.set_token_info(loaded).await
386 }
387
388 pub async fn set_token_from_debug_info(
393 &self,
394 access_token: &str,
395 debug_resp: &DebugTokenResponse,
396 ) -> crate::Result<()> {
397 let data = &debug_resp.data;
398
399 if !data.is_valid {
400 return Err(error::new_authentication_error(
401 401,
402 "Cannot set token from invalid debug info: token is not valid",
403 "",
404 ));
405 }
406
407 let expires_at =
408 DateTime::<Utc>::from_timestamp(data.expires_at, 0).unwrap_or_else(Utc::now);
409
410 let created_at =
411 DateTime::<Utc>::from_timestamp(data.issued_at, 0).unwrap_or_else(Utc::now);
412
413 let token_info = TokenInfo {
414 access_token: access_token.to_owned(),
415 token_type: "bearer".into(),
416 expires_at,
417 user_id: data.user_id.clone(),
418 created_at,
419 };
420
421 self.set_token_info(token_info).await
422 }
423}
424
425#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::client::Config;
433
434 fn test_config() -> Config {
435 Config::new(
436 "test-client-id",
437 "test-secret",
438 "https://example.com/callback",
439 )
440 }
441
442 #[test]
443 fn test_generate_state_unique() {
444 let a = generate_state();
445 let b = generate_state();
446 assert_ne!(a, b);
447 assert_eq!(a.len(), 43);
449 }
450
451 #[test]
452 fn test_generate_state_is_valid_base64url() {
453 let s = generate_state();
454 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
455 .decode(&s)
456 .expect("should be valid base64url");
457 assert_eq!(decoded.len(), 32);
458 }
459
460 #[tokio::test]
461 async fn test_get_auth_url_contains_required_params() {
462 let client = Client::new(test_config()).await.unwrap();
463 let (url, state) = client.get_auth_url(&[]);
464
465 assert!(url.starts_with("https://www.threads.net/oauth/authorize?"));
466 assert!(url.contains("client_id=test-client-id"));
467 assert!(url.contains("redirect_uri="));
468 assert!(url.contains("response_type=code"));
469 assert!(url.contains("state="));
470 assert!(url.contains("scope="));
471 assert!(
472 !state.is_empty(),
473 "state must be returned for CSRF verification"
474 );
475 assert!(url.contains(&format!("state={state}")));
476 }
477
478 #[tokio::test]
479 async fn test_get_auth_url_uses_custom_scopes() {
480 let client = Client::new(test_config()).await.unwrap();
481 let scopes = vec!["threads_basic".into(), "threads_manage_replies".into()];
482 let (url, _state) = client.get_auth_url(&scopes);
483
484 assert!(url.contains("scope=threads_basic%2Cthreads_manage_replies"));
486 }
487
488 #[tokio::test]
489 async fn test_get_auth_url_uses_config_scopes_when_empty() {
490 let client = Client::new(test_config()).await.unwrap();
491 let (url, _state) = client.get_auth_url(&[]);
492
493 assert!(url.contains("threads_basic"));
495 }
496
497 #[test]
498 fn test_token_response_deserialize() {
499 let json = r#"{
500 "access_token": "tok_abc",
501 "token_type": "bearer",
502 "expires_in": 3600,
503 "user_id": 12345
504 }"#;
505 let resp: TokenResponse = serde_json::from_str(json).unwrap();
506 assert_eq!(resp.access_token, "tok_abc");
507 assert_eq!(resp.token_type, "bearer");
508 assert_eq!(resp.expires_in, Some(3600));
509 assert_eq!(resp.user_id, Some(12345));
510 }
511
512 #[test]
513 fn test_token_response_deserialize_optional_fields() {
514 let json = r#"{
515 "access_token": "tok_abc",
516 "token_type": "bearer"
517 }"#;
518 let resp: TokenResponse = serde_json::from_str(json).unwrap();
519 assert!(resp.expires_in.is_none());
520 assert!(resp.user_id.is_none());
521 }
522
523 #[test]
524 fn test_long_lived_token_response_deserialize() {
525 let json = r#"{
526 "access_token": "long_tok",
527 "token_type": "bearer",
528 "expires_in": 5184000
529 }"#;
530 let resp: LongLivedTokenResponse = serde_json::from_str(json).unwrap();
531 assert_eq!(resp.access_token, "long_tok");
532 assert_eq!(resp.expires_in, 5184000);
533 }
534
535 #[test]
536 fn test_debug_token_response_deserialize() {
537 let json = r#"{
538 "data": {
539 "is_valid": true,
540 "expires_at": 1700000000,
541 "issued_at": 1699900000,
542 "scopes": ["threads_basic", "threads_content_publish"],
543 "user_id": "987654"
544 }
545 }"#;
546 let resp: DebugTokenResponse = serde_json::from_str(json).unwrap();
547 assert!(resp.data.is_valid);
548 assert_eq!(resp.data.expires_at, 1700000000);
549 assert_eq!(resp.data.issued_at, 1699900000);
550 assert_eq!(resp.data.scopes.len(), 2);
551 assert_eq!(resp.data.user_id, "987654");
552 }
553
554 #[tokio::test]
555 async fn test_validate_token_no_token() {
556 let client = Client::new(test_config()).await.unwrap();
557 assert!(client.validate_token().await.is_err());
558 }
559
560 #[tokio::test]
561 async fn test_validate_token_valid() {
562 let client = Client::new(test_config()).await.unwrap();
563 let token = crate::client::TokenInfo {
564 access_token: "valid-tok".into(),
565 token_type: "Bearer".into(),
566 expires_at: Utc::now() + chrono::Duration::hours(1),
567 user_id: "u-1".into(),
568 created_at: Utc::now(),
569 };
570 client.set_token_info(token).await.unwrap();
571 assert!(client.validate_token().await.is_ok());
572 }
573
574 #[tokio::test]
575 async fn test_validate_token_expired() {
576 let client = Client::new(test_config()).await.unwrap();
577 let token = crate::client::TokenInfo {
578 access_token: "expired-tok".into(),
579 token_type: "Bearer".into(),
580 expires_at: Utc::now() - chrono::Duration::hours(1),
581 user_id: "u-1".into(),
582 created_at: Utc::now() - chrono::Duration::hours(2),
583 };
584 client.set_token_info(token).await.unwrap();
585 assert!(client.validate_token().await.is_err());
586 }
587
588 #[tokio::test]
589 async fn test_get_token_debug_info_no_token() {
590 let client = Client::new(test_config()).await.unwrap();
591 let info = client.get_token_debug_info().await;
592 assert_eq!(info.get("status").unwrap(), "no_token");
593 }
594
595 #[tokio::test]
596 async fn test_get_token_debug_info_with_token() {
597 let client = Client::new(test_config()).await.unwrap();
598 let token = crate::client::TokenInfo {
599 access_token: "abcdefghijklmnop".into(),
600 token_type: "Bearer".into(),
601 expires_at: Utc::now() + chrono::Duration::hours(1),
602 user_id: "u-1".into(),
603 created_at: Utc::now(),
604 };
605 client.set_token_info(token).await.unwrap();
606 let info = client.get_token_debug_info().await;
607 let masked = info.get("access_token").unwrap();
608 assert!(masked.starts_with("abcd"));
609 assert!(masked.ends_with("mnop"));
610 assert!(masked.contains("..."));
611 assert_eq!(info.get("user_id").unwrap(), "u-1");
612 assert_eq!(info.get("is_expired").unwrap(), "false");
613 }
614
615 #[tokio::test]
616 async fn test_load_token_from_storage_empty() {
617 let client = Client::new(test_config()).await.unwrap();
618 assert!(client.load_token_from_storage().await.is_err());
620 }
621
622 #[test]
623 fn test_app_access_token_response_deserialize() {
624 let json = r#"{
625 "access_token": "app_tok_abc",
626 "token_type": "bearer"
627 }"#;
628 let resp: AppAccessTokenResponse = serde_json::from_str(json).unwrap();
629 assert_eq!(resp.access_token, "app_tok_abc");
630 assert_eq!(resp.token_type, "bearer");
631 }
632
633 #[tokio::test]
634 async fn test_get_app_access_token_shorthand() {
635 let client = Client::new(test_config()).await.unwrap();
636 let shorthand = client.get_app_access_token_shorthand();
637 assert_eq!(shorthand, "TH|test-client-id|test-secret");
638 }
639
640 #[test]
641 fn test_app_access_token_shorthand_empty_client_id() {
642 assert_eq!(app_access_token_shorthand("", "secret"), "");
643 }
644
645 #[test]
646 fn test_app_access_token_shorthand_empty_secret() {
647 assert_eq!(app_access_token_shorthand("id", ""), "");
648 }
649
650 #[test]
651 fn test_app_access_token_shorthand_both_empty() {
652 assert_eq!(app_access_token_shorthand("", ""), "");
653 }
654}