Skip to main content

arbiter_lifecycle/
token.rs

1use chrono::{Duration, Utc};
2use jsonwebtoken::{EncodingKey, Header, encode};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Claims embedded in agent short-lived JWTs.
7#[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    /// Unique token identifier for future revocation tracking.
15    /// NOTE: JTI is generated per-token but not currently checked against a
16    /// blocklist during validation. Token revocation is handled at the session
17    /// and agent level (deactivate agent → cascade invalidation). A JTI
18    /// blocklist would add per-request state lookup; the current design trades
19    /// fine-grained token revocation for stateless validation performance.
20    pub jti: String,
21}
22
23/// Configuration for token issuance.
24#[derive(Debug, Clone)]
25pub struct TokenConfig {
26    /// HMAC secret for signing tokens.
27    pub signing_secret: String,
28    /// Token validity duration in seconds. Default: 3600 (1 hour).
29    pub expiry_seconds: i64,
30    /// Issuer claim.
31    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
44/// Minimum signing secret length.
45/// HMAC-SHA256 requires at least 256 bits (32 bytes) for security.
46pub const MIN_SIGNING_SECRET_LEN: usize = 32;
47
48/// Maximum token expiry to prevent tokens with infinite-like lifetimes.
49pub const MAX_TOKEN_EXPIRY_SECS: i64 = 86400; // 24 hours
50
51/// Issue a short-lived JWT for an agent.
52///
53/// Note: These tokens use HS256 (symmetric HMAC) and are intended
54/// for agent-to-admin-API authentication ONLY. They MUST NOT be validated through
55/// the proxy's OAuth middleware, which restricts to asymmetric algorithms (RS256, ES256,
56/// etc.) per FIX-008. The proxy's OAuth path is for external IdP tokens.
57///
58/// If you need agents to authenticate to the proxy via OAuth, issue tokens from an
59/// external IdP that uses asymmetric signing, and configure it as an OAuth issuer.
60pub fn issue_token(
61    agent_id: Uuid,
62    owner: &str,
63    config: &TokenConfig,
64) -> Result<String, jsonwebtoken::errors::Error> {
65    // Signing secret minimum length is now a hard error.
66    // Previously only warned, allowing 1-byte secrets that are trivially brutable.
67    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    // Cap token expiry to prevent arbitrarily long-lived tokens.
79    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    /// A signing secret shorter than 32 bytes must be rejected.
127    #[test]
128    fn short_signing_secret_rejected() {
129        let config = TokenConfig {
130            signing_secret: "only-16-bytes!!!".into(), // 16 bytes
131            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    /// Exactly 32 bytes should be accepted (boundary condition).
140    #[test]
141    fn minimum_length_secret_accepted() {
142        let config = TokenConfig {
143            signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(), // exactly 32 bytes
144            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    /// Token expiry must be capped at MAX_TOKEN_EXPIRY_SECS (24h).
153    #[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, // 48 hours — should be capped to 24h
158            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    /// A normal expiry (below the cap) should not be altered.
183    #[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    /// Each issued token must carry a unique jti for revocation tracking.
210    #[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}