use crate::auth::credentials::REDACTED;
use std::path::Path;
use serde::Serialize;
use url::Url;
use crate::error::Error;
#[derive(Clone)]
pub struct Config {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub auth_url: String,
pub token_url: String,
pub api_base_url: String,
pub info_url: String,
pub app_name: String,
pub(crate) redirect_uri_source: ResolveSource,
pub(crate) redirect_uri_from_env: bool,
pub http_timeout_secs: u64,
}
crate::assert_send_sync!(Config);
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("client_id", &self.client_id)
.field("client_secret", &REDACTED)
.field("redirect_uri", &self.redirect_uri)
.field("auth_url", &self.auth_url)
.field("token_url", &self.token_url)
.field("api_base_url", &self.api_base_url)
.field("info_url", &self.info_url)
.field("app_name", &self.app_name)
.field("redirect_uri_source", &self.redirect_uri_source)
.field("redirect_uri_from_env", &self.redirect_uri_from_env)
.field("http_timeout_secs", &self.http_timeout_secs)
.finish()
}
}
pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:8080/callback";
pub const DEFAULT_API_BASE_URL: &str = "https://api.x.com";
pub const DEFAULT_TOKEN_URL: &str = "https://api.x.com/2/oauth2/token";
#[derive(Clone, Default, PartialEq, Eq)]
pub struct EnvOverrides {
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub redirect_uri: Option<String>,
pub auth_url: Option<String>,
pub token_url: Option<String>,
pub api_base_url: Option<String>,
pub info_url: Option<String>,
pub bearer_token: Option<String>,
pub output: Option<String>,
pub home: Option<String>,
pub token_store: Option<String>,
pub no_color: bool,
}
impl std::fmt::Debug for EnvOverrides {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvOverrides")
.field("client_id", &self.client_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| REDACTED),
)
.field("redirect_uri", &self.redirect_uri)
.field("auth_url", &self.auth_url)
.field("token_url", &self.token_url)
.field("api_base_url", &self.api_base_url)
.field("info_url", &self.info_url)
.field(
"bearer_token",
&self.bearer_token.as_ref().map(|_| REDACTED),
)
.field("output", &self.output)
.field("home", &self.home)
.field("token_store", &self.token_store)
.field("no_color", &self.no_color)
.finish()
}
}
impl EnvOverrides {
#[must_use]
pub fn from_env() -> Self {
Self {
client_id: std::env::var("CLIENT_ID").ok(),
client_secret: std::env::var("CLIENT_SECRET").ok(),
redirect_uri: std::env::var("REDIRECT_URI").ok(),
auth_url: std::env::var("AUTH_URL").ok(),
token_url: std::env::var("TOKEN_URL").ok(),
api_base_url: std::env::var("API_BASE_URL").ok(),
info_url: std::env::var("INFO_URL").ok(),
bearer_token: std::env::var("XURL_BEARER_TOKEN").ok(),
output: None,
home: None,
token_store: None,
no_color: false,
}
}
}
impl Config {
#[must_use]
pub fn new() -> Self {
Self::from_overrides(&EnvOverrides::from_env())
}
#[must_use]
pub fn from_overrides(overrides: &EnvOverrides) -> Self {
let redirect_uri_from_env = overrides.redirect_uri.is_some();
let redirect_uri = overrides
.redirect_uri
.clone()
.unwrap_or_else(|| DEFAULT_REDIRECT_URI.to_string());
let redirect_uri_source = if redirect_uri_from_env {
ResolveSource::EnvVar
} else {
ResolveSource::BuiltInDefault
};
let api_base_url = overrides
.api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_API_BASE_URL.to_string());
let info_url = overrides
.info_url
.clone()
.unwrap_or_else(|| format!("{api_base_url}/2/users/me"));
Self {
client_id: overrides.client_id.clone().unwrap_or_default(),
client_secret: overrides.client_secret.clone().unwrap_or_default(),
redirect_uri,
auth_url: overrides
.auth_url
.clone()
.unwrap_or_else(|| "https://x.com/i/oauth2/authorize".to_string()),
token_url: overrides
.token_url
.clone()
.unwrap_or_else(|| DEFAULT_TOKEN_URL.to_string()),
api_base_url,
info_url,
app_name: String::new(),
redirect_uri_source,
redirect_uri_from_env,
http_timeout_secs: crate::api::DEFAULT_TIMEOUT_SECS,
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl Config {
#[must_use]
pub fn default_store_path() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".xurl")
}
pub fn validate_redirect_uri(uri: &str) -> crate::error::Result<Url> {
let parsed =
Url::parse(uri).map_err(|e| Error::validation(format!("invalid redirect URI: {e}")))?;
let scheme = parsed.scheme();
if scheme == "https" {
return Ok(parsed);
}
if scheme == "http"
&& let Some(host) = parsed.host_str()
&& matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
{
return Ok(parsed);
}
Err(Error::validation(format!(
"redirect URI must be https, or http on loopback (localhost / 127.0.0.1 / [::1]); got: {uri}"
)))
}
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
#[schemars(
description = "Where a resolved redirect URI came from: `env-var`, `app-config`, or `built-in-default`."
)]
pub enum ResolveSource {
EnvVar,
AppConfig,
BuiltInDefault,
}
impl ResolveSource {
pub fn as_text_label(&self) -> &'static str {
match self {
Self::EnvVar => "REDIRECT_URI environment variable",
Self::AppConfig => "app config",
Self::BuiltInDefault => "built-in default",
}
}
pub fn is_env_var(&self) -> bool {
matches!(self, Self::EnvVar)
}
}
#[doc(hidden)]
#[derive(Debug)]
pub struct ResolvedRedirectUri {
pub uri: String,
pub source: ResolveSource,
}
#[doc(hidden)]
pub fn resolve_redirect_uri_from(
env_value: Option<String>,
stored: Option<&str>,
) -> ResolvedRedirectUri {
if let Some(v) = env_value {
if Config::validate_redirect_uri(&v).is_ok() {
return ResolvedRedirectUri {
uri: v,
source: ResolveSource::EnvVar,
};
}
tracing::warn!(
target: "xdk::config",
"REDIRECT_URI env value rejected by validation; falling through to next precedence level"
);
}
if let Some(s) = stored
&& !s.is_empty()
{
return ResolvedRedirectUri {
uri: s.to_string(),
source: ResolveSource::AppConfig,
};
}
ResolvedRedirectUri {
uri: DEFAULT_REDIRECT_URI.to_string(),
source: ResolveSource::BuiltInDefault,
}
}
#[must_use]
#[doc(hidden)]
pub fn resolve_redirect_uri(store_path: &Path, app_name: &str) -> ResolvedRedirectUri {
let env = std::env::var("REDIRECT_URI").ok();
let store = crate::store::TokenStore::new_with_path(store_path.to_str().unwrap_or("."));
let stored = store.get_app_redirect_uri(app_name).map(str::to_string);
resolve_redirect_uri_from(env, stored.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_overrides_present_redirect_uri_records_env_provenance() {
let cfg = Config::from_overrides(&EnvOverrides {
redirect_uri: Some("https://example.com/cb".to_string()),
..EnvOverrides::default()
});
assert_eq!(cfg.redirect_uri_source, ResolveSource::EnvVar);
assert!(cfg.redirect_uri_from_env);
}
#[test]
fn from_overrides_absent_redirect_uri_records_builtin_provenance() {
let cfg = Config::from_overrides(&EnvOverrides::default());
assert_eq!(cfg.redirect_uri_source, ResolveSource::BuiltInDefault);
assert!(!cfg.redirect_uri_from_env);
assert_eq!(cfg.redirect_uri, DEFAULT_REDIRECT_URI);
}
#[test]
fn resolve_redirect_uri_from_env_wins_over_stored() {
let resolved = resolve_redirect_uri_from(
Some("https://example.com/cb".to_string()),
Some("http://stored.example.com/cb"),
);
assert_eq!(resolved.source, ResolveSource::EnvVar);
assert_eq!(resolved.uri, "https://example.com/cb");
}
#[test]
fn resolve_redirect_uri_from_stored_wins_over_default() {
let resolved = resolve_redirect_uri_from(None, Some("http://localhost:9090/cb"));
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
fn resolve_redirect_uri_from_default_fallback() {
let resolved = resolve_redirect_uri_from(None, None);
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
#[test]
fn resolve_redirect_uri_from_empty_stored_falls_through_to_default() {
let resolved = resolve_redirect_uri_from(None, Some(""));
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
#[test]
fn resolve_redirect_uri_from_invalid_env_falls_through_to_stored() {
let resolved = resolve_redirect_uri_from(
Some("not-a-url".to_string()),
Some("http://localhost:9090/cb"),
);
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
fn resolve_source_as_text_label_exhaustive() {
for variant in [
ResolveSource::EnvVar,
ResolveSource::AppConfig,
ResolveSource::BuiltInDefault,
] {
let label = variant.as_text_label();
match variant {
ResolveSource::EnvVar => {
assert_eq!(label, "REDIRECT_URI environment variable");
}
ResolveSource::AppConfig => {
assert_eq!(label, "app config");
}
ResolveSource::BuiltInDefault => {
assert_eq!(label, "built-in default");
}
}
}
}
#[test]
fn resolve_source_serialize_kebab_case_exhaustive() {
for variant in [
ResolveSource::EnvVar,
ResolveSource::AppConfig,
ResolveSource::BuiltInDefault,
] {
let json = serde_json::to_string(&variant).expect("serialize ResolveSource");
match variant {
ResolveSource::EnvVar => assert_eq!(json, "\"env-var\""),
ResolveSource::AppConfig => assert_eq!(json, "\"app-config\""),
ResolveSource::BuiltInDefault => assert_eq!(json, "\"built-in-default\""),
}
}
}
#[test]
fn resolve_source_is_env_var_predicate() {
assert!(ResolveSource::EnvVar.is_env_var());
assert!(!ResolveSource::AppConfig.is_env_var());
assert!(!ResolveSource::BuiltInDefault.is_env_var());
}
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
fn write_store_with_redirect_uri(path: &std::path::Path, app: &str, uri: &str) {
let yaml = format!(
"apps:\n {app}:\n client_id: ''\n client_secret: ''\n redirect_uri: '{uri}'\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
);
fs::write(path, yaml).expect("write tempdir store");
}
fn write_empty_store(path: &std::path::Path, app: &str) {
let yaml = format!(
"apps:\n {app}:\n client_id: ''\n client_secret: ''\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
);
fs::write(path, yaml).expect("write tempdir store");
}
#[test]
#[serial]
fn resolve_redirect_uri_env_wins() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_store_with_redirect_uri(&store_path, "app1", "http://localhost:7777/cb");
unsafe {
std::env::set_var("REDIRECT_URI", "https://example.com/cb");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
assert_eq!(resolved.source, ResolveSource::EnvVar);
assert_eq!(resolved.uri, "https://example.com/cb");
}
#[test]
#[serial]
fn resolve_redirect_uri_stored_when_no_env() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_store_with_redirect_uri(&store_path, "app1", "http://localhost:9090/cb");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
#[serial]
fn resolve_redirect_uri_default_fallback() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_empty_store(&store_path, "app1");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
}