use base64::Engine;
use serde::{Deserialize, Serialize};
const TOKEN_PREFIX: &str = "tenuo_ct_";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConnectToken {
#[serde(rename = "v", default = "default_version")]
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", default, skip_serializing_if = "Option::is_none")]
pub registration_token: Option<String>,
}
fn default_version() -> u8 {
1
}
#[derive(Debug)]
pub enum ConnectTokenError {
MissingPrefix,
Base64(String),
Json(String),
MissingField(&'static str),
ClaimFailed(String),
UnsupportedVersion(u8),
}
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
.strip_prefix(TOKEN_PREFIX)
.ok_or(ConnectTokenError::MissingPrefix)?;
let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| ConnectTokenError::Base64(e.to_string()))?;
let token: ConnectToken = serde_json::from_slice(&json_bytes)
.map_err(|e| ConnectTokenError::Json(e.to_string()))?;
const MAX_SUPPORTED_VERSION: u8 = 1;
if token.version > MAX_SUPPORTED_VERSION {
return Err(ConnectTokenError::UnsupportedVersion(token.version));
}
if token.endpoint.is_empty() {
return Err(ConnectTokenError::MissingField("endpoint"));
}
if token.api_key.is_empty() {
return Err(ConnectTokenError::MissingField("api_key"));
}
Ok(token)
}
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!("{}/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://api.tenuo.cloud/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://api.tenuo.cloud/v1");
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_authorizer_only_token() {
let raw = make_token_str(r#"{"v":1,"e":"https://api.tenuo.cloud/v1","k":"tc_xyz"}"#);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.version, 1);
assert!(ct.agent_id.is_none());
assert!(ct.registration_token.is_none());
}
#[test]
fn parse_v0_token_without_version() {
let raw =
make_token_str(r#"{"e":"https://api.tenuo.cloud/v1","k":"tc_old","a":"ag","t":"rt"}"#);
let ct = ConnectToken::parse(&raw).unwrap();
assert_eq!(ct.version, 1); }
#[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://api.tenuo.cloud","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://api.tenuo.cloud","k":"tc_abc"}"#);
assert!(matches!(
ConnectToken::parse(&raw),
Err(ConnectTokenError::UnsupportedVersion(2))
));
}
}