use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use serde::Serialize; use serde_json::{json, Value}; use crate::client::{ authenticated_or_local_trusted_request, stored_session_for_coordinator, JsonLineSession, }; use crate::config::{ default_hosted_coordinator_endpoint, effective_scope_value, read_cli_session, read_project_config, write_cli_session, write_project_config, ProjectConfig, StoredCliSession, }; use crate::errors::cli_error_summary; use crate::run::{session_from_env, CliSession}; use crate::ConnectSelfHostedArgs; use crate::{AuthStatusArgs, LoginArgs}; const DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS: u64 = 300; pub(crate) fn read_session_secret_from_stdin() -> Result { let mut secret = String::new(); std::io::stdin() .read_line(&mut secret) .context("failed to read self-hosted session secret from stdin")?; let secret = secret.trim_end_matches(['\r', '\n']).to_owned(); if secret.trim().is_empty() { anyhow::bail!("self-hosted session secret from stdin must not be empty"); } Ok(secret) } pub(crate) fn connect_self_hosted_report( args: ConnectSelfHostedArgs, cwd: PathBuf, session_secret: String, ) -> Result { if !args.session_secret_stdin { anyhow::bail!("self-hosted session configuration requires --session-secret-stdin"); } let coordinator = args .scope .coordinator .as_deref() .filter(|value| !value.trim().is_empty()) .context("self-hosted session configuration requires --coordinator ")?; if session_secret.trim().is_empty() { anyhow::bail!("self-hosted session secret from stdin must not be empty"); } let mut connection = JsonLineSession::connect(coordinator)?; let response = connection.request(json!({ "type": "authenticated", "session_secret": session_secret, "request": { "type": "auth_status" }, }))?; for (field, expected) in [ ("tenant", args.scope.tenant.as_str()), ("project", args.scope.project.as_str()), ("actor", args.scope.user.as_str()), ] { let actual = response.get(field).and_then(Value::as_str).unwrap_or(""); if actual != expected { anyhow::bail!( "self-hosted session {field} mismatch: coordinator returned {actual:?}, expected {expected:?}" ); } } if response.get("authenticated").and_then(Value::as_bool) != Some(true) { anyhow::bail!("self-hosted coordinator did not confirm the CLI session"); } let stored = StoredCliSession { kind: "self_hosted".to_owned(), coordinator: coordinator.to_owned(), tenant: args.scope.tenant, project: args.scope.project, user: args.scope.user, cli_session_credential_kind: "CliDeviceSession".to_owned(), session_secret: Some(session_secret), token_expiry_posture: "configured_by_self_hosted_operator".to_owned(), expires_at: None, provider_tokens_exposed_to_cli: false, provider_tokens_sent_to_nodes: false, created_at_unix_seconds: unix_timestamp_seconds(), }; let session_file = write_cli_session(&cwd, &stored)?; Ok(json!({ "command": "auth connect-self-hosted", "status": "connected", "coordinator": coordinator, "tenant": stored.tenant, "project": stored.project, "user": stored.user, "session_file": session_file, "session_secret_read_from_stdin": true, "session_secret_exposed_in_report": false, "provider_tokens_exposed_to_cli": false, "provider_tokens_sent_to_nodes": false, "coordinator_response": response, "coordinator_session_requests": connection.requests(), })) } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(crate) struct LoginPlan { pub(crate) coordinator: String, pub(crate) human_flow: LoginFlowPlan, } #[derive(Clone, Debug, PartialEq, Serialize)] pub(crate) struct LoginCompletionReport { pub(crate) plan: LoginPlan, pub(crate) boundary: LoginCompletionBoundaryEvidence, pub(crate) coordinator_response: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(crate) struct LoginCompletionBoundaryEvidence { pub(crate) cli_contacted_coordinator: bool, pub(crate) coordinator_address: String, pub(crate) scoped_cli_session_received: bool, pub(crate) local_cli_session_file_written: bool, pub(crate) provider_tokens_persisted_locally: bool, pub(crate) provider_tokens_exposed_to_cli: bool, pub(crate) provider_tokens_sent_to_nodes: bool, pub(crate) coordinator_session_requests: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(crate) enum LoginFlowPlan { Browser(HostedBrowserLoginPlan), } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub(crate) struct HostedBrowserLoginPlan { pub(crate) authorization_url: Option, pub(crate) server_owns_state: bool, pub(crate) server_owns_nonce: bool, pub(crate) pkce_required: bool, pub(crate) hosted_callback: bool, pub(crate) cli_receives_provider_authorization_code: bool, pub(crate) cli_submits_identity_claims: bool, } pub(crate) fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result { let config = read_project_config(&cwd)?; let stored_session = read_cli_session(&cwd)?; let configured_coordinator = args .scope .coordinator .clone() .or_else(|| { config .as_ref() .and_then(|config| config.coordinator.clone()) }) .or_else(|| { stored_session .as_ref() .map(|session| session.coordinator.clone()) }); let active_coordinator = configured_coordinator .clone() .unwrap_or_else(default_hosted_coordinator_endpoint); let tenant = effective_scope_value( &args.scope.tenant, config .as_ref() .map(|config| config.tenant.as_str()) .or_else(|| { stored_session .as_ref() .map(|session| session.tenant.as_str()) }), "tenant", ); let project = effective_scope_value( &args.scope.project, config .as_ref() .map(|config| config.project.as_str()) .or_else(|| { stored_session .as_ref() .map(|session| session.project.as_str()) }), "project", ); let principal = effective_scope_value( &args.scope.user, config .as_ref() .map(|config| config.user.as_str()) .or_else(|| stored_session.as_ref().map(|session| session.user.as_str())), "user", ); let session_scope_mismatch = crate::auth_scope::session_scope_mismatch( stored_session.as_ref(), &active_coordinator, &tenant, &project, &principal, ); let session_matches_project = stored_session.is_some() && session_scope_mismatch.is_none(); let coordinator_account_status = configured_coordinator .as_ref() .filter(|_| session_scope_mismatch.is_none()) .map(|coordinator| { coordinator_auth_status_summary( coordinator, &tenant, &project, &principal, stored_session.as_ref(), ) }) .unwrap_or_else(|| { if let Some(fields) = &session_scope_mismatch { return crate::auth_scope::session_scope_mismatch_status(fields); } json!({ "checked": false, "reason": "no project or session coordinator configured", "suspension_known": false, "account_state_known": false, "account_status": "unknown", "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, }) }); Ok(json!({ "command": "auth status", "active_coordinator": active_coordinator, "principal": principal, "tenant": tenant, "project": project, "session": auth_state_value(&cwd)?, "session_matches_current_project": session_matches_project, "session_scope_mismatch": session_scope_mismatch, "coordinator_account_status": coordinator_account_status, "project_config": config, })) } fn coordinator_auth_status_summary( coordinator: &str, tenant: &str, project: &str, principal: &str, stored_session: Option<&StoredCliSession>, ) -> Value { let mut session = match JsonLineSession::connect(coordinator) { Ok(session) => session, Err(error) => { let message = error.to_string(); return json!({ "checked": true, "reachable": false, "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, "next_actions": ["clusterflux doctor", "check coordinator status"], "coordinator_session_requests": 0, }); } }; let request = match authenticated_or_local_trusted_request( coordinator, stored_session, json!({ "type": "auth_status", }), json!({ "type": "auth_status", "tenant": tenant, "project": project, "actor_user": principal, }), ) { Ok(request) => request, Err(error) => { let message = error.to_string(); return json!({ "checked": false, "reachable": "not_checked", "source": "public_coordinator_api", "authenticated_for_current_project": false, "account_status": "unknown", "suspension_known": false, "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, "next_actions": ["clusterflux login --browser"], "coordinator_session_requests": 0, }); } }; let response = match session.request_allow_error(request) { Ok(response) => response, Err(error) => { let message = error.to_string(); return json!({ "checked": true, "reachable": false, "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(&message), "error": message, "next_actions": ["clusterflux doctor", "check coordinator status"], "coordinator_session_requests": session.requests(), }); } }; let coordinator_session_requests = session.requests(); if response.get("type").and_then(Value::as_str) == Some("error") { let message = response .get("message") .and_then(Value::as_str) .unwrap_or("coordinator rejected auth status"); return json!({ "checked": true, "reachable": true, "source": "public_coordinator_api", "account_status": "unknown", "suspension_known": false, "account_state_known": false, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "machine_error": cli_error_summary(message), "coordinator_response_type": "error", "next_actions": ["clusterflux doctor", "clusterflux login --browser"], "coordinator_session_requests": coordinator_session_requests, }); } let suspended = response .get("suspended") .and_then(Value::as_bool) .unwrap_or(false); let disabled = response .get("disabled") .and_then(Value::as_bool) .unwrap_or(false); let deleted = response .get("deleted") .and_then(Value::as_bool) .unwrap_or(false); let manual_review = response .get("manual_review") .and_then(Value::as_bool) .unwrap_or(false); let account_status = response .get("account_status") .and_then(Value::as_str) .map(str::to_owned) .unwrap_or_else(|| { if deleted { "deleted" } else if disabled { "disabled" } else if suspended { "suspended" } else if manual_review { "manual_review" } else { "active" } .to_owned() }); let sanitized_reason = response.get("sanitized_reason").and_then(Value::as_str); let next_actions = response .get("next_actions") .and_then(Value::as_array) .cloned() .unwrap_or_default() .into_iter() .filter_map(|value| value.as_str().map(str::to_owned)) .collect::>(); json!({ "checked": true, "reachable": true, "source": "public_coordinator_api", "used_cli_session_credential": stored_session_for_coordinator(coordinator, stored_session).is_some(), "account_status": account_status, "suspension_known": true, "account_state_known": true, "suspended": suspended, "disabled": disabled, "deleted": deleted, "manual_review": manual_review, "sanitized_reason": sanitized_reason, "next_actions": next_actions, "private_moderation_details_exposed": false, "signup_failure_details_exposed": false, "coordinator_response_type": response.get("type").and_then(Value::as_str).unwrap_or("auth_status"), "coordinator_session_requests": coordinator_session_requests, }) } pub(crate) fn non_interactive_browser_login_report(args: &LoginArgs) -> Value { let message = "browser login requires an interactive browser, but non-interactive mode is enabled"; let next_actions = vec![ "rerun without --non-interactive to open the browser", "clusterflux login --browser --plan", "use CLUSTERFLUX_AGENT_PRIVATE_KEY for automation", ]; json!({ "command": "login", "status": "authentication_required", "coordinator": args.coordinator, "non_interactive": true, "browser_requested": true, "browser_opened": false, "safe_failure": true, "message": message, "next_actions": next_actions, "machine_error": crate::auth_scope::non_interactive_auth_machine_error(message, next_actions), }) } pub(crate) fn auth_state_value(cwd: &Path) -> Result { match session_from_env()? { CliSession::Anonymous => { if let Some(session) = read_cli_session(cwd)? { return Ok(json!({ "kind": session.kind, "authenticated": true, "source": "session_file", "coordinator": session.coordinator, "tenant": session.tenant, "project": session.project, "principal": session.user, "cli_session_credential_kind": session.cli_session_credential_kind, "provider_tokens_exposed_to_cli": session.provider_tokens_exposed_to_cli, "provider_tokens_exposed_to_nodes": session.provider_tokens_sent_to_nodes, "expires_at": session.expires_at, "token_expiry_posture": session.token_expiry_posture, })); } Ok(json!({ "kind": "anonymous", "authenticated": false, "source": "environment", "token_expiry_posture": "no_session", })) } CliSession::HumanSession => { let expires_at = std::env::var("CLUSTERFLUX_TOKEN_EXPIRES_AT").ok(); Ok(json!({ "kind": "human", "authenticated": true, "source": "CLUSTERFLUX_TOKEN", "provider_tokens_exposed_to_nodes": false, "expires_at": expires_at, "token_expiry_posture": if expires_at.is_some() { "expires_at" } else { "unknown_env_token" }, })) } CliSession::AgentPublicKey { agent, public_key_fingerprint, browser_interaction_required, .. } => Ok(json!({ "kind": "agent_public_key", "authenticated": true, "agent": agent, "source": "CLUSTERFLUX_AGENT_PRIVATE_KEY", "public_key_fingerprint": public_key_fingerprint, "browser_interaction_required": browser_interaction_required, "token_expiry_posture": "not_applicable_public_key", })), } } pub(crate) fn login_plan(args: LoginArgs) -> LoginPlan { let human_flow = LoginFlowPlan::Browser(HostedBrowserLoginPlan { authorization_url: None, server_owns_state: true, server_owns_nonce: true, pkce_required: true, hosted_callback: true, cli_receives_provider_authorization_code: false, cli_submits_identity_claims: false, }); LoginPlan { coordinator: args.coordinator, human_flow, } } fn finalize_browser_login( args: LoginArgs, plan: LoginPlan, coordinator_response: Value, coordinator_session_requests: u64, ) -> Result { let coordinator = args.coordinator.clone(); let scoped_cli_session_received = coordinator_response .pointer("/session/cli_session_credential_kind") .and_then(Value::as_str) == Some("CliDeviceSession"); let provider_tokens_sent_to_nodes = coordinator_response .pointer("/session/provider_tokens_sent_to_nodes") .and_then(Value::as_bool) .unwrap_or(true); let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response); let local_cli_session_file_written = if scoped_cli_session_received { let cwd = std::env::current_dir()?; let stored_session = stored_cli_session_from_login_response( &coordinator, &coordinator_response, provider_tokens_exposed_to_cli, provider_tokens_sent_to_nodes, )?; write_cli_session(&cwd, &stored_session)?; write_project_config( &cwd, &ProjectConfig { tenant: stored_session.tenant.clone(), project: stored_session.project.clone(), user: stored_session.user.clone(), coordinator: Some(stored_session.coordinator.clone()), }, )?; true } else { false }; Ok(LoginCompletionReport { plan, boundary: LoginCompletionBoundaryEvidence { cli_contacted_coordinator: true, coordinator_address: coordinator, scoped_cli_session_received, local_cli_session_file_written, provider_tokens_persisted_locally: false, provider_tokens_exposed_to_cli, provider_tokens_sent_to_nodes, coordinator_session_requests, }, coordinator_response, }) } pub(crate) fn stored_cli_session_from_login_response( coordinator: &str, coordinator_response: &Value, provider_tokens_exposed_to_cli: bool, provider_tokens_sent_to_nodes: bool, ) -> Result { let session = coordinator_response.get("session").unwrap_or(&Value::Null); let expires_at = session .get("expires_at") .or_else(|| session.get("token_expires_at")) .and_then(Value::as_str) .map(str::to_owned) .or_else(|| { session .get("expires_at_epoch_seconds") .and_then(Value::as_u64) .map(|value| value.to_string()) }); let tenant = session .get("tenant") .and_then(Value::as_str) .context("hosted login session omitted tenant")?; let project = session .get("project") .and_then(Value::as_str) .context("hosted login session omitted project")?; let user = session .get("user") .and_then(Value::as_str) .context("hosted login session omitted user")?; let session_secret = session .get("cli_session_secret") .or_else(|| session.get("session_secret")) .and_then(Value::as_str) .context("hosted login session omitted CLI session secret")?; Ok(StoredCliSession { kind: "human".to_owned(), coordinator: coordinator.to_owned(), tenant: tenant.to_owned(), project: project.to_owned(), user: user.to_owned(), cli_session_credential_kind: session .get("cli_session_credential_kind") .and_then(Value::as_str) .unwrap_or("CliDeviceSession") .to_owned(), session_secret: Some(session_secret.to_owned()), token_expiry_posture: if expires_at.is_some() { "expires_at".to_owned() } else { "unknown_coordinator_session".to_owned() }, expires_at, provider_tokens_exposed_to_cli, provider_tokens_sent_to_nodes, created_at_unix_seconds: unix_timestamp_seconds(), }) } pub(crate) fn execute_interactive_browser_login(args: LoginArgs) -> Result { let coordinator = args.coordinator.clone(); let mut session = JsonLineSession::connect_browser_login(&coordinator)?; let started = session.request(json!({ "type": "begin_oidc_browser_login", }))?; if started.get("type").and_then(Value::as_str) != Some("oidc_browser_login_started") { anyhow::bail!("coordinator did not start a hosted browser login transaction"); } let transaction_id = started .get("transaction_id") .and_then(Value::as_str) .context("hosted login transaction omitted transaction id")? .to_owned(); let polling_secret = started .get("polling_secret") .and_then(Value::as_str) .context("hosted login transaction omitted polling secret")? .to_owned(); let authorization_url = started .get("authorization_url") .and_then(Value::as_str) .context("hosted login transaction omitted authorization URL")? .to_owned(); let loopback_test_flow = coordinator.starts_with("http://") && crate::client::is_loopback_coordinator(&coordinator) && authorization_url.starts_with("http://") && crate::client::is_loopback_coordinator(&authorization_url); if !authorization_url.starts_with("https://") && !loopback_test_flow { anyhow::bail!( "hosted login authorization URL must use HTTPS (plain HTTP is accepted only when both coordinator and identity provider are loopback test services)" ); } let plan = LoginPlan { coordinator: coordinator.clone(), human_flow: LoginFlowPlan::Browser(HostedBrowserLoginPlan { authorization_url: Some(authorization_url.clone()), server_owns_state: true, server_owns_nonce: true, pkce_required: true, hosted_callback: true, cli_receives_provider_authorization_code: false, cli_submits_identity_claims: false, }), }; eprintln!("Opening Clusterflux browser login: {authorization_url}"); eprintln!("Waiting for the hosted login callback to complete."); open_browser(&authorization_url)?; let deadline = Instant::now() + browser_login_timeout(); loop { let response = session.request(json!({ "type": "poll_oidc_browser_login", "transaction_id": transaction_id, "polling_secret": polling_secret, }))?; match response.get("type").and_then(Value::as_str) { Some("oidc_browser_login_pending") => { if Instant::now() >= deadline { anyhow::bail!("timed out waiting for hosted browser login completion"); } std::thread::sleep(Duration::from_millis(500)); } Some("oidc_browser_session") => { return finalize_browser_login(args, plan, response, session.requests()); } _ => anyhow::bail!("coordinator returned an invalid hosted login status"), } } } pub(crate) fn print_browser_login_success(report: &LoginCompletionReport) { let session = report.coordinator_response.get("session"); let tenant = session .and_then(|value| value.get("tenant")) .and_then(Value::as_str) .unwrap_or("tenant"); let project = session .and_then(|value| value.get("project")) .and_then(Value::as_str) .unwrap_or("project"); let user = session .and_then(|value| value.get("user")) .and_then(Value::as_str) .unwrap_or("user"); println!( "Signed in to {} as {user} for {tenant}/{project}.", report.plan.coordinator ); if report.boundary.scoped_cli_session_received { println!("Received a scoped CLI session from the coordinator."); } } fn open_browser(url: &str) -> Result<()> { let mut command = if let Some(command) = std::env::var_os("CLUSTERFLUX_BROWSER_OPEN_COMMAND") { Command::new(command) } else { platform_browser_command() }; command .arg(url) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); command .spawn() .with_context(|| format!("failed to start browser opener for {url}"))?; Ok(()) } #[cfg(target_os = "macos")] fn platform_browser_command() -> Command { Command::new("open") } #[cfg(target_os = "windows")] fn platform_browser_command() -> Command { let mut command = Command::new("cmd"); command.args(["/C", "start", ""]); command } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] fn platform_browser_command() -> Command { Command::new("xdg-open") } fn browser_login_timeout() -> Duration { let seconds = std::env::var("CLUSTERFLUX_BROWSER_LOGIN_TIMEOUT_SECONDS") .ok() .and_then(|value| value.parse::().ok()) .filter(|seconds| *seconds > 0) .unwrap_or(DEFAULT_BROWSER_LOGIN_TRANSACTION_TIMEOUT_SECONDS); Duration::from_secs(seconds) } fn unix_timestamp_seconds() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } pub(crate) fn contains_provider_token_field(value: &Value) -> bool { match value { Value::Object(object) => object.iter().any(|(key, value)| { matches!( key.as_str(), "access_token" | "refresh_token" | "id_token" | "provider_token" | "oauth_token" ) || contains_provider_token_field(value) }), Value::Array(items) => items.iter().any(contains_provider_token_field), _ => false, } }