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;
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
62/// Top-level entrypoint for the standalone `assay-engine` binary.
63/// Picks the backend from config, composes engine via
64/// [`embedded::build`], and serves forever on `cfg.server.bind_addr`.
65///
66/// For embedded use (composing engine into a parent binary's
67/// router), call [`embedded::build`] directly.
68pub 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/// The seal key for the vault's master KEK, from the environment.
75/// A malformed value fails boot rather than quietly leaving the vault
76/// unsealed at rest.
77#[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/// Build the vault context iff the runtime `engine.modules.vault.enabled`
94/// row is TRUE. Loads the master KEK from `vault.kek_metadata` (or seeds
95/// a fresh one on first boot) and composes the per-feature stores
96/// against the same pool the rest of the engine uses.
97#[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    // The `vault` umbrella feature on assay-vault implies vault-kv +
110    // vault-transit, so the with_* methods are unconditionally
111    // available here.
112    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        // Phase 5 default-config: registry is empty until an operator
154        // configures providers via /dynamic/* admin routes (or in
155        // future, engine.toml). The dispatcher returns NotFound for
156        // unknown providers, which surfaces as 404 to the caller.
157        let svc = assay_vault::dynamic::DynamicCredsService::new(registry, leases);
158        ctx = ctx.with_dynamic(svc);
159    }
160    Ok(Some(ctx))
161}
162
163/// SQLite mirror of [`build_vault_ctx_pg`].
164#[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/// Discover each configured external OIDC issuer once at boot and
455/// hand back ready-to-use verifiers. Each verifier owns a background
456/// task that refreshes its JWKS on the configured interval.
457///
458/// Errors here are fatal — if Hydra (or whichever IdP) is unreachable
459/// at boot the engine shouldn't pretend it can validate tokens. The
460/// alternative (silently degrading to "no external issuer trusted")
461/// would surface as 401s and look like a session bug.
462#[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
487/// Issuer for JWTs minted via the `auth-jwt` module. Defaults to
488/// `<auth.public_url>/auth` when unset, matching where the auth
489/// router is mounted.
490fn 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
505/// Issuer the OIDC provider advertises in its discovery doc + the `iss`
506/// claim of every issued id_token. Defaults to the parent
507/// [`effective_issuer`] when no override is set.
508fn 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
516/// Parse the canonical auth origin as a `url::Url`. Used by passkey RP setup
517/// (which wants the bare origin) — not by the OIDC provider, which
518/// needs the issuer URL (with `/auth`); see [`oidc_public_url`].
519fn 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
569/// Base URL the OIDC provider exposes its endpoints at — same as
570/// [`oidc_issuer`] (which already accounts for the `/auth` mount
571/// prefix), parsed as a `url::Url`. Passed into `OidcProviderConfig`
572/// so `upstream_callback_url(...)` produces an absolute URI that
573/// matches the actual handler path.
574fn 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/// Build a passkey manager from `auth.passkey` config. Returns `None`
580/// when the public_url isn't parseable as a URL with a host (passkeys
581/// require an origin) — we log + skip rather than fail boot.
582#[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// `run_with_store` (the previous private composition helper) is gone.
730// Its body lives in `embedded::compose` (this module's `embedded` sibling),
731// minus the final `server::serve` call. `pub async fn run` above
732// composes the engine via `embedded::build` and then binds + serves
733// the resulting `axum::Router` via `server::bind_and_serve`.