fix: unify browser origin configuration

This commit is contained in:
2026-08-26 02:37:14 +09:00
parent 8396d09891
commit 33db2ea7f4
7 changed files with 182 additions and 47 deletions
+147 -34
View File
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::{fs, io};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
@@ -15,22 +16,12 @@ pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String {
DEFAULT_AUTH_RP_ID.to_string()
}
fn default_auth_origin() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_public_base_url() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
}
fn default_auth_cookie_name() -> String {
@@ -65,8 +56,6 @@ pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
@@ -91,12 +80,8 @@ pub struct WorkspaceBackendLimitsConfig {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_auth_rp_id")]
pub rp_id: String,
#[serde(default = "default_auth_origin")]
pub origin: String,
#[serde(default = "default_auth_public_base_url")]
pub public_base_url: String,
#[serde(default = "default_browser_public_url")]
pub browser_public_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
@@ -104,9 +89,7 @@ pub struct WorkspaceBackendAuthConfig {
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
rp_id: default_auth_rp_id(),
origin: default_auth_origin(),
public_base_url: default_auth_public_base_url(),
browser_public_url: default_browser_public_url(),
cookie_name: default_auth_cookie_name(),
}
}
@@ -398,13 +381,10 @@ impl WorkspaceBackendConfigFile {
))
})?;
let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&self.auth.browser_public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone();
server.frontend_url = self
.server
.frontend_url
.clone()
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
server.static_assets_dir = self
.server
.static_assets_dir
@@ -424,10 +404,10 @@ impl WorkspaceBackendConfigFile {
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(),
origin: self.auth.origin.trim().to_string(),
public_base_url: self.auth.public_base_url.trim().to_string(),
cookie_name: self.auth.cookie_name.trim().to_string(),
rp_id: browser_rp_id,
origin: browser_public_url.clone(),
public_base_url: browser_public_url,
cookie_name: normalize_required_string("auth.cookie_name", &self.auth.cookie_name)?,
};
Ok(ResolvedWorkspaceBackendConfig {
@@ -451,6 +431,20 @@ impl ResolvedWorkspaceBackendConfig {
self
}
pub fn with_browser_public_url(mut self, public_url: &str) -> Result<Self> {
let (origin, rp_id) = resolve_browser_public_url(public_url)?;
let AuthConfig::Passkey {
rp_id: configured_rp_id,
origin: configured_origin,
public_base_url,
..
} = &mut self.server.auth;
*configured_rp_id = rp_id;
*configured_origin = origin.clone();
*public_base_url = origin;
Ok(self)
}
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self
@@ -578,6 +572,36 @@ pub(crate) fn resolve_remote_runtime(
))
}
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
let value = normalize_required_string("auth.browser_public_url", value)?;
let url = Url::parse(&value).map_err(|error| {
Error::Config(format!(
"auth.browser_public_url must be an absolute http(s) URL: {error}"
))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Config(
"auth.browser_public_url must use the http or https scheme".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Config(
"auth.browser_public_url must not contain user information".to_string(),
));
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"auth.browser_public_url must contain only an origin without a path, query, or fragment"
.to_string(),
));
}
let rp_id = url
.host_str()
.ok_or_else(|| Error::Config("auth.browser_public_url must contain a host".to_string()))?
.to_string();
Ok((url.origin().ascii_serialization(), rp_id))
}
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
@@ -605,7 +629,15 @@ mod tests {
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL);
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "localhost");
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("server.db"));
assert!(
@@ -634,6 +666,87 @@ mod tests {
);
}
#[test]
fn browser_public_url_drives_all_browser_auth_settings() {
let dir = tempfile::tempdir().unwrap();
let resolved = WorkspaceBackendConfigFile::parse_str(
"[auth]\nbrowser_public_url = \"https://Yoi.Example:443/\"\n",
"test",
)
.unwrap()
.resolve(dir.path(), identity())
.unwrap();
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "yoi.example");
assert_eq!(origin, "https://yoi.example");
assert_eq!(public_base_url, "https://yoi.example");
}
#[test]
fn browser_public_url_override_replaces_the_derived_settings() {
let dir = tempfile::tempdir().unwrap();
let resolved = WorkspaceBackendConfigFile::default()
.resolve(dir.path(), identity())
.unwrap()
.with_browser_public_url("https://deploy.example.test:8443")
.unwrap();
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "deploy.example.test");
assert_eq!(origin, "https://deploy.example.test:8443");
assert_eq!(public_base_url, "https://deploy.example.test:8443");
}
#[test]
fn browser_public_url_rejects_non_origin_urls() {
for value in [
"https://example.test/path",
"https://example.test?query=true",
"file:///tmp/web",
] {
let result = WorkspaceBackendConfigFile::parse_str(
&format!("[auth]\nbrowser_public_url = {value:?}\n"),
"test",
)
.unwrap()
.resolve(tempfile::tempdir().unwrap().path(), identity());
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
Err(error) => error,
};
assert!(
error.to_string().contains("auth.browser_public_url"),
"unexpected error for {value}: {error}"
);
}
}
#[test]
fn rejects_legacy_independent_browser_auth_settings() {
for key in ["rp_id", "origin", "public_base_url"] {
let error = WorkspaceBackendConfigFile::parse_str(
&format!("[auth]\n{key} = \"legacy.example\"\n"),
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error for {key}: {error}"
);
}
}
#[test]
fn rejects_unknown_fields() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
+16 -1
View File
@@ -16,6 +16,8 @@ use yoi_workspace_server::{
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
const BROWSER_PUBLIC_URL_ENV: &str = "YOI_BROWSER_PUBLIC_URL";
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
@@ -634,6 +636,9 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
if let Some(browser_public_url) = browser_public_url_from_environment()? {
resolved = resolved.with_browser_public_url(&browser_public_url)?;
}
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
@@ -655,6 +660,16 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Ok(())
}
fn browser_public_url_from_environment() -> Result<Option<String>, CliError> {
match std::env::var(BROWSER_PUBLIC_URL_ENV) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(CliError(format!(
"{BROWSER_PUBLIC_URL_ENV} must contain valid UTF-8"
))),
}
}
fn append_trusted_runtime_sources(
store: &SqliteWorkspaceStore,
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
@@ -966,7 +981,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_BROWSER_PUBLIC_URL Browser-facing Vite/Nginx origin used by WebAuthn, device login, cookies, and CSRF checks"
);
}
+2 -4
View File
@@ -155,7 +155,6 @@ pub struct ServerConfig {
pub workspace_created_at: String,
pub workspace_root: PathBuf,
pub database_path: PathBuf,
pub frontend_url: String,
pub embedded_runtime_store_root: PathBuf,
pub static_assets_dir: Option<PathBuf>,
pub auth: AuthConfig,
@@ -182,13 +181,12 @@ impl ServerConfig {
workspace_created_at: identity.created_at,
workspace_root,
database_path,
frontend_url: "http://127.0.0.1:5173".to_string(),
embedded_runtime_store_root,
static_assets_dir: None,
auth: AuthConfig::Passkey {
rp_id: "localhost".to_string(),
origin: "http://localhost:8787".to_string(),
public_base_url: "http://localhost:8787".to_string(),
origin: "http://localhost:5173".to_string(),
public_base_url: "http://localhost:5173".to_string(),
cookie_name: "yoi_workspace_session".to_string(),
},
max_records: 200,