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 package_cache: Option<PathBuf>,
pub claude_code_home: PathBuf,
pub codex_home: PathBuf,
pub codex_issuer: String,
pub codex_callback_port: u16,
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![],
package_cache: None,
claude_code_home: PathBuf::from("/data/claude"),
codex_home: PathBuf::from("/data/codex"),
codex_issuer: crate::auth::CODEX_ISSUER.to_string(),
codex_callback_port: 1455,
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,
AwaitingCallback,
AwaitingDevice,
Authorized,
Failed,
Expired,
}
#[derive(Clone, Debug, Serialize)]
pub struct LoginView {
pub login_id: String,
pub provider: SubscriptionProvider,
pub status: LoginStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_code: 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 flow: {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,
provider: SubscriptionProvider,
url: String,
user_code: Option<String>,
deadline: DateTime<Utc>,
state: Mutex<SessionState>,
}
struct SessionState {
status: LoginStatus,
expires_at: Option<i64>,
error: Option<String>,
pty: Option<Arc<PtySession>>,
claude_login: Option<crate::claude_auth::ClaudeLogin>,
auth_task: Option<tokio::task::JoinHandle<()>>,
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(),
provider: self.provider,
status: state.status.clone(),
url: matches!(
state.status,
LoginStatus::AwaitingCode
| LoginStatus::AwaitingCallback
| LoginStatus::AwaitingDevice
)
.then(|| self.url.clone()),
user_code: (state.status == LoginStatus::AwaitingDevice)
.then(|| self.user_code.clone())
.flatten(),
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> {
self.begin_for(SubscriptionProvider::Claude).await
}
pub async fn begin_for(&self, provider: SubscriptionProvider) -> 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));
}
if provider == SubscriptionProvider::Codex {
return self.begin_codex().await;
}
if provider != SubscriptionProvider::Claude {
return Err(LoginError::Spawn(format!(
"{provider} authorization is not implemented; supported providers: claude, codex"
)));
}
ensure_writable_dir(&self.config.claude_code_home)?;
let (pty, claude_login, url) = if self.config.command != "claude"
|| !self.config.args.is_empty()
{
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()))??;
(Some(pty), None, url)
} else {
let login = crate::claude_auth::ClaudeLogin::begin(
crate::claude_auth::ClaudeAuthConfig::production(
self.config.claude_code_home.clone(),
),
);
let url = login.authorization_url().to_string();
(None, Some(login), url)
};
let id = uuid::Uuid::new_v4().to_string();
let session = Arc::new(Session {
id: id.clone(),
provider,
url,
user_code: None,
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,
claude_login,
auth_task: None,
settled_at: None,
}),
});
self.lock_sessions().insert(id, Arc::clone(&session));
Ok(session.view())
}
async fn begin_codex(&self) -> Result<LoginView, LoginError> {
let codex_home = self.config.codex_home.clone();
ensure_writable_dir(&codex_home)?;
let mut codex_config = crate::auth::CodexAuthConfig::production(
codex_home,
self.config.codex_callback_port,
self.config.session_ttl,
);
codex_config.issuer.clone_from(&self.config.codex_issuer);
let login = crate::auth::CodexDeviceLogin::begin(codex_config)
.await
.map_err(LoginError::Spawn)?;
let id = uuid::Uuid::new_v4().to_string();
let session = Arc::new(Session {
id: id.clone(),
provider: SubscriptionProvider::Codex,
url: login.verification_url().to_string(),
user_code: Some(login.user_code().to_string()),
deadline: Utc::now()
+ chrono::Duration::from_std(self.config.session_ttl)
.unwrap_or_else(|_| chrono::Duration::seconds(900)),
state: Mutex::new(SessionState {
status: LoginStatus::AwaitingDevice,
expires_at: None,
error: None,
pty: None,
claude_login: None,
auth_task: None,
settled_at: None,
}),
});
self.lock_sessions().insert(id, Arc::clone(&session));
let task_session = Arc::clone(&session);
let task = tokio::spawn(async move {
let outcome = login.complete().await;
let mut state = task_session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match outcome {
Ok(_) => {
state.status = LoginStatus::Authorized;
state.error = None;
}
Err(error) => {
state.status = if Utc::now() >= task_session.deadline {
LoginStatus::Expired
} else {
LoginStatus::Failed
};
state.error = Some(error);
}
}
state.settled_at = Some(Utc::now());
});
session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.auth_task = Some(task);
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, claude_login) = {
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(), state.claude_login.clone())
};
let code = code.trim().to_string();
let outcome = if let Some(login) = claude_login {
match login.complete(&code).await {
Ok(_) => read_credential(&self.config.claude_code_home).map_or_else(
|| {
Outcome::Failed(
"native Claude OAuth did not write a readable credential".into(),
)
},
|credential| Outcome::Authorized {
expires_at: credential.expires_at,
},
),
Err(error) => Outcome::Failed(error),
}
} else if let Some(pty) = pty {
let config = Arc::clone(&self.config);
tokio::task::spawn_blocking(move || submit_and_finalize(&config, &pty, &code))
.await
.map_err(|e| LoginError::Spawn(e.to_string()))?
} else {
return Err(LoginError::NotFound);
};
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 = matches!(
state.status,
LoginStatus::AwaitingCode
| LoginStatus::AwaitingCallback
| LoginStatus::AwaitingDevice
) && session.deadline <= now;
if expired {
state.status = LoginStatus::Expired;
state.error = Some("login session expired before authorization completed".into());
state.pty = None; state.claude_login = None;
if let Some(task) = state.auth_task.take() {
task.abort();
}
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.claude_login = None;
state.auth_task = 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;
state.claude_login = None;
if let Some(task) = state.auth_task.take() {
task.abort();
}
if matches!(
state.status,
LoginStatus::AwaitingCode | LoginStatus::AwaitingCallback | LoginStatus::AwaitingDevice
) {
state.status = LoginStatus::Failed;
state.error = Some("login session cancelled".into());
}
}
fn count_pending(&self) -> usize {
self.lock_sessions()
.values()
.filter(|session| {
let status = session
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.status
.clone();
matches!(
status,
LoginStatus::AwaitingCode
| LoginStatus::AwaitingCallback
| LoginStatus::AwaitingDevice
)
})
.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 Some(cache) = &config.package_cache {
command.env("BUN_INSTALL_CACHE_DIR", cache);
}
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;