diff --git a/Cargo.lock b/Cargo.lock index 90562ea..c1dbeb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "axum", @@ -2691,7 +2691,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "base64", @@ -2718,7 +2718,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "bytes", @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "axum", @@ -2768,7 +2768,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.5.8" +version = "0.5.9" dependencies = [ "openpxe-core", "rust-embed", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "bcrypt", @@ -2807,7 +2807,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.5.8" +version = "0.5.9" dependencies = [ "anyhow", "bytes", @@ -2821,7 +2821,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.5.8" +version = "0.5.9" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index b317df7..e968fe2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.8" +version = "0.5.9" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3ac6f7b..527bd71 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -36,4 +36,4 @@ pub use profile::DeployProfile; pub use queue::{DeploymentQueue, QueueEntry}; pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse}; pub use settings::{Settings, SettingsStore, TimeoutAction}; -pub use sso::{SsoConfig, SsoStore}; +pub use sso::{SsoConfig, SsoLoginInfo, SsoStore}; diff --git a/crates/core/src/sso.rs b/crates/core/src/sso.rs index da47b9e..bfcccfe 100644 --- a/crates/core/src/sso.rs +++ b/crates/core/src/sso.rs @@ -73,6 +73,23 @@ impl SsoConfig { } } +/// The minimal, non-sensitive slice of the SSO config that the **pre-auth** +/// login screen needs to render the "Sign in with …" button. Carries only +/// the display affordances — never the metadata XML/URL or entity ID, which +/// stay behind the auth-gated `/api/sso`. Served as part of the public +/// `/api/me` so the button renders reliably whether or not anyone is signed +/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SsoLoginInfo { + /// True only when SSO is *usable* (enabled AND a metadata source is + /// present) — i.e. clicking the button will actually reach an IdP. + pub enabled: bool, + /// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI. + pub idp_name: String, + /// Optional IdP logo rendered on the button. Empty = no image. + pub idp_logo_url: String, +} + /// In-memory + on-disk SSO settings registry. #[derive(Debug, Clone)] pub struct SsoStore { @@ -111,6 +128,19 @@ impl SsoStore { self.inner.read().clone() } + /// Public, non-sensitive descriptor for the login screen. Safe to + /// expose pre-auth — it's exactly what the "Sign in with …" button + /// keys off, with no metadata/entity-ID leakage. v0.5.9. + #[must_use] + pub fn login_info(&self) -> SsoLoginInfo { + let cfg = self.inner.read(); + SsoLoginInfo { + enabled: cfg.is_usable(), + idp_name: cfg.idp_name.clone(), + idp_logo_url: cfg.idp_logo_url.clone(), + } + } + /// Replace the whole config in one shot. Light validation: metadata /// XML and URL are length-capped so an operator can't OOM us by /// pasting a 10 GiB blob; the IdP UI tab clamps the input visually, diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index c5a6f4f..85aa3dd 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -319,6 +319,12 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa // and the logo asset is public, so this leaks nothing sensitive. let has_custom_logo = state.branding.has_any_web_logo(); let logo_rev = state.branding.logo_rev(); + // v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so + // the pre-auth login screen can render the "Sign in with …" button + // reliably. Previously the button keyed off the auth-gated /api/sso, + // which 401s when logged out — the button only survived on a stale + // in-memory config and vanished on any fresh login-page load. + let sso = state.sso.login_info(); if !state.admin.is_configured() { return ( StatusCode::OK, @@ -327,6 +333,7 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa "authenticated": false, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, + "sso": sso, })), ) .into_response(); @@ -343,6 +350,7 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa "session_user": u, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, + "sso": sso, })), ) .into_response(), @@ -353,6 +361,7 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa "authenticated": false, "has_custom_logo": has_custom_logo, "logo_rev": logo_rev, + "sso": sso, })), ) .into_response(), diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index ade9373..ebe39b8 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DistroFamily { DebianUbuntu, @@ -22,10 +22,22 @@ pub enum DistroFamily { Arch, Alpine, WindowsPe, + #[default] Unknown, } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Bumped whenever the introspection logic changes in a way that should +/// re-classify already-uploaded ISOs. On startup the store re-runs +/// `introspect` on any *local* ISO whose persisted report predates this +/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale +/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary — +/// without the operator having to delete and re-upload it. +/// +/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened +/// Windows (UDF/UTF-16) detection becomes retroactive. +pub const INTROSPECT_REV: u32 = 1; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IntrospectionReport { pub family: DistroFamily, pub volume_label: Option, @@ -35,17 +47,28 @@ pub struct IntrospectionReport { pub initrd_paths: Vec, /// True if `sources/boot.wim` present — Windows install media. pub has_boot_wim: bool, + /// True if the ISO carries an El Torito boot catalog — i.e. it is + /// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot` + /// (emulated CD). This is the authoritative "can this boot at all?" + /// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi, + /// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter + /// appliance bundle) has no boot catalog and reports `false`. v0.5.9. + #[serde(default)] + pub el_torito: bool, + /// Revision of the introspection logic that produced this report. Old + /// `meta.json` files without the field deserialize as 0, which is + /// below [`INTROSPECT_REV`], triggering a one-time re-introspect on + /// the next startup. v0.5.9. + #[serde(default)] + pub introspect_rev: u32, } /// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log /// and return an `Unknown` family so the uploader still sees a record. pub fn introspect(path: &Path) -> IntrospectionReport { let mut report = IntrospectionReport { - family: DistroFamily::Unknown, - volume_label: None, - kernel_path: None, - initrd_paths: Vec::new(), - has_boot_wim: false, + introspect_rev: INTROSPECT_REV, + ..Default::default() }; let Ok(mut f) = std::fs::File::open(path) else { @@ -68,6 +91,11 @@ pub fn introspect(path: &Path) -> IntrospectionReport { } } + // Does the ISO have an El Torito boot catalog? This is what decides + // whether an ISO we *can't* otherwise classify is bootable at all — + // a bootable ISO sanboots; a data/appliance ISO (no catalog) can't. + report.el_torito = detect_el_torito(&mut f); + // Cheap content scan: read the first ~64 MiB, look for signature filenames. // This is enough to identify `sources/boot.wim` (Windows) and common // kernel/initrd paths for the major Linux distros. @@ -222,6 +250,44 @@ fn filename_looks_windows(path: &Path) -> bool { TOKENS.iter().any(|t| name.contains(t)) } +/// The boot-system identifier string in an El Torito Boot Record Volume +/// Descriptor (offset 7, NUL-padded to 32 bytes). +const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION"; + +/// Detect an El Torito boot catalog — the marker that an ISO is bootable +/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`). +/// +/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and +/// runs one 2048-byte descriptor per sector until a Set Terminator +/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot +/// system identifier reads "EL TORITO SPECIFICATION" means the image +/// declares an El Torito boot catalog. We only confirm its presence — we +/// don't parse the catalog (sanboot/the firmware does that). The walk is +/// capped so a malformed/huge image can't spin us. v0.5.9. +fn detect_el_torito(f: &mut std::fs::File) -> bool { + let mut vd = [0u8; 2048]; + for lba in 16u64..32 { + if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() { + return false; + } + // Every descriptor in the set carries the "CD001" magic; once it's + // missing we've walked off the end of a valid set. + if &vd[1..6] != b"CD001" { + return false; + } + match vd[0] { + // Boot Record descriptor carrying the El Torito signature. + 0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true, + // Volume Descriptor Set Terminator — nothing bootable found. + 0xFF => return false, + // Any other descriptor (incl. a non-El-Torito boot record) — + // keep walking the set. + _ => {} + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -260,6 +326,40 @@ mod tests { assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim")); } + #[test] + fn el_torito_boot_catalog_detected() { + let dir = tempfile::tempdir().unwrap(); + // Helper: stamp a 2048-byte descriptor at `lba` with type + magic. + let stamp = |img: &mut [u8], lba: usize, ty: u8| { + let off = lba * 2048; + img[off] = ty; + img[off + 1..off + 6].copy_from_slice(b"CD001"); + }; + + // Bootable image: PVD @16, El Torito Boot Record @17, terminator @18. + let mut boot = vec![0u8; 2048 * 19]; + stamp(&mut boot, 16, 0x01); + stamp(&mut boot, 17, 0x00); + boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID); + stamp(&mut boot, 18, 0xFF); + let bp = dir.path().join("boot.iso"); + std::fs::write(&bp, &boot).unwrap(); + let mut f = std::fs::File::open(&bp).unwrap(); + assert!( + detect_el_torito(&mut f), + "El Torito boot record should match" + ); + + // Data/appliance image: PVD @16, terminator @17, no boot record. + let mut data = vec![0u8; 2048 * 18]; + stamp(&mut data, 16, 0x01); + stamp(&mut data, 17, 0xFF); + let dp = dir.path().join("data.iso"); + std::fs::write(&dp, &data).unwrap(); + let mut f2 = std::fs::File::open(&dp).unwrap(); + assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog"); + } + #[test] fn filename_hint_catches_windows_isos() { use std::path::Path; diff --git a/crates/iso-store/src/nfs_share.rs b/crates/iso-store/src/nfs_share.rs index 422e439..2652e6c 100644 --- a/crates/iso-store/src/nfs_share.rs +++ b/crates/iso-store/src/nfs_share.rs @@ -57,7 +57,7 @@ //! UI to ask for. (If a future server needs Kerberos or non-default //! uid mapping we can add those, but for ISO read access nobody does.) -use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::introspect::IntrospectionReport; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use bytes::Bytes; use nfs3_client::tokio::TokioConnector; @@ -399,13 +399,7 @@ impl NfsShareManager { // Same approach as SMB: no real introspection over the // network in v0.4.67. The boot-entry generator falls back // to filename-based sanboot detection. - let report = IntrospectionReport { - family: DistroFamily::Unknown, - volume_label: None, - kernel_path: None, - initrd_paths: Vec::new(), - has_boot_wim: false, - }; + let report = IntrospectionReport::default(); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Nfs { share_id: share.id.clone(), diff --git a/crates/iso-store/src/sftp_share.rs b/crates/iso-store/src/sftp_share.rs index 35512d9..5f36e01 100644 --- a/crates/iso-store/src/sftp_share.rs +++ b/crates/iso-store/src/sftp_share.rs @@ -56,7 +56,7 @@ //! hint}` error shape is shared so the storage tab renders all three //! protocols through one code path. -use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::introspect::IntrospectionReport; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use bytes::Bytes; use openpxe_core::{Error, Result}; @@ -480,13 +480,7 @@ impl SftpShareManager { // register `Unknown` and let the boot-entry generator fall // back to filename-based detection. SFTP *could* do bounded // PVD reads (it has random access) — a follow-up can add it. - let report = IntrospectionReport { - family: DistroFamily::Unknown, - volume_label: None, - kernel_path: None, - initrd_paths: Vec::new(), - has_boot_wim: false, - }; + let report = IntrospectionReport::default(); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Sftp { share_id: share.id.clone(), diff --git a/crates/iso-store/src/smb_share.rs b/crates/iso-store/src/smb_share.rs index fa41231..4ff307f 100644 --- a/crates/iso-store/src/smb_share.rs +++ b/crates/iso-store/src/smb_share.rs @@ -56,7 +56,7 @@ //! streaming. A follow-up release can add libsmbclient-based seek if //! a real workload needs it. -use crate::introspect::{DistroFamily, IntrospectionReport}; +use crate::introspect::IntrospectionReport; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use openpxe_core::{Error, Result}; use parking_lot::Mutex; @@ -470,13 +470,7 @@ impl SmbShareManager { // and the operator gets *something* bootable. A follow-up // release can do a bounded `smbclient get` of the first // 64 KiB for real detection. - let report = IntrospectionReport { - family: DistroFamily::Unknown, - volume_label: None, - kernel_path: None, - initrd_paths: Vec::new(), - has_boot_wim: false, - }; + let report = IntrospectionReport::default(); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let source = IsoSource::Smb { share_id: share.id.clone(), diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 80cb4f6..de8fcd6 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -223,7 +223,8 @@ impl IsoStore { continue; } if let Ok(text) = tokio::fs::read_to_string(&p).await { - if let Ok(meta) = serde_json::from_str::(&text) { + if let Ok(mut meta) = serde_json::from_str::(&text) { + self.reintrospect_if_stale(&mut meta).await; self.insert(meta); } } @@ -231,6 +232,46 @@ impl IsoStore { Ok(()) } + /// v0.5.9: re-run introspection on a *local* ISO whose persisted report + /// predates the current logic. ISOs uploaded by an older binary carry a + /// stale family/boot profile — most visibly a Windows 11 ISO tagged + /// `Unknown` before the UDF/El-Torito detection landed, which then shows + /// as "won't boot" forever. Re-probing on startup fixes them in place, + /// no delete-and-re-upload. Bounded: only `Local` sources (we have the + /// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at + /// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we + /// push it onto the blocking pool to keep the async runtime responsive. + async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) { + if !matches!(meta.source, IsoSource::Local) + || meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV + { + return; + } + let path = self.iso_path(&meta.id); + if !path.exists() { + return; + } + let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else { + tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed"); + return; + }; + let before = meta.introspection.family; + meta.introspection = fresh; + meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection); + if let Err(e) = self.persist_meta(meta).await { + tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}"); + return; + } + tracing::info!( + target: "openpxe::iso", + id = %meta.id, + from = ?before, + to = ?meta.introspection.family, + el_torito = meta.introspection.el_torito, + "re-introspected stale ISO metadata" + ); + } + fn insert(&self, meta: IsoMeta) { self.inner.write().isos.insert(meta.id.clone(), meta); } @@ -687,13 +728,7 @@ mod tests { size_bytes: 0, sha256_hex: None, uploaded_at: OffsetDateTime::now_utc(), - introspection: IntrospectionReport { - family: DistroFamily::Unknown, - volume_label: None, - kernel_path: None, - initrd_paths: vec![], - has_boot_wim: false, - }, + introspection: IntrospectionReport::default(), boot_entries: vec![], source: IsoSource::Local, password_hash: None, diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index f990211..1a981d9 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -165,17 +165,30 @@ if (fam === 'windows_pe') { return { ok: true }; } - if (!iso.introspection.kernel_path) { - // Not Windows and no Linux kernel/initrd detected. Small images can - // still try the sanboot fallback; large ones almost certainly aren't - // network-bootable installers (e.g. appliance bundles like VMware - // VCSA) — flag them clearly instead of with a Linux-centric message. - if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) { - return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" }; - } - return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' }; + // Linux with a detected kernel/initrd — direct kernel+initrd boot. + if (iso.introspection.kernel_path) { + return { ok: true }; } - return { ok: true }; + // v0.5.9: any other ISO that carries an El Torito boot catalog is + // bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware + // tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒ + // unbootable" size guess with the authoritative on-disk boot signal, + // so a large bootable ISO is no longer mislabeled and a Windows ISO + // re-introspected on upgrade lights up correctly. + if (iso.introspection.el_torito) { + return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' }; + } + // Remote-share ISOs aren't introspected (no random access over the + // network), so el_torito is unknown — assume bootable and let sanboot + // try rather than cry wolf. + const remote = iso.source && iso.source.kind && iso.source.kind !== 'local'; + if (remote) { + return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' }; + } + // Local ISO with no Windows/Linux boot files and no El Torito catalog: + // a data/appliance image (e.g. a VMware vCenter bundle), not a bootable + // installer. + return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it can’t be PXE-booted' }; } // v0.5.2: pretty label for an unattended file's detected kind. @@ -291,14 +304,23 @@ el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Images available'), el('div', {class: 'value'}, String(isos.length)), - el('div', {class: 'trend'}, - isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + - isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + + el('div', {class: 'trend'}, (() => { + // v0.5.9: count families honestly. Anything that isn't a known + // Linux family or Windows lands in "other" (data/appliance ISOs + // like VMware VCSA, or as-yet-unclassified images) instead of + // being lumped under "Linux". + const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine']; + const win = isos.filter(i => i.introspection.family === 'windows_pe').length; + const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length; + const other = isos.length - win - lin; // v0.4.67+v0.5.5: count all remote-share protocols. Label // generically since operators may use any mix of SMB/NFS/SFTP. - ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) + - ' remote share' + - (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')), + const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0); + const parts = [win + ' Windows', lin + ' Linux']; + if (other > 0) parts.push(other + ' other'); + parts.push(remote + ' remote share' + (remote === 1 ? '' : 's')); + return parts.join(' · '); + })()), ])), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Uptime'), @@ -2280,7 +2302,9 @@ // own self-contained
; when SSO is enabled, a distinct // "Sign in with …" button sits below a divider — the credential // fields no longer double as the SSO trigger. - const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata); + // `enabled` from /api/me already means "usable" (enabled AND a metadata + // source is configured), so the button only shows when SSO will work. + const ssoLive = !!(ssoConfig && ssoConfig.enabled); const ssoBlock = ssoLive ? el('div', {class:'sso-block'}, [ el('div', {class:'auth-divider'}, el('span', {}, 'or')), @@ -2525,10 +2549,12 @@ ]))); return; } - // Preload the SSO config so the login card can offer the operator - // an "Sign in with X" button when configured. Failure is harmless. - try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); } - catch { ssoConfig = null; } + // v0.5.9: the login card's "Sign in with …" button keys off the SSO + // descriptor that /api/me now carries (public, non-sensitive: enabled + // + idp_name + idp_logo_url). It's available signed in or out, so the + // button is static — it no longer relied on the auth-gated /api/sso, + // which 401s pre-auth and made the button vanish on fresh login loads. + ssoConfig = me.sso || null; if (me.setup_required) { showAuthScreen('setup');