use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
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] = &["Loginsuccessful", "successfullyauthenticated", "sk-ant-oat"];
const FAILURE_MARKERS: &[&str] = &[
"OAutherror",
"PressEntertoretry",
"Invalidcode",
"invalid_grant",
"Authenticationfailed",
"Loginfailed",
];
const THEME_PICKER_MARKER: &str = "Choosethetextstylethatlooksbestwithyourterminal";
const WORKSPACE_TRUST_MARKER: &str = "Quicksafetycheck:Isthisaprojectyoucreated";
const LOGIN_METHOD_MARKER: &str = "Selectloginmethod:";
const READY_PROMPT_MARKER: &str = "Tipsforgettingstarted";
#[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![],
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()))?);
let result = if config.args.is_empty() {
drive_tui_to_login_url(&session, config)
} else {
wait_for_login_url(&session, config)
};
match result {
Ok(url) => Ok((session, url)),
Err(detail) => {
session.kill();
Err(LoginError::NoUrl(detail))
}
}
}
#[derive(Default)]
struct TuiProgress {
completed: Vec<TuiAction>,
}
impl TuiProgress {
fn needs(&self, action: TuiAction) -> bool {
!self.completed.contains(&action)
}
fn complete(&mut self, action: TuiAction) {
self.completed.push(action);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TuiAction {
AcceptTheme,
AcceptWorkspaceTrust,
SendLogin,
SelectLoginMethod,
}
fn next_tui_action(text: &str, progress: &TuiProgress) -> Option<TuiAction> {
let compact = compact_terminal_text(text);
if progress.needs(TuiAction::AcceptTheme) && compact.contains(THEME_PICKER_MARKER) {
Some(TuiAction::AcceptTheme)
} else if progress.needs(TuiAction::AcceptWorkspaceTrust)
&& compact.contains(WORKSPACE_TRUST_MARKER)
{
Some(TuiAction::AcceptWorkspaceTrust)
} else if progress.needs(TuiAction::SendLogin) && compact.contains(READY_PROMPT_MARKER) {
Some(TuiAction::SendLogin)
} else if progress.needs(TuiAction::SelectLoginMethod) && compact.contains(LOGIN_METHOD_MARKER)
{
Some(TuiAction::SelectLoginMethod)
} else {
None
}
}
fn compact_terminal_text(text: &str) -> String {
text.chars().filter(|ch| !ch.is_whitespace()).collect()
}
fn contains_marker(compact: &str, markers: &[&str]) -> bool {
markers.iter().any(|marker| compact.contains(marker))
}
fn drive_tui_to_login_url(session: &PtySession, config: &LoginConfig) -> Result<String, String> {
let deadline = Instant::now() + config.url_timeout;
let mut progress = TuiProgress::default();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"timed out; last output: {}",
session.transcript_tail(400)
));
}
let text = session
.wait_for(
|text| {
extract_login_url(text).is_some() || next_tui_action(text, &progress).is_some()
},
config.idle_settle,
remaining,
)
.map_err(|err| wait_error_detail(session, &err))?;
if let Some(url) = extract_login_url(&text) {
return Ok(url);
}
let action = next_tui_action(&text, &progress);
match action {
Some(TuiAction::AcceptTheme) => {
session
.send_key(Key::Enter)
.map_err(|e| format!("could not accept the Claude Code theme screen: {e}"))?;
}
Some(TuiAction::AcceptWorkspaceTrust) => {
session.send_key(Key::Enter).map_err(|e| {
format!("could not accept the Claude Code workspace trust screen: {e}")
})?;
}
Some(TuiAction::SendLogin) => {
session
.send_text("/login")
.and_then(|()| session.send_key(Key::Enter))
.map_err(|e| format!("could not type /login at the Claude Code prompt: {e}"))?;
}
Some(TuiAction::SelectLoginMethod) => {
session.send_key(Key::Enter).map_err(|e| {
format!("could not select the Claude subscription login method: {e}")
})?;
}
None => {
return Err(format!(
"Claude Code reached an unrecognized screen; last output: {}",
session.transcript_tail(400)
));
}
}
if let Some(action) = action {
progress.complete(action);
}
}
}
fn wait_for_login_url(session: &PtySession, config: &LoginConfig) -> Result<String, String> {
let text = session
.wait_for(
|text| extract_login_url(text).is_some(),
config.idle_settle,
config.url_timeout,
)
.map_err(|err| wait_error_detail(session, &err))?;
extract_login_url(&text).ok_or_else(|| {
format!(
"authorization URL disappeared; last output: {}",
session.transcript_tail(400)
)
})
}
fn wait_error_detail(session: &PtySession, err: &WaitError) -> String {
match err {
WaitError::Timeout => format!("timed out; last output: {}", session.transcript_tail(400)),
WaitError::ChildExited(_) => {
format!("{err}; last output: {}", session.transcript_tail(400))
}
}
}
fn submit_and_finalize(config: &LoginConfig, pty: &PtySession, code: &str) -> Outcome {
let send_result = if config.args.is_empty() {
pty.send_bracketed_paste(code)
} else {
pty.send_text(code)
};
if let Err(e) = send_result {
return Outcome::Failed(format!("could not send the code to the login process: {e}"));
}
if let Err(e) = pty.wait_idle(config.idle_settle, config.code_timeout) {
return Outcome::Failed(format!(
"login timed out while waiting for the pasted authorization code to settle: {e}"
));
}
if let Err(e) = pty.send_key(Key::Enter) {
return Outcome::Failed(format!("could not submit the authorization code: {e}"));
}
let verdict = pty.wait_for(
|text| {
let compact = compact_terminal_text(text);
contains_marker(&compact, SUCCESS_MARKERS) || contains_marker(&compact, FAILURE_MARKERS)
},
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 compact = compact_terminal_text(&transcript);
let failure = if contains_marker(&compact, FAILURE_MARKERS) {
format!(
"authorization code was rejected; CLI reported: {}. Request a fresh login URL and code",
rejection_verdict(&compact)
)
} else if matches!(verdict, Err(WaitError::Timeout)) {
format!(
"login timed out waiting for the CLI to accept or reject the authorization code; last output: {}",
excerpt(&transcript, code, 400)
)
} else {
format!(
"login process ended without producing a credential; last output: {}",
excerpt(&transcript, code, 400)
)
};
Outcome::Failed(failure)
}
fn rejection_verdict(compact: &str) -> String {
const STATUS_PREFIX: &str = "OAutherror:Requestfailedwithstatuscode";
if compact.contains("OAutherror:Invalidcode") {
return "OAuth error: Invalid code. Please make sure the full code was copied".into();
}
if let Some(start) = compact.rfind(STATUS_PREFIX) {
let rest = &compact[start + STATUS_PREFIX.len()..];
let status: String = rest.chars().take_while(char::is_ascii_digit).collect();
if !status.is_empty() {
return format!("OAuth error: Request failed with status code {status}");
}
}
if compact.contains("invalid_grant") {
return "OAuth error: invalid_grant".into();
}
if compact.contains("Authenticationfailed") {
return "Authentication failed".into();
}
if compact.contains("Loginfailed") {
return "Login failed".into();
}
"OAuth error: the CLI rejected the authorization code".into()
}
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::*;
#[test]
fn tui_only_types_into_recognized_screens() {
let progress = TuiProgress::default();
let rendered_theme = crate::login_pty::strip_ansi(
"Choose\x1b[9Gthe\x1b[13Gtext\x1b[18Gstyle\x1b[24Gthat\x1b[29Glooks\x1b[35Gbest\x1b[40Gwith\x1b[45Gyour\x1b[50Gterminal",
);
assert_eq!(
next_tui_action(&rendered_theme, &progress),
Some(TuiAction::AcceptTheme)
);
assert_eq!(
next_tui_action("A future, unknown onboarding screen", &progress),
None
);
}
#[test]
fn login_config_defaults_to_bare_tui() {
assert!(LoginConfig::default().args.is_empty());
}
#[test]
fn compacted_oauth_verdicts_are_restored_for_api_errors() {
assert_eq!(
rejection_verdict("promptOAutherror:Invalidcode.Pleasemakesurethefullcodewascopied"),
"OAuth error: Invalid code. Please make sure the full code was copied"
);
assert_eq!(
rejection_verdict("promptOAutherror:Requestfailedwithstatuscode400"),
"OAuth error: Request failed with status code 400"
);
}
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)));
}
}