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

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