1use chrono::{DateTime, Duration, Utc};
22use serde::{Deserialize, Serialize};
23
24use super::{IdentityKeypair, PublicKey};
25use crate::error::JoyError;
26
27const TOKEN_PREFIX: &str = "joy_t_";
29
30fn default_scopes() -> Vec<String> {
32 vec!["auth".to_string()]
33}
34
35pub const SCOPE_CRYPT: &str = "crypt";
38pub const SCOPE_AUTH: &str = "auth";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DelegationClaims {
44 pub token_id: String,
48 pub ai_member: String,
49 pub delegated_by: String,
50 pub project_id: String,
51 pub created: DateTime<Utc>,
52 pub expires: Option<DateTime<Utc>>,
53 #[serde(default = "default_scopes")]
57 pub scopes: Vec<String>,
58}
59
60impl DelegationClaims {
61 pub fn has_crypt_scope(&self) -> bool {
63 self.scopes.iter().any(|s| s == SCOPE_CRYPT)
64 }
65}
66
67#[derive(Debug, Serialize, Deserialize)]
69pub struct DelegationToken {
70 pub claims: DelegationClaims,
71 pub delegator_signature: String,
73 pub binding_signature: String,
75 pub delegation_public_key: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub delegation_private_key: Option<String>,
86}
87
88pub struct TokenSigningKeys<'a> {
90 pub delegator: &'a IdentityKeypair,
92 pub delegation: &'a IdentityKeypair,
96 pub delegation_seed: &'a [u8; 32],
100}
101
102pub struct TokenIssueParams<'a> {
104 pub ai_member: &'a str,
105 pub human: &'a str,
106 pub project_id: &'a str,
107 pub ttl: Option<Duration>,
108 pub crypt_scope: bool,
111}
112
113pub fn create_token(keys: TokenSigningKeys<'_>, params: TokenIssueParams<'_>) -> DelegationToken {
122 let now = Utc::now();
123 let scopes = if params.crypt_scope {
124 vec![SCOPE_AUTH.to_string(), SCOPE_CRYPT.to_string()]
125 } else {
126 vec![SCOPE_AUTH.to_string()]
127 };
128 let claims = DelegationClaims {
129 token_id: uuid::Uuid::new_v4().to_string(),
130 ai_member: params.ai_member.to_string(),
131 delegated_by: params.human.to_string(),
132 project_id: params.project_id.to_string(),
133 created: now,
134 expires: params.ttl.map(|d| now + d),
135 scopes,
136 };
137 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
138
139 let delegator_sig = keys.delegator.sign(claims_json.as_bytes());
140 let binding_sig = keys.delegation.sign(claims_json.as_bytes());
141
142 let delegation_private_key = if params.crypt_scope {
143 Some(hex::encode(keys.delegation_seed))
144 } else {
145 None
146 };
147
148 DelegationToken {
149 claims,
150 delegator_signature: hex::encode(delegator_sig),
151 binding_signature: hex::encode(binding_sig),
152 delegation_public_key: keys.delegation.public_key().to_hex(),
153 delegation_private_key,
154 }
155}
156
157pub fn validate_token(
160 token: &DelegationToken,
161 delegator_pk: &PublicKey,
162 delegation_pk: &PublicKey,
163 project_id: &str,
164) -> Result<DelegationClaims, JoyError> {
165 if token.claims.project_id != project_id {
166 return Err(JoyError::AuthFailed(
167 "token belongs to a different project".into(),
168 ));
169 }
170
171 if let Some(expires) = token.claims.expires {
172 if Utc::now() > expires {
173 return Err(JoyError::AuthFailed(format!(
174 "Token expired (issued {}, expired {}). \
175 Ask the human to issue a new one with: joy auth token add {}",
176 token.claims.created.format("%Y-%m-%d %H:%M UTC"),
177 expires.format("%Y-%m-%d %H:%M UTC"),
178 token.claims.ai_member
179 )));
180 }
181 }
182
183 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
184
185 let delegator_sig = hex::decode(&token.delegator_signature)
186 .map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
187 delegator_pk.verify(claims_json.as_bytes(), &delegator_sig)?;
188
189 let binding_sig =
190 hex::decode(&token.binding_signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
191 delegation_pk.verify(claims_json.as_bytes(), &binding_sig)?;
192
193 Ok(token.claims.clone())
194}
195
196pub fn encode_token(token: &DelegationToken) -> String {
198 let json = serde_json::to_string(token).expect("token serialize");
199 let encoded = base64_encode(json.as_bytes());
200 format!("{TOKEN_PREFIX}{encoded}")
201}
202
203pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
205 let data = s.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
206 JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
207 })?;
208 let json = base64_decode(data)?;
209 let token: DelegationToken = serde_json::from_slice(&json)
210 .map_err(|e| JoyError::AuthFailed(format!("invalid token: {e}")))?;
211 Ok(token)
212}
213
214pub fn is_token(s: &str) -> bool {
216 s.starts_with(TOKEN_PREFIX)
217}
218
219fn base64_encode(data: &[u8]) -> String {
220 use base64ct::{Base64, Encoding};
221 Base64::encode_string(data)
222}
223
224fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
225 use base64ct::{Base64, Encoding};
226 Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::auth::{derive_key, Salt};
233 use chrono::Duration;
234
235 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
236
237 fn test_keypair() -> (IdentityKeypair, PublicKey) {
238 let salt =
239 Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
240 .unwrap();
241 let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
242 let kp = IdentityKeypair::from_derived_key(&key);
243 let pk = kp.public_key();
244 (kp, pk)
245 }
246
247 fn fresh_delegation() -> ([u8; 32], IdentityKeypair, PublicKey) {
248 use rand::RngCore;
249 let mut seed = [0u8; 32];
250 rand::thread_rng().fill_bytes(&mut seed);
251 let kp = IdentityKeypair::from_seed(&seed);
252 let pk = kp.public_key();
253 (seed, kp, pk)
254 }
255
256 fn make_token(
257 delegator: &IdentityKeypair,
258 delegation: &IdentityKeypair,
259 seed: &[u8; 32],
260 ttl: Option<Duration>,
261 crypt_scope: bool,
262 ) -> DelegationToken {
263 create_token(
264 TokenSigningKeys {
265 delegator,
266 delegation,
267 delegation_seed: seed,
268 },
269 TokenIssueParams {
270 ai_member: "ai:claude@joy",
271 human: "human@example.com",
272 project_id: "TST",
273 ttl,
274 crypt_scope,
275 },
276 )
277 }
278
279 #[test]
280 fn create_and_validate_token() {
281 let (delegator, delegator_pk) = test_keypair();
282 let (seed, delegation, delegation_pk) = fresh_delegation();
283 let token = make_token(&delegator, &delegation, &seed, None, false);
284 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
285 assert_eq!(claims.ai_member, "ai:claude@joy");
286 assert_eq!(claims.delegated_by, "human@example.com");
287 assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
288 assert!(token.delegation_private_key.is_none());
289 assert!(!claims.has_crypt_scope());
290 }
291
292 #[test]
293 fn crypt_token_carries_seed() {
294 let (delegator, _) = test_keypair();
295 let (seed, delegation, _) = fresh_delegation();
296 let token = make_token(&delegator, &delegation, &seed, None, true);
297 assert_eq!(token.delegation_private_key, Some(hex::encode(seed)));
298 assert!(token.claims.has_crypt_scope());
299 }
300
301 #[test]
302 fn token_with_expiry() {
303 let (delegator, delegator_pk) = test_keypair();
304 let (seed, delegation, delegation_pk) = fresh_delegation();
305 let token = make_token(
306 &delegator,
307 &delegation,
308 &seed,
309 Some(Duration::hours(8)),
310 false,
311 );
312 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
313 assert!(claims.expires.is_some());
314 }
315
316 #[test]
317 fn expired_token_rejected() {
318 let (delegator, delegator_pk) = test_keypair();
319 let (seed, delegation, delegation_pk) = fresh_delegation();
320 let token = make_token(
321 &delegator,
322 &delegation,
323 &seed,
324 Some(Duration::seconds(-1)),
325 false,
326 );
327 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
328 }
329
330 #[test]
331 fn wrong_project_rejected() {
332 let (delegator, delegator_pk) = test_keypair();
333 let (seed, delegation, delegation_pk) = fresh_delegation();
334 let token = make_token(&delegator, &delegation, &seed, None, false);
335 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
336 }
337
338 #[test]
339 fn tampered_claims_rejected() {
340 let (delegator, delegator_pk) = test_keypair();
341 let (seed, delegation, delegation_pk) = fresh_delegation();
342 let mut token = make_token(&delegator, &delegation, &seed, None, false);
343 token.claims.ai_member = "ai:attacker@evil".into();
344 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
345 }
346
347 #[test]
348 fn wrong_delegator_key_rejected() {
349 let (delegator, _) = test_keypair();
350 let (seed, delegation, delegation_pk) = fresh_delegation();
351 let token = make_token(&delegator, &delegation, &seed, None, false);
352
353 let other_salt = crate::auth::generate_salt();
354 let other_key = derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
355 let other_kp = IdentityKeypair::from_derived_key(&other_key);
356 let other_pk = other_kp.public_key();
357
358 assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
359 }
360
361 #[test]
362 fn wrong_delegation_key_rejected() {
363 let (delegator, delegator_pk) = test_keypair();
364 let (seed, delegation, _) = fresh_delegation();
365 let token = make_token(&delegator, &delegation, &seed, None, false);
366
367 let (_, _, rotated_pk) = fresh_delegation();
369 assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
370 }
371
372 #[test]
373 fn encode_decode_roundtrip() {
374 let (delegator, delegator_pk) = test_keypair();
375 let (seed, delegation, delegation_pk) = fresh_delegation();
376 let token = make_token(&delegator, &delegation, &seed, None, false);
377 let encoded = encode_token(&token);
378 assert!(encoded.starts_with("joy_t_"));
379 let decoded = decode_token(&encoded).unwrap();
380 let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
381 assert_eq!(claims.ai_member, "ai:claude@joy");
382 }
383
384 #[test]
385 fn legacy_token_without_scopes_field_decodes() {
386 let legacy_json = r#"{
389 "claims": {
390 "token_id": "abc",
391 "ai_member": "ai:claude@joy",
392 "delegated_by": "human@example.com",
393 "project_id": "TST",
394 "created": "2026-05-01T00:00:00Z",
395 "expires": null
396 },
397 "delegator_signature": "00",
398 "binding_signature": "00",
399 "delegation_public_key": "00"
400 }"#;
401 let token: DelegationToken = serde_json::from_str(legacy_json).unwrap();
402 assert_eq!(token.claims.scopes, vec!["auth".to_string()]);
403 assert!(!token.claims.has_crypt_scope());
404 assert!(token.delegation_private_key.is_none());
405 }
406
407 #[test]
408 fn invalid_prefix_rejected() {
409 assert!(decode_token("invalid_prefix_data").is_err());
410 }
411}