Skip to main content

threads_rs/
auth.rs

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// ---------------------------------------------------------------------------
13// OAuth response types
14// ---------------------------------------------------------------------------
15
16/// Response from the short-lived token exchange (`/oauth/access_token`).
17#[derive(Debug, Deserialize)]
18pub struct TokenResponse {
19    /// The OAuth access token.
20    pub access_token: String,
21    /// Token type (usually "bearer").
22    pub token_type: String,
23    /// Token lifetime in seconds.
24    pub expires_in: Option<i64>,
25    /// App-scoped user ID.
26    pub user_id: Option<i64>,
27}
28
29/// Response from the long-lived token exchange (`/access_token`).
30#[derive(Debug, Deserialize)]
31pub struct LongLivedTokenResponse {
32    /// The long-lived access token.
33    pub access_token: String,
34    /// Token type (usually "bearer").
35    pub token_type: String,
36    /// Token lifetime in seconds (typically 5184000 for 60 days).
37    pub expires_in: i64,
38}
39
40/// Response from the debug token endpoint (`/debug_token`).
41#[derive(Debug, Deserialize)]
42pub struct DebugTokenResponse {
43    /// Token introspection data.
44    pub data: DebugTokenData,
45}
46
47/// Inner payload of a debug-token response.
48#[derive(Debug, Deserialize)]
49pub struct DebugTokenData {
50    /// Whether the token is currently valid.
51    pub is_valid: bool,
52    /// Unix timestamp when the token expires.
53    pub expires_at: i64,
54    /// Unix timestamp when the token was issued.
55    pub issued_at: i64,
56    /// OAuth scopes granted to the token.
57    pub scopes: Vec<String>,
58    /// App-scoped user ID.
59    pub user_id: String,
60    /// Token type: "USER" or "APP".
61    #[serde(default, rename = "type")]
62    pub token_type: Option<String>,
63    /// Name of the application.
64    #[serde(default)]
65    pub application: Option<String>,
66    /// Unix timestamp when the app's data access expires.
67    #[serde(default)]
68    pub data_access_expires_at: Option<i64>,
69}
70
71/// Response from the app access token endpoint.
72#[derive(Debug, Deserialize)]
73pub struct AppAccessTokenResponse {
74    /// The app access token.
75    pub access_token: String,
76    /// Token type (usually "bearer").
77    pub token_type: String,
78}
79
80// ---------------------------------------------------------------------------
81// Helpers
82// ---------------------------------------------------------------------------
83
84/// Build the app access token shorthand string.
85///
86/// Returns `"TH|{client_id}|{client_secret}"` or an empty string if either is empty.
87fn 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
94/// Generate a cryptographically-random state parameter (base64url, 32 bytes).
95fn generate_state() -> String {
96    let bytes: [u8; 32] = rand::rng().random();
97    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
98}
99
100// ---------------------------------------------------------------------------
101// Auth methods on Client
102// ---------------------------------------------------------------------------
103
104impl Client {
105    /// Build the OAuth authorization URL that the user should visit.
106    ///
107    /// `scopes` overrides the scopes from the client config. Pass an empty
108    /// slice to use the config defaults.
109    /// Returns `(url, state)` — the caller must store `state` and verify it
110    /// matches the `state` query parameter on the OAuth callback to prevent CSRF.
111    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    /// Get an app access token using client credentials.
136    ///
137    /// This does NOT store the token in the client (matches Go behavior).
138    /// The caller should use the returned token as needed.
139    pub async fn get_app_access_token(&self) -> crate::Result<AppAccessTokenResponse> {
140        let cfg = self.config();
141
142        // SECURITY: The Graph API requires client_secret as a query parameter for
143        // app access token requests (GET /oauth/access_token). This means the secret
144        // appears in server/proxy access logs. Always use HTTPS and ensure log access
145        // is restricted.
146        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    /// Get an app access token in shorthand format.
160    ///
161    /// Returns `"TH|{client_id}|{client_secret}"` or an empty string if
162    /// `client_id` or `client_secret` are empty.
163    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    /// Exchange an authorization code for a short-lived access token.
169    ///
170    /// On success the token is stored via `set_token_info`.
171    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    /// Convert the current short-lived token into a long-lived token (60 days).
206    ///
207    /// Requires that the client already holds a valid short-lived token.
208    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        // SECURITY: The Graph API requires client_secret as a query parameter for
221        // long-lived token exchange (GET /access_token). This means the secret appears
222        // in server/proxy access logs. Always use HTTPS and ensure log access is restricted.
223        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    /// Refresh the current long-lived token, extending its expiry.
249    ///
250    /// The token must still be valid (not expired) to be refreshed.
251    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    /// Inspect a token via the `/debug_token` endpoint.
286    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    /// Validate the current token locally: non-empty and not expired.
305    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    /// Validate the current token and auto-refresh if expired.
330    ///
331    /// Only attempts a refresh when the token exists but has expired.
332    /// Returns the original error for other failures (no token, empty token).
333    pub async fn ensure_valid_token(&self) -> crate::Result<()> {
334        match self.validate_token().await {
335            Ok(()) => Ok(()),
336            Err(e) => {
337                // Only refresh if we have a token that expired
338                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    /// Return debug information about the current token.
348    ///
349    /// The access token is masked (first 4 + last 4 characters shown).
350    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    /// Explicitly reload the token from storage.
383    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    /// Store a token built from a previous `debug_token` response.
389    ///
390    /// Useful for bootstrapping the client from a known-valid token without
391    /// going through the full OAuth flow again.
392    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// ---------------------------------------------------------------------------
426// Tests
427// ---------------------------------------------------------------------------
428
429#[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        // base64url of 32 bytes = 43 chars (no padding)
448        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        // comma-joined in the scope param
485        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        // Config default includes threads_basic
494        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        // No token stored — should error
619        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}