use reqwest::Client;
use super::{is_token_expired, now_millis, OAuthCredentials, TokenResponse, CLIENT_ID, TOKEN_URL};
use super::storage::{auth_file_path, load_provider_auth, save_provider_auth};
pub async fn exchange_code_for_tokens(
code: &str,
state: &str,
verifier: &str,
port: u16,
) -> std::result::Result<OAuthCredentials, String> {
let redirect_uri = format!("http://localhost:{}/callback", port);
let body = serde_json::json!({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"state": state,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
});
let client = Client::builder()
.tls_built_in_webpki_certs(true)
.tls_built_in_native_certs(true)
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let resp = client
.post(TOKEN_URL)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Token exchange request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Token exchange failed ({}): {}", status, text));
}
let token_resp: TokenResponse = resp
.json()
.await
.map_err(|e| format!("Failed to parse token response: {}", e))?;
let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
Ok(OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: token_resp.refresh_token,
access: token_resp.access_token,
expires,
account_id: None,
})
}
pub async fn refresh_token(client: &Client, refresh: &str) -> std::result::Result<OAuthCredentials, String> {
let body = serde_json::json!({
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh,
});
let resp = client
.post(TOKEN_URL)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Token refresh request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Token refresh failed ({}): {}", status, text));
}
let token_resp: TokenResponse = resp
.json()
.await
.map_err(|e| format!("Failed to parse refresh response: {}", e))?;
let expires = now_millis() + (token_resp.expires_in * 1000) - (5 * 60 * 1000);
Ok(OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: token_resp.refresh_token,
access: token_resp.access_token,
expires,
account_id: None,
})
}
pub async fn ensure_fresh_token(client: &Client) -> std::result::Result<OAuthCredentials, String> {
use fs4::fs_std::FileExt;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom};
let path = auth_file_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
if !path.exists() {
return Err(format!(
"No credentials at {}. Run `login` to authenticate.",
path.display()
));
}
let file = OpenOptions::new()
.read(true)
.open(&path)
.map_err(|e| format!("Failed to open {}: {}", path.display(), e))?;
let mut file = tokio::task::spawn_blocking(move || -> std::result::Result<std::fs::File, String> {
FileExt::lock_exclusive(&file)
.map_err(|e| format!("Failed to lock auth.json: {}", e))?;
Ok(file)
})
.await
.map_err(|e| format!("Lock task failed: {}", e))??;
file.seek(SeekFrom::Start(0))
.map_err(|e| format!("Failed to seek auth.json: {}", e))?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| format!("Failed to read auth.json: {}", e))?;
drop(file);
let root: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse auth.json: {}", e))?;
let anthropic_raw = root
.get("anthropic")
.ok_or_else(|| "No anthropic credential in auth.json. Run `login`.".to_string())?;
let creds: OAuthCredentials = serde_json::from_value(anthropic_raw.clone())
.map_err(|e| format!("Failed to parse anthropic credential: {}", e))?;
if !is_token_expired(&creds) {
return Ok(creds);
}
let new_creds = refresh_token(client, &creds.refresh).await?;
save_provider_auth("anthropic", &new_creds)?;
Ok(new_creds)
}
pub async fn ensure_fresh_provider_token(
client: &Client,
provider: &str,
) -> std::result::Result<OAuthCredentials, String> {
let Some(creds) = load_provider_auth(provider)? else {
return Err(format!("No credentials for {}. Run `synaps login`.", provider));
};
if !is_token_expired(&creds) {
return Ok(creds);
}
let fresh = match provider {
"openai-codex" => super::openai_codex::refresh_token(client, &creds.refresh).await?,
other => return Err(format!("No refresh handler for OAuth provider {}", other)),
};
save_provider_auth(provider, &fresh)?;
Ok(fresh)
}
#[cfg(test)]
mod tests {
use super::super::storage::save_provider_auth_at_test_hook;
use super::super::OAuthCredentials;
fn fresh_creds(refresh: &str) -> OAuthCredentials {
OAuthCredentials {
auth_type: "oauth".to_string(),
refresh: refresh.to_string(),
access: "access".to_string(),
expires: crate::epoch_millis() + 3_600_000,
account_id: None,
}
}
#[test]
fn bug2_write_produces_non_empty_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok1"))
.expect("save must succeed");
let meta = std::fs::metadata(&path).expect("file must exist");
assert!(meta.len() > 0, "auth.json must not be empty after save");
}
#[test]
fn bug2_tmp_file_cleaned_up_after_successful_write() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok2"))
.expect("save must succeed");
let tmp = path.with_extension("json.tmp");
assert!(
!tmp.exists(),
"auth.json.tmp must not persist after a successful atomic rename"
);
}
#[test]
fn bug2_final_file_is_valid_json_with_correct_credential() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("tok3"))
.expect("save must succeed");
let content = std::fs::read_to_string(&path).expect("must be readable");
let parsed: serde_json::Value =
serde_json::from_str(&content).expect("must be valid JSON after atomic write");
assert_eq!(
parsed["anthropic"]["refresh"].as_str(),
Some("tok3"),
"refresh token must be persisted correctly"
);
}
#[test]
fn bug2_refresh_write_preserves_other_providers() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("auth.json");
save_provider_auth_at_test_hook(&path, "openai-codex", &fresh_creds("codex-tok"))
.expect("save codex");
save_provider_auth_at_test_hook(&path, "future-provider", &fresh_creds("future-tok"))
.expect("save future");
save_provider_auth_at_test_hook(&path, "anthropic", &fresh_creds("anth-new"))
.expect("save anthropic after refresh");
let content = std::fs::read_to_string(&path).expect("readable");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid JSON");
assert_eq!(parsed["anthropic"]["refresh"].as_str(), Some("anth-new"));
assert_eq!(
parsed["openai-codex"]["refresh"].as_str(),
Some("codex-tok"),
"openai-codex must survive the anthropic refresh write"
);
assert_eq!(
parsed["future-provider"]["refresh"].as_str(),
Some("future-tok"),
"future-provider must survive (fixes #184 — 3rd provider not dropped)"
);
}
}