use std::time::Duration;
use tokio::io::AsyncReadExt;
use secrecy::{ExposeSecret, SecretString};
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::core::auth::org_choice;
pub use crate::core::auth::org_choice::Org;
use crate::core::auth::pkce::{Pkce, CHALLENGE_METHOD};
use crate::error::{
OlError, ERR_AUTH_FLOW_FAILED, ERR_AUTH_TIMEOUT, ERR_CLOUD_UNREACHABLE, ERR_NO_ORGANIZATION,
ERR_ORG_UNAVAILABLE, ERR_SIGN_IN_EXPIRED, ERR_SIGN_IN_THROTTLED,
};
use crate::generated::types::{CliAuthExchangeResponse, CliAuthOrganizationsResponse};
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 use crate::daemon::identity::hostname::system_hostname;
#[derive(Default, PartialEq, Eq)]
pub(crate) struct CallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
}
pub(crate) fn parse_callback_query(query: &str) -> CallbackQuery {
let mut q = CallbackQuery::default();
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
let k = parts.next().unwrap_or("").trim();
let v = url_decode(parts.next().unwrap_or("").trim());
if v.is_empty() {
continue;
}
match k {
"code" => q.code = Some(v),
"state" => q.state = Some(v),
"error" => q.error = Some(v),
_ => {}
}
}
q
}
#[derive(Debug, Clone, Copy)]
pub enum Outcome {
Connected,
Expired,
Malformed,
Refused,
}
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 system 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 system auth login</code> in your terminal.",
class: "settled",
},
Outcome::Refused => Page {
title: "Sign-in stopped",
heading: "Sign-in stopped.",
body: "Your terminal says why, and what to do next.",
class: "settled",
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reply {
Connected,
Refused,
BadRequest,
NotFound,
MethodNotAllowed,
RequestTimeout,
Expired,
}
impl Reply {
fn status(self) -> &'static str {
match self {
Reply::Connected | Reply::Refused => "200 OK",
Reply::BadRequest => "400 Bad Request",
Reply::NotFound => "404 Not Found",
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::Refused => Outcome::Refused,
Reply::Expired => Outcome::Expired,
Reply::BadRequest
| Reply::NotFound
| 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)
}
#[derive(Debug, PartialEq, Eq)]
enum CallbackDecision {
Code(String),
Refused(String),
Ignored(Reply),
}
fn classify_callback(raw: &[u8], pkce: &Pkce) -> CallbackDecision {
let request_str = String::from_utf8_lossy(raw);
let first_line = request_str.lines().next().unwrap_or("");
let Some(target) = first_line.strip_prefix("GET ") else {
return CallbackDecision::Ignored(Reply::MethodNotAllowed);
};
let target = target.split_whitespace().next().unwrap_or("");
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != "/callback" {
return CallbackDecision::Ignored(Reply::NotFound);
}
let q = parse_callback_query(query);
match q.state.as_deref() {
Some(state) if pkce.state_matches(state) => {}
_ => return CallbackDecision::Ignored(Reply::BadRequest),
}
match (q.code, q.error) {
(_, Some(error)) => CallbackDecision::Refused(error),
(Some(code), None) => CallbackDecision::Code(code),
(None, None) => CallbackDecision::Ignored(Reply::BadRequest),
}
}
fn callback_refusal(code: &str) -> OlError {
match code {
"no_organization" => no_organization(),
"too_many_codes" => OlError::new(
ERR_SIGN_IN_THROTTLED,
"Too many sign-ins are in progress for your account",
)
.with_suggestion("Wait five minutes, then run the command again."),
other => OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("The sign-in page refused the sign-in ({other})"),
)
.with_suggestion("Run the command again. If it keeps failing, update the client."),
}
}
fn no_organization() -> OlError {
OlError::new(
ERR_NO_ORGANIZATION,
"Your account belongs to no available organization",
)
.with_suggestion("Ask an admin of your organization to invite you.")
}
async fn wait_for_code(
listener: &tokio::net::TcpListener,
pkce: &Pkce,
deadline: tokio::time::Instant,
read_timeout: Duration,
) -> Result<String, OlError> {
loop {
let accepted = match tokio::time::timeout_at(deadline, listener.accept()).await {
Err(_elapsed) => {
serve_expired_page(listener).await;
return Err(OlError::new(
ERR_AUTH_TIMEOUT,
"Authentication timed out after 5 minutes",
)
.with_suggestion("Run the command again to sign in."));
}
Ok(accepted) => accepted,
};
let Ok((mut stream, _)) = accepted else {
continue;
};
let raw = match read_request(&mut stream, read_timeout).await {
Ok(raw) => raw,
Err(Rejection { reply, error }) => {
tracing::debug!(code = error.code, reason = %error.message, "loopback connection ignored");
if let Some(reply) = reply {
write_page(&mut stream, reply).await;
}
continue;
}
};
match classify_callback(&raw, pkce) {
CallbackDecision::Code(code) => {
write_page(&mut stream, Reply::Connected).await;
return Ok(code);
}
CallbackDecision::Refused(error) => {
write_page(&mut stream, Reply::Refused).await;
return Err(callback_refusal(&error));
}
CallbackDecision::Ignored(reply) => write_page(&mut stream, reply).await,
}
}
}
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;
}
const PICKER_BUDGET: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
Enroll,
Switch,
}
impl Intent {
pub fn as_str(self) -> &'static str {
match self {
Intent::Enroll => "enroll",
Intent::Switch => "switch",
}
}
}
pub enum Exchanged {
New {
key: SecretString,
organization: Org,
},
AlreadyIn(Org),
}
pub struct SignInCtx<'a> {
pub output: &'a OutputConfig,
pub api_url: String,
pub app_url: String,
pub egress: crate::egress::EgressConfig,
pub interactive: bool,
pub no_browser: bool,
}
impl<'a> SignInCtx<'a> {
pub fn from_config(output: &'a OutputConfig, interactive: bool, no_browser: bool) -> Self {
let loaded = crate::core::config::Config::load(None, None, false).ok();
Self {
output,
api_url: loaded
.as_ref()
.map(|c| c.cloud.api_url.clone())
.unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string()),
app_url: resolve_app_url(),
egress: loaded
.map(|c| c.egress)
.unwrap_or_else(crate::egress::EgressConfig::direct),
interactive,
no_browser,
}
}
fn api(&self, path: &str) -> String {
format!("{}{path}", self.api_url.trim_end_matches('/'))
}
}
pub fn pinned_organization() -> Result<Option<String>, OlError> {
pinned_organization_in(&crate::config::openlatch_dir())
}
pub(crate) fn pinned_organization_in(base: &std::path::Path) -> Result<Option<String>, OlError> {
match crate::core::policy::store::read_meta(base) {
Ok(Some(meta)) if !meta.organization_id.is_empty() => Ok(Some(meta.organization_id)),
Ok(_) => Ok(None),
Err(e) => Err(OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot read the organization this machine is enrolled in: {e}"),
)
.with_suggestion("Check the permissions of the policy directory, then re-run.")),
}
}
fn cli_auth_url(app_url: &str, port: u16, hostname: &str, pkce: &Pkce) -> String {
format!(
"{}/cli-auth?flow=code&callback=http://127.0.0.1:{port}/callback&hostname={}&state={}\
&code_challenge={}&code_challenge_method={CHALLENGE_METHOD}",
app_url.trim_end_matches('/'),
url_encode(hostname),
url_encode(&pkce.state),
url_encode(&pkce.challenge),
)
}
pub async fn sign_in_with_code(
ctx: &SignInCtx<'_>,
intent: Intent,
org_value: Option<&str>,
pinned: Option<&str>,
) -> Result<Exchanged, OlError> {
let pkce = Pkce::new()?;
let code = browser_code(ctx, &pkce).await?;
let orgs = list_organizations(ctx, &code, &pkce.verifier).await?;
let chosen = choose(ctx, org_value, &orgs)?;
match (intent, pinned) {
(Intent::Enroll, Some(p)) if chosen.id != p => {
return Err(org_choice::already_enrolled(
&org_choice::display_name(p, &orgs),
&chosen.slug,
));
}
(Intent::Switch, Some(p)) if chosen.id == p => return Ok(Exchanged::AlreadyIn(chosen)),
_ => {}
}
let previous = match intent {
Intent::Switch => pinned,
Intent::Enroll => None,
};
exchange(
ctx,
&code,
&pkce.verifier,
&chosen,
intent,
previous,
org_value,
)
.await
}
async fn browser_code(ctx: &SignInCtx<'_>, pkce: &Pkce) -> Result<String, OlError> {
use tokio::sync::oneshot;
let output = ctx.output;
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 hostname = system_hostname().unwrap_or_else(|| "unknown-host".to_string());
let auth_url = cli_auth_url(&ctx.app_url, port, &hostname, pkce);
let browser_opened = !ctx.no_browser && try_open_browser(&auth_url);
let rail = crate::cli::ui::is_active();
if rail {
crate::cli::ui::progress("Waiting for you to sign in");
if browser_opened {
crate::cli::ui::hint("A browser window opened. Sign in there to continue.");
crate::cli::ui::link("Didn't open? ", &auth_url);
} else {
crate::cli::ui::hint("Open this link in your browser to sign in.");
crate::cli::ui::link("", &auth_url);
}
} else {
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_note(&format!("\n {auth_url}\n"));
}
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
let spinner_handle = if output.format != OutputFormat::Json && !output.quiet && !rail {
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 deadline = tokio::time::Instant::now() + CALLBACK_TIMEOUT;
let result = wait_for_code(&listener, pkce, deadline, REQUEST_READ_TIMEOUT).await;
let _ = cancel_tx.send(());
if let Some(handle) = spinner_handle {
let _ = handle.await;
}
if matches!(&result, Err(e) if e.code == ERR_AUTH_TIMEOUT) {
serve_expired_page(&listener).await;
}
result
}
async fn error_code_of(resp: reqwest::Response) -> Option<String> {
let body: serde_json::Value = resp.json().await.ok()?;
body.get("error")?.get("code")?.as_str().map(str::to_string)
}
fn cloud_unreachable(e: &reqwest::Error) -> OlError {
OlError::new(
ERR_CLOUD_UNREACHABLE,
format!("Could not reach the OpenLatch platform: {e}"),
)
.with_suggestion("Check your network or proxy, then run the command again.")
}
fn sign_in_expired() -> OlError {
OlError::new(
ERR_SIGN_IN_EXPIRED,
"The sign-in expired or was already used",
)
.with_suggestion("Run the command again to sign in.")
}
fn api_refusal(status: reqwest::StatusCode, code: Option<&str>, org: Option<&Org>) -> OlError {
match (status.as_u16(), code) {
(401, _) => sign_in_expired(),
(403, Some("no_organization")) => no_organization(),
(403, Some("FORBIDDEN" | "license_expired")) => {
let name = org.map_or("The organization", |o| o.name.as_str());
OlError::new(
ERR_ORG_UNAVAILABLE,
format!("{name} is suspended, deleted, or its licence has ended"),
)
.with_suggestion("Pick another organization, or ask its admin.")
}
(s, code) => OlError::new(
ERR_AUTH_FLOW_FAILED,
format!(
"The platform refused the sign-in (HTTP {s}{})",
code.map(|c| format!(", {c}")).unwrap_or_default()
),
)
.with_suggestion("Run the command again. If it keeps failing, update the client."),
}
}
async fn list_organizations(
ctx: &SignInCtx<'_>,
code: &str,
verifier: &str,
) -> Result<Vec<Org>, OlError> {
let client = crate::egress::build_client(crate::egress::Consumer::Auth, &ctx.egress)?;
let resp = client
.get(ctx.api("/api/v1/cli-auth/organizations"))
.bearer_auth(code)
.header(VERIFIER_HEADER, verifier)
.send()
.await
.map_err(|e| cloud_unreachable(&e))?;
let status = resp.status();
if !status.is_success() {
let code = error_code_of(resp).await;
return Err(api_refusal(status, code.as_deref(), None));
}
let body: CliAuthOrganizationsResponse = resp.json().await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("The platform's organization list could not be read: {e}"),
)
})?;
let orgs: Vec<Org> = body.data.into_iter().map(Org::from).collect();
if orgs.is_empty() {
return Err(no_organization());
}
Ok(orgs)
}
const VERIFIER_HEADER: &str = "X-OpenLatch-Code-Verifier";
fn choose(ctx: &SignInCtx<'_>, org_value: Option<&str>, orgs: &[Org]) -> Result<Org, OlError> {
if let Some(value) = org_value {
return org_choice::resolve(value, orgs)
.cloned()
.map_err(|e| org_choice::match_error(value, orgs, &e));
}
match orgs {
[] => Err(no_organization()),
[only] => {
let line = format!("Organization {}", only.name);
if crate::cli::ui::is_active() {
crate::cli::ui::note(&line);
} else {
ctx.output.print_substep(&line);
}
Ok(only.clone())
}
_ if !ctx.interactive => Err(org_choice::choice_required(orgs)),
_ => pick(orgs),
}
}
fn pick(orgs: &[Org]) -> Result<Org, OlError> {
let host = system_hostname().unwrap_or_else(|| "This machine".to_string());
let line = format!("{host} will report to it and follow its policies.");
let lines = [line.as_str()];
let question = crate::cli::ui::SelectList {
title: "Organization",
question: "Which organization should this machine join?",
lines: &lines,
items: orgs
.iter()
.map(|o| (o.name.clone(), o.slug.clone()))
.collect(),
timeout: Some(PICKER_BUDGET),
};
let outcome =
crate::cli::ui::select_list(&question).unwrap_or_else(|| ask_list_without_rail(&question));
match outcome {
crate::cli::ui::ListOutcome::Answered(i) if i < orgs.len() => Ok(orgs[i].clone()),
_ => Err(org_choice::not_chosen(orgs)),
}
}
fn ask_list_without_rail(question: &crate::cli::ui::SelectList<'_>) -> crate::cli::ui::ListOutcome {
use crate::cli::ui;
let caps = ui::Caps::new(ui::Mode::Plain, false, false, 80);
ui::Rail::new(caps, ui::Out::Term(console::Term::stderr()), None, 0).select_list(question)
}
async fn exchange(
ctx: &SignInCtx<'_>,
code: &str,
verifier: &str,
chosen: &Org,
intent: Intent,
previous: Option<&str>,
org_value: Option<&str>,
) -> Result<Exchanged, OlError> {
let client = crate::egress::build_client(crate::egress::Consumer::Auth, &ctx.egress)?;
let body = serde_json::json!({
"code_verifier": verifier,
"organization_id": chosen.id,
"machine_id": crate::daemon::identity::host_id::host_id(),
"intent": intent.as_str(),
"previous_organization_id": previous,
});
let resp = client
.post(ctx.api("/api/v1/cli-auth/exchange"))
.bearer_auth(code)
.json(&body)
.send()
.await
.map_err(|e| cloud_unreachable(&e))?;
let status = resp.status();
if !status.is_success() {
let error = error_code_of(resp).await;
if status.as_u16() == 403 && error.as_deref() == Some("not_a_member") {
let fresh = list_organizations(ctx, code, verifier)
.await
.unwrap_or_default();
let value = org_value.unwrap_or(&chosen.slug);
return Err(org_choice::not_found(value, &fresh));
}
return Err(api_refusal(status, error.as_deref(), Some(chosen)));
}
let minted: CliAuthExchangeResponse = resp.json().await.map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("The platform's answer to the sign-in could not be read: {e}"),
)
})?;
let organization = Org {
id: minted.organization.id,
slug: minted
.organization
.slug
.unwrap_or_else(|| chosen.slug.clone()),
name: minted.organization.name,
};
Ok(Exchanged::New {
key: SecretString::from(minted.key),
organization,
})
}
pub(crate) enum StoredCheck {
Matches(AuthValidation),
Differs(AuthValidation),
NoUsableCredential,
}
pub(crate) async fn check_stored_credential(
ctx: &SignInCtx<'_>,
value: &str,
) -> Result<StoredCheck, OlError> {
let keychain = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
let Some((key, _)) = crate::core::auth::retrieve_stored(&keychain, &file_store) else {
return Ok(StoredCheck::NoUsableCredential);
};
let v = validate_online_full(key.expose_secret(), &ctx.api_url, &ctx.egress).await;
if v.rejected {
return Ok(StoredCheck::NoUsableCredential);
}
if !v.online {
return Err(org_choice::unverified());
}
if org_choice::names(value, &v.org_id, v.org_slug.as_deref(), &v.org_name) {
Ok(StoredCheck::Matches(v))
} else {
Ok(StoredCheck::Differs(v))
}
}
async fn store_new_key(
key: &SecretString,
api_url: &str,
egress: &crate::egress::EgressConfig,
) -> Result<(), OlError> {
let store = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
crate::core::auth::store_credential(&store, &file_store, key.clone()).map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to store API key: {}", e.message),
)
.with_suggestion("Try running 'openlatch system auth login' again.")
})?;
let validation = validate_online_full(key.expose_secret(), 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 = load_agent_id().unwrap_or_else(|| "agt_unknown".into());
let org_id_opt = (!validation.org_id.is_empty()).then_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;
Ok(())
}
async fn enroll_and_store(
ctx: &SignInCtx<'_>,
org_value: Option<&str>,
pinned: Option<&str>,
) -> Result<(Org, String), OlError> {
match sign_in_with_code(ctx, Intent::Enroll, org_value, pinned).await? {
Exchanged::New { key, organization } => {
store_new_key(&key, &ctx.api_url, &ctx.egress).await?;
Ok((organization, mask_api_key(key.expose_secret())))
}
Exchanged::AlreadyIn(organization) => Ok((organization, String::new())),
}
}
fn auth_failure_stage(code: &str) -> &'static str {
match code {
"OL-1605" => "timeout",
"OL-1606" => "callback",
crate::error::ERR_ORG_CHOICE_REQUIRED
| crate::error::ERR_ORG_NOT_CHOSEN
| crate::error::ERR_ORG_NOT_FOUND
| crate::error::ERR_ORG_AMBIGUOUS
| crate::error::ERR_ORG_KEY_MISMATCH
| crate::error::ERR_ORG_UNVERIFIED
| crate::error::ERR_ALREADY_ENROLLED
| crate::error::ERR_NO_ORGANIZATION
| crate::error::ERR_ORG_UNAVAILABLE => "org",
_ => "other",
}
}
fn with_auth_telemetry<T>(f: impl FnOnce() -> Result<T, OlError>) -> Result<T, OlError> {
let started = std::time::Instant::now();
let result = f();
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) => crate::telemetry::capture_global(crate::telemetry::Event::auth_failed(
e.code,
auth_failure_stage(e.code),
)),
}
result
}
fn runtime() -> Result<tokio::runtime::Runtime, OlError> {
tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_AUTH_FLOW_FAILED,
format!("Failed to create async runtime: {e}"),
)
})
}
pub(crate) fn enroll_for_init(
ctx: &SignInCtx<'_>,
org_value: Option<&str>,
pinned: Option<&str>,
) -> Result<Org, OlError> {
let rt = runtime()?;
let org = with_auth_telemetry(|| rt.block_on(enroll_and_store(ctx, org_value, pinned)))
.map(|(org, _)| org)?;
ctx.output
.print_step(&format!("Authenticated (org: {})", org.name));
Ok(org)
}
pub fn run_login(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
let rt = runtime()?;
with_auth_telemetry(|| rt.block_on(run_login_async(args, output)))
}
async fn run_login_async(args: &AuthLoginArgs, output: &OutputConfig) -> Result<(), OlError> {
let org_value = org_choice::requested(args.org.as_deref());
let pinned = pinned_organization()?;
let ctx = SignInCtx::from_config(
output,
crate::cli::prompt::interactive(output, false),
args.no_browser,
);
let mut reused = None;
if let (Some(value), Some(pinned_id)) = (org_value.as_deref(), pinned.as_deref()) {
match check_stored_credential(&ctx, value).await? {
StoredCheck::Differs(v) => {
let current = if v.org_name.is_empty() {
pinned_id.to_string()
} else {
v.org_name
};
return Err(org_choice::already_enrolled(¤t, value));
}
StoredCheck::Matches(v) => reused = Some(v),
StoredCheck::NoUsableCredential => {}
}
}
let (organization, masked) = match reused {
Some(v) => {
let keychain = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
let masked = crate::core::auth::retrieve_stored(&keychain, &file_store)
.map(|(key, _)| mask_api_key(key.expose_secret()))
.unwrap_or_default();
let slug = v.org_slug.clone().unwrap_or_default();
(
Org {
id: v.org_id,
slug,
name: v.org_name,
},
masked,
)
}
None => enroll_and_store(&ctx, org_value.as_deref(), pinned.as_deref()).await?,
};
let backend = keychain_backend_name();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"authenticated": true,
"org_name": organization.name,
"org_id": organization.id,
"key_prefix": masked,
"keychain_backend": backend,
});
output.print_json(&json);
} else {
output.print_step("Authenticated successfully");
output.print_substep(&format!("Org: {} ({})", organization.name, organization.id));
output.print_substep(&format!("API key: {masked}"));
output.print_substep(&format!("Stored in: {backend}"));
}
Ok(())
}
pub fn run_logout(output: &OutputConfig) -> Result<(), OlError> {
runtime()?.block_on(run_logout_async(output))
}
pub(crate) struct LogoutReport {
pub server_revoked: bool,
pub cleared: Vec<crate::core::auth::Tier>,
pub remaining: Option<crate::core::auth::Tier>,
}
pub(crate) async fn logout_stores(
primary: &dyn crate::core::auth::CredentialStore,
fallback: &dyn crate::core::auth::CredentialStore,
api_url: &str,
egress: &crate::egress::EgressConfig,
) -> LogoutReport {
let server_revoked = match crate::core::auth::retrieve_stored(primary, fallback) {
Some((key, _)) => attempt_server_revocation(key.expose_secret(), api_url, egress).await,
None => false,
};
let cleared = crate::core::auth::delete_all_credentials(primary, fallback);
primary.invalidate();
let remaining = crate::core::auth::retrieve_stored(primary, fallback).map(|(_, tier)| tier);
LogoutReport {
server_revoked,
cleared,
remaining,
}
}
async fn run_logout_async(output: &OutputConfig) -> Result<(), OlError> {
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 keychain = crate::core::auth::KeyringCredentialStore::new();
let file_store = make_file_store();
let report = logout_stores(&keychain, &file_store, &api_url, &egress).await;
if let Some(tier) = report.remaining {
let (code, place) = match tier {
crate::core::auth::Tier::Keychain => {
(crate::error::ERR_KEYCHAIN_PERMISSION, "the OS keychain")
}
crate::core::auth::Tier::File => (
crate::error::ERR_FILE_FALLBACK_ERROR,
"the encrypted credentials file",
),
};
return Err(OlError::new(
code,
format!("Could not delete the stored credential from {place}; it is still there"),
)
.with_suggestion(
"Check the permissions of the credential store, then run the command again.",
));
}
crate::telemetry::identity::record_logout(
crate::telemetry::global(),
&crate::config::openlatch_dir(),
);
notify_daemon_auth_refresh().await;
let backend = keychain_backend_name();
let cleared: Vec<&str> = report.cleared.iter().map(|t| t.as_str()).collect();
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"logged_out": true,
"server_revoked": report.server_revoked,
"backend": backend,
"credentials_cleared": cleared,
});
output.print_json(&json);
} else {
if report.server_revoked {
output.print_step("API key revoked on server");
} else {
output.print_step("Server revocation failed — continuing with local cleanup");
}
let place = |t: &crate::core::auth::Tier| match t {
crate::core::auth::Tier::Keychain => backend.to_string(),
crate::core::auth::Tier::File => "the encrypted credentials file".to_string(),
};
if report.cleared.is_empty() {
output.print_substep("No stored credentials to clear");
} else {
let places: Vec<String> = report.cleared.iter().map(place).collect();
output.print_substep(&format!(
"Credentials cleared from {}",
places.join(" and ")
));
}
output.print_substep("Cloud forwarding is now disabled");
output.print_info("\nRun 'openlatch system 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 system 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;
}
pub(crate) 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()
}
pub(crate) 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(crate) fn resolve_app_url_for(api_url: &str) -> String {
match std::env::var("OPENLATCH_APP_URL") {
Ok(val) if !val.is_empty() => val,
_ => 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 org_slug: Option<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 org_slug = body
.get("organization_slug")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.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,
org_slug,
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"
);
}
}
fn pkce() -> Pkce {
Pkce::new().expect("CSPRNG available")
}
fn get(target: &str) -> Vec<u8> {
format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").into_bytes()
}
#[test]
fn callback_query_reads_code_state_and_error() {
let q = parse_callback_query("code=olc_eu-west-1_abc&state=s%2Dx&error=&other=1");
assert_eq!(q.code.as_deref(), Some("olc_eu-west-1_abc"));
assert_eq!(q.state.as_deref(), Some("s-x"));
assert_eq!(q.error, None, "an empty value is absent");
assert!(parse_callback_query("") == CallbackQuery::default());
}
#[test]
fn callback_with_this_state_and_a_code_ends_the_wait() {
let p = pkce();
let raw = get(&format!("/callback?code=olc_c1&state={}", p.state));
assert_eq!(
classify_callback(&raw, &p),
CallbackDecision::Code("olc_c1".into())
);
}
#[test]
fn callback_with_this_state_and_an_error_ends_the_sign_in() {
let p = pkce();
let raw = get(&format!(
"/callback?error=no_organization&state={}",
p.state
));
assert_eq!(
classify_callback(&raw, &p),
CallbackDecision::Refused("no_organization".into())
);
assert_eq!(
callback_refusal("no_organization").code,
ERR_NO_ORGANIZATION
);
assert_eq!(
callback_refusal("too_many_codes").code,
ERR_SIGN_IN_THROTTLED
);
assert_eq!(callback_refusal("something_new").code, ERR_AUTH_FLOW_FAILED);
}
#[test]
fn callback_rejections_are_answered_and_ignored() {
let p = pkce();
let wrong = format!("/callback?code=olc_c1&state={}x", p.state);
let cases: Vec<(&str, Vec<u8>, Reply)> = vec![
(
"non-GET",
format!("POST /callback?state={} HTTP/1.1\r\n\r\n", p.state).into_bytes(),
Reply::MethodNotAllowed,
),
("favicon", get("/favicon.ico"), Reply::NotFound),
(
"another path with the right state",
get(&format!("/other?code=olc_c1&state={}", p.state)),
Reply::NotFound,
),
("no query", get("/callback"), Reply::BadRequest),
(
"missing state",
get("/callback?code=olc_c1"),
Reply::BadRequest,
),
("foreign state", get(&wrong), Reply::BadRequest),
(
"an old client's key callback",
get("/callback?key=openlatch_k&org_id=o1"),
Reply::BadRequest,
),
(
"the right state and nothing else",
get(&format!("/callback?state={}", p.state)),
Reply::BadRequest,
),
];
for (label, raw, want) in cases {
assert_eq!(
classify_callback(&raw, &p),
CallbackDecision::Ignored(want),
"{label}"
);
assert!(render_page(want.outcome()).contains("<h1"), "{label}");
}
assert_eq!(Reply::NotFound.status(), "404 Not Found");
assert_eq!(Reply::BadRequest.status(), "400 Bad Request");
}
async fn send(addr: std::net::SocketAddr, raw: Vec<u8>) -> String {
use tokio::io::AsyncWriteExt;
let mut s = tokio::net::TcpStream::connect(addr).await.unwrap();
s.write_all(&raw).await.unwrap();
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf).await;
String::from_utf8_lossy(&buf)
.lines()
.next()
.unwrap_or("")
.to_string()
}
#[tokio::test]
async fn callback_listener_keeps_waiting_past_strays_and_foreign_state() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let p = pkce();
let state = p.state.clone();
let client = tokio::spawn(async move {
let favicon = send(addr, get("/favicon.ico")).await;
let foreign = send(addr, get("/callback?code=olc_bad&state=nope")).await;
let missing = send(addr, get("/callback?code=olc_bad")).await;
let good = send(addr, get(&format!("/callback?code=olc_good&state={state}"))).await;
(favicon, foreign, missing, good)
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let code = wait_for_code(&listener, &p, deadline, Duration::from_secs(2))
.await
.expect("the callback with this sign-in's state is accepted");
assert_eq!(code, "olc_good");
let (favicon, foreign, missing, good) = client.await.unwrap();
assert_eq!(favicon, "HTTP/1.1 404 Not Found");
assert_eq!(foreign, "HTTP/1.1 400 Bad Request");
assert_eq!(missing, "HTTP/1.1 400 Bad Request");
assert_eq!(good, "HTTP/1.1 200 OK");
}
#[tokio::test]
async fn callback_listener_ends_on_a_refusal_with_its_error() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let p = pkce();
let state = p.state.clone();
let client = tokio::spawn(async move {
send(
addr,
get(&format!("/callback?error=too_many_codes&state={state}")),
)
.await
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let err = wait_for_code(&listener, &p, deadline, Duration::from_secs(2))
.await
.expect_err("a refusal ends the sign-in");
assert_eq!(err.code, ERR_SIGN_IN_THROTTLED);
assert_eq!(client.await.unwrap(), "HTTP/1.1 200 OK");
}
#[tokio::test]
async fn callback_listener_survives_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 p = pkce();
let state = p.state.clone();
let client = tokio::spawn(async move {
let silent = tokio::net::TcpStream::connect(addr).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
drop(silent);
send(addr, get(&format!("/callback?code=olc_ok&state={state}"))).await
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let code = wait_for_code(&listener, &p, deadline, Duration::from_millis(100))
.await
.expect("a silent peer does not abort the sign-in");
assert_eq!(code, "olc_ok");
let _ = client.await;
}
#[tokio::test]
async fn callback_listener_times_out_at_the_deadline() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_millis(50);
let err = wait_for_code(&listener, &pkce(), deadline, Duration::from_millis(50))
.await
.expect_err("nobody came");
assert_eq!(err.code, ERR_AUTH_TIMEOUT);
}
#[test]
fn callback_url_carries_the_challenge_never_the_verifier() {
let p = pkce();
let url = cli_auth_url("https://app.example.test/", 4321, "Alice's Mac", &p);
assert!(url.starts_with("https://app.example.test/cli-auth?flow=code&"));
assert!(url.contains("&callback=http://127.0.0.1:4321/callback&"));
assert!(url.contains("&hostname=Alice%27s%20Mac&"));
assert!(url.contains(&format!("&state={}&", p.state)));
assert!(url.contains(&format!("&code_challenge={}&", p.challenge)));
assert!(url.ends_with("&code_challenge_method=S256"));
assert!(!url.contains(&p.verifier));
}
#[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."),
(Outcome::Refused, "Sign-in stopped."),
] {
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, Outcome::Refused] {
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,
Outcome::Refused,
] {
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_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_slug": "acme",
"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.org_slug.as_deref(), Some("acme"));
assert_eq!(v.user_db_id.as_deref(), Some("usr_abc"));
}
#[test]
fn test_parse_me_response_body_null_slug_is_no_slug() {
let body = serde_json::json!({
"organization_id": "org_123",
"organization_slug": null,
"organization_name": "Acme Corp",
});
let v = parse_me_response_body(&body);
assert_eq!(v.org_id, "org_123");
assert_eq!(v.org_slug, None);
let typed: crate::generated::types::AuthMeResponse =
serde_json::from_value(body).expect("the schema type accepts a null slug");
assert_eq!(typed.organization_slug, None);
}
#[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"));
}
fn quiet() -> OutputConfig {
OutputConfig {
format: OutputFormat::Json,
verbose: false,
debug: false,
quiet: true,
color: false,
}
}
fn ctx<'a>(output: &'a OutputConfig, api_url: &str, interactive: bool) -> SignInCtx<'a> {
SignInCtx {
output,
api_url: api_url.to_string(),
app_url: "http://127.0.0.1:9".into(),
egress: crate::egress::EgressConfig::direct(),
interactive,
no_browser: true,
}
}
const TWO_ORGS: &str = r#"{"data":[
{"organization_id":"11111111-0000-4000-a000-000000000001","slug":"openlatch","name":"OpenLatch","role":"admin"},
{"organization_id":"11111111-0000-4000-a000-000000000002","slug":"superadmin","name":"SuperAdmin","role":"owner"}
],"user":{"email":"alice@example.com"}}"#;
fn two_orgs() -> Vec<Org> {
serde_json::from_str::<CliAuthOrganizationsResponse>(TWO_ORGS)
.unwrap()
.data
.into_iter()
.map(Org::from)
.collect()
}
#[test]
fn org_choice_schema_examples_parse_into_the_generated_types() {
for (name, raw) in [
(
"organizations",
include_str!("../../../schemas/cli-auth-organizations-response.schema.json"),
),
(
"exchange",
include_str!("../../../schemas/cli-auth-exchange-response.schema.json"),
),
] {
let schema: serde_json::Value = serde_json::from_str(raw).unwrap();
for example in schema["examples"].as_array().expect(name) {
let ok = match name {
"organizations" => {
serde_json::from_value::<CliAuthOrganizationsResponse>(example.clone())
.is_ok()
}
_ => serde_json::from_value::<CliAuthExchangeResponse>(example.clone()).is_ok(),
};
assert!(ok, "{name} example must parse: {example}");
}
}
}
#[tokio::test]
async fn org_choice_listing_sends_the_code_and_verifier() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("GET", "/api/v1/cli-auth/organizations")
.match_header("authorization", "Bearer olc_code")
.match_header("x-openlatch-code-verifier", "the-verifier")
.with_status(200)
.with_body(TWO_ORGS)
.create_async()
.await;
let out = quiet();
let orgs = list_organizations(&ctx(&out, &server.url(), false), "olc_code", "the-verifier")
.await
.expect("listing");
m.assert_async().await;
assert_eq!(orgs, two_orgs());
}
#[tokio::test]
async fn org_choice_listing_maps_the_platform_refusals() {
let mut server = mockito::Server::new_async().await;
let out = quiet();
for (status, body, want) in [
(
401,
r#"{"error":{"code":"code_invalid"}}"#,
ERR_SIGN_IN_EXPIRED,
),
(
403,
r#"{"error":{"code":"no_organization"}}"#,
ERR_NO_ORGANIZATION,
),
(200, r#"{"data":[]}"#, ERR_NO_ORGANIZATION),
(500, "oops", ERR_AUTH_FLOW_FAILED),
] {
let m = server
.mock("GET", "/api/v1/cli-auth/organizations")
.with_status(status)
.with_body(body)
.create_async()
.await;
let err = list_organizations(&ctx(&out, &server.url(), false), "olc_c", "v")
.await
.expect_err("refused");
assert_eq!(err.code, want, "{status} {body}");
m.remove_async().await;
}
}
#[tokio::test]
async fn org_choice_exchange_names_the_chosen_organization() {
let mut server = mockito::Server::new_async().await;
let m = server
.mock("POST", "/api/v1/cli-auth/exchange")
.match_header("authorization", "Bearer olc_code")
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"code_verifier": "v",
"organization_id": "11111111-0000-4000-a000-000000000002",
"intent": "switch",
"previous_organization_id": "11111111-0000-4000-a000-000000000001",
})))
.with_status(201)
.with_body(
r#"{"key":"openlatch_secret","key_prefix":"openlatch_s",
"organization":{"id":"11111111-0000-4000-a000-000000000002","slug":"superadmin","name":"SuperAdmin"}}"#,
)
.expect(1)
.create_async()
.await;
let out = quiet();
let orgs = two_orgs();
let got = exchange(
&ctx(&out, &server.url(), false),
"olc_code",
"v",
&orgs[1],
Intent::Switch,
Some(&orgs[0].id),
None,
)
.await
.expect("exchange");
m.assert_async().await;
match got {
Exchanged::New { key, organization } => {
assert_eq!(key.expose_secret(), "openlatch_secret");
assert_eq!(organization, orgs[1]);
}
Exchanged::AlreadyIn(_) => panic!("a key was minted"),
}
}
#[tokio::test]
async fn org_choice_exchange_maps_refusals_and_is_never_retried() {
let mut server = mockito::Server::new_async().await;
let out = quiet();
let orgs = two_orgs();
for (status, code, want) in [
(401, "code_invalid", ERR_SIGN_IN_EXPIRED),
(403, "FORBIDDEN", ERR_ORG_UNAVAILABLE),
(403, "license_expired", ERR_ORG_UNAVAILABLE),
(403, "no_organization", ERR_NO_ORGANIZATION),
] {
let m = server
.mock("POST", "/api/v1/cli-auth/exchange")
.with_status(status)
.with_body(format!(r#"{{"error":{{"code":"{code}","message":"m"}}}}"#))
.expect(1)
.create_async()
.await;
let err = exchange(
&ctx(&out, &server.url(), false),
"olc_c",
"v",
&orgs[0],
Intent::Enroll,
None,
None,
)
.await
.err()
.expect("refused");
assert_eq!(err.code, want, "{status} {code}");
m.assert_async().await;
m.remove_async().await;
}
}
#[tokio::test]
async fn org_choice_exchange_not_a_member_lists_the_fresh_organizations() {
let mut server = mockito::Server::new_async().await;
let _x = server
.mock("POST", "/api/v1/cli-auth/exchange")
.with_status(403)
.with_body(r#"{"error":{"code":"not_a_member"}}"#)
.create_async()
.await;
let _l = server
.mock("GET", "/api/v1/cli-auth/organizations")
.with_status(200)
.with_body(
r#"{"data":[{"organization_id":"i9","slug":"left-over","name":"Left Over"}]}"#,
)
.create_async()
.await;
let out = quiet();
let orgs = two_orgs();
let err = exchange(
&ctx(&out, &server.url(), false),
"olc_c",
"v",
&orgs[1],
Intent::Enroll,
None,
Some("SuperAdmin"),
)
.await
.err()
.expect("not a member");
assert_eq!(err.code, crate::error::ERR_ORG_NOT_FOUND);
assert_eq!(err.exit_code(), 3);
assert!(err.message.contains("\"SuperAdmin\""), "{}", err.message);
assert!(err.message.contains("left-over"), "{}", err.message);
}
#[test]
fn org_choice_choose_follows_the_c6_table() {
let out = quiet();
let orgs = two_orgs();
let headless = ctx(&out, "http://127.0.0.1:9", false);
assert_eq!(
choose(&headless, Some("SUPERADMIN"), &orgs).unwrap(),
orgs[1]
);
assert_eq!(
choose(&headless, Some(&orgs[0].id), &orgs).unwrap(),
orgs[0]
);
assert_eq!(
choose(&headless, Some("nope"), &orgs).unwrap_err().code,
crate::error::ERR_ORG_NOT_FOUND
);
assert_eq!(choose(&headless, None, &orgs[..1]).unwrap(), orgs[0]);
let e = choose(&headless, None, &orgs).unwrap_err();
assert_eq!(e.code, crate::error::ERR_ORG_CHOICE_REQUIRED);
assert!(e.message.contains("openlatch") && e.message.contains("superadmin"));
}
#[test]
fn org_choice_pin_is_read_from_the_bundle_meta() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(pinned_organization_in(dir.path()).unwrap(), None);
let policy = dir.path().join("policy");
std::fs::create_dir_all(&policy).unwrap();
std::fs::write(policy.join("bundle.meta.json"), "{not json").unwrap();
assert_eq!(
pinned_organization_in(dir.path()).unwrap_err().code,
crate::error::ERR_INVALID_CONFIG
);
std::fs::write(
policy.join("bundle.meta.json"),
r#"{"digest":"sha256:00","revision":1,"organization_id":"org-a","built_at":"2026-10-12T00:00:00Z"}"#,
)
.unwrap();
assert_eq!(
pinned_organization_in(dir.path()).unwrap().as_deref(),
Some("org-a")
);
}
#[tokio::test]
async fn logout_revokes_the_stored_key_and_clears_both_tiers() {
use crate::core::auth::{CredentialStore, InMemoryCredentialStore, Tier};
let mut server = mockito::Server::new_async().await;
let m = server
.mock("DELETE", "/api/v1/api-keys/self")
.match_header("authorization", "Bearer stored-key")
.with_status(204)
.expect(1)
.create_async()
.await;
let keychain = InMemoryCredentialStore::new();
let file = InMemoryCredentialStore::new();
file.store(SecretString::from("stored-key".to_string()))
.unwrap();
let report = logout_stores(
&keychain,
&file,
&server.url(),
&crate::egress::EgressConfig::direct(),
)
.await;
m.assert_async().await;
assert!(report.server_revoked);
assert_eq!(report.cleared, vec![Tier::File]);
assert!(crate::core::auth::retrieve_stored(&keychain, &file).is_none());
}
#[tokio::test]
async fn logout_with_nothing_stored_revokes_nothing_and_succeeds() {
use crate::core::auth::InMemoryCredentialStore;
let mut server = mockito::Server::new_async().await;
let m = server
.mock("DELETE", "/api/v1/api-keys/self")
.expect(0)
.create_async()
.await;
let report = logout_stores(
&InMemoryCredentialStore::new(),
&InMemoryCredentialStore::new(),
&server.url(),
&crate::egress::EgressConfig::direct(),
)
.await;
m.assert_async().await;
assert!(!report.server_revoked);
assert!(report.cleared.is_empty());
}
}