Update public browser login session flow

This commit is contained in:
Michel Paulissen 2026-07-04 09:37:17 +02:00
parent afd7456423
commit cec080aa98
4 changed files with 388 additions and 29 deletions

View file

@ -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 [