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

@@ -151,6 +151,20 @@ pub struct OptionsArgs {
#[arg(help("Password hash for server authentication (requires --server)"))]
pub server_password_hash: Option<String>,
#[arg(long, env("KEEP_SERVER_USERNAME"))]
#[arg(help(
"Username for server Basic authentication (requires --server, defaults to 'keep')"
))]
pub server_username: Option<String>,
#[arg(long, env("KEEP_SERVER_JWT_SECRET"))]
#[arg(help("JWT secret for token-based authentication (requires --server)"))]
pub server_jwt_secret: Option<String>,
#[arg(long, env("KEEP_SERVER_JWT_SECRET_FILE"))]
#[arg(help("Path to file containing JWT secret (requires --server)"))]
pub server_jwt_secret_file: Option<PathBuf>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_URL"), help_heading("Client Options"))]
#[arg(help("Remote keep server URL for client mode"))]
@@ -161,6 +175,16 @@ pub struct OptionsArgs {
#[arg(help("Password for remote keep server authentication"))]
pub client_password: Option<String>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_USERNAME"), help_heading("Client Options"))]
#[arg(help("Username for remote keep server authentication (defaults to 'keep')"))]
pub client_username: Option<String>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_JWT"), help_heading("Client Options"))]
#[arg(help("JWT token for remote keep server authentication"))]
pub client_jwt: Option<String>,
#[arg(
long,
help("Force output even when binary data would be sent to a TTY")

View File

@@ -1,4 +1,5 @@
use crate::services::error::CoreError;
use base64::Engine;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::io::Read;
@@ -17,17 +18,26 @@ pub struct ItemInfo {
pub struct KeepClient {
base_url: String,
agent: ureq::Agent,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
}
impl KeepClient {
pub fn new(base_url: &str, password: Option<String>) -> Result<Self, CoreError> {
pub fn new(
base_url: &str,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
) -> Result<Self, CoreError> {
let base_url = base_url.trim_end_matches('/').to_string();
let agent = ureq::Agent::new_with_defaults();
Ok(Self {
base_url,
agent,
username,
password,
jwt,
})
}
@@ -35,14 +45,40 @@ impl KeepClient {
&self.base_url
}
pub fn username(&self) -> Option<&String> {
self.username.as_ref()
}
pub fn password(&self) -> Option<&String> {
self.password.as_ref()
}
pub fn jwt(&self) -> Option<&String> {
self.jwt.as_ref()
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
/// Get the Authorization header value for the current credentials.
///
/// JWT token is sent as `Bearer <token>`.
/// Password is sent as `Basic base64(username:password)`
/// where username defaults to "keep".
fn auth_header(&self) -> Option<String> {
if let Some(ref jwt) = self.jwt {
Some(format!("Bearer {jwt}"))
} else if let Some(ref password) = self.password {
let username = self.username.as_deref().unwrap_or("keep");
let credentials = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
Some(format!("Basic {encoded}"))
} else {
None
}
}
fn handle_error<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
match result {
Ok(v) => Ok(v),
@@ -57,8 +93,8 @@ impl KeepClient {
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
@@ -81,8 +117,8 @@ impl KeepClient {
}
}
let mut req = self.agent.get(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
@@ -92,8 +128,8 @@ impl KeepClient {
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let mut body = response.into_body();
@@ -135,8 +171,8 @@ impl KeepClient {
}
let mut req = self.agent.post(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/octet-stream");
@@ -162,8 +198,8 @@ impl KeepClient {
pub fn delete(&self, path: &str) -> Result<(), CoreError> {
let url = self.url(path);
let mut req = self.agent.delete(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
self.handle_error(req.call())?;
Ok(())
@@ -254,8 +290,8 @@ impl KeepClient {
) -> Result<(), CoreError> {
let url = self.url(&format!("/api/item/{id}/meta"));
let mut req = self.agent.post(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/json");
@@ -274,8 +310,8 @@ impl KeepClient {
);
let mut req = self.agent.get(&url);
if let Some(ref password) = self.password {
req = req.header("Authorization", &format!("Bearer {password}"));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;

View File

@@ -11,12 +11,12 @@ use std::io::{Read, Write};
#[cfg(feature = "gzip")]
use std::path::PathBuf;
#[cfg(feature = "gzip")]
use flate2::Compression;
#[cfg(feature = "gzip")]
use flate2::read::GzDecoder;
#[cfg(feature = "gzip")]
use flate2::write::GzEncoder;
#[cfg(feature = "gzip")]
use flate2::Compression;
#[cfg(feature = "gzip")]
use crate::compression_engine::CompressionEngine;

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, Result};
use anyhow::{Result, anyhow};
use std::io;
use std::io::{Read, Write};
use std::path::PathBuf;

View File

@@ -1,4 +1,4 @@
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result, anyhow};
use log::*;
use std::fs::File;
use std::io::{Read, Write};

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())
}

View File

@@ -188,8 +188,12 @@ fn main() -> Result<(), Error> {
#[cfg(feature = "client")]
{
if let Some(ref client_url) = settings.client_url {
let client =
keep::client::KeepClient::new(client_url, settings.client_password.clone())?;
let client = keep::client::KeepClient::new(
client_url,
settings.client_username.clone(),
settings.client_password.clone(),
settings.client_jwt.clone(),
)?;
return match mode {
KeepModes::Save => {

View File

@@ -100,11 +100,14 @@ pub fn mode(
// Streamer thread: reads compressed bytes from pipe → POST to server
let client_url = client.base_url().to_string();
let client_username = client.username().cloned();
let client_password = client.password().cloned();
let client_jwt = client.jwt().cloned();
let tags_clone = tags.clone();
let streamer_handle = std::thread::spawn(move || -> Result<ItemInfo> {
let streaming_client = KeepClient::new(&client_url, client_password)?;
let streaming_client =
KeepClient::new(&client_url, client_username, client_password, client_jwt)?;
let params = [
("compress".to_string(), server_compress.to_string()),
("meta".to_string(), "false".to_string()),

231
src/modes/server/auth.rs Normal file
View File

@@ -0,0 +1,231 @@
use axum::http::Method;
use jsonwebtoken::{DecodingKey, TokenData, Validation, decode};
use log::debug;
use serde::Deserialize;
/// JWT claims for permission-based access control.
///
/// External token generators should include these claims in the JWT payload.
/// The server validates the signature and checks permissions for each request.
///
/// # Example token payload
///
/// ```json
/// {
/// "sub": "my-client",
/// "exp": 1735689600,
/// "read": true,
/// "write": true,
/// "delete": false
/// }
/// ```
#[derive(Debug, Deserialize)]
pub struct Claims {
/// Subject (client identifier).
pub sub: String,
/// Expiration time (Unix timestamp).
pub exp: usize,
/// Read permission (GET requests).
#[serde(default)]
pub read: bool,
/// Write permission (POST/PUT requests).
#[serde(default)]
pub write: bool,
/// Delete permission (DELETE requests).
#[serde(default)]
pub delete: bool,
}
/// Returns the required permission for an HTTP method.
///
/// # Mapping
///
/// - GET, HEAD → "read"
/// - POST, PUT, PATCH → "write"
/// - DELETE → "delete"
///
/// # Arguments
///
/// * `method` - The HTTP method of the incoming request.
///
/// # Returns
///
/// A string slice representing the required permission.
pub fn required_permission(method: &Method) -> &'static str {
if method == Method::GET || method == Method::HEAD {
"read"
} else if method == Method::DELETE {
"delete"
} else {
"write"
}
}
/// Checks if the JWT claims grant the required permission.
///
/// # Arguments
///
/// * `claims` - The validated JWT claims.
/// * `permission` - The required permission string ("read", "write", or "delete").
///
/// # Returns
///
/// `true` if the claims grant the permission, `false` otherwise.
pub fn check_permission(claims: &Claims, permission: &str) -> bool {
match permission {
"read" => claims.read,
"write" => claims.write,
"delete" => claims.delete,
_ => false,
}
}
/// Validates a JWT token and returns the claims.
///
/// Uses HMAC-SHA256 signature verification with the provided secret.
///
/// # Arguments
///
/// * `token` - The JWT token string (without "Bearer " prefix).
/// * `secret` - The secret key used to verify the signature.
///
/// # Returns
///
/// * `Ok(Claims)` - The validated claims if the token is valid.
/// * `Err(String)` - A human-readable error message if validation fails.
pub fn validate_jwt(token: &str, secret: &str) -> Result<Claims, String> {
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
validation.algorithms = vec![jsonwebtoken::Algorithm::HS256];
validation.set_required_spec_claims(&["exp", "sub"]);
let token_data: TokenData<Claims> = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(|e| {
debug!("JWT validation failed: {e}");
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => "Token expired".to_string(),
jsonwebtoken::errors::ErrorKind::InvalidSignature => "Invalid token".to_string(),
jsonwebtoken::errors::ErrorKind::InvalidToken => "Malformed token".to_string(),
jsonwebtoken::errors::ErrorKind::ImmatureSignature => "Token not yet valid".to_string(),
_ => "Invalid token".to_string(),
}
})?;
Ok(token_data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode};
fn make_token(claims: &serde_json::Value, secret: &str) -> String {
let header = Header::new(jsonwebtoken::Algorithm::HS256);
encode(
&header,
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
#[test]
fn test_validate_jwt_valid_token() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize,
"read": true,
"write": true,
"delete": false
});
let token = make_token(&claims, secret);
let result = validate_jwt(&token, secret);
assert!(result.is_ok());
let claims = result.unwrap();
assert_eq!(claims.sub, "test-client");
assert!(claims.read);
assert!(claims.write);
assert!(!claims.delete);
}
#[test]
fn test_validate_jwt_expired_token() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 1000000000usize,
"read": true
});
let token = make_token(&claims, secret);
let result = validate_jwt(&token, secret);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Token expired");
}
#[test]
fn test_validate_jwt_wrong_secret() {
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize,
"read": true
});
let token = make_token(&claims, "correct-secret");
let result = validate_jwt(&token, "wrong-secret");
assert!(result.is_err());
}
#[test]
fn test_validate_jwt_malformed_token() {
let result = validate_jwt("not.a.jwt", "secret");
assert!(result.is_err());
}
#[test]
fn test_required_permission() {
assert_eq!(required_permission(&Method::GET), "read");
assert_eq!(required_permission(&Method::HEAD), "read");
assert_eq!(required_permission(&Method::POST), "write");
assert_eq!(required_permission(&Method::PUT), "write");
assert_eq!(required_permission(&Method::PATCH), "write");
assert_eq!(required_permission(&Method::DELETE), "delete");
}
#[test]
fn test_check_permission() {
let claims = Claims {
sub: "test".to_string(),
exp: 9999999999,
read: true,
write: false,
delete: true,
};
assert!(check_permission(&claims, "read"));
assert!(!check_permission(&claims, "write"));
assert!(check_permission(&claims, "delete"));
assert!(!check_permission(&claims, "unknown"));
}
#[test]
fn test_check_permission_default_false() {
// When fields are missing from JSON, serde(default) makes them false
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize
});
let token = make_token(&claims, secret);
let claims = validate_jwt(&token, secret).unwrap();
assert!(!claims.read);
assert!(!claims.write);
assert!(!claims.delete);
}
}

View File

@@ -15,7 +15,7 @@ use crate::services::item_service::ItemService;
use anyhow::Result;
use axum::{
extract::{ConnectInfo, Request},
http::{HeaderMap, StatusCode},
http::{HeaderMap, Method, StatusCode},
middleware::Next,
response::Response,
};
@@ -42,8 +42,13 @@ use utoipa::ToSchema;
/// let config = ServerConfig {
/// address: "127.0.0.1".to_string(),
/// port: Some(8080),
/// username: None,
/// password: None,
/// password_hash: None,
/// jwt_secret: None,
/// cert_file: None,
/// key_file: None,
/// cors_origin: None,
/// };
/// ```
#[derive(Debug, Clone)]
@@ -58,9 +63,13 @@ pub struct ServerConfig {
/// The TCP port number to listen on. If not specified, a default port (typically
/// 8080 or 21080) will be used.
pub port: Option<u16>,
/// Optional authentication username.
///
/// Username for Basic authentication. Defaults to "keep" when not specified.
pub username: Option<String>,
/// Optional authentication password.
///
/// Plain text password for basic or bearer token authentication. This should be
/// Plain text password for Basic authentication. This should be
/// used only for testing or low-security environments.
pub password: Option<String>,
/// Optional hashed authentication password.
@@ -68,6 +77,11 @@ pub struct ServerConfig {
/// Pre-hashed password (Unix crypt format) for secure authentication. Preferred
/// over plain text password for production use.
pub password_hash: Option<String>,
/// Optional JWT secret for token-based authentication.
///
/// When set, the server validates JWT tokens (HS256) and checks permission claims
/// (read, write, delete) for each request. Takes priority over password auth.
pub jwt_secret: Option<String>,
/// Optional path to TLS certificate file (PEM).
///
/// When both cert_file and key_file are set, the server uses HTTPS.
@@ -633,56 +647,59 @@ pub struct CreateItemRequest {
pub metadata: Option<std::collections::HashMap<String, String>>,
}
/// Validates bearer authentication token.
/// Checks authorization header for valid credentials.
///
/// This function checks if the provided authorization string is a valid Bearer token
/// matching the expected password or hash.
/// This function inspects the HTTP Authorization header for valid Basic
/// authentication credentials against the provided username and password or hash.
/// Bearer tokens are not checked here — JWT validation is handled separately
/// in the middleware.
///
/// # Arguments
///
/// * `auth_str` - The authorization string from the header.
/// * `expected_password` - The expected plain text password.
/// * `expected_hash` - Optional expected password hash.
/// * `headers` - HTTP headers from the request.
/// * `username` - Optional expected username (defaults to "keep").
/// * `password` - Optional expected password.
/// * `password_hash` - Optional expected password hash.
///
/// # Returns
///
/// * `true` - If authentication succeeds.
/// * `false` - Otherwise.
///
/// # Errors
///
/// None; returns false on failure.
fn check_bearer_auth(
auth_str: &str,
expected_password: &str,
expected_hash: &Option<String>,
/// * `true` - If authorized (or no auth required).
/// * `false` - If unauthorized.
pub fn check_auth(
headers: &HeaderMap,
username: &Option<String>,
password: &Option<String>,
password_hash: &Option<String>,
) -> bool {
if !auth_str.starts_with("Bearer ") {
return false;
// If neither password nor hash is set, no authentication required
if password.is_none() && password_hash.is_none() {
return true;
}
let provided_password = &auth_str[7..];
let effective_username = username.as_deref().unwrap_or("keep");
// If we have a password hash, verify against it
if let Some(hash) = expected_hash {
return pwhash::unix::verify(provided_password, hash);
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
return check_basic_auth(
auth_str,
effective_username,
password.as_deref().unwrap_or(""),
password_hash,
);
}
}
// Otherwise, do constant-time comparison to prevent timing attacks
provided_password
.as_bytes()
.ct_eq(expected_password.as_bytes())
.into()
false
}
/// Validates basic authentication credentials.
///
/// This function decodes and validates Basic Auth credentials from the authorization
/// header against the expected password or hash.
/// header against the expected username and password or hash.
///
/// # Arguments
///
/// * `auth_str` - The authorization string from the header.
/// * `expected_username` - The expected username.
/// * `expected_password` - The expected plain text password.
/// * `expected_hash` - Optional expected password hash.
///
@@ -696,6 +713,7 @@ fn check_bearer_auth(
/// Returns false on decode or validation failure.
fn check_basic_auth(
auth_str: &str,
expected_username: &str,
expected_password: &str,
expected_hash: &Option<String>,
) -> bool {
@@ -707,67 +725,35 @@ fn check_basic_auth(
if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) {
if let Ok(decoded_str) = String::from_utf8(decoded_bytes) {
if let Some(colon_pos) = decoded_str.find(':') {
let provided_username = &decoded_str[..colon_pos];
let provided_password = &decoded_str[colon_pos + 1..];
// Check username with constant-time comparison
if !bool::from(
provided_username
.as_bytes()
.ct_eq(expected_username.as_bytes()),
) {
return false;
}
// If we have a password hash, verify against it
if let Some(hash) = expected_hash {
return pwhash::unix::verify(provided_password, hash);
}
// Otherwise, do constant-time comparison to prevent timing attacks
let expected_credentials = format!("keep:{expected_password}");
return decoded_str
.as_bytes()
.ct_eq(expected_credentials.as_bytes())
.into();
return bool::from(
provided_password
.as_bytes()
.ct_eq(expected_password.as_bytes()),
);
}
}
}
false
}
/// Checks authorization header for valid credentials.
///
/// This function inspects the HTTP Authorization header for valid Bearer or Basic
/// authentication credentials against the provided password or hash.
///
/// # Arguments
///
/// * `headers` - HTTP headers from the request.
/// * `password` - Optional expected password.
/// * `password_hash` - Optional expected password hash.
///
/// # Returns
///
/// * `true` - If authorized (or no auth required).
/// * `false` - If unauthorized.
///
/// # Examples
///
/// ```
/// if check_auth(&headers, &Some("pass".to_string()), &None) {
/// // Proceed
/// }
/// ```
pub fn check_auth(
headers: &HeaderMap,
password: &Option<String>,
password_hash: &Option<String>,
) -> bool {
// If neither password nor hash is set, no authentication required
if password.is_none() && password_hash.is_none() {
return true;
}
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
return check_bearer_auth(auth_str, password.as_deref().unwrap_or(""), password_hash)
|| check_basic_auth(auth_str, password.as_deref().unwrap_or(""), password_hash);
}
}
false
}
/// Middleware for logging requests and responses.
///
/// This middleware logs incoming requests and outgoing responses, including method,
@@ -830,14 +816,17 @@ pub async fn logging_middleware(
/// Creates authentication middleware for the application.
///
/// This function returns a middleware that enforces authentication on protected routes
/// using Bearer token or Basic Auth, challenging unauthorized requests with appropriate
/// headers.
/// This function returns a middleware that enforces authentication on protected routes.
/// When `jwt_secret` is set, it validates JWT tokens and checks permission claims
/// (read, write, delete) based on the HTTP method. Otherwise, it falls back to
/// Basic Auth password authentication.
///
/// # Arguments
///
/// * `username` - Optional username (defaults to "keep").
/// * `password` - Optional plain text password.
/// * `password_hash` - Optional hashed password.
/// * `jwt_secret` - Optional JWT secret for token-based authentication.
///
/// # Returns
///
@@ -846,13 +835,15 @@ pub async fn logging_middleware(
/// # Examples
///
/// ```
/// let auth_middleware = create_auth_middleware(Some("pass".to_string()), None);
/// let auth_middleware = create_auth_middleware(None, Some("pass".to_string()), None, None);
/// router.layer(auth_middleware);
/// ```
#[allow(clippy::type_complexity)]
pub fn create_auth_middleware(
username: Option<String>,
password: Option<String>,
password_hash: Option<String>,
jwt_secret: Option<String>,
) -> impl Fn(
ConnectInfo<SocketAddr>,
Request,
@@ -862,13 +853,63 @@ pub fn create_auth_middleware(
+ Clone
+ Send {
move |ConnectInfo(addr): ConnectInfo<SocketAddr>, request: Request, next: Next| {
let username = username.clone();
let password = password.clone();
let password_hash = password_hash.clone();
let jwt_secret = jwt_secret.clone();
Box::pin(async move {
let headers = request.headers().clone();
let uri = request.uri().clone();
let method = request.method().clone();
if !check_auth(&headers, &password, &password_hash) {
// CORS preflight requests pass through without authentication
if method == Method::OPTIONS {
return Ok(next.run(request).await);
}
// JWT authentication takes priority when secret is configured
if let Some(ref secret) = jwt_secret {
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if let Some(token) = auth_str.strip_prefix("Bearer ") {
match super::auth::validate_jwt(token, secret) {
Ok(claims) => {
let required = super::auth::required_permission(&method);
if !super::auth::check_permission(&claims, required) {
warn!(
"Forbidden: {method} {uri} from {addr} \
(sub={}, missing permission: {required})",
claims.sub
);
let mut response =
Response::new(axum::body::Body::from("Forbidden"));
*response.status_mut() = StatusCode::FORBIDDEN;
return Ok(response);
}
// JWT valid and authorized, proceed
let response = next.run(request).await;
return Ok(response);
}
Err(e) => {
warn!("JWT validation failed for {uri} from {addr}: {e}");
let mut response =
Response::new(axum::body::Body::from("Unauthorized"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
return Ok(response);
}
}
}
}
}
// JWT secret configured but no valid Bearer token provided
warn!("Missing JWT token for {uri} from {addr}");
let mut response = Response::new(axum::body::Body::from("Unauthorized"));
*response.status_mut() = StatusCode::UNAUTHORIZED;
return Ok(response);
}
// Fall back to Basic Auth password authentication
if !check_auth(&headers, &username, &password, &password_hash) {
warn!("Unauthorized request to {uri} from {addr}");
// Add WWW-Authenticate header to trigger basic auth in browsers
let mut response = Response::new(axum::body::Body::from("Unauthorized"));

View File

@@ -13,6 +13,7 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
mod api;
pub mod auth;
pub mod common;
#[cfg(feature = "mcp")]
mod mcp;
@@ -50,8 +51,10 @@ pub fn mode_server(
let server_config = common::ServerConfig {
address: server_address,
port: Some(server_port),
username: settings.server_username(),
password: settings.server_password(),
password_hash: settings.server_password_hash(),
jwt_secret: settings.server_jwt_secret(),
cert_file: settings.server_cert_file(),
key_file: settings.server_key_file(),
cors_origin: settings.server_cors_origin(),
@@ -119,9 +122,13 @@ async fn run_server(
protected_router = protected_router.merge(mcp_router);
}
let protected_router = protected_router.layer(axum::middleware::from_fn(
create_auth_middleware(config.password.clone(), config.password_hash.clone()),
));
let protected_router =
protected_router.layer(axum::middleware::from_fn(create_auth_middleware(
config.username.clone(),
config.password.clone(),
config.password_hash.clone(),
config.jwt_secret.clone(),
)));
// Build CORS layer - restricted by default, configurable via cors_origin setting
let cors_origin = config.cors_origin.as_deref().unwrap_or("http://localhost");
@@ -166,16 +173,16 @@ async fn run_server(
let addr: SocketAddr = bind_address.parse()?;
// Warn if password auth is enabled without TLS
if config.password.is_some() || config.password_hash.is_some() {
// Warn if authentication is enabled without TLS
if config.password.is_some() || config.password_hash.is_some() || config.jwt_secret.is_some() {
#[cfg(not(feature = "tls"))]
log::warn!(
"SECURITY: Password authentication enabled but TLS support is not compiled in. Password will be transmitted in plain text!"
"SECURITY: Authentication enabled but TLS support is not compiled in. Credentials will be transmitted in plain text!"
);
#[cfg(feature = "tls")]
if config.cert_file.is_none() || config.key_file.is_none() {
log::warn!(
"SECURITY: Password authentication enabled but TLS is not configured. Password will be transmitted in plain text!"
"SECURITY: Authentication enabled but TLS is not configured. Credentials will be transmitted in plain text!"
);
}
}

View File

@@ -1,92 +1,309 @@
#[cfg(all(test, feature = "server"))]
mod tests {
use crate::modes::server::auth::{Claims, check_permission, required_permission, validate_jwt};
use crate::modes::server::common::check_auth;
use axum::http::{HeaderMap, HeaderValue};
use axum::http::{HeaderMap, HeaderValue, Method};
use jsonwebtoken::{EncodingKey, Header, encode};
fn make_jwt(claims: &serde_json::Value, secret: &str) -> String {
let header = Header::new(jsonwebtoken::Algorithm::HS256);
encode(
&header,
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
fn make_basic_auth(username: &str, password: &str) -> String {
use base64::Engine;
let credentials = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
format!("Basic {encoded}")
}
// --- Password auth tests (Basic auth) ---
#[test]
fn test_auth_with_no_password_required() {
let headers = HeaderMap::new();
let password = None;
// When no password is required, auth should pass
assert!(check_auth(&headers, &password));
assert!(check_auth(&headers, &None, &None, &None));
}
#[test]
fn test_auth_with_bearer_token() {
fn test_auth_with_basic_auth_default_username() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_str(&make_basic_auth("keep", "secret123")).unwrap(),
);
let password = Some("secret123".to_string());
assert!(check_auth(&headers, &None, &password, &None));
}
#[test]
fn test_auth_with_basic_auth_custom_username() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_str(&make_basic_auth("admin", "secret123")).unwrap(),
);
let username = Some("admin".to_string());
let password = Some("secret123".to_string());
assert!(check_auth(&headers, &username, &password, &None));
}
#[test]
fn test_auth_with_basic_auth_wrong_password() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_str(&make_basic_auth("keep", "wrongpass")).unwrap(),
);
let password = Some("secret123".to_string());
assert!(!check_auth(&headers, &None, &password, &None));
}
#[test]
fn test_auth_with_basic_auth_wrong_username() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_str(&make_basic_auth("wrong", "secret123")).unwrap(),
);
let username = Some("admin".to_string());
let password = Some("secret123".to_string());
assert!(!check_auth(&headers, &username, &password, &None));
}
#[test]
fn test_auth_with_basic_auth_wrong_password_custom_username() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_str(&make_basic_auth("admin", "wrongpass")).unwrap(),
);
let username = Some("admin".to_string());
let password = Some("secret123".to_string());
assert!(!check_auth(&headers, &username, &password, &None));
}
#[test]
fn test_auth_bearer_token_ignored_for_password_auth() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer secret123"),
);
let password = Some("secret123".to_string());
// Valid bearer token should pass
assert!(check_auth(&headers, &password));
}
#[test]
fn test_auth_with_invalid_bearer_token() {
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer wrongtoken"),
);
let password = Some("secret123".to_string());
// Invalid bearer token should fail
assert!(!check_auth(&headers, &password));
}
#[test]
fn test_auth_with_basic_auth() {
let mut headers = HeaderMap::new();
// Basic auth for "keep:secret123" base64 encoded
headers.insert(
"authorization",
HeaderValue::from_static("Basic a2VlcDpzZWNyZXQxMjM="),
);
let password = Some("secret123".to_string());
// Valid basic auth should pass
assert!(check_auth(&headers, &password));
}
#[test]
fn test_auth_with_invalid_basic_auth() {
let mut headers = HeaderMap::new();
// Basic auth for "keep:wrongpass" base64 encoded
headers.insert(
"authorization",
HeaderValue::from_static("Basic a2VlcDp3cm9uZ3Bhc3M="),
);
let password = Some("secret123".to_string());
// Invalid basic auth should fail
assert!(!check_auth(&headers, &password));
// Bearer tokens are not checked for password auth — only Basic auth is valid
assert!(!check_auth(&headers, &None, &password, &None));
}
#[test]
fn test_auth_with_missing_auth_header() {
let headers = HeaderMap::new();
let password = Some("secret123".to_string());
// Missing auth header should fail when password is required
assert!(!check_auth(&headers, &password));
assert!(!check_auth(&headers, &None, &password, &None));
}
#[test]
fn test_auth_with_malformed_auth_header() {
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("Invalid header"));
let password = Some("secret123".to_string());
assert!(!check_auth(&headers, &None, &password, &None));
}
// Malformed auth header should fail
assert!(!check_auth(&headers, &password));
// --- JWT validation tests ---
#[test]
fn test_validate_jwt_valid_token() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize,
"read": true,
"write": true,
"delete": false
});
let token = make_jwt(&claims, secret);
let result = validate_jwt(&token, secret);
assert!(result.is_ok());
let claims = result.unwrap();
assert_eq!(claims.sub, "test-client");
assert!(claims.read);
assert!(claims.write);
assert!(!claims.delete);
}
#[test]
fn test_validate_jwt_expired() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 1000000000usize,
"read": true
});
let token = make_jwt(&claims, secret);
let result = validate_jwt(&token, secret);
assert!(result.is_err());
}
#[test]
fn test_validate_jwt_wrong_secret() {
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize,
"read": true
});
let token = make_jwt(&claims, "correct-secret");
let result = validate_jwt(&token, "wrong-secret");
assert!(result.is_err());
}
#[test]
fn test_validate_jwt_malformed() {
let result = validate_jwt("not.a.jwt", "secret");
assert!(result.is_err());
}
#[test]
fn test_validate_jwt_missing_permissions_default_false() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "test-client",
"exp": 9999999999usize
});
let token = make_jwt(&claims, secret);
let claims = validate_jwt(&token, secret).unwrap();
assert!(!claims.read);
assert!(!claims.write);
assert!(!claims.delete);
}
// --- Permission tests ---
#[test]
fn test_required_permission_mapping() {
assert_eq!(required_permission(&Method::GET), "read");
assert_eq!(required_permission(&Method::HEAD), "read");
assert_eq!(required_permission(&Method::POST), "write");
assert_eq!(required_permission(&Method::PUT), "write");
assert_eq!(required_permission(&Method::PATCH), "write");
assert_eq!(required_permission(&Method::DELETE), "delete");
}
#[test]
fn test_check_permission_granted() {
let claims = Claims {
sub: "test".to_string(),
exp: 9999999999,
read: true,
write: false,
delete: true,
};
assert!(check_permission(&claims, "read"));
assert!(!check_permission(&claims, "write"));
assert!(check_permission(&claims, "delete"));
}
#[test]
fn test_check_permission_unknown_denied() {
let claims = Claims {
sub: "test".to_string(),
exp: 9999999999,
read: true,
write: true,
delete: true,
};
assert!(!check_permission(&claims, "unknown"));
}
// --- End-to-end permission scenarios ---
#[test]
fn test_read_only_token_scenarios() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "readonly-client",
"exp": 9999999999usize,
"read": true,
"write": false,
"delete": false
});
let token = make_jwt(&claims, secret);
let claims = validate_jwt(&token, secret).unwrap();
// GET (read) should be allowed
assert!(check_permission(&claims, required_permission(&Method::GET)));
// POST (write) should be denied
assert!(!check_permission(
&claims,
required_permission(&Method::POST)
));
// DELETE should be denied
assert!(!check_permission(
&claims,
required_permission(&Method::DELETE)
));
}
#[test]
fn test_full_access_token_scenarios() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "admin",
"exp": 9999999999usize,
"read": true,
"write": true,
"delete": true
});
let token = make_jwt(&claims, secret);
let claims = validate_jwt(&token, secret).unwrap();
assert!(check_permission(&claims, required_permission(&Method::GET)));
assert!(check_permission(
&claims,
required_permission(&Method::POST)
));
assert!(check_permission(&claims, required_permission(&Method::PUT)));
assert!(check_permission(
&claims,
required_permission(&Method::DELETE)
));
}
#[test]
fn test_write_only_token_scenarios() {
let secret = "test-secret";
let claims = serde_json::json!({
"sub": "writer",
"exp": 9999999999usize,
"read": false,
"write": true,
"delete": false
});
let token = make_jwt(&claims, secret);
let claims = validate_jwt(&token, secret).unwrap();
assert!(!check_permission(
&claims,
required_permission(&Method::GET)
));
assert!(check_permission(
&claims,
required_permission(&Method::POST)
));
assert!(!check_permission(
&claims,
required_permission(&Method::DELETE)
));
}
}