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

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