use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
use url::Url;
use super::Auth;
use super::callback;
use super::pending;
use crate::error::{Result, XurlError};
use crate::output::OutputConfig;
#[must_use]
pub fn get_oauth2_scopes() -> Vec<&'static str> {
vec![
"tweet.read",
"users.read",
"bookmark.read",
"follows.read",
"list.read",
"block.read",
"mute.read",
"like.read",
"users.email",
"dm.read",
"tweet.write",
"tweet.moderate.write",
"follows.write",
"bookmark.write",
"block.write",
"mute.write",
"like.write",
"list.write",
"media.write",
"dm.write",
"offline.access",
"space.read",
]
}
#[must_use]
pub fn generate_code_verifier_and_challenge() -> (String, String) {
let b: [u8; 32] = rand::random();
let verifier = URL_SAFE_NO_PAD.encode(b);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(verifier, challenge)
}
pub(crate) fn build_auth_url(auth: &Auth, state: &str, challenge: &str) -> Result<String> {
let scopes = get_oauth2_scopes().join(" ");
let mut auth_url =
Url::parse(auth.auth_url()).map_err(|e| XurlError::auth_with_cause("InvalidURL", &e))?;
auth_url
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", auth.client_id())
.append_pair("redirect_uri", auth.redirect_uri())
.append_pair("scope", &scopes)
.append_pair("state", state)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256");
Ok(auth_url.to_string())
}
pub(crate) fn exchange_code_for_token(
auth: &mut Auth,
code: &str,
verifier: &str,
username: &str,
) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(auth.http_timeout_secs()))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
let token_resp = client
.post(auth.token_url())
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", auth.redirect_uri()),
("client_id", auth.client_id()),
("code_verifier", verifier),
])
.basic_auth(auth.client_id(), Some(auth.client_secret()))
.send()
.map_err(|e| XurlError::auth_with_cause("TokenExchangeError", &e))?;
let status = token_resp.status();
let token_data: serde_json::Value = token_resp
.json()
.map_err(|e| XurlError::auth_with_cause("TokenExchangeError", &e))?;
if !status.is_success() {
let api_error = token_data["error"].as_str().unwrap_or("unknown");
let api_desc = token_data["error_description"].as_str().unwrap_or("");
return Err(XurlError::auth(format!(
"TokenExchangeError: HTTP {status} — {api_error}: {api_desc}"
)));
}
let access_token = token_data["access_token"]
.as_str()
.ok_or_else(|| XurlError::auth("TokenExchangeError: no access_token in response"))?
.to_string();
let refresh_token = token_data["refresh_token"]
.as_str()
.unwrap_or("")
.to_string();
let expires_in = token_data["expires_in"].as_u64().unwrap_or(7200);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expiration_time = now + expires_in;
let app_name = auth.app_name().to_string();
if username.is_empty() {
match auth.fetch_username(&access_token) {
Ok(discovered) => {
auth.token_store.save_oauth2_token_for_app(
&app_name,
&discovered,
&access_token,
&refresh_token,
expiration_time,
)?;
}
Err(_) => {
crate::output::warn_stderr(
"token exchange succeeded but /2/users/me lookup failed; token stored under unnamed slot",
);
auth.token_store.save_oauth2_token_unnamed_for_app(
&app_name,
&access_token,
&refresh_token,
expiration_time,
)?;
}
}
} else {
auth.token_store.save_oauth2_token_for_app(
&app_name,
username,
&access_token,
&refresh_token,
expiration_time,
)?;
}
let _ = auth
.token_store
.promote_to_default_if_first_credentialed(&app_name)?;
Ok(access_token)
}
pub fn run_oauth2_flow(
auth: &mut Auth,
username: &str,
out: &OutputConfig,
stdout: &mut dyn std::io::Write,
browser_opener: fn(&str) -> std::io::Result<()>,
) -> Result<String> {
let state_bytes: [u8; 32] = rand::random();
let state = BASE64_STANDARD.encode(state_bytes);
let (verifier, challenge) = generate_code_verifier_and_challenge();
let auth_url_str = build_auth_url(auth, &state, &challenge)?;
let redirect_parsed = Url::parse(auth.redirect_uri())
.map_err(|e| XurlError::auth_with_cause("InvalidURL", &e))?;
let opener_outcome = std::sync::Arc::new(std::sync::Mutex::new(None::<std::io::Result<()>>));
let opener_outcome_for_closure = std::sync::Arc::clone(&opener_outcome);
let auth_url_for_closure = auth_url_str.clone();
let cancel = tokio_util::sync::CancellationToken::new();
let cancel_for_closure = cancel.clone();
let on_bound = move || {
let result = browser_opener(&auth_url_for_closure);
let failed = result.is_err();
if let Ok(mut slot) = opener_outcome_for_closure.lock() {
*slot = Some(result);
}
if failed {
cancel_for_closure.cancel();
}
};
let code_result = callback::wait_for_callback_with(&redirect_parsed, &state, cancel, on_bound);
let opener_err = opener_outcome.lock().ok().and_then(|mut s| s.take());
if let Some(Err(_e)) = &opener_err {
out.print_message(
stdout,
"Failed to open browser automatically. Re-run with --no-browser \
to use the paste-the-URL flow:",
);
out.print_message(stdout, &auth_url_str);
return Err(XurlError::auth(
"browser-open failed; re-run with --no-browser to paste the URL manually",
));
}
let code = code_result?;
exchange_code_for_token(auth, &code, &verifier, username)
}
pub fn run_remote_step1(auth: &Auth, pending_path: &std::path::Path) -> Result<String> {
if pending_path.exists() {
crate::output::warn_stderr("overwriting previous pending auth flow");
}
let state_bytes: [u8; 32] = rand::random();
let state = BASE64_STANDARD.encode(state_bytes);
let (verifier, challenge) = generate_code_verifier_and_challenge();
let auth_url_str = build_auth_url(auth, &state, &challenge)?;
let now = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let pending_state = pending::PendingOAuth2State {
code_verifier: verifier,
state,
client_id: auth.client_id().to_string(),
app_name: auth.app_name().to_string(),
created_at: now,
};
pending::save(&pending_state, pending_path)?;
Ok(auth_url_str)
}
pub fn run_remote_step2(
auth: &mut Auth,
redirect_url: &str,
username: &str,
pending_path: &std::path::Path,
) -> Result<String> {
let pending_state = pending::load(pending_path)?;
if pending_state.client_id != auth.client_id() {
return Err(XurlError::auth(format!(
"AppMismatch: pending state was created for app {:?} (client_id: {}), \
but current context uses client_id: {}. Re-run step 1 with the correct --app",
pending_state.app_name,
pending_state.client_id,
auth.client_id()
)));
}
let parsed = Url::parse(redirect_url).map_err(|e| {
XurlError::auth_with_cause("InvalidRedirectURL: failed to parse redirect URL", &e)
})?;
let params: std::collections::HashMap<String, String> = parsed
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let state = params.get("state").ok_or_else(|| {
XurlError::auth("MissingState: no 'state' parameter found in redirect URL")
})?;
if *state != pending_state.state {
return Err(XurlError::auth(
"StateMismatch: the state parameter in the redirect URL does not match \
the pending auth flow. This may indicate a CSRF attack or that step 1 \
was re-run. Please start over with step 1",
));
}
let code = params.get("code").ok_or_else(|| {
XurlError::auth(
"MissingCode: no 'code' parameter found in redirect URL. \
Make sure you copied the full URL from your browser's address bar",
)
})?;
let access_token = exchange_code_for_token(auth, code, &pending_state.code_verifier, username)?;
pending::delete(pending_path)?;
Ok(access_token)
}
pub fn refresh_oauth2_token(auth: &mut Auth, username: &str) -> Result<String> {
let app_name_lookup = auth.app_name().to_string();
let token = if username.is_empty() {
auth.token_store
.get_first_oauth2_token_for_app(&app_name_lookup)
.cloned()
.or_else(|| {
auth.token_store
.get_oauth2_token_unnamed_for_app(&app_name_lookup)
.cloned()
})
} else {
auth.token_store
.get_oauth2_token_for_app(&app_name_lookup, username)
.cloned()
};
let token = token.ok_or_else(|| XurlError::auth("TokenNotFound: oauth2 token not found"))?;
let oauth2 = token
.oauth2
.as_ref()
.ok_or_else(|| XurlError::auth("TokenNotFound: oauth2 token not found"))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
if now < oauth2.expiration_time {
return Ok(oauth2.access_token.clone());
}
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(auth.http_timeout_secs()))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
let token_resp = client
.post(auth.token_url())
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &oauth2.refresh_token),
("client_id", auth.client_id()),
])
.basic_auth(auth.client_id(), Some(auth.client_secret()))
.send()
.map_err(|e| XurlError::auth_with_cause("RefreshTokenError", &e))?;
let token_data: serde_json::Value = token_resp
.json()
.map_err(|e| XurlError::auth_with_cause("RefreshTokenError", &e))?;
let new_access_token = token_data["access_token"]
.as_str()
.ok_or_else(|| XurlError::auth("RefreshTokenError: no access_token in response"))?
.to_string();
let new_refresh_token = token_data["refresh_token"]
.as_str()
.unwrap_or("")
.to_string();
let expires_in = token_data["expires_in"].as_u64().unwrap_or(7200);
let new_now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expiration_time = new_now + expires_in;
let app_name = auth.app_name().to_string();
if username.is_empty() {
match auth.fetch_username(&new_access_token) {
Ok(discovered) => {
auth.token_store.save_oauth2_token_for_app(
&app_name,
&discovered,
&new_access_token,
&new_refresh_token,
expiration_time,
)?;
}
Err(_) => {
crate::output::warn_stderr(
"refresh succeeded but /2/users/me lookup failed; token stored under unnamed slot",
);
auth.token_store.save_oauth2_token_unnamed_for_app(
&app_name,
&new_access_token,
&new_refresh_token,
expiration_time,
)?;
}
}
} else {
auth.token_store.save_oauth2_token_for_app(
&app_name,
username,
&new_access_token,
&new_refresh_token,
expiration_time,
)?;
}
Ok(new_access_token)
}