From e672ec751eb600c13a9476c9ee2713205c1cdc91 Mon Sep 17 00:00:00 2001 From: Andrew Phillips Date: Fri, 13 Mar 2026 13:56:35 -0300 Subject: [PATCH] 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 --- Cargo.lock | 117 +++++++++-- Cargo.toml | 3 +- Dockerfile | 5 + README.md | 155 +++++++++++++- docker-compose.yml | 5 + src/args.rs | 24 +++ src/client.rs | 66 ++++-- src/compression_engine/gzip.rs | 4 +- src/compression_engine/mod.rs | 2 +- src/compression_engine/program.rs | 2 +- src/config.rs | 56 +++++ src/main.rs | 8 +- src/modes/client/save.rs | 5 +- src/modes/server/auth.rs | 231 ++++++++++++++++++++ src/modes/server/common.rs | 207 ++++++++++-------- src/modes/server/mod.rs | 21 +- src/tests/server/auth_tests.rs | 337 ++++++++++++++++++++++++------ 17 files changed, 1052 insertions(+), 196 deletions(-) create mode 100644 src/modes/server/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 2e93328..817b826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1520,6 +1521,23 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.16", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", +] + [[package]] name = "keep" version = "0.1.0" @@ -1547,6 +1565,7 @@ dependencies = [ "hyper", "inventory", "is-terminal", + "jsonwebtoken", "lazy_static", "libc", "local-ip-address", @@ -1850,10 +1869,29 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.1.0" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] [[package]] name = "num-traits" @@ -1952,6 +1990,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2172,7 +2220,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2353,7 +2401,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2410,18 +2458,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2547,6 +2605,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -2559,6 +2626,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.14", + "time", +] + [[package]] name = "slab" version = "0.4.11" @@ -2774,30 +2853,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -3074,6 +3153,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 08d6126..6a409bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ similar = { version = "2.7.0", default-features = false, features = ["text"] } ureq = { version = "3", features = ["json"], optional = true } os_pipe = { version = "1", optional = true } axum-server = { version = "0.8", features = ["tls-rustls"], optional = true } +jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] } [features] # Default features include core compression engines and swagger UI @@ -84,7 +85,7 @@ default = ["magic", "lz4", "gzip"] # Server feature (includes axum and related dependencies) -server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:utoipa"] +server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:utoipa", "dep:jsonwebtoken"] # Compression features gzip = ["flate2"] diff --git a/Dockerfile b/Dockerfile index 1c8b620..c7017e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,11 @@ ENV KEEP_LIST_FORMAT="id,time,size,tags,meta:hostname" # Server options ENV KEEP_SERVER_ADDRESS=0.0.0.0 ENV KEEP_SERVER_PORT=21080 +# ENV KEEP_SERVER_USERNAME="keep" # ENV KEEP_SERVER_PASSWORD="" # ENV KEEP_SERVER_PASSWORD_HASH="" +# ENV KEEP_SERVER_JWT_SECRET="" +# ENV KEEP_SERVER_JWT_SECRET_FILE=/config/jwt_secret # TLS options # ENV KEEP_SERVER_CERT=/certs/cert.pem @@ -57,6 +60,8 @@ ENV KEEP_SERVER_PORT=21080 # Client options # ENV KEEP_CLIENT_URL="" +# ENV KEEP_CLIENT_USERNAME="keep" # ENV KEEP_CLIENT_PASSWORD="" +# ENV KEEP_CLIENT_JWT="" ENTRYPOINT ["/keep"] diff --git a/README.md b/README.md index 48508d5..f936e34 100644 --- a/README.md +++ b/README.md @@ -351,12 +351,17 @@ KEEP_META_build=1234 echo "data" | keep --save tag --meta env=staging | `KEEP_LIST_FORMAT` | List column format | built-in defaults | | `KEEP_SERVER_ADDRESS` | Server bind address | `127.0.0.1` | | `KEEP_SERVER_PORT` | Server port | `21080` | +| `KEEP_SERVER_USERNAME` | Server Basic auth username | `keep` | | `KEEP_SERVER_PASSWORD` | Server password | none | | `KEEP_SERVER_PASSWORD_HASH` | Server password hash | none | +| `KEEP_SERVER_JWT_SECRET` | JWT secret for token auth | none | +| `KEEP_SERVER_JWT_SECRET_FILE` | Path to JWT secret file | none | | `KEEP_SERVER_CERT` | TLS certificate file path (PEM) | none | | `KEEP_SERVER_KEY` | TLS private key file path (PEM) | none | | `KEEP_CLIENT_URL` | Remote keep server URL | none | +| `KEEP_CLIENT_USERNAME` | Remote server username | `keep` | | `KEEP_CLIENT_PASSWORD` | Remote server password | none | +| `KEEP_CLIENT_JWT` | JWT token for remote server | none | Any config setting can be overridden with `KEEP__` environment variables (double underscore separator). @@ -409,7 +414,11 @@ meta_plugins: server: address: "127.0.0.1" port: 21080 + username: "keep" password: "secret" + # JWT authentication (takes priority over password) + # jwt_secret: "my-secret-key" + # jwt_secret_file: /path/to/jwt_secret # TLS (requires tls feature) # cert_file: /path/to/cert.pem # key_file: /path/to/key.pem @@ -417,7 +426,10 @@ server: # Client settings client: url: "http://localhost:21080" + username: "keep" password: "secret" + # Or use JWT token + # jwt: "eyJhbGciOiJIUzI1NiIs..." human_readable: true quiet: false @@ -444,10 +456,117 @@ keep --server # Custom address and port keep --server --server-address 0.0.0.0 --server-port 8080 -# With authentication +# With password authentication keep --server --server-password mypassword + +# With custom username +keep --server --server-username admin --server-password mypassword + +# With JWT authentication +keep --server --server-jwt-secret my-secret-key ``` +#### JWT Authentication + +JWT (JSON Web Token) authentication provides permission-based access control. When a JWT secret is configured, the server validates tokens and checks permission claims for each request. + +**Configuration:** + +```sh +# Via CLI flag +keep --server --server-jwt-secret my-secret-key + +# Via environment variable +export KEEP_SERVER_JWT_SECRET=my-secret-key +keep --server + +# Via config file (config.yml) +server: + jwt_secret: "my-secret-key" + +# Via secret file (for Docker/secrets management) +keep --server --server-jwt-secret-file /path/to/secret +``` + +**Token format:** + +JWTs must use HS256 algorithm with the following claims: + +| Claim | Type | Required | Description | +|-------|------|----------|-------------| +| `sub` | string | Yes | Subject (client identifier) | +| `exp` | number | Yes | Expiration time (Unix timestamp) | +| `read` | boolean | No | Permission for GET requests (default: false) | +| `write` | boolean | No | Permission for POST/PUT requests (default: false) | +| `delete` | boolean | No | Permission for DELETE requests (default: false) | + +**Permission mapping:** + +| HTTP Method | Required Permission | +|-------------|-------------------| +| `GET` | `read` | +| `POST`, `PUT`, `PATCH` | `write` | +| `DELETE` | `delete` | + +**Example token payload:** + +```json +{ + "sub": "ci-pipeline", + "exp": 1735689600, + "read": true, + "write": true, + "delete": false +} +``` + +**Generating tokens:** + +The server does not generate tokens — use any JWT library or tool: + +```sh +# Using jwt-cli (https://github.com/mike-engel/jwt-cli) +jwt encode --secret my-secret-key \ + --exp=$(date -d '+24 hours' +%s) \ + '{"sub":"my-client","read":true,"write":true,"delete":false}' + +# Using Python +python3 -c " +import jwt, time +token = jwt.encode({ + 'sub': 'my-client', + 'exp': int(time.time()) + 86400, + 'read': True, 'write': True, 'delete': False +}, 'my-secret-key', algorithm='HS256') +print(token) +" +``` + +**Using tokens:** + +```sh +# With curl +curl -H "Authorization: Bearer " http://localhost:21080/api/item/ + +# The keep client uses --client-jwt for JWT tokens +keep --client-url http://server:21080 --client-jwt --save my-tag +``` + +**Response codes:** + +| Code | Meaning | +|------|---------| +| `200` | Authorized | +| `401` | Missing, invalid, or expired token | +| `403` | Valid token but insufficient permissions | + +**Notes:** + +- When `jwt_secret` is set, password authentication is disabled — all requests must present a valid JWT Bearer token +- JWT and password authentication are mutually exclusive — when both `jwt_secret` and `password` are configured, only JWT is used +- Permission fields default to `false` if omitted — tokens must explicitly grant permissions +- JWT authentication requires the `server` feature (jsonwebtoken is included automatically) + #### HTTPS / TLS Build with the `tls` feature to enable HTTPS: @@ -533,9 +652,16 @@ cargo build --release --features client keep --client-url http://server:21080 --save my-tag export KEEP_CLIENT_URL=http://server:21080 -# With authentication +# With password authentication keep --client-url http://server:21080 --client-password mypassword --save my-tag export KEEP_CLIENT_PASSWORD=mypassword + +# With custom username +keep --client-url http://server:21080 --client-username admin --client-password mypassword --save my-tag + +# With JWT authentication +keep --client-url http://server:21080 --client-jwt --save my-tag +export KEEP_CLIENT_JWT= ``` #### How Client Mode Works @@ -608,15 +734,30 @@ keep --client-url http://logserver:21080 --list --meta project=myapp #### Authentication -```sh -# Bearer token -curl -H "Authorization: Bearer mypassword" http://localhost:21080/api/status +The server supports three authentication modes: -# Basic auth +**1. Password (HTTP Basic auth):** + +```sh +# Default username is "keep" curl -u keep:mypassword http://localhost:21080/api/status + +# Custom username +curl -u admin:mypassword http://localhost:21080/api/status ``` -When no password is configured, authentication is disabled. +**2. JWT (permission-based):** + +```sh +# Valid JWT with read permission allows GET requests +curl -H "Authorization: Bearer " http://localhost:21080/api/item/ +``` + +See [JWT Authentication](#jwt-authentication) for token format and configuration. + +**3. No authentication:** + +When neither password nor JWT secret is configured, authentication is disabled. #### Swagger UI diff --git a/docker-compose.yml b/docker-compose.yml index 7d71e3b..d2ff6c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,14 +9,19 @@ services: environment: - KEEP_SERVER_ADDRESS=0.0.0.0 - KEEP_SERVER_PORT=21080 + # - KEEP_SERVER_USERNAME=keep # - KEEP_SERVER_PASSWORD=changeme # - KEEP_SERVER_PASSWORD_HASH= + # - KEEP_SERVER_JWT_SECRET= + # - KEEP_SERVER_JWT_SECRET_FILE=/config/jwt_secret # - KEEP_COMPRESSION=lz4 # - KEEP_META_PLUGINS= # - KEEP_FILTERS= - KEEP_CONFIG=/config/config.yml # - KEEP_SERVER_CERT=/certs/cert.pem # - KEEP_SERVER_KEY=/certs/key.pem + # - KEEP_CLIENT_USERNAME=keep + # - KEEP_CLIENT_JWT="" restart: unless-stopped # For TLS, mount certificate files: # volumes: diff --git a/src/args.rs b/src/args.rs index b53679a..2cb754e 100644 --- a/src/args.rs +++ b/src/args.rs @@ -151,6 +151,20 @@ pub struct OptionsArgs { #[arg(help("Password hash for server authentication (requires --server)"))] pub server_password_hash: Option, + #[arg(long, env("KEEP_SERVER_USERNAME"))] + #[arg(help( + "Username for server Basic authentication (requires --server, defaults to 'keep')" + ))] + pub server_username: Option, + + #[arg(long, env("KEEP_SERVER_JWT_SECRET"))] + #[arg(help("JWT secret for token-based authentication (requires --server)"))] + pub server_jwt_secret: Option, + + #[arg(long, env("KEEP_SERVER_JWT_SECRET_FILE"))] + #[arg(help("Path to file containing JWT secret (requires --server)"))] + pub server_jwt_secret_file: Option, + #[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, + #[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, + + #[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, + #[arg( long, help("Force output even when binary data would be sent to a TTY") diff --git a/src/client.rs b/src/client.rs index 0143c22..4539ab4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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, password: Option, + jwt: Option, } impl KeepClient { - pub fn new(base_url: &str, password: Option) -> Result { + pub fn new( + base_url: &str, + username: Option, + password: Option, + jwt: Option, + ) -> Result { 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 `. + /// Password is sent as `Basic base64(username:password)` + /// where username defaults to "keep". + fn auth_header(&self) -> Option { + 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(&self, result: Result) -> Result { match result { Ok(v) => Ok(v), @@ -57,8 +93,8 @@ impl KeepClient { pub fn get_json(&self, path: &str) -> Result { 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, 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())?; diff --git a/src/compression_engine/gzip.rs b/src/compression_engine/gzip.rs index 70e6708..80fa973 100644 --- a/src/compression_engine/gzip.rs +++ b/src/compression_engine/gzip.rs @@ -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; diff --git a/src/compression_engine/mod.rs b/src/compression_engine/mod.rs index a05647c..4cc39cb 100644 --- a/src/compression_engine/mod.rs +++ b/src/compression_engine/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use std::io; use std::io::{Read, Write}; use std::path::PathBuf; diff --git a/src/compression_engine/program.rs b/src/compression_engine/program.rs index bf99a65..506cd8e 100644 --- a/src/compression_engine/program.rs +++ b/src/compression_engine/program.rs @@ -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}; diff --git a/src/config.rs b/src/config.rs index d062a07..bcb7fe7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,9 +143,12 @@ impl<'de> serde::Deserialize<'de> for ColumnConfig { pub struct ServerConfig { pub address: Option, pub port: Option, + pub username: Option, pub password_file: Option, pub password: Option, pub password_hash: Option, + pub jwt_secret: Option, + pub jwt_secret_file: Option, pub cert_file: Option, pub key_file: Option, pub cors_origin: Option, @@ -159,7 +162,9 @@ pub struct CompressionPluginConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ClientConfig { pub url: Option, + pub username: Option, pub password: Option, + pub jwt: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -198,7 +203,11 @@ pub struct Settings { #[serde(skip)] pub client_url: Option, #[serde(skip)] + pub client_username: Option, + #[serde(skip)] pub client_password: Option, + #[serde(skip)] + pub client_jwt: Option, } 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 { + 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> { + 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 { + self.get_server_jwt_secret().ok().flatten() + } + pub fn server_address(&self) -> Option { self.server.as_ref().and_then(|s| s.address.clone()) } diff --git a/src/main.rs b/src/main.rs index ad22cd0..8a94834 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 => { diff --git a/src/modes/client/save.rs b/src/modes/client/save.rs index 4fa0f32..211a7e8 100644 --- a/src/modes/client/save.rs +++ b/src/modes/client/save.rs @@ -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 { - 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()), diff --git a/src/modes/server/auth.rs b/src/modes/server/auth.rs new file mode 100644 index 0000000..57d0f67 --- /dev/null +++ b/src/modes/server/auth.rs @@ -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 { + 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 = decode::( + 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); + } +} diff --git a/src/modes/server/common.rs b/src/modes/server/common.rs index fb31935..e57bbbf 100644 --- a/src/modes/server/common.rs +++ b/src/modes/server/common.rs @@ -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, + /// Optional authentication username. + /// + /// Username for Basic authentication. Defaults to "keep" when not specified. + pub username: Option, /// 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, /// 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, + /// 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, /// 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>, } -/// 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, +/// * `true` - If authorized (or no auth required). +/// * `false` - If unauthorized. +pub fn check_auth( + headers: &HeaderMap, + username: &Option, + password: &Option, + password_hash: &Option, ) -> 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, ) -> 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, - password_hash: &Option, -) -> 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, password: Option, password_hash: Option, + jwt_secret: Option, ) -> impl Fn( ConnectInfo, Request, @@ -862,13 +853,63 @@ pub fn create_auth_middleware( + Clone + Send { move |ConnectInfo(addr): ConnectInfo, 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")); diff --git a/src/modes/server/mod.rs b/src/modes/server/mod.rs index 5accb25..34d7ad7 100644 --- a/src/modes/server/mod.rs +++ b/src/modes/server/mod.rs @@ -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!" ); } } diff --git a/src/tests/server/auth_tests.rs b/src/tests/server/auth_tests.rs index f13f16d..44ad925 100644 --- a/src/tests/server/auth_tests.rs +++ b/src/tests/server/auth_tests.rs @@ -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) + )); } }