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