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:
231
src/modes/server/auth.rs
Normal file
231
src/modes/server/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user