Update public browser login session flow
This commit is contained in:
parent
afd7456423
commit
cec080aa98
4 changed files with 388 additions and 29 deletions
|
|
@ -617,6 +617,8 @@ struct LoginCompletionBoundaryEvidence {
|
|||
cli_contacted_coordinator: bool,
|
||||
coordinator_address: String,
|
||||
scoped_cli_session_received: bool,
|
||||
local_cli_session_file_written: bool,
|
||||
provider_tokens_persisted_locally: bool,
|
||||
provider_tokens_exposed_to_cli: bool,
|
||||
provider_tokens_sent_to_nodes: bool,
|
||||
coordinator_session_requests: u64,
|
||||
|
|
@ -724,6 +726,21 @@ struct ProjectConfig {
|
|||
coordinator: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct StoredCliSession {
|
||||
kind: String,
|
||||
coordinator: String,
|
||||
tenant: String,
|
||||
project: String,
|
||||
user: String,
|
||||
cli_session_credential_kind: String,
|
||||
token_expiry_posture: String,
|
||||
expires_at: Option<String>,
|
||||
provider_tokens_exposed_to_cli: bool,
|
||||
provider_tokens_sent_to_nodes: bool,
|
||||
created_at_unix_seconds: u64,
|
||||
}
|
||||
|
||||
fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result<Value> {
|
||||
let config = read_project_config(&cwd)?;
|
||||
let coordinator = args.scope.coordinator.or_else(|| {
|
||||
|
|
@ -737,7 +754,7 @@ fn doctor_report(args: DoctorArgs, cwd: PathBuf) -> Result<Value> {
|
|||
"cwd": cwd,
|
||||
"coordinator": coordinator,
|
||||
"coordinator_reachability": coordinator_reachability,
|
||||
"auth": auth_state_value(),
|
||||
"auth": auth_state_value(&cwd)?,
|
||||
"project": config,
|
||||
"dependencies": {
|
||||
"cargo": command_available("cargo"),
|
||||
|
|
@ -826,13 +843,61 @@ fn ping_coordinator(coordinator: &str, timeout: Duration) -> Result<Value> {
|
|||
|
||||
fn auth_status_report(args: AuthStatusArgs, cwd: PathBuf) -> Result<Value> {
|
||||
let config = read_project_config(&cwd)?;
|
||||
let stored_session = read_cli_session(&cwd)?;
|
||||
let active_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())
|
||||
})
|
||||
.unwrap_or_else(default_operator_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",
|
||||
);
|
||||
Ok(json!({
|
||||
"command": "auth status",
|
||||
"active_coordinator": args.scope.coordinator.or_else(|| config.as_ref().and_then(|config| config.coordinator.clone())).unwrap_or_else(default_operator_endpoint),
|
||||
"principal": args.scope.user,
|
||||
"tenant": args.scope.tenant,
|
||||
"project": args.scope.project,
|
||||
"session": auth_state_value(),
|
||||
"active_coordinator": active_coordinator,
|
||||
"principal": principal,
|
||||
"tenant": tenant,
|
||||
"project": project,
|
||||
"session": auth_state_value(&cwd)?,
|
||||
"project_config": config,
|
||||
}))
|
||||
}
|
||||
|
|
@ -2739,7 +2804,10 @@ fn run_cli() -> Result<()> {
|
|||
}
|
||||
Commands::Run(args) => {
|
||||
let json_output = args.json;
|
||||
let report = run_report(args, std::env::current_dir()?, session_from_env())?;
|
||||
let cwd = std::env::current_dir()?;
|
||||
let session_project = args.project.clone().unwrap_or_else(|| cwd.clone());
|
||||
let session = session_from_sources(&session_project)?;
|
||||
let report = run_report(args, cwd, session)?;
|
||||
emit_report(&report, json_output)?;
|
||||
}
|
||||
Commands::Node {
|
||||
|
|
@ -2912,6 +2980,29 @@ fn write_project_config(project: &Path, config: &ProjectConfig) -> Result<()> {
|
|||
.with_context(|| format!("failed to write {}", file.display()))
|
||||
}
|
||||
|
||||
fn read_cli_session(project: &Path) -> Result<Option<StoredCliSession>> {
|
||||
let file = session_config_file(project);
|
||||
if !file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes =
|
||||
std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let session = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("failed to parse {}", file.display()))?;
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
fn write_cli_session(project: &Path, session: &StoredCliSession) -> Result<PathBuf> {
|
||||
let file = session_config_file(project);
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&file, serde_json::to_vec_pretty(session)?)
|
||||
.with_context(|| format!("failed to write {}", file.display()))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn effective_project_scope(scope: &CliScopeArgs, config: Option<&ProjectConfig>) -> CliScopeArgs {
|
||||
CliScopeArgs {
|
||||
coordinator: scope
|
||||
|
|
@ -2949,30 +3040,48 @@ fn effective_scope_value(
|
|||
}
|
||||
}
|
||||
|
||||
fn auth_state_value() -> Value {
|
||||
fn auth_state_value(cwd: &Path) -> Result<Value> {
|
||||
match session_from_env() {
|
||||
CliSession::Anonymous => json!({
|
||||
"kind": "anonymous",
|
||||
"authenticated": false,
|
||||
"source": "environment",
|
||||
"token_expiry_posture": "no_session",
|
||||
}),
|
||||
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("DISASMER_TOKEN_EXPIRES_AT").ok();
|
||||
json!({
|
||||
Ok(json!({
|
||||
"kind": "human",
|
||||
"authenticated": true,
|
||||
"source": "DISASMER_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,
|
||||
} => json!({
|
||||
} => Ok(json!({
|
||||
"kind": "agent_public_key",
|
||||
"authenticated": true,
|
||||
"agent": agent,
|
||||
|
|
@ -2980,7 +3089,7 @@ fn auth_state_value() -> Value {
|
|||
"public_key_fingerprint": public_key_fingerprint,
|
||||
"browser_interaction_required": browser_interaction_required,
|
||||
"token_expiry_posture": "not_applicable_public_key",
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3682,6 +3791,20 @@ fn execute_browser_login_completion_for_plan(
|
|||
.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(
|
||||
&args,
|
||||
&coordinator,
|
||||
&coordinator_response,
|
||||
provider_tokens_exposed_to_cli,
|
||||
provider_tokens_sent_to_nodes,
|
||||
);
|
||||
write_cli_session(&cwd, &stored_session)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(LoginCompletionReport {
|
||||
plan,
|
||||
|
|
@ -3689,6 +3812,8 @@ fn execute_browser_login_completion_for_plan(
|
|||
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: session.requests(),
|
||||
|
|
@ -3697,6 +3822,54 @@ fn execute_browser_login_completion_for_plan(
|
|||
})
|
||||
}
|
||||
|
||||
fn stored_cli_session_from_login_response(
|
||||
args: &LoginArgs,
|
||||
coordinator: &str,
|
||||
coordinator_response: &Value,
|
||||
provider_tokens_exposed_to_cli: bool,
|
||||
provider_tokens_sent_to_nodes: bool,
|
||||
) -> 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);
|
||||
StoredCliSession {
|
||||
kind: "human".to_owned(),
|
||||
coordinator: coordinator.to_owned(),
|
||||
tenant: session
|
||||
.get("tenant")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&args.tenant)
|
||||
.to_owned(),
|
||||
project: session
|
||||
.get("project")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&args.project)
|
||||
.to_owned(),
|
||||
user: session
|
||||
.get("user")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&args.user)
|
||||
.to_owned(),
|
||||
cli_session_credential_kind: session
|
||||
.get("cli_session_credential_kind")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("CliDeviceSession")
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_interactive_browser_login(mut args: LoginArgs) -> Result<LoginCompletionReport> {
|
||||
args.browser = true;
|
||||
let callback_path = browser_callback_url();
|
||||
|
|
@ -3868,6 +4041,13 @@ fn browser_callback_timeout() -> Duration {
|
|||
Duration::from_secs(seconds)
|
||||
}
|
||||
|
||||
fn unix_timestamp_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn read_browser_callback_request(stream: &TcpStream) -> Result<BrowserCallback> {
|
||||
let mut reader = BufReader::new(
|
||||
stream
|
||||
|
|
@ -4724,6 +4904,17 @@ fn session_from_env() -> CliSession {
|
|||
CliSession::Anonymous
|
||||
}
|
||||
|
||||
fn session_from_sources(project: &Path) -> Result<CliSession> {
|
||||
let session = session_from_env();
|
||||
if session.is_authenticated() {
|
||||
return Ok(session);
|
||||
}
|
||||
if read_cli_session(project)?.is_some() {
|
||||
return Ok(CliSession::HumanSession);
|
||||
}
|
||||
Ok(CliSession::Anonymous)
|
||||
}
|
||||
|
||||
fn add_workflow_actor_fields(request: &mut Value, session: &CliSession, fallback_user: &str) {
|
||||
let Value::Object(map) = request else {
|
||||
return;
|
||||
|
|
@ -5586,6 +5777,60 @@ mod tests {
|
|||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_browser_login_session_omits_provider_token_values() {
|
||||
let Cli {
|
||||
command: Commands::Login(args),
|
||||
} = parse(&[
|
||||
"disasmer",
|
||||
"login",
|
||||
"--browser",
|
||||
"--coordinator",
|
||||
"https://coord.example.test",
|
||||
"--tenant",
|
||||
"tenant-cli",
|
||||
"--project-id",
|
||||
"project-cli",
|
||||
"--user",
|
||||
"user-cli",
|
||||
])
|
||||
else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
let stored = stored_cli_session_from_login_response(
|
||||
&args,
|
||||
"https://coord.example.test",
|
||||
&json!({
|
||||
"session": {
|
||||
"tenant": "tenant-live",
|
||||
"project": "project-live",
|
||||
"user": "user-live",
|
||||
"cli_session_credential_kind": "CliDeviceSession",
|
||||
"expires_at": "2026-07-04T00:00:00Z",
|
||||
"access_token": "provider-secret",
|
||||
"id_token": "provider-id-token",
|
||||
"provider_tokens_sent_to_nodes": false
|
||||
}
|
||||
}),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let serialized = serde_json::to_string(&stored).unwrap();
|
||||
|
||||
assert_eq!(stored.kind, "human");
|
||||
assert_eq!(stored.coordinator, "https://coord.example.test");
|
||||
assert_eq!(stored.tenant, "tenant-live");
|
||||
assert_eq!(stored.project, "project-live");
|
||||
assert_eq!(stored.user, "user-live");
|
||||
assert_eq!(stored.token_expiry_posture, "expires_at");
|
||||
assert!(stored.provider_tokens_exposed_to_cli);
|
||||
assert!(!stored.provider_tokens_sent_to_nodes);
|
||||
assert!(!serialized.contains("provider-secret"));
|
||||
assert!(!serialized.contains("provider-id-token"));
|
||||
assert!(!serialized.contains("access_token"));
|
||||
assert!(!serialized.contains("id_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_enroll_uses_public_key_without_browser_each_run() {
|
||||
let Cli {
|
||||
|
|
@ -5977,6 +6222,57 @@ mod tests {
|
|||
assert_eq!(report["coordinator_reachability"]["response"]["epoch"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_status_reads_stored_cli_session_without_provider_tokens() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
write_cli_session(
|
||||
temp.path(),
|
||||
&StoredCliSession {
|
||||
kind: "human".to_owned(),
|
||||
coordinator: "https://coord.example.test".to_owned(),
|
||||
tenant: "tenant-session".to_owned(),
|
||||
project: "project-session".to_owned(),
|
||||
user: "user-session".to_owned(),
|
||||
cli_session_credential_kind: "CliDeviceSession".to_owned(),
|
||||
token_expiry_posture: "expires_at".to_owned(),
|
||||
expires_at: Some("2026-07-04T00:00:00Z".to_owned()),
|
||||
provider_tokens_exposed_to_cli: false,
|
||||
provider_tokens_sent_to_nodes: false,
|
||||
created_at_unix_seconds: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = auth_status_report(
|
||||
AuthStatusArgs {
|
||||
scope: CliScopeArgs {
|
||||
coordinator: None,
|
||||
tenant: "tenant".to_owned(),
|
||||
project: "project".to_owned(),
|
||||
user: "user".to_owned(),
|
||||
json: false,
|
||||
},
|
||||
},
|
||||
temp.path().to_path_buf(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report["active_coordinator"], "https://coord.example.test");
|
||||
assert_eq!(report["principal"], "user-session");
|
||||
assert_eq!(report["tenant"], "tenant-session");
|
||||
assert_eq!(report["project"], "project-session");
|
||||
assert_eq!(report["session"]["kind"], "human");
|
||||
assert_eq!(report["session"]["source"], "session_file");
|
||||
assert_eq!(report["session"]["authenticated"], true);
|
||||
assert_eq!(
|
||||
report["session"]["cli_session_credential_kind"],
|
||||
"CliDeviceSession"
|
||||
);
|
||||
assert_eq!(report["session"]["token_expiry_posture"], "expires_at");
|
||||
assert_eq!(report["session"]["provider_tokens_exposed_to_cli"], false);
|
||||
assert_eq!(report["session"]["provider_tokens_exposed_to_nodes"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_first_mvp_command_surface_parses() {
|
||||
for args in [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue