use std::time::Duration;
use tokio::io::AsyncReadExt;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::error::{OlError, ERR_AUTH_FLOW_FAILED, ERR_AUTH_TIMEOUT};
pub use crate::cli::AuthLoginArgs;
const DEFAULT_CLOUD_URL: &str = "https://app.openlatch.ai";
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
const EXPIRED_GRACE: Duration = Duration::from_secs(3);
const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(10);
pub fn try_open_browser(url: &str) -> bool {
#[cfg(target_os = "linux")]
{
let has_display = std::env::var("DISPLAY").is_ok();
let has_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
if !has_display && !has_wayland {
return false;
}
}
open::that(url).is_ok()
}
pub fn mask_api_key(key: &str) -> String {
if key.len() <= 11 {
return key.to_string();
}
let prefix = &key[..7];
let suffix = &key[key.len() - 4..];
format!("{prefix}...{suffix}")
}
fn url_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'+' {
out.push(' ');
i += 1;
} else if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(char::from((h * 16 + l) as u8));
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
pub fn system_hostname() -> Option<String> {
#[cfg(unix)]
{
use std::ffi::CStr;
let mut buf = [0u8; 256];
let ret = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) };
if ret != 0 {
return None;
}
let cstr = unsafe { CStr::from_ptr(buf.as_ptr() as *const libc::c_char) };
let s = cstr.to_str().ok()?.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[cfg(windows)]
{
let s = std::env::var("COMPUTERNAME").ok()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
pub fn parse_callback_params(query: &str) -> Result<(String, String, String), OlError> {
let mut api_key = String::new();
let mut org_name = String::new();
let mut org_id = String::new();
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
let k = parts.next().unwrap_or("").trim();
let v = parts.next().unwrap_or("").trim();
match k {
"key" => api_key = url_decode(v),
"org_name" => org_name = url_decode(v),
"org_id" => org_id = url_decode(v),
_ => {}
}
}
if api_key.is_empty() {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback is missing required 'key' parameter",
)
.with_suggestion("The authentication server may be misconfigured. Try again."));
}
Ok((api_key, org_name, org_id))
}
#[derive(Debug, Clone, Copy)]
pub enum Outcome {
Connected,
Expired,
Malformed,
}
struct Page {
title: &'static str,
heading: &'static str,
body: &'static str,
class: &'static str,
}
impl Outcome {
fn page(self) -> Page {
match self {
Outcome::Connected => Page {
title: "Connected",
heading: "Connected.",
body: "You can close this tab — the terminal has what it needs.",
class: "connected",
},
Outcome::Expired => Page {
title: "Link expired",
heading: "This link expired.",
body: "Run <code>openlatch auth login</code> again to start over.",
class: "settled",
},
Outcome::Malformed => Page {
title: "Open from the CLI",
heading: "This page opens from the CLI.",
body: "Run <code>openlatch auth login</code> in your terminal.",
class: "settled",
},
}
}
}
#[derive(Debug, Clone, Copy)]
enum Reply {
Connected,
BadRequest,
MethodNotAllowed,
RequestTimeout,
Expired,
}
impl Reply {
fn status(self) -> &'static str {
match self {
Reply::Connected => "200 OK",
Reply::BadRequest => "400 Bad Request",
Reply::MethodNotAllowed => "405 Method Not Allowed",
Reply::RequestTimeout => "408 Request Timeout",
Reply::Expired => "410 Gone",
}
}
fn outcome(self) -> Outcome {
match self {
Reply::Connected => Outcome::Connected,
Reply::Expired => Outcome::Expired,
Reply::BadRequest | Reply::MethodNotAllowed | Reply::RequestTimeout => {
Outcome::Malformed
}
}
}
fn extra_headers(self) -> &'static str {
match self {
Reply::MethodNotAllowed => "Allow: GET\r\n",
_ => "",
}
}
}
const MARK_SVG: &str = r#"<svg class="mark" viewBox="0 0 218.89 225.39" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenLatch">
<g transform="translate(-503.156 -640.3556)">
<path class="mk-a" data-anim fill="var(--signal)" d="M706.5,698.5v112.9h-55.7v-92.7c0-25.6-20.8-46.3-46.3-46.3h-85.7v-16.5h145.1 C687.4,655.8,706.5,674.9,706.5,698.5z"/>
<path class="mk-b" data-anim fill="var(--signal-2)" d="M518.7,807.5V694.7h55.7v92.7c0,25.6,20.8,46.3,46.3,46.3h85.7v16.5H561.4 C537.8,850.2,518.7,831.1,518.7,807.5z"/>
</g>
</svg>"#;
const PAGE_CSS: &str = r#":root{
--background:#FFFFFF;
--foreground:#0F172A;
--muted-foreground:#64748B;
--border:#E2E8F0;
--primary:#0D9488;
--primary-deep:#0F766E;
--ease-kinetic:cubic-bezier(.34,1.56,.64,1);
--ease-micro:cubic-bezier(.2,0,0,1);
--serif:Georgia,"Times New Roman",serif;
--sans:system-ui,-apple-system,"Segoe UI",sans-serif;
--mono:ui-monospace,"JetBrains Mono",Consolas,monospace;
}
@media (prefers-color-scheme:dark){
:root{
--background:#0F172A;
--foreground:#F8FAFC;
--muted-foreground:#94A3B8;
--border:#334155;
--primary:#14B8A6;
--primary-deep:#0D9488;
}
}
/* The mark carries the outcome: teal when the handshake landed, muted when
it did not. Set on body so it cascades into the inlined SVG. */
body{--signal:var(--muted-foreground);--signal-2:var(--muted-foreground)}
body.connected{--signal:var(--primary);--signal-2:var(--primary-deep)}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;display:flex;background:var(--background);color:var(--foreground);
font-family:var(--sans);-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility}
.wrap{margin:auto;padding:40px 28px;text-align:center;max-width:600px}
.mark{display:block;width:86px;height:auto;margin:0 auto 42px}
h1{font-family:var(--serif);font-weight:600;font-size:clamp(30px,5.4vw,42px);
line-height:1.1;letter-spacing:-.021em;margin:0 0 16px;text-wrap:balance}
p{margin:0 auto;max-width:48ch;color:var(--muted-foreground);font-size:15px;
line-height:1.62;text-wrap:balance}
code{font-family:var(--mono);font-size:.92em;color:var(--foreground);
border:1px solid var(--border);padding:1px 5px;white-space:nowrap}
/* One page-load beat, and only one. The implicit `to` keyframe resolves to
each element's own resting style, so nothing has to restate it. */
@keyframes latch-a{from{transform:translate(40px,-32px);opacity:0}}
@keyframes latch-b{from{transform:translate(-40px,32px);opacity:0}}
@keyframes rise{from{transform:translateY(10px);opacity:0}}
@keyframes fade{from{opacity:0}}
.mk-a{animation:latch-a 640ms var(--ease-kinetic) both}
.mk-b{animation:latch-b 640ms var(--ease-kinetic) 90ms both}
h1{animation:rise 480ms var(--ease-micro) 430ms both}
p{animation:rise 480ms var(--ease-micro) 530ms both}
/* Nothing arrived, so nothing animates as if it did. */
.settled [data-anim]{animation-name:fade!important;animation-duration:340ms!important;
animation-timing-function:ease!important}
@media (prefers-reduced-motion:reduce){
[data-anim]{animation-name:fade!important;animation-duration:300ms!important;
animation-timing-function:ease!important}
}"#;
pub fn render_page(outcome: Outcome) -> String {
let Page {
title,
heading,
body,
class,
} = outcome.page();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OpenLatch — {title}</title>
<style>
{PAGE_CSS}
</style>
</head>
<body class="{class}">
<div class="wrap">
{MARK_SVG}
<h1 data-anim>{heading}</h1>
<p data-anim>{body}</p>
</div>
</body>
</html>"#
)
}
fn build_response(reply: Reply, html: &str) -> String {
format!(
"HTTP/1.1 {status}\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Content-Length: {len}\r\n\
Cache-Control: no-store\r\n\
{extra}\
Connection: close\r\n\
\r\n\
{html}",
status = reply.status(),
extra = reply.extra_headers(),
len = html.len(),
)
}
async fn write_page(stream: &mut tokio::net::TcpStream, reply: Reply) {
use tokio::io::AsyncWriteExt;
let response = build_response(reply, &render_page(reply.outcome()));
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
pub fn keychain_backend_name() -> &'static str {
#[cfg(target_os = "macos")]
return "macOS Keychain";
#[cfg(target_os = "windows")]
return "Windows Credential Manager";
#[cfg(target_os = "linux")]
return "Linux Secret Service";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return "OS Keychain";
}
struct Rejection {
reply: Option<Reply>,
error: OlError,
}
impl Rejection {
fn silent(error: OlError) -> Self {
Rejection { reply: None, error }
}
fn answered(reply: Reply, error: OlError) -> Self {
Rejection {
reply: Some(reply),
error,
}
}
}
async fn read_request(
stream: &mut tokio::net::TcpStream,
timeout: Duration,
) -> Result<Vec<u8>, Rejection> {
let mut buf = vec![0u8; 4096];
let read = tokio::time::timeout(timeout, stream.read(&mut buf)).await;
let n = match read {
Ok(Ok(n)) => n,
Ok(Err(e)) => {
return Err(Rejection::silent(OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to read callback request: {e}"),
)))
}
Err(_elapsed) => {
return Err(Rejection::answered(
Reply::RequestTimeout,
OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback connection sent no request in time",
)
.with_suggestion("Something else may have claimed the port. Try again."),
))
}
};
if n == 0 {
return Err(Rejection::silent(
OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback received empty (truncated) HTTP request",
)
.with_suggestion("The browser may have closed the connection. Try again."),
));
}
buf.truncate(n);
Ok(buf)
}
fn parse_callback_request(raw: &[u8]) -> Result<(String, String, String), Rejection> {
let request_str = String::from_utf8_lossy(raw);
let first_line = request_str.lines().next().unwrap_or("");
if !first_line.starts_with("GET ") {
return Err(Rejection::answered(
Reply::MethodNotAllowed,
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Callback received unexpected request: {first_line}"),
),
));
}
let path_part = first_line
.trim_start_matches("GET ")
.split_whitespace()
.next()
.unwrap_or("");
let Some(pos) = path_part.find('?') else {
return Err(Rejection::answered(
Reply::BadRequest,
OlError::new(
ERR_AUTH_FLOW_FAILED,
"Callback URL missing query parameters (api_key not provided)",
)
.with_suggestion("The authentication server may be misconfigured. Try again."),
));
};
parse_callback_params(&path_part[pos + 1..])
.map_err(|e| Rejection::answered(Reply::BadRequest, e))
}
async fn handle_callback(
stream: &mut tokio::net::TcpStream,
read_timeout: Duration,
) -> Result<(String, String, String), OlError> {
let parsed = match read_request(stream, read_timeout).await {
Ok(raw) => parse_callback_request(&raw),
Err(rejection) => Err(rejection),
};
match parsed {
Ok(params) => {
write_page(stream, Reply::Connected).await;
Ok(params)
}
Err(Rejection { reply, error }) => {
if let Some(reply) = reply {
write_page(stream, reply).await;
}
Err(error)
}
}
}
async fn serve_expired_page(listener: &tokio::net::TcpListener) {
let Ok(Ok((mut stream, _))) = tokio::time::timeout(EXPIRED_GRACE, listener.accept()).await
else {
return;
};
let _ = read_request(&mut stream, REQUEST_READ_TIMEOUT).await;
write_page(&mut stream, Reply::Expired).await;
}
pub fn run_login(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
let started = std::time::Instant::now();
let result = rt.block_on(run_login_async(args, output));
let duration_ms = started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
match &result {
Ok(()) => {
crate::telemetry::capture_global(crate::telemetry::Event::auth_completed(
"browser",
duration_ms,
));
}
Err(e) => {
let stage = match e.code {
"OL-1605" => "timeout",
"OL-1606" => "callback",
_ => "other",
};
crate::telemetry::capture_global(crate::telemetry::Event::auth_failed(e.code, stage));
}
}
result
}
async fn run_login_async(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
use tokio::sync::oneshot;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to bind callback server: {e}"),
)
.with_suggestion("Check that no firewall rules block localhost connections.")
})?;
let port = listener
.local_addr()
.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to get callback port: {e}"),
)
})?
.port();
let callback_url = format!("http://127.0.0.1:{port}/callback");
let app_url = resolve_app_url();
let mut auth_url = format!(
"{}/cli-auth?callback={callback_url}",
app_url.trim_end_matches('/')
);
if let Some(h) = system_hostname() {
auth_url.push_str("&hostname=");
auth_url.push_str(&url_encode(&h));
}
let browser_opened = if args.no_browser {
false
} else {
try_open_browser(&auth_url)
};
if browser_opened {
output.print_info("Opening browser for authentication...");
} else {
output.print_info("Open the following URL in your browser to authenticate:");
}
output.print_info(&format!("\n {auth_url}\n"));
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
let spinner_handle = if output.format != OutputFormat::Json && !output.quiet {
let total_secs = CALLBACK_TIMEOUT.as_secs();
Some(tokio::spawn(async move {
let pb = indicatif::ProgressBar::new_spinner();
pb.set_draw_target(indicatif::ProgressDrawTarget::stderr());
pb.enable_steady_tick(Duration::from_millis(100));
let mut remaining = total_secs;
let mut cancel_rx = cancel_rx;
loop {
let minutes = remaining / 60;
let seconds = remaining % 60;
pb.set_message(format!(
"Waiting for authentication... ({minutes}:{seconds:02} remaining)"
));
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {
remaining = remaining.saturating_sub(1);
}
_ = &mut cancel_rx => {
pb.finish_and_clear();
break;
}
}
}
}))
} else {
drop(cancel_rx);
None
};
let accepted = tokio::time::timeout(CALLBACK_TIMEOUT, listener.accept()).await;
let _ = cancel_tx.send(());
if let Some(handle) = spinner_handle {
let _ = handle.await;
}
let (api_key, org_name, org_id) = match accepted {
Err(_timeout) => {
serve_expired_page(&listener).await;
return Err(
OlError::new(ERR_AUTH_TIMEOUT, "Authentication timed out after 5 minutes")
.with_suggestion("Run 'openlatch auth login' to try again."),
);
}
Ok(Err(e)) => {
return Err(OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to accept callback connection: {e}"),
));
}
Ok(Ok((mut stream, _))) => handle_callback(&mut stream, REQUEST_READ_TIMEOUT).await?,
};
let store = crate::core::auth::KeyringCredentialStore::new();
let secret_key = secrecy::SecretString::from(api_key.clone());
store.store_async(secret_key).await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to store API key in keychain: {}", e.message),
)
.with_suggestion("Try running 'openlatch auth login' again.")
})?;
{
let loaded = crate::core::config::Config::load(None, None, false).ok();
let api_url = loaded
.as_ref()
.map(|c| c.cloud.api_url.clone())
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string());
let egress = loaded
.as_ref()
.map(|c| c.egress.clone())
.unwrap_or_else(crate::egress::EgressConfig::direct);
let validation = validate_online_full(&api_key, &api_url, &egress).await;
if let Some(user_db_id) = validation.user_db_id.as_deref() {
if let Some(handle) = crate::telemetry::global() {
let dir = crate::config::openlatch_dir();
let agent_id = crate::core::config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_else(|| "agt_unknown".into());
let org_id_opt = if validation.org_id.is_empty() {
None
} else {
Some(validation.org_id.as_str())
};
crate::telemetry::identity::record_auth_success(
handle, &dir, &agent_id, user_db_id, org_id_opt,
);
}
}
}
notify_daemon_auth_refresh().await;
let masked = mask_api_key(&api_key);
let backend = keychain_backend_name();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"authenticated": true,
"org_name": org_name,
"org_id": org_id,
"key_prefix": masked,
"keychain_backend": backend,
});
output.print_json(&json);
} else {
output.print_step("Authenticated successfully");
if !org_name.is_empty() {
output.print_substep(&format!("Org: {org_name} ({org_id})"));
}
output.print_substep(&format!("API key: {masked}"));
output.print_substep(&format!("Stored in: {backend}"));
}
Ok(())
}
pub fn run_logout(output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
rt.block_on(run_logout_async(output))
}
async fn run_logout_async(output: &OutputConfig) -> Result<(), OlError> {
let store = crate::core::auth::KeyringCredentialStore::new();
let loaded = crate::core::config::Config::load(None, None, false).ok();
let api_url = loaded
.as_ref()
.map(|c| c.cloud.api_url.clone())
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string());
let egress = loaded
.as_ref()
.map(|c| c.egress.clone())
.unwrap_or_else(crate::egress::EgressConfig::direct);
let server_revoked = match store.retrieve_async().await {
Ok(key) => {
use secrecy::ExposeSecret;
let key_str = key.expose_secret().to_string();
attempt_server_revocation(&key_str, &api_url, &egress).await
}
Err(_) => false,
};
if let Err(e) = store.delete_async().await {
tracing::warn!(error = %e.message, "Failed to delete credential from keychain");
}
let backend = keychain_backend_name();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"logged_out": true,
"server_revoked": server_revoked,
"backend": backend,
});
output.print_json(&json);
} else {
if server_revoked {
output.print_step("API key revoked on server");
} else {
output.print_step("Server revocation failed — continuing with local cleanup");
}
output.print_substep(&format!("Credentials cleared from {backend}"));
output.print_substep("Cloud forwarding is now disabled");
output.print_info("\nRun 'openlatch auth login' to re-authenticate.");
}
Ok(())
}
pub fn run_status(output: &OutputConfig) -> Result<(), OlError> {
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})?;
rt.block_on(run_status_async(output))
}
fn pending_outbox_summary() -> String {
let path = crate::config::openlatch_dir().join("outbox.jsonl");
let Ok(content) = std::fs::read_to_string(&path) else {
return String::new();
};
let pending = content.lines().filter(|l| !l.trim().is_empty()).count();
if pending == 0 {
String::new()
} else {
format!(", {pending} event(s) waiting in the outbox")
}
}
async fn run_status_async(output: &OutputConfig) -> Result<(), OlError> {
use secrecy::ExposeSecret;
let store = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
let loaded = crate::core::config::Config::load(None, None, false).ok();
let api_url = loaded
.as_ref()
.map(|c| c.cloud.api_url.clone())
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string());
let egress = loaded
.as_ref()
.map(|c| c.egress.clone())
.unwrap_or_else(crate::egress::EgressConfig::direct);
let key_result = crate::core::auth::retrieve_credential(
&store as &dyn crate::core::auth::CredentialStore,
&file_store as &dyn crate::core::auth::CredentialStore,
);
match key_result {
Err(_) => {
if output.format == OutputFormat::Json {
output.print_json(&build_auth_status_json(false, "", "", "", "", false));
} else {
output.print_info("Not authenticated");
output.print_info(&format!(
" Cloud forwarding is paused{}. Run `openlatch auth login` to resume it.",
pending_outbox_summary()
));
}
crate::cli::report::record_exit_code(1);
}
Ok(key) => {
let key_str = key.expose_secret().to_string();
let masked = mask_api_key(&key_str);
let backend = keychain_backend_name();
let (online, org_name, org_id) = validate_online(&key_str, &api_url, &egress).await;
if output.format == OutputFormat::Json {
output.print_json(&build_auth_status_json(
true, &org_name, &org_id, &masked, backend, online,
));
} else {
output.print_step(if online {
"Authenticated (online)"
} else {
"Authenticated (offline — could not reach cloud)"
});
if !online {
crate::cli::report::record_exit_code(crate::cli::report::EXIT_DEGRADED);
}
output.print_substep(&format!("API key: {masked}"));
output.print_substep(&format!("Keychain: {backend}"));
if !org_name.is_empty() {
output.print_substep(&format!("Org: {org_name} ({org_id})"));
}
}
}
}
Ok(())
}
pub fn build_auth_status_json(
authenticated: bool,
org_name: &str,
org_id: &str,
key_prefix: &str,
keychain_backend: &str,
online: bool,
) -> serde_json::Value {
if !authenticated {
return serde_json::json!({ "authenticated": false });
}
serde_json::json!({
"authenticated": true,
"org_name": org_name,
"org_id": org_id,
"key_prefix": key_prefix,
"keychain_backend": keychain_backend,
"online": online,
})
}
pub(crate) fn make_file_store() -> crate::core::auth::FileCredentialStore {
let path = crate::core::config::openlatch_dir().join("credentials.enc");
let agent_id = load_agent_id().unwrap_or_default();
crate::core::auth::FileCredentialStore::new(path, agent_id)
}
fn load_agent_id() -> Option<String> {
let config = crate::core::config::Config::load(None, None, false).ok()?;
config.agent_id
}
async fn notify_daemon_auth_refresh() {
let cfg = match crate::core::config::Config::load(None, None, false) {
Ok(c) => c,
Err(_) => return,
};
let port = cfg.port;
let token_path = crate::core::config::openlatch_dir().join("daemon.token");
let Ok(token) = std::fs::read_to_string(&token_path) else {
return;
};
let token = token.trim().to_string();
if token.is_empty() {
return;
}
let client = match crate::egress::client_builder()
.timeout(Duration::from_secs(2))
.use_rustls_tls()
.build()
{
Ok(c) => c,
Err(_) => return,
};
let url = format!("http://127.0.0.1:{port}/admin/auth/refresh");
let _ = client.post(&url).bearer_auth(&token).send().await;
}
async fn attempt_server_revocation(
api_key: &str,
api_url: &str,
egress: &crate::egress::EgressConfig,
) -> bool {
let client = match crate::egress::build_client(crate::egress::Consumer::Auth, egress) {
Ok(c) => c,
Err(_) => return false,
};
let base = api_url.trim_end_matches('/');
let result = client
.delete(format!("{base}/api/v1/api-keys/self"))
.bearer_auth(api_key)
.send()
.await;
match result {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
fn derive_app_url_from_api(api_url: &str) -> String {
let trimmed = api_url.trim_end_matches('/');
trimmed.strip_suffix("/api").unwrap_or(trimmed).to_string()
}
fn resolve_app_url() -> String {
if let Ok(val) = std::env::var("OPENLATCH_APP_URL") {
if !val.is_empty() {
return val;
}
}
let api_url = crate::core::config::Config::load(None, None, false)
.ok()
.map(|c| c.cloud.api_url)
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string());
derive_app_url_from_api(&api_url)
}
pub async fn validate_online(
api_key: &str,
api_url: &str,
egress: &crate::egress::EgressConfig,
) -> (bool, String, String) {
let v = validate_online_full(api_key, api_url, egress).await;
(v.online, v.org_name, v.org_id)
}
#[derive(Debug, Default, Clone)]
pub struct AuthValidation {
pub online: bool,
pub rejected: bool,
pub org_name: String,
pub org_id: String,
pub user_db_id: Option<String>,
}
pub async fn validate_online_full(
api_key: &str,
api_url: &str,
egress: &crate::egress::EgressConfig,
) -> AuthValidation {
let client = match crate::egress::build_client(crate::egress::Consumer::Auth, egress) {
Ok(c) => c,
Err(_) => return AuthValidation::default(),
};
let base = api_url.trim_end_matches('/');
let result = client
.get(format!("{base}/api/v1/users/me"))
.bearer_auth(api_key)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => {
match resp.json::<serde_json::Value>().await {
Ok(body) => parse_me_response_body(&body),
Err(_) => AuthValidation {
online: true,
..Default::default()
},
}
}
Ok(resp)
if resp.status() == reqwest::StatusCode::UNAUTHORIZED
|| resp.status() == reqwest::StatusCode::FORBIDDEN =>
{
AuthValidation {
rejected: true,
..Default::default()
}
}
_ => {
AuthValidation::default()
}
}
}
fn parse_me_response_body(body: &serde_json::Value) -> AuthValidation {
let org_name = body
.get("organization_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let org_id = body
.get("organization_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let user_db_id = body
.get("user_db_id")
.or_else(|| body.get("id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
AuthValidation {
online: true,
rejected: false,
org_name,
org_id,
user_db_id,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn test_try_open_browser_headless_linux_returns_false() {
let display_orig = std::env::var("DISPLAY").ok();
let wayland_orig = std::env::var("WAYLAND_DISPLAY").ok();
unsafe {
std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
}
let result = try_open_browser("https://example.com");
unsafe {
match display_orig {
Some(v) => std::env::set_var("DISPLAY", v),
None => std::env::remove_var("DISPLAY"),
}
match wayland_orig {
Some(v) => std::env::set_var("WAYLAND_DISPLAY", v),
None => std::env::remove_var("WAYLAND_DISPLAY"),
}
}
assert!(
!result,
"Expected false on headless Linux (no DISPLAY/WAYLAND_DISPLAY)"
);
}
#[test]
fn test_derive_app_url_strips_trailing_api_suffix() {
assert_eq!(
derive_app_url_from_api("https://app.openlatch.ai/api"),
"https://app.openlatch.ai"
);
}
#[test]
fn test_derive_app_url_strips_trailing_slash_before_api() {
assert_eq!(
derive_app_url_from_api("https://app.openlatch.ai/api/"),
"https://app.openlatch.ai"
);
}
#[test]
fn test_derive_app_url_passes_through_bare_origin() {
assert_eq!(
derive_app_url_from_api("http://localhost:5173"),
"http://localhost:5173"
);
}
#[test]
fn test_derive_app_url_passes_through_bare_origin_with_trailing_slash() {
assert_eq!(
derive_app_url_from_api("http://localhost:5173/"),
"http://localhost:5173"
);
}
#[test]
fn test_derive_app_url_does_not_strip_mid_path_api_segment() {
assert_eq!(
derive_app_url_from_api("https://example.com/api/v2"),
"https://example.com/api/v2"
);
}
#[test]
fn test_url_decode_plain_string_unchanged() {
assert_eq!(url_decode("hello"), "hello");
}
#[test]
fn test_url_decode_plus_becomes_space() {
assert_eq!(url_decode("Acme+Corp"), "Acme Corp");
}
#[test]
fn test_url_decode_percent_encoded_space() {
assert_eq!(url_decode("Acme%20Corp"), "Acme Corp");
}
#[test]
fn test_url_decode_mixed_encoding() {
assert_eq!(url_decode("Acme%20Corp+Ltd"), "Acme Corp Ltd");
}
#[test]
fn test_url_decode_invalid_percent_sequence_passes_through() {
assert_eq!(url_decode("%ZZ"), "%ZZ");
}
#[test]
fn test_url_encode_alphanumeric_unchanged() {
assert_eq!(url_encode("devbox-01"), "devbox-01");
}
#[test]
fn test_url_encode_space_becomes_percent_20() {
assert_eq!(url_encode("Acme Corp"), "Acme%20Corp");
}
#[test]
fn test_url_encode_apostrophe_encoded() {
assert_eq!(url_encode("Alice's Mac"), "Alice%27s%20Mac");
}
#[test]
fn test_url_encode_unreserved_chars_passthrough() {
assert_eq!(url_encode("a-b.c_d~e"), "a-b.c_d~e");
}
#[test]
fn test_url_encode_non_ascii_utf8() {
assert_eq!(url_encode("café"), "caf%C3%A9");
}
#[test]
fn test_url_encode_roundtrips_with_url_decode() {
let input = "Alice's MacBook Pro";
assert_eq!(url_decode(&url_encode(input)), input);
}
#[test]
fn test_system_hostname_is_non_empty_when_available() {
if let Some(h) = system_hostname() {
assert!(!h.is_empty(), "system_hostname must not return Some(\"\")");
assert_eq!(
h.trim(),
h,
"system_hostname must not return padded whitespace"
);
}
}
#[test]
fn test_parse_callback_params_extracts_all_fields() {
let query = "key=ol_org_abc123&org_name=Acme&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.0, "ol_org_abc123");
assert_eq!(result.1, "Acme");
assert_eq!(result.2, "org_456");
}
#[test]
fn test_parse_callback_params_decodes_percent_encoded_org_name() {
let query = "key=ol_org_abc123&org_name=Acme%20Corp&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.1, "Acme Corp");
}
#[test]
fn test_parse_callback_params_decodes_plus_encoded_org_name() {
let query = "key=ol_org_abc123&org_name=Acme+Corp&org_id=org_456";
let result = parse_callback_params(query).expect("Should parse successfully");
assert_eq!(result.1, "Acme Corp");
}
#[test]
fn test_parse_callback_params_returns_error_on_missing_key() {
let query = "org_name=Acme&org_id=org_456";
let result = parse_callback_params(query);
assert!(result.is_err(), "Should fail when key is absent");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_parse_callback_params_returns_error_on_empty_key() {
let query = "key=&org_name=Acme&org_id=org_456";
let result = parse_callback_params(query);
assert!(result.is_err(), "Should fail when key is empty");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_parse_callback_params_returns_error_on_empty_query() {
let result = parse_callback_params("");
assert!(result.is_err(), "Should fail on empty query string");
let err = result.unwrap_err();
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn test_mask_api_key_shows_prefix_and_suffix() {
let masked = mask_api_key("ol_org_abcdef1234567890");
assert_eq!(masked, "ol_org_...7890");
}
#[test]
fn test_mask_api_key_short_key_returned_as_is() {
let masked = mask_api_key("short");
assert_eq!(masked, "short");
}
#[test]
fn test_render_page_connected_tells_the_user_to_close_the_tab() {
let html = render_page(Outcome::Connected);
assert!(
html.contains("You can close this tab"),
"Connected page must tell the user to close the tab; got: {html}"
);
}
#[test]
fn test_render_page_every_outcome_has_its_own_copy() {
for (outcome, heading) in [
(Outcome::Connected, "Connected."),
(Outcome::Expired, "This link expired."),
(Outcome::Malformed, "This page opens from the CLI."),
] {
let html = render_page(outcome);
assert!(
html.contains(heading),
"{outcome:?} must render its own heading {heading:?}; got: {html}"
);
}
}
#[test]
fn test_render_page_marks_failures_as_settled() {
assert!(render_page(Outcome::Connected).contains(r#"<body class="connected">"#));
for outcome in [Outcome::Expired, Outcome::Malformed] {
assert!(
render_page(outcome).contains(r#"<body class="settled">"#),
"{outcome:?} must render settled"
);
}
}
#[test]
fn test_render_page_makes_no_external_requests() {
for outcome in [Outcome::Connected, Outcome::Expired, Outcome::Malformed] {
let html = render_page(outcome);
for probe in ["<link", "<script", "@import", "src=", "url("] {
assert!(
!html.contains(probe),
"{outcome:?} page must not contain {probe:?} — it would fetch; got: {html}"
);
}
assert_eq!(
html.matches("http").count(),
1,
"{outcome:?} page may reference exactly one http string, the SVG xmlns"
);
assert!(html.contains(r#"xmlns="http://www.w3.org/2000/svg""#));
}
}
#[test]
fn test_render_page_is_responsive_and_theme_aware() {
let html = render_page(Outcome::Connected);
assert!(
html.contains(r#"<meta name="viewport""#),
"page must scale on a phone; got: {html}"
);
assert!(
html.contains("prefers-color-scheme:dark"),
"page must follow the browser theme; got: {html}"
);
assert!(
html.contains("prefers-reduced-motion:reduce"),
"every animation must carry a reduced-motion guard; got: {html}"
);
}
#[test]
fn test_build_response_declares_utf8_and_a_byte_accurate_length() {
let html = render_page(Outcome::Connected);
assert!(html.contains('—'), "fixture must exercise multi-byte copy");
let response = build_response(Reply::Connected, &html);
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
assert!(response.contains("Content-Type: text/html; charset=utf-8\r\n"));
assert!(response.contains(&format!("Content-Length: {}\r\n", html.len())));
let body = response
.split("\r\n\r\n")
.nth(1)
.expect("response has a body");
assert_eq!(body.len(), html.len());
}
#[test]
fn test_build_response_405_advertises_the_allowed_method() {
let response = build_response(Reply::MethodNotAllowed, &render_page(Outcome::Malformed));
assert!(response.contains("Allow: GET\r\n"), "got: {response}");
assert!(!build_response(Reply::Connected, "x").contains("Allow:"));
}
#[test]
fn test_parse_callback_request_answers_every_rejection() {
for (label, raw, want) in [
(
"non-GET",
&b"POST /callback?key=k HTTP/1.1\r\n\r\n"[..],
"405 Method Not Allowed",
),
(
"no query string",
&b"GET /callback HTTP/1.1\r\n\r\n"[..],
"400 Bad Request",
),
(
"query without key",
&b"GET /callback?state=xyz HTTP/1.1\r\n\r\n"[..],
"400 Bad Request",
),
] {
let rejection = parse_callback_request(raw)
.expect_err(&format!("{label} must be rejected"))
.reply
.unwrap_or_else(|| panic!("{label} must still answer the browser"));
assert_eq!(rejection.status(), want, "{label}");
assert!(render_page(rejection.outcome()).contains("<h1"), "{label}");
}
}
#[test]
fn test_parse_callback_request_accepts_a_well_formed_callback() {
let raw = b"GET /callback?key=ol_org_abc&org_name=Meridian&org_id=o1 HTTP/1.1\r\n\r\n";
let (key, org_name, org_id) = parse_callback_request(raw).unwrap_or_else(|e| {
panic!("well-formed callback must parse: {}", e.error.message);
});
assert_eq!(key, "ol_org_abc");
assert_eq!(org_name, "Meridian");
assert_eq!(org_id, "o1");
}
#[tokio::test]
async fn test_handle_callback_returns_error_on_truncated_request() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _stream = tokio::net::TcpStream::connect(addr).await.unwrap();
});
let (mut stream, _) = listener.accept().await.unwrap();
let result = handle_callback(&mut stream, REQUEST_READ_TIMEOUT).await;
let err = result.expect_err("Empty/truncated request should return error");
assert_eq!(
err.code, ERR_AUTH_FLOW_FAILED,
"Expected OL-1606 for truncated request, got: {}",
err.code
);
}
#[tokio::test]
async fn test_handle_callback_does_not_hang_on_a_peer_that_never_speaks() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let peer = tokio::spawn(async move {
let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
tokio::time::sleep(Duration::from_millis(400)).await;
drop(stream);
});
let (mut stream, _) = listener.accept().await.unwrap();
let result = handle_callback(&mut stream, Duration::from_millis(50)).await;
let err = result.expect_err("a silent peer must not hang the login");
assert_eq!(err.code, ERR_AUTH_FLOW_FAILED);
let _ = peer.await;
}
#[test]
fn test_build_auth_status_json_authenticated() {
let json = build_auth_status_json(
true,
"Acme Corp",
"org_123",
"ol_org_...7890",
"macOS Keychain",
true,
);
assert_eq!(json["authenticated"], true);
assert_eq!(json["org_name"], "Acme Corp");
assert_eq!(json["org_id"], "org_123");
assert_eq!(json["key_prefix"], "ol_org_...7890");
assert_eq!(json["keychain_backend"], "macOS Keychain");
assert_eq!(json["online"], true);
}
#[test]
fn test_build_auth_status_json_not_authenticated() {
let json = build_auth_status_json(false, "", "", "", "", false);
assert_eq!(json["authenticated"], false);
assert!(
json.as_object().map(|o| o.len() == 1).unwrap_or(false),
"Unauthenticated JSON should have exactly one field"
);
}
#[test]
fn test_parse_me_response_body_canonical_platform_shape() {
let body = serde_json::json!({
"id": "usr_abc",
"user_db_id": "usr_abc",
"email": "alice@example.com",
"organization_id": "org_123",
"organization_name": "Acme Corp",
});
let v = parse_me_response_body(&body);
assert!(v.online);
assert!(!v.rejected);
assert_eq!(v.org_name, "Acme Corp");
assert_eq!(v.org_id, "org_123");
assert_eq!(v.user_db_id.as_deref(), Some("usr_abc"));
}
#[test]
fn test_parse_me_response_body_user_db_id_falls_back_to_id() {
let body = serde_json::json!({
"id": "usr_xyz",
"organization_id": "org_123",
"organization_name": "Acme Corp",
});
let v = parse_me_response_body(&body);
assert_eq!(v.user_db_id.as_deref(), Some("usr_xyz"));
}
#[test]
fn test_parse_me_response_body_missing_org_fields_yield_empty_strings() {
let body = serde_json::json!({
"id": "usr_no_org",
});
let v = parse_me_response_body(&body);
assert_eq!(v.org_name, "");
assert_eq!(v.org_id, "");
assert_eq!(v.user_db_id.as_deref(), Some("usr_no_org"));
}
#[test]
fn test_run_logout_succeeds_even_when_no_credentials_stored() {
let output = OutputConfig {
format: OutputFormat::Json,
verbose: false,
debug: false,
quiet: true,
color: false,
};
let result = run_logout(&output);
assert!(
result.is_ok(),
"run_logout must succeed (fail-open) even with no stored credentials: {result:?}"
);
}
}