Skip to main content

heddle_client/
auth_cmd.rs

1//! `heddle auth` command implementations.
2
3use 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/// Top-level dispatch for `heddle auth <subcommand>`. The CLI context supplies
36/// output mode and the caller-owned operation ID for hosted mutations; auth
37/// credential state itself remains global rather than repository-local.
38#[derive(Serialize)]
39struct AuthLogoutOutput {
40    output_kind: &'static str,
41    server: String,
42    removed: bool,
43    /// Whether a device signing identity recorded for this server (heddle#482)
44    /// was removed. `false` when none was linked; on a removal failure logout
45    /// errors instead of emitting this output, so a `true` here always means
46    /// the logged-out private key is no longer on disk.
47    device_identity_removed: bool,
48}
49
50#[derive(Serialize)]
51struct AuthStatusOutput {
52    output_kind: &'static str,
53    server: String,
54    authenticated: bool,
55    /// Credential origin: `env:<path>` when `HEDDLE_CREDENTIAL` is set,
56    /// `keystore` for a stored credential, `none` when unauthenticated.
57    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    /// Absolute path of the `.hcred` credential file written with mode 0600.
72    /// The token and proof key never appear on stdout or in JSON.
73    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/// Derive an offline child credential with a fresh PoP key, then either install
133/// it as the active credential or write a portable token + child-key bundle.
134#[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            // W3 (weft#644): the server injects a `resource("repo", <path>)`
195            // fact per request, so emit the ENFORCEABLE resource caveat. A
196            // `namespace:` scope is encoded client-side as a repo-path prefix
197            // (see `build_attenuation_block`) because the server never emits a
198            // `resource("namespace", …)` fact. `agent_scope` facts are still
199            // recorded (`declared_scopes`) for audit + narrowing checks.
200            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            // A derived child must never auto-rotate through MintBiscuit (that
229            // would drop this block's caveats), so it carries no credential id.
230            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    // The installed derived token must not auto-rotate through MintBiscuit:
244    // renewal would produce a fresh authority token without this block's
245    // caveats. Keeping credential_id unset disables the client's rotation path
246    // while preserving the authority block and server-side revocation bindings.
247    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
281/// Resolve the final operation ceiling for a derived agent.
282///
283/// The base set is the template's curated subset (when `--template` is given)
284/// or the full [`SAFE_AGENT_OPERATIONS`] ceiling otherwise. An explicit
285/// `--allow` may only *narrow* that base: every requested operation must be a
286/// member of the base set, and the result is their intersection. This keeps
287/// `--template` pure sugar over `--allow` — it can never widen the ceiling.
288fn 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        // Omitting a scope adds no new restriction; every ancestor's resource
344        // caveat remains in the immutable chain and keeps being enforced.
345        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
420/// Install a verified `.hcred` credential file without a browser.
421///
422/// [`credential_file::load_credential_file`] is the trust chokepoint: it
423/// re-verifies the proof key, subject, and expiry against the token before we
424/// store anything. The server is taken from the file. A `device`-kind
425/// credential is a machine bootstrap key, so it also registers the host's
426/// device signing identity (heddle#482); `agent`/`service` credentials never
427/// do.
428pub(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    // An attenuated token must never use keypair renewal: MintBiscuit would
481    // return a new authority token without the appended caveats. The
482    // authority credential id remains cryptographically intact in the token,
483    // but is intentionally omitted from the local child credential metadata.
484    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
552/// Authenticate via device authorization flow.
553async fn cmd_auth_login(server: &str, open_browser: bool) -> Result<()> {
554    // 1. Generate Ed25519 keypair for device binding.
555    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    // 2. Connect to the auth service.
563    let mut auth_client: IdentityServiceClient<Channel> = connect_auth_client(server).await?;
564
565    // 3. Create device authorization.
566    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    // 4. Print instructions.
588    println!();
589    println!("Open this URL to authorize:");
590    println!("  {verification_uri}");
591    println!();
592    println!("Enter code: {user_code}");
593    println!();
594
595    // 5. Attempt to open browser. The verification URI is server-controlled,
596    // so validate scheme/host (and reject shell metacharacters) before
597    // spawning a browser helper — especially on Windows where `cmd /C start`
598    // would otherwise interpret the URL.
599    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    // 6. Poll for approval.
616    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    // 7. Store credential.
628    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    // Reconcile the local signing identity with the device key (heddle#482):
647    // record the device key as the machine's active signing identity so
648    // subsequent captures sign with it (it supersedes any per-repo local key;
649    // states already signed by a local key keep verifying). Best-effort — a
650    // failure here just leaves captures signing with the local key.
651    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
664/// Remove stored credentials.
665fn cmd_auth_logout(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
666    let server = resolve_server(server)?;
667
668    // Remove the device signing identity `auth login` recorded for THIS server
669    // BEFORE dropping the credential (heddle#482 ordering). Fail-closed: if a
670    // matching device key is on disk but can't be removed, surface the error
671    // rather than reporting a clean logout while the logged-out private key —
672    // which `signing_signer()` would keep preferring for every capture — still
673    // persists. Unlinking first keeps a failed logout retryable: were the
674    // credential removed first, `resolve_server` would no longer resolve back to
675    // this server, so a no-arg retry could never re-target the matching
676    // `device-identity.toml` still on disk. Holding the credential until the
677    // unlink succeeds preserves the same server resolution for the retry.
678    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
700/// Show current authentication status.
701fn cmd_auth_status(ctx: &dyn CliContext, server: Option<&str>) -> Result<()> {
702    let server = resolve_server(server)?;
703    // Report through the SAME precedence the runtime authenticates with, so
704    // `auth status` reflects what a hosted op would actually use — including a
705    // `HEDDLE_CREDENTIAL` that overrides the keystore.
706    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
779// ---------------------------------------------------------------------------
780// Create service token
781// ---------------------------------------------------------------------------
782
783/// Create a namespace-scoped service token for CI/ephemeral runners.
784///
785/// Emits a single self-verifying `.hcred` credential file. The token and proof
786/// key never touch stdout or the JSON contract — only the credential path,
787/// scope, and expiry are reported.
788async 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    // Resolve (and fail-fast reject an existing) credential path BEFORE any
799    // server round trip, so a name collision doesn't strand a created service
800    // account with no locally-written credential.
801    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    // Select and validate the exact stored bearer + matching device proof key
810    // before generating the new service-account key.
811    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    // Generate a fresh Ed25519 keypair for the service account credential.
825    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    // 1. Create the service account.
833    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    // 2. Issue a credential (token) for the service account.
850    let credential_request = IssueServiceAccountCredentialRequest {
851        service_account_id: sa_response.service_account_id,
852        public_key: public_key_bytes,
853        scope: scope.clone(),
854        // CLI-issued tokens retain their pre-TTL behaviour: 30-day
855        // expiry from the server's default (applied when ttl_secs == 0
856        // in the handler is "never expires", so pass 30 days here
857        // explicitly to preserve prior semantics).
858        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    // Re-derive the subject from the issued token so the written credential is
871    // self-consistent (`load_credential_file` re-checks this on every read).
872    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
920/// Resolve where to write the service-account `.hcred` credential.
921///
922/// Prefers an explicit `--out` path; otherwise writes under
923/// `<heddle_home>/service-accounts/<sanitized-name>.hcred`.
924fn 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
1020// ---------------------------------------------------------------------------
1021// Helpers
1022// ---------------------------------------------------------------------------
1023
1024/// Resolve server from explicit arg, default credential, or fallback.
1025pub(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
1035/// Connect a raw gRPC channel to the given server.
1036pub(crate) async fn connect_channel(server: &str) -> Result<Channel> {
1037    let uri = infer_server_uri(server);
1038    // F2: the auth-login / service-token connect path sends the device key and
1039    // receives the bearer biscuit. Refuse cleartext (`http://`) to a
1040    // non-loopback address unless the operator explicitly opts in, mirroring
1041    // the remote paths' `cleartext_connect_allowed` gate. Loopback stays free.
1042    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
1051/// Refuse a cleartext (`http://`) auth connection to a non-loopback address
1052/// unless the operator has opted in via `HEDDLE_REMOTE_INSECURE`.
1053///
1054/// This routes the auth-login / service-token connect path through the same
1055/// `cleartext_connect_allowed` semantics the remote paths use: TLS is always
1056/// allowed, cleartext to loopback is allowed, cleartext to a non-loopback
1057/// address is rejected unless the insecure opt-in is set. Fail-closed: an
1058/// `http://` URI whose host is not a parseable loopback IP literal is treated
1059/// as non-loopback and refused without the opt-in.
1060fn enforce_auth_cleartext_gate(uri: &str) -> Result<()> {
1061    // Only cleartext connections are gated; `https://` is always permitted.
1062    let Some(rest) = uri.strip_prefix("http://") else {
1063        return Ok(());
1064    };
1065
1066    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1067    // Drop userinfo if present, then split host[:port].
1068    let hostport = authority.rsplit('@').next().unwrap_or(authority);
1069    let host = if let Some(inner) = hostport.strip_prefix('[') {
1070        // IPv6 literal: [::1]:port
1071        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    // `localhost` resolves to loopback but is not an `IpAddr`; treat it as
1080    // allowed. Any host that does not parse as a loopback IP literal is
1081    // fail-closed non-loopback.
1082    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        // Non-loopback cleartext: honor the insecure opt-in and reuse the
1090        // remote gate's error message verbatim.
1091        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    // Non-IP-literal cleartext host (e.g. `localhost`-alias or bare name that
1100    // `infer_server_uri` chose `http://` for): fail-closed unless opted in.
1101    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
1110/// Whether the operator opted in to non-loopback cleartext for the auth path.
1111///
1112/// There is no `--insecure` flag on the auth subcommands, so this honors the
1113/// same `HEDDLE_REMOTE_INSECURE` environment opt-in the remote paths accept.
1114fn 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
1131/// Connect an unauthenticated `IdentityServiceClient` to the given server.
1132async 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
1164/// Poll `MintBiscuit(DeviceAuthProof)` until the device code is approved or
1165/// the authorization expires.
1166async 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
1320/// Extracted token fields from `AccessTokenResponse`.
1321struct AccessToken {
1322    token: String,
1323    subject: String,
1324    expires_at: Option<prost_types::Timestamp>,
1325    credential_id: String,
1326}
1327
1328/// Validate a URL before handing it to a browser helper.
1329///
1330/// Accepts only `https://` URLs, or `http://` when the host is loopback
1331/// (`localhost`, `127.0.0.1`, `::1`). Rejects empty strings, control
1332/// characters, and shell metacharacters that are unsafe for Windows
1333/// `cmd /C start` even when passed as separate argv elements (including `%`
1334/// env-var expansion and `<`/`>` redirection).
1335///
1336/// Validation is the primary control; Windows still uses the safer
1337/// `start "" <url>` form after this check passes.
1338pub(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        // Fail-closed: reject control chars and every shell/`cmd` metacharacter
1344        // unsafe for `cmd /C start`. `%` enables env-var expansion (`%VAR%`),
1345        // and `<`/`>` enable redirection — a hostile auth server could use any
1346        // of these to inject via the Windows browser launcher.
1347        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    // Authority ends at the first path/query/fragment delimiter.
1369    let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
1370    if authority.is_empty() {
1371        bail!("browser URL is missing a host");
1372    }
1373    // Drop userinfo if present.
1374    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    // Spaces already rejected above; also refuse empty host labels.
1380    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        // IPv6 literal: [::1]:port
1393        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
1412/// Percent-encode a query component using the unreserved set (RFC 3986).
1413fn 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
1429/// Best-effort browser open. Caller MUST pass a URL that already passed
1430/// [`validate_browser_url`]. On Windows, validation is the primary control
1431/// against command injection via `cmd /C start`.
1432fn open_url(url: &str) -> Result<()> {
1433    // Defense in depth: refuse to open unvalidated URLs even if a caller
1434    // forgets the pre-check.
1435    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        // Empty title argument prevents `start` from treating a quoted URL
1448        // as a window title. Only invoked after validate_browser_url.
1449        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        // `%` enables Windows env-var expansion (`%VAR%`) via `cmd /C start`.
1489        assert!(
1490            validate_browser_url("https://evil.com/%USERPROFILE%").is_err(),
1491            "percent (env-var expansion) must be rejected"
1492        );
1493        // `<` / `>` enable redirection.
1494        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        // A crafted device/auth URL combining them must not slip through.
1503        assert!(validate_browser_url("https://evil.com/?x=%TEMP%>out").is_err());
1504    }
1505
1506    /// Serializes tests that mutate `HEDDLE_REMOTE_INSECURE`.
1507    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            // Fail-closed: a bare non-loopback host that inferred http:// is also
1545            // refused.
1546            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    /// Minimal `CliContext` for the logout tests — text output, no repo.
1751    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    /// Run `f` with `HOME` pointed at a fresh temp dir and `HEDDLE_HOME` cleared,
1766    /// so the credential store and device identity both resolve under
1767    /// `<temp>/.heddle`. Serialised with the credential store's env tests.
1768    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            // Exactly one file is emitted — no sibling token/key/metadata files.
1965            assert!(out.is_file(), "expected a single .hcred file");
1966
1967            // The self-verifying load chokepoint accepts it and re-derives the
1968            // effective identity from the token.
1969            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        // Reviewer grants no writes / ref moves.
2039        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        // CI landing grants no collaboration writes.
2055        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        // `--allow GetState` intersects the reviewer set: result is just GetState.
2062        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        // `--allow Push` is outside the reviewer set, so it cannot widen it.
2070        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        // `with_isolated_home` guards a process-global env mutation; run the
2121        // async install on the current thread inside that guard.
2122        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    /// On a successful logout, both the credential and the matching device
2196    /// signing identity are removed (heddle#482).
2197    #[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            // Record a matching device identity via the real link path.
2204            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            // No explicit --server: resolve_server falls back to the stored
2214            // default written by `store_server_credential`.
2215            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    /// Ordering (heddle#482): logout unlinks the device identity BEFORE dropping
2231    /// the credential, so a fail-closed unlink leaves the credential/default
2232    /// intact and `heddle auth logout` stays retryable with the SAME server
2233    /// resolution. A corrupt device-identity file stands in for any unlink
2234    /// failure (it fails the parse). Were the credential removed first, a no-arg
2235    /// retry could no longer resolve back to this server.
2236    #[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}