ndl_core/
oauth.rs

1use serde::Deserialize;
2use thiserror::Error;
3
4pub const TOKEN_URL: &str = "https://graph.threads.net/oauth/access_token";
5pub const OAUTH_SCOPES: &str =
6    "threads_basic,threads_read_replies,threads_manage_replies,threads_content_publish";
7
8#[derive(Debug, Deserialize)]
9pub struct TokenResponse {
10    pub access_token: String,
11    #[allow(dead_code)]
12    pub user_id: u64,
13    /// Number of seconds until the token expires (3600 for short-lived, 5184000 for long-lived)
14    #[serde(default)]
15    pub expires_in: Option<u64>,
16}
17
18#[derive(Debug, Error)]
19pub enum TokenExchangeError {
20    #[error("Request failed: {0}")]
21    Request(String),
22    #[error("HTTP {status}: {body}")]
23    Http { status: u16, body: String },
24    #[error("Parse error: {0}")]
25    Parse(String),
26}
27
28/// Exchange an authorization code for an access token
29pub async fn exchange_code(
30    client_id: &str,
31    client_secret: &str,
32    redirect_uri: &str,
33    code: &str,
34) -> Result<TokenResponse, TokenExchangeError> {
35    let client = reqwest::Client::new();
36
37    let params = [
38        ("client_id", client_id),
39        ("client_secret", client_secret),
40        ("grant_type", "authorization_code"),
41        ("redirect_uri", redirect_uri),
42        ("code", code),
43    ];
44
45    let response = client
46        .post(TOKEN_URL)
47        .form(&params)
48        .send()
49        .await
50        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
51
52    if !response.status().is_success() {
53        let status = response.status().as_u16();
54        let body = response.text().await.unwrap_or_default();
55        return Err(TokenExchangeError::Http { status, body });
56    }
57
58    response
59        .json::<TokenResponse>()
60        .await
61        .map_err(|e| TokenExchangeError::Parse(e.to_string()))
62}
63
64/// Exchange a short-lived access token for a long-lived one (60 days)
65pub async fn exchange_for_long_lived_token(
66    client_secret: &str,
67    short_lived_token: &str,
68) -> Result<TokenResponse, TokenExchangeError> {
69    let client = reqwest::Client::new();
70
71    let url = format!(
72        "https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret={}&access_token={}",
73        client_secret, short_lived_token
74    );
75
76    let response = client
77        .get(&url)
78        .send()
79        .await
80        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
81
82    if !response.status().is_success() {
83        let status = response.status().as_u16();
84        let body = response.text().await.unwrap_or_default();
85        return Err(TokenExchangeError::Http { status, body });
86    }
87
88    response
89        .json::<TokenResponse>()
90        .await
91        .map_err(|e| TokenExchangeError::Parse(e.to_string()))
92}
93
94/// Refresh a long-lived access token (extends validity by another 60 days)
95pub async fn refresh_access_token(
96    long_lived_token: &str,
97) -> Result<TokenResponse, TokenExchangeError> {
98    let client = reqwest::Client::new();
99
100    let url = format!(
101        "https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token={}",
102        long_lived_token
103    );
104
105    let response = client
106        .get(&url)
107        .send()
108        .await
109        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
110
111    if !response.status().is_success() {
112        let status = response.status().as_u16();
113        let body = response.text().await.unwrap_or_default();
114        return Err(TokenExchangeError::Http { status, body });
115    }
116
117    response
118        .json::<TokenResponse>()
119        .await
120        .map_err(|e| TokenExchangeError::Parse(e.to_string()))
121}