Public release release-09ca780bd67a
Source commit: 09ca780bd67a00467e78139cf38466b8201b66ca Public tree identity: sha256:2f744c260b5fc2cf074ad9129f97f87f03714902e5b9fe174128afdc342ec2d0
This commit is contained in:
commit
eb1077f380
221 changed files with 81144 additions and 0 deletions
776
crates/clusterflux-cli/src/auth.rs
Normal file
776
crates/clusterflux-cli/src/auth.rs
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
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) 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,
|
||||
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_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::<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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue