1use std::time::Duration;
4
5use crate::error::{ForgeError, Result};
6use serde::{Deserialize, Serialize};
7
8use super::types::DurationStr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
15#[serde(rename_all = "UPPERCASE")]
16#[non_exhaustive]
17pub enum JwtAlgorithm {
18 #[default]
20 HS256,
21 RS256,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct LegacySecret {
35 pub secret: String,
39 pub valid_until: chrono::DateTime<chrono::Utc>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct AuthConfig {
47 pub jwt_secret: Option<String>,
49
50 #[serde(default)]
51 pub jwt_algorithm: JwtAlgorithm,
52
53 pub jwt_issuer: Option<String>,
55
56 pub jwt_audience: Option<String>,
58
59 pub access_token_ttl: Option<DurationStr>,
61
62 pub refresh_token_ttl: Option<DurationStr>,
64
65 pub jwks_url: Option<String>,
67
68 #[serde(default = "default_jwks_cache_ttl")]
70 pub jwks_cache_ttl: DurationStr,
71
72 #[serde(default = "default_session_ttl")]
74 pub session_ttl: DurationStr,
75
76 #[serde(default = "default_jwt_leeway")]
80 pub jwt_leeway: DurationStr,
81
82 #[serde(default = "default_audience_required")]
85 pub audience_required: bool,
86
87 #[serde(default = "default_required_claims")]
91 pub required_claims: Vec<String>,
92
93 pub session_cookie_ttl: Option<DurationStr>,
95
96 #[serde(default = "default_true")]
102 pub jwks_require_kid: bool,
103
104 #[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 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 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 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 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 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 pub fn is_hmac(&self) -> bool {
216 matches!(self.jwt_algorithm, JwtAlgorithm::HS256)
217 }
218
219 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}