1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, thiserror::Error)]
9pub enum JwtError {
10 #[error("token expired")]
12 Expired,
13
14 #[error("invalid token: {0}")]
16 Invalid(String),
17
18 #[error("missing claim: {0}")]
20 MissingClaim(String),
21}
22
23pub fn encode_hs256(claims: &impl Serialize, secret: &str) -> Result<String, JwtError> {
25 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
26 jsonwebtoken::encode(
27 &header,
28 claims,
29 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
30 )
31 .map_err(|e| JwtError::Invalid(e.to_string()))
32}
33
34pub fn decode_hs256<T: for<'de> Deserialize<'de>>(
36 token: &str,
37 secret: &str,
38 issuer: Option<&str>,
39 audience: Option<&str>,
40) -> Result<T, JwtError> {
41 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
42
43 if let Some(iss) = issuer {
44 validation.set_issuer(&[iss]);
45 }
46 if let Some(aud) = audience {
47 validation.set_audience(&[aud]);
48 }
49
50 jsonwebtoken::decode::<T>(
51 token,
52 &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
53 &validation,
54 )
55 .map(|data| data.claims)
56 .map_err(|e| match *e.kind() {
57 jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
58 _ => JwtError::Invalid(e.to_string()),
59 })
60}
61
62
63pub fn encode_es256(claims: &impl Serialize, private_key_pem: &str) -> Result<String, JwtError> {
68 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
69 let key = jsonwebtoken::EncodingKey::from_ec_pem(private_key_pem.as_bytes())
70 .map_err(|e| JwtError::Invalid(format!("bad ES256 private PEM: {e}")))?;
71 jsonwebtoken::encode(&header, claims, &key).map_err(|e| JwtError::Invalid(e.to_string()))
72}
73
74pub fn decode_es256<T: for<'de> Deserialize<'de>>(
80 token: &str,
81 public_key_pem: &str,
82 issuer: Option<&str>,
83 audience: Option<&str>,
84) -> Result<T, JwtError> {
85 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
86 if let Some(iss) = issuer {
87 validation.set_issuer(&[iss]);
88 }
89 if let Some(aud) = audience {
90 validation.set_audience(&[aud]);
91 }
92 let key = jsonwebtoken::DecodingKey::from_ec_pem(public_key_pem.as_bytes())
93 .map_err(|e| JwtError::Invalid(format!("bad ES256 public PEM: {e}")))?;
94 jsonwebtoken::decode::<T>(token, &key, &validation)
95 .map(|data| data.claims)
96 .map_err(|e| match *e.kind() {
97 jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
98 _ => JwtError::Invalid(e.to_string()),
99 })
100}
101
102pub fn encode_rs256(claims: &impl Serialize, private_key_pem: &str) -> Result<String, JwtError> {
107 let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
108 let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
109 .map_err(|e| JwtError::Invalid(format!("bad RS256 private PEM: {e}")))?;
110 jsonwebtoken::encode(&header, claims, &key).map_err(|e| JwtError::Invalid(e.to_string()))
111}
112
113pub fn decode_rs256<T: for<'de> Deserialize<'de>>(
119 token: &str,
120 public_key_pem: &str,
121 issuer: Option<&str>,
122 audience: Option<&str>,
123) -> Result<T, JwtError> {
124 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
125 if let Some(iss) = issuer {
126 validation.set_issuer(&[iss]);
127 }
128 if let Some(aud) = audience {
129 validation.set_audience(&[aud]);
130 }
131 let key = jsonwebtoken::DecodingKey::from_rsa_pem(public_key_pem.as_bytes())
132 .map_err(|e| JwtError::Invalid(format!("bad RS256 public PEM: {e}")))?;
133 jsonwebtoken::decode::<T>(token, &key, &validation)
134 .map(|data| data.claims)
135 .map_err(|e| match *e.kind() {
136 jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
137 _ => JwtError::Invalid(e.to_string()),
138 })
139}
140
141pub fn extract_bearer_token(header_value: &str) -> Option<&str> {
143 header_value.strip_prefix("Bearer ").map(|t| t.trim())
144}
145
146pub fn build_auth_cookie(cookie_name: &str, token: &str, max_age_secs: i64) -> String {
148 format!("{cookie_name}={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age_secs}")
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used)]
154mod tests {
155 use super::*;
156 use serde::{Deserialize, Serialize};
157
158 #[derive(Debug, Serialize, Deserialize, PartialEq)]
159 struct TestClaims {
160 sub: String,
161 exp: usize,
162 iat: usize,
163 }
164
165 #[test]
166 fn encode_decode_roundtrip() {
167 let claims = TestClaims {
168 sub: "user123".into(),
169 exp: 9999999999,
170 iat: 1000000000,
171 };
172 let token = encode_hs256(&claims, "secret-key-1234567890123456").unwrap();
173 let decoded: TestClaims =
174 decode_hs256(&token, "secret-key-1234567890123456", None, None).unwrap();
175 assert_eq!(decoded, claims);
176 }
177
178 #[test]
179 fn decode_wrong_secret_fails() {
180 let claims = TestClaims {
181 sub: "user123".into(),
182 exp: 9999999999,
183 iat: 1000000000,
184 };
185 let token = encode_hs256(&claims, "secret1").unwrap();
186 assert!(decode_hs256::<TestClaims>(&token, "secret2", None, None).is_err());
187 }
188
189 #[test]
190 fn extract_bearer_token_valid() {
191 assert_eq!(extract_bearer_token("Bearer abc123"), Some("abc123"));
192 }
193
194 #[test]
195 fn extract_bearer_token_missing() {
196 assert!(extract_bearer_token("Basic abc123").is_none());
197 }
198
199 #[test]
200 fn build_auth_cookie_format() {
201 let cookie = build_auth_cookie("session", "tok123", 3600);
202 assert!(cookie.contains("session=tok123"));
203 assert!(cookie.contains("HttpOnly"));
204 assert!(cookie.contains("Secure"));
205 assert!(cookie.contains("Max-Age=3600"));
206 }
207
208 #[derive(Debug, Serialize, Deserialize, PartialEq)]
209 struct EsClaims {
210 sub: String,
211 iss: String,
212 aud: String,
213 exp: usize,
214 }
215
216 const TEST_ES256_PRIV: &str = r##"-----BEGIN PRIVATE KEY-----
217MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxvCgr98XLeY5qBtV
218EUAHmzqfBkJclip1tDcOaOnZRnWhRANCAARciewarZqgQwFGY5sTdhAs6Bl8KIjz
219KJiV2d3HJBDLGgb/HCrFnhPOFPR2wzgrDrIQvBTQ+dvaEp9KA7EONjR3
220-----END PRIVATE KEY-----"##;
221 const TEST_ES256_PUB: &str =r##"-----BEGIN PUBLIC KEY-----
222MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXInsGq2aoEMBRmObE3YQLOgZfCiI
2238yiYldndxyQQyxoG/xwqxZ4TzhT0dsM4Kw6yELwU0Pnb2hKfSgOxDjY0dw==
224-----END PUBLIC KEY-----
225"##;
226
227 fn es_claims() -> EsClaims {
228 EsClaims {
229 sub: "wyatt".to_string(),
230 iss: "https://authbox.wyattau.com".to_string(),
231 aud: "hosting-api".to_string(),
232 exp: (std::time::SystemTime::now()
233 .duration_since(std::time::UNIX_EPOCH)
234 .unwrap_or_default()
235 .as_secs() as usize)
236 + 600,
237 }
238 }
239
240 #[test]
241 fn es256_roundtrip_and_rejection() {
242 let token = encode_es256(&es_claims(), TEST_ES256_PRIV).unwrap();
243 let decoded: EsClaims = decode_es256(
244 &token,
245 TEST_ES256_PUB,
246 Some("https://authbox.wyattau.com"),
247 Some("hosting-api"),
248 )
249 .unwrap();
250 assert_eq!(decoded, es_claims());
251
252 let mut parts: Vec<&str> = token.split('.').collect();
254 parts[1] = "eyJzdWIiOiJldmlsIn0";
255 let tampered = parts.join(".");
256 assert!(decode_es256::<EsClaims>(&tampered, TEST_ES256_PUB, None, None).is_err());
257 }
258}