1use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use super::scope::Scope;
8
9const TOKEN_EXPIRY_SECS: u64 = 24 * 60 * 60;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Claims {
15 pub sub: String,
17 pub iat: u64,
19 pub exp: u64,
21 pub scope: Vec<Scope>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub team_id: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub org_id: Option<String>,
33}
34
35pub struct JwtSigner {
37 encoding_key: EncodingKey,
38}
39
40impl JwtSigner {
41 pub fn new(secret: &[u8]) -> Self {
43 Self {
44 encoding_key: EncodingKey::from_secret(secret),
45 }
46 }
47
48 pub fn sign(&self, key_id: &str, scopes: &[Scope]) -> Result<String, JwtError> {
52 self.sign_with_tenant(key_id, scopes, None, None)
53 }
54
55 pub fn sign_with_tenant(
61 &self,
62 key_id: &str,
63 scopes: &[Scope],
64 team_id: Option<String>,
65 org_id: Option<String>,
66 ) -> Result<String, JwtError> {
67 let now = now_epoch_secs();
68 let claims = Claims {
69 sub: key_id.to_string(),
70 iat: now,
71 exp: now + TOKEN_EXPIRY_SECS,
72 scope: scopes.to_vec(),
73 team_id,
74 org_id,
75 };
76 encode(&Header::default(), &claims, &self.encoding_key).map_err(JwtError::Encode)
77 }
78
79 #[cfg(test)]
81 fn sign_with_expiry(&self, key_id: &str, scopes: &[Scope], exp: u64) -> Result<String, JwtError> {
82 let claims = Claims {
83 sub: key_id.to_string(),
84 iat: now_epoch_secs(),
85 exp,
86 scope: scopes.to_vec(),
87 team_id: None,
88 org_id: None,
89 };
90 encode(&Header::default(), &claims, &self.encoding_key).map_err(JwtError::Encode)
91 }
92}
93
94pub struct JwtVerifier {
96 decoding_key: DecodingKey,
97 validation: Validation,
98}
99
100impl JwtVerifier {
101 pub fn new(secret: &[u8]) -> Self {
103 let mut validation = Validation::default();
104 validation.set_required_spec_claims(&["sub", "iat", "exp"]);
105 Self {
106 decoding_key: DecodingKey::from_secret(secret),
107 validation,
108 }
109 }
110
111 pub fn verify(&self, token: &str) -> Result<Claims, JwtError> {
115 let data = decode::<Claims>(token, &self.decoding_key, &self.validation).map_err(JwtError::Decode)?;
116 Ok(data.claims)
117 }
118}
119
120fn now_epoch_secs() -> u64 {
122 std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .expect("system clock before Unix epoch")
125 .as_secs()
126}
127
128#[derive(Debug, Error)]
130pub enum JwtError {
131 #[error("failed to encode JWT: {0}")]
132 Encode(jsonwebtoken::errors::Error),
133 #[error("failed to decode JWT: {0}")]
134 Decode(jsonwebtoken::errors::Error),
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 const TEST_SECRET: &[u8] = b"test-secret-key-that-is-at-least-32-bytes-long!!";
142
143 #[test]
144 fn test_jwt_sign_verify_roundtrip() {
145 let signer = JwtSigner::new(TEST_SECRET);
146 let verifier = JwtVerifier::new(TEST_SECRET);
147
148 let token = signer
149 .sign("key-123", &[Scope::Read, Scope::Write])
150 .expect("signing should succeed");
151 let claims = verifier.verify(&token).expect("verification should succeed");
152
153 assert_eq!(claims.sub, "key-123");
154 assert_eq!(claims.scope, vec![Scope::Read, Scope::Write]);
155 assert!(claims.exp > claims.iat);
156 }
157
158 #[test]
159 fn test_jwt_expired_token_rejected() {
160 let signer = JwtSigner::new(TEST_SECRET);
161 let verifier = JwtVerifier::new(TEST_SECRET);
162
163 let expired = now_epoch_secs() - 3600;
165 let token = signer
166 .sign_with_expiry("key-123", &[Scope::Read], expired)
167 .expect("signing should succeed");
168
169 let result = verifier.verify(&token);
170 assert!(result.is_err(), "expired token should be rejected");
171 }
172
173 #[test]
174 fn test_jwt_wrong_secret_rejected() {
175 let signer = JwtSigner::new(TEST_SECRET);
176 let verifier = JwtVerifier::new(b"different-secret-that-is-also-32-bytes-long!!");
177
178 let token = signer.sign("key-123", &[Scope::Read]).expect("signing should succeed");
179
180 let result = verifier.verify(&token);
181 assert!(result.is_err(), "token signed with different secret should be rejected");
182 }
183
184 #[test]
185 fn test_jwt_tenant_claim_roundtrip() {
186 let signer = JwtSigner::new(TEST_SECRET);
189 let verifier = JwtVerifier::new(TEST_SECRET);
190
191 let scoped = signer
192 .sign_with_tenant("key-1", &[Scope::Read], Some("alpha".into()), Some("org-1".into()))
193 .unwrap();
194 let claims = verifier.verify(&scoped).unwrap();
195 assert_eq!(claims.team_id.as_deref(), Some("alpha"));
196 assert_eq!(claims.org_id.as_deref(), Some("org-1"));
197
198 let plain = signer.sign("key-2", &[Scope::Read]).unwrap();
199 let plain_claims = verifier.verify(&plain).unwrap();
200 assert_eq!(plain_claims.team_id, None);
201 assert_eq!(plain_claims.org_id, None);
202 }
203
204 #[test]
205 fn test_jwt_scopes_preserved() {
206 let signer = JwtSigner::new(TEST_SECRET);
207 let verifier = JwtVerifier::new(TEST_SECRET);
208
209 let scopes = vec![Scope::Read, Scope::Write, Scope::Admin];
210 let token = signer.sign("key-456", &scopes).expect("signing should succeed");
211 let claims = verifier.verify(&token).expect("verification should succeed");
212
213 assert_eq!(claims.scope, scopes);
214 }
215}