Skip to main content

forge_core/config/
auth.rs

1//! Authentication and JWT configuration.
2
3use std::time::Duration;
4
5use crate::error::{ForgeError, Result};
6use serde::{Deserialize, Serialize};
7
8use super::types::DurationStr;
9
10/// JWT signing algorithm.
11///
12/// Supported values in forge.toml: `"HS256"` (default), `"RS256"`.
13/// Any other value produces a deserialization error at startup.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "UPPERCASE")]
16#[non_exhaustive]
17pub enum JwtAlgorithm {
18    /// HMAC using SHA-256 (symmetric, requires jwt_secret).
19    #[default]
20    HS256,
21    /// RSA using SHA-256 (asymmetric, requires jwks_url).
22    RS256,
23}
24
25/// A retired HMAC secret kept around for a bounded window so tokens minted
26/// before rotation still validate. After `valid_until` the entry is silently
27/// dropped at startup and never used for verification — leaked old keys
28/// cannot extend their reach indefinitely.
29///
30/// Rotate by adding the outgoing secret here with `valid_until` set one
31/// access-token TTL into the future, swap `jwt_secret` to the new value,
32/// then remove the entry once the window closes.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct LegacySecret {
35    /// HMAC secret bytes (treated as opaque; min length is not re-enforced
36    /// here — the active `jwt_secret` validation already covers minimum
37    /// strength, and a previously-active key already satisfied it).
38    pub secret: String,
39    /// RFC 3339 timestamp after which this key is no longer accepted.
40    pub valid_until: chrono::DateTime<chrono::Utc>,
41}
42
43/// Authentication configuration.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct AuthConfig {
47    /// Required for HS256.
48    pub jwt_secret: Option<String>,
49
50    #[serde(default)]
51    pub jwt_algorithm: JwtAlgorithm,
52
53    /// If set, tokens with a different issuer are rejected.
54    pub jwt_issuer: Option<String>,
55
56    /// If set, tokens with a different audience are rejected.
57    pub jwt_audience: Option<String>,
58
59    /// Access token lifetime (e.g., "15m", "1h"). Used by `ctx.issue_token_pair()`.
60    pub access_token_ttl: Option<DurationStr>,
61
62    /// Refresh token lifetime (e.g., "7d", "30d"). Used by `ctx.issue_token_pair()`.
63    pub refresh_token_ttl: Option<DurationStr>,
64
65    /// Required for RS256; keys are fetched and cached automatically.
66    pub jwks_url: Option<String>,
67
68    /// JWKS cache TTL duration (e.g. "1h", "30m").
69    #[serde(default = "default_jwks_cache_ttl")]
70    pub jwks_cache_ttl: DurationStr,
71
72    /// Session TTL duration (e.g. "7d", "24h"). Used for WebSocket sessions.
73    #[serde(default = "default_session_ttl")]
74    pub session_ttl: DurationStr,
75
76    /// Clock-skew tolerance for `exp` / `nbf` validation (e.g. "60s", "5m").
77    /// Sites with NTP-synchronized clocks can drop this to "5s"; older deployments
78    /// or clients with drifting clocks may need higher. Defaults to "60s".
79    #[serde(default = "default_jwt_leeway")]
80    pub jwt_leeway: DurationStr,
81
82    /// When `true` (default), `jwt_audience` must be set when auth is enabled.
83    /// Set to `false` only during migration.
84    #[serde(default = "default_audience_required")]
85    pub audience_required: bool,
86
87    /// JWT spec claims that must be present in every token.
88    /// Defaults to `["exp", "sub"]`. Add `"aud"` here for claim-level
89    /// enforcement in addition to the `jwt_audience` equality check.
90    #[serde(default = "default_required_claims")]
91    pub required_claims: Vec<String>,
92
93    /// Used for OAuth consent flow cookies. Defaults to the access token TTL.
94    pub session_cookie_ttl: Option<DurationStr>,
95
96    /// Reject RS256 tokens that arrive without a `kid` header.
97    /// Default: `true`. On shared issuers (Firebase, Clerk multi-app) a kidless
98    /// token would validate against an arbitrary cached key, accepting tokens
99    /// signed by any app the issuer exposes. Set to `false` only for providers
100    /// that genuinely omit `kid` in token headers (rare).
101    #[serde(default = "default_true")]
102    pub jwks_require_kid: bool,
103
104    /// Old HMAC secrets still accepted for validation (never for signing).
105    /// Each entry carries a mandatory `valid_until` timestamp; expired entries
106    /// are silently dropped at middleware construction.
107    #[serde(default)]
108    pub legacy_secrets: Vec<LegacySecret>,
109}
110
111impl Default for AuthConfig {
112    fn default() -> Self {
113        Self {
114            jwt_secret: None,
115            jwt_algorithm: JwtAlgorithm::default(),
116            jwt_issuer: None,
117            jwt_audience: None,
118            access_token_ttl: None,
119            refresh_token_ttl: None,
120            jwks_url: None,
121            jwks_cache_ttl: default_jwks_cache_ttl(),
122            session_ttl: default_session_ttl(),
123            jwt_leeway: default_jwt_leeway(),
124            audience_required: default_audience_required(),
125            required_claims: default_required_claims(),
126            session_cookie_ttl: None,
127            jwks_require_kid: default_true(),
128            legacy_secrets: Vec::new(),
129        }
130    }
131}
132
133impl AuthConfig {
134    /// Resolved access token TTL in seconds. Minimum 1 to prevent zero-lifetime tokens.
135    pub fn access_token_ttl_secs(&self) -> i64 {
136        self.access_token_ttl
137            .map(|d| (d.as_secs() as i64).max(1))
138            .unwrap_or(3600)
139    }
140
141    /// Resolved refresh token TTL in days. Default 30; sub-day values floor to 1.
142    pub fn refresh_token_ttl_days(&self) -> i64 {
143        self.refresh_token_ttl
144            .map(|d| {
145                let days = (d.as_secs() / 86400) as i64;
146                if days == 0 { 1 } else { days }
147            })
148            .unwrap_or(30)
149    }
150
151    /// Resolved session cookie TTL in seconds. Falls back to `access_token_ttl_secs()`.
152    pub fn session_cookie_ttl_secs(&self) -> i64 {
153        self.session_cookie_ttl
154            .map(|d| (d.as_secs() as i64).max(1))
155            .unwrap_or_else(|| self.access_token_ttl_secs())
156    }
157
158    /// Returns `true` when any credential or claim validation field is set.
159    pub fn is_configured(&self) -> bool {
160        self.jwt_secret.is_some()
161            || self.jwks_url.is_some()
162            || self.jwt_issuer.is_some()
163            || self.jwt_audience.is_some()
164    }
165
166    /// Validate that the config is complete for the chosen algorithm.
167    pub fn validate(&self) -> Result<()> {
168        if !self.is_configured() {
169            return Ok(());
170        }
171
172        match self.jwt_algorithm {
173            JwtAlgorithm::HS256 => {
174                if self.jwt_secret.is_none() {
175                    return Err(ForgeError::config(
176                        "auth.jwt_secret is required for HMAC algorithms (HS256). \
177                         Set auth.jwt_secret to a secure random string, \
178                         or switch to RS256 and provide auth.jwks_url for external identity providers.",
179                    ));
180                }
181                if let Some(secret) = &self.jwt_secret
182                    && secret.len() < 32
183                {
184                    return Err(ForgeError::config(format!(
185                        "auth.jwt_secret is {} bytes but must be at least 32 bytes for HMAC \
186                         to be collision-resistant. Generate one with: \
187                         openssl rand -base64 32",
188                        secret.len()
189                    )));
190                }
191            }
192            JwtAlgorithm::RS256 => {
193                if self.jwks_url.is_none() {
194                    return Err(ForgeError::config(
195                        "auth.jwks_url is required for RSA algorithms (RS256). \
196                         Set auth.jwks_url to your identity provider's JWKS endpoint, \
197                         or switch to HS256 and provide auth.jwt_secret for symmetric signing.",
198                    ));
199                }
200            }
201        }
202
203        if self.audience_required && self.jwt_audience.is_none() {
204            return Err(ForgeError::config(
205                "auth.jwt_audience is required when auth is enabled. \
206                 Set auth.jwt_audience to your application's audience identifier (e.g. \"https://api.example.com\"), \
207                 or set auth.audience_required = false to opt out during migration.",
208            ));
209        }
210
211        Ok(())
212    }
213
214    /// Check if this config uses HMAC (symmetric) algorithms.
215    pub fn is_hmac(&self) -> bool {
216        matches!(self.jwt_algorithm, JwtAlgorithm::HS256)
217    }
218
219    /// Check if this config uses RSA (asymmetric) algorithms.
220    pub fn is_rsa(&self) -> bool {
221        matches!(self.jwt_algorithm, JwtAlgorithm::RS256)
222    }
223}
224
225fn default_jwks_cache_ttl() -> DurationStr {
226    DurationStr::new(Duration::from_secs(3600))
227}
228
229fn default_session_ttl() -> DurationStr {
230    DurationStr::new(Duration::from_secs(604800))
231}
232
233fn default_jwt_leeway() -> DurationStr {
234    DurationStr::new(Duration::from_secs(60))
235}
236
237fn default_audience_required() -> bool {
238    true
239}
240
241fn default_required_claims() -> Vec<String> {
242    vec!["exp".into(), "sub".into()]
243}
244
245fn default_true() -> bool {
246    true
247}
248
249#[cfg(test)]
250#[allow(clippy::unwrap_used, clippy::panic)]
251mod tests {
252    use super::*;
253
254    fn strong_secret() -> String {
255        "x".repeat(32)
256    }
257
258    #[test]
259    fn default_algorithm_is_hs256() {
260        assert_eq!(JwtAlgorithm::default(), JwtAlgorithm::HS256);
261    }
262
263    #[test]
264    fn default_required_claims_are_exp_and_sub() {
265        let claims = default_required_claims();
266        assert!(claims.contains(&"exp".to_string()));
267        assert!(claims.contains(&"sub".to_string()));
268    }
269
270    #[test]
271    fn default_audience_is_required() {
272        assert!(default_audience_required());
273    }
274
275    #[test]
276    fn is_configured_false_when_completely_empty() {
277        let cfg = AuthConfig::default();
278        assert!(!cfg.is_configured());
279    }
280
281    #[test]
282    fn is_configured_true_when_jwt_secret_set() {
283        let cfg = AuthConfig {
284            jwt_secret: Some("anything".into()),
285            ..AuthConfig::default()
286        };
287        assert!(cfg.is_configured());
288    }
289
290    #[test]
291    fn is_configured_true_when_only_jwt_issuer_set() {
292        let cfg = AuthConfig {
293            jwt_issuer: Some("https://issuer".into()),
294            ..AuthConfig::default()
295        };
296        assert!(cfg.is_configured());
297    }
298
299    #[test]
300    fn is_configured_true_when_only_jwks_url_set() {
301        let cfg = AuthConfig {
302            jwks_url: Some("https://jwks".into()),
303            ..AuthConfig::default()
304        };
305        assert!(cfg.is_configured());
306    }
307
308    #[test]
309    fn validate_passes_when_auth_disabled() {
310        let cfg = AuthConfig::default();
311        cfg.validate().unwrap();
312    }
313
314    #[test]
315    fn validate_hs256_rejects_missing_secret() {
316        let cfg = AuthConfig {
317            jwt_algorithm: JwtAlgorithm::HS256,
318            jwt_issuer: Some("https://issuer".into()),
319            jwt_audience: Some("api".into()),
320            ..AuthConfig::default()
321        };
322        let err = cfg.validate().unwrap_err();
323        let ForgeError::Config { context: msg, .. } = err else {
324            panic!("expected Config error");
325        };
326        assert!(msg.contains("jwt_secret"));
327    }
328
329    #[test]
330    fn validate_hs256_rejects_short_secret() {
331        let cfg = AuthConfig {
332            jwt_secret: Some("too-short".into()),
333            jwt_audience: Some("api".into()),
334            ..AuthConfig::default()
335        };
336        let err = cfg.validate().unwrap_err();
337        let ForgeError::Config { context: msg, .. } = err else {
338            panic!("expected Config error");
339        };
340        assert!(msg.contains("32 bytes"), "{msg}");
341    }
342
343    #[test]
344    fn validate_hs256_accepts_exactly_32_byte_secret() {
345        let cfg = AuthConfig {
346            jwt_secret: Some(strong_secret()),
347            jwt_audience: Some("api".into()),
348            ..AuthConfig::default()
349        };
350        cfg.validate().unwrap();
351    }
352
353    #[test]
354    fn validate_rs256_rejects_missing_jwks_url() {
355        let cfg = AuthConfig {
356            jwt_algorithm: JwtAlgorithm::RS256,
357            jwt_issuer: Some("https://issuer".into()),
358            jwt_audience: Some("api".into()),
359            ..AuthConfig::default()
360        };
361        let err = cfg.validate().unwrap_err();
362        let ForgeError::Config { context: msg, .. } = err else {
363            panic!("expected Config error");
364        };
365        assert!(msg.contains("jwks_url"));
366    }
367
368    #[test]
369    fn validate_rs256_does_not_require_jwt_secret() {
370        let cfg = AuthConfig {
371            jwt_algorithm: JwtAlgorithm::RS256,
372            jwks_url: Some("https://jwks".into()),
373            jwt_audience: Some("api".into()),
374            ..AuthConfig::default()
375        };
376        cfg.validate().unwrap();
377    }
378
379    #[test]
380    fn validate_audience_required_rejects_missing_audience() {
381        let cfg = AuthConfig {
382            jwt_secret: Some(strong_secret()),
383            audience_required: true,
384            jwt_audience: None,
385            ..AuthConfig::default()
386        };
387        let err = cfg.validate().unwrap_err();
388        let ForgeError::Config { context: msg, .. } = err else {
389            panic!("expected Config error");
390        };
391        assert!(msg.contains("jwt_audience"));
392    }
393
394    #[test]
395    fn validate_audience_opt_out_passes_without_audience() {
396        let cfg = AuthConfig {
397            jwt_secret: Some(strong_secret()),
398            audience_required: false,
399            jwt_audience: None,
400            ..AuthConfig::default()
401        };
402        cfg.validate().unwrap();
403    }
404
405    #[test]
406    fn access_token_ttl_default_is_one_hour() {
407        let cfg = AuthConfig::default();
408        assert_eq!(cfg.access_token_ttl_secs(), 3600);
409    }
410
411    #[test]
412    fn access_token_ttl_clamps_to_at_least_one_second() {
413        let cfg = AuthConfig {
414            access_token_ttl: Some(DurationStr::new(Duration::from_secs(0))),
415            ..AuthConfig::default()
416        };
417        assert_eq!(cfg.access_token_ttl_secs(), 1);
418    }
419
420    #[test]
421    fn refresh_token_ttl_default_is_30_days() {
422        let cfg = AuthConfig::default();
423        assert_eq!(cfg.refresh_token_ttl_days(), 30);
424    }
425
426    #[test]
427    fn refresh_token_ttl_sub_day_rounds_to_one() {
428        let cfg = AuthConfig {
429            refresh_token_ttl: Some(DurationStr::new(Duration::from_secs(60))),
430            ..AuthConfig::default()
431        };
432        assert_eq!(cfg.refresh_token_ttl_days(), 1);
433    }
434
435    #[test]
436    fn refresh_token_ttl_seven_days_passes_through() {
437        let cfg = AuthConfig {
438            refresh_token_ttl: Some(DurationStr::new(Duration::from_secs(7 * 86400))),
439            ..AuthConfig::default()
440        };
441        assert_eq!(cfg.refresh_token_ttl_days(), 7);
442    }
443
444    #[test]
445    fn session_cookie_ttl_falls_back_to_access_token_ttl() {
446        let cfg = AuthConfig {
447            access_token_ttl: Some(DurationStr::new(Duration::from_secs(900))),
448            session_cookie_ttl: None,
449            ..AuthConfig::default()
450        };
451        assert_eq!(cfg.session_cookie_ttl_secs(), 900);
452    }
453
454    #[test]
455    fn session_cookie_ttl_overrides_access_token_ttl_when_set() {
456        let cfg = AuthConfig {
457            access_token_ttl: Some(DurationStr::new(Duration::from_secs(900))),
458            session_cookie_ttl: Some(DurationStr::new(Duration::from_secs(1200))),
459            ..AuthConfig::default()
460        };
461        assert_eq!(cfg.session_cookie_ttl_secs(), 1200);
462    }
463
464    #[test]
465    fn is_hmac_and_is_rsa_are_mutually_exclusive() {
466        let hs = AuthConfig {
467            jwt_algorithm: JwtAlgorithm::HS256,
468            ..AuthConfig::default()
469        };
470        assert!(hs.is_hmac());
471        assert!(!hs.is_rsa());
472
473        let rs = AuthConfig {
474            jwt_algorithm: JwtAlgorithm::RS256,
475            ..AuthConfig::default()
476        };
477        assert!(rs.is_rsa());
478        assert!(!rs.is_hmac());
479    }
480
481    #[test]
482    fn jwt_algorithm_deserializes_uppercase_strings() {
483        let hs: JwtAlgorithm = serde_json::from_str(r#""HS256""#).unwrap();
484        let rs: JwtAlgorithm = serde_json::from_str(r#""RS256""#).unwrap();
485        assert_eq!(hs, JwtAlgorithm::HS256);
486        assert_eq!(rs, JwtAlgorithm::RS256);
487        assert!(serde_json::from_str::<JwtAlgorithm>(r#""ES256""#).is_err());
488    }
489
490    #[test]
491    fn jwks_require_kid_defaults_to_true() {
492        let cfg = AuthConfig::default();
493        assert!(cfg.jwks_require_kid);
494    }
495
496    #[test]
497    fn jwks_require_kid_deserializes_from_toml() {
498        let toml = r#"jwks_require_kid = false"#;
499        let cfg: AuthConfig = toml::from_str(toml).unwrap();
500        assert!(!cfg.jwks_require_kid);
501    }
502}