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> {
212 let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
213 let trimmed = cleaned
220 .strip_prefix('"')
221 .and_then(|s| s.strip_suffix('"'))
222 .or_else(|| {
223 cleaned
224 .strip_prefix('\'')
225 .and_then(|s| s.strip_suffix('\''))
226 })
227 .unwrap_or(&cleaned);
228 let data = trimmed.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
229 JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
230 })?;
231 let json = base64_decode(data).map_err(|e| wrap_decode_error(&e.to_string()))?;
232 let token: DelegationToken =
233 serde_json::from_slice(&json).map_err(|e| wrap_decode_error(&format!("{e}")))?;
234 Ok(token)
235}
236
237fn wrap_decode_error(detail: &str) -> JoyError {
238 JoyError::AuthFailed(format!(
239 "token decode failed: {detail}. \
240 A delegation token is a single base64 line. If this was forwarded \
241 through a chat tool, the visual line wrap may have hidden a \
242 truncation: re-read the operator's original message in full, strip \
243 all whitespace, and retry before asking the operator to paste it \
244 again."
245 ))
246}
247
248pub fn is_token(s: &str) -> bool {
250 s.starts_with(TOKEN_PREFIX)
251}
252
253fn base64_encode(data: &[u8]) -> String {
254 use base64ct::{Base64, Encoding};
255 Base64::encode_string(data)
256}
257
258fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
259 use base64ct::{Base64, Encoding};
260 Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::auth::{derive_key, Salt};
267 use chrono::Duration;
268
269 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
270
271 fn test_keypair() -> (IdentityKeypair, PublicKey) {
272 let salt =
273 Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
274 .unwrap();
275 let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
276 let kp = IdentityKeypair::from_derived_key(&key);
277 let pk = kp.public_key();
278 (kp, pk)
279 }
280
281 fn fresh_delegation() -> ([u8; 32], IdentityKeypair, PublicKey) {
282 use rand::RngCore;
283 let mut seed = [0u8; 32];
284 rand::thread_rng().fill_bytes(&mut seed);
285 let kp = IdentityKeypair::from_seed(&seed);
286 let pk = kp.public_key();
287 (seed, kp, pk)
288 }
289
290 fn make_token(
291 delegator: &IdentityKeypair,
292 delegation: &IdentityKeypair,
293 seed: &[u8; 32],
294 ttl: Option<Duration>,
295 crypt_scope: bool,
296 ) -> DelegationToken {
297 create_token(
298 TokenSigningKeys {
299 delegator,
300 delegation,
301 delegation_seed: seed,
302 },
303 TokenIssueParams {
304 ai_member: "ai:claude@joy",
305 human: "human@example.com",
306 project_id: "TST",
307 ttl,
308 crypt_scope,
309 },
310 )
311 }
312
313 #[test]
314 fn create_and_validate_token() {
315 let (delegator, delegator_pk) = test_keypair();
316 let (seed, delegation, delegation_pk) = fresh_delegation();
317 let token = make_token(&delegator, &delegation, &seed, None, false);
318 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
319 assert_eq!(claims.ai_member, "ai:claude@joy");
320 assert_eq!(claims.delegated_by, "human@example.com");
321 assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
322 assert!(token.delegation_private_key.is_none());
323 assert!(!claims.has_crypt_scope());
324 }
325
326 #[test]
327 fn crypt_token_carries_seed() {
328 let (delegator, _) = test_keypair();
329 let (seed, delegation, _) = fresh_delegation();
330 let token = make_token(&delegator, &delegation, &seed, None, true);
331 assert_eq!(token.delegation_private_key, Some(hex::encode(seed)));
332 assert!(token.claims.has_crypt_scope());
333 }
334
335 #[test]
336 fn token_with_expiry() {
337 let (delegator, delegator_pk) = test_keypair();
338 let (seed, delegation, delegation_pk) = fresh_delegation();
339 let token = make_token(
340 &delegator,
341 &delegation,
342 &seed,
343 Some(Duration::hours(8)),
344 false,
345 );
346 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
347 assert!(claims.expires.is_some());
348 }
349
350 #[test]
351 fn expired_token_rejected() {
352 let (delegator, delegator_pk) = test_keypair();
353 let (seed, delegation, delegation_pk) = fresh_delegation();
354 let token = make_token(
355 &delegator,
356 &delegation,
357 &seed,
358 Some(Duration::seconds(-1)),
359 false,
360 );
361 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
362 }
363
364 #[test]
365 fn wrong_project_rejected() {
366 let (delegator, delegator_pk) = test_keypair();
367 let (seed, delegation, delegation_pk) = fresh_delegation();
368 let token = make_token(&delegator, &delegation, &seed, None, false);
369 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
370 }
371
372 #[test]
373 fn tampered_claims_rejected() {
374 let (delegator, delegator_pk) = test_keypair();
375 let (seed, delegation, delegation_pk) = fresh_delegation();
376 let mut token = make_token(&delegator, &delegation, &seed, None, false);
377 token.claims.ai_member = "ai:attacker@evil".into();
378 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
379 }
380
381 #[test]
382 fn wrong_delegator_key_rejected() {
383 let (delegator, _) = test_keypair();
384 let (seed, delegation, delegation_pk) = fresh_delegation();
385 let token = make_token(&delegator, &delegation, &seed, None, false);
386
387 let other_salt = crate::auth::generate_salt();
388 let other_key = derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
389 let other_kp = IdentityKeypair::from_derived_key(&other_key);
390 let other_pk = other_kp.public_key();
391
392 assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
393 }
394
395 #[test]
396 fn wrong_delegation_key_rejected() {
397 let (delegator, delegator_pk) = test_keypair();
398 let (seed, delegation, _) = fresh_delegation();
399 let token = make_token(&delegator, &delegation, &seed, None, false);
400
401 let (_, _, rotated_pk) = fresh_delegation();
403 assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
404 }
405
406 #[test]
407 fn encode_decode_roundtrip() {
408 let (delegator, delegator_pk) = test_keypair();
409 let (seed, delegation, delegation_pk) = fresh_delegation();
410 let token = make_token(&delegator, &delegation, &seed, None, false);
411 let encoded = encode_token(&token);
412 assert!(encoded.starts_with("joy_t_"));
413 let decoded = decode_token(&encoded).unwrap();
414 let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
415 assert_eq!(claims.ai_member, "ai:claude@joy");
416 }
417
418 #[test]
419 fn legacy_token_without_scopes_field_decodes() {
420 let legacy_json = r#"{
423 "claims": {
424 "token_id": "abc",
425 "ai_member": "ai:claude@joy",
426 "delegated_by": "human@example.com",
427 "project_id": "TST",
428 "created": "2026-05-01T00:00:00Z",
429 "expires": null
430 },
431 "delegator_signature": "00",
432 "binding_signature": "00",
433 "delegation_public_key": "00"
434 }"#;
435 let token: DelegationToken = serde_json::from_str(legacy_json).unwrap();
436 assert_eq!(token.claims.scopes, vec!["auth".to_string()]);
437 assert!(!token.claims.has_crypt_scope());
438 assert!(token.delegation_private_key.is_none());
439 }
440
441 #[test]
442 fn invalid_prefix_rejected() {
443 assert!(decode_token("invalid_prefix_data").is_err());
444 }
445
446 #[test]
447 fn decode_tolerates_embedded_whitespace() {
448 let (delegator, _) = test_keypair();
453 let (seed, delegation, _) = fresh_delegation();
454 let token = make_token(&delegator, &delegation, &seed, None, false);
455 let encoded = encode_token(&token);
456
457 let mangled: String = encoded
459 .as_bytes()
460 .chunks(40)
461 .map(|c| std::str::from_utf8(c).unwrap())
462 .collect::<Vec<_>>()
463 .join(" \n\t");
464 assert!(mangled.contains('\n'));
465 let decoded = decode_token(&mangled).expect("whitespace-mangled token should decode");
466 assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
467 }
468
469 #[test]
470 fn decode_accepts_double_quoted_token() {
471 let (delegator, _) = test_keypair();
472 let (seed, delegation, _) = fresh_delegation();
473 let token = make_token(&delegator, &delegation, &seed, None, false);
474 let encoded = encode_token(&token);
475 let quoted = format!("\"{encoded}\"");
476 let decoded = decode_token("ed).expect("double-quoted token should decode");
477 assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
478 }
479
480 #[test]
481 fn decode_accepts_single_quoted_token() {
482 let (delegator, _) = test_keypair();
483 let (seed, delegation, _) = fresh_delegation();
484 let token = make_token(&delegator, &delegation, &seed, None, false);
485 let encoded = encode_token(&token);
486 let quoted = format!("'{encoded}'");
487 let decoded = decode_token("ed).expect("single-quoted token should decode");
488 assert_eq!(decoded.claims.ai_member, "ai:claude@joy");
489 }
490
491 #[test]
492 fn truncated_token_surfaces_hint() {
493 let (delegator, _) = test_keypair();
494 let (seed, delegation, _) = fresh_delegation();
495 let token = make_token(&delegator, &delegation, &seed, None, false);
496 let encoded = encode_token(&token);
497
498 let truncated = &encoded[..encoded.len() / 2];
501 let err = decode_token(truncated).expect_err("truncated token must not decode");
502 let msg = err.to_string();
503 assert!(
504 msg.contains("strip all whitespace"),
505 "expected re-read hint in error, got: {msg}"
506 );
507 }
508}