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 revocation tracking.
15    pub jti: String,
16}
17
18/// Configuration for token issuance.
19#[derive(Debug, Clone)]
20pub struct TokenConfig {
21    /// HMAC secret for signing tokens.
22    pub signing_secret: String,
23    /// Token validity duration in seconds. Default: 3600 (1 hour).
24    pub expiry_seconds: i64,
25    /// Issuer claim.
26    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
39/// Minimum signing secret length.
40/// HMAC-SHA256 requires at least 256 bits (32 bytes) for security.
41pub const MIN_SIGNING_SECRET_LEN: usize = 32;
42
43/// Maximum token expiry to prevent tokens with infinite-like lifetimes.
44pub const MAX_TOKEN_EXPIRY_SECS: i64 = 86400; // 24 hours
45
46/// Issue a short-lived JWT for an agent.
47///
48/// Note: These tokens use HS256 (symmetric HMAC) and are intended
49/// for agent-to-admin-API authentication ONLY. They MUST NOT be validated through
50/// the proxy's OAuth middleware, which restricts to asymmetric algorithms (RS256, ES256,
51/// etc.) per FIX-008. The proxy's OAuth path is for external IdP tokens.
52///
53/// If you need agents to authenticate to the proxy via OAuth, issue tokens from an
54/// external IdP that uses asymmetric signing, and configure it as an OAuth issuer.
55pub fn issue_token(
56    agent_id: Uuid,
57    owner: &str,
58    config: &TokenConfig,
59) -> Result<String, jsonwebtoken::errors::Error> {
60    // Signing secret minimum length is now a hard error.
61    // Previously only warned, allowing 1-byte secrets that are trivially brutable.
62    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    // Cap token expiry to prevent arbitrarily long-lived tokens.
74    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    /// A signing secret shorter than 32 bytes must be rejected.
122    #[test]
123    fn short_signing_secret_rejected() {
124        let config = TokenConfig {
125            signing_secret: "only-16-bytes!!!".into(), // 16 bytes
126            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    /// Exactly 32 bytes should be accepted (boundary condition).
135    #[test]
136    fn minimum_length_secret_accepted() {
137        let config = TokenConfig {
138            signing_secret: "a]3Fz!9qL#mR&vXw2Tp7Ks@Yc0Nd8Ge$".into(), // exactly 32 bytes
139            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    /// Token expiry must be capped at MAX_TOKEN_EXPIRY_SECS (24h).
148    #[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, // 48 hours — should be capped to 24h
153            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    /// A normal expiry (below the cap) should not be altered.
178    #[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    /// Each issued token must carry a unique jti for revocation tracking.
205    #[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}