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