use crate::secret::Secret;
use std::path::Path;
pub fn token_url() -> String {
if std::env::var_os("SWAPDEX_ROOT").is_some() {
if let Some(u) = std::env::var_os("SWAPDEX_OAUTH_URL") {
return u.to_string_lossy().into_owned();
}
}
"https://console.anthropic.com/v1/oauth/token".to_string()
}
pub const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
#[derive(Debug, PartialEq)]
pub enum RefreshError {
NoCredential,
InUse,
Expired,
Busy,
Refused(String),
Offline(String),
}
impl RefreshError {
pub fn remedy(&self, name: &str) -> String {
match self {
Self::NoCredential => {
format!("'{name}' has no login yet - `swapdex run {name}` signs it in")
}
Self::InUse => format!(
"'{name}' is in use right now - its own session will renew it; \
renewing from here would retire the token that session is holding"
),
Self::Expired => format!(
"'{name}' has been idle too long to renew - `swapdex run {name}` signs it in again"
),
Self::Busy => format!(
"the login server is busy - '{name}' is fine, renewing again shortly will work"
),
Self::Refused(why) => format!("'{name}' could not be renewed: {why}"),
Self::Offline(why) => format!("could not reach the login server: {why}"),
}
}
}
pub fn needs_refresh(blob: &[u8], now_ms: i64) -> bool {
const SLACK_MS: i64 = 5 * 60_000;
serde_json::from_slice::<serde_json::Value>(blob)
.ok()
.and_then(|v| v["claudeAiOauth"]["expiresAt"].as_i64())
.is_some_and(|exp| exp - now_ms <= SLACK_MS)
}
fn refresh_token_expired(blob: &[u8], now_ms: i64) -> bool {
serde_json::from_slice::<serde_json::Value>(blob)
.ok()
.and_then(|v| v["claudeAiOauth"]["refreshTokenExpiresAt"].as_i64())
.is_some_and(|exp| exp <= now_ms)
}
pub fn merge_response(old: &[u8], response: &str, now_ms: i64) -> Option<Vec<u8>> {
let mut blob: serde_json::Value = serde_json::from_slice(old).ok()?;
let r: serde_json::Value = serde_json::from_str(response).ok()?;
let access = r["access_token"].as_str().filter(|s| !s.is_empty())?;
let o = blob.get_mut("claudeAiOauth")?.as_object_mut()?;
o.insert("accessToken".into(), access.into());
if let Some(rt) = r["refresh_token"].as_str().filter(|s| !s.is_empty()) {
o.insert("refreshToken".into(), rt.into());
}
if let Some(secs) = r["expires_in"].as_i64() {
o.insert("expiresAt".into(), (now_ms + secs * 1000).into());
}
serde_json::to_vec(&blob).ok()
}
pub fn request_body(refresh_token: &str) -> String {
serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
})
.to_string()
}
pub fn refresh_slot(dir: &Path, now_ms: i64) -> Result<(), RefreshError> {
if slot_in_use(dir) {
return Err(RefreshError::InUse);
}
let blob = read_credential(dir).ok_or(RefreshError::NoCredential)?;
if refresh_token_expired(blob.expose(), now_ms) {
return Err(RefreshError::Expired);
}
let token = serde_json::from_slice::<serde_json::Value>(blob.expose())
.ok()
.and_then(|v| {
v["claudeAiOauth"]["refreshToken"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
})
.ok_or(RefreshError::NoCredential)?;
let (body, status) = post(&token)?;
if status == 429 {
return Err(RefreshError::Busy);
}
if status == 401 || status == 400 {
return Err(RefreshError::Refused(short_reason(&body)));
}
if !(200..300).contains(&status) {
return Err(RefreshError::Refused(format!("HTTP {status}")));
}
let merged = merge_response(blob.expose(), &body, now_ms)
.ok_or_else(|| RefreshError::Refused("the server's answer had no access token".into()))?;
write_credential(dir, &merged).map_err(|e| RefreshError::Refused(e.to_string()))
}
fn short_reason(body: &str) -> String {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v["error_description"]
.as_str()
.or_else(|| v["error"].as_str())
.or_else(|| v["error"]["message"].as_str())
.map(str::to_string)
})
.unwrap_or_else(|| "the login server refused it".into())
}
fn slot_in_use(dir: &Path) -> bool {
crate::proc::config_dir_in_use(dir)
}
fn read_credential(dir: &Path) -> Option<Secret> {
if let Ok(bytes) = std::fs::read(dir.join(".credentials.json")) {
if !bytes.is_empty() {
return Some(Secret::new(bytes));
}
}
crate::adapters::claude::slot_keychain_read_detail(dir)
.ok()
.map(Secret::new)
}
fn write_credential(dir: &Path, blob: &[u8]) -> anyhow::Result<()> {
let file = dir.join(".credentials.json");
if file.exists() {
return crate::atomic::write_secret(&file, blob);
}
crate::adapters::claude::slot_keychain_write(dir, blob)
}
fn post(refresh_token: &str) -> Result<(String, u32), RefreshError> {
let body = request_body(refresh_token);
let cfg = format!(
"url = \"{}\"\n\
request = POST\n\
header = \"content-type: application/json\"\n\
header = \"Accept: application/json\"\n\
header = \"User-Agent: swapdex\"\n\
data = \"{}\"\n\
silent\n\
show-error\n\
connect-timeout = 6\n\
max-time = 15\n\
write-out = \"\\n%{{http_code}}\"\n",
token_url(),
body.replace('\\', "\\\\").replace('"', "\\\"")
);
let out = crate::quota::run_curl_cfg(&cfg).map_err(RefreshError::Offline)?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const BLOB: &str = r#"{"claudeAiOauth":{"accessToken":"OLD-AT","refreshToken":"OLD-RT",
"expiresAt":1000,"refreshTokenExpiresAt":9999999999999,"subscriptionType":"max"},
"mcpOAuth":{"keep":"me"}}"#;
#[test]
fn a_renewal_replaces_the_refresh_token_the_server_retired() {
let now = 1_800_000_000_000i64;
let resp = r#"{"access_token":"NEW-AT","refresh_token":"NEW-RT","expires_in":3600}"#;
let merged = merge_response(BLOB.as_bytes(), resp, now).expect("merged");
let v: serde_json::Value = serde_json::from_slice(&merged).unwrap();
let o = &v["claudeAiOauth"];
assert_eq!(o["accessToken"], "NEW-AT");
assert_eq!(
o["refreshToken"], "NEW-RT",
"keeping the old one would leave a spent token on disk"
);
assert_eq!(o["expiresAt"], now + 3_600_000, "an absolute moment");
assert_eq!(o["subscriptionType"], "max");
assert_eq!(v["mcpOAuth"]["keep"], "me");
}
#[test]
fn a_response_without_a_new_refresh_token_keeps_the_old_one() {
let resp = r#"{"access_token":"NEW-AT","expires_in":3600}"#;
let merged = merge_response(BLOB.as_bytes(), resp, 0).expect("merged");
let v: serde_json::Value = serde_json::from_slice(&merged).unwrap();
assert_eq!(v["claudeAiOauth"]["refreshToken"], "OLD-RT");
}
#[test]
fn an_answer_with_no_access_token_is_not_merged() {
assert!(merge_response(BLOB.as_bytes(), r#"{"error":"invalid_grant"}"#, 0).is_none());
assert!(merge_response(BLOB.as_bytes(), "not json", 0).is_none());
assert!(merge_response(b"not json", r#"{"access_token":"X"}"#, 0).is_none());
}
#[test]
fn renewal_is_due_before_the_token_actually_lapses() {
let now = 1_800_000_000_000i64;
let at = |exp: i64| format!(r#"{{"claudeAiOauth":{{"expiresAt":{exp}}}}}"#);
assert!(needs_refresh(at(now - 1).as_bytes(), now), "already lapsed");
assert!(
needs_refresh(at(now + 60_000).as_bytes(), now),
"a minute left would lapse mid-flight"
);
assert!(
!needs_refresh(at(now + 3_600_000).as_bytes(), now),
"an hour"
);
assert!(!needs_refresh(br#"{"claudeAiOauth":{}}"#, now));
}
#[test]
fn an_expired_refresh_token_is_named_rather_than_retried() {
let now = 1_800_000_000_000i64;
let blob = format!(
r#"{{"claudeAiOauth":{{"refreshToken":"R","refreshTokenExpiresAt":{}}}}}"#,
now - 1
);
assert!(refresh_token_expired(blob.as_bytes(), now));
let msg = RefreshError::Expired.remedy("work");
assert!(msg.contains("swapdex run work"), "names the way out: {msg}");
assert!(!refresh_token_expired(br#"{"claudeAiOauth":{}}"#, now));
}
#[test]
fn the_request_names_the_grant_and_the_client() {
let b = request_body("RT-1");
let v: serde_json::Value = serde_json::from_str(&b).unwrap();
assert_eq!(v["grant_type"], "refresh_token");
assert_eq!(v["refresh_token"], "RT-1");
assert_eq!(v["client_id"], CLIENT_ID);
}
#[test]
fn a_refusal_is_reported_in_the_users_terms() {
assert_eq!(
short_reason(r#"{"error":"invalid_grant","error_description":"token revoked"}"#),
"token revoked"
);
assert_eq!(
short_reason(r#"{"error":{"type":"rate_limit_error","message":"Rate limited."}}"#),
"Rate limited."
);
assert_eq!(
short_reason("<html>502</html>"),
"the login server refused it"
);
let busy = RefreshError::Busy.remedy("work");
assert!(busy.contains("is fine"), "{busy}");
assert!(!busy.contains("swapdex run"), "not a sign-in: {busy}");
let msg = RefreshError::InUse.remedy("work");
assert!(
msg.contains("its own session will renew it"),
"an in-use slot is fine, not broken: {msg}"
);
}
}
pub const KEEP_ALIVE_WINDOW_MS: i64 = 6 * 60 * 60 * 1000;
pub fn wants_keep_alive(blob: &[u8], now_ms: i64) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(blob) else {
return false;
};
let oauth = &v["claudeAiOauth"];
if oauth["refreshToken"].as_str().is_none_or(str::is_empty) {
return false;
}
if refresh_token_expired(blob, now_ms) {
return false;
}
oauth["expiresAt"]
.as_i64()
.is_some_and(|exp| exp - now_ms <= KEEP_ALIVE_WINDOW_MS)
}
#[cfg(test)]
mod keep_alive_tests {
use super::*;
fn blob(expires_in_ms: i64, refresh: &str, refresh_expiry: Option<i64>) -> Vec<u8> {
let now = 1_700_000_000_000i64;
let mut o = serde_json::json!({
"claudeAiOauth": {
"accessToken": "a",
"refreshToken": refresh,
"expiresAt": now + expires_in_ms,
}
});
if let Some(r) = refresh_expiry {
o["claudeAiOauth"]["refreshTokenExpiresAt"] = (now + r).into();
}
serde_json::to_vec(&o).unwrap()
}
const NOW: i64 = 1_700_000_000_000;
#[test]
fn an_idle_account_is_renewed_before_it_lapses() {
let hour = 60 * 60 * 1000;
assert!(
wants_keep_alive(&blob(2 * hour, "r", None), NOW),
"two hours left is inside the window"
);
assert!(
!wants_keep_alive(&blob(12 * hour, "r", None), NOW),
"half a day left needs nothing yet"
);
}
#[test]
fn there_is_nothing_to_sweep_without_a_live_refresh_token() {
let hour = 60 * 60 * 1000;
assert!(
!wants_keep_alive(&blob(hour, "", None), NOW),
"no refresh token"
);
assert!(
!wants_keep_alive(&blob(hour, "r", Some(-1)), NOW),
"the refresh token itself has expired"
);
}
#[test]
fn nonsense_is_never_swept() {
assert!(!wants_keep_alive(b"not json", NOW));
assert!(!wants_keep_alive(br#"{"claudeAiOauth":{}}"#, NOW));
}
}
pub fn keep_alive_sweep(
slots: &[(String, std::path::PathBuf)],
now_ms: i64,
) -> (Vec<String>, Vec<(String, RefreshError)>) {
let (mut renewed, mut failed) = (Vec::new(), Vec::new());
for (name, dir) in slots {
let Some(blob) = read_credential(dir) else {
continue;
};
if !wants_keep_alive(blob.expose(), now_ms) {
continue;
}
match refresh_slot(dir, now_ms) {
Ok(()) => renewed.push(name.clone()),
Err(RefreshError::InUse) => {}
Err(e) => failed.push((name.clone(), e)),
}
}
(renewed, failed)
}