use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use time::OffsetDateTime; use uuid::Uuid; use crate::error::ExportError; use crate::redis::{self, AppRedis}; /// Space top-level tokens are opaque short-lived credentials bound to a single /// space. They are issued through `mcp_api::space_token` (a library-only /// service, never an HTTP route or MCP tool) and verified here so that both /// the MCP endpoint and the Git HTTP endpoint can accept the same token. pub const TOKEN_PREFIX: &str = "spt_"; const KEY_PREFIX: &str = "mcp:space_token:"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceTokenRecord { pub token_id: Uuid, pub space_id: Uuid, pub space_name: String, pub bot_uid: i64, pub secret_hash: String, #[serde(default)] pub issued_by: Option, pub created_at_unix: i64, pub expires_at_unix: i64, } pub fn looks_like(token: &str) -> bool { token.starts_with(TOKEN_PREFIX) } pub fn key(token_id: Uuid) -> String { format!("{KEY_PREFIX}{token_id}") } pub fn parse(token: &str) -> Option<(Uuid, &str)> { let rest = token.strip_prefix(TOKEN_PREFIX)?; let (id, secret) = rest.split_once('.')?; if secret.is_empty() { return None; } let id = Uuid::parse_str(id).ok()?; Some((id, secret)) } pub fn hash_secret(secret: &str) -> String { let digest = Sha256::digest(secret.as_bytes()); digest.iter().map(|byte| format!("{byte:02x}")).collect() } pub async fn store( redis: &AppRedis, record: &SpaceTokenRecord, ttl_secs: u64, ) -> Result<(), ExportError> { let value = serde_json::to_string(record) .map_err(|error| ExportError::InvalidConfig(format!("space token encode: {error}")))?; redis::set_string(redis, &key(record.token_id), &value, ttl_secs.max(1)).await?; Ok(()) } pub async fn verify( redis: &AppRedis, token: &str, ) -> Result, ExportError> { let Some((token_id, secret)) = parse(token) else { return Ok(None); }; let Some(raw) = redis::get_string(redis, &key(token_id)).await? else { return Ok(None); }; let Ok(record) = serde_json::from_str::(&raw) else { return Ok(None); }; if record.expires_at_unix <= OffsetDateTime::now_utc().unix_timestamp() { return Ok(None); } let computed = hash_secret(secret); if !constant_time_eq(computed.as_bytes(), record.secret_hash.as_bytes()) { return Ok(None); } Ok(Some(record)) } /// Deletes the token; returns whether it still existed. pub async fn revoke(redis: &AppRedis, token_id: Uuid) -> Result { let existed = redis::exists(redis, &key(token_id)).await?; redis::delete(redis, &key(token_id)).await?; Ok(existed) } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff: u8 = 0; for (x, y) in a.iter().zip(b.iter()) { diff |= x ^ y; } diff == 0 }