use base64::Engine;
use serde::{Deserialize, Serialize};
const TOKEN_PREFIX: &str = "tenuo_ct_";
pub fn normalize_control_plane_url(url: &str) -> String {
let mut endpoint = url.trim().trim_end_matches('/').to_string();
if endpoint.ends_with("/v1") {
endpoint.truncate(endpoint.len() - 3);
endpoint = endpoint.trim_end_matches('/').to_string();
}
endpoint
}
#[derive(Clone, Deserialize)]
pub struct ConnectToken {
#[serde(rename = "v")]
pub version: u8,
#[serde(rename = "e")]
pub endpoint: String,
#[serde(rename = "k")]
pub api_key: String,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(
rename = "t",
alias = "r",
alias = "registration_token",
default,
skip_serializing_if = "Option::is_none"
)]
pub registration_token: Option<String>,
}
impl std::fmt::Debug for ConnectToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnectToken")
.field("version", &self.version)
.field("endpoint", &self.endpoint)
.field("api_key", &"[REDACTED]")
.field("agent_id", &self.agent_id)
.field(
"registration_token",
&self.registration_token.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
impl Serialize for ConnectToken {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("ConnectToken", 5)?;
state.serialize_field("v", &self.version)?;
state.serialize_field("e", &self.endpoint)?;
state.serialize_field("k", "[REDACTED]")?;
state.serialize_field("a", &self.agent_id)?;
state.serialize_field("t", &self.registration_token.as_ref().map(|_| "[REDACTED]"))?;
state.end()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ConnectTokenError {
MissingPrefix,
Base64(String),
Json(String),
MissingField(&'static str),
ClaimFailed(String),
UnsupportedVersion(u64),
}
impl std::fmt::Display for ConnectTokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingPrefix => write!(f, "token must start with '{}'", TOKEN_PREFIX),
Self::Base64(e) => write!(f, "base64 decode error: {}", e),
Self::Json(e) => write!(f, "JSON parse error: {}", e),
Self::MissingField(name) => write!(f, "required field '{}' is empty", name),
Self::ClaimFailed(e) => write!(f, "agent claim failed: {}", e),
Self::UnsupportedVersion(v) => write!(
f,
"connect token version {} is not supported by this SDK (max: 1). \
Upgrade tenuo to use this token.",
v
),
}
}
}
impl std::error::Error for ConnectTokenError {}
impl ConnectToken {
pub fn parse(raw: &str) -> Result<Self, ConnectTokenError> {
let encoded = raw
.trim()
.strip_prefix(TOKEN_PREFIX)
.ok_or(ConnectTokenError::MissingPrefix)?;
let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(encoded))
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded))
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(encoded))
.map_err(|e| ConnectTokenError::Base64(e.to_string()))?;
let value: serde_json::Value = serde_json::from_slice(&json_bytes)
.map_err(|e| ConnectTokenError::Json(e.to_string()))?;
if !value.is_object() {
return Err(ConnectTokenError::Json(
"connect token payload must be a JSON object".into(),
));
}
match value.get("v") {
None | Some(serde_json::Value::Null) => {
return Err(ConnectTokenError::MissingField("version"));
}
Some(v) => {
let parsed = v.as_u64().ok_or_else(|| {
ConnectTokenError::Json(
"version must be a non-negative integer. This SDK supports version 1."
.into(),
)
})?;
if parsed != 1 {
return Err(ConnectTokenError::UnsupportedVersion(parsed));
}
}
}
let token: ConnectToken =
serde_json::from_value(value).map_err(|e| ConnectTokenError::Json(e.to_string()))?;
if token.endpoint.is_empty() {
return Err(ConnectTokenError::MissingField("endpoint"));
}
if token.api_key.is_empty() {
return Err(ConnectTokenError::MissingField("api_key"));
}
Ok(ConnectToken {
endpoint: normalize_control_plane_url(&token.endpoint),
..token
})
}
pub fn needs_endpoint_base(&self) -> bool {
self.endpoint.is_empty() || self.endpoint.starts_with('/')
}
pub fn resolve_endpoint(&mut self, base: &str) -> Result<(), ConnectTokenError> {
if !self.needs_endpoint_base() {
return Ok(());
}
let origin = normalize_control_plane_url(base);
if origin.is_empty() || origin.starts_with('/') {
return Err(ConnectTokenError::MissingField("endpoint"));
}
let remainder = self.endpoint.clone();
self.endpoint = if remainder.is_empty() || remainder == "/" {
origin
} else {
format!("{}{}", origin.trim_end_matches('/'), remainder)
};
Ok(())
}
#[cfg(feature = "server")]
pub async fn claim_agent(
&self,
signing_key: &crate::crypto::SigningKey,
) -> Result<(), ConnectTokenError> {
let (agent_id, reg_token) = match (&self.agent_id, &self.registration_token) {
(Some(a), Some(t)) if !a.is_empty() && !t.is_empty() => (a, t),
_ => return Ok(()),
};
let public_key_hex = hex::encode(signing_key.public_key().to_bytes());
let claim_url = format!("{}/v1/agents/claim", self.endpoint.trim_end_matches('/'));
let body = serde_json::json!({
"agent_id": agent_id,
"public_key": public_key_hex,
"registration_token": reg_token,
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ConnectTokenError::ClaimFailed(e.to_string()))?;
tracing::debug!(agent_id = %agent_id, "claiming agent via connect token");
let resp = client
.post(&claim_url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| ConnectTokenError::ClaimFailed(e.to_string()))?;
let status = resp.status().as_u16();
match status {
200..=299 => {
tracing::info!(agent_id = %agent_id, "agent claimed successfully");
Ok(())
}
409 => {
tracing::debug!(agent_id = %agent_id, "agent already claimed (idempotent)");
Ok(())
}
_ => {
let msg = resp.text().await.unwrap_or_default();
tracing::warn!(agent_id = %agent_id, status, body = %msg, "agent claim returned non-success");
Err(ConnectTokenError::ClaimFailed(format!(
"HTTP {}: {}",
status, msg
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_token_str(payload: &str) -> String {
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
format!("{}{}", TOKEN_PREFIX, encoded)
}
#[test]
fn parse_full_token() {
let raw = make_token_str(
r#"{"v":1,"e":"https://control.example.com/v1","k":"tc_abc","a":"my-agent","t":"tok123"}"#,
);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.version, 1);
assert_eq!(ct.endpoint, "https://control.example.com");
assert_eq!(ct.api_key, "tc_abc");
assert_eq!(ct.agent_id.as_deref(), Some("my-agent"));
assert_eq!(ct.registration_token.as_deref(), Some("tok123"));
}
#[test]
fn parse_token_without_v1_suffix() {
let raw = make_token_str(
r#"{"v":1,"e":"https://control.example.com","k":"tc_abc","a":"my-agent","t":"tok123"}"#,
);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.endpoint, "https://control.example.com");
}
#[test]
fn parse_token_with_trailing_slash() {
let raw = make_token_str(r#"{"v":1,"e":"https://control.example.com/v1/","k":"tc_abc"}"#);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.endpoint, "https://control.example.com");
}
#[test]
fn parse_authorizer_only_token() {
let raw = make_token_str(r#"{"v":1,"e":"https://control.example.com/v1","k":"tc_xyz"}"#);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.version, 1);
assert_eq!(ct.endpoint, "https://control.example.com");
assert!(ct.agent_id.is_none());
assert!(ct.registration_token.is_none());
}
#[test]
fn parse_padded_base64url_and_registration_alias() {
let payload =
r#"{"v":1,"e":"https://control.example.com/v1","k":"tc_abc","r":"tok-alias"}"#;
let padded = base64::engine::general_purpose::URL_SAFE.encode(payload);
let raw = format!("{TOKEN_PREFIX}{padded}");
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.registration_token.as_deref(), Some("tok-alias"));
}
#[test]
fn parse_trims_whitespace_and_accepts_standard_base64() {
let payload = br#"{"v":1,"e":"https://control.example.com/v1","k":"tc_abc"}"#;
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
let raw = format!(" {TOKEN_PREFIX}{encoded}\n");
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.api_key, "tc_abc");
assert_eq!(ct.endpoint, "https://control.example.com");
}
#[test]
fn parse_v0_token_without_version() {
let raw = make_token_str(
r#"{"e":"https://control.example.com/v1","k":"tc_old","a":"ag","t":"rt"}"#,
);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::MissingField("version"))
));
}
#[test]
fn reject_missing_prefix() {
assert!(ConnectToken::parse("not_a_token").is_err());
}
#[test]
fn reject_empty_endpoint() {
let raw = make_token_str(r#"{"v":1,"e":"","k":"tc_abc"}"#);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::MissingField("endpoint"))
));
}
#[test]
fn reject_empty_api_key() {
let raw = make_token_str(r#"{"v":1,"e":"https://control.example.com","k":""}"#);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::MissingField("api_key"))
));
}
#[test]
fn reject_future_version() {
let raw = make_token_str(r#"{"v":2,"e":"https://control.example.com","k":"tc_abc"}"#);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::UnsupportedVersion(2))
));
}
#[test]
fn relative_v1_endpoint_needs_caller_base() {
let raw = make_token_str(r#"{"v":1,"e":"/v1","k":"tc_abc"}"#);
let mut ct = ConnectToken::parse(&raw).unwrap();
assert!(ct.needs_endpoint_base());
ct.resolve_endpoint("https://control.example.com/v1")
.unwrap();
assert_eq!(ct.endpoint, "https://control.example.com");
assert!(!ct.needs_endpoint_base());
}
#[test]
fn resolve_endpoint_does_not_replace_absolute_origin() {
let raw = make_token_str(r#"{"v":1,"e":"https://control.example.com/v1","k":"tc_abc"}"#);
let mut ct = ConnectToken::parse(&raw).unwrap();
ct.resolve_endpoint("https://other.example").unwrap();
assert_eq!(ct.endpoint, "https://control.example.com");
}
#[test]
fn resolve_endpoint_joins_remaining_relative_path() {
let raw = make_token_str(r#"{"v":1,"e":"/api/v1","k":"tc_abc"}"#);
let mut ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.endpoint, "/api");
ct.resolve_endpoint("https://control.example.com").unwrap();
assert_eq!(ct.endpoint, "https://control.example.com/api");
}
#[test]
fn reject_version_out_of_u8_range() {
let raw = make_token_str(r#"{"v":257,"e":"https://control.example.com","k":"tc_abc"}"#);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::UnsupportedVersion(257))
));
assert!(ConnectToken::parse(&raw)
.unwrap_err()
.to_string()
.contains("257"));
}
#[test]
fn resolve_endpoint_rejects_empty_or_relative_base() {
let raw = make_token_str(r#"{"v":1,"e":"/v1","k":"tc_abc"}"#);
let mut ct = ConnectToken::parse(&raw).unwrap();
assert!(matches!(
ct.resolve_endpoint("/v1"),
Err(ConnectTokenError::MissingField("endpoint"))
));
assert!(matches!(
ct.resolve_endpoint(""),
Err(ConnectTokenError::MissingField("endpoint"))
));
}
#[test]
fn debug_and_serialize_redact_secrets() {
let raw = make_token_str(
r#"{"v":1,"e":"https://control.example.com","k":"tc_secret","t":"reg_secret"}"#,
);
let ct = ConnectToken::parse(&raw).unwrap();
let debug = format!("{ct:?}");
assert!(!debug.contains("tc_secret"), "{debug}");
assert!(!debug.contains("reg_secret"), "{debug}");
assert!(debug.contains("[REDACTED]"), "{debug}");
let json = serde_json::to_string(&ct).unwrap();
assert!(!json.contains("tc_secret"), "{json}");
assert!(!json.contains("reg_secret"), "{json}");
assert!(json.contains("[REDACTED]"), "{json}");
}
}