Skip to main content

arbiter_lifecycle/
token.rs

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/// Claims embedded in agent short-lived JWTs.
9#[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    /// Unique token identifier for future revocation tracking.
17    /// NOTE: JTI is generated per-token but not currently checked against a
18    /// blocklist during validation. Token revocation is handled at the session
19    /// and agent level (deactivate agent → cascade invalidation). A JTI
20    /// blocklist would add per-request state lookup; the current design trades
21    /// fine-grained token revocation for stateless validation performance.
22    pub jti: String,
23}
24
25/// Configuration for token issuance.
26#[derive(Debug, Clone)]
27pub struct TokenConfig {
28    /// HMAC secret for signing tokens.
29    pub signing_secret: String,
30    /// Token validity duration in seconds. Default: 3600 (1 hour).
31    pub expiry_seconds: i64,
32    /// Issuer claim.
33    pub issuer: String,
34}
35
36impl Default for TokenConfig {
37    /// Default config with an empty signing secret that will be rejected by issue_token.
38    /// Operators MUST provide a real secret via configuration.
39    fn default() -> Self {
40        Self {
41            signing_secret: String::new(),
42            expiry_seconds: 3600,
43            issuer: "arbiter".into(),
44        }
45    }
46}
47
48/// Minimum signing secret length.
49/// HMAC-SHA256 requires at least 256 bits (32 bytes) for security.
50pub const MIN_SIGNING_SECRET_LEN: usize = 32;
51
52/// Maximum token expiry to prevent tokens with infinite-like lifetimes.
53pub const MAX_TOKEN_EXPIRY_SECS: i64 = 86400; // 24 hours
54
55/// Issue a short-lived JWT for an agent.
56///
57/// Note: These tokens use HS256 (symmetric HMAC) and are intended
58/// for agent-to-admin-API authentication ONLY. They MUST NOT be validated through
59/// the proxy's OAuth middleware, which restricts to asymmetric algorithms (RS256, ES256,
60/// etc.) per FIX-008. The proxy's OAuth path is for external IdP tokens.
61///
62/// If you need agents to authenticate to the proxy via OAuth, issue tokens from an
63/// external IdP that uses asymmetric signing, and configure it as an OAuth issuer.
64pub fn issue_token(
65    agent_id: Uuid,
66    owner: &str,
67    config: &TokenConfig,
68) -> Result<String, jsonwebtoken::errors::Error> {
69    // Signing secret minimum length is now a hard error.
70    // Previously only warned, allowing 1-byte secrets that are trivially brutable.
71    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    // Cap token expiry to prevent arbitrarily long-lived tokens.
83    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
102/// In-memory JTI blocklist for token revocation.
103///
104/// Stores revoked JTI values with their expiry time so they can be
105/// cleaned up once the token would have expired anyway.
106pub struct JtiBlocklist {
107    /// Map of JTI -> expiry timestamp. Entries are removed after expiry.
108    revoked: Mutex<HashMap<String, i64>>,
109}
110
111impl JtiBlocklist {
112    /// Create a new empty blocklist.
113    pub fn new() -> Self {
114        Self {
115            revoked: Mutex::new(HashMap::new()),
116        }
117    }
118
119    /// Revoke a token by its JTI. The `exp` is the token's expiry time;
120    /// the entry will be auto-cleaned after that time.
121    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    /// Check if a JTI has been revoked.
128    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    /// Remove expired entries from the blocklist.
134    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    /// Number of currently revoked JTIs.
146    pub fn len(&self) -> usize {
147        self.revoked.lock().unwrap_or_else(|e| e.into_inner()).len()
148    }
149
150    /// Whether the blocklist is empty.
151    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    /// A signing secret shorter than 32 bytes must be rejected.
210    #[test]
211    fn short_signing_secret_rejected() {
212        let config = TokenConfig {
213            signing_secret: "only-16-bytes!!!".into(), // 16 bytes
214            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    /// Exactly 32 bytes should be accepted (boundary condition).
223    #[test]
224    fn minimum_length_secret_accepted() {
225        let config = TokenConfig {
226            signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(), // exactly 32 bytes
227            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    /// Token expiry must be capped at MAX_TOKEN_EXPIRY_SECS (24h).
236    #[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, // 48 hours — should be capped to 24h
241            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    /// A normal expiry (below the cap) should not be altered.
266    #[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    /// Each issued token must carry a unique jti for revocation tracking.
293    #[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}