1use chrono::{Duration, Utc};
2use jsonwebtoken::{EncodingKey, Header, encode};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::sync::Mutex;
6use uuid::Uuid;
7
8#[derive(Debug, Serialize, Deserialize)]
10pub struct AgentTokenClaims {
11 pub sub: String,
12 pub agent_id: String,
13 pub iss: String,
14 pub iat: i64,
15 pub exp: i64,
16 pub jti: String,
23}
24
25#[derive(Debug, Clone)]
27pub struct TokenConfig {
28 pub signing_secret: String,
30 pub expiry_seconds: i64,
32 pub issuer: String,
34}
35
36impl Default for TokenConfig {
37 fn default() -> Self {
40 Self {
41 signing_secret: String::new(),
42 expiry_seconds: 3600,
43 issuer: "arbiter".into(),
44 }
45 }
46}
47
48pub const MIN_SIGNING_SECRET_LEN: usize = 32;
51
52pub const MAX_TOKEN_EXPIRY_SECS: i64 = 86400; pub fn issue_token(
65 agent_id: Uuid,
66 owner: &str,
67 config: &TokenConfig,
68) -> Result<String, jsonwebtoken::errors::Error> {
69 if config.signing_secret.len() < MIN_SIGNING_SECRET_LEN {
72 tracing::error!(
73 length = config.signing_secret.len(),
74 minimum = MIN_SIGNING_SECRET_LEN,
75 "signing secret is shorter than required minimum, refusing to issue token"
76 );
77 return Err(jsonwebtoken::errors::Error::from(
78 jsonwebtoken::errors::ErrorKind::InvalidKeyFormat,
79 ));
80 }
81
82 let effective_expiry = config.expiry_seconds.min(MAX_TOKEN_EXPIRY_SECS);
84
85 let now = Utc::now();
86 let claims = AgentTokenClaims {
87 sub: owner.to_string(),
88 agent_id: agent_id.to_string(),
89 iss: config.issuer.clone(),
90 iat: now.timestamp(),
91 exp: (now + Duration::seconds(effective_expiry)).timestamp(),
92 jti: uuid::Uuid::new_v4().to_string(),
93 };
94
95 encode(
96 &Header::default(),
97 &claims,
98 &EncodingKey::from_secret(config.signing_secret.as_bytes()),
99 )
100}
101
102pub struct JtiBlocklist {
107 revoked: Mutex<HashMap<String, i64>>,
109}
110
111impl JtiBlocklist {
112 pub fn new() -> Self {
114 Self {
115 revoked: Mutex::new(HashMap::new()),
116 }
117 }
118
119 pub fn revoke(&self, jti: &str, exp: i64) {
122 let mut map = self.revoked.lock().unwrap_or_else(|e| e.into_inner());
123 map.insert(jti.to_string(), exp);
124 tracing::info!(jti, "token revoked via JTI blocklist");
125 }
126
127 pub fn is_revoked(&self, jti: &str) -> bool {
129 let map = self.revoked.lock().unwrap_or_else(|e| e.into_inner());
130 map.contains_key(jti)
131 }
132
133 pub fn cleanup(&self) {
135 let now = Utc::now().timestamp();
136 let mut map = self.revoked.lock().unwrap_or_else(|e| e.into_inner());
137 let before = map.len();
138 map.retain(|_, exp| *exp > now);
139 let removed = before - map.len();
140 if removed > 0 {
141 tracing::debug!(removed, "cleaned up expired JTI blocklist entries");
142 }
143 }
144
145 pub fn len(&self) -> usize {
147 self.revoked.lock().unwrap_or_else(|e| e.into_inner()).len()
148 }
149
150 pub fn is_empty(&self) -> bool {
152 self.len() == 0
153 }
154}
155
156impl Default for JtiBlocklist {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use jsonwebtoken::{DecodingKey, Validation, decode};
166
167 fn test_config() -> TokenConfig {
168 TokenConfig {
169 signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(),
170 expiry_seconds: 3600,
171 issuer: "arbiter".into(),
172 }
173 }
174
175 #[test]
176 fn default_config_rejects_token_issuance() {
177 let config = TokenConfig::default();
178 let agent_id = Uuid::new_v4();
179 let result = issue_token(agent_id, "user:alice", &config);
180 assert!(
181 result.is_err(),
182 "default config with empty secret must reject token issuance"
183 );
184 }
185
186 #[test]
187 fn issue_and_decode_token() {
188 let config = test_config();
189 let agent_id = Uuid::new_v4();
190 let token = issue_token(agent_id, "user:alice", &config).unwrap();
191
192 let mut validation = Validation::default();
193 validation.set_issuer(&[&config.issuer]);
194 validation.validate_exp = true;
195 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
196
197 let decoded = decode::<AgentTokenClaims>(
198 &token,
199 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
200 &validation,
201 )
202 .unwrap();
203
204 assert_eq!(decoded.claims.agent_id, agent_id.to_string());
205 assert_eq!(decoded.claims.sub, "user:alice");
206 assert_eq!(decoded.claims.iss, "arbiter");
207 }
208
209 #[test]
211 fn short_signing_secret_rejected() {
212 let config = TokenConfig {
213 signing_secret: "only-16-bytes!!!".into(), expiry_seconds: 3600,
215 issuer: "arbiter".into(),
216 };
217 let agent_id = Uuid::new_v4();
218 let result = issue_token(agent_id, "user:alice", &config);
219 assert!(result.is_err(), "16-byte secret must be rejected");
220 }
221
222 #[test]
224 fn minimum_length_secret_accepted() {
225 let config = TokenConfig {
226 signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(), expiry_seconds: 3600,
228 issuer: "arbiter".into(),
229 };
230 let agent_id = Uuid::new_v4();
231 let result = issue_token(agent_id, "user:alice", &config);
232 assert!(result.is_ok(), "32-byte secret must be accepted");
233 }
234
235 #[test]
237 fn expiry_capped_at_24_hours() {
238 let config = TokenConfig {
239 signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(),
240 expiry_seconds: 172_800, issuer: "arbiter".into(),
242 };
243 let agent_id = Uuid::new_v4();
244 let token = issue_token(agent_id, "user:alice", &config).unwrap();
245
246 let mut validation = Validation::default();
247 validation.set_issuer(&[&config.issuer]);
248 validation.validate_exp = true;
249 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
250
251 let decoded = decode::<AgentTokenClaims>(
252 &token,
253 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
254 &validation,
255 )
256 .unwrap();
257
258 let delta = decoded.claims.exp - decoded.claims.iat;
259 assert!(
260 delta <= MAX_TOKEN_EXPIRY_SECS,
261 "exp - iat ({delta}) must be <= {MAX_TOKEN_EXPIRY_SECS}"
262 );
263 }
264
265 #[test]
267 fn normal_expiry_not_capped() {
268 let config = TokenConfig {
269 signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(),
270 expiry_seconds: 3600,
271 issuer: "arbiter".into(),
272 };
273 let agent_id = Uuid::new_v4();
274 let token = issue_token(agent_id, "user:alice", &config).unwrap();
275
276 let mut validation = Validation::default();
277 validation.set_issuer(&[&config.issuer]);
278 validation.validate_exp = true;
279 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
280
281 let decoded = decode::<AgentTokenClaims>(
282 &token,
283 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
284 &validation,
285 )
286 .unwrap();
287
288 let delta = decoded.claims.exp - decoded.claims.iat;
289 assert_eq!(delta, 3600, "exp - iat should equal the configured 3600s");
290 }
291
292 #[test]
294 fn each_token_has_unique_jti() {
295 let config = test_config();
296 let agent_id = Uuid::new_v4();
297
298 let token_a = issue_token(agent_id, "user:alice", &config).unwrap();
299 let token_b = issue_token(agent_id, "user:alice", &config).unwrap();
300
301 let mut validation = Validation::default();
302 validation.set_issuer(&[&config.issuer]);
303 validation.validate_exp = true;
304 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
305
306 let claims_a = decode::<AgentTokenClaims>(
307 &token_a,
308 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
309 &validation,
310 )
311 .unwrap()
312 .claims;
313
314 let claims_b = decode::<AgentTokenClaims>(
315 &token_b,
316 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
317 &validation,
318 )
319 .unwrap()
320 .claims;
321
322 assert_ne!(
323 claims_a.jti, claims_b.jti,
324 "each token must have a unique jti"
325 );
326 }
327}