1use std::{collections::BTreeSet, path::Path};
4
5use anyhow::{Context, Result, bail};
6use cli_shared::UserConfig;
7use crypto::{Ed25519Signer, Signer};
8use grpc::heddle::api::v1alpha1::{
9 CreateDeviceAuthorizationRequest, CreateServiceAccountRequest, DeviceAuthProof,
10 DeviceAuthorizationResponse, ExchangeDeviceAuthorizationRequest,
11 IssueServiceAccountCredentialRequest, MintBiscuitRequest, WaitForDeviceAuthorizationRequest,
12 identity_service_client::IdentityServiceClient, mint_biscuit_request::Proof,
13};
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use tonic::{
17 Request,
18 metadata::MetadataValue,
19 transport::{Channel, Endpoint},
20};
21use weft_client_shim::{CliContext, HostedRecoveryAdvice};
22
23use crate::{
24 auth_requests::AuthCommand,
25 credential_file::{self, CredentialKind, CredentialProvenance, VerifiedCredential},
26 credentials,
27 credentials::ServerCredential,
28 device_flow::{
29 AgentAttenuation, AgentTemplate, SAFE_AGENT_OPERATIONS, attenuate_for_agent,
30 effective_pop_public_key_hex,
31 },
32 grpc_hosted::{HostedSession, operation_id::ClientOperationId},
33};
34
35#[derive(Serialize)]
39struct AuthLogoutOutput {
40 output_kind: &'static str,
41 server: String,
42 removed: bool,
43 device_identity_removed: bool,
48}
49
50#[derive(Serialize)]
51struct AuthStatusOutput {
52 output_kind: &'static str,
53 server: String,
54 authenticated: bool,
55 source: String,
58 proof_key_available: bool,
59 subject: Option<String>,
60 credential_id: Option<String>,
61 expires_at: Option<String>,
62 recommended_action: Option<String>,
63}
64
65#[derive(Serialize)]
66struct ServiceTokenOutput {
67 output_kind: &'static str,
68 name: String,
69 namespace: String,
70 scope: String,
71 credential_path: String,
74 expires_in_days: u32,
75}
76
77const DERIVED_TOKEN_SECURITY_NOTE: &str = "Derived credential has its own proof key and is operation/TTL/resource-scope-limited and enforced server-side. The token and proof key travel together inside the .hcred file; the parent device key is not exported.";
78
79const SERVICE_TOKEN_TTL_DAYS: u32 = 30;
80const SERVICE_TOKEN_TTL_SECS: i64 = SERVICE_TOKEN_TTL_DAYS as i64 * 24 * 3600;
81const ISSUE_SA_PROOF_DOMAIN: &[u8] = b"heddle-sa-credential-issue-v1";
82const ISSUE_SA_PROOF_TS_HEADER: &str = "x-heddle-issue-sa-proof-ts";
83const ISSUE_SA_PROOF_SIG_HEADER: &str = "x-heddle-issue-sa-proof-sig-bin";
84
85pub async fn cmd_auth(ctx: &dyn CliContext, command: AuthCommand) -> Result<()> {
86 match command {
87 AuthCommand::Login {
88 server,
89 open_browser,
90 credential,
91 } => match credential {
92 Some(credential) => {
93 let subject = install_credential_file(&credential)?;
94 println!("Authenticated as {subject}. Credentials saved.");
95 Ok(())
96 }
97 None => {
98 let server = resolve_server(server.as_deref())?;
99 cmd_auth_login(&server, open_browser).await
100 }
101 },
102 AuthCommand::Logout { server } => cmd_auth_logout(ctx, server.as_deref()),
103 AuthCommand::Status { server } => cmd_auth_status(ctx, server.as_deref()),
104 AuthCommand::DeriveAgent {
105 server,
106 agent_id,
107 ttl_secs,
108 scopes,
109 allowed_operations,
110 template,
111 out,
112 } => cmd_auth_derive_agent(
113 &server,
114 agent_id,
115 ttl_secs,
116 scopes,
117 allowed_operations,
118 template,
119 out.as_deref(),
120 ),
121 AuthCommand::CreateServiceToken {
122 name,
123 namespace,
124 server,
125 out,
126 } => {
127 cmd_create_service_token(ctx, server.as_deref(), name, namespace, out.as_deref()).await
128 }
129 }
130}
131
132#[allow(clippy::too_many_arguments)]
135fn cmd_auth_derive_agent(
136 server: &str,
137 agent_id: Option<String>,
138 ttl_secs: u64,
139 scopes: Vec<String>,
140 requested_operations: Vec<String>,
141 template: Option<AgentTemplate>,
142 out: Option<&Path>,
143) -> Result<()> {
144 if ttl_secs == 0 {
145 bail!("--ttl must be greater than zero seconds");
146 }
147 let ttl_secs = i64::try_from(ttl_secs).context("--ttl is too large")?;
148 let parent = credentials::get_server_credential(server)?
149 .ok_or_else(|| anyhow::anyhow!(HostedRecoveryAdvice::auth_required(server)))?;
150 let private_key_pem = parent.private_key_pem.as_deref().ok_or_else(|| {
151 anyhow::anyhow!(
152 "stored credential for {server} has no device proof key; run `heddle auth login --server {server}` first"
153 )
154 })?;
155 let signer = Ed25519Signer::from_pem(private_key_pem)
156 .map_err(|error| anyhow::anyhow!("stored device proof key is invalid: {error}"))?;
157 let metadata = headless_token_metadata(&parent.token)?;
158 if !metadata
159 .proof_public_key_hex
160 .eq_ignore_ascii_case(&hex::encode(signer.public_key()))
161 {
162 bail!("stored device proof key does not match the parent Biscuit");
163 }
164
165 let now = chrono::Utc::now();
166 let requested_expiry = now
167 .checked_add_signed(chrono::Duration::seconds(ttl_secs))
168 .ok_or_else(|| anyhow::anyhow!("--ttl produces an unsupported expiry"))?;
169 let expires_at = match parent.expires_at.as_deref() {
170 Some(value) => {
171 let parent_expiry = chrono::DateTime::parse_from_rfc3339(value)
172 .with_context(|| format!("stored parent expiry is invalid: {value}"))?
173 .with_timezone(&chrono::Utc);
174 if parent_expiry <= now {
175 bail!("stored parent credential expired at {parent_expiry}");
176 }
177 requested_expiry.min(parent_expiry)
178 }
179 None => requested_expiry,
180 };
181
182 let agent_id = agent_id.unwrap_or_else(|| format!("agent-{}", uuid::Uuid::new_v4()));
183 let allowed_operations = resolve_agent_operations(template, requested_operations)?;
184 let declared_scopes = parse_agent_scopes(scopes)?;
185 validate_scope_narrowing(&parent.token, &declared_scopes)?;
186 let child_signer = Ed25519Signer::generate()
187 .map_err(|error| anyhow::anyhow!("failed to generate child proof key: {error}"))?;
188 let child_token = attenuate_for_agent(
189 &parent.token,
190 AgentAttenuation {
191 agent_id: agent_id.clone(),
192 expires_at,
193 allowed_operations: Some(allowed_operations.clone()),
194 allowed_resources: (!declared_scopes.is_empty()).then(|| declared_scopes.clone()),
201 declared_scopes: declared_scopes.clone(),
202 },
203 &signer,
204 child_signer.public_key(),
205 )?;
206 let child_private_key_pem = child_signer
207 .to_pem()
208 .map_err(|error| anyhow::anyhow!("failed to export child proof key: {error}"))?;
209 if let Some(out) = out {
210 let provenance = CredentialProvenance {
211 template: template.map(|template| template.as_str().to_string()),
212 scopes: (!declared_scopes.is_empty()).then(|| {
213 declared_scopes
214 .iter()
215 .map(|(kind, path)| format!("{kind}:{path}"))
216 .collect()
217 }),
218 allowed_operations: Some(allowed_operations.clone()),
219 agent_id: Some(agent_id.clone()),
220 };
221 let verified = VerifiedCredential {
222 server: server.to_string(),
223 kind: CredentialKind::Agent,
224 subject: metadata.subject.clone(),
225 token: child_token,
226 proof_key_pem: child_private_key_pem,
227 expires_at: Some(expires_at.to_rfc3339()),
228 credential_id: None,
231 provenance: Some(provenance),
232 };
233 credential_file::write_credential_file(out, &verified)?;
234 println!("Agent credential {agent_id} written to {}.", out.display());
235 if let Some(template) = template {
236 println!("Template: {} ceiling", template.as_str());
237 }
238 println!("Allowed operations: {}", allowed_operations.join(", "));
239 println!("{DERIVED_TOKEN_SECURITY_NOTE}");
240 return Ok(());
241 }
242
243 credentials::store_server_credential(
248 server,
249 ServerCredential {
250 token: child_token,
251 subject: parent.subject,
252 device_id: None,
253 credential_id: None,
254 private_key_pem: Some(child_private_key_pem),
255 expires_at: Some(expires_at.to_rfc3339()),
256 },
257 )?;
258
259 println!("Derived and installed agent token {agent_id} for {server}.");
260 println!("Expires: {expires_at}");
261 if let Some(template) = template {
262 println!("Template: {} ceiling", template.as_str());
263 }
264 println!("Allowed operations: {}", allowed_operations.join(", "));
265 if declared_scopes.is_empty() {
266 println!("Scopes: none (full resource authority inherited from parent)");
267 } else {
268 println!(
269 "Scopes: {} (enforced server-side per request)",
270 declared_scopes
271 .iter()
272 .map(|(kind, path)| format!("{kind}:{path}"))
273 .collect::<Vec<_>>()
274 .join(", ")
275 );
276 }
277 println!("{DERIVED_TOKEN_SECURITY_NOTE}");
278 Ok(())
279}
280
281fn resolve_agent_operations(
289 template: Option<AgentTemplate>,
290 requested: Vec<String>,
291) -> Result<Vec<String>> {
292 let base: BTreeSet<String> = match template {
293 Some(template) => template.operations().into_iter().collect(),
294 None => SAFE_AGENT_OPERATIONS
295 .iter()
296 .map(|operation| (*operation).to_string())
297 .collect(),
298 };
299
300 if requested.is_empty() {
301 return Ok(base.into_iter().collect());
302 }
303
304 let mut selected = BTreeSet::new();
305 for operation in requested {
306 if !base.contains(&operation) {
307 let ceiling = match template {
308 Some(template) => format!("the {:?} template's operation set", template.as_str()),
309 None => "the safe agent operation ceiling".to_string(),
310 };
311 bail!(
312 "operation {operation:?} is outside {ceiling}; --allow can only narrow the {} set",
313 if template.is_some() { "template" } else { "default" }
314 );
315 }
316 selected.insert(operation);
317 }
318 Ok(selected.into_iter().collect())
319}
320
321fn parse_agent_scopes(scopes: Vec<String>) -> Result<Vec<(String, String)>> {
322 let mut parsed = BTreeSet::new();
323 for scope in scopes {
324 let (kind, path) = match scope.split_once(':') {
325 Some(("repo", path)) => ("repo", path),
326 Some(("namespace" | "ns", path)) => ("namespace", path),
327 Some((kind, _)) => bail!(
328 "unsupported scope kind {kind:?}; use repo:<path>, namespace:<path>, or a bare repo path"
329 ),
330 None => ("repo", scope.as_str()),
331 };
332 let path = path.trim_matches('/');
333 if path.is_empty() {
334 bail!("--scope path must not be empty");
335 }
336 parsed.insert((kind.to_string(), path.to_string()));
337 }
338 Ok(parsed.into_iter().collect())
339}
340
341fn validate_scope_narrowing(parent_token: &str, child: &[(String, String)]) -> Result<()> {
342 if child.is_empty() {
343 return Ok(());
346 }
347 for ancestor in agent_scope_blocks(parent_token)? {
348 if ancestor.is_empty() {
349 continue;
350 }
351 for child_scope in child {
352 if !ancestor
353 .iter()
354 .any(|parent_scope| scope_is_within(child_scope, parent_scope))
355 {
356 bail!(
357 "scope {}:{} would widen an ancestor agent scope; sub-derivation may only narrow",
358 child_scope.0,
359 child_scope.1
360 );
361 }
362 }
363 }
364 Ok(())
365}
366
367fn agent_scope_blocks(token: &str) -> Result<Vec<Vec<(String, String)>>> {
368 use biscuit_auth::builder::{BlockBuilder, Term};
369
370 let biscuit = biscuit_auth::UnverifiedBiscuit::from_base64(token.as_bytes())
371 .context("parsing parent Biscuit scopes")?;
372 let mut blocks = Vec::new();
373 for index in 1..biscuit.block_count() {
374 let source = biscuit
375 .print_block_source(index)
376 .with_context(|| format!("reading Biscuit attenuation block {index}"))?;
377 let block = BlockBuilder::new()
378 .code(&source)
379 .with_context(|| format!("parsing Biscuit attenuation block {index}"))?;
380 let scopes = block
381 .facts
382 .iter()
383 .filter_map(|fact| {
384 if fact.predicate.name != "agent_scope" || fact.predicate.terms.len() != 2 {
385 return None;
386 }
387 match (&fact.predicate.terms[0], &fact.predicate.terms[1]) {
388 (Term::Str(kind), Term::Str(path)) => Some((kind.clone(), path.clone())),
389 _ => None,
390 }
391 })
392 .collect();
393 blocks.push(scopes);
394 }
395 Ok(blocks)
396}
397
398fn scope_is_within(child: &(String, String), parent: &(String, String)) -> bool {
399 let path_is_within = child.1 == parent.1
400 || child
401 .1
402 .strip_prefix(&parent.1)
403 .is_some_and(|suffix| suffix.starts_with('/'));
404 match (parent.0.as_str(), child.0.as_str()) {
405 ("repo", "repo") => path_is_within,
406 ("namespace", "namespace") => path_is_within,
407 ("namespace", "repo") => child.1 != parent.1 && path_is_within,
408 _ => false,
409 }
410}
411
412pub(crate) struct HeadlessTokenMetadata {
413 pub(crate) subject: String,
414 pub(crate) is_derived: bool,
415 pub(crate) credential_id: Option<String>,
416 pub(crate) expires_at: Option<String>,
417 pub(crate) proof_public_key_hex: String,
418}
419
420pub(crate) fn install_credential_file(path: &Path) -> Result<String> {
429 let credential = credential_file::load_credential_file(path)?;
430 let server = credential.server.clone();
431 let subject = credential.subject.clone();
432 let register_device_identity = matches!(credential.kind, CredentialKind::Device);
433 let proof_key_pem = credential.proof_key_pem.clone();
434
435 credentials::store_server_credential(&server, credential.into_server_credential())?;
436
437 if register_device_identity {
438 let signer = Ed25519Signer::from_pem(&proof_key_pem)
439 .map_err(|error| anyhow::anyhow!("credential proof key is invalid: {error}"))?;
440 repo::identity::link_device_key(signer.public_key(), &proof_key_pem, &server)
441 .with_context(|| format!("registering device identity for {server}"))?;
442 }
443
444 Ok(subject)
445}
446
447pub(crate) fn headless_token_metadata(token: &str) -> Result<HeadlessTokenMetadata> {
448 use biscuit_auth::builder::{BlockBuilder, Term};
449
450 let biscuit = biscuit_auth::UnverifiedBiscuit::from_base64(token.as_bytes())
451 .context("parsing credential token as a Biscuit")?;
452 let block_count = biscuit.block_count();
453 let authority_source = biscuit
454 .print_block_source(0)
455 .context("reading Biscuit authority block")?;
456 let authority = BlockBuilder::new()
457 .code(&authority_source)
458 .context("parsing Biscuit authority facts")?;
459
460 let string_fact = |name: &str| -> Result<Option<String>> {
461 let mut values = authority.facts.iter().filter_map(|fact| {
462 if fact.predicate.name != name || fact.predicate.terms.len() != 1 {
463 return None;
464 }
465 match &fact.predicate.terms[0] {
466 Term::Str(value) => Some(value.clone()),
467 _ => None,
468 }
469 });
470 let value = values.next();
471 if values.next().is_some() {
472 bail!("Biscuit authority block contains multiple {name} facts");
473 }
474 Ok(value)
475 };
476
477 let subject = string_fact("user")?
478 .filter(|subject| !subject.trim().is_empty())
479 .ok_or_else(|| anyhow::anyhow!("Biscuit authority block is missing user(subject)"))?;
480 let credential_id = if block_count > 1 {
485 None
486 } else {
487 string_fact("credential_id")?
488 };
489 let proof_public_key_hex = effective_pop_public_key_hex(token)?;
490
491 let mut expiries = authority.facts.iter().filter_map(|fact| {
492 if fact.predicate.name != "expires_at" || fact.predicate.terms.len() != 1 {
493 return None;
494 }
495 match fact.predicate.terms[0] {
496 Term::Date(seconds) => Some(seconds),
497 _ => None,
498 }
499 });
500 let authority_expires_at = expiries
501 .next()
502 .map(|seconds| {
503 i64::try_from(seconds)
504 .ok()
505 .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
506 .map(|expires_at| expires_at.to_rfc3339())
507 .ok_or_else(|| anyhow::anyhow!("Biscuit expires_at is outside the supported range"))
508 })
509 .transpose()?;
510 if expiries.next().is_some() {
511 bail!("Biscuit authority block contains multiple expires_at facts");
512 }
513
514 let mut effective_expiry = authority_expires_at
515 .as_deref()
516 .map(chrono::DateTime::parse_from_rfc3339)
517 .transpose()
518 .context("parsing Biscuit authority expiry")?
519 .map(|value| value.with_timezone(&chrono::Utc));
520 for index in 1..block_count {
521 let source = biscuit
522 .print_block_source(index)
523 .with_context(|| format!("reading Biscuit attenuation block {index}"))?;
524 let block = BlockBuilder::new()
525 .code(&source)
526 .with_context(|| format!("parsing Biscuit attenuation block {index}"))?;
527 for fact in &block.facts {
528 if fact.predicate.name != "agent_expires_at" || fact.predicate.terms.len() != 1 {
529 continue;
530 }
531 let Term::Date(seconds) = fact.predicate.terms[0] else {
532 bail!("Biscuit attenuation block {index} has invalid agent_expires_at fact");
533 };
534 let seconds = i64::try_from(seconds)
535 .with_context(|| format!("attenuation block {index} expiry is too large"))?;
536 let value = chrono::DateTime::from_timestamp(seconds, 0)
537 .ok_or_else(|| anyhow::anyhow!("attenuation block {index} expiry is invalid"))?;
538 effective_expiry = Some(effective_expiry.map_or(value, |current| current.min(value)));
539 }
540 }
541 let expires_at = effective_expiry.map(|value| value.to_rfc3339());
542
543 Ok(HeadlessTokenMetadata {
544 subject,
545 is_derived: block_count > 1,
546 credential_id,
547 expires_at,
548 proof_public_key_hex,
549 })
550}
551
552async fn cmd_auth_login(server: &str, open_browser: bool) -> Result<()> {
554 let signer = Ed25519Signer::generate()
556 .map_err(|e| anyhow::anyhow!("failed to generate keypair: {e}"))?;
557 let public_key_bytes = signer.public_key().to_vec();
558 let private_key_pem = signer
559 .to_pem()
560 .map_err(|e| anyhow::anyhow!("failed to export private key: {e}"))?;
561
562 let mut auth_client: IdentityServiceClient<Channel> = connect_auth_client(server).await?;
564
565 let hostname = std::env::var("HOSTNAME")
567 .or_else(|_| std::env::var("HOST"))
568 .unwrap_or_else(|_| "heddle-cli".to_string());
569
570 let response: DeviceAuthorizationResponse = auth_client
571 .create_device_authorization(CreateDeviceAuthorizationRequest {
572 device_name: hostname,
573 device_public_key: public_key_bytes.clone(),
574 scope: "repo:*".to_string(),
575 client_operation_id: String::new(),
576 })
577 .await
578 .map_err(|status: tonic::Status| {
579 anyhow::anyhow!("create_device_authorization failed: {}", status.message())
580 })?
581 .into_inner();
582
583 let verification_uri = &response.verification_uri;
584 let user_code = &response.user_code;
585 let device_code = &response.device_code;
586
587 println!();
589 println!("Open this URL to authorize:");
590 println!(" {verification_uri}");
591 println!();
592 println!("Enter code: {user_code}");
593 println!();
594
595 if open_browser {
600 let encoded_code = percent_encode_query_component(user_code);
601 let url = format!("{verification_uri}?code={encoded_code}");
602 match validate_browser_url(&url) {
603 Ok(()) => {
604 if let Err(_e) = open_url(&url) {
605 eprintln!("Could not open browser automatically. Please open the URL above.");
606 }
607 }
608 Err(err) => {
609 eprintln!("Refusing to open browser URL: {err}");
610 eprintln!("Please open the URL printed above in your browser.");
611 }
612 }
613 }
614
615 println!("Waiting for authorization...");
617
618 let access_token = poll_for_approval(
619 &mut auth_client,
620 device_code,
621 &public_key_bytes,
622 &signer,
623 response.expires_at,
624 )
625 .await?;
626
627 let credential = ServerCredential {
629 token: access_token.token,
630 subject: access_token.subject.clone(),
631 device_id: None,
632 credential_id: if access_token.credential_id.is_empty() {
633 None
634 } else {
635 Some(access_token.credential_id)
636 },
637 private_key_pem: Some(private_key_pem.clone()),
638 expires_at: access_token.expires_at.as_ref().and_then(|ts| {
639 chrono::DateTime::from_timestamp(ts.seconds, ts.nanos.max(0) as u32)
640 .map(|dt| dt.to_rfc3339())
641 }),
642 };
643
644 credentials::store_server_credential(server, credential)?;
645
646 if let Err(error) = repo::identity::link_device_key(&public_key_bytes, &private_key_pem, server)
652 {
653 tracing::warn!(%error, "could not record device signing identity; captures will use the per-repo local key");
654 }
655
656 println!();
657 println!(
658 "Authenticated as {}. Credentials saved.",
659 access_token.subject
660 );
661 Ok(())
662}
663
664fn cmd_auth_logout(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
666 let server = resolve_server(server)?;
667
668 let device_identity_removed = repo::identity::unlink_device_key(&server).map_err(|error| {
679 anyhow::anyhow!("failed to remove device signing identity for {server}: {error}")
680 })?;
681 credentials::remove_server_credential(&server)?;
682
683 if ctx.should_output_json(None) {
684 let output = AuthLogoutOutput {
685 output_kind: "auth_logout",
686 server,
687 removed: true,
688 device_identity_removed,
689 };
690 println!("{}", serde_json::to_string(&output)?);
691 } else {
692 println!("Credentials removed for {server}.");
693 if device_identity_removed {
694 println!("Device signing identity removed.");
695 }
696 }
697 Ok(())
698}
699
700fn cmd_auth_status(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
702 let server = resolve_server(server)?;
703 let resolved = crate::grpc_hosted::resolve_hosted_credential(Some(&server))?;
707 let output = auth_status_output(&server, &resolved);
708 if ctx.should_output_json(None) {
709 println!("{}", serde_json::to_string(&output)?);
710 } else if output.authenticated {
711 println!("Server: {server}");
712 println!("Source: {}", output.source);
713 println!(
714 "Subject: {}",
715 output.subject.as_deref().unwrap_or_default()
716 );
717 if let Some(ref cred_id) = output.credential_id {
718 println!("Credential: {cred_id}");
719 }
720 if let Some(ref expires) = output.expires_at {
721 println!("Expires: {expires}");
722 }
723 if output.proof_key_available {
724 println!("Hosted writes: ready (device proof key available)");
725 } else {
726 println!(
727 "Hosted writes: unavailable — credential missing device proof key; re-login / re-install"
728 );
729 if let Some(ref action) = output.recommended_action {
730 println!("Run `{action}` to repair the credential.");
731 }
732 }
733 } else {
734 println!("Not authenticated with {server}.");
735 if let Some(ref action) = output.recommended_action {
736 println!("Run `{action}` to authenticate.");
737 }
738 }
739 Ok(())
740}
741
742fn auth_status_output(
743 server: &str,
744 resolved: &crate::grpc_hosted::ResolvedHostedCredential,
745) -> AuthStatusOutput {
746 let source = resolved.source.label();
747 if resolved.token.is_some() {
748 let proof_key_available = resolved
749 .proof_key_pem
750 .as_deref()
751 .is_some_and(|pem| Ed25519Signer::from_pem(pem).is_ok());
752 AuthStatusOutput {
753 output_kind: "auth_status",
754 server: server.to_string(),
755 authenticated: true,
756 source,
757 proof_key_available,
758 subject: resolved.subject.clone(),
759 credential_id: resolved.credential_id.clone(),
760 expires_at: resolved.expires_at.clone(),
761 recommended_action: (!proof_key_available)
762 .then(|| format!("heddle auth login --server {server}")),
763 }
764 } else {
765 AuthStatusOutput {
766 output_kind: "auth_status",
767 server: server.to_string(),
768 authenticated: false,
769 source,
770 proof_key_available: false,
771 subject: None,
772 credential_id: None,
773 expires_at: None,
774 recommended_action: Some(format!("heddle auth login --server {server}")),
775 }
776 }
777}
778
779async fn cmd_create_service_token(
789 ctx: &dyn CliContext,
790 server: Option<&str>,
791 name: String,
792 namespace: String,
793 out: Option<&Path>,
794) -> Result<()> {
795 let server = resolve_server(server)?;
796 let scope = format!("repo:{namespace}/*");
797
798 let credential_path = resolve_service_account_credential_path(&name, out);
802 if std::fs::symlink_metadata(&credential_path).is_ok() {
803 bail!(
804 "credential destination {} already exists; choose a new --out path",
805 credential_path.display()
806 );
807 }
808
809 let user_config = UserConfig::load_default()?;
812 let session = HostedSession::build_stored_credential(&user_config, &server)?;
813 let channel = connect_channel(&server).await?;
814 let mut auth_client = session.connect_channel(channel).await?;
815 let create_operation_id = ClientOperationId::caller_or_fresh(
816 "heddle.api.v1alpha1.IdentityService/CreateServiceAccount",
817 ctx.operation_id_wire(),
818 );
819 let issue_operation_id = ClientOperationId::for_required_method(
820 "heddle.api.v1alpha1.IdentityService/IssueServiceAccountCredential",
821 create_operation_id.to_wire(),
822 )?;
823
824 let signer = Ed25519Signer::generate()
826 .map_err(|e| anyhow::anyhow!("failed to generate keypair: {e}"))?;
827 let public_key_bytes = signer.public_key().to_vec();
828 let private_key_pem = signer
829 .to_pem()
830 .map_err(|e| anyhow::anyhow!("failed to export service-account private key: {e}"))?;
831
832 let sa_response = auth_client
834 .create_service_account(CreateServiceAccountRequest {
835 subject: name.clone(),
836 display_name: name.clone(),
837 scope: scope.clone(),
838 client_operation_id: create_operation_id.to_wire(),
839 })
840 .await
841 .map_err(|error| anyhow::anyhow!("create_service_account failed: {error}"))?;
842
843 tracing::info!(
844 service_account_id = %sa_response.service_account_id,
845 subject = %sa_response.subject,
846 "service account created"
847 );
848
849 let credential_request = IssueServiceAccountCredentialRequest {
851 service_account_id: sa_response.service_account_id,
852 public_key: public_key_bytes,
853 scope: scope.clone(),
854 ttl_secs: Some(prost_types::Duration {
859 seconds: SERVICE_TOKEN_TTL_SECS,
860 nanos: 0,
861 }),
862 client_operation_id: issue_operation_id.to_wire(),
863 };
864 let credential_request = issue_service_account_credential_request(credential_request, &signer)?;
865 let issued = auth_client
866 .issue_service_account_credential(credential_request)
867 .await
868 .map_err(|error| anyhow::anyhow!("issue_service_account_credential failed: {error}"))?;
869
870 let subject = crate::device_flow::authenticated_subject(&issued.token)
873 .context("reading the issued service token's authenticated subject")?;
874 let expires_at = (chrono::Utc::now()
875 + chrono::Duration::seconds(SERVICE_TOKEN_TTL_SECS))
876 .to_rfc3339();
877
878 let verified = VerifiedCredential {
879 server: server.clone(),
880 kind: CredentialKind::Service,
881 subject,
882 token: issued.token,
883 proof_key_pem: private_key_pem,
884 expires_at: Some(expires_at),
885 credential_id: None,
886 provenance: Some(CredentialProvenance {
887 scopes: Some(vec![scope.clone()]),
888 ..CredentialProvenance::default()
889 }),
890 };
891 credential_file::write_credential_file(&credential_path, &verified)?;
892 let credential_path_display = credential_path.display().to_string();
893
894 if ctx.should_output_json(None) {
895 let output = ServiceTokenOutput {
896 output_kind: "auth_create_service_token",
897 name,
898 namespace,
899 scope,
900 credential_path: credential_path_display,
901 expires_in_days: SERVICE_TOKEN_TTL_DAYS,
902 };
903 println!("{}", serde_json::to_string(&output)?);
904 } else {
905 println!();
906 println!("Service token created for \"{name}\" (scope: {scope})");
907 println!();
908 println!("Credential written to: {credential_path_display}");
909 println!("Expires in {SERVICE_TOKEN_TTL_DAYS} days.");
910 println!(
911 "The single .hcred file carries the token and its proof key; keep it secret (mode 0600)."
912 );
913 println!("Point the runtime at it with HEDDLE_CREDENTIAL={credential_path_display}.");
914 println!("This token is scoped to the {namespace} namespace.");
915 }
916
917 Ok(())
918}
919
920fn resolve_service_account_credential_path(name: &str, out: Option<&Path>) -> std::path::PathBuf {
925 if let Some(path) = out {
926 return path.to_path_buf();
927 }
928 let mut safe: String = name
929 .chars()
930 .map(|ch| {
931 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
932 ch
933 } else {
934 '_'
935 }
936 })
937 .collect();
938 if safe.is_empty() {
939 safe = "service-account".to_string();
940 }
941 repo::identity::heddle_home_dir()
942 .join("service-accounts")
943 .join(format!("{safe}.hcred"))
944}
945
946fn issue_service_account_credential_request(
947 request: IssueServiceAccountCredentialRequest,
948 signer: &Ed25519Signer,
949) -> Result<Request<IssueServiceAccountCredentialRequest>> {
950 let timestamp = current_unix_timestamp_i64()?;
951 issue_service_account_credential_request_at(request, signer, timestamp)
952}
953
954fn issue_service_account_credential_request_at(
955 request: IssueServiceAccountCredentialRequest,
956 signer: &Ed25519Signer,
957 timestamp: i64,
958) -> Result<Request<IssueServiceAccountCredentialRequest>> {
959 let signature = issue_service_account_credential_signature(
960 signer,
961 timestamp,
962 &request.service_account_id,
963 &request.public_key,
964 )?;
965 let mut request = Request::new(request);
966 request.metadata_mut().insert(
967 ISSUE_SA_PROOF_TS_HEADER,
968 timestamp
969 .to_string()
970 .parse()
971 .map_err(|err| anyhow::anyhow!("invalid proof timestamp metadata: {err}"))?,
972 );
973 request.metadata_mut().insert_bin(
974 ISSUE_SA_PROOF_SIG_HEADER,
975 MetadataValue::from_bytes(&signature),
976 );
977 Ok(request)
978}
979
980fn issue_service_account_credential_signature(
981 signer: &Ed25519Signer,
982 timestamp: i64,
983 service_account_id: &str,
984 public_key: &[u8],
985) -> Result<Vec<u8>> {
986 let canonical = derive_issue_service_account_credential_canonical(
987 timestamp,
988 service_account_id,
989 public_key,
990 );
991 signer
992 .sign(&canonical)
993 .map_err(|e| anyhow::anyhow!("failed to sign service-account proof: {e}"))
994}
995
996fn derive_issue_service_account_credential_canonical(
997 timestamp: i64,
998 service_account_id: &str,
999 public_key: &[u8],
1000) -> [u8; 32] {
1001 let mut hasher = Sha256::new();
1002 hasher.update(ISSUE_SA_PROOF_DOMAIN);
1003 hasher.update([0u8]);
1004 hasher.update(timestamp.to_be_bytes());
1005 hasher.update([0u8]);
1006 hasher.update(service_account_id.as_bytes());
1007 hasher.update([0u8]);
1008 hasher.update(public_key);
1009 hasher.finalize().into()
1010}
1011
1012fn current_unix_timestamp_i64() -> Result<i64> {
1013 let secs = std::time::SystemTime::now()
1014 .duration_since(std::time::UNIX_EPOCH)
1015 .map_err(|err| anyhow::anyhow!("system clock is before unix epoch: {err}"))?
1016 .as_secs();
1017 i64::try_from(secs).map_err(|_| anyhow::anyhow!("system clock exceeds i64 unix timestamp"))
1018}
1019
1020pub(crate) fn resolve_server(explicit: Option<&str>) -> Result<String> {
1026 if let Some(s) = explicit {
1027 return Ok(s.to_string());
1028 }
1029 if let Some(default) = credentials::default_server()? {
1030 return Ok(default);
1031 }
1032 Ok("grpc.heddle.sh".to_string())
1033}
1034
1035pub(crate) async fn connect_channel(server: &str) -> Result<Channel> {
1037 let uri = infer_server_uri(server);
1038 enforce_auth_cleartext_gate(&uri)?;
1043 let endpoint = Endpoint::from_shared(uri.clone())
1044 .map_err(|e| anyhow::anyhow!("invalid server address '{server}': {e}"))?;
1045 endpoint
1046 .connect()
1047 .await
1048 .map_err(|e| anyhow::anyhow!("failed to connect to {server}: {e}"))
1049}
1050
1051fn enforce_auth_cleartext_gate(uri: &str) -> Result<()> {
1061 let Some(rest) = uri.strip_prefix("http://") else {
1063 return Ok(());
1064 };
1065
1066 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1067 let hostport = authority.rsplit('@').next().unwrap_or(authority);
1069 let host = if let Some(inner) = hostport.strip_prefix('[') {
1070 inner.split(']').next().unwrap_or(inner)
1072 } else {
1073 hostport
1074 .rsplit_once(':')
1075 .map(|(host, _port)| host)
1076 .unwrap_or(hostport)
1077 };
1078
1079 if host.eq_ignore_ascii_case("localhost") {
1083 return Ok(());
1084 }
1085 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
1086 if cli_shared::is_loopback_ip(ip) {
1087 return Ok(());
1088 }
1089 let allow_insecure = auth_cleartext_insecure_opt_in()?;
1092 let addr = std::net::SocketAddr::new(ip, 0);
1093 if cli_shared::cleartext_connect_allowed(addr, false, allow_insecure) {
1094 return Ok(());
1095 }
1096 bail!(cli_shared::cleartext_refused_message(addr));
1097 }
1098
1099 if auth_cleartext_insecure_opt_in()? {
1102 return Ok(());
1103 }
1104 bail!(
1105 "refusing cleartext connection to non-loopback host {host:?}; \
1106enable TLS or set HEDDLE_REMOTE_INSECURE=1 for intentional cleartext"
1107 );
1108}
1109
1110fn auth_cleartext_insecure_opt_in() -> Result<bool> {
1115 match std::env::var("HEDDLE_REMOTE_INSECURE") {
1116 Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
1117 "1" | "true" | "yes" | "on" => Ok(true),
1118 "0" | "false" | "no" | "off" | "" => Ok(false),
1119 other => bail!(
1120 "invalid HEDDLE_REMOTE_INSECURE value {other:?}; \
1121expected one of 1/0, true/false, yes/no, or on/off"
1122 ),
1123 },
1124 Err(std::env::VarError::NotPresent) => Ok(false),
1125 Err(err @ std::env::VarError::NotUnicode(_)) => {
1126 bail!("failed to read HEDDLE_REMOTE_INSECURE: {err}")
1127 }
1128 }
1129}
1130
1131async fn connect_auth_client(server: &str) -> Result<IdentityServiceClient<Channel>> {
1133 Ok(IdentityServiceClient::new(connect_channel(server).await?))
1134}
1135
1136fn infer_server_uri(server: &str) -> String {
1137 if server.starts_with("http://") || server.starts_with("https://") {
1138 return server.to_string();
1139 }
1140
1141 let authority = server.split('/').next().unwrap_or(server);
1142 let host = authority
1143 .strip_prefix('[')
1144 .and_then(|value| value.split_once(']'))
1145 .map(|(value, _)| value)
1146 .unwrap_or_else(|| {
1147 authority
1148 .rsplit_once(':')
1149 .map(|(value, _)| value)
1150 .unwrap_or(authority)
1151 });
1152
1153 let use_http = host.contains("localhost")
1154 || host.parse::<std::net::IpAddr>().is_ok()
1155 || authority.parse::<std::net::SocketAddr>().is_ok();
1156
1157 if use_http {
1158 format!("http://{server}")
1159 } else {
1160 format!("https://{server}")
1161 }
1162}
1163
1164async fn poll_for_approval(
1167 client: &mut IdentityServiceClient<Channel>,
1168 device_code: &str,
1169 public_key: &[u8],
1170 signer: &Ed25519Signer,
1171 expires_at: Option<prost_types::Timestamp>,
1172) -> Result<AccessToken> {
1173 let proof_bytes = device_authorization_signature(device_code, signer)?;
1174
1175 let expires_at_secs = expires_at
1176 .as_ref()
1177 .map(|t| t.seconds.max(0) as u64)
1178 .unwrap_or(0);
1179 let deadline = std::time::Instant::now()
1180 + std::time::Duration::from_secs(
1181 expires_at_secs
1182 .saturating_sub(
1183 std::time::SystemTime::now()
1184 .duration_since(std::time::UNIX_EPOCH)
1185 .unwrap_or_default()
1186 .as_secs(),
1187 )
1188 .max(30),
1189 );
1190
1191 let mut events = client
1192 .wait_for_device_authorization(WaitForDeviceAuthorizationRequest {
1193 device_code: device_code.to_string(),
1194 })
1195 .await
1196 .map_err(|status| {
1197 anyhow::anyhow!("wait_for_device_authorization failed: {}", status.message())
1198 })?
1199 .into_inner();
1200
1201 loop {
1202 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1203 if remaining.is_zero() {
1204 bail!("Authorization timed out. Please try again.");
1205 }
1206
1207 let event = tokio::time::timeout(remaining, events.message())
1208 .await
1209 .map_err(|_| anyhow::anyhow!("Authorization timed out. Please try again."))?
1210 .map_err(|status| {
1211 anyhow::anyhow!("device authorization wait failed: {}", status.message())
1212 })?;
1213
1214 match event {
1215 Some(status) if status.status == "pending" => continue,
1216 Some(status) if status.status == "approved" => break,
1217 Some(status) if status.status == "expired" => {
1218 bail!("Authorization expired before approval. Please try again.");
1219 }
1220 Some(status) => bail!("Unexpected device authorization status: {}", status.status),
1221 None => bail!("Device authorization ended before approval. Please try again."),
1222 }
1223 }
1224
1225 match mint_biscuit_with_device_auth(client, device_code, public_key, proof_bytes.clone()).await
1226 {
1227 Ok(token) => Ok(token),
1228 Err(status) if should_fallback_to_exchange_device_authorization(&status) => {
1229 tracing::debug!(
1230 status = %status.message(),
1231 "falling back to ExchangeDeviceAuthorization for lagging auth server"
1232 );
1233 exchange_device_authorization(client, device_code, public_key, proof_bytes).await
1234 }
1235 Err(status) => Err(anyhow::anyhow!(
1236 "device authorization failed: {}",
1237 status.message()
1238 )),
1239 }
1240}
1241
1242async fn mint_biscuit_with_device_auth(
1243 client: &mut IdentityServiceClient<Channel>,
1244 device_code: &str,
1245 public_key: &[u8],
1246 signature: Vec<u8>,
1247) -> std::result::Result<AccessToken, tonic::Status> {
1248 let inner = client
1249 .mint_biscuit(device_auth_mint_biscuit_request(
1250 device_code,
1251 public_key,
1252 signature,
1253 ))
1254 .await?
1255 .into_inner();
1256
1257 Ok(AccessToken {
1258 token: inner.token,
1259 subject: inner.subject,
1260 expires_at: inner.expires_at,
1261 credential_id: inner.credential_id,
1262 })
1263}
1264
1265async fn exchange_device_authorization(
1266 client: &mut IdentityServiceClient<Channel>,
1267 device_code: &str,
1268 public_key: &[u8],
1269 proof: Vec<u8>,
1270) -> Result<AccessToken> {
1271 let inner = client
1272 .exchange_device_authorization(ExchangeDeviceAuthorizationRequest {
1273 device_code: device_code.to_string(),
1274 device_public_key: public_key.to_vec(),
1275 proof,
1276 })
1277 .await
1278 .map_err(|status| anyhow::anyhow!("device authorization failed: {}", status.message()))?
1279 .into_inner();
1280
1281 Ok(AccessToken {
1282 token: inner.token,
1283 subject: inner.subject,
1284 expires_at: inner.expires_at,
1285 credential_id: inner.credential_id,
1286 })
1287}
1288
1289fn device_auth_mint_biscuit_request(
1290 device_code: &str,
1291 public_key: &[u8],
1292 signature: Vec<u8>,
1293) -> MintBiscuitRequest {
1294 MintBiscuitRequest {
1295 subject: String::new(),
1296 requested_scope: String::new(),
1297 user_agent: String::new(),
1298 ip: String::new(),
1299 proof: Some(Proof::DeviceAuth(DeviceAuthProof {
1300 device_code: device_code.to_string(),
1301 device_public_key: public_key.to_vec(),
1302 signature,
1303 })),
1304 client_operation_id: String::new(),
1305 }
1306}
1307
1308fn device_authorization_signature(device_code: &str, signer: &Ed25519Signer) -> Result<Vec<u8>> {
1309 signer
1310 .sign(format!("device:{device_code}").as_bytes())
1311 .map_err(|e| anyhow::anyhow!("failed to sign proof: {e}"))
1312}
1313
1314fn should_fallback_to_exchange_device_authorization(status: &tonic::Status) -> bool {
1315 status.code() == tonic::Code::Unimplemented
1316 && status.message().contains("DeviceAuthProof")
1317 && status.message().contains("ExchangeDeviceAuthorization")
1318}
1319
1320struct AccessToken {
1322 token: String,
1323 subject: String,
1324 expires_at: Option<prost_types::Timestamp>,
1325 credential_id: String,
1326}
1327
1328pub(crate) fn validate_browser_url(url: &str) -> Result<()> {
1339 if url.is_empty() {
1340 bail!("browser URL is empty");
1341 }
1342 for ch in url.chars() {
1343 if ch.is_control()
1348 || matches!(
1349 ch,
1350 '"' | '\'' | '|' | '&' | '^' | '`' | '%' | '<' | '>' | ' ' | '\n' | '\r' | '\t'
1351 )
1352 {
1353 bail!("browser URL contains forbidden character {ch:?}");
1354 }
1355 }
1356
1357 let Some((scheme, rest)) = url.split_once("://") else {
1358 bail!("browser URL must include a scheme (https://…)");
1359 };
1360 let scheme = scheme.to_ascii_lowercase();
1361 if scheme != "https" && scheme != "http" {
1362 bail!("browser URL scheme must be https (or http for localhost only)");
1363 }
1364 if rest.is_empty() {
1365 bail!("browser URL is missing a host");
1366 }
1367
1368 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1370 if authority.is_empty() {
1371 bail!("browser URL is missing a host");
1372 }
1373 let hostport = authority.rsplit('@').next().unwrap_or(authority);
1375 let host = extract_url_host(hostport);
1376 if host.is_empty() {
1377 bail!("browser URL is missing a host");
1378 }
1379 if host.chars().any(|ch| ch.is_whitespace()) {
1381 bail!("browser URL host must not contain whitespace");
1382 }
1383
1384 if scheme == "http" && !is_loopback_browser_host(host) {
1385 bail!("http browser URLs are only allowed for localhost/127.0.0.1/::1");
1386 }
1387 Ok(())
1388}
1389
1390fn extract_url_host(hostport: &str) -> &str {
1391 if let Some(inner) = hostport.strip_prefix('[') {
1392 return inner.split(']').next().unwrap_or(inner);
1394 }
1395 hostport
1396 .rsplit_once(':')
1397 .map(|(host, _port)| host)
1398 .unwrap_or(hostport)
1399}
1400
1401fn is_loopback_browser_host(host: &str) -> bool {
1402 let host = host.trim_matches(|c| c == '[' || c == ']');
1403 if host.eq_ignore_ascii_case("localhost") {
1404 return true;
1405 }
1406 match host.parse::<std::net::IpAddr>() {
1407 Ok(ip) => ip.is_loopback(),
1408 Err(_) => false,
1409 }
1410}
1411
1412fn percent_encode_query_component(s: &str) -> String {
1414 let mut out = String::with_capacity(s.len());
1415 for b in s.bytes() {
1416 match b {
1417 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1418 out.push(b as char);
1419 }
1420 _ => {
1421 use std::fmt::Write as _;
1422 let _ = write!(out, "%{b:02X}");
1423 }
1424 }
1425 }
1426 out
1427}
1428
1429fn open_url(url: &str) -> Result<()> {
1433 validate_browser_url(url)?;
1436
1437 #[cfg(target_os = "macos")]
1438 {
1439 std::process::Command::new("open").arg(url).spawn()?;
1440 }
1441 #[cfg(target_os = "linux")]
1442 {
1443 std::process::Command::new("xdg-open").arg(url).spawn()?;
1444 }
1445 #[cfg(target_os = "windows")]
1446 {
1447 std::process::Command::new("cmd")
1450 .args(["/C", "start", "", url])
1451 .spawn()?;
1452 }
1453 Ok(())
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use super::*;
1459
1460 #[test]
1461 fn validate_browser_url_accepts_https() {
1462 validate_browser_url("https://auth.heddle.sh/device").expect("https ok");
1463 validate_browser_url("https://auth.heddle.sh/device?code=ABCD-1234").expect("https+query");
1464 }
1465
1466 #[test]
1467 fn validate_browser_url_accepts_loopback_http() {
1468 validate_browser_url("http://127.0.0.1:8421/path").expect("loopback http");
1469 validate_browser_url("http://localhost:8421/device").expect("localhost http");
1470 validate_browser_url("http://[::1]:8421/path").expect("ipv6 loopback http");
1471 }
1472
1473 #[test]
1474 fn validate_browser_url_rejects_injection_and_dangerous_schemes() {
1475 assert!(
1476 validate_browser_url("https://x.com & calc").is_err(),
1477 "shell metacharacters must be rejected"
1478 );
1479 assert!(validate_browser_url("file:///etc/passwd").is_err());
1480 assert!(validate_browser_url("javascript:alert(1)").is_err());
1481 assert!(validate_browser_url("").is_err());
1482 assert!(validate_browser_url("http://example.com/device").is_err());
1483 assert!(validate_browser_url("https://evil.com\"&calc").is_err());
1484 }
1485
1486 #[test]
1487 fn validate_browser_url_rejects_percent_and_redirection() {
1488 assert!(
1490 validate_browser_url("https://evil.com/%USERPROFILE%").is_err(),
1491 "percent (env-var expansion) must be rejected"
1492 );
1493 assert!(
1495 validate_browser_url("https://evil.com/a<b").is_err(),
1496 "< (redirection) must be rejected"
1497 );
1498 assert!(
1499 validate_browser_url("https://evil.com/a>b").is_err(),
1500 "> (redirection) must be rejected"
1501 );
1502 assert!(validate_browser_url("https://evil.com/?x=%TEMP%>out").is_err());
1504 }
1505
1506 static INSECURE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1508
1509 fn with_insecure_env<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
1510 let _guard = INSECURE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1511 let prev = std::env::var("HEDDLE_REMOTE_INSECURE").ok();
1512 match value {
1513 Some(v) => unsafe { std::env::set_var("HEDDLE_REMOTE_INSECURE", v) },
1514 None => unsafe { std::env::remove_var("HEDDLE_REMOTE_INSECURE") },
1515 }
1516 let out = f();
1517 match prev {
1518 Some(v) => unsafe { std::env::set_var("HEDDLE_REMOTE_INSECURE", v) },
1519 None => unsafe { std::env::remove_var("HEDDLE_REMOTE_INSECURE") },
1520 }
1521 out
1522 }
1523
1524 #[test]
1525 fn cleartext_gate_allows_https_and_loopback() {
1526 with_insecure_env(None, || {
1527 enforce_auth_cleartext_gate("https://grpc.heddle.sh").expect("https always allowed");
1528 enforce_auth_cleartext_gate("http://127.0.0.1:8421").expect("loopback v4 allowed");
1529 enforce_auth_cleartext_gate("http://[::1]:8421").expect("loopback v6 allowed");
1530 enforce_auth_cleartext_gate("http://localhost:8421").expect("localhost allowed");
1531 });
1532 }
1533
1534 #[test]
1535 fn cleartext_gate_rejects_nonloopback_without_insecure() {
1536 with_insecure_env(None, || {
1537 let err = enforce_auth_cleartext_gate("http://192.168.1.44:8421")
1538 .expect_err("non-loopback cleartext must be refused");
1539 let msg = err.to_string();
1540 assert!(
1541 msg.contains("refusing cleartext connection to non-loopback"),
1542 "unexpected message: {msg}"
1543 );
1544 assert!(enforce_auth_cleartext_gate("http://server.internal:8421").is_err());
1547 });
1548 }
1549
1550 #[test]
1551 fn cleartext_gate_allows_nonloopback_with_insecure_opt_in() {
1552 with_insecure_env(Some("1"), || {
1553 enforce_auth_cleartext_gate("http://192.168.1.44:8421")
1554 .expect("insecure opt-in permits non-loopback cleartext");
1555 });
1556 }
1557
1558 #[test]
1559 fn cleartext_gate_rejects_invalid_insecure_value() {
1560 with_insecure_env(Some("maybe"), || {
1561 assert!(
1562 enforce_auth_cleartext_gate("http://192.168.1.44:8421").is_err(),
1563 "an ambiguous opt-in value must fail closed"
1564 );
1565 });
1566 }
1567
1568 #[test]
1569 fn percent_encode_query_component_encodes_reserved() {
1570 assert_eq!(percent_encode_query_component("ABCD-1234"), "ABCD-1234");
1571 assert_eq!(percent_encode_query_component("a b"), "a%20b");
1572 assert_eq!(percent_encode_query_component("x&y"), "x%26y");
1573 }
1574
1575 #[test]
1576 fn infers_http_for_plain_ip_targets() {
1577 assert_eq!(
1578 infer_server_uri("192.168.1.44:8421"),
1579 "http://192.168.1.44:8421"
1580 );
1581 assert_eq!(infer_server_uri("10.0.0.8"), "http://10.0.0.8");
1582 }
1583
1584 #[test]
1585 fn infers_http_for_loopback_targets() {
1586 assert_eq!(infer_server_uri("localhost:8421"), "http://localhost:8421");
1587 assert_eq!(infer_server_uri("[::1]:8421"), "http://[::1]:8421");
1588 }
1589
1590 #[test]
1591 fn keeps_https_default_for_hostnames() {
1592 assert_eq!(infer_server_uri("grpc.heddle.sh"), "https://grpc.heddle.sh");
1593 assert_eq!(
1594 infer_server_uri("example.internal:8443"),
1595 "https://example.internal:8443"
1596 );
1597 }
1598
1599 #[test]
1600 fn preserves_explicit_scheme() {
1601 assert_eq!(
1602 infer_server_uri("http://example.internal:8421"),
1603 "http://example.internal:8421"
1604 );
1605 assert_eq!(
1606 infer_server_uri("https://grpc.heddle.sh"),
1607 "https://grpc.heddle.sh"
1608 );
1609 }
1610
1611 #[test]
1612 fn device_auth_mint_request_uses_device_auth_proof_variant() {
1613 let request =
1614 device_auth_mint_biscuit_request("device-123", &[1, 2, 3, 4], vec![5, 6, 7, 8]);
1615
1616 assert!(request.subject.is_empty());
1617 assert!(request.requested_scope.is_empty());
1618 match request.proof.expect("proof variant") {
1619 Proof::DeviceAuth(proof) => {
1620 assert_eq!(proof.device_code, "device-123");
1621 assert_eq!(proof.device_public_key, vec![1, 2, 3, 4]);
1622 assert_eq!(proof.signature, vec![5, 6, 7, 8]);
1623 }
1624 Proof::Keypair(_) => panic!("device login must use DeviceAuthProof"),
1625 }
1626 }
1627
1628 #[test]
1629 fn device_authorization_signature_signs_device_code_challenge() {
1630 let signer = Ed25519Signer::generate().expect("signer");
1631 let signature =
1632 device_authorization_signature("device-123", &signer).expect("device proof");
1633
1634 Ed25519Signer::verify_with_public_key(
1635 b"device:device-123",
1636 signer.public_key(),
1637 &signature,
1638 )
1639 .expect("signature must verify against device challenge");
1640 assert!(
1641 Ed25519Signer::verify_with_public_key(
1642 b"device:other",
1643 signer.public_key(),
1644 &signature,
1645 )
1646 .is_err(),
1647 "signature must commit to the device code",
1648 );
1649 }
1650
1651 #[test]
1652 fn issue_service_account_request_attaches_pop_metadata() {
1653 let signer = Ed25519Signer::generate().expect("signer");
1654 let public_key = signer.public_key().to_vec();
1655 let request = IssueServiceAccountCredentialRequest {
1656 service_account_id: "sa-123".to_string(),
1657 public_key: public_key.clone(),
1658 scope: "repo:heddle/platform/*".to_string(),
1659 ttl_secs: Some(prost_types::Duration {
1660 seconds: SERVICE_TOKEN_TTL_SECS,
1661 nanos: 0,
1662 }),
1663 client_operation_id: "op-1".to_string(),
1664 };
1665
1666 let timestamp = 1_700_000_000;
1667 let request = issue_service_account_credential_request_at(request, &signer, timestamp)
1668 .expect("request with proof");
1669
1670 assert_eq!(
1671 request
1672 .metadata()
1673 .get(ISSUE_SA_PROOF_TS_HEADER)
1674 .expect("proof timestamp")
1675 .to_str()
1676 .expect("ascii timestamp"),
1677 timestamp.to_string(),
1678 );
1679 let signature = request
1680 .metadata()
1681 .get_bin(ISSUE_SA_PROOF_SIG_HEADER)
1682 .expect("proof signature")
1683 .to_bytes()
1684 .expect("binary signature");
1685 let canonical =
1686 derive_issue_service_account_credential_canonical(timestamp, "sa-123", &public_key);
1687 Ed25519Signer::verify_with_public_key(&canonical, &public_key, signature.as_ref())
1688 .expect("proof must be signed by the new service-account key");
1689
1690 let body = request.get_ref();
1691 assert_eq!(body.service_account_id, "sa-123");
1692 assert_eq!(body.public_key, public_key);
1693 assert_eq!(body.scope, "repo:heddle/platform/*");
1694 }
1695
1696 #[test]
1697 fn authenticated_identity_mutations_cannot_use_a_direct_bearer_interceptor() {
1698 let source = include_str!("auth_cmd.rs");
1699 assert!(
1700 !source.contains(concat!("IdentityServiceClient", "::with_interceptor")),
1701 "authenticated IdentityService mutations must route through HostedGrpcClient signed auth"
1702 );
1703 assert_eq!(
1704 source
1705 .matches(concat!("IdentityServiceClient", "::new("))
1706 .count(),
1707 1,
1708 "the only direct IdentityService client is the bootstrap-login connector"
1709 );
1710 }
1711
1712 #[test]
1713 fn issue_service_account_canonical_commits_to_each_field() {
1714 let public_key = vec![0xAA; 32];
1715 let base =
1716 derive_issue_service_account_credential_canonical(1_700_000_000, "sa-1", &public_key);
1717
1718 assert_ne!(
1719 base,
1720 derive_issue_service_account_credential_canonical(1_700_000_001, "sa-1", &public_key,),
1721 );
1722 assert_ne!(
1723 base,
1724 derive_issue_service_account_credential_canonical(1_700_000_000, "sa-2", &public_key,),
1725 );
1726 assert_ne!(
1727 base,
1728 derive_issue_service_account_credential_canonical(1_700_000_000, "sa-1", &[0xBB; 32],),
1729 );
1730 }
1731
1732 #[test]
1733 fn device_auth_fallback_is_limited_to_lagging_weft_stub() {
1734 let lagging = tonic::Status::unimplemented(
1735 "MintBiscuit DeviceAuthProof is not implemented yet; use ExchangeDeviceAuthorization for now",
1736 );
1737 assert!(should_fallback_to_exchange_device_authorization(&lagging));
1738
1739 let unrelated = tonic::Status::unimplemented("some other endpoint is missing");
1740 assert!(!should_fallback_to_exchange_device_authorization(
1741 &unrelated
1742 ));
1743
1744 let denied = tonic::Status::permission_denied(
1745 "MintBiscuit DeviceAuthProof signature verification failed",
1746 );
1747 assert!(!should_fallback_to_exchange_device_authorization(&denied));
1748 }
1749
1750 struct TextCtx;
1752
1753 impl CliContext for TextCtx {
1754 fn repo_path(&self) -> Option<&std::path::Path> {
1755 None
1756 }
1757 fn operation_id_wire(&self) -> String {
1758 String::new()
1759 }
1760 fn should_output_json(&self, _repo_config: Option<&repo::Config>) -> bool {
1761 false
1762 }
1763 }
1764
1765 fn with_isolated_home<T>(f: impl FnOnce() -> T) -> T {
1769 let _guard = credentials::lock_test_env();
1770 let temp = tempfile::TempDir::new().expect("temp home");
1771 let prev_home = std::env::var_os("HOME");
1772 let prev_heddle_home = std::env::var_os("HEDDLE_HOME");
1773 unsafe {
1774 std::env::set_var("HOME", temp.path());
1775 std::env::remove_var("HEDDLE_HOME");
1776 }
1777 let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
1778 unsafe {
1779 match prev_home {
1780 Some(value) => std::env::set_var("HOME", value),
1781 None => std::env::remove_var("HOME"),
1782 }
1783 match prev_heddle_home {
1784 Some(value) => std::env::set_var("HEDDLE_HOME", value),
1785 None => std::env::remove_var("HEDDLE_HOME"),
1786 }
1787 }
1788 drop(temp);
1789 match out {
1790 Ok(value) => value,
1791 Err(payload) => std::panic::resume_unwind(payload),
1792 }
1793 }
1794
1795 fn sample_credential() -> ServerCredential {
1796 ServerCredential {
1797 token: "tkn".to_string(),
1798 subject: "dev".to_string(),
1799 device_id: None,
1800 credential_id: None,
1801 private_key_pem: None,
1802 expires_at: None,
1803 }
1804 }
1805
1806 fn stored_device_parent() -> (ServerCredential, String) {
1807 let signer = Ed25519Signer::generate().expect("device key");
1808 let private_key_pem = signer.to_pem().expect("device PEM");
1809 let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
1810 let token = biscuit_auth::Biscuit::builder()
1811 .fact(r#"user("alice")"#)
1812 .expect("user fact")
1813 .fact(r#"credential_id("root-credential")"#)
1814 .expect("credential fact")
1815 .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
1816 .expect("device PoP fact")
1817 .fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
1818 .expect("expiry fact")
1819 .check(format!("check if time($now), $now < {}", expires_at.to_rfc3339()).as_str())
1820 .expect("expiry check")
1821 .build(&biscuit_auth::KeyPair::new())
1822 .expect("build parent")
1823 .to_base64()
1824 .expect("encode parent");
1825 (
1826 ServerCredential {
1827 token,
1828 subject: "alice".to_string(),
1829 device_id: Some("device-root".to_string()),
1830 credential_id: Some("root-credential".to_string()),
1831 private_key_pem: Some(private_key_pem.clone()),
1832 expires_at: Some(expires_at.to_rfc3339()),
1833 },
1834 private_key_pem,
1835 )
1836 }
1837
1838 #[test]
1839 fn derive_agent_installs_fresh_pop_child_and_supports_narrower_subderivation() {
1840 with_isolated_home(|| {
1841 let server = "grpc.S";
1842 let (parent, private_key_pem) = stored_device_parent();
1843 credentials::store_server_credential(server, parent).expect("store parent");
1844
1845 cmd_auth_derive_agent(
1846 server,
1847 Some("agent-parent".to_string()),
1848 3600,
1849 vec!["repo:acme/heddle".to_string()],
1850 vec!["Push".to_string()],
1851 None,
1852 None,
1853 )
1854 .expect("derive and install parent agent");
1855 let installed = credentials::get_server_credential(server)
1856 .expect("load installed child")
1857 .expect("installed child");
1858 let installed_private_key = installed
1859 .private_key_pem
1860 .as_deref()
1861 .expect("derived child stores its own PoP key");
1862 assert_ne!(
1863 installed_private_key, private_key_pem,
1864 "the parent device private key must never be handed to a derived child"
1865 );
1866 let installed_signer =
1867 Ed25519Signer::from_pem(installed_private_key).expect("parse child PoP key");
1868 assert!(
1869 installed.device_id.is_none(),
1870 "a derived PoP key is not the registered root device key"
1871 );
1872 assert!(
1873 installed.credential_id.is_none(),
1874 "derived tokens must not auto-rotate into an unattenuated token"
1875 );
1876 let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(installed.token.as_bytes())
1877 .expect("parse installed child");
1878 assert_eq!(parsed.block_count(), 2);
1879 assert!(
1880 parsed
1881 .print_block_source(1)
1882 .expect("child block")
1883 .contains("agent_scope(\"repo\", \"acme/heddle\")")
1884 );
1885 assert!(
1886 parsed
1887 .print_block_source(1)
1888 .expect("child block")
1889 .contains(&hex::encode(installed_signer.public_key())),
1890 "the child attenuation block must bind the child's PoP public key"
1891 );
1892
1893 cmd_auth_derive_agent(
1894 server,
1895 Some("agent-child".to_string()),
1896 600,
1897 vec!["repo:acme/heddle/subtree".to_string()],
1898 vec!["Push".to_string()],
1899 None,
1900 None,
1901 )
1902 .expect("derive narrower subagent");
1903 let subagent = credentials::get_server_credential(server)
1904 .expect("load subagent")
1905 .expect("installed subagent");
1906 let subagent_private_key = subagent
1907 .private_key_pem
1908 .as_deref()
1909 .expect("subagent stores its own PoP key");
1910 assert_ne!(
1911 subagent_private_key, installed_private_key,
1912 "each delegation hop must generate a fresh private key"
1913 );
1914 let subagent_signer =
1915 Ed25519Signer::from_pem(subagent_private_key).expect("parse subagent PoP key");
1916 let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(subagent.token.as_bytes())
1917 .expect("parse subagent");
1918 assert_eq!(
1919 parsed.block_count(),
1920 3,
1921 "delegation tree adds one block per hop"
1922 );
1923 assert!(
1924 parsed
1925 .print_block_source(2)
1926 .expect("subagent block")
1927 .contains(&hex::encode(subagent_signer.public_key())),
1928 "the subagent attenuation block must bind the subagent's PoP public key"
1929 );
1930
1931 let error = cmd_auth_derive_agent(
1932 server,
1933 Some("agent-widening".to_string()),
1934 300,
1935 vec!["repo:acme".to_string()],
1936 vec!["Push".to_string()],
1937 None,
1938 None,
1939 )
1940 .expect_err("subagent scope widening must be rejected");
1941 assert!(error.to_string().contains("would widen"));
1942 });
1943 }
1944
1945 #[test]
1946 fn derive_agent_out_writes_one_verifiable_hcred_with_a_fresh_child_key() {
1947 with_isolated_home(|| {
1948 let server = "grpc.S";
1949 let (parent, private_key_pem) = stored_device_parent();
1950 credentials::store_server_credential(server, parent).expect("store parent");
1951 let out = repo::identity::heddle_home_dir().join("agent-export.hcred");
1952
1953 cmd_auth_derive_agent(
1954 server,
1955 Some("agent-export".to_string()),
1956 3600,
1957 vec!["repo:acme/heddle".to_string()],
1958 vec!["Push".to_string()],
1959 None,
1960 Some(&out),
1961 )
1962 .expect("derive portable child credential");
1963
1964 assert!(out.is_file(), "expected a single .hcred file");
1966
1967 let loaded = credential_file::load_credential_file(&out).expect("load written .hcred");
1970 assert_eq!(loaded.server, server);
1971 assert_eq!(loaded.subject, "alice");
1972 assert_eq!(loaded.kind, credential_file::CredentialKind::Agent);
1973 assert_ne!(
1974 loaded.proof_key_pem, private_key_pem,
1975 "the .hcred must carry a fresh child key, never the parent device key"
1976 );
1977 let provenance = loaded.provenance.expect("audit provenance recorded");
1978 assert_eq!(provenance.agent_id.as_deref(), Some("agent-export"));
1979 assert_eq!(
1980 provenance.scopes.as_deref(),
1981 Some(["repo:acme/heddle".to_string()].as_slice())
1982 );
1983 assert_eq!(
1984 provenance.allowed_operations.as_deref(),
1985 Some(["Push".to_string()].as_slice())
1986 );
1987
1988 let child_signer =
1989 Ed25519Signer::from_pem(&loaded.proof_key_pem).expect("parse child key");
1990 let parsed = biscuit_auth::UnverifiedBiscuit::from_base64(loaded.token.as_bytes())
1991 .expect("token is a Biscuit");
1992 assert!(
1993 parsed
1994 .print_block_source(1)
1995 .expect("child block")
1996 .contains(&hex::encode(child_signer.public_key())),
1997 "the token must bind the key packaged alongside it"
1998 );
1999
2000 let error = cmd_auth_derive_agent(
2001 server,
2002 Some("agent-export-again".to_string()),
2003 3600,
2004 vec!["repo:acme/heddle".to_string()],
2005 vec!["Push".to_string()],
2006 None,
2007 Some(&out),
2008 )
2009 .expect_err("an existing credential file must not be overwritten");
2010 assert!(error.to_string().contains("already exists"));
2011 });
2012 }
2013
2014 #[test]
2015 fn derive_agent_allow_flag_cannot_select_unsafe_operations() {
2016 for operation in [
2017 "CreateServiceAccount",
2018 "IssueServiceAccountCredential",
2019 "DeleteRepository",
2020 "DeleteNamespace",
2021 ] {
2022 let error = resolve_agent_operations(None, vec![operation.to_string()])
2023 .expect_err("unsafe operation must be outside CLI ceiling");
2024 assert!(
2025 error
2026 .to_string()
2027 .contains("outside the safe agent operation ceiling")
2028 );
2029 }
2030 }
2031
2032 #[test]
2033 fn template_expands_to_a_curated_allow_set() {
2034 let reviewer = resolve_agent_operations(Some(AgentTemplate::Reviewer), Vec::new())
2035 .expect("reviewer template resolves");
2036 assert!(reviewer.contains(&"GetState".to_string()));
2037 assert!(reviewer.contains(&"Pull".to_string()));
2038 assert!(!reviewer.contains(&"Push".to_string()));
2040 assert!(!reviewer.contains(&"UpdateRef".to_string()));
2041 assert!(!reviewer.contains(&"SetContext".to_string()));
2042
2043 let contributor = resolve_agent_operations(Some(AgentTemplate::Contributor), Vec::new())
2044 .expect("contributor template resolves");
2045 assert!(contributor.contains(&"Push".to_string()));
2046 assert!(contributor.contains(&"SetContext".to_string()));
2047 assert!(contributor.contains(&"OpenDiscussion".to_string()));
2048
2049 let ci = resolve_agent_operations(Some(AgentTemplate::CiLanding), Vec::new())
2050 .expect("ci-landing template resolves");
2051 assert!(ci.contains(&"Push".to_string()));
2052 assert!(ci.contains(&"UpdateRef".to_string()));
2053 assert!(ci.contains(&"Pull".to_string()));
2054 assert!(!ci.contains(&"OpenDiscussion".to_string()));
2056 assert!(!ci.contains(&"SetContext".to_string()));
2057 }
2058
2059 #[test]
2060 fn explicit_allow_only_narrows_a_template() {
2061 let narrowed = resolve_agent_operations(
2063 Some(AgentTemplate::Reviewer),
2064 vec!["GetState".to_string()],
2065 )
2066 .expect("narrowing within the template is allowed");
2067 assert_eq!(narrowed, vec!["GetState".to_string()]);
2068
2069 let error = resolve_agent_operations(
2071 Some(AgentTemplate::Reviewer),
2072 vec!["Push".to_string()],
2073 )
2074 .expect_err("a template cannot be widened by --allow");
2075 assert!(error.to_string().contains("outside"));
2076 }
2077
2078 #[test]
2079 fn auth_status_qualifies_a_credential_without_a_proof_key() {
2080 let credential = sample_credential();
2081 let resolved = crate::grpc_hosted::ResolvedHostedCredential {
2082 token: Some(wire::AuthToken::new(credential.token, "credential-store")),
2083 proof_key_pem: credential.private_key_pem,
2084 renewable: None,
2085 subject: Some(credential.subject),
2086 credential_id: credential.credential_id,
2087 expires_at: credential.expires_at,
2088 source: crate::grpc_hosted::CredentialSource::Keystore,
2089 };
2090 let output = auth_status_output("grpc.S", &resolved);
2091
2092 assert!(output.authenticated);
2093 assert_eq!(output.source, "keystore");
2094 assert!(!output.proof_key_available);
2095 assert!(
2096 output
2097 .recommended_action
2098 .as_deref()
2099 .is_some_and(|action| action.contains("auth login --server grpc.S"))
2100 );
2101 }
2102
2103 #[tokio::test]
2104 async fn login_with_missing_credential_file_fails_closed() {
2105 let error = cmd_auth(
2106 &TextCtx,
2107 AuthCommand::Login {
2108 server: None,
2109 open_browser: false,
2110 credential: Some(std::path::PathBuf::from("/definitely/not/here.hcred")),
2111 },
2112 )
2113 .await
2114 .expect_err("a missing credential file must fail");
2115 assert!(error.to_string().contains("opening credential file"));
2116 }
2117
2118 #[tokio::test]
2119 async fn login_with_device_credential_file_installs_and_links_identity() {
2120 let temp = tempfile::TempDir::new().expect("temp home");
2123 let _guard = credentials::lock_test_env();
2124 let prev_home = std::env::var_os("HOME");
2125 let prev_heddle_home = std::env::var_os("HEDDLE_HOME");
2126 unsafe {
2127 std::env::set_var("HOME", temp.path());
2128 std::env::remove_var("HEDDLE_HOME");
2129 }
2130
2131 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2132 let server = "grpc.device";
2133 let signer = Ed25519Signer::generate().expect("device key");
2134 let expires_at = chrono::Utc::now() + chrono::Duration::hours(2);
2135 let token = biscuit_auth::Biscuit::builder()
2136 .fact(r#"user("alice")"#)
2137 .expect("user fact")
2138 .fact(r#"credential_id("root-credential")"#)
2139 .expect("credential fact")
2140 .fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
2141 .expect("device PoP fact")
2142 .fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
2143 .expect("expiry fact")
2144 .check(format!("check if time($now), $now < {}", expires_at.to_rfc3339()).as_str())
2145 .expect("expiry check")
2146 .build(&biscuit_auth::KeyPair::new())
2147 .expect("build device token")
2148 .to_base64()
2149 .expect("encode device token");
2150 let path = repo::identity::heddle_home_dir().join("device.hcred");
2151 credential_file::write_credential_file(
2152 &path,
2153 &VerifiedCredential {
2154 server: server.to_string(),
2155 kind: CredentialKind::Device,
2156 subject: "alice".to_string(),
2157 token,
2158 proof_key_pem: signer.to_pem().expect("device PEM"),
2159 expires_at: Some(expires_at.to_rfc3339()),
2160 credential_id: Some("root-credential".to_string()),
2161 provenance: None,
2162 },
2163 )
2164 .expect("write device .hcred");
2165
2166 let subject = install_credential_file(&path).expect("install device credential");
2167 assert_eq!(subject, "alice");
2168
2169 let stored = credentials::get_server_credential(server)
2170 .expect("load stored")
2171 .expect("credential stored under the file's server");
2172 assert_eq!(stored.subject, "alice");
2173 assert_eq!(stored.credential_id.as_deref(), Some("root-credential"));
2174 assert!(
2175 repo::identity::device_identity_path().exists(),
2176 "a device-kind credential registers the host signing identity",
2177 );
2178 }));
2179
2180 unsafe {
2181 match prev_home {
2182 Some(value) => std::env::set_var("HOME", value),
2183 None => std::env::remove_var("HOME"),
2184 }
2185 match prev_heddle_home {
2186 Some(value) => std::env::set_var("HEDDLE_HOME", value),
2187 None => std::env::remove_var("HEDDLE_HOME"),
2188 }
2189 }
2190 if let Err(payload) = result {
2191 std::panic::resume_unwind(payload);
2192 }
2193 }
2194
2195 #[test]
2198 fn logout_removes_credential_and_device_identity_on_success() {
2199 with_isolated_home(|| {
2200 credentials::store_server_credential("grpc.S", sample_credential())
2201 .expect("store credential");
2202
2203 let signer = Ed25519Signer::generate().expect("keypair");
2205 repo::identity::link_device_key(
2206 signer.public_key(),
2207 &signer.to_pem().expect("pem"),
2208 "grpc.S",
2209 )
2210 .expect("link device key");
2211 assert!(repo::identity::device_identity_path().exists());
2212
2213 cmd_auth_logout(&TextCtx, None).expect("logout succeeds");
2216
2217 assert!(
2218 credentials::get_server_credential("grpc.S")
2219 .expect("load")
2220 .is_none(),
2221 "credential must be removed on a successful logout",
2222 );
2223 assert!(
2224 !repo::identity::device_identity_path().exists(),
2225 "device identity must be removed on a successful logout",
2226 );
2227 });
2228 }
2229
2230 #[test]
2237 fn logout_preserves_credential_when_device_unlink_fails() {
2238 with_isolated_home(|| {
2239 credentials::store_server_credential("grpc.S", sample_credential())
2240 .expect("store credential");
2241
2242 let device_path = repo::identity::device_identity_path();
2243 std::fs::create_dir_all(device_path.parent().expect("home parent")).expect("home dir");
2244 std::fs::write(
2245 &device_path,
2246 b"!!! definitely not valid device-identity toml !!!",
2247 )
2248 .expect("write corrupt device identity");
2249
2250 let result = cmd_auth_logout(&TextCtx, None);
2251 assert!(
2252 result.is_err(),
2253 "a failed device unlink must fail the logout, not report a clean removal",
2254 );
2255
2256 assert!(
2257 credentials::get_server_credential("grpc.S")
2258 .expect("load")
2259 .is_some(),
2260 "credential must be preserved when unlink fails, so logout is retryable",
2261 );
2262 assert_eq!(
2263 credentials::default_server().expect("default").as_deref(),
2264 Some("grpc.S"),
2265 "default server must be preserved so a no-arg retry re-targets the same server",
2266 );
2267 });
2268 }
2269}