feat: add JWT auth, configurable username, switch password auth to Basic

Add server-side JWT authentication with permission-based access control
(read/write/delete claims). Password authentication now uses HTTP Basic
auth only (replacing Bearer). Add configurable username for both server
and client (--server-username/--client-username, defaults to "keep").

JWT secret supports file-based loading via --server-jwt-secret-file for
Docker secrets. OPTIONS preflight requests bypass auth. HEAD mapped to
read permission.

Co-Authored-By: opencode <noreply@opencode.ai>
This commit is contained in:
2026-03-13 13:56:35 -03:00
parent af1e0ca570
commit e672ec751e
17 changed files with 1052 additions and 196 deletions

View File

@@ -143,9 +143,12 @@ impl<'de> serde::Deserialize<'de> for ColumnConfig {
pub struct ServerConfig {
pub address: Option<String>,
pub port: Option<u16>,
pub username: Option<String>,
pub password_file: Option<PathBuf>,
pub password: Option<String>,
pub password_hash: Option<String>,
pub jwt_secret: Option<String>,
pub jwt_secret_file: Option<PathBuf>,
pub cert_file: Option<PathBuf>,
pub key_file: Option<PathBuf>,
pub cors_origin: Option<String>,
@@ -159,7 +162,9 @@ pub struct CompressionPluginConfig {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ClientConfig {
pub url: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub jwt: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -198,7 +203,11 @@ pub struct Settings {
#[serde(skip)]
pub client_url: Option<String>,
#[serde(skip)]
pub client_username: Option<String>,
#[serde(skip)]
pub client_password: Option<String>,
#[serde(skip)]
pub client_jwt: Option<String>,
}
impl Settings {
@@ -281,6 +290,11 @@ impl Settings {
.set_override("server.password_hash", server_password_hash.as_str())?;
}
if let Some(server_username) = &args.options.server_username {
config_builder =
config_builder.set_override("server.username", server_username.as_str())?;
}
if let Some(server_address) = &args.mode.server_address {
config_builder =
config_builder.set_override("server.address", server_address.as_str())?;
@@ -429,11 +443,21 @@ impl Settings {
.client_url
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.url.clone()));
settings.client_username = args
.options
.client_username
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.username.clone()));
settings.client_password = args
.options
.client_password
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.password.clone()));
settings.client_jwt = args
.options
.client_jwt
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.jwt.clone()));
}
debug!("CONFIG: Final settings: {settings:?}");
@@ -487,6 +511,38 @@ impl Settings {
self.server.as_ref().and_then(|s| s.password_hash.clone())
}
pub fn server_username(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.username.clone())
}
/// Get JWT secret from jwt_secret_file or directly from config if configured
pub fn get_server_jwt_secret(&self) -> Result<Option<String>> {
if let Some(server) = &self.server {
// First check for jwt_secret_file
if let Some(jwt_secret_file) = &server.jwt_secret_file {
debug!("CONFIG: Reading JWT secret from file: {jwt_secret_file:?}");
let secret = fs::read_to_string(jwt_secret_file)
.with_context(|| {
format!("Failed to read JWT secret file: {jwt_secret_file:?}")
})?
.trim()
.to_string();
return Ok(Some(secret));
}
// Fall back to direct jwt_secret field
if let Some(secret) = &server.jwt_secret {
debug!("CONFIG: Using JWT secret from config");
return Ok(Some(secret.clone()));
}
}
Ok(None)
}
pub fn server_jwt_secret(&self) -> Option<String> {
self.get_server_jwt_secret().ok().flatten()
}
pub fn server_address(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.address.clone())
}