1use std::sync::Arc;
41
42pub mod config;
43pub mod embedded;
44pub mod engine_api;
45pub mod init;
46#[cfg(all(feature = "backend-postgres", feature = "backend-sqlite"))]
47pub mod migrate;
48pub mod server;
49pub mod state;
50
51pub use assay_auth as auth;
52pub use assay_dashboard as dashboard;
53pub use assay_domain as core;
54pub use assay_workflow as workflow;
55
56pub use config::{
57 AuthConfig, AuthOidcProviderConfig, AuthPasskeyConfig, AuthRecoveryConfig, AuthSessionConfig,
58 AuthSmtpConfig, BackendConfig, DashboardConfig, EngineConfig, ServerConfig,
59};
60pub use state::{AdminApiKeys, EngineState};
61
62pub async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
69 let bind_addr = cfg.server.bind_addr.clone();
70 let engine = embedded::build(cfg).await?;
71 server::bind_and_serve(&bind_addr, engine.router).await
72}
73
74#[cfg(feature = "vault")]
78fn vault_seal_key() -> anyhow::Result<Option<assay_vault::crypto::env_seal::SealKey>> {
79 assay_vault::crypto::env_seal::SealKey::from_env()
80 .map_err(|e| anyhow::anyhow!("read {}: {e}", assay_vault::crypto::env_seal::ENV_VAR))
81}
82
83#[cfg(feature = "vault")]
84fn seal_method(
85 seal: &Option<assay_vault::crypto::env_seal::SealKey>,
86) -> assay_vault::crypto::SealingMethod {
87 match seal {
88 Some(_) => assay_vault::crypto::SealingMethod::EnvKey,
89 None => assay_vault::crypto::SealingMethod::Plaintext,
90 }
91}
92
93#[cfg(all(feature = "vault", feature = "backend-postgres"))]
98async fn build_vault_ctx_pg(
99 modules: &[String],
100 pool: &sqlx::PgPool,
101) -> anyhow::Result<Option<assay_vault::VaultCtx>> {
102 if !modules.iter().any(|m| m == "vault") {
103 return Ok(None);
104 }
105 let seal = vault_seal_key()?;
106 let kek = assay_vault::crypto::kek_store::load_or_init_postgres_sealed(pool, seal.as_ref())
107 .await
108 .map_err(|e| anyhow::anyhow!("vault KEK bootstrap (pg): {e}"))?;
109 let mut ctx = assay_vault::VaultCtx::new()
113 .with_kek_method(kek, seal_method(&seal))
114 .with_kv(assay_vault::store::postgres::PgKvStore::new(pool.clone()))
115 .with_transit(assay_vault::store::postgres::PgTransitStore::new(
116 pool.clone(),
117 ));
118 #[cfg(feature = "vault-sealing-shamir")]
119 {
120 ctx = ctx.with_seal_store(assay_vault::store::postgres::PgSealStore::new(pool.clone()));
121 }
122 #[cfg(feature = "vault-collections")]
123 {
124 ctx = ctx
125 .with_personal_vaults(assay_vault::store::postgres::PgPersonalVaultStore::new(
126 pool.clone(),
127 ))
128 .with_collections(assay_vault::store::postgres::PgCollectionStore::new(
129 pool.clone(),
130 ))
131 .with_items(assay_vault::store::postgres::PgItemStore::new(pool.clone()))
132 .with_folders(assay_vault::store::postgres::PgFolderStore::new(
133 pool.clone(),
134 ));
135 }
136 #[cfg(feature = "vault-share")]
137 {
138 let kp = assay_vault::store::postgres::load_or_init_biscuit_root_postgres(pool)
139 .await
140 .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (pg): {e}"))?;
141 let revs = std::sync::Arc::new(assay_vault::store::postgres::PgRevocationStore::new(
142 pool.clone(),
143 ));
144 let svc = assay_vault::share::ShareService::new(kp, revs);
145 ctx = ctx.with_share(svc);
146 }
147 #[cfg(feature = "vault-dynamic-postgres")]
148 {
149 let leases = std::sync::Arc::new(assay_vault::store::postgres::PgLeaseStore::new(
150 pool.clone(),
151 ));
152 let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
153 let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
158 ctx = ctx.with_dynamic(svc);
159 }
160 Ok(Some(ctx))
161}
162
163#[cfg(all(feature = "vault", feature = "backend-sqlite"))]
165async fn build_vault_ctx_sqlite(
166 modules: &[String],
167 pool: &sqlx::SqlitePool,
168) -> anyhow::Result<Option<assay_vault::VaultCtx>> {
169 if !modules.iter().any(|m| m == "vault") {
170 return Ok(None);
171 }
172 let seal = vault_seal_key()?;
173 let kek = assay_vault::crypto::kek_store::load_or_init_sqlite_sealed(pool, seal.as_ref())
174 .await
175 .map_err(|e| anyhow::anyhow!("vault KEK bootstrap (sqlite): {e}"))?;
176 let mut ctx = assay_vault::VaultCtx::new()
177 .with_kek_method(kek, seal_method(&seal))
178 .with_kv(assay_vault::store::sqlite::SqliteKvStore::new(pool.clone()))
179 .with_transit(assay_vault::store::sqlite::SqliteTransitStore::new(
180 pool.clone(),
181 ));
182 #[cfg(feature = "vault-sealing-shamir")]
183 {
184 ctx = ctx.with_seal_store(assay_vault::store::sqlite::SqliteSealStore::new(
185 pool.clone(),
186 ));
187 }
188 #[cfg(feature = "vault-collections")]
189 {
190 ctx = ctx
191 .with_personal_vaults(assay_vault::store::sqlite::SqlitePersonalVaultStore::new(
192 pool.clone(),
193 ))
194 .with_collections(assay_vault::store::sqlite::SqliteCollectionStore::new(
195 pool.clone(),
196 ))
197 .with_items(assay_vault::store::sqlite::SqliteItemStore::new(
198 pool.clone(),
199 ))
200 .with_folders(assay_vault::store::sqlite::SqliteFolderStore::new(
201 pool.clone(),
202 ));
203 }
204 #[cfg(feature = "vault-share")]
205 {
206 let kp = assay_vault::store::sqlite::load_or_init_biscuit_root_sqlite(pool)
207 .await
208 .map_err(|e| anyhow::anyhow!("vault biscuit root bootstrap (sqlite): {e}"))?;
209 let revs = std::sync::Arc::new(assay_vault::store::sqlite::SqliteRevocationStore::new(
210 pool.clone(),
211 ));
212 let svc = assay_vault::share::ShareService::new(kp, revs);
213 ctx = ctx.with_share(svc);
214 }
215 #[cfg(feature = "vault-dynamic-postgres")]
216 {
217 let leases = std::sync::Arc::new(assay_vault::store::sqlite::SqliteLeaseStore::new(
218 pool.clone(),
219 ));
220 let registry = assay_vault::dynamic::DynamicCredsRegistry::new();
221 let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
222 ctx = ctx.with_dynamic(svc);
223 }
224 Ok(Some(ctx))
225}
226
227#[cfg(feature = "backend-postgres")]
228async fn build_auth_ctx_pg(
229 cfg: &EngineConfig,
230 pool: &sqlx::PgPool,
231) -> anyhow::Result<assay_auth::AuthCtx> {
232 use assay_auth::store::{PostgresSessionStore, PostgresUserStore};
233 let users = PostgresUserStore::new(pool.clone()).into_dyn();
234 let sessions = PostgresSessionStore::new(pool.clone()).into_dyn();
235 let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
236
237 #[cfg(feature = "auth-recovery")]
238 if let Some(options) = recovery_options(cfg)? {
239 let store = Arc::new(assay_auth::recovery::PostgresRecoveryStore::new(
240 pool.clone(),
241 ));
242 let mailer = Arc::new(assay_auth::recovery::SmtpRecoveryMailer::new(options.smtp)?);
243 ctx = ctx.with_recovery(assay_auth::recovery::PasswordRecovery::new(
244 store,
245 mailer,
246 options.recovery_url,
247 options.token_ttl,
248 options.request_cooldown,
249 ));
250 }
251
252 let biscuit = assay_auth::biscuit::load_or_init_postgres(pool)
253 .await
254 .map_err(|e| anyhow::anyhow!("biscuit root key (pg): {e}"))?;
255 ctx = ctx.with_biscuit(biscuit);
256
257 #[cfg(feature = "auth-jwt")]
258 {
259 let issuer = effective_issuer(cfg);
260 let audience = if cfg.auth.audience.is_empty() {
261 vec![issuer.clone()]
262 } else {
263 cfg.auth.audience.clone()
264 };
265 let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
266 if let Err(e) = jwt.load_from_postgres(pool).await {
267 tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
268 jwt.rotate_postgres(pool)
269 .await
270 .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
271 }
272 if jwt.active_kid().is_none() {
273 jwt.rotate_postgres(pool)
274 .await
275 .map_err(|e| anyhow::anyhow!("seed JWKS (pg): {e}"))?;
276 }
277 ctx = ctx.with_jwt(jwt);
278
279 ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
280 }
281
282 #[cfg(feature = "auth-oidc")]
283 {
284 ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
285 }
286
287 #[cfg(feature = "auth-passkey")]
288 if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
289 ctx = ctx.with_passkeys(passkey_mgr);
290 }
291
292 #[cfg(feature = "auth-zanzibar")]
293 {
294 let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> = Arc::new(
295 assay_auth::zanzibar::PostgresZanzibarStore::new(pool.clone()),
296 );
297 ctx = ctx.with_zanzibar(zanzibar);
298 }
299
300 #[cfg(feature = "auth-oidc-provider")]
301 if cfg.auth.oidc_provider.enabled {
302 let issuer = oidc_issuer(cfg);
303 let public_url = oidc_public_url(cfg)?;
304 let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
305 issuer,
306 public_url,
307 assay_auth::oidc_provider::PostgresOidcClientStore::new(pool.clone()).into_dyn(),
308 assay_auth::oidc_provider::PostgresOidcUpstreamStore::new(pool.clone()).into_dyn(),
309 assay_auth::oidc_provider::PostgresOidcCodeStore::new(pool.clone()).into_dyn(),
310 assay_auth::oidc_provider::PostgresOidcRefreshStore::new(pool.clone()).into_dyn(),
311 assay_auth::oidc_provider::PostgresOidcSessionStore::new(pool.clone()).into_dyn(),
312 assay_auth::oidc_provider::PostgresOidcConsentStore::new(pool.clone()).into_dyn(),
313 assay_auth::oidc_provider::PostgresOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
314 )
315 .with_jwks_source(assay_auth::oidc_provider::JwksSource::Postgres(
316 pool.clone(),
317 ))
318 .with_auto_provision(cfg.auth.oidc_provider.auto_provision);
319 ctx = ctx.with_oidc_provider(provider);
320
321 if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
322 match provider.upstream.list().await {
323 Ok(rows) => {
324 for row in rows {
325 assay_auth::oidc_provider::sync_upstream_to_registry(
326 registry,
327 &row,
328 &provider.public_url,
329 )
330 .await;
331 }
332 }
333 Err(e) => {
334 tracing::warn!("failed to list upstream providers at boot: {e}");
335 }
336 }
337 }
338 }
339
340 Ok(ctx)
341}
342
343#[cfg(feature = "backend-sqlite")]
344async fn build_auth_ctx_sqlite(
345 cfg: &EngineConfig,
346 pool: &sqlx::SqlitePool,
347) -> anyhow::Result<assay_auth::AuthCtx> {
348 use assay_auth::store::{SqliteSessionStore, SqliteUserStore};
349 let users = SqliteUserStore::new(pool.clone()).into_dyn();
350 let sessions = SqliteSessionStore::new(pool.clone()).into_dyn();
351 let mut ctx = assay_auth::AuthCtx::new(users.clone(), sessions);
352
353 #[cfg(feature = "auth-recovery")]
354 if let Some(options) = recovery_options(cfg)? {
355 let store = Arc::new(assay_auth::recovery::SqliteRecoveryStore::new(pool.clone()));
356 let mailer = Arc::new(assay_auth::recovery::SmtpRecoveryMailer::new(options.smtp)?);
357 ctx = ctx.with_recovery(assay_auth::recovery::PasswordRecovery::new(
358 store,
359 mailer,
360 options.recovery_url,
361 options.token_ttl,
362 options.request_cooldown,
363 ));
364 }
365
366 let biscuit = assay_auth::biscuit::load_or_init_sqlite(pool)
367 .await
368 .map_err(|e| anyhow::anyhow!("biscuit root key (sqlite): {e}"))?;
369 ctx = ctx.with_biscuit(biscuit);
370
371 #[cfg(feature = "auth-jwt")]
372 {
373 let issuer = effective_issuer(cfg);
374 let audience = if cfg.auth.audience.is_empty() {
375 vec![issuer.clone()]
376 } else {
377 cfg.auth.audience.clone()
378 };
379 let jwt = assay_auth::jwt::JwtConfig::new(issuer.clone(), audience);
380 if let Err(e) = jwt.load_from_sqlite(pool).await {
381 tracing::warn!(?e, "no JWKS rows yet; rotating to seed first key");
382 jwt.rotate_sqlite(pool)
383 .await
384 .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
385 }
386 if jwt.active_kid().is_none() {
387 jwt.rotate_sqlite(pool)
388 .await
389 .map_err(|e| anyhow::anyhow!("seed JWKS (sqlite): {e}"))?;
390 }
391 ctx = ctx.with_jwt(jwt);
392
393 ctx = ctx.with_external_issuers(discover_external_issuers(cfg).await?);
394 }
395
396 #[cfg(feature = "auth-oidc")]
397 {
398 ctx = ctx.with_oidc(assay_auth::oidc::OidcRegistry::new());
399 }
400
401 #[cfg(feature = "auth-passkey")]
402 if let Some(passkey_mgr) = build_passkey_manager(cfg, users.clone()) {
403 ctx = ctx.with_passkeys(passkey_mgr);
404 }
405
406 #[cfg(feature = "auth-zanzibar")]
407 {
408 let zanzibar: Arc<dyn assay_auth::zanzibar::ZanzibarStore> =
409 Arc::new(assay_auth::zanzibar::SqliteZanzibarStore::new(pool.clone()));
410 ctx = ctx.with_zanzibar(zanzibar);
411 }
412
413 #[cfg(feature = "auth-oidc-provider")]
414 if cfg.auth.oidc_provider.enabled {
415 let issuer = oidc_issuer(cfg);
416 let public_url = oidc_public_url(cfg)?;
417 let provider = assay_auth::oidc_provider::OidcProviderConfig::new(
418 issuer,
419 public_url,
420 assay_auth::oidc_provider::SqliteOidcClientStore::new(pool.clone()).into_dyn(),
421 assay_auth::oidc_provider::SqliteOidcUpstreamStore::new(pool.clone()).into_dyn(),
422 assay_auth::oidc_provider::SqliteOidcCodeStore::new(pool.clone()).into_dyn(),
423 assay_auth::oidc_provider::SqliteOidcRefreshStore::new(pool.clone()).into_dyn(),
424 assay_auth::oidc_provider::SqliteOidcSessionStore::new(pool.clone()).into_dyn(),
425 assay_auth::oidc_provider::SqliteOidcConsentStore::new(pool.clone()).into_dyn(),
426 assay_auth::oidc_provider::SqliteOidcUpstreamStateStore::new(pool.clone()).into_dyn(),
427 )
428 .with_jwks_source(assay_auth::oidc_provider::JwksSource::Sqlite(pool.clone()))
429 .with_auto_provision(cfg.auth.oidc_provider.auto_provision);
430 ctx = ctx.with_oidc_provider(provider);
431
432 if let (Some(registry), Some(provider)) = (&ctx.oidc, &ctx.oidc_provider) {
433 match provider.upstream.list().await {
434 Ok(rows) => {
435 for row in rows {
436 assay_auth::oidc_provider::sync_upstream_to_registry(
437 registry,
438 &row,
439 &provider.public_url,
440 )
441 .await;
442 }
443 }
444 Err(e) => {
445 tracing::warn!("failed to list upstream providers at boot: {e}");
446 }
447 }
448 }
449 }
450
451 Ok(ctx)
452}
453
454#[cfg(feature = "auth-jwt")]
463async fn discover_external_issuers(
464 cfg: &EngineConfig,
465) -> anyhow::Result<Vec<assay_auth::external_jwt::ExternalJwtIssuer>> {
466 let entries = cfg.auth.external_issuers();
467 let mut out = Vec::with_capacity(entries.len());
468 for entry in entries {
469 let verifier = assay_auth::external_jwt::ExternalJwtIssuer::discover(
470 entry.issuer_url.clone(),
471 entry.audience.clone(),
472 entry.jwks_refresh_secs,
473 )
474 .await
475 .map_err(|e| anyhow::anyhow!("discover external issuer `{}`: {e}", entry.issuer_url))?;
476 tracing::info!(
477 target: "assay-engine",
478 issuer = %entry.issuer_url,
479 audience = ?entry.audience,
480 "trusted external OIDC issuer for JWT pass-through"
481 );
482 out.push(verifier);
483 }
484 Ok(out)
485}
486
487fn effective_issuer(cfg: &EngineConfig) -> String {
491 if let Some(issuer) = &cfg.auth.issuer {
492 return issuer.clone();
493 }
494 let base = auth_public_url(cfg).trim_end_matches('/');
495 format!("{base}/auth")
496}
497
498fn auth_public_url(cfg: &EngineConfig) -> &str {
499 cfg.auth
500 .public_url
501 .as_deref()
502 .unwrap_or(&cfg.server.public_url)
503}
504
505fn oidc_issuer(cfg: &EngineConfig) -> String {
509 cfg.auth
510 .oidc_provider
511 .issuer_override
512 .clone()
513 .unwrap_or_else(|| effective_issuer(cfg))
514}
515
516fn parse_auth_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
520 let public_url = auth_public_url(cfg);
521 url::Url::parse(public_url).map_err(|e| anyhow::anyhow!("auth.public_url {public_url:?}: {e}"))
522}
523
524struct RecoveryOptions {
525 smtp: assay_auth::recovery::SmtpRecoverySettings,
526 recovery_url: url::Url,
527 token_ttl: std::time::Duration,
528 request_cooldown: std::time::Duration,
529}
530
531impl std::fmt::Debug for RecoveryOptions {
532 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533 formatter
534 .debug_struct("RecoveryOptions")
535 .field("recovery_url", &self.recovery_url)
536 .field("token_ttl", &self.token_ttl)
537 .field("request_cooldown", &self.request_cooldown)
538 .finish_non_exhaustive()
539 }
540}
541
542fn recovery_options(cfg: &EngineConfig) -> anyhow::Result<Option<RecoveryOptions>> {
543 if !cfg.auth.recovery.enabled {
544 return Ok(None);
545 }
546 let smtp = cfg.auth.recovery.smtp.as_ref().ok_or_else(|| {
547 anyhow::anyhow!("auth.recovery.smtp is required when recovery is enabled")
548 })?;
549 let recovery_url = parse_auth_public_url(cfg)?
550 .join("auth/recovery")
551 .map_err(|error| anyhow::anyhow!("build auth recovery URL: {error}"))?;
552 Ok(Some(RecoveryOptions {
553 smtp: assay_auth::recovery::SmtpRecoverySettings {
554 host: smtp.host.clone(),
555 port: smtp.port,
556 username: smtp.username.clone(),
557 password: smtp.password.clone(),
558 from: smtp.from.clone(),
559 starttls: smtp.starttls,
560 },
561 recovery_url,
562 token_ttl: std::time::Duration::from_secs(cfg.auth.recovery.token_ttl_seconds),
563 request_cooldown: std::time::Duration::from_secs(
564 cfg.auth.recovery.request_cooldown_seconds,
565 ),
566 }))
567}
568
569fn oidc_public_url(cfg: &EngineConfig) -> anyhow::Result<url::Url> {
575 let issuer = oidc_issuer(cfg);
576 url::Url::parse(&issuer).map_err(|e| anyhow::anyhow!("oidc issuer {issuer:?}: {e}"))
577}
578
579#[cfg(feature = "auth-passkey")]
583fn build_passkey_manager(
584 cfg: &EngineConfig,
585 users: Arc<dyn assay_auth::store::UserStore>,
586) -> Option<assay_auth::passkey::PasskeyManager> {
587 let url = match parse_auth_public_url(cfg) {
588 Ok(u) => u,
589 Err(e) => {
590 tracing::warn!(?e, "passkeys disabled — bad public_url");
591 return None;
592 }
593 };
594 let host = match url.host_str() {
595 Some(h) => h.to_string(),
596 None => {
597 tracing::warn!("passkeys disabled — public_url has no host");
598 return None;
599 }
600 };
601 let pk_cfg = assay_auth::passkey::PasskeyConfig {
602 rp_id: cfg.auth.passkey.rp_id.clone().unwrap_or(host),
603 rp_name: cfg
604 .auth
605 .passkey
606 .rp_name
607 .clone()
608 .unwrap_or_else(|| "Assay".to_string()),
609 origin: url,
610 };
611 match assay_auth::passkey::PasskeyManager::new(pk_cfg, users) {
612 Ok(m) => Some(m),
613 Err(e) => {
614 tracing::warn!(?e, "passkeys disabled — manager construction failed");
615 None
616 }
617 }
618}
619
620#[cfg(test)]
621mod public_url_tests {
622 use super::*;
623
624 fn config(server_public_url: &str, auth_public_url: Option<&str>) -> EngineConfig {
625 let auth_public_url = auth_public_url
626 .map(|url| format!("public_url = \"{url}\""))
627 .unwrap_or_default();
628 toml::from_str(&format!(
629 r#"
630[server]
631bind_addr = "127.0.0.1:3000"
632public_url = "{server_public_url}"
633
634[backend]
635type = "sqlite"
636data_dir = ":memory:"
637
638[auth]
639{auth_public_url}
640"#
641 ))
642 .expect("valid engine config")
643 }
644
645 #[test]
646 fn auth_origin_defaults_to_engine_public_url() {
647 let cfg = config("https://engine.example.com", None);
648
649 assert_eq!(effective_issuer(&cfg), "https://engine.example.com/auth");
650 assert_eq!(
651 parse_auth_public_url(&cfg).unwrap().as_str(),
652 "https://engine.example.com/"
653 );
654 }
655
656 #[test]
657 fn auth_origin_override_drives_default_issuer_and_passkey_origin() {
658 let cfg = config(
659 "https://engine.example.com",
660 Some("https://auth.example.com"),
661 );
662
663 assert_eq!(effective_issuer(&cfg), "https://auth.example.com/auth");
664 assert_eq!(
665 parse_auth_public_url(&cfg).unwrap().as_str(),
666 "https://auth.example.com/"
667 );
668 }
669
670 #[test]
671 fn explicit_issuer_takes_precedence_over_auth_origin() {
672 let mut cfg = config(
673 "https://engine.example.com",
674 Some("https://auth.example.com"),
675 );
676 cfg.auth.issuer = Some("https://issuer.example.net/oauth".to_string());
677
678 assert_eq!(effective_issuer(&cfg), "https://issuer.example.net/oauth");
679 }
680
681 #[test]
682 fn invalid_auth_origin_is_rejected() {
683 let cfg = config("https://engine.example.com", Some("not a URL"));
684
685 let error = parse_auth_public_url(&cfg).unwrap_err();
686 assert!(error.to_string().contains("auth.public_url"));
687 }
688
689 #[test]
690 fn enabled_password_recovery_requires_smtp_configuration() {
691 let mut cfg = config(
692 "https://engine.example.com",
693 Some("https://auth.example.com"),
694 );
695 cfg.auth.recovery.enabled = true;
696
697 let error = recovery_options(&cfg).unwrap_err();
698 assert!(error.to_string().contains("auth.recovery.smtp"));
699 }
700
701 #[test]
702 fn password_recovery_uses_auth_origin_and_configured_limits() {
703 let mut cfg = config(
704 "https://engine.example.com",
705 Some("https://auth.example.com"),
706 );
707 cfg.auth.recovery.enabled = true;
708 cfg.auth.recovery.token_ttl_seconds = 1200;
709 cfg.auth.recovery.request_cooldown_seconds = 90;
710 cfg.auth.recovery.smtp = Some(crate::config::AuthSmtpConfig {
711 host: "smtp.example.com".to_string(),
712 port: 587,
713 username: "mailer".to_string(),
714 password: "secret".to_string(),
715 from: "Example Auth <noreply@example.com>".to_string(),
716 starttls: true,
717 });
718
719 let options = recovery_options(&cfg).unwrap().unwrap();
720 assert_eq!(
721 options.recovery_url.as_str(),
722 "https://auth.example.com/auth/recovery"
723 );
724 assert_eq!(options.token_ttl, std::time::Duration::from_secs(1200));
725 assert_eq!(options.request_cooldown, std::time::Duration::from_secs(90));
726 }
727}
728
729