Skip to main content

ndl_core/
oauth.rs

1use serde::{Deserialize, Deserializer, de};
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/// Deserialize user_id from either a string or number (Threads API returns both), or None if missing
9fn deserialize_user_id_opt<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
10where
11    D: Deserializer<'de>,
12{
13    #[derive(Deserialize)]
14    #[serde(untagged)]
15    enum StringOrNumber {
16        String(String),
17        Number(u64),
18    }
19
20    let opt: Option<StringOrNumber> = Option::deserialize(deserializer)?;
21    match opt {
22        Some(StringOrNumber::String(s)) => s.parse().map(Some).map_err(de::Error::custom),
23        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
24        None => Ok(None),
25    }
26}
27
28#[derive(Debug, Deserialize)]
29pub struct TokenResponse {
30    pub access_token: String,
31    #[allow(dead_code)]
32    #[serde(default, deserialize_with = "deserialize_user_id_opt")]
33    pub user_id: Option<u64>,
34    /// Number of seconds until the token expires (3600 for short-lived, 5184000 for long-lived)
35    #[serde(default)]
36    pub expires_in: Option<u64>,
37}
38
39#[derive(Debug, Error)]
40pub enum TokenExchangeError {
41    #[error("Request failed: {0}")]
42    Request(String),
43    #[error("HTTP {status}: {body}")]
44    Http { status: u16, body: String },
45    #[error("Parse error: {0}")]
46    Parse(String),
47}
48
49/// Parse response body as TokenResponse, logging body at debug level on failure
50async fn parse_token_response(
51    response: reqwest::Response,
52) -> Result<TokenResponse, TokenExchangeError> {
53    let body = response
54        .text()
55        .await
56        .map_err(|e| TokenExchangeError::Parse(e.to_string()))?;
57
58    serde_json::from_str(&body).map_err(|e| {
59        tracing::debug!(response_body = %body, "Failed to parse token response");
60        TokenExchangeError::Parse(e.to_string())
61    })
62}
63
64/// Exchange an authorization code for an access token
65pub async fn exchange_code(
66    client_id: &str,
67    client_secret: &str,
68    redirect_uri: &str,
69    code: &str,
70) -> Result<TokenResponse, TokenExchangeError> {
71    let client = reqwest::Client::new();
72
73    let params = [
74        ("client_id", client_id),
75        ("client_secret", client_secret),
76        ("grant_type", "authorization_code"),
77        ("redirect_uri", redirect_uri),
78        ("code", code),
79    ];
80
81    let response = client
82        .post(TOKEN_URL)
83        .form(&params)
84        .send()
85        .await
86        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
87
88    if !response.status().is_success() {
89        let status = response.status().as_u16();
90        let body = response.text().await.unwrap_or_default();
91        return Err(TokenExchangeError::Http { status, body });
92    }
93
94    parse_token_response(response).await
95}
96
97/// Exchange a short-lived access token for a long-lived one (60 days)
98pub async fn exchange_for_long_lived_token(
99    client_secret: &str,
100    short_lived_token: &str,
101) -> Result<TokenResponse, TokenExchangeError> {
102    let client = reqwest::Client::new();
103
104    let url = format!(
105        "https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret={}&access_token={}",
106        client_secret, short_lived_token
107    );
108
109    let response = client
110        .get(&url)
111        .send()
112        .await
113        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
114
115    if !response.status().is_success() {
116        let status = response.status().as_u16();
117        let body = response.text().await.unwrap_or_default();
118        return Err(TokenExchangeError::Http { status, body });
119    }
120
121    parse_token_response(response).await
122}
123
124/// Refresh a long-lived access token (extends validity by another 60 days)
125pub async fn refresh_access_token(
126    long_lived_token: &str,
127) -> Result<TokenResponse, TokenExchangeError> {
128    let client = reqwest::Client::new();
129
130    let url = format!(
131        "https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token={}",
132        long_lived_token
133    );
134
135    let response = client
136        .get(&url)
137        .send()
138        .await
139        .map_err(|e| TokenExchangeError::Request(e.to_string()))?;
140
141    if !response.status().is_success() {
142        let status = response.status().as_u16();
143        let body = response.text().await.unwrap_or_default();
144        return Err(TokenExchangeError::Http { status, body });
145    }
146
147    parse_token_response(response).await
148}