From 3f1c4df898183a0cd3d8f71a12b11e728d4fb4d1 Mon Sep 17 00:00:00 2001 From: xuelongmu Date: Sun, 5 Apr 2026 18:22:07 +0000 Subject: [PATCH 1/3] Add OAuth2 user-context bookmark support --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 27 ++ legacy/lib/t/cli.rb | 18 ++ man/x.1 | 9 + src/manifest.rs | 3 + src/rcfile.rs | 46 ++++ src/runner.rs | 557 ++++++++++++++++++++++++++++++++++++++- tests/parity_fixtures.rs | 71 +++++ x-api/src/backend.rs | 407 +++++++++++++++++++++++++++- 10 files changed, 1123 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c137bfe..1677fea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2416,6 +2416,7 @@ dependencies = [ "predicates", "regex", "reqwest", + "ring", "serde", "serde_json", "serde_urlencoded", diff --git a/Cargo.toml b/Cargo.toml index 3a683bb3..abfe4bb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ thiserror = "2.0.18" base64 = "0.22.1" dirs = "6.0.0" reqwest = { version = "0.13.2", features = ["blocking"] } +ring = "0.17" x-api = { path = "x-api" } [dev-dependencies] diff --git a/README.md b/README.md index d3eadc25..a35fef09 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,39 @@ A command-line interface for the X API. - Command families: `cli`, `delete`, `list`, `search`, `set`, `stream` - Local account/profile commands: `accounts`, `set active`, `delete account`, `version`, `ruler` +- Bookmark commands: `bookmarks`, `bookmark `, `unbookmark ` - Stream commands use persistent HTTP streaming: - `stream all` and `stream matrix` use OAuth2 sample stream - `stream search`, `stream users`, `stream list`, and `stream timeline` use v2 filtered stream rules + stream - `X_STREAM_MAX_EVENTS` can be set to limit emitted events (useful for tests/automation) - Default profile config is `~/.xrc`. If `~/.xrc` is missing, `~/.trc` is used as a read fallback and migrated on write. +## Bookmark Auth + +X bookmarks require OAuth 2.0 user context with PKCE. The CLI now supports: + +```bash +x authorize --oauth2 +``` + +The OAuth 2.0 flow expects an app with OAuth 2.0 enabled, an exact callback URL match, and bookmark-capable scopes. By default the CLI requests: + +```text +tweet.read users.read bookmark.read bookmark.write offline.access +``` + +Environment variables can prefill the OAuth 2.0 prompts: + +```text +X_AUTHORIZE_OAUTH2_CLIENT_ID +X_AUTHORIZE_OAUTH2_CLIENT_SECRET +X_AUTHORIZE_OAUTH2_REDIRECT_URI +X_AUTHORIZE_OAUTH2_SCOPES +X_AUTHORIZE_OAUTH2_REDIRECTED_URL +``` + +`T_AUTHORIZE_OAUTH2_*` aliases are also accepted for compatibility with the existing authorize flow. + ## Development ```bash diff --git a/legacy/lib/t/cli.rb b/legacy/lib/t/cli.rb index 93789f8d..68bfddee 100644 --- a/legacy/lib/t/cli.rb +++ b/legacy/lib/t/cli.rb @@ -53,6 +53,7 @@ def accounts desc "authorize", "Allows an application to request user authorization" method_option "display-uri", aliases: "-d", type: :boolean, desc: "Display the authorization URL instead of attempting to open it." + method_option "oauth2", type: :boolean, desc: "Authorize an OAuth 2.0 user-context token via PKCE." def authorize @rcfile.path = options["profile"] if options["profile"] if @rcfile.empty? @@ -111,6 +112,20 @@ def authorize say "Authorization successful." end + desc "bookmark TWEET_ID [TWEET_ID...]", "Bookmark posts." + def bookmark(status_id, *status_ids); end + + desc "bookmarks", "Returns the most recent bookmarked posts." + method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." + method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." + method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." + method_option "number", aliases: "-n", type: :numeric, default: DEFAULT_NUM_RESULTS, desc: "Limit the number of results." + method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." + method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort." + method_option "max_id", aliases: "-m", type: :numeric, desc: "Returns only the results with an ID less than the specified ID." + method_option "since_id", aliases: "-s", type: :numeric, desc: "Returns only the results with an ID greater than the specified ID." + def bookmarks; end + desc "block USER [USER...]", "Block users." method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names." def block(user, *users) @@ -799,6 +814,9 @@ def unfollow(user, *users) say "Run `#{File.basename($PROGRAM_NAME)} follow #{unfollowed_users.collect { |unfollowed_user| "@#{unfollowed_user['screen_name']}" }.join(' ')}` to follow again." end + desc "unbookmark TWEET_ID [TWEET_ID...]", "Remove bookmarked posts." + def unbookmark(status_id, *status_ids); end + desc "update [MESSAGE]", "Post a Tweet." method_option "location", aliases: "-l", type: :string, default: nil, desc: "Add location information. If the optional 'latitude,longitude' parameter is not supplied, looks up location by IP address." method_option "file", aliases: "-f", type: :string, desc: "The path to an image to attach to your tweet." diff --git a/man/x.1 b/man/x.1 index 14fb991c..eeb8dcd8 100644 --- a/man/x.1 +++ b/man/x.1 @@ -37,6 +37,12 @@ List accounts. x\-authorize(1) Authorize an application via OAuth. .TP +x\-bookmark(1) +Bookmark posts. +.TP +x\-bookmarks(1) +Returns the most recent bookmarked posts. +.TP x\-block(1) Block users. .TP @@ -157,6 +163,9 @@ Returns the locations for which X has trending topic information. x\-unfollow(1) Unfollow users. .TP +x\-unbookmark(1) +Remove bookmarked posts. +.TP x\-update(1) Post to your timeline. .TP diff --git a/src/manifest.rs b/src/manifest.rs index 6f46efa5..0d2073d0 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -608,6 +608,8 @@ fn descriptions() -> HashMap<&'static str, HashMap<&'static str, &'static str>> let top = map.entry("").or_default(); top.insert("accounts", "List accounts."); top.insert("authorize", "Authorize an application via OAuth."); + top.insert("bookmark", "Bookmark posts."); + top.insert("bookmarks", "Returns the most recent bookmarked posts."); top.insert("block", "Block users."); top.insert("blocks", "Returns a list of blocked users."); top.insert( @@ -689,6 +691,7 @@ fn descriptions() -> HashMap<&'static str, HashMap<&'static str, &'static str>> "trend_locations", "Returns the locations for which X has trending topic information.", ); + top.insert("unbookmark", "Remove bookmarked posts."); top.insert("unfollow", "Unfollow users."); top.insert("update", "Post to your timeline."); top.insert("users", "Returns a list of users you specify."); diff --git a/src/rcfile.rs b/src/rcfile.rs index 45e75ad6..e24d1d37 100644 --- a/src/rcfile.rs +++ b/src/rcfile.rs @@ -406,6 +406,52 @@ mod tests { assert_eq!(loaded.profiles().len(), 2); } + #[test] + fn save_and_load_round_trip_oauth2_user_context() { + let tmp = tempfile::tempdir().expect("tempdir works"); + let path = tmp.path().join(".xrc"); + let mut rcfile = RcFile::default(); + + rcfile.upsert_profile_credentials( + "erik", + "client-id", + Credentials { + username: "erik".to_string(), + consumer_key: "client-id".to_string(), + oauth2_user: Some(x_api::backend::OAuth2UserContext { + client_id: "client-id".to_string(), + client_secret: Some("secret".to_string()), + access_token: "access-token".to_string(), + refresh_token: Some("refresh-token".to_string()), + expires_at: Some(1_700_000_000), + scopes: vec![ + "tweet.read".to_string(), + "users.read".to_string(), + "bookmark.read".to_string(), + "bookmark.write".to_string(), + ], + }), + ..Credentials::default() + }, + ); + rcfile + .set_active("erik", Some("client-id")) + .expect("set active works"); + + rcfile.save(&path).expect("save should work"); + let loaded = RcFile::load(&path).expect("load should work"); + let loaded_credentials = loaded.active_credentials().expect("active credentials"); + + let oauth2 = loaded_credentials + .oauth2_user + .as_ref() + .expect("oauth2 context should round-trip"); + assert_eq!(oauth2.client_id, "client-id"); + assert_eq!(oauth2.refresh_token.as_deref(), Some("refresh-token")); + assert_eq!(oauth2.expires_at, Some(1_700_000_000)); + assert!(oauth2.scopes.iter().any(|scope| scope == "bookmark.write")); + } + #[test] fn default_profile_path_is_xrc() { let path = default_profile_path(); diff --git a/src/runner.rs b/src/runner.rs index 40872c95..0f7653fb 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3,6 +3,9 @@ use crate::rcfile::{Credentials, RcFile, RcFileError, default_profile_path}; use base64::Engine; use chrono::{DateTime, Duration, Local, TimeZone, Utc}; use clap::{ArgMatches, Command as ClapCommand}; +use reqwest::Url; +use ring::digest::{SHA256, digest}; +use ring::rand::{SecureRandom, SystemRandom}; use serde_json::Value; use std::collections::{BTreeSet, HashMap}; use std::ffi::OsString; @@ -12,9 +15,11 @@ use std::path::PathBuf; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use x_api::backend::{ - AuthScheme, Backend, BackendError, TwitterBackend, get_json_oauth2_with_retry, - get_json_with_retry, post_json_body_oauth2_with_retry as post_json_oauth2_with_retry, - post_json_with_retry, + AuthScheme, Backend, BackendError, OAuth2UserContext, TwitterBackend, + delete_json_oauth2_user_with_retry, format_api_error, get_json_oauth2_user_with_retry, + get_json_oauth2_with_retry, get_json_with_retry, + post_json_body_oauth2_user_with_retry as post_json_oauth2_user_with_retry, + post_json_body_oauth2_with_retry as post_json_oauth2_with_retry, post_json_with_retry, }; use x_api::oauth1::{self, ParamList, Token}; @@ -30,6 +35,14 @@ const V2_TWEET_EXPANSIONS: &str = "author_id,geo.place_id"; const V2_USER_EXPANSIONS: &str = "pinned_tweet_id"; const V2_PLACE_FIELDS: &str = "contained_within,country,country_code,full_name,geo,id,name,place_type"; +const DEFAULT_OAUTH2_REDIRECT_URI: &str = "http://127.0.0.1:8080/callback"; +const OAUTH2_DEFAULT_SCOPES: [&str; 5] = [ + "tweet.read", + "users.read", + "bookmark.read", + "bookmark.write", + "offline.access", +]; const TWEET_HEADINGS: [&str; 4] = ["ID", "Posted at", "Screen name", "Text"]; const DIRECT_MESSAGE_HEADINGS: [&str; 4] = ["ID", "Posted at", "Screen name", "Text"]; const LIST_HEADINGS: [&str; 8] = [ @@ -183,13 +196,27 @@ fn execute_remote_with_profile_backend( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let rcfile = RcFile::load(&context.profile_path)?; + let mut rcfile = RcFile::load(&context.profile_path)?; + let active_profile = rcfile + .active_profile() + .map(|(username, key)| (username.to_string(), key.to_string())); let Some(credentials) = rcfile.active_credentials().cloned() else { return Err(CommandError::Backend(BackendError::MissingCredentials)); }; + let original_credentials = credentials.clone(); let mut backend = TwitterBackend::from_credentials(credentials)?; - execute_remote_command(path, leaf, args, context, &mut backend, out, err) + let result = execute_remote_command(path, leaf, args, context, &mut backend, out, err); + + if let Ok(_) = result + && backend.credentials() != &original_credentials + && let Some((username, key)) = active_profile + { + rcfile.upsert_profile_credentials(&username, &key, backend.credentials().clone()); + rcfile.save(&context.profile_path)?; + } + + result } struct CommandContext { @@ -379,10 +406,50 @@ fn execute_remote_command( .active_profile() .map(|(name, _)| name.to_string()) .unwrap_or_else(|| "unknown".to_string()); + let active_credentials = rcfile.active_credentials().cloned(); match path { [single] if single == "authorize" => { - run_authorize(leaf, &context.profile_path, out, err)?; + run_authorize( + leaf, + &context.profile_path, + active_credentials.as_ref(), + out, + err, + )?; + Ok(0) + } + [single] if single == "bookmark" => { + ensure_min_args(path, args, 1)?; + let me_id = authenticated_user_id_for_bookmarks(backend)?; + let ids = resolve_id_list(args); + for id in &ids { + let _ = post_json_oauth2_user_with_retry( + backend, + &format!("/2/users/{me_id}/bookmarks"), + serde_json::json!({ "tweet_id": id }), + )?; + } + writeln!( + out, + "@{} bookmarked {}.", + active_name, + pluralize(ids.len(), "post", None) + ) + .ok(); + Ok(0) + } + [single] if single == "bookmarks" => { + let me_id = authenticated_user_id_for_bookmarks(backend)?; + let number = opt_usize(leaf, "number").unwrap_or(DEFAULT_NUM_RESULTS); + let tweets = collect_tweets_paginated( + backend, + &format!("/2/users/{me_id}/bookmarks"), + timeline_v2_params(leaf), + AuthScheme::OAuth2User, + number, + )?; + print_tweets(&tweets, leaf, out, &context.color); Ok(0) } [single] if single == "open" => { @@ -497,6 +564,26 @@ fn execute_remote_command( .ok(); Ok(0) } + [single] if single == "unbookmark" => { + ensure_min_args(path, args, 1)?; + let me_id = authenticated_user_id_for_bookmarks(backend)?; + let ids = resolve_id_list(args); + for id in &ids { + let _ = delete_json_oauth2_user_with_retry( + backend, + &format!("/2/users/{me_id}/bookmarks/{id}"), + Vec::new(), + )?; + } + writeln!( + out, + "@{} removed {}.", + active_name, + pluralize(ids.len(), "bookmark", None) + ) + .ok(); + Ok(0) + } [single] if single == "report_spam" => { ensure_min_args(path, args, 1)?; let users = resolve_user_list(args, opt_bool(leaf, "id")); @@ -783,7 +870,7 @@ fn execute_remote_command( Ok(0) } [single] if single == "mentions" => { - let me = fetch_current_user(backend)?; + let me = fetch_current_user_with_credentials(backend, active_credentials.as_ref())?; let me_id = value_id(&me).unwrap_or_default(); let number = opt_usize(leaf, "number").unwrap_or(DEFAULT_NUM_RESULTS); let tweets = collect_tweets_paginated( @@ -848,7 +935,7 @@ fn execute_remote_command( number, )? } else { - let me = fetch_current_user(backend)?; + let me = fetch_current_user_with_credentials(backend, active_credentials.as_ref())?; let me_id = value_id(&me).unwrap_or_default(); collect_tweets_paginated( backend, @@ -936,7 +1023,8 @@ fn execute_remote_command( } [single] if single == "whoami" => { if let Some((_username, _)) = rcfile.active_profile() { - let user = fetch_current_user(backend)?; + let user = + fetch_current_user_with_credentials(backend, active_credentials.as_ref())?; print_whois(&user, leaf, out); } else { writeln!( @@ -1668,7 +1756,7 @@ fn execute_remote_command( MAX_SEARCH_RESULTS * MAX_PAGE, )? } else { - let me = fetch_current_user(backend)?; + let me = fetch_current_user_with_credentials(backend, active_credentials.as_ref())?; let me_id = value_id(&me).unwrap_or_default(); collect_tweets_paginated( backend, @@ -1684,7 +1772,7 @@ fn execute_remote_command( } [first, second] if first == "search" && second == "mentions" => { ensure_min_args(path, args, 1)?; - let me = fetch_current_user(backend)?; + let me = fetch_current_user_with_credentials(backend, active_credentials.as_ref())?; let me_id = value_id(&me).unwrap_or_default(); let tweets = collect_tweets_paginated( backend, @@ -2869,9 +2957,20 @@ fn print_message(out: &mut dyn Write, from_user: &str, message: &str) { fn run_authorize( leaf: &ArgMatches, profile_path: &std::path::Path, + existing_credentials: Option<&Credentials>, out: &mut dyn Write, err: &mut dyn Write, ) -> Result<(), CommandError> { + if opt_bool(leaf, "oauth2") { + return run_authorize_oauth2( + profile_path, + existing_credentials, + opt_bool(leaf, "display-uri"), + out, + err, + ); + } + let mut rcfile = RcFile::load(profile_path)?; let display_uri = opt_bool(leaf, "display-uri"); @@ -2961,6 +3060,7 @@ fn run_authorize( token: access_token, secret: access_secret, bearer_token: None, + oauth2_user: None, }; rcfile.upsert_profile_credentials(&screen_name, &key, credentials); let _ = rcfile.set_active(&screen_name, Some(&key))?; @@ -3086,6 +3186,398 @@ fn oauth_verify_screen_name( .map(ToString::to_string) } +fn run_authorize_oauth2( + profile_path: &std::path::Path, + existing_credentials: Option<&Credentials>, + display_uri: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result<(), CommandError> { + let mut rcfile = RcFile::load(profile_path)?; + + writeln!( + out, + "OAuth 2.0 user-context authorization is required for bookmarks." + )?; + writeln!( + out, + "Configure your X app with OAuth 2.0 enabled and an exact callback URL match." + )?; + writeln!( + out, + "Recommended scopes: {}", + OAUTH2_DEFAULT_SCOPES.join(" ") + )?; + writeln!(out)?; + prompt(out, "Press [Enter] to open the X Developer site.")?; + writeln!(out)?; + open_or_print( + "https://developer.twitter.com/en/portal/projects-and-apps", + display_uri, + out, + err, + ); + + let client_id = env_first(&[ + "X_AUTHORIZE_OAUTH2_CLIENT_ID", + "T_AUTHORIZE_OAUTH2_CLIENT_ID", + ]) + .unwrap_or(prompt(out, "Enter your OAuth2 client ID:")?); + let client_secret = env_first(&[ + "X_AUTHORIZE_OAUTH2_CLIENT_SECRET", + "T_AUTHORIZE_OAUTH2_CLIENT_SECRET", + ]) + .or({ + let entered = prompt( + out, + "Enter your OAuth2 client secret (press Enter for public clients):", + )?; + if entered.trim().is_empty() { + None + } else { + Some(entered) + } + }); + let redirect_uri = env_first(&[ + "X_AUTHORIZE_OAUTH2_REDIRECT_URI", + "T_AUTHORIZE_OAUTH2_REDIRECT_URI", + ]) + .unwrap_or(prompt_with_default( + out, + "Enter your OAuth2 redirect URI:", + DEFAULT_OAUTH2_REDIRECT_URI, + )?); + let scopes = oauth2_scopes(); + + let state = random_urlsafe_token(24)?; + let code_verifier = random_urlsafe_token(48)?; + let authorize_uri = + build_oauth2_authorize_uri(&client_id, &redirect_uri, &scopes, &state, &code_verifier)?; + + writeln!(out)?; + writeln!( + out, + "Open the authorization page, sign in, and approve the app." + )?; + writeln!( + out, + "After X redirects to your callback URL, copy the full URL from the browser and paste it below." + )?; + writeln!(out)?; + prompt( + out, + "Press [Enter] to open the OAuth 2.0 authorization page.", + )?; + writeln!(out)?; + open_or_print(&authorize_uri, display_uri, out, err); + + let redirected_input = env_first(&[ + "X_AUTHORIZE_OAUTH2_REDIRECTED_URL", + "T_AUTHORIZE_OAUTH2_REDIRECTED_URL", + ]) + .unwrap_or(prompt( + out, + "Paste the full redirected URL, or just the code value:", + )?); + let (code, returned_state) = parse_oauth2_redirect_input(&redirected_input)?; + if let Some(returned_state) = returned_state + && returned_state != state + { + return Err(CommandError::Other( + "OAuth2 state mismatch; authorization response could not be verified".to_string(), + )); + } + + let oauth2_user = oauth2_exchange_authorization_code( + &client_id, + client_secret.as_deref(), + &redirect_uri, + &code, + &code_verifier, + &scopes, + )?; + let screen_name = oauth2_fetch_screen_name(&oauth2_user.access_token)?; + + let storage_key = existing_credentials + .filter(|credentials| credentials.username.eq_ignore_ascii_case(&screen_name)) + .map(|credentials| credentials.consumer_key.clone()) + .filter(|key| !key.trim().is_empty()) + .unwrap_or_else(|| client_id.clone()); + + let mut credentials = existing_credentials + .filter(|credentials| credentials.username.eq_ignore_ascii_case(&screen_name)) + .cloned() + .unwrap_or_else(|| Credentials { + username: screen_name.clone(), + consumer_key: storage_key.clone(), + consumer_secret: String::new(), + token: String::new(), + secret: String::new(), + bearer_token: None, + oauth2_user: None, + }); + credentials.username = screen_name.clone(); + if credentials.consumer_key.trim().is_empty() { + credentials.consumer_key = storage_key.clone(); + } + credentials.oauth2_user = Some(oauth2_user); + + rcfile.upsert_profile_credentials(&screen_name, &storage_key, credentials); + let _ = rcfile.set_active(&screen_name, Some(&storage_key))?; + rcfile.save(profile_path)?; + + writeln!(out, "OAuth2 authorization successful.")?; + Ok(()) +} + +fn env_first(names: &[&str]) -> Option { + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn prompt_with_default( + out: &mut dyn Write, + label: &str, + default: &str, +) -> Result { + let value = prompt(out, &format!("{label} [{default}]"))?; + if value.trim().is_empty() { + Ok(default.to_string()) + } else { + Ok(value) + } +} + +fn oauth2_scopes() -> Vec { + let mut scopes = env_first(&["X_AUTHORIZE_OAUTH2_SCOPES", "T_AUTHORIZE_OAUTH2_SCOPES"]) + .map(|value| { + value + .split(|ch: char| ch.is_whitespace() || ch == ',') + .filter(|scope| !scope.is_empty()) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_else(|| { + OAUTH2_DEFAULT_SCOPES + .iter() + .map(|scope| (*scope).to_string()) + .collect() + }); + + for required in [ + "tweet.read", + "users.read", + "bookmark.read", + "bookmark.write", + ] { + ensure_scope(&mut scopes, required); + } + + scopes +} + +fn ensure_scope(scopes: &mut Vec, scope: &str) { + if !scopes.iter().any(|candidate| candidate == scope) { + scopes.push(scope.to_string()); + } +} + +fn random_urlsafe_token(len: usize) -> Result { + let rng = SystemRandom::new(); + let mut bytes = vec![0u8; len]; + rng.fill(&mut bytes) + .map_err(|_| CommandError::Other("Failed to generate secure random bytes".to_string()))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +fn pkce_s256_challenge(code_verifier: &str) -> String { + let hash = digest(&SHA256, code_verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash.as_ref()) +} + +fn build_oauth2_authorize_uri( + client_id: &str, + redirect_uri: &str, + scopes: &[String], + state: &str, + code_verifier: &str, +) -> Result { + let mut url = Url::parse("https://x.com/i/oauth2/authorize") + .map_err(|error| CommandError::Other(error.to_string()))?; + let code_challenge = pkce_s256_challenge(code_verifier); + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", &scopes.join(" ")) + .append_pair("state", state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url.to_string()) +} + +fn parse_oauth2_redirect_input(input: &str) -> Result<(String, Option), CommandError> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(CommandError::Other( + "OAuth2 authorization response was empty".to_string(), + )); + } + + if let Ok(url) = Url::parse(trimmed) { + let query = url.query_pairs().collect::>(); + if let Some(error) = query.get("error") { + let description = query + .get("error_description") + .map(|value| format!(": {value}")) + .unwrap_or_default(); + return Err(CommandError::Other(format!( + "OAuth2 authorization failed with {error}{description}" + ))); + } + + let code = query + .get("code") + .map(|value| value.to_string()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + CommandError::Other( + "OAuth2 redirect URL did not contain a code parameter".to_string(), + ) + })?; + let state = query.get("state").map(|value| value.to_string()); + return Ok((code, state)); + } + + Ok((trimmed.to_string(), None)) +} + +fn oauth2_exchange_authorization_code( + client_id: &str, + client_secret: Option<&str>, + redirect_uri: &str, + code: &str, + code_verifier: &str, + requested_scopes: &[String], +) -> Result { + let client = reqwest::blocking::Client::builder() + .user_agent("x-rust/5.0") + .build() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + + let mut request = client + .post("https://api.x.com/2/oauth2/token") + .header("Content-Type", "application/x-www-form-urlencoded"); + let mut form = vec![ + ("code".to_string(), code.to_string()), + ("grant_type".to_string(), "authorization_code".to_string()), + ("redirect_uri".to_string(), redirect_uri.to_string()), + ("code_verifier".to_string(), code_verifier.to_string()), + ]; + + match client_secret.filter(|value| !value.trim().is_empty()) { + Some(client_secret) => { + let basic = base64::engine::general_purpose::STANDARD + .encode(format!("{client_id}:{client_secret}").as_bytes()); + request = request.header("Authorization", format!("Basic {basic}")); + } + None => form.push(("client_id".to_string(), client_id.to_string())), + } + + let response = request + .form(&form) + .send() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + let status = response.status(); + let body = response + .text() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + if !status.is_success() { + return Err(CommandError::Backend(BackendError::Http(format_api_error( + status, &body, + )))); + } + + let payload: Value = serde_json::from_str(&body).map_err(BackendError::from)?; + let access_token = payload + .get("access_token") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + CommandError::Other("OAuth2 token response did not include access_token".to_string()) + })? + .to_string(); + let refresh_token = payload + .get("refresh_token") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let expires_at = payload + .get("expires_in") + .and_then(Value::as_i64) + .map(|seconds| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64 + seconds) + .unwrap_or(seconds) + }); + let scopes = payload + .get("scope") + .and_then(Value::as_str) + .map(|value| { + value + .split_whitespace() + .filter(|scope| !scope.is_empty()) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or_else(|| requested_scopes.to_vec()); + + Ok(OAuth2UserContext { + client_id: client_id.to_string(), + client_secret: client_secret.map(ToString::to_string), + access_token, + refresh_token, + expires_at, + scopes, + }) +} + +fn oauth2_fetch_screen_name(access_token: &str) -> Result { + let client = reqwest::blocking::Client::builder() + .user_agent("x-rust/5.0") + .build() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + let response = client + .get("https://api.x.com/2/users/me?user.fields=username") + .bearer_auth(access_token) + .send() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + let status = response.status(); + let body = response + .text() + .map_err(|error| CommandError::Backend(BackendError::Http(error.to_string())))?; + if !status.is_success() { + return Err(CommandError::Backend(BackendError::Http(format_api_error( + status, &body, + )))); + } + + let payload: Value = serde_json::from_str(&body).map_err(BackendError::from)?; + payload + .get("data") + .and_then(|data| data.get("username")) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| { + CommandError::Other("OAuth2 /2/users/me response did not include username".to_string()) + }) +} + fn prompt(out: &mut dyn Write, label: &str) -> Result { write!(out, "{} ", label)?; out.flush()?; @@ -3392,6 +3884,9 @@ fn collect_tweets_paginated( let response = match auth { AuthScheme::OAuth1User => get_json_with_retry(backend, path, params.clone())?, AuthScheme::OAuth2Bearer => get_json_oauth2_with_retry(backend, path, params.clone())?, + AuthScheme::OAuth2User => { + get_json_oauth2_user_with_retry(backend, path, params.clone())? + } }; tweets.extend(extract_tweets(&response)); if tweets.len() >= limit { @@ -3756,6 +4251,33 @@ fn fetch_user(backend: &mut dyn Backend, user: &str, by_id: bool) -> Result Result { + fetch_current_user_with_credentials(backend, None) +} + +fn fetch_current_user_with_credentials( + backend: &mut dyn Backend, + credentials: Option<&Credentials>, +) -> Result { + if credentials + .and_then(|credentials| credentials.oauth2_user.as_ref()) + .is_some() + { + let response = get_json_oauth2_user_with_retry( + backend, + "/2/users/me", + vec![ + ("user.fields".to_string(), V2_USER_FIELDS.to_string()), + ("expansions".to_string(), V2_USER_EXPANSIONS.to_string()), + ("tweet.fields".to_string(), V2_TWEET_FIELDS.to_string()), + ], + )?; + + return Ok(extract_users(&response) + .into_iter() + .next() + .unwrap_or(Value::Null)); + } + let v2_result = get_json_with_retry( backend, "/2/users/me", @@ -3784,6 +4306,19 @@ fn authenticated_user_id(backend: &mut dyn Backend) -> Result Result { + let response = get_json_oauth2_user_with_retry( + backend, + "/2/users/me", + vec![("user.fields".to_string(), "id,username".to_string())], + )?; + Ok(response + .get("data") + .and_then(|data| data.get("id")) + .and_then(value_to_string) + .unwrap_or_default()) +} + fn resolve_user_id( backend: &mut dyn Backend, user: &str, diff --git a/tests/parity_fixtures.rs b/tests/parity_fixtures.rs index f7fbcf2d..d2df8e8b 100644 --- a/tests/parity_fixtures.rs +++ b/tests/parity_fixtures.rs @@ -116,6 +116,77 @@ fn search_all_prefers_oauth2_backend() { ); } +#[test] +fn bookmarks_use_oauth2_user_context() { + let mut backend = MockBackend::new(); + backend.enqueue_json_response("GET_OAUTH2_USER", "/2/users/me", me_fixture()); + backend.enqueue_json_response( + "GET_OAUTH2_USER", + "/2/users/7505382/bookmarks", + serde_json::json!({ + "data": [{ + "id": "1", + "text": "saved post", + "created_at": "2011-04-06T19:13:37.000Z", + "author_id": "42" + }], + "includes": { + "users": [{ + "id": "42", + "username": "alice" + }] + } + }), + ); + + let (code, out, err) = run_cmd_with_profile(&["bookmarks", "--csv"], &mut backend); + + assert_success(code, &err); + assert!(out.contains("ID,Posted at,Screen name,Text")); + assert!(out.contains(",alice,saved post")); + assert!(backend.calls().iter().any(|call| { + call.method == "GET_OAUTH2_USER" && call.path == "/2/users/7505382/bookmarks" + })); +} + +#[test] +fn bookmark_uses_oauth2_user_context() { + let mut backend = MockBackend::new(); + backend.enqueue_json_response("GET_OAUTH2_USER", "/2/users/me", me_fixture()); + backend.enqueue_json_response( + "POST_JSON_OAUTH2_USER", + "/2/users/7505382/bookmarks", + serde_json::json!({ "data": { "bookmarked": true } }), + ); + + let (code, out, err) = run_cmd_with_profile(&["bookmark", "123"], &mut backend); + + assert_success(code, &err); + assert!(out.contains("bookmarked 1 post")); + assert!(backend.calls().iter().any(|call| { + call.method == "POST_JSON_OAUTH2_USER" && call.path == "/2/users/7505382/bookmarks" + })); +} + +#[test] +fn unbookmark_uses_oauth2_user_context() { + let mut backend = MockBackend::new(); + backend.enqueue_json_response("GET_OAUTH2_USER", "/2/users/me", me_fixture()); + backend.enqueue_json_response( + "DELETE_OAUTH2_USER", + "/2/users/7505382/bookmarks/123", + serde_json::json!({ "data": { "bookmarked": false } }), + ); + + let (code, out, err) = run_cmd_with_profile(&["unbookmark", "123"], &mut backend); + + assert_success(code, &err); + assert!(out.contains("removed 1 bookmark")); + assert!(backend.calls().iter().any(|call| { + call.method == "DELETE_OAUTH2_USER" && call.path == "/2/users/7505382/bookmarks/123" + })); +} + #[test] fn users_csv_matches_legacy_rows() { let mut backend = MockBackend::new(); diff --git a/x-api/src/backend.rs b/x-api/src/backend.rs index 57546bbb..c868e1c3 100644 --- a/x-api/src/backend.rs +++ b/x-api/src/backend.rs @@ -74,6 +74,8 @@ pub enum AuthScheme { OAuth1User, /// OAuth 2 bearer token authentication. OAuth2Bearer, + /// OAuth 2.0 user-context bearer authentication. + OAuth2User, } #[derive(Debug, thiserror::Error)] @@ -82,6 +84,9 @@ pub enum BackendError { /// No active credentials were available in profile configuration. #[error("No active credentials found in profile")] MissingCredentials, + /// No OAuth 2.0 user-context credentials were available. + #[error("No OAuth2 user-context credentials found in profile")] + MissingOAuth2UserContext, /// Network, OAuth, or HTTP status failure. /// /// For API errors, the message is formatted as `"NNN: human-readable message"` where NNN @@ -102,7 +107,30 @@ pub enum BackendError { }, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +/// OAuth 2.0 user-context credentials loaded from rc profile data. +pub struct OAuth2UserContext { + /// OAuth 2.0 client identifier from the X developer console. + #[serde(default)] + pub client_id: String, + /// Optional OAuth 2.0 client secret for confidential clients. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + /// OAuth 2.0 user access token. + #[serde(default)] + pub access_token: String, + /// Optional OAuth 2.0 refresh token. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + /// Absolute UNIX timestamp at which the access token expires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// Granted OAuth 2.0 scopes. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] /// API credentials loaded from rc profile data. pub struct Credentials { /// Account username or screen name. @@ -123,6 +151,9 @@ pub struct Credentials { /// Optional OAuth2 bearer token override. #[serde(default)] pub bearer_token: Option, + /// Optional OAuth 2.0 user-context credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth2_user: Option, } /// Default number of attempts used by retry helper functions. @@ -195,6 +226,17 @@ pub fn get_json_oauth2_with_retry( }) } +/// Calls [`Backend::get_json_oauth2_user`] with [`DEFAULT_RETRY_TRIES`] attempts. +pub fn get_json_oauth2_user_with_retry( + backend: &mut dyn Backend, + path: &str, + params: Vec<(String, String)>, +) -> Result { + retry_with(DEFAULT_RETRY_TRIES, || { + backend.get_json_oauth2_user(path, params.clone()) + }) +} + /// Calls [`Backend::post_json_body_oauth2`] with [`DEFAULT_RETRY_TRIES`] attempts. pub fn post_json_body_oauth2_with_retry( backend: &mut dyn Backend, @@ -206,6 +248,28 @@ pub fn post_json_body_oauth2_with_retry( }) } +/// Calls [`Backend::post_json_body_oauth2_user`] with [`DEFAULT_RETRY_TRIES`] attempts. +pub fn post_json_body_oauth2_user_with_retry( + backend: &mut dyn Backend, + path: &str, + body: Value, +) -> Result { + retry_with(DEFAULT_RETRY_TRIES, || { + backend.post_json_body_oauth2_user(path, body.clone()) + }) +} + +/// Calls [`Backend::delete_json_oauth2_user`] with [`DEFAULT_RETRY_TRIES`] attempts. +pub fn delete_json_oauth2_user_with_retry( + backend: &mut dyn Backend, + path: &str, + params: Vec<(String, String)>, +) -> Result { + retry_with(DEFAULT_RETRY_TRIES, || { + backend.delete_json_oauth2_user(path, params.clone()) + }) +} + /// Transport abstraction used by the `x` CLI command runner. pub trait Backend { /// Executes an OAuth1 GET request and returns parsed JSON. @@ -228,6 +292,15 @@ pub trait Backend { /// Executes an OAuth2 POST request with a JSON body and returns parsed JSON. fn post_json_body_oauth2(&mut self, path: &str, body: Value) -> Result; + /// Executes an OAuth2 user-context POST request with a JSON body and returns parsed JSON. + fn post_json_body_oauth2_user( + &mut self, + _path: &str, + _body: Value, + ) -> Result { + Err(BackendError::MissingOAuth2UserContext) + } + /// Executes an OAuth1 DELETE request and returns parsed JSON. fn delete_json( &mut self, @@ -235,6 +308,15 @@ pub trait Backend { params: Vec<(String, String)>, ) -> Result; + /// Executes an OAuth2 user-context DELETE request and returns parsed JSON. + fn delete_json_oauth2_user( + &mut self, + _path: &str, + _params: Vec<(String, String)>, + ) -> Result { + Err(BackendError::MissingOAuth2UserContext) + } + /// Executes an OAuth2 GET request and returns parsed JSON. fn get_json_oauth2( &mut self, @@ -242,6 +324,15 @@ pub trait Backend { params: Vec<(String, String)>, ) -> Result; + /// Executes an OAuth2 user-context GET request and returns parsed JSON. + fn get_json_oauth2_user( + &mut self, + _path: &str, + _params: Vec<(String, String)>, + ) -> Result { + Err(BackendError::MissingOAuth2UserContext) + } + /// Opens a streaming endpoint and emits each decoded JSON line to `on_event`. /// /// Returning `false` from `on_event` stops the stream. @@ -300,6 +391,11 @@ impl TwitterBackend { }) } + /// Returns the current credentials, including any refreshed OAuth2 user token state. + pub fn credentials(&self) -> &Credentials { + &self.credentials + } + fn request_oauth1_signed( &mut self, method: &str, @@ -384,6 +480,27 @@ impl TwitterBackend { params: Vec<(String, String)>, ) -> Result { let token = self.ensure_bearer_token()?.to_string(); + self.request_bearer(method, "OAUTH2", path, params, &token) + } + + fn request_oauth2_user( + &mut self, + method: &str, + path: &str, + params: Vec<(String, String)>, + ) -> Result { + let token = self.ensure_oauth2_user_token()?.to_string(); + self.request_bearer(method, "OAUTH2_USER", path, params, &token) + } + + fn request_bearer( + &mut self, + method: &str, + label: &str, + path: &str, + params: Vec<(String, String)>, + token: &str, + ) -> Result { let url = self.absolute_url(path); let response = match method { @@ -401,17 +518,35 @@ impl TwitterBackend { }; self.client .get(request_url) - .bearer_auth(&token) + .bearer_auth(token) .send() .map_err(|error| BackendError::Http(error.to_string()))? } "POST" => self .client .post(url) - .bearer_auth(&token) + .bearer_auth(token) .form(¶ms) .send() .map_err(|error| BackendError::Http(error.to_string()))?, + "DELETE" => { + let query = if params.is_empty() { + String::new() + } else { + serde_urlencoded::to_string(¶ms) + .map_err(|error| BackendError::Http(error.to_string()))? + }; + let request_url = if query.is_empty() { + url + } else { + format!("{url}?{query}") + }; + self.client + .delete(request_url) + .bearer_auth(token) + .send() + .map_err(|error| BackendError::Http(error.to_string()))? + } _ => { return Err(BackendError::Http(format!( "Unsupported HTTP method: {method}" @@ -420,7 +555,7 @@ impl TwitterBackend { }; self.calls.push(CallRecord { - method: format!("{method}_OAUTH2"), + method: format!("{method}_{label}"), path: path.to_string(), params, }); @@ -430,19 +565,38 @@ impl TwitterBackend { fn request_oauth2_json(&mut self, path: &str, body: Value) -> Result { let token = self.ensure_bearer_token()?.to_string(); + self.request_oauth2_json_with_token(path, body, &token, "POST_JSON_OAUTH2") + } + + fn request_oauth2_user_json( + &mut self, + path: &str, + body: Value, + ) -> Result { + let token = self.ensure_oauth2_user_token()?.to_string(); + self.request_oauth2_json_with_token(path, body, &token, "POST_JSON_OAUTH2_USER") + } + + fn request_oauth2_json_with_token( + &mut self, + path: &str, + body: Value, + token: &str, + method_label: &str, + ) -> Result { let url = self.absolute_url(path); let response = self .client .post(url) - .bearer_auth(&token) + .bearer_auth(token) .header("Accept", "application/json") .json(&body) .send() .map_err(|error| BackendError::Http(error.to_string()))?; self.calls.push(CallRecord { - method: "POST_JSON_OAUTH2".to_string(), + method: method_label.to_string(), path: path.to_string(), params: vec![("json".to_string(), body.to_string())], }); @@ -492,6 +646,132 @@ impl TwitterBackend { Ok(token) } + fn ensure_oauth2_user_token(&mut self) -> Result { + let Some(context) = self.credentials.oauth2_user.as_ref() else { + return Err(BackendError::MissingOAuth2UserContext); + }; + + let access_token = context.access_token.trim(); + let expires_at = context.expires_at.unwrap_or(i64::MAX); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + if !access_token.is_empty() && expires_at > now + 60 { + return Ok(access_token.to_string()); + } + + if !access_token.is_empty() && context.expires_at.is_none() { + return Ok(access_token.to_string()); + } + + self.refresh_oauth2_user_token() + } + + fn refresh_oauth2_user_token(&mut self) -> Result { + let Some(context) = self.credentials.oauth2_user.clone() else { + return Err(BackendError::MissingOAuth2UserContext); + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + + let refresh_token = context + .refresh_token + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + BackendError::Http( + "OAuth2 user access token expired and no refresh token is configured. Re-run `x authorize --oauth2` with `offline.access`.".to_string(), + ) + })?; + + let client_id = context.client_id.trim(); + if client_id.is_empty() { + return Err(BackendError::Http( + "OAuth2 user credentials are missing client_id".to_string(), + )); + } + + let mut request = self + .client + .post("https://api.x.com/2/oauth2/token") + .header("Content-Type", "application/x-www-form-urlencoded"); + + let mut form = vec![ + ("refresh_token".to_string(), refresh_token.to_string()), + ("grant_type".to_string(), "refresh_token".to_string()), + ]; + + match context + .client_secret + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + Some(client_secret) => { + let basic = base64::engine::general_purpose::STANDARD + .encode(format!("{client_id}:{client_secret}").as_bytes()); + request = request.header("Authorization", format!("Basic {basic}")); + } + None => form.push(("client_id".to_string(), client_id.to_string())), + } + + let response = request + .form(&form) + .send() + .map_err(|error| BackendError::Http(error.to_string()))?; + + let payload_text = Self::check_response(response)?; + let payload: Value = serde_json::from_str(&payload_text)?; + + let access_token = payload + .get("access_token") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + BackendError::Http( + "OAuth2 refresh response did not include access_token".to_string(), + ) + })? + .to_string(); + + let refresh_token = payload + .get("refresh_token") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string) + .or(context.refresh_token); + + let expires_at = payload + .get("expires_in") + .and_then(Value::as_i64) + .map(|seconds| now + seconds); + + let scopes = payload + .get("scope") + .and_then(Value::as_str) + .map(|value| { + value.split_whitespace() + .filter(|scope| !scope.is_empty()) + .map(ToString::to_string) + .collect::>() + }) + .unwrap_or(context.scopes); + + self.credentials.oauth2_user = Some(OAuth2UserContext { + client_id: context.client_id, + client_secret: context.client_secret, + access_token: access_token.clone(), + refresh_token, + expires_at, + scopes, + }); + + Ok(access_token) + } + fn absolute_url(&self, path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { path.to_string() @@ -548,6 +828,14 @@ impl Backend for TwitterBackend { self.request_oauth2_json(path, body) } + fn post_json_body_oauth2_user( + &mut self, + path: &str, + body: Value, + ) -> Result { + self.request_oauth2_user_json(path, body) + } + fn delete_json( &mut self, path: &str, @@ -556,6 +844,14 @@ impl Backend for TwitterBackend { self.request_oauth1_signed("DELETE", path, params, None) } + fn delete_json_oauth2_user( + &mut self, + path: &str, + params: Vec<(String, String)>, + ) -> Result { + self.request_oauth2_user("DELETE", path, params) + } + fn get_json_oauth2( &mut self, path: &str, @@ -564,6 +860,14 @@ impl Backend for TwitterBackend { self.request_oauth2("GET", path, params) } + fn get_json_oauth2_user( + &mut self, + path: &str, + params: Vec<(String, String)>, + ) -> Result { + self.request_oauth2_user("GET", path, params) + } + fn stream_json_lines( &mut self, path: &str, @@ -617,6 +921,10 @@ impl Backend for TwitterBackend { let token = self.ensure_bearer_token()?.to_string(); request = request.bearer_auth(token); } + AuthScheme::OAuth2User => { + let token = self.ensure_oauth2_user_token()?.to_string(); + request = request.bearer_auth(token); + } } let response = request @@ -773,6 +1081,40 @@ impl Backend for MockBackend { }) } + fn post_json_body_oauth2_user( + &mut self, + path: &str, + body: Value, + ) -> Result { + self.calls.push(CallRecord { + method: "POST_JSON_OAUTH2_USER".to_string(), + path: path.to_string(), + params: vec![("json".to_string(), body.to_string())], + }); + self.responses + .get_mut(&("POST_JSON_OAUTH2_USER".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + .or_else(|| { + self.responses + .get_mut(&("POST_JSON_OAUTH2".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .or_else(|| { + self.responses + .get_mut(&("POST_JSON".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .or_else(|| { + self.responses + .get_mut(&("POST".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .ok_or_else(|| BackendError::MissingMockResponse { + method: "POST_JSON_OAUTH2_USER".to_string(), + path: path.to_string(), + }) + } + fn delete_json( &mut self, path: &str, @@ -792,6 +1134,30 @@ impl Backend for MockBackend { }) } + fn delete_json_oauth2_user( + &mut self, + path: &str, + params: Vec<(String, String)>, + ) -> Result { + self.calls.push(CallRecord { + method: "DELETE_OAUTH2_USER".to_string(), + path: path.to_string(), + params, + }); + self.responses + .get_mut(&("DELETE_OAUTH2_USER".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + .or_else(|| { + self.responses + .get_mut(&("DELETE".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .ok_or_else(|| BackendError::MissingMockResponse { + method: "DELETE_OAUTH2_USER".to_string(), + path: path.to_string(), + }) + } + fn get_json_oauth2( &mut self, path: &str, @@ -816,6 +1182,35 @@ impl Backend for MockBackend { }) } + fn get_json_oauth2_user( + &mut self, + path: &str, + params: Vec<(String, String)>, + ) -> Result { + self.calls.push(CallRecord { + method: "GET_OAUTH2_USER".to_string(), + path: path.to_string(), + params, + }); + self.responses + .get_mut(&("GET_OAUTH2_USER".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + .or_else(|| { + self.responses + .get_mut(&("GET_OAUTH2".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .or_else(|| { + self.responses + .get_mut(&("GET".to_string(), path.to_string())) + .and_then(VecDeque::pop_front) + }) + .ok_or_else(|| BackendError::MissingMockResponse { + method: "GET_OAUTH2_USER".to_string(), + path: path.to_string(), + }) + } + fn stream_json_lines( &mut self, path: &str, From 3a929d96ca2efd7d1a4d92556fa3e2d8d5658ad8 Mon Sep 17 00:00:00 2001 From: xuelongmu Date: Sun, 5 Apr 2026 21:12:59 +0000 Subject: [PATCH 2/3] Fix bookmark pagination parameters --- src/runner.rs | 14 ++++- tests/parity_fixtures.rs | 116 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 0f7653fb..bf5a0c2e 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -445,7 +445,7 @@ fn execute_remote_command( let tweets = collect_tweets_paginated( backend, &format!("/2/users/{me_id}/bookmarks"), - timeline_v2_params(leaf), + bookmark_v2_params(leaf), AuthScheme::OAuth2User, number, )?; @@ -4703,6 +4703,18 @@ fn timeline_like_v2_params(leaf: &ArgMatches) -> Vec<(String, String)> { params } +fn bookmark_v2_params(leaf: &ArgMatches) -> Vec<(String, String)> { + let mut params = vec![( + "max_results".to_string(), + opt_usize(leaf, "number") + .unwrap_or(DEFAULT_NUM_RESULTS) + .min(MAX_SEARCH_RESULTS) + .to_string(), + )]; + params.extend(v2_tweet_params()); + params +} + fn timeline_v2_params(leaf: &ArgMatches) -> Vec<(String, String)> { [timeline_like_v2_params(leaf), v2_tweet_params()].concat() } diff --git a/tests/parity_fixtures.rs b/tests/parity_fixtures.rs index d2df8e8b..2a279731 100644 --- a/tests/parity_fixtures.rs +++ b/tests/parity_fixtures.rs @@ -149,6 +149,122 @@ fn bookmarks_use_oauth2_user_context() { })); } +#[test] +fn bookmarks_does_not_send_timeline_only_params() { + let mut backend = MockBackend::new(); + backend.enqueue_json_response("GET_OAUTH2_USER", "/2/users/me", me_fixture()); + backend.enqueue_json_response( + "GET_OAUTH2_USER", + "/2/users/7505382/bookmarks", + serde_json::json!({ + "data": [{ + "id": "1", + "text": "saved post", + "created_at": "2011-04-06T19:13:37.000Z", + "author_id": "42" + }], + "includes": { + "users": [{ + "id": "42", + "username": "alice" + }] + } + }), + ); + + let (code, _out, err) = run_cmd_with_profile(&["bookmarks", "--csv"], &mut backend); + + assert_success(code, &err); + let bookmark_call = backend + .calls() + .iter() + .find(|c| c.path == "/2/users/7505382/bookmarks") + .expect("should have called bookmarks endpoint"); + let param_keys: Vec<&str> = bookmark_call.params.iter().map(|(k, _)| k.as_str()).collect(); + assert!( + !param_keys.contains(&"since_id"), + "bookmarks must not send since_id; got params: {param_keys:?}" + ); + assert!( + !param_keys.contains(&"until_id"), + "bookmarks must not send until_id; got params: {param_keys:?}" + ); + assert!( + !param_keys.contains(&"exclude"), + "bookmarks must not send exclude; got params: {param_keys:?}" + ); +} + +#[test] +fn bookmarks_paginates_with_next_token() { + let mut backend = MockBackend::new(); + backend.enqueue_json_response("GET_OAUTH2_USER", "/2/users/me", me_fixture()); + // Page 1 + backend.enqueue_json_response( + "GET_OAUTH2_USER", + "/2/users/7505382/bookmarks", + serde_json::json!({ + "data": [{ + "id": "1", + "text": "first page post", + "created_at": "2011-04-06T19:13:37.000Z", + "author_id": "42" + }], + "includes": { + "users": [{ "id": "42", "username": "alice" }] + }, + "meta": { + "next_token": "abc123", + "result_count": 1 + } + }), + ); + // Page 2 + backend.enqueue_json_response( + "GET_OAUTH2_USER", + "/2/users/7505382/bookmarks", + serde_json::json!({ + "data": [{ + "id": "2", + "text": "second page post", + "created_at": "2011-04-06T19:14:37.000Z", + "author_id": "42" + }], + "includes": { + "users": [{ "id": "42", "username": "alice" }] + } + }), + ); + + let (code, out, err) = + run_cmd_with_profile(&["bookmarks", "--csv", "--number", "5"], &mut backend); + + assert_success(code, &err); + assert!(out.contains("first page post"), "should contain page 1 tweet"); + assert!( + out.contains("second page post"), + "should contain page 2 tweet" + ); + + let bookmark_calls: Vec<_> = backend + .calls() + .iter() + .filter(|c| c.path == "/2/users/7505382/bookmarks") + .collect(); + assert_eq!(bookmark_calls.len(), 2, "should have made two paginated requests"); + + // Second call should include pagination_token + let second_params: Vec<(&str, &str)> = bookmark_calls[1] + .params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + assert!( + second_params.contains(&("pagination_token", "abc123")), + "second request should contain pagination_token=abc123; got: {second_params:?}" + ); +} + #[test] fn bookmark_uses_oauth2_user_context() { let mut backend = MockBackend::new(); From 1f3163d88c8df45b9dde85cdfe7b091e61a3a1d1 Mon Sep 17 00:00:00 2001 From: xuelongmu Date: Mon, 6 Apr 2026 00:48:40 +0000 Subject: [PATCH 3/3] Persist refreshed OAuth2 bookmark credentials --- src/runner.rs | 12 ++++-- x-api/src/backend.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index bf5a0c2e..0b392d40 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -208,12 +208,18 @@ fn execute_remote_with_profile_backend( let mut backend = TwitterBackend::from_credentials(credentials)?; let result = execute_remote_command(path, leaf, args, context, &mut backend, out, err); - if let Ok(_) = result - && backend.credentials() != &original_credentials + // Always persist refreshed credentials, even when the command itself fails. + // X/Twitter uses refresh-token rotation: once a refresh token is exchanged, + // the previous one is permanently invalidated. Saving only on success meant + // that any post-refresh failure (API error, broken pipe, etc.) would discard + // the new tokens, leaving the now-invalid old refresh token on disk and + // causing `invalid_request` on the next run. + if backend.credentials() != &original_credentials && let Some((username, key)) = active_profile { rcfile.upsert_profile_credentials(&username, &key, backend.credentials().clone()); - rcfile.save(&context.profile_path)?; + // Best-effort: don't let a save failure mask the original command result. + let _ = rcfile.save(&context.profile_path); } result diff --git a/x-api/src/backend.rs b/x-api/src/backend.rs index c868e1c3..24d6d2de 100644 --- a/x-api/src/backend.rs +++ b/x-api/src/backend.rs @@ -1488,4 +1488,94 @@ mod tests { other => panic!("expected MissingMockResponse, got: {other:?}"), } } + + fn make_oauth2_credentials( + access_token: &str, + refresh_token: Option<&str>, + expires_at: Option, + ) -> Credentials { + Credentials { + oauth2_user: Some(OAuth2UserContext { + client_id: "test_client".to_string(), + client_secret: None, + access_token: access_token.to_string(), + refresh_token: refresh_token.map(ToString::to_string), + expires_at, + scopes: vec!["tweet.read".to_string(), "offline.access".to_string()], + }), + ..Default::default() + } + } + + #[test] + fn ensure_oauth2_user_token_returns_valid_token() { + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 7200; + let creds = make_oauth2_credentials("valid_token", Some("rt"), Some(future)); + let mut backend = TwitterBackend::from_credentials(creds).unwrap(); + + let token = backend.ensure_oauth2_user_token().unwrap(); + assert_eq!(token, "valid_token"); + } + + #[test] + fn ensure_oauth2_user_token_returns_token_without_expiry() { + let creds = make_oauth2_credentials("no_expiry_token", Some("rt"), None); + let mut backend = TwitterBackend::from_credentials(creds).unwrap(); + + let token = backend.ensure_oauth2_user_token().unwrap(); + assert_eq!(token, "no_expiry_token"); + } + + #[test] + fn ensure_oauth2_user_token_rejects_missing_context() { + let creds = Credentials::default(); + let mut backend = TwitterBackend::from_credentials(creds).unwrap(); + + let err = backend.ensure_oauth2_user_token().unwrap_err(); + assert!(matches!(err, BackendError::MissingOAuth2UserContext)); + } + + #[test] + fn ensure_oauth2_user_token_expired_without_refresh_token_errors() { + let past = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + - 100; + let creds = make_oauth2_credentials("expired", None, Some(past)); + let mut backend = TwitterBackend::from_credentials(creds).unwrap(); + + let err = backend.ensure_oauth2_user_token().unwrap_err(); + match err { + BackendError::Http(msg) => assert!( + msg.contains("offline.access"), + "should hint about offline.access scope, got: {msg}" + ), + other => panic!("expected Http error, got: {other:?}"), + } + } + + #[test] + fn ensure_oauth2_user_token_within_buffer_triggers_refresh_path() { + // Token expires in 30 seconds (within the 60-second buffer) + let soon = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + + 30; + let creds = make_oauth2_credentials("almost_expired", Some("rt"), Some(soon)); + let mut backend = TwitterBackend::from_credentials(creds).unwrap(); + + // This will try to refresh (and fail because there's no real server), + // proving the expiry buffer works correctly. + let err = backend.ensure_oauth2_user_token().unwrap_err(); + assert!( + matches!(err, BackendError::Http(_)), + "should attempt refresh and fail on network, got: {err:?}" + ); + } }