1use chrono::{DateTime, Duration, Utc};
15use serde::{Deserialize, Serialize};
16
17use super::sign::{IdentityKeypair, PublicKey};
18use crate::error::JoyError;
19
20const TOKEN_PREFIX: &str = "joy_t_";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DelegationClaims {
26 pub token_id: String,
30 pub ai_member: String,
31 pub delegated_by: String,
32 pub project_id: String,
33 pub created: DateTime<Utc>,
34 pub expires: Option<DateTime<Utc>>,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
39pub struct DelegationToken {
40 pub claims: DelegationClaims,
41 pub delegator_signature: String,
43 pub binding_signature: String,
45 pub delegation_public_key: String,
49}
50
51pub fn create_token(
58 delegator_keypair: &IdentityKeypair,
59 delegation_keypair: &IdentityKeypair,
60 ai_member: &str,
61 human: &str,
62 project_id: &str,
63 ttl: Option<Duration>,
64) -> DelegationToken {
65 let now = Utc::now();
66 let claims = DelegationClaims {
67 token_id: uuid::Uuid::new_v4().to_string(),
68 ai_member: ai_member.to_string(),
69 delegated_by: human.to_string(),
70 project_id: project_id.to_string(),
71 created: now,
72 expires: ttl.map(|d| now + d),
73 };
74 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
75
76 let delegator_sig = delegator_keypair.sign(claims_json.as_bytes());
77 let binding_sig = delegation_keypair.sign(claims_json.as_bytes());
78
79 DelegationToken {
80 claims,
81 delegator_signature: hex::encode(delegator_sig),
82 binding_signature: hex::encode(binding_sig),
83 delegation_public_key: delegation_keypair.public_key().to_hex(),
84 }
85}
86
87pub fn validate_token(
90 token: &DelegationToken,
91 delegator_pk: &PublicKey,
92 delegation_pk: &PublicKey,
93 project_id: &str,
94) -> Result<DelegationClaims, JoyError> {
95 if token.claims.project_id != project_id {
96 return Err(JoyError::AuthFailed(
97 "token belongs to a different project".into(),
98 ));
99 }
100
101 if let Some(expires) = token.claims.expires {
102 if Utc::now() > expires {
103 return Err(JoyError::AuthFailed(format!(
104 "Token expired (issued {}, expired {}). \
105 Ask the human to issue a new one with: joy auth token add {}",
106 token.claims.created.format("%Y-%m-%d %H:%M UTC"),
107 expires.format("%Y-%m-%d %H:%M UTC"),
108 token.claims.ai_member
109 )));
110 }
111 }
112
113 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
114
115 let delegator_sig = hex::decode(&token.delegator_signature)
116 .map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
117 delegator_pk.verify(claims_json.as_bytes(), &delegator_sig)?;
118
119 let binding_sig =
120 hex::decode(&token.binding_signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
121 delegation_pk.verify(claims_json.as_bytes(), &binding_sig)?;
122
123 Ok(token.claims.clone())
124}
125
126pub fn encode_token(token: &DelegationToken) -> String {
128 let json = serde_json::to_string(token).expect("token serialize");
129 let encoded = base64_encode(json.as_bytes());
130 format!("{TOKEN_PREFIX}{encoded}")
131}
132
133pub fn decode_token(s: &str) -> Result<DelegationToken, JoyError> {
135 let data = s.strip_prefix(TOKEN_PREFIX).ok_or_else(|| {
136 JoyError::AuthFailed("invalid token format (missing joy_t_ prefix)".into())
137 })?;
138 let json = base64_decode(data)?;
139 let token: DelegationToken = serde_json::from_slice(&json)
140 .map_err(|e| JoyError::AuthFailed(format!("invalid token: {e}")))?;
141 Ok(token)
142}
143
144pub fn is_token(s: &str) -> bool {
146 s.starts_with(TOKEN_PREFIX)
147}
148
149fn base64_encode(data: &[u8]) -> String {
150 use base64ct::{Base64, Encoding};
151 Base64::encode_string(data)
152}
153
154fn base64_decode(s: &str) -> Result<Vec<u8>, JoyError> {
155 use base64ct::{Base64, Encoding};
156 Base64::decode_vec(s).map_err(|e| JoyError::AuthFailed(format!("base64 decode: {e}")))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::auth::{derive, sign};
163 use chrono::Duration;
164
165 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
166
167 fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
168 let salt = derive::Salt::from_hex(
169 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
170 )
171 .unwrap();
172 let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
173 let kp = sign::IdentityKeypair::from_derived_key(&key);
174 let pk = kp.public_key();
175 (kp, pk)
176 }
177
178 fn fresh_delegation() -> (sign::IdentityKeypair, sign::PublicKey) {
179 let kp = sign::IdentityKeypair::from_random();
180 let pk = kp.public_key();
181 (kp, pk)
182 }
183
184 #[test]
185 fn create_and_validate_token() {
186 let (delegator, delegator_pk) = test_keypair();
187 let (delegation, delegation_pk) = fresh_delegation();
188 let token = create_token(
189 &delegator,
190 &delegation,
191 "ai:claude@joy",
192 "human@example.com",
193 "TST",
194 None,
195 );
196 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
197 assert_eq!(claims.ai_member, "ai:claude@joy");
198 assert_eq!(claims.delegated_by, "human@example.com");
199 assert_eq!(token.delegation_public_key, delegation_pk.to_hex());
200 }
201
202 #[test]
203 fn token_with_expiry() {
204 let (delegator, delegator_pk) = test_keypair();
205 let (delegation, delegation_pk) = fresh_delegation();
206 let token = create_token(
207 &delegator,
208 &delegation,
209 "ai:claude@joy",
210 "human@example.com",
211 "TST",
212 Some(Duration::hours(8)),
213 );
214 let claims = validate_token(&token, &delegator_pk, &delegation_pk, "TST").unwrap();
215 assert!(claims.expires.is_some());
216 }
217
218 #[test]
219 fn expired_token_rejected() {
220 let (delegator, delegator_pk) = test_keypair();
221 let (delegation, delegation_pk) = fresh_delegation();
222 let token = create_token(
223 &delegator,
224 &delegation,
225 "ai:claude@joy",
226 "human@example.com",
227 "TST",
228 Some(Duration::seconds(-1)),
229 );
230 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
231 }
232
233 #[test]
234 fn wrong_project_rejected() {
235 let (delegator, delegator_pk) = test_keypair();
236 let (delegation, delegation_pk) = fresh_delegation();
237 let token = create_token(
238 &delegator,
239 &delegation,
240 "ai:claude@joy",
241 "human@example.com",
242 "TST",
243 None,
244 );
245 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "OTHER").is_err());
246 }
247
248 #[test]
249 fn tampered_claims_rejected() {
250 let (delegator, delegator_pk) = test_keypair();
251 let (delegation, delegation_pk) = fresh_delegation();
252 let mut token = create_token(
253 &delegator,
254 &delegation,
255 "ai:claude@joy",
256 "human@example.com",
257 "TST",
258 None,
259 );
260 token.claims.ai_member = "ai:attacker@evil".into();
261 assert!(validate_token(&token, &delegator_pk, &delegation_pk, "TST").is_err());
262 }
263
264 #[test]
265 fn wrong_delegator_key_rejected() {
266 let (delegator, _) = test_keypair();
267 let (delegation, delegation_pk) = fresh_delegation();
268 let token = create_token(
269 &delegator,
270 &delegation,
271 "ai:claude@joy",
272 "human@example.com",
273 "TST",
274 None,
275 );
276
277 let other_salt = derive::generate_salt();
278 let other_key =
279 derive::derive_key("alpha bravo charlie delta echo foxtrot", &other_salt).unwrap();
280 let other_kp = sign::IdentityKeypair::from_derived_key(&other_key);
281 let other_pk = other_kp.public_key();
282
283 assert!(validate_token(&token, &other_pk, &delegation_pk, "TST").is_err());
284 }
285
286 #[test]
287 fn wrong_delegation_key_rejected() {
288 let (delegator, delegator_pk) = test_keypair();
289 let (delegation, _) = fresh_delegation();
290 let token = create_token(
291 &delegator,
292 &delegation,
293 "ai:claude@joy",
294 "human@example.com",
295 "TST",
296 None,
297 );
298
299 let (_, rotated_pk) = fresh_delegation();
301 assert!(validate_token(&token, &delegator_pk, &rotated_pk, "TST").is_err());
302 }
303
304 #[test]
305 fn encode_decode_roundtrip() {
306 let (delegator, delegator_pk) = test_keypair();
307 let (delegation, delegation_pk) = fresh_delegation();
308 let token = create_token(
309 &delegator,
310 &delegation,
311 "ai:claude@joy",
312 "human@example.com",
313 "TST",
314 None,
315 );
316 let encoded = encode_token(&token);
317 assert!(encoded.starts_with("joy_t_"));
318 let decoded = decode_token(&encoded).unwrap();
319 let claims = validate_token(&decoded, &delegator_pk, &delegation_pk, "TST").unwrap();
320 assert_eq!(claims.ai_member, "ai:claude@joy");
321 }
322
323 #[test]
324 fn invalid_prefix_rejected() {
325 assert!(decode_token("invalid_prefix_data").is_err());
326 }
327}