1use std::{fmt, time::Duration};
2
3use rskit_util::SecretString;
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
9#[non_exhaustive]
10pub enum JwtAlgorithm {
11 Hs256Internal,
13 Rs256,
15 Es256,
17 EdDsa,
19}
20
21impl JwtAlgorithm {
22 #[must_use]
24 pub const fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm {
25 match self {
26 Self::Hs256Internal => jsonwebtoken::Algorithm::HS256,
27 Self::Rs256 => jsonwebtoken::Algorithm::RS256,
28 Self::Es256 => jsonwebtoken::Algorithm::ES256,
29 Self::EdDsa => jsonwebtoken::Algorithm::EdDSA,
30 }
31 }
32
33 #[must_use]
35 pub const fn is_symmetric(self) -> bool {
36 matches!(self, Self::Hs256Internal)
37 }
38}
39
40#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
42#[non_exhaustive]
43pub enum AsymmetricAlgorithm {
44 #[serde(rename = "RS256")]
46 Rs256,
47 #[serde(rename = "ES256")]
49 Es256,
50 #[serde(rename = "EDDSA")]
52 EdDsa,
53}
54
55impl AsymmetricAlgorithm {
56 #[must_use]
58 pub const fn as_jwt_algorithm(self) -> JwtAlgorithm {
59 match self {
60 Self::Rs256 => JwtAlgorithm::Rs256,
61 Self::Es256 => JwtAlgorithm::Es256,
62 Self::EdDsa => JwtAlgorithm::EdDsa,
63 }
64 }
65}
66
67impl fmt::Debug for AsymmetricAlgorithm {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::Rs256 => f.write_str("RS256"),
71 Self::Es256 => f.write_str("ES256"),
72 Self::EdDsa => f.write_str("EdDSA"),
73 }
74 }
75}
76
77impl zeroize::Zeroize for AsymmetricAlgorithm {
79 fn zeroize(&mut self) {}
80}
81
82#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
84pub struct KeyPair {
85 pub private_key_pem: SecretString,
87 pub public_key_pem: SecretString,
89}
90
91impl fmt::Debug for KeyPair {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.debug_struct("KeyPair")
94 .field("private_key_pem", &self.private_key_pem)
95 .field("public_key_pem", &self.public_key_pem)
96 .finish()
97 }
98}
99
100#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
102#[non_exhaustive]
103pub enum JwtKeyMaterial {
104 Hs256Internal {
106 secret: SecretString,
108 },
109 Asymmetric {
111 algorithm: AsymmetricAlgorithm,
113 #[serde(flatten)]
115 keys: KeyPair,
116 },
117}
118
119impl JwtKeyMaterial {
120 #[must_use]
122 pub const fn algorithm(&self) -> JwtAlgorithm {
123 match self {
124 Self::Hs256Internal { .. } => JwtAlgorithm::Hs256Internal,
125 Self::Asymmetric { algorithm, .. } => algorithm.as_jwt_algorithm(),
126 }
127 }
128}
129
130impl fmt::Debug for JwtKeyMaterial {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 Self::Hs256Internal { secret } => f
134 .debug_struct("Hs256Internal")
135 .field("secret", secret)
136 .finish(),
137 Self::Asymmetric { algorithm, keys } => f
138 .debug_struct("Asymmetric")
139 .field("algorithm", algorithm)
140 .field("keys", keys)
141 .finish(),
142 }
143 }
144}
145
146#[derive(Clone, Deserialize)]
148pub struct JwtConfig {
149 pub key_material: JwtKeyMaterial,
151 pub issuer: String,
153 pub audience: Vec<String>,
155 #[serde(default = "JwtConfig::default_ttl")]
157 pub ttl: Duration,
158 #[serde(default = "JwtConfig::default_leeway")]
160 pub leeway: Duration,
161}
162
163impl JwtConfig {
164 const fn default_ttl() -> Duration {
165 Duration::from_hours(1)
166 }
167
168 const fn default_leeway() -> Duration {
169 Duration::from_secs(30)
170 }
171
172 #[must_use]
174 pub fn asymmetric(
175 algorithm: AsymmetricAlgorithm,
176 private_key_pem: impl Into<String>,
177 public_key_pem: impl Into<String>,
178 issuer: impl Into<String>,
179 audience: Vec<String>,
180 ) -> Self {
181 Self {
182 key_material: JwtKeyMaterial::Asymmetric {
183 algorithm,
184 keys: KeyPair {
185 private_key_pem: SecretString::new(private_key_pem),
186 public_key_pem: SecretString::new(public_key_pem),
187 },
188 },
189 issuer: issuer.into(),
190 audience,
191 ttl: Self::default_ttl(),
192 leeway: Self::default_leeway(),
193 }
194 }
195
196 #[must_use]
198 pub fn hs256_internal(
199 secret: impl Into<String>,
200 issuer: impl Into<String>,
201 audience: Vec<String>,
202 ) -> Self {
203 Self {
204 key_material: JwtKeyMaterial::Hs256Internal {
205 secret: SecretString::new(secret),
206 },
207 issuer: issuer.into(),
208 audience,
209 ttl: Self::default_ttl(),
210 leeway: Self::default_leeway(),
211 }
212 }
213
214 #[must_use]
216 pub fn rs256(
217 private_key_pem: impl Into<String>,
218 public_key_pem: impl Into<String>,
219 issuer: impl Into<String>,
220 audience: Vec<String>,
221 ) -> Self {
222 Self::asymmetric(
223 AsymmetricAlgorithm::Rs256,
224 private_key_pem,
225 public_key_pem,
226 issuer,
227 audience,
228 )
229 }
230
231 #[must_use]
233 pub fn es256(
234 private_key_pem: impl Into<String>,
235 public_key_pem: impl Into<String>,
236 issuer: impl Into<String>,
237 audience: Vec<String>,
238 ) -> Self {
239 Self::asymmetric(
240 AsymmetricAlgorithm::Es256,
241 private_key_pem,
242 public_key_pem,
243 issuer,
244 audience,
245 )
246 }
247
248 #[must_use]
250 pub fn eddsa(
251 private_key_pem: impl Into<String>,
252 public_key_pem: impl Into<String>,
253 issuer: impl Into<String>,
254 audience: Vec<String>,
255 ) -> Self {
256 Self::asymmetric(
257 AsymmetricAlgorithm::EdDsa,
258 private_key_pem,
259 public_key_pem,
260 issuer,
261 audience,
262 )
263 }
264
265 #[must_use]
267 pub const fn with_ttl(mut self, ttl: Duration) -> Self {
268 self.ttl = ttl;
269 self
270 }
271
272 #[must_use]
274 pub const fn with_leeway(mut self, leeway: Duration) -> Self {
275 self.leeway = leeway;
276 self
277 }
278
279 #[must_use]
281 pub const fn algorithm(&self) -> JwtAlgorithm {
282 self.key_material.algorithm()
283 }
284}
285
286impl fmt::Debug for JwtConfig {
287 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
288 formatter
289 .debug_struct("JwtConfig")
290 .field("key_material", &self.key_material)
291 .field("issuer", &self.issuer)
292 .field("audience", &self.audience)
293 .field("ttl", &self.ttl)
294 .field("leeway", &self.leeway)
295 .finish()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn jwt_key_material_debug_redacts_secret_values() {
305 let symmetric = format!(
306 "{:?}",
307 JwtKeyMaterial::Hs256Internal {
308 secret: SecretString::new("super-secret-value"),
309 }
310 );
311 assert!(symmetric.contains("***"));
312 assert!(!symmetric.contains("super-secret-value"));
313
314 let asymmetric = format!(
315 "{:?}",
316 JwtKeyMaterial::Asymmetric {
317 algorithm: AsymmetricAlgorithm::Rs256,
318 keys: KeyPair {
319 private_key_pem: SecretString::new("private-pem"),
320 public_key_pem: SecretString::new("public-pem"),
321 },
322 }
323 );
324 assert!(asymmetric.contains("***"));
325 assert!(asymmetric.contains("RS256"));
326 assert!(!asymmetric.contains("private-pem"));
327 assert!(!asymmetric.contains("public-pem"));
328 }
329
330 #[test]
331 fn jwt_algorithm_policy_identifies_symmetric_internal_mode() {
332 assert!(JwtAlgorithm::Hs256Internal.is_symmetric());
333 assert!(!JwtAlgorithm::Rs256.is_symmetric());
334 assert!(!JwtAlgorithm::Es256.is_symmetric());
335 assert!(!JwtAlgorithm::EdDsa.is_symmetric());
336 }
337
338 #[test]
339 fn jwt_config_builders_preserve_ttl_and_leeway_overrides() {
340 let config = JwtConfig::hs256_internal(
341 "secret-material-that-is-long-enough",
342 "https://issuer.example",
343 vec!["audience".into()],
344 )
345 .with_ttl(Duration::from_mins(5))
346 .with_leeway(Duration::from_secs(10));
347
348 assert_eq!(config.ttl, Duration::from_mins(5));
349 assert_eq!(config.leeway, Duration::from_secs(10));
350 }
351
352 #[test]
353 fn jwt_config_debug_redacts_nested_key_material() {
354 let config = JwtConfig::hs256_internal(
355 "another-secret-value",
356 "issuer.example",
357 vec!["audience".into()],
358 );
359
360 let formatted = format!("{config:?}");
361
362 assert!(formatted.contains("***"));
363 assert!(!formatted.contains("another-secret-value"));
364 assert!(formatted.contains("issuer.example"));
365 }
366
367 #[test]
368 fn convenience_constructors_delegate_to_asymmetric() {
369 let rs = JwtConfig::rs256("priv", "pub", "iss", vec!["aud".into()]);
370 assert_eq!(rs.algorithm(), JwtAlgorithm::Rs256);
371
372 let es = JwtConfig::es256("priv", "pub", "iss", vec!["aud".into()]);
373 assert_eq!(es.algorithm(), JwtAlgorithm::Es256);
374
375 let ed = JwtConfig::eddsa("priv", "pub", "iss", vec!["aud".into()]);
376 assert_eq!(ed.algorithm(), JwtAlgorithm::EdDsa);
377 }
378
379 #[test]
380 fn asymmetric_algorithm_roundtrip_serde() {
381 let json = r#""RS256""#;
382 let alg: AsymmetricAlgorithm = serde_json::from_str(json).unwrap();
383 assert_eq!(alg, AsymmetricAlgorithm::Rs256);
384
385 let json = r#""EDDSA""#;
386 let alg: AsymmetricAlgorithm = serde_json::from_str(json).unwrap();
387 assert_eq!(alg, AsymmetricAlgorithm::EdDsa);
388 }
389
390 #[test]
391 fn key_material_serde_symmetric() {
392 let json = r#"{"Hs256Internal": {"secret": "my-secret"}}"#;
393 let mat: JwtKeyMaterial = serde_json::from_str(json).unwrap();
394 assert_eq!(mat.algorithm(), JwtAlgorithm::Hs256Internal);
395 }
396
397 #[test]
398 fn key_material_serde_asymmetric() {
399 let json = r#"{"Asymmetric": {"algorithm": "RS256", "private_key_pem": "priv", "public_key_pem": "pub"}}"#;
400 let mat: JwtKeyMaterial = serde_json::from_str(json).unwrap();
401 assert_eq!(mat.algorithm(), JwtAlgorithm::Rs256);
402 }
403}