Source commit: a43e907efd9d1561c23fe73499478e881f868355 Public tree identity: sha256:453dea30195485dd8939f575a69b39f4bb7acd84c4df23f8aa29b55d044f2673
780 lines
28 KiB
Rust
780 lines
28 KiB
Rust
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<String> {
|
|
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<Value> {
|
|
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 <host:port>")?;
|
|
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<String>,
|
|
pub(crate) requested_project: String,
|
|
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<Value> {
|
|
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::<Vec<_>>();
|
|
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<Value> {
|
|
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,
|
|
requested_project: args.project.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,
|
|
});
|
|
|
|
LoginPlan {
|
|
coordinator: args.coordinator,
|
|
human_flow,
|
|
}
|
|
}
|
|
|
|
fn finalize_browser_login(
|
|
args: LoginArgs,
|
|
plan: LoginPlan,
|
|
coordinator_response: Value,
|
|
coordinator_session_requests: u64,
|
|
) -> Result<LoginCompletionReport> {
|
|
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<StoredCliSession> {
|
|
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<LoginCompletionReport> {
|
|
let coordinator = args.coordinator.clone();
|
|
let mut session = JsonLineSession::connect(&coordinator)?;
|
|
let started = session.request(json!({
|
|
"type": "begin_oidc_browser_login",
|
|
"requested_project": args.project,
|
|
}))?;
|
|
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()),
|
|
requested_project: args.project.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::<u64>().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,
|
|
}
|
|
}
|