use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{DateTime, Utc};
use portable_pty::CommandBuilder;
use serde::Serialize;
use crate::login_pty::{Key, PtySession, WaitError};
use crate::login_url::{extract_login_url, extract_token};
use crate::subscription::{SubscriptionProvider, SubscriptionReader};
const SUCCESS_MARKERS: &[&str] = &[
"Login successful",
"successfully authenticated",
"sk-ant-oat",
];
const FAILURE_MARKERS: &[&str] = &[
"Invalid code",
"invalid_grant",
"Authentication failed",
"Login failed",
];
#[derive(Clone, Debug)]
pub struct LoginConfig {
pub enabled: bool,
pub command: String,
pub args: Vec<String>,
pub claude_code_home: PathBuf,
pub session_ttl: Duration,
pub max_sessions: usize,
pub idle_settle: Duration,
pub url_timeout: Duration,
pub code_timeout: Duration,
}
impl Default for LoginConfig {
fn default() -> Self {
Self {
enabled: true,
command: "claude".to_string(),
args: vec!["setup-token".to_string()],
claude_code_home: PathBuf::from("/data/claude"),
session_ttl: Duration::from_secs(900),
max_sessions: 4,
idle_settle: Duration::from_millis(750),
url_timeout: Duration::from_secs(60),
code_timeout: Duration::from_secs(120),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LoginStatus {
AwaitingCode,
Authorized,
Failed,
Expired,
}
#[derive(Clone, Debug, Serialize)]
pub struct LoginView {
pub login_id: String,
pub status: LoginStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub session_expires_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug)]
pub enum LoginError {
Disabled,
TooManySessions(usize),
NotFound,
NotPending(LoginStatus),
Spawn(String),
NoUrl(String),
Storage(String),
}
impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "the login API is disabled"),
Self::TooManySessions(max) => {
write!(f, "too many pending logins (limit {max}); cancel one first")
}
Self::NotFound => write!(f, "unknown login_id"),
Self::NotPending(status) => {
write!(f, "login is not awaiting a code (status: {status:?})")
}
Self::Spawn(msg) => write!(f, "could not start the login CLI: {msg}"),
Self::NoUrl(msg) => write!(f, "no authorization URL appeared: {msg}"),
Self::Storage(msg) => write!(f, "credential directory is unusable: {msg}"),
}
}
}
impl std::error::Error for LoginError {}
struct Session {
id: String,
url: String,
deadline: DateTime<Utc>,
state: Mutex<SessionState>,
}
struct SessionState {
status: LoginStatus,
expires_at: Option<i64>,
error: Option<String>,
pty: Option<Arc<PtySession>>,
settled_at: Option<DateTime<Utc>>,
}
impl Session {
fn view(&self) -> LoginView {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
LoginView {
login_id: self.id.clone(),
status: state.status.clone(),
url: (state.status == LoginStatus::AwaitingCode).then(|| self.url.clone()),
session_expires_at: self.deadline,
expires_at: state.expires_at,
error: state.error.clone(),
}
}
}
const TERMINAL_RETENTION_SECS: i64 = 300;
const fn terminal_retention() -> chrono::Duration {
chrono::Duration::seconds(TERMINAL_RETENTION_SECS)
}
#[derive(Clone)]
pub struct LoginManager {
config: Arc<LoginConfig>,
sessions: Arc<Mutex<HashMap<String, Arc<Session>>>>,
}
impl LoginManager {
#[must_use]
pub fn new(config: LoginConfig) -> Self {
Self {
config: Arc::new(config),
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
#[must_use]
pub fn config(&self) -> &LoginConfig {
&self.config
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub async fn begin(&self) -> Result<LoginView, LoginError> {
if !self.config.enabled {
return Err(LoginError::Disabled);
}
self.sweep();
let pending = self.count_pending();
if pending >= self.config.max_sessions {
return Err(LoginError::TooManySessions(self.config.max_sessions));
}
ensure_writable_dir(&self.config.claude_code_home)?;
let config = Arc::clone(&self.config);
let (pty, url) = tokio::task::spawn_blocking(move || spawn_and_wait_for_url(&config))
.await
.map_err(|e| LoginError::Spawn(e.to_string()))??;
let id = uuid::Uuid::new_v4().to_string();
let session = Arc::new(Session {
id: id.clone(),
url,
deadline: Utc::now()
+ chrono::Duration::from_std(self.config.session_ttl)
.unwrap_or_else(|_| chrono::Duration::seconds(900)),
state: Mutex::new(SessionState {
status: LoginStatus::AwaitingCode,
expires_at: None,
error: None,
pty: Some(pty),
settled_at: None,
}),
});
self.lock_sessions().insert(id, Arc::clone(&session));
Ok(session.view())
}
#[must_use]
pub fn status(&self, id: &str) -> Option<LoginView> {
self.sweep();
let session = self.lock_sessions().get(id).map(Arc::clone)?;
Some(session.view())
}
pub async fn submit_code(&self, id: &str, code: &str) -> Result<LoginView, LoginError> {
if !self.config.enabled {
return Err(LoginError::Disabled);
}
self.sweep();
let session = self
.lock_sessions()
.get(id)
.map(Arc::clone)
.ok_or(LoginError::NotFound)?;
let pty = {
let state = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.status != LoginStatus::AwaitingCode {
return Err(LoginError::NotPending(state.status.clone()));
}
state.pty.clone().ok_or(LoginError::NotFound)?
};
let config = Arc::clone(&self.config);
let code = code.trim().to_string();
let outcome =
tokio::task::spawn_blocking(move || submit_and_finalize(&config, &pty, &code))
.await
.map_err(|e| LoginError::Spawn(e.to_string()))?;
Self::finish(&session, outcome);
Ok(session.view())
}
#[must_use]
pub fn cancel(&self, id: &str) -> bool {
self.lock_sessions().remove(id).is_some_and(|session| {
Self::release(&session);
true
})
}
#[must_use]
pub fn pending_count(&self) -> usize {
self.sweep();
self.count_pending()
}
pub fn sweep(&self) {
let now = Utc::now();
let sessions: Vec<Arc<Session>> = self.lock_sessions().values().map(Arc::clone).collect();
let mut evict = Vec::new();
for session in sessions {
let mut state = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let expired = state.status == LoginStatus::AwaitingCode && session.deadline <= now;
if expired {
state.status = LoginStatus::Expired;
state.error = Some("login session expired before a code was submitted".into());
state.pty = None; state.settled_at = Some(now);
} else if state
.settled_at
.is_some_and(|at| now - at >= terminal_retention())
{
evict.push(session.id.clone());
}
drop(state);
if expired {
tracing::info!("login session {} expired", session.id);
}
}
if !evict.is_empty() {
let mut sessions = self.lock_sessions();
for id in evict {
sessions.remove(&id);
}
}
}
fn finish(session: &Arc<Session>, outcome: Outcome) {
let mut state = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match outcome {
Outcome::Authorized { expires_at } => {
state.status = LoginStatus::Authorized;
state.expires_at = expires_at;
state.error = None;
}
Outcome::Failed(error) => {
state.status = LoginStatus::Failed;
state.error = Some(error);
}
}
state.pty = None;
state.settled_at = Some(Utc::now());
}
fn release(session: &Arc<Session>) {
let mut state = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.pty = None;
if state.status == LoginStatus::AwaitingCode {
state.status = LoginStatus::Failed;
state.error = Some("login session cancelled".into());
}
}
fn count_pending(&self) -> usize {
self.lock_sessions()
.values()
.filter(|session| {
session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.status
== LoginStatus::AwaitingCode
})
.count()
}
fn lock_sessions(&self) -> std::sync::MutexGuard<'_, HashMap<String, Arc<Session>>> {
self.sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
enum Outcome {
Authorized { expires_at: Option<i64> },
Failed(String),
}
fn spawn_and_wait_for_url(config: &LoginConfig) -> Result<(Arc<PtySession>, String), LoginError> {
let mut command = CommandBuilder::new(&config.command);
for arg in &config.args {
command.arg(arg);
}
command.env("CLAUDE_CONFIG_DIR", &config.claude_code_home);
if let Some(parent) = config.claude_code_home.parent() {
command.env("HOME", parent);
}
command.env("TERM", "xterm-256color");
if let Ok(path) = std::env::var("PATH") {
command.env("PATH", path);
}
let session =
Arc::new(PtySession::spawn(command).map_err(|e| LoginError::Spawn(e.to_string()))?);
match session.wait_for(
|text| extract_login_url(text).is_some(),
config.idle_settle,
config.url_timeout,
) {
Ok(text) => {
let url = extract_login_url(&text)
.ok_or_else(|| LoginError::NoUrl(session.transcript_tail(400)))?;
Ok((session, url))
}
Err(err) => {
let detail = match err {
WaitError::Timeout => {
format!("timed out; last output: {}", session.transcript_tail(400))
}
WaitError::ChildExited(_) => {
format!("{err}; last output: {}", session.transcript_tail(400))
}
};
session.kill();
Err(LoginError::NoUrl(detail))
}
}
}
fn submit_and_finalize(config: &LoginConfig, pty: &PtySession, code: &str) -> Outcome {
if let Err(e) = pty.send_text(code).and_then(|()| pty.send_key(Key::Enter)) {
return Outcome::Failed(format!("could not send the code to the login process: {e}"));
}
let _ = pty.wait_for(
|text| {
SUCCESS_MARKERS.iter().any(|m| text.contains(m))
|| FAILURE_MARKERS.iter().any(|m| text.contains(m))
},
config.idle_settle,
config.code_timeout,
);
if !pty.is_running() {
let _ = pty.wait_for_exit(Duration::from_secs(1));
}
let transcript = pty.transcript();
if let Some(credential) = read_credential(&config.claude_code_home) {
return Outcome::Authorized {
expires_at: credential.expires_at,
};
}
if let Some(token) = extract_token(&transcript) {
return match write_credential(&config.claude_code_home, &token) {
Ok(()) => Outcome::Authorized { expires_at: None },
Err(e) => Outcome::Failed(format!(
"login succeeded but the credential could not be saved: {e}"
)),
};
}
let failure = FAILURE_MARKERS
.iter()
.find(|marker| transcript.contains(**marker))
.map_or_else(
|| {
format!(
"no credential was produced; last output: {}",
excerpt(&transcript, code, 400)
)
},
|marker| {
format!(
"login rejected ({marker}); last output: {}",
excerpt(&transcript, code, 400)
)
},
);
Outcome::Failed(failure)
}
struct FoundCredential {
expires_at: Option<i64>,
}
fn read_credential(home: &Path) -> Option<FoundCredential> {
let reader = SubscriptionReader::new(SubscriptionProvider::Claude, home);
reader.read_token().ok().map(|token| FoundCredential {
expires_at: token.expires_at_ms,
})
}
fn write_credential(home: &Path, token: &str) -> std::io::Result<()> {
std::fs::create_dir_all(home)?;
let path = home.join(".credentials.json");
let body = serde_json::json!({
"claudeAiOauth": {
"accessToken": token,
"subscriptionType": "max",
}
});
std::fs::write(&path, serde_json::to_vec_pretty(&body)?)?;
restrict_permissions(&path);
Ok(())
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &Path) {}
fn ensure_writable_dir(home: &Path) -> Result<(), LoginError> {
std::fs::create_dir_all(home).map_err(|e| {
LoginError::Storage(format!(
"{} is not writable ({e}); mount it read-write to use the login API",
home.display()
))
})?;
let probe = home.join(".router-login-write-probe");
std::fs::write(&probe, b"").map_err(|e| {
LoginError::Storage(format!(
"{} is not writable ({e}); mount it read-write to use the login API",
home.display()
))
})?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
fn excerpt(text: &str, code: &str, limit: usize) -> String {
let redacted = crate::login_url::redact_value(&crate::login_url::redact_secrets(text), code);
tail(&redacted, limit)
}
fn tail(text: &str, limit: usize) -> String {
let trimmed = text.trim();
let count = trimmed.chars().count();
if count <= limit {
return trimmed.to_string();
}
trimmed.chars().skip(count - limit).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn settled_session(id: &str, status: LoginStatus, age: chrono::Duration) -> Arc<Session> {
Arc::new(Session {
id: id.to_string(),
url: "https://claude.ai/oauth/authorize".to_string(),
deadline: Utc::now() + chrono::Duration::seconds(900),
state: Mutex::new(SessionState {
status,
expires_at: None,
error: None,
pty: None,
settled_at: Some(Utc::now() - age),
}),
})
}
#[test]
fn finished_sessions_are_evicted_once_their_result_has_been_retained() {
let manager = LoginManager::new(LoginConfig::default());
let fresh = settled_session("fresh", LoginStatus::Authorized, chrono::Duration::zero());
let stale = settled_session(
"stale",
LoginStatus::Failed,
terminal_retention() + chrono::Duration::seconds(1),
);
{
let mut sessions = manager.lock_sessions();
sessions.insert("fresh".to_string(), fresh);
sessions.insert("stale".to_string(), stale);
}
manager.sweep();
assert!(
manager.status("fresh").is_some(),
"a just-finished login must still be pollable"
);
assert!(
manager.status("stale").is_none(),
"a long-finished login must not occupy the registry forever"
);
}
#[test]
fn an_expired_session_becomes_evictable() {
let manager = LoginManager::new(LoginConfig::default());
let session = Arc::new(Session {
id: "gone".to_string(),
url: String::new(),
deadline: Utc::now() - chrono::Duration::seconds(1),
state: Mutex::new(SessionState {
status: LoginStatus::AwaitingCode,
expires_at: None,
error: None,
pty: None,
settled_at: None,
}),
});
manager
.lock_sessions()
.insert("gone".to_string(), Arc::clone(&session));
manager.sweep();
assert_eq!(
session.view().status,
LoginStatus::Expired,
"the TTL must still expire the session"
);
let settled = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.settled_at;
assert!(settled.is_some(), "expiry must start the retention clock");
}
#[test]
fn a_failure_excerpt_carries_neither_the_token_nor_the_pasted_code() {
let code = "authcode-9f3a2b7c";
let transcript =
format!("Paste code: {code}\nrejected\nleftover token sk-ant-oat01-SECRETVALUE0001\n");
let text = excerpt(&transcript, code, 400);
assert!(!text.contains("SECRETVALUE0001"), "{text}");
assert!(!text.contains(code), "{text}");
assert!(text.contains("rejected"), "context is still useful: {text}");
}
#[test]
fn write_credential_is_readable_by_the_oauth_reader() {
let dir = tempfile::tempdir().unwrap();
write_credential(dir.path(), "sk-ant-oat01-testtoken").unwrap();
let provider = crate::oauth::OAuthProvider::new(dir.path().to_str().unwrap());
assert_eq!(provider.get_token().unwrap(), "sk-ant-oat01-testtoken");
}
#[test]
fn ensure_writable_dir_creates_missing_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a/b/claude");
ensure_writable_dir(&nested).unwrap();
assert!(nested.is_dir());
}
#[test]
fn disabled_manager_refuses_to_begin() {
let manager = LoginManager::new(LoginConfig {
enabled: false,
..LoginConfig::default()
});
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(manager.begin());
assert!(matches!(result, Err(LoginError::Disabled)));
}
}