Skip to main content

rskit_auth/jwt/
config.rs

1use std::{fmt, time::Duration};
2
3use rskit_util::SecretString;
4use serde::Deserialize;
5
6/// Public JWT algorithm policy for rskit.
7#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
9#[non_exhaustive]
10pub enum JwtAlgorithm {
11    /// HMAC-SHA256 for explicitly internal symmetric deployments only.
12    Hs256Internal,
13    /// RSA-SHA256 — preferred public-key default.
14    Rs256,
15    /// ECDSA P-256 / SHA-256.
16    Es256,
17    /// Ed25519 / `EdDSA`.
18    EdDsa,
19}
20
21impl JwtAlgorithm {
22    /// Return the `jsonwebtoken` algorithm for this policy entry.
23    #[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    /// True when the algorithm uses a symmetric shared secret.
34    #[must_use]
35    pub const fn is_symmetric(self) -> bool {
36        matches!(self, Self::Hs256Internal)
37    }
38}
39
40/// Asymmetric signing algorithm.
41#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
42#[non_exhaustive]
43pub enum AsymmetricAlgorithm {
44    /// RSA-SHA256.
45    #[serde(rename = "RS256")]
46    Rs256,
47    /// ECDSA P-256 / SHA-256.
48    #[serde(rename = "ES256")]
49    Es256,
50    /// Ed25519 / `EdDSA`.
51    #[serde(rename = "EDDSA")]
52    EdDsa,
53}
54
55impl AsymmetricAlgorithm {
56    /// Map to the public-facing [`JwtAlgorithm`].
57    #[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
77// Algorithm identifier is not secret — no-op zeroize.
78impl zeroize::Zeroize for AsymmetricAlgorithm {
79    fn zeroize(&mut self) {}
80}
81
82/// PEM key pair for asymmetric algorithms.
83#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
84pub struct KeyPair {
85    /// PKCS#8 (or PKCS#1 for RSA) private key PEM.
86    pub private_key_pem: SecretString,
87    /// `SubjectPublicKeyInfo` public key PEM.
88    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/// Key material used to sign and verify JWTs.
101#[derive(Clone, Deserialize, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
102#[non_exhaustive]
103pub enum JwtKeyMaterial {
104    /// Explicit internal-only HMAC secret.
105    Hs256Internal {
106        /// Shared secret used for signing and verification.
107        secret: SecretString,
108    },
109    /// Asymmetric PEM key pair (RS256, ES256, or `EdDSA`).
110    Asymmetric {
111        /// Which asymmetric algorithm this key pair is for.
112        algorithm: AsymmetricAlgorithm,
113        /// The PEM key pair.
114        #[serde(flatten)]
115        keys: KeyPair,
116    },
117}
118
119impl JwtKeyMaterial {
120    /// Return the algorithm implied by the key material.
121    #[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/// JWT validation policy.
147#[derive(Clone, Deserialize)]
148pub struct JwtConfig {
149    /// Signing and verification key material.
150    pub key_material: JwtKeyMaterial,
151    /// Expected issuer claim.
152    pub issuer: String,
153    /// Accepted audience claims.
154    pub audience: Vec<String>,
155    /// Token time-to-live. Generation helpers may use this value.
156    #[serde(default = "JwtConfig::default_ttl")]
157    pub ttl: Duration,
158    /// Clock-skew tolerance. Defaults to 30 seconds and must not exceed 60 seconds.
159    #[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    /// Create an asymmetric key configuration.
173    #[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    /// Create an explicit internal-only HS256 configuration.
197    #[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    /// Create an RS256 configuration from PEM-encoded keys.
215    #[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    /// Create an ES256 configuration from PEM-encoded keys.
232    #[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    /// Create an `EdDSA` configuration from PEM-encoded keys.
249    #[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    /// Override the configured token TTL.
266    #[must_use]
267    pub const fn with_ttl(mut self, ttl: Duration) -> Self {
268        self.ttl = ttl;
269        self
270    }
271
272    /// Override clock skew tolerance.
273    #[must_use]
274    pub const fn with_leeway(mut self, leeway: Duration) -> Self {
275        self.leeway = leeway;
276        self
277    }
278
279    /// Return the effective JWT algorithm.
280    #[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}