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:
117
Cargo.lock
generated
117
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
155
README.md
155
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__<SETTING>` 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 <jwt-token>" http://localhost:21080/api/item/
|
||||
|
||||
# The keep client uses --client-jwt for JWT tokens
|
||||
keep --client-url http://server:21080 --client-jwt <jwt-token> --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 <jwt-token> --save my-tag
|
||||
export KEEP_CLIENT_JWT=<jwt-token>
|
||||
```
|
||||
|
||||
#### 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 <jwt-token>" 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
src/args.rs
24
src/args.rs
@@ -151,6 +151,20 @@ pub struct OptionsArgs {
|
||||
#[arg(help("Password hash for server authentication (requires --server)"))]
|
||||
pub server_password_hash: Option<String>,
|
||||
|
||||
#[arg(long, env("KEEP_SERVER_USERNAME"))]
|
||||
#[arg(help(
|
||||
"Username for server Basic authentication (requires --server, defaults to 'keep')"
|
||||
))]
|
||||
pub server_username: Option<String>,
|
||||
|
||||
#[arg(long, env("KEEP_SERVER_JWT_SECRET"))]
|
||||
#[arg(help("JWT secret for token-based authentication (requires --server)"))]
|
||||
pub server_jwt_secret: Option<String>,
|
||||
|
||||
#[arg(long, env("KEEP_SERVER_JWT_SECRET_FILE"))]
|
||||
#[arg(help("Path to file containing JWT secret (requires --server)"))]
|
||||
pub server_jwt_secret_file: Option<PathBuf>,
|
||||
|
||||
#[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<String>,
|
||||
|
||||
#[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<String>,
|
||||
|
||||
#[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<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help("Force output even when binary data would be sent to a TTY")
|
||||
|
||||
@@ -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<String>,
|
||||
password: Option<String>,
|
||||
jwt: Option<String>,
|
||||
}
|
||||
|
||||
impl KeepClient {
|
||||
pub fn new(base_url: &str, password: Option<String>) -> Result<Self, CoreError> {
|
||||
pub fn new(
|
||||
base_url: &str,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
jwt: Option<String>,
|
||||
) -> Result<Self, CoreError> {
|
||||
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 <token>`.
|
||||
/// Password is sent as `Basic base64(username:password)`
|
||||
/// where username defaults to "keep".
|
||||
fn auth_header(&self) -> Option<String> {
|
||||
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<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
|
||||
match result {
|
||||
Ok(v) => Ok(v),
|
||||
@@ -57,8 +93,8 @@ impl KeepClient {
|
||||
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, 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 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<Vec<u8>, 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())?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::io;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -143,9 +143,12 @@ impl<'de> serde::Deserialize<'de> for ColumnConfig {
|
||||
pub struct ServerConfig {
|
||||
pub address: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub username: Option<String>,
|
||||
pub password_file: Option<PathBuf>,
|
||||
pub password: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub jwt_secret: Option<String>,
|
||||
pub jwt_secret_file: Option<PathBuf>,
|
||||
pub cert_file: Option<PathBuf>,
|
||||
pub key_file: Option<PathBuf>,
|
||||
pub cors_origin: Option<String>,
|
||||
@@ -159,7 +162,9 @@ pub struct CompressionPluginConfig {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ClientConfig {
|
||||
pub url: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub jwt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -198,7 +203,11 @@ pub struct Settings {
|
||||
#[serde(skip)]
|
||||
pub client_url: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub client_username: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub client_password: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub client_jwt: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<Option<String>> {
|
||||
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<String> {
|
||||
self.get_server_jwt_secret().ok().flatten()
|
||||
}
|
||||
|
||||
pub fn server_address(&self) -> Option<String> {
|
||||
self.server.as_ref().and_then(|s| s.address.clone())
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<ItemInfo> {
|
||||
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()),
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u16>,
|
||||
/// Optional authentication username.
|
||||
///
|
||||
/// Username for Basic authentication. Defaults to "keep" when not specified.
|
||||
pub username: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// * `true` - If authorized (or no auth required).
|
||||
/// * `false` - If unauthorized.
|
||||
pub fn check_auth(
|
||||
headers: &HeaderMap,
|
||||
username: &Option<String>,
|
||||
password: &Option<String>,
|
||||
password_hash: &Option<String>,
|
||||
) -> 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<String>,
|
||||
) -> 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
|
||||
return bool::from(
|
||||
provided_password
|
||||
.as_bytes()
|
||||
.ct_eq(expected_credentials.as_bytes())
|
||||
.into();
|
||||
.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<String>,
|
||||
password_hash: &Option<String>,
|
||||
) -> 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<String>,
|
||||
password: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
jwt_secret: Option<String>,
|
||||
) -> impl Fn(
|
||||
ConnectInfo<SocketAddr>,
|
||||
Request,
|
||||
@@ -862,13 +853,63 @@ pub fn create_auth_middleware(
|
||||
+ Clone
|
||||
+ Send {
|
||||
move |ConnectInfo(addr): ConnectInfo<SocketAddr>, 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"));
|
||||
|
||||
@@ -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!"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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