Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

release:
name: Create Release
Expand Down
10 changes: 6 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ndl-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ description = "Shared library for ndl and ndld"
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
46 changes: 27 additions & 19 deletions ndl-core/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ pub const TOKEN_URL: &str = "https://graph.threads.net/oauth/access_token";
pub const OAUTH_SCOPES: &str =
"threads_basic,threads_read_replies,threads_manage_replies,threads_content_publish";

/// Deserialize user_id from either a string or number (Threads API returns both)
fn deserialize_user_id<'de, D>(deserializer: D) -> Result<u64, D::Error>
/// Deserialize user_id from either a string or number (Threads API returns both), or None if missing
fn deserialize_user_id_opt<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
Expand All @@ -17,18 +17,20 @@ where
Number(u64),
}

match StringOrNumber::deserialize(deserializer)? {
StringOrNumber::String(s) => s.parse().map_err(de::Error::custom),
StringOrNumber::Number(n) => Ok(n),
let opt: Option<StringOrNumber> = Option::deserialize(deserializer)?;
match opt {
Some(StringOrNumber::String(s)) => s.parse().map(Some).map_err(de::Error::custom),
Some(StringOrNumber::Number(n)) => Ok(Some(n)),
None => Ok(None),
}
}

#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
#[allow(dead_code)]
#[serde(deserialize_with = "deserialize_user_id")]
pub user_id: u64,
#[serde(default, deserialize_with = "deserialize_user_id_opt")]
pub user_id: Option<u64>,
/// Number of seconds until the token expires (3600 for short-lived, 5184000 for long-lived)
#[serde(default)]
pub expires_in: Option<u64>,
Expand All @@ -44,6 +46,21 @@ pub enum TokenExchangeError {
Parse(String),
}

/// Parse response body as TokenResponse, logging body at debug level on failure
async fn parse_token_response(
response: reqwest::Response,
) -> Result<TokenResponse, TokenExchangeError> {
let body = response
.text()
.await
.map_err(|e| TokenExchangeError::Parse(e.to_string()))?;

serde_json::from_str(&body).map_err(|e| {
tracing::debug!(response_body = %body, "Failed to parse token response");
TokenExchangeError::Parse(e.to_string())
})
}

/// Exchange an authorization code for an access token
pub async fn exchange_code(
client_id: &str,
Expand Down Expand Up @@ -74,10 +91,7 @@ pub async fn exchange_code(
return Err(TokenExchangeError::Http { status, body });
}

response
.json::<TokenResponse>()
.await
.map_err(|e| TokenExchangeError::Parse(e.to_string()))
parse_token_response(response).await
}

/// Exchange a short-lived access token for a long-lived one (60 days)
Expand All @@ -104,10 +118,7 @@ pub async fn exchange_for_long_lived_token(
return Err(TokenExchangeError::Http { status, body });
}

response
.json::<TokenResponse>()
.await
.map_err(|e| TokenExchangeError::Parse(e.to_string()))
parse_token_response(response).await
}

/// Refresh a long-lived access token (extends validity by another 60 days)
Expand All @@ -133,8 +144,5 @@ pub async fn refresh_access_token(
return Err(TokenExchangeError::Http { status, body });
}

response
.json::<TokenResponse>()
.await
.map_err(|e| TokenExchangeError::Parse(e.to_string()))
parse_token_response(response).await
}
2 changes: 1 addition & 1 deletion ndl/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ pub async fn hosted_login(auth_server: &str) -> Result<TokenResponse, OAuthError
// Return a TokenResponse for compatibility
return Ok(TokenResponse {
access_token,
user_id: 0, // Not provided by hosted auth
user_id: None,
expires_in: Some(60 * 24 * 60 * 60), // Assume 60 days for long-lived token
});
}
Expand Down