Skip to main content

assay_engine/
lib.rs

1//! `assay-engine` — one static binary that replaces a Temporal +
2//! Kratos + Hydra + Keto stack.
3//!
4//! `v0.2.0` is the umbrella release that turns the engine into a full
5//! IdP + workflow runtime: the previously-empty [`auth`] feature now
6//! pulls [`assay_auth`] in, mounting OIDC client + provider, passkey,
7//! Argon2 password, JWT + JWKS rotation, biscuit capability tokens,
8//! Zanzibar ReBAC, session + admin endpoints under `/auth`. The
9//! dashboard panes that consume those routes (Users, Sessions, OIDC
10//! clients, Upstream providers, Zanzibar, JWKS, Biscuit, Audit) light up
11//! when the auth module is enabled in `engine.modules`.
12//!
13//! Composition is via [`axum::extract::FromRef`] over [`EngineState<S>`]
14//! — workflow / auth / dashboard each contribute their own `Ctx` and
15//! the parent state derives every sub-state extractor automatically. A
16//! no-auth build (`--no-default-features --features
17//! "backend-postgres,backend-sqlite"`) compiles identically to the
18//! pre-v0.2.0 engine; an auth build composes the auth ctx if and only if
19//! `engine.modules` shows `auth` enabled at boot.
20//!
21//! ## Module enablement model
22//!
23//! Three layers compose:
24//!
25//! 1. **Compile features (Cargo)** — decide whether the module's code
26//!    is *linked* into the binary. `assay-engine`'s default compiles
27//!    workflow + dashboard; opt into `auth` for the IdP.
28//! 2. **`engine.modules` row (DB)** — decides whether the module is
29//!    *active* at runtime. `name`, `enabled`, `version`, `config`. The
30//!    boot path runs the module's migrations + mounts its routes + lets
31//!    the dashboard render its panes only when `enabled = TRUE`.
32//! 3. **`engine.toml` config** — decides how the active module is
33//!    *configured* (issuer URL, session TTL, OIDC provider toggle,
34//!    admin api-keys, …).
35//!
36//! See plan 12 § Architecture principle 1 (composition) and § principle
37//! 8 (runtime/engine split). Migration notes for v0.1.x → v0.2.0 live
38//! in `docs/migration-to-0.2.0.md`.
39
40use 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
60/// Top-level entrypoint for the standalone `assay-engine` binary.
61/// Picks the backend from config, composes engine via
62/// [`embedded::build`], and serves forever on `cfg.server.bind_addr`.
63///
64/// For embedded use (composing engine into a parent binary's
65/// router), call [`embedded::build`] directly.
66pub 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/// Build the vault context iff the runtime `engine.modules.vault.enabled`
73/// row is TRUE. Loads the master KEK from `vault.kek_metadata` (or seeds
74/// a fresh one on first boot) and composes the per-feature stores
75/// against the same pool the rest of the engine uses.
76#[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    // The `vault` umbrella feature on assay-vault implies vault-kv +
88    // vault-transit, so the with_* methods are unconditionally
89    // available here.
90    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        // Phase 5 default-config: registry is empty until an operator
132        // configures providers via /dynamic/* admin routes (or in
133        // future, engine.toml). The dispatcher returns NotFound for
134        // unknown providers, which surfaces as 404 to the caller.
135        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
136        ctx = ctx.with_dynamic(svc);
137    }
138    Ok(Some(ctx))
139}
140
141/// SQLite mirror of [`build_vault_ctx_pg`].
142#[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/// Discover each configured external OIDC issuer once at boot and
432/// hand back ready-to-use verifiers. Each verifier owns a background
433/// task that refreshes its JWKS on the configured interval.
434///
435/// Errors here are fatal — if Hydra (or whichever IdP) is unreachable
436/// at boot the engine shouldn't pretend it can validate tokens. The
437/// alternative (silently degrading to "no external issuer trusted")
438/// would surface as 401s and look like a session bug.
439#[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
464/// Issuer for JWTs minted via the `auth-jwt` module. Defaults to
465/// `<auth.public_url>/auth` when unset, matching where the auth
466/// router is mounted.
467fn 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
482/// Issuer the OIDC provider advertises in its discovery doc + the `iss`
483/// claim of every issued id_token. Defaults to the parent
484/// [`effective_issuer`] when no override is set.
485fn 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
493/// Parse the canonical auth origin as a `url::Url`. Used by passkey RP setup
494/// (which wants the bare origin) — not by the OIDC provider, which
495/// needs the issuer URL (with `/auth`); see [`oidc_public_url`].
496fn 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
546/// Base URL the OIDC provider exposes its endpoints at — same as
547/// [`oidc_issuer`] (which already accounts for the `/auth` mount
548/// prefix), parsed as a `url::Url`. Passed into `OidcProviderConfig`
549/// so `upstream_callback_url(...)` produces an absolute URI that
550/// matches the actual handler path.
551fn 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/// Build a passkey manager from `auth.passkey` config. Returns `None`
557/// when the public_url isn't parseable as a URL with a host (passkeys
558/// require an origin) — we log + skip rather than fail boot.
559#[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// `run_with_store` (the previous private composition helper) is gone.
707// Its body lives in `embedded::compose` (this module's `embedded` sibling),
708// minus the final `server::serve` call. `pub async fn run` above
709// composes the engine via `embedded::build` and then binds + serves
710// the resulting `axum::Router` via `server::bind_and_serve`.