use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use super::credential::{Credential, CredentialProvider};
use super::error::AuthError;
use super::lock::{write_private_json_atomic, FileLock, DEFAULT_LOCK_TIMEOUT};
use super::refresh::{credential_from_token_response, merged_file, refresh_request, LoginProvider};
use super::stores::{
claude_credentials_path, codex_auth_path, pi_agent_auth_path, read_claude_code_credential,
read_codex_cli_credential, read_xai_credential, LocalOAuthCredential, CLAUDE_CODE_LOGIN_HINT,
OPENAI_CODEX_LOGIN_HINT, XAI_LOGIN_HINT,
};
use crate::transport::{BoxFuture, Transport};
pub fn stored_login_paths(
provider: &str,
env: &dyn Fn(&str) -> Option<String>,
credentials_path: Option<&Path>,
) -> Vec<PathBuf> {
if let Some(path) = credentials_path {
return vec![path.to_path_buf()];
}
let value = |key: &str| env(key).filter(|v| !v.is_empty());
let home = value("HOME").map(PathBuf::from);
match provider {
"claude-code" => home
.map(|h| claude_credentials_path(&h))
.into_iter()
.collect(),
"openai-codex" => home.map(|h| codex_auth_path(&h)).into_iter().collect(),
_ => {
let mut paths = Vec::new();
let store = match value("LM15_CREDENTIALS_PATH") {
Some(path) => Some(PathBuf::from(path)),
None => match value("XDG_CONFIG_HOME") {
Some(config_home) => Some(PathBuf::from(config_home)),
None => home.as_ref().map(|h| h.join(".config")),
}
.map(|base| base.join("lm15").join("credentials.json")),
};
paths.extend(store);
paths.extend(home.as_ref().map(|h| pi_agent_auth_path(h)));
paths
}
}
}
type Reader = fn(&Path) -> Result<LocalOAuthCredential, AuthError>;
fn reader_for(provider: &str) -> (Reader, &'static str) {
match provider {
"claude-code" => (read_claude_code_credential, CLAUDE_CODE_LOGIN_HINT),
"openai-codex" => (read_codex_cli_credential, OPENAI_CODEX_LOGIN_HINT),
_ => (read_xai_credential, XAI_LOGIN_HINT),
}
}
#[derive(Clone)]
struct Refresher {
transport: Arc<dyn Transport>,
lock_dir: PathBuf,
lock_timeout: Duration,
}
#[derive(Clone)]
pub struct StoredLogin {
provider: String,
paths: Vec<PathBuf>,
refresher: Option<Refresher>,
}
impl StoredLogin {
pub fn for_provider(
provider: &str,
env: &dyn Fn(&str) -> Option<String>,
credentials_path: Option<&Path>,
) -> Self {
StoredLogin {
provider: provider.to_string(),
paths: stored_login_paths(provider, env, credentials_path),
refresher: None,
}
}
pub fn at(provider: &str, paths: Vec<PathBuf>) -> Self {
StoredLogin {
provider: provider.to_string(),
paths,
refresher: None,
}
}
pub fn refreshing(mut self, transport: Arc<dyn Transport>, lock_dir: PathBuf) -> Self {
self.refresher = Some(Refresher {
transport,
lock_dir,
lock_timeout: DEFAULT_LOCK_TIMEOUT,
});
self
}
pub fn lock_timeout(mut self, timeout: Duration) -> Self {
if let Some(refresher) = &mut self.refresher {
refresher.lock_timeout = timeout;
}
self
}
pub fn refreshes(&self) -> bool {
self.refresher.is_some()
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn paths(&self) -> &[PathBuf] {
&self.paths
}
pub fn read(&self) -> Result<LocalOAuthCredential, AuthError> {
self.read_with_source().map(|(credential, _)| credential)
}
pub fn read_with_source(&self) -> Result<(LocalOAuthCredential, &Path), AuthError> {
let (reader, hint) = reader_for(&self.provider);
let mut last = None;
for path in &self.paths {
match reader(path) {
Ok(credential) => return Ok((credential, path)),
Err(err) => last = Some(err),
}
}
Err(last.unwrap_or_else(|| {
AuthError::not_configured(
self.provider.clone(),
"no HOME to derive the stored-login path from (spec/auth.md AUTH-8)",
hint,
)
}))
}
fn expired_error(&self, credential: &LocalOAuthCredential) -> AuthError {
let (_, hint) = reader_for(&self.provider);
AuthError::Expired {
provider: self.provider.clone(),
refreshable: credential.has_refresh_token(),
refresh_configured: self.refreshes(),
hint: hint.to_string(),
}
}
pub async fn refresh_if_expired(&self) -> Result<LocalOAuthCredential, AuthError> {
let (credential, source) = self.read_with_source()?;
if !credential.expired() {
return Ok(credential);
}
let Some(refresher) = &self.refresher else {
return Err(self.expired_error(&credential));
};
if !credential.has_refresh_token() {
return Err(self.expired_error(&credential));
}
let provider =
LoginProvider::from_id(&self.provider).ok_or_else(|| AuthError::UnknownProvider {
provider: self.provider.clone(),
})?;
let (reader, _) = reader_for(&self.provider);
let _lock = FileLock::acquire(&refresher.lock_dir, source, refresher.lock_timeout).await?;
let current = reader(source)?;
if !current.expired() {
return Ok(current);
}
let Some(refresh_token) = current.refresh_token() else {
return Err(self.expired_error(¤t));
};
let refreshed = self
.post_refresh(provider, refresher, refresh_token)
.await?;
let existing = std::fs::read_to_string(source)
.ok()
.and_then(|text| serde_json::from_str(&text).ok());
write_private_json_atomic(source, &merged_file(provider, existing, &refreshed))?;
Ok(refreshed)
}
async fn post_refresh(
&self,
provider: LoginProvider,
refresher: &Refresher,
refresh_token: &str,
) -> Result<LocalOAuthCredential, AuthError> {
let failed = |message: String| AuthError::Rejected {
provider: Some(self.provider.clone()),
message,
hint: Some(provider.login_hint().to_string()),
};
let request = refresh_request(provider, refresh_token);
let response = refresher
.transport
.send(request)
.await
.map_err(|err| failed(format!("token refresh failed: {}", err.message())))?;
let status = response.status;
let body = response
.read()
.await
.map_err(|err| failed(format!("token refresh failed: {}", err.message())))?;
if !(200..300).contains(&status) {
return Err(failed(format!(
"the stored login is expired and the refresh attempt failed (HTTP {status})"
)));
}
let body: serde_json::Map<String, serde_json::Value> = serde_json::from_slice(&body)
.map_err(|_| failed("token refresh response is not a JSON object".into()))?;
credential_from_token_response(provider, &body, Some(refresh_token))
}
pub fn is_usable(&self) -> bool {
self.state() == StoredState::Usable
}
pub fn state(&self) -> StoredState {
let (reader, _) = reader_for(&self.provider);
for path in &self.paths {
if let Ok(credential) = reader(path) {
return if credential.usable() {
StoredState::Usable
} else {
StoredState::Unusable
};
}
if signed_out_marker(path, &self.provider) {
return StoredState::LoggedOut;
}
}
StoredState::Absent
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StoredState {
Usable,
Unusable,
LoggedOut,
Absent,
}
fn signed_out_marker(path: &Path, provider: &str) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
return false;
};
value
.pointer(&format!("/_lm15/slots/{provider}/logged_out"))
.is_some_and(|marker| !marker.is_null() && marker != &serde_json::Value::Bool(false))
}
impl CredentialProvider for StoredLogin {
fn source(&self) -> Option<super::CredentialSource> {
Some(super::CredentialSource {
kind: "oauth-file".into(),
label: format!("the stored {} login", self.provider),
named: None,
expires_at: None,
})
}
fn credential(&self) -> Result<Credential, AuthError> {
let credential = self.read()?;
if credential.expired() {
return Err(self.expired_error(&credential));
}
let expires_at = credential.expires_at_ms().map(|ms| ms.div_euclid(1000));
Credential::bearer_token(credential.access_token(), expires_at)
}
fn prepare(&self) -> Option<BoxFuture<'_, Result<(), AuthError>>> {
self.refresher.as_ref()?;
Some(Box::pin(async move {
self.refresh_if_expired().await.map(|_| ())
}))
}
}
impl fmt::Debug for StoredLogin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredLogin")
.field("provider", &self.provider)
.field("paths", &self.paths)
.field("refreshes", &self.refreshes())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |key: &str| map.get(key).cloned()
}
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
path
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[test]
fn paths_follow_auth_8() {
let e = env(&[("HOME", "/h")]);
assert_eq!(
stored_login_paths("claude-code", &e, None),
vec![PathBuf::from("/h/.claude/.credentials.json")]
);
assert_eq!(
stored_login_paths("openai-codex", &e, None),
vec![PathBuf::from("/h/.codex/auth.json")]
);
assert_eq!(
stored_login_paths("xai", &e, None),
vec![
PathBuf::from("/h/.config/lm15/credentials.json"),
PathBuf::from("/h/.pi/agent/auth.json")
]
);
let e = env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]);
assert_eq!(
stored_login_paths("xai", &e, None)[0],
PathBuf::from("/x/lm15/credentials.json")
);
let e = env(&[("HOME", "/h"), ("LM15_CREDENTIALS_PATH", "/c.json")]);
assert_eq!(
stored_login_paths("xai", &e, None)[0],
PathBuf::from("/c.json")
);
assert!(stored_login_paths("claude-code", &env(&[]), None).is_empty());
assert_eq!(
stored_login_paths("claude-code", &env(&[]), Some(Path::new("/explicit"))),
vec![PathBuf::from("/explicit")]
);
}
#[test]
fn fresh_login_is_a_bearer_token_read_per_call() {
let dir = tempdir();
let expires = now_ms() + 3_600_000;
let path = write(
&dir,
".claude/.credentials.json",
&format!(
r#"{{"claudeAiOauth":{{"accessToken":"tok-1","refreshToken":"r","expiresAt":{expires}}}}}"#
),
);
let login = StoredLogin::at("claude-code", vec![path.clone()]);
assert!(login.is_usable());
let credential = login.credential().unwrap();
assert_eq!(
credential,
Credential::bearer_token("tok-1", Some(expires.div_euclid(1000))).unwrap()
);
std::fs::write(
&path,
format!(
r#"{{"claudeAiOauth":{{"accessToken":"tok-2","refreshToken":"r","expiresAt":{expires}}}}}"#
),
)
.unwrap();
assert!(matches!(
login.credential().unwrap(),
Credential::BearerToken { value, .. } if value == "tok-2"
));
assert!(!format!("{login:?}").contains("tok-"));
}
#[test]
fn expired_login_is_the_auth_error_never_a_fallback() {
let dir = tempdir();
let expired = now_ms() - 1;
let path = write(
&dir,
"codex.json",
r#"{"tokens":{"access_token":"a.b.c","refresh_token":"r","account_id":"acct"},"last_refresh":"2020-01-01T00:00:00Z"}"#,
);
let login = StoredLogin::at("openai-codex", vec![path]);
assert!(login.credential().is_ok());
let path = write(
&dir,
".claude/.credentials.json",
&format!(
r#"{{"claudeAiOauth":{{"accessToken":"SECRET-SENTINEL-DO-NOT-PRINT","refreshToken":"r","expiresAt":{expired}}}}}"#
),
);
let login = StoredLogin::at("claude-code", vec![path.clone()]);
assert!(login.is_usable());
assert!(!login.refreshes());
let err = login.credential().unwrap_err();
assert_eq!(err.class_name(), "AuthError");
assert_eq!(err.code(), "auth");
assert!(matches!(
err,
AuthError::Expired {
refreshable: true,
..
}
));
let text = err.to_string();
assert!(text.contains("/login"), "{text}");
assert!(!text.contains("SENTINEL"));
let lm15: crate::errors::Lm15Error = err.into();
assert_eq!(lm15.class_name(), "AuthError");
std::fs::write(
&path,
format!(r#"{{"claudeAiOauth":{{"accessToken":"x","expiresAt":{expired}}}}}"#),
)
.unwrap();
assert!(!login.is_usable());
assert!(matches!(
login.credential().unwrap_err(),
AuthError::Expired {
refreshable: false,
..
}
));
}
#[test]
fn missing_file_is_not_configured_with_the_hint() {
let login = StoredLogin::at("claude-code", vec![PathBuf::from("/nonexistent/x.json")]);
assert!(!login.is_usable());
let err = login.credential().unwrap_err();
assert_eq!(err.class_name(), "NotConfiguredError");
assert!(err.to_string().contains("/login"));
let login = StoredLogin::at("claude-code", vec![]);
assert!(login.credential().unwrap_err().to_string().contains("HOME"));
}
fn tempdir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"lm15-login-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}