use std::{net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result};
use cli_shared::{ClientConfig, UserConfig};
use crypto::{Ed25519Signer, Signer};
use tonic::transport::Channel;
use wire::{AuthToken, ProtocolError};
use crate::{
credentials,
grpc_hosted::{HostedGrpcClient, RenewableAuthorityCredential},
};
const HEDDLE_CREDENTIAL_ENV: &str = "HEDDLE_CREDENTIAL";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialSource {
Env(PathBuf),
Keystore,
Unauthenticated,
}
impl CredentialSource {
pub fn label(&self) -> String {
match self {
CredentialSource::Env(path) => format!("env:{}", path.display()),
CredentialSource::Keystore => "keystore".to_string(),
CredentialSource::Unauthenticated => "none".to_string(),
}
}
}
pub struct ResolvedHostedCredential {
pub token: Option<AuthToken>,
pub proof_key_pem: Option<String>,
pub(crate) renewable: Option<RenewableAuthorityCredential>,
pub subject: Option<String>,
pub credential_id: Option<String>,
pub expires_at: Option<String>,
pub source: CredentialSource,
}
impl std::fmt::Debug for ResolvedHostedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedHostedCredential")
.field("token", &self.token.as_ref().map(|_| "<redacted>"))
.field("proof_key_pem", &self.proof_key_pem.as_ref().map(|_| "<redacted>"))
.field("renewable", &self.renewable.is_some())
.field("subject", &self.subject)
.field("credential_id", &self.credential_id)
.field("expires_at", &self.expires_at)
.field("source", &self.source)
.finish()
}
}
fn heddle_credential_env_path() -> Result<Option<PathBuf>> {
match std::env::var(HEDDLE_CREDENTIAL_ENV) {
Ok(value) => {
if value.is_empty() {
anyhow::bail!(
"HEDDLE_CREDENTIAL is set but empty; unset it to use the stored \
credential, or point it at a .hcred file"
);
}
if value.starts_with('{') || value.contains('\n') {
anyhow::bail!(
"HEDDLE_CREDENTIAL takes a file path, not credential contents"
);
}
Ok(Some(PathBuf::from(value)))
}
Err(std::env::VarError::NotPresent) => Ok(None),
Err(err @ std::env::VarError::NotUnicode(_)) => {
anyhow::bail!("HEDDLE_CREDENTIAL is not valid UTF-8: {err}")
}
}
}
pub fn resolve_hosted_credential(server_key: Option<&str>) -> Result<ResolvedHostedCredential> {
if let Some(path) = heddle_credential_env_path()? {
let verified = crate::credential_file::load_credential_file(&path)
.with_context(|| format!("loading HEDDLE_CREDENTIAL {}", path.display()))?;
if let Some(target) = server_key
&& !server_keys_match(&verified.server, target)
{
anyhow::bail!(
"HEDDLE_CREDENTIAL {} authenticates server {:?}, but this operation targets {:?}; \
point HEDDLE_CREDENTIAL at a credential minted for {}",
path.display(),
verified.server,
target,
target,
);
}
return Ok(ResolvedHostedCredential {
token: Some(AuthToken::new(verified.token, "hcred-env")),
proof_key_pem: Some(verified.proof_key_pem),
renewable: None,
subject: Some(verified.subject),
credential_id: verified.credential_id,
expires_at: verified.expires_at,
source: CredentialSource::Env(path),
});
}
if let Some(key) = server_key {
if let Some(cred) = credentials::resolve_credential_for_server(key)? {
let renewable = RenewableAuthorityCredential::from_stored(&cred);
return Ok(ResolvedHostedCredential {
token: Some(AuthToken::new(cred.token, "credential-store")),
proof_key_pem: cred.private_key_pem,
renewable,
subject: Some(cred.subject),
credential_id: cred.credential_id,
expires_at: cred.expires_at,
source: CredentialSource::Keystore,
});
}
}
Ok(ResolvedHostedCredential {
token: None,
proof_key_pem: None,
renewable: None,
subject: None,
credential_id: None,
expires_at: None,
source: CredentialSource::Unauthenticated,
})
}
pub fn resolve_active_bearer() -> Result<Option<AuthToken>> {
let server = credentials::default_server()?;
Ok(resolve_hosted_credential(server.as_deref())?.token)
}
pub struct HostedSession {
config: ClientConfig,
renewable_authority_credential: Option<RenewableAuthorityCredential>,
}
impl HostedSession {
pub fn build_stored_credential(user_config: &UserConfig, server_key: &str) -> Result<Self> {
let credential = credentials::get_server_credential(server_key)?.ok_or_else(|| {
anyhow::anyhow!(weft_client_shim::HostedRecoveryAdvice::auth_required(
server_key
))
})?;
let proof_key = validated_stored_proof_key(&credential, server_key)?;
let authenticated_principal = validated_authenticated_principal(&credential)?;
let renewable_authority_credential = RenewableAuthorityCredential::from_stored(&credential);
let token = AuthToken::new(credential.token, "credential-store");
let mut config = user_config.heddle_client_config(Some(token))?;
config = config
.with_server_key(server_key.to_string())
.with_auth_proof_key_pem(proof_key)
.with_authenticated_principal(authenticated_principal);
Ok(Self {
config,
renewable_authority_credential,
})
}
pub fn build(user_config: &UserConfig, server_key: Option<String>) -> Result<Self> {
let ResolvedHostedCredential {
token,
proof_key_pem: mut credential_proof_key,
renewable: renewable_authority_credential,
subject: resolved_credential_subject,
..
} = resolve_hosted_credential(server_key.as_deref())?;
if credential_proof_key.is_none()
&& let Some(ref key) = server_key
&& let Some(token) = token.as_ref()
{
credential_proof_key = shared_device_proof_key(key, &token.id)?;
}
let mut config = user_config.heddle_client_config(token)?;
if let Some(key) = server_key {
config = config.with_server_key(key);
}
if let Some(pem) = credential_proof_key
&& config.auth_proof_key_pem.is_none()
{
config = config.with_auth_proof_key_pem(pem);
}
if config.auth_proof_key_pem.is_some() {
let token = config.token.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"hosted request signing has a proof key but no authenticated bearer token"
)
})?;
let subject = crate::device_flow::authenticated_subject(&token.id)
.context("reading the hosted bearer token's authenticated principal")?;
if resolved_credential_subject
.as_deref()
.is_some_and(|resolved| resolved != subject.as_str())
{
anyhow::bail!(
"resolved credential subject does not match the bearer token's authenticated principal"
);
}
config = config.with_authenticated_principal(format!("principal:{subject}"));
}
Ok(Self {
config,
renewable_authority_credential,
})
}
pub fn with_allow_insecure(mut self, allow: bool) -> Self {
if allow {
self.config.allow_insecure = true;
}
self
}
pub async fn connect(&self, addr: SocketAddr) -> Result<HostedGrpcClient, ProtocolError> {
let mut client = HostedGrpcClient::connect(addr, &self.config).await?;
client
.auto_rotate_if_needed(self.renewable_authority_credential.as_ref())
.await;
Ok(client)
}
pub async fn connect_channel(
&self,
channel: Channel,
) -> Result<HostedGrpcClient, ProtocolError> {
let mut client = HostedGrpcClient::from_channel(channel, &self.config)?;
client
.auto_rotate_if_needed(self.renewable_authority_credential.as_ref())
.await;
Ok(client)
}
}
fn validated_authenticated_principal(credential: &credentials::ServerCredential) -> Result<String> {
let subject = crate::device_flow::authenticated_subject(&credential.token)
.context("reading the stored credential's authenticated principal")?;
if subject != credential.subject {
anyhow::bail!(
"stored credential subject does not match the bearer token's authenticated principal"
);
}
Ok(format!("principal:{subject}"))
}
fn validated_stored_proof_key(
credential: &credentials::ServerCredential,
server_key: &str,
) -> Result<String> {
let pem = credential.private_key_pem.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"stored credential for {server_key} has no device proof key; run `heddle auth login --server {server_key}` first"
)
})?;
let signer = Ed25519Signer::from_pem(pem)
.map_err(|error| anyhow::anyhow!("stored device proof key is invalid: {error}"))?;
let token_key = crate::device_flow::effective_pop_public_key_hex(&credential.token)
.context("reading the stored credential's effective proof key")?;
if !token_key.eq_ignore_ascii_case(&hex::encode(signer.public_key())) {
anyhow::bail!("stored device proof key does not match the credential Biscuit");
}
Ok(pem.to_string())
}
fn shared_device_proof_key(server_key: &str, token: &str) -> Result<Option<String>> {
let identity = repo::identity::load_device(&repo::identity::device_identity_path())
.context("loading this host's shared device identity")?;
let Some(identity) = identity else {
return Ok(None);
};
if !server_keys_match(&identity.server, server_key)
|| !token_proof_key_matches(token, &identity.public_key)
{
return Ok(None);
}
Ok(Some(identity.private_key_pem))
}
fn token_proof_key_matches(token: &str, expected_public_key_hex: &str) -> bool {
crate::device_flow::effective_pop_public_key_hex(token)
.is_ok_and(|proof_key| proof_key.eq_ignore_ascii_case(expected_public_key_hex))
}
fn server_keys_match(left: &str, right: &str) -> bool {
fn without_scheme(value: &str) -> &str {
value
.strip_prefix("http://")
.or_else(|| value.strip_prefix("https://"))
.or_else(|| value.strip_prefix("heddle://"))
.unwrap_or(value)
}
without_scheme(left) == without_scheme(right)
}
impl HostedGrpcClient {
pub async fn open_session(
addr: SocketAddr,
user_config: &UserConfig,
server_key: Option<String>,
) -> Result<Self> {
Self::open_session_with_insecure(addr, user_config, server_key, false).await
}
pub async fn open_session_with_insecure(
addr: SocketAddr,
user_config: &UserConfig,
server_key: Option<String>,
allow_insecure: bool,
) -> Result<Self> {
Ok(HostedSession::build(user_config, server_key)?
.with_allow_insecure(allow_insecure)
.connect(addr)
.await?)
}
}
#[cfg(test)]
mod tests {
use biscuit_auth::{Biscuit, KeyPair};
use crypto::{Ed25519Signer, Signer};
use super::{HostedSession, validated_authenticated_principal, validated_stored_proof_key};
use crate::credentials;
fn proof_bound_credential(signer: &Ed25519Signer) -> credentials::ServerCredential {
let token = Biscuit::builder()
.fact(r#"user("alice")"#)
.expect("user fact")
.fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
.expect("proof key fact")
.build(&KeyPair::new())
.expect("mint credential")
.to_base64()
.expect("encode credential");
credentials::ServerCredential {
token,
subject: "alice".to_string(),
device_id: Some("device-1".to_string()),
credential_id: None,
private_key_pem: Some(signer.to_pem().expect("proof PEM")),
expires_at: None,
}
}
#[test]
fn stored_session_rejects_missing_or_mismatched_proof_keys_before_connect() {
let signer = Ed25519Signer::generate().expect("proof signer");
let mut missing = proof_bound_credential(&signer);
missing.private_key_pem = None;
let error = validated_stored_proof_key(&missing, "grpc.example")
.expect_err("a missing proof key must fail before connect");
assert!(error.to_string().contains("has no device proof key"));
let wrong_signer = Ed25519Signer::generate().expect("wrong proof signer");
let mut mismatched = proof_bound_credential(&signer);
mismatched.private_key_pem = Some(wrong_signer.to_pem().expect("wrong PEM"));
let error = validated_stored_proof_key(&mismatched, "grpc.example")
.expect_err("a mismatched proof key must fail before connect");
assert!(error.to_string().contains("does not match"));
}
#[test]
fn stored_session_accepts_the_proof_key_bound_into_its_biscuit() {
let signer = Ed25519Signer::generate().expect("proof signer");
let credential = proof_bound_credential(&signer);
let pem =
validated_stored_proof_key(&credential, "grpc.example").expect("matching proof key");
assert_eq!(pem, credential.private_key_pem.unwrap());
}
#[test]
fn stored_session_rejects_a_subject_that_disagrees_with_its_biscuit() {
let signer = Ed25519Signer::generate().expect("proof signer");
let mut credential = proof_bound_credential(&signer);
credential.subject = "mallory".to_string();
let error = validated_authenticated_principal(&credential)
.expect_err("stored metadata cannot replace the authenticated token subject");
assert!(error.to_string().contains("does not match"));
}
#[test]
fn session_connect_checks_eligible_renewal_after_connect() {
let source = include_str!("session.rs");
let connect_idx = source
.find("HostedGrpcClient::connect(addr, &self.config)")
.expect("session.rs must connect with the resolved addr");
let after_connect = &source[connect_idx..];
let rotate_offset = after_connect
.find("auto_rotate_if_needed")
.expect("auto_rotate_if_needed must appear in session.rs");
assert!(
rotate_offset < 400,
"auto_rotate_if_needed must follow HostedGrpcClient::connect within the \
same async block (found {rotate_offset} chars later)",
);
}
fn mint_authority_token(subject: &str, signer: &Ed25519Signer) -> String {
biscuit_auth::Biscuit::builder()
.fact(format!("user(\"{subject}\")").as_str())
.expect("user fact")
.fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
.expect("proof key fact")
.build(&biscuit_auth::KeyPair::new())
.expect("mint token")
.to_base64()
.expect("encode token")
}
fn write_sample_hcred(path: &std::path::Path, server: &str, subject: &str) {
let signer = Ed25519Signer::generate().expect("proof key");
let token = mint_authority_token(subject, &signer);
crate::credential_file::write_credential_file(
path,
&crate::credential_file::VerifiedCredential {
server: server.to_string(),
kind: crate::credential_file::CredentialKind::Device,
subject: subject.to_string(),
token,
proof_key_pem: signer.to_pem().expect("proof PEM"),
expires_at: None,
credential_id: None,
provenance: None,
},
)
.expect("write sample .hcred");
}
fn with_isolated_env<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
let _guard = crate::credentials::lock_test_env();
let home = tempfile::TempDir::new().expect("temp Heddle home");
let previous_home = std::env::var_os("HEDDLE_HOME");
let previous_credential = std::env::var_os("HEDDLE_CREDENTIAL");
unsafe {
std::env::set_var("HEDDLE_HOME", home.path());
std::env::remove_var("HEDDLE_CREDENTIAL");
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(home.path())));
unsafe {
match previous_home {
Some(path) => std::env::set_var("HEDDLE_HOME", path),
None => std::env::remove_var("HEDDLE_HOME"),
}
match previous_credential {
Some(path) => std::env::set_var("HEDDLE_CREDENTIAL", path),
None => std::env::remove_var("HEDDLE_CREDENTIAL"),
}
}
match result {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
#[test]
fn env_credential_resolves_and_reports_env_source() {
use super::{CredentialSource, resolve_hosted_credential};
with_isolated_env(|home| {
let path = home.join("agent.hcred");
write_sample_hcred(&path, "grpc.heddle.test", "alice");
unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &path) };
let resolved =
resolve_hosted_credential(Some("grpc.heddle.test")).expect("resolve env credential");
assert!(resolved.token.is_some(), "env credential is authoritative");
assert!(resolved.proof_key_pem.is_some(), "env .hcred carries its key");
assert!(
resolved.renewable.is_none(),
"an env credential is never renewed"
);
assert_eq!(resolved.subject.as_deref(), Some("alice"));
assert_eq!(resolved.source, CredentialSource::Env(path));
});
}
#[test]
fn env_credential_inline_contents_are_rejected() {
use super::resolve_hosted_credential;
with_isolated_env(|_home| {
unsafe {
std::env::set_var("HEDDLE_CREDENTIAL", "{\"format\":\"heddle-credential\"}");
}
let error = resolve_hosted_credential(Some("grpc.heddle.test"))
.expect_err("inline contents must be rejected");
assert!(
error.to_string().contains("takes a file path"),
"unexpected error: {error}"
);
});
}
#[test]
fn env_credential_server_mismatch_is_a_hard_error_with_no_keystore_fallback() {
use super::resolve_hosted_credential;
with_isolated_env(|home| {
credentials::store_server_credential(
"grpc.target.test",
credentials::ServerCredential {
token: "keystore-token".to_string(),
subject: "human".to_string(),
device_id: None,
credential_id: None,
private_key_pem: None,
expires_at: None,
},
)
.expect("seed keystore");
let path = home.join("other.hcred");
write_sample_hcred(&path, "grpc.other.test", "agent");
unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &path) };
let error = resolve_hosted_credential(Some("grpc.target.test"))
.expect_err("server mismatch must be a hard error");
let message = error.to_string();
assert!(message.contains("grpc.other.test"), "message: {message}");
assert!(message.contains("grpc.target.test"), "message: {message}");
});
}
#[test]
fn env_credential_unreadable_is_a_hard_error_with_no_keystore_fallback() {
use super::resolve_hosted_credential;
with_isolated_env(|home| {
credentials::store_server_credential(
"grpc.target.test",
credentials::ServerCredential {
token: "keystore-token".to_string(),
subject: "human".to_string(),
device_id: None,
credential_id: None,
private_key_pem: None,
expires_at: None,
},
)
.expect("seed keystore");
let missing = home.join("does-not-exist.hcred");
unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &missing) };
resolve_hosted_credential(Some("grpc.target.test"))
.expect_err("an unreadable HEDDLE_CREDENTIAL must never fall back to the keystore");
});
}
#[test]
fn env_credential_is_authoritative_and_not_renewable_over_a_stored_parent() {
use crypto::{Ed25519Signer, Signer};
with_isolated_env(|home| {
let parent_signer = Ed25519Signer::generate().expect("parent proof key");
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
let parent_token = biscuit_auth::Biscuit::builder()
.fact(r#"user("alice")"#)
.expect("user fact")
.fact(r#"credential_id("cred-parent")"#)
.expect("credential fact")
.fact(
format!(
"device_pop_key(\"{}\")",
hex::encode(parent_signer.public_key())
)
.as_str(),
)
.expect("proof key fact")
.fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
.expect("expiry fact")
.build(&biscuit_auth::KeyPair::new())
.expect("mint parent")
.to_base64()
.expect("encode parent");
let server = "127.0.0.1:8421";
let stored_parent = credentials::ServerCredential {
token: parent_token.clone(),
subject: "alice".to_string(),
device_id: Some("device-parent".to_string()),
credential_id: Some("cred-parent".to_string()),
private_key_pem: Some(parent_signer.to_pem().expect("parent PEM")),
expires_at: Some(expires_at.to_rfc3339()),
};
assert!(
credentials::token_needs_rotation(&stored_parent),
"the stored parent fixture must exercise the renewal window"
);
credentials::store_server_credential(server, stored_parent)
.expect("store nearly expired parent");
let stored_session =
HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
.expect("build stored-parent session");
assert_eq!(
stored_session
.config
.token
.as_ref()
.map(|token| token.id.as_str()),
Some(parent_token.as_str())
);
assert!(
stored_session.renewable_authority_credential.is_some(),
"the exact stored authority token is renewable"
);
let child_signer = Ed25519Signer::generate().expect("child proof key");
let child_token = crate::device_flow::attenuate_for_agent(
&parent_token,
crate::device_flow::AgentAttenuation {
agent_id: "explicit-child".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::minutes(3),
allowed_operations: Some(vec!["Push".to_string()]),
allowed_resources: None,
declared_scopes: Vec::new(),
},
&parent_signer,
child_signer.public_key(),
)
.expect("derive explicit child");
let child_hcred = home.join("child.hcred");
crate::credential_file::write_credential_file(
&child_hcred,
&crate::credential_file::VerifiedCredential {
server: server.to_string(),
kind: crate::credential_file::CredentialKind::Agent,
subject: "alice".to_string(),
token: child_token.clone(),
proof_key_pem: child_signer.to_pem().expect("child PEM"),
expires_at: Some(
(chrono::Utc::now() + chrono::Duration::minutes(3)).to_rfc3339(),
),
credential_id: None,
provenance: None,
},
)
.expect("write child .hcred");
unsafe { std::env::set_var("HEDDLE_CREDENTIAL", &child_hcred) };
let explicit_child_session =
HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
.expect("build explicit-child session");
assert_eq!(
explicit_child_session
.config
.token
.as_ref()
.map(|token| token.id.as_str()),
Some(child_token.as_str()),
"HEDDLE_CREDENTIAL is authoritative over the keystore"
);
assert!(
explicit_child_session
.renewable_authority_credential
.is_none(),
"an env-supplied credential must not borrow the stored parent's renewal identity"
);
});
}
#[tokio::test]
async fn token_only_stored_child_does_not_borrow_the_host_device_key() {
use crypto::{Ed25519Signer, Signer};
use grpc::heddle::api::v1alpha1::{
collaboration_service_client::CollaborationServiceClient,
identity_service_client::IdentityServiceClient,
registry_service_client::RegistryServiceClient,
repo_sync_service_client::RepoSyncServiceClient,
repository_service_client::RepositoryServiceClient,
state_review_service_client::StateReviewServiceClient,
workflow_service_client::WorkflowServiceClient,
};
use tonic::{Request, metadata::MetadataValue, transport::Endpoint};
use crate::{auth_cmd, grpc_hosted::HostedGrpcClient};
with_isolated_env(|home| {
let signer = Ed25519Signer::generate().expect("device key");
let private_key_pem = signer.to_pem().expect("device PEM");
let subject = "headless-agent";
let credential_id = "cred-headless";
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
let token = biscuit_auth::Biscuit::builder()
.fact(format!("user(\"{subject}\")").as_str())
.expect("user fact")
.fact(format!("credential_id(\"{credential_id}\")").as_str())
.expect("credential fact")
.fact(format!("expires_at({})", expires_at.to_rfc3339()).as_str())
.expect("expiry fact")
.fact(format!("device_pop_key(\"{}\")", hex::encode(signer.public_key())).as_str())
.expect("PoP key fact")
.build(&biscuit_auth::KeyPair::new())
.expect("mint fixture biscuit")
.to_base64()
.expect("encode fixture biscuit");
let server = "127.0.0.1:8421";
let root_hcred = home.join("root.hcred");
crate::credential_file::write_credential_file(
&root_hcred,
&crate::credential_file::VerifiedCredential {
server: server.to_string(),
kind: crate::credential_file::CredentialKind::Device,
subject: subject.to_string(),
token: token.clone(),
proof_key_pem: private_key_pem.clone(),
expires_at: Some(expires_at.to_rfc3339()),
credential_id: Some(credential_id.to_string()),
provenance: None,
},
)
.expect("write root .hcred");
auth_cmd::install_credential_file(&root_hcred).expect("headless credential install");
let identity = repo::identity::load_device(&repo::identity::device_identity_path())
.expect("load device identity")
.expect("linked device identity");
assert_eq!(identity.public_key, hex::encode(signer.public_key()));
assert_eq!(identity.server, server);
let root_session =
HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
.expect("build root session");
assert_eq!(
root_session.config.auth_proof_key_pem.as_deref(),
Some(private_key_pem.as_str()),
"a stored root credential resolves its bound device key"
);
let child_signer = Ed25519Signer::generate().expect("child PoP key");
let child_token = crate::device_flow::attenuate_for_agent(
&token,
crate::device_flow::AgentAttenuation {
agent_id: "agent-push".to_string(),
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
allowed_operations: Some(vec!["Push".to_string()]),
allowed_resources: None,
declared_scopes: vec![("repo".to_string(), "acme/heddle".to_string())],
},
&signer,
child_signer.public_key(),
)
.expect("derive child token");
credentials::store_server_credential(
server,
credentials::ServerCredential {
token: child_token.clone(),
subject: subject.to_string(),
device_id: None,
credential_id: None,
private_key_pem: None,
expires_at: Some((chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339()),
},
)
.expect("store token-only child");
let session =
HostedSession::build(&cli_shared::UserConfig::default(), Some(server.to_string()))
.expect("build token-only child session");
let config = session.config;
assert!(
config.auth_proof_key_pem.is_none(),
"a token-only child must not silently sign with its ancestor device key"
);
let channel = Endpoint::from_static("http://127.0.0.1:1").connect_lazy();
let client = HostedGrpcClient {
inner: RepoSyncServiceClient::new(channel.clone())
.max_decoding_message_size(wire::MAX_PULL_DECODE_MESSAGE_SIZE),
user: RegistryServiceClient::new(channel.clone()),
auth: IdentityServiceClient::new(channel.clone()),
content: RepositoryServiceClient::new(channel.clone()),
workflow: WorkflowServiceClient::new(channel.clone()),
collaboration: CollaborationServiceClient::new(channel.clone()),
review: StateReviewServiceClient::new(channel),
token_header: Some(
MetadataValue::try_from(format!(
"Bearer {}",
config.token.as_ref().expect("session token").id
))
.expect("bearer metadata"),
),
transport: crate::grpc_hosted::helpers::HostedTransportPolicy::from_client_config(
&config,
),
auth_proof_key_pem: config.auth_proof_key_pem,
authenticated_principal: config.authenticated_principal,
server_key: config.server_key,
on_human_signature: None,
};
let method = "/heddle.api.v1alpha1.RepoSyncService/Push";
let mut request = Request::new(());
client
.apply_auth(&mut request, method)
.expect("attach hosted auth");
let metadata = request.metadata();
assert!(metadata.get(crypto::pop::HDR_PROOF_TS).is_none());
assert!(metadata.get(crypto::pop::HDR_PROOF_NONCE).is_none());
assert!(metadata.get(crypto::pop::HDR_PROOF).is_none());
});
}
}