1use chrono::{Duration, Utc};
2use jsonwebtoken::{EncodingKey, Header, encode};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Serialize, Deserialize)]
8pub struct AgentTokenClaims {
9 pub sub: String,
10 pub agent_id: String,
11 pub iss: String,
12 pub iat: i64,
13 pub exp: i64,
14 pub jti: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct TokenConfig {
21 pub signing_secret: String,
23 pub expiry_seconds: i64,
25 pub issuer: String,
27}
28
29impl Default for TokenConfig {
30 fn default() -> Self {
31 Self {
32 signing_secret: "arbiter-dev-secret-change-in-production".into(),
33 expiry_seconds: 3600,
34 issuer: "arbiter".into(),
35 }
36 }
37}
38
39pub const MIN_SIGNING_SECRET_LEN: usize = 32;
42
43pub const MAX_TOKEN_EXPIRY_SECS: i64 = 86400; pub fn issue_token(
56 agent_id: Uuid,
57 owner: &str,
58 config: &TokenConfig,
59) -> Result<String, jsonwebtoken::errors::Error> {
60 if config.signing_secret.len() < MIN_SIGNING_SECRET_LEN {
63 tracing::error!(
64 length = config.signing_secret.len(),
65 minimum = MIN_SIGNING_SECRET_LEN,
66 "signing secret is shorter than required minimum, refusing to issue token"
67 );
68 return Err(jsonwebtoken::errors::Error::from(
69 jsonwebtoken::errors::ErrorKind::InvalidKeyFormat,
70 ));
71 }
72
73 let effective_expiry = config.expiry_seconds.min(MAX_TOKEN_EXPIRY_SECS);
75
76 let now = Utc::now();
77 let claims = AgentTokenClaims {
78 sub: owner.to_string(),
79 agent_id: agent_id.to_string(),
80 iss: config.issuer.clone(),
81 iat: now.timestamp(),
82 exp: (now + Duration::seconds(effective_expiry)).timestamp(),
83 jti: uuid::Uuid::new_v4().to_string(),
84 };
85
86 encode(
87 &Header::default(),
88 &claims,
89 &EncodingKey::from_secret(config.signing_secret.as_bytes()),
90 )
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use jsonwebtoken::{DecodingKey, Validation, decode};
97
98 #[test]
99 fn issue_and_decode_token() {
100 let config = TokenConfig::default();
101 let agent_id = Uuid::new_v4();
102 let token = issue_token(agent_id, "user:alice", &config).unwrap();
103
104 let mut validation = Validation::default();
105 validation.set_issuer(&[&config.issuer]);
106 validation.validate_exp = true;
107 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
108
109 let decoded = decode::<AgentTokenClaims>(
110 &token,
111 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
112 &validation,
113 )
114 .unwrap();
115
116 assert_eq!(decoded.claims.agent_id, agent_id.to_string());
117 assert_eq!(decoded.claims.sub, "user:alice");
118 assert_eq!(decoded.claims.iss, "arbiter");
119 }
120
121 #[test]
123 fn short_signing_secret_rejected() {
124 let config = TokenConfig {
125 signing_secret: "only-16-bytes!!!".into(), expiry_seconds: 3600,
127 issuer: "arbiter".into(),
128 };
129 let agent_id = Uuid::new_v4();
130 let result = issue_token(agent_id, "user:alice", &config);
131 assert!(result.is_err(), "16-byte secret must be rejected");
132 }
133
134 #[test]
136 fn minimum_length_secret_accepted() {
137 let config = TokenConfig {
138 signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(), expiry_seconds: 3600,
140 issuer: "arbiter".into(),
141 };
142 let agent_id = Uuid::new_v4();
143 let result = issue_token(agent_id, "user:alice", &config);
144 assert!(result.is_ok(), "32-byte secret must be accepted");
145 }
146
147 #[test]
149 fn expiry_capped_at_24_hours() {
150 let config = TokenConfig {
151 signing_secret: "arbiter-dev-secret-change-in-production".into(),
152 expiry_seconds: 172_800, issuer: "arbiter".into(),
154 };
155 let agent_id = Uuid::new_v4();
156 let token = issue_token(agent_id, "user:alice", &config).unwrap();
157
158 let mut validation = Validation::default();
159 validation.set_issuer(&[&config.issuer]);
160 validation.validate_exp = true;
161 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
162
163 let decoded = decode::<AgentTokenClaims>(
164 &token,
165 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
166 &validation,
167 )
168 .unwrap();
169
170 let delta = decoded.claims.exp - decoded.claims.iat;
171 assert!(
172 delta <= MAX_TOKEN_EXPIRY_SECS,
173 "exp - iat ({delta}) must be <= {MAX_TOKEN_EXPIRY_SECS}"
174 );
175 }
176
177 #[test]
179 fn normal_expiry_not_capped() {
180 let config = TokenConfig {
181 signing_secret: "arbiter-dev-secret-change-in-production".into(),
182 expiry_seconds: 3600,
183 issuer: "arbiter".into(),
184 };
185 let agent_id = Uuid::new_v4();
186 let token = issue_token(agent_id, "user:alice", &config).unwrap();
187
188 let mut validation = Validation::default();
189 validation.set_issuer(&[&config.issuer]);
190 validation.validate_exp = true;
191 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
192
193 let decoded = decode::<AgentTokenClaims>(
194 &token,
195 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
196 &validation,
197 )
198 .unwrap();
199
200 let delta = decoded.claims.exp - decoded.claims.iat;
201 assert_eq!(delta, 3600, "exp - iat should equal the configured 3600s");
202 }
203
204 #[test]
206 fn each_token_has_unique_jti() {
207 let config = TokenConfig::default();
208 let agent_id = Uuid::new_v4();
209
210 let token_a = issue_token(agent_id, "user:alice", &config).unwrap();
211 let token_b = issue_token(agent_id, "user:alice", &config).unwrap();
212
213 let mut validation = Validation::default();
214 validation.set_issuer(&[&config.issuer]);
215 validation.validate_exp = true;
216 validation.set_required_spec_claims(&["exp", "sub", "iss"]);
217
218 let claims_a = decode::<AgentTokenClaims>(
219 &token_a,
220 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
221 &validation,
222 )
223 .unwrap()
224 .claims;
225
226 let claims_b = decode::<AgentTokenClaims>(
227 &token_b,
228 &DecodingKey::from_secret(config.signing_secret.as_bytes()),
229 &validation,
230 )
231 .unwrap()
232 .claims;
233
234 assert_ne!(
235 claims_a.jti, claims_b.jti,
236 "each token must have a unique jti"
237 );
238 }
239}