Skip to main content

kagi_sync/domain/
project_token.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Serialize, Deserialize, Debug, Clone)]
4pub struct TokenPayload {
5    pub version: u8,
6    pub remote: String,
7    pub project_id: String,
8    pub token_id: String,
9    pub server_fingerprint: String,
10    pub capabilities: Vec<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub bootstrap_signer_public_key: Option<String>,
13}
14
15#[derive(Debug, Clone)]
16pub struct ProjectToken {
17    pub payload: TokenPayload,
18    #[allow(dead_code)]
19    pub full_token: String,
20}
21
22impl ProjectToken {
23    pub fn parse(token: &str) -> Option<Self> {
24        let prefix = if token.starts_with("kagi_proj_v1_") {
25            "kagi_proj_v1_"
26        } else if token.starts_with("kagi_admin_v1_") {
27            "kagi_admin_v1_"
28        } else {
29            return None;
30        };
31        let rest = &token[prefix.len()..];
32        let (payload_b64, secret_b64) = rest.split_once('.')?;
33        let payload_json = base64_decode_url(payload_b64).ok()?;
34        let payload: TokenPayload = serde_json::from_slice(&payload_json).ok()?;
35        if payload.version != 1 {
36            return None;
37        }
38        let secret = base64_decode_url(secret_b64).ok()?;
39        if secret.len() != 32 {
40            return None;
41        }
42        Some(Self {
43            payload,
44            full_token: token.to_string(),
45        })
46    }
47
48    #[cfg(feature = "server")]
49    pub fn generate(
50        remote: String,
51        project_id: String,
52        server_fingerprint: String,
53        capabilities: Vec<String>,
54        bootstrap_signer_public_key: Option<String>,
55    ) -> Self {
56        let token_id = format!("kgt_{}", nanoid::nanoid!(12));
57        let payload = TokenPayload {
58            version: 1,
59            remote,
60            project_id: project_id.clone(),
61            token_id: token_id.clone(),
62            server_fingerprint: server_fingerprint.clone(),
63            capabilities,
64            bootstrap_signer_public_key,
65        };
66        let payload_json = serde_json::to_vec(&payload).unwrap();
67        let payload_b64 = base64_encode_url(&payload_json);
68        let secret_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
69        let secret = base64_encode_url(&secret_bytes);
70        let full_token = format!("kagi_proj_v1_{}.{}", payload_b64, secret);
71        Self {
72            payload,
73            full_token,
74        }
75    }
76
77    #[cfg(feature = "server")]
78    pub fn generate_admin_token(server_fingerprint: String) -> Self {
79        let token_id = format!("kat_{}", nanoid::nanoid!(12));
80        let payload = TokenPayload {
81            version: 1,
82            remote: "admin".into(),
83            project_id: "admin".into(),
84            token_id: token_id.clone(),
85            server_fingerprint: server_fingerprint.clone(),
86            capabilities: vec!["admin".into()],
87            bootstrap_signer_public_key: None,
88        };
89        let payload_json = serde_json::to_vec(&payload).unwrap();
90        let payload_b64 = base64_encode_url(&payload_json);
91        let secret_bytes: Vec<u8> = (0..32).map(|_| rand::random::<u8>()).collect();
92        let secret = base64_encode_url(&secret_bytes);
93        let full_token = format!("kagi_admin_v1_{}.{}", payload_b64, secret);
94        Self {
95            payload,
96            full_token,
97        }
98    }
99}
100
101#[cfg(feature = "server")]
102pub fn base64_encode_url(input: &[u8]) -> String {
103    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
104    URL_SAFE_NO_PAD.encode(input)
105}
106
107pub fn base64_decode_url(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
108    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
109    URL_SAFE_NO_PAD.decode(input)
110}
111
112#[cfg(feature = "server")]
113pub fn normalize_member_name(name: &str) -> String {
114    let trimmed = name.trim();
115    let collapsed = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
116    collapsed.to_lowercase()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    #[cfg(feature = "server")]
125    fn test_generate_and_parse_roundtrip() {
126        let token = ProjectToken::generate(
127            "http://localhost:13816".into(),
128            "kgp_test123".into(),
129            "kgs_fp123".into(),
130            vec!["pull".into(), "push".into()],
131            Some("signer_public_key".into()),
132        );
133        assert!(token.full_token.starts_with("kagi_proj_v1_"));
134        assert!(token.payload.token_id.starts_with("kgt_"));
135
136        let parsed = ProjectToken::parse(&token.full_token).unwrap();
137        assert_eq!(parsed.payload.remote, token.payload.remote);
138        assert_eq!(parsed.payload.project_id, token.payload.project_id);
139        assert_eq!(parsed.payload.token_id, token.payload.token_id);
140        assert_eq!(
141            parsed.payload.server_fingerprint,
142            token.payload.server_fingerprint
143        );
144        assert_eq!(parsed.payload.capabilities, token.payload.capabilities);
145        assert_eq!(
146            parsed.payload.bootstrap_signer_public_key,
147            token.payload.bootstrap_signer_public_key
148        );
149        assert_eq!(parsed.full_token, token.full_token);
150    }
151
152    #[test]
153    fn test_parse_invalid_prefix() {
154        assert!(ProjectToken::parse("not_a_kagi_token").is_none());
155    }
156
157    #[test]
158    fn test_parse_missing_dot() {
159        assert!(ProjectToken::parse("kagi_proj_v1_abc").is_none());
160    }
161
162    #[test]
163    fn test_parse_bad_base64_payload() {
164        assert!(ProjectToken::parse("kagi_proj_v1_!!!.validb64").is_none());
165    }
166
167    #[test]
168    #[cfg(feature = "server")]
169    fn test_parse_rejects_bad_secret() {
170        let token = ProjectToken::generate(
171            "http://localhost:13816".into(),
172            "kgp_test123".into(),
173            "kgs_fp123".into(),
174            vec!["pull".into()],
175            None,
176        );
177        let (prefix_and_payload, _) = token.full_token.rsplit_once('.').unwrap();
178
179        assert!(ProjectToken::parse(&format!("{}.!!!", prefix_and_payload)).is_none());
180        assert!(
181            ProjectToken::parse(&format!(
182                "{}.{}",
183                prefix_and_payload,
184                base64_encode_url(b"short")
185            ))
186            .is_none()
187        );
188    }
189
190    #[test]
191    #[cfg(feature = "server")]
192    fn test_normalize_member_name() {
193        assert_eq!(normalize_member_name("  Alice  Smith  "), "alice smith");
194        assert_eq!(normalize_member_name("BOB"), "bob");
195        assert_eq!(normalize_member_name("carol\tdan"), "carol dan");
196    }
197
198    #[test]
199    #[cfg(feature = "server")]
200    fn test_generate_admin_token() {
201        let token = ProjectToken::generate_admin_token("kgs_fp_admin".into());
202        assert!(token.full_token.starts_with("kagi_admin_v1_"));
203        assert!(token.payload.token_id.starts_with("kat_"));
204        assert_eq!(token.payload.remote, "admin");
205        assert_eq!(token.payload.project_id, "admin");
206        assert_eq!(token.payload.server_fingerprint, "kgs_fp_admin");
207        assert_eq!(token.payload.capabilities, vec!["admin"]);
208    }
209
210    #[test]
211    #[cfg(feature = "server")]
212    fn test_parse_admin_token_roundtrip() {
213        let token = ProjectToken::generate_admin_token("kgs_fp_admin".into());
214        let parsed = ProjectToken::parse(&token.full_token).unwrap();
215        assert_eq!(parsed.payload.remote, token.payload.remote);
216        assert_eq!(parsed.payload.project_id, token.payload.project_id);
217        assert_eq!(parsed.payload.token_id, token.payload.token_id);
218        assert_eq!(
219            parsed.payload.server_fingerprint,
220            token.payload.server_fingerprint
221        );
222        assert_eq!(parsed.payload.capabilities, token.payload.capabilities);
223        assert_eq!(parsed.full_token, token.full_token);
224    }
225}