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:
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user