kagi_sync/domain/
project_token.rs1use 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}