194c44c
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<String>,
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<Option<SpaceTokenRecord>, 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::<SpaceTokenRecord>(&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<bool, ExportError> {
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
}