Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,26 @@ Expose decrypted secrets as an HTTP JSON API — perfect for MCP servers, CI/CD
rh-envault serve --port 8080

# Custom host and password
rh-envault serve --host 0.0.0.0 --port 3000 --password your-master-key
rh-envault serve --host 0.0.0.0 --port 3000 --password your-master-key --api-token my-token
```

Endpoints:
- `GET /health` — store connectivity check (no auth required)
- `GET /auth/info` — discover configured authentication methods (no auth required)
- `GET /secrets` — list all secret keys, optional `?prefix=X` filter (auth required)
- `GET /secrets/{key}` — get decrypted value for a key (auth required)

Authentication: Bearer token in the `Authorization` header. The token is the SHA-256 hex digest of your encryption key.
Authentication: set `--api-token` or `ENVAULT_API_TOKEN` for `Authorization: Bearer <token>`, or set `--api-key` or `ENVAULT_API_KEY` for `X-API-Key: <key>`. API credentials are separate from the encryption password. Without credentials, secrets routes are unauthenticated and binding is limited to localhost.

```bash
# List all secrets
curl -H "Authorization: Bearer <sha256-of-encrypt-key>" http://localhost:8080/secrets
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets

# Filter by prefix
curl -H "Authorization: Bearer <sha256-of-encrypt-key>" "http://localhost:8080/secrets?prefix=STRIPE"
curl -H "Authorization: Bearer my-token" "http://localhost:8080/secrets?prefix=STRIPE"

# Get a specific secret
curl -H "Authorization: Bearer <sha256-of-encrypt-key>" http://localhost:8080/secrets/DB_PASSWORD
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets/DB_PASSWORD
```

### `rh-envault audit`
Expand All @@ -156,7 +157,7 @@ Start an HTTP server that exposes decrypted secrets as a JSON API — ideal for
rh-envault serve

# Custom port, host, and API key
rh-envault serve --port 3000 --host 0.0.0.0 --api-key my-bearer-token
rh-envault serve --port 3000 --host 0.0.0.0 --api-key my-api-key

# Use a named store from config
rh-envault serve --store production-secrets
Expand All @@ -167,23 +168,25 @@ rh-envault serve --store production-secrets
| Endpoint | Auth | Description |
|----------|------|-------------|
| `GET /health` | No | Store connectivity check |
| `GET /auth/info` | No | Configured authentication methods |
| `GET /secrets` | Yes | List all secret keys (filter with `?prefix=X`) |
| `GET /secrets/{key}` | Yes | Get decrypted value for a key |

**Security:**
- Defaults to `127.0.0.1` (localhost only) — use `--host 0.0.0.0` only behind a firewall or reverse proxy
- Set `--api-key` or `ENVAULT_API_KEY` env var to require Bearer token auth on `/secrets` endpoints
- Set `--api-key` or `ENVAULT_API_KEY` for `X-API-Key` authentication, or `--api-token` or `ENVAULT_API_TOKEN` for Bearer authentication on `/secrets` endpoints
- Without API credentials, secrets endpoints are unauthenticated and binding is limited to localhost
- No built-in TLS — run behind a reverse proxy (nginx, Caddy) for HTTPS in production

```bash
# Fetch secrets with curl
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets
curl -H "X-API-Key: my-api-key" http://localhost:8080/secrets

# Filter by prefix
curl -H "Authorization: Bearer my-token" "http://localhost:8080/secrets?prefix=STRIPE"
curl -H "X-API-Key: my-api-key" "http://localhost:8080/secrets?prefix=STRIPE"

# Get a specific secret
curl -H "Authorization: Bearer my-token" http://localhost:8080/secrets/DB_PASSWORD
curl -H "X-API-Key: my-api-key" http://localhost:8080/secrets/DB_PASSWORD
```

## Features
Expand Down
6 changes: 4 additions & 2 deletions src/envault/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ def serve(
"-k",
help="Encryption password (prompted if omitted, or use ENVAULT_ENCRYPT_KEY)",
),
api_key: str | None = typer.Option(None, "--api-key", help="Bearer token for API auth (or set ENVAULT_API_KEY)"),
api_key: str | None = typer.Option(None, "--api-key", help="X-API-Key header credential (or set ENVAULT_API_KEY)"),
store: str | None = typer.Option(None, "--store", "-s", help="Named store from config to use"),
config_path: str = typer.Option("", "--config", "-c", help="Config file path"),
api_token: str | None = typer.Option(
Expand All @@ -760,7 +760,8 @@ def serve(

Security:
- Default bind is 127.0.0.1 (localhost only); use --host 0.0.0.0 to expose.
- Set --api-key or ENVAULT_API_KEY to require Bearer token auth on /secrets.
- Set --api-key or ENVAULT_API_KEY for X-API-Key header authentication.
- Set --api-token or ENVAULT_API_TOKEN for Authorization: Bearer authentication.
"""
config = load_config(config_path)
run_server(
Expand All @@ -770,6 +771,7 @@ def serve(
encrypt_key=password,
store_name=store,
api_key=api_key,
api_token=api_token,
)


Expand Down
49 changes: 36 additions & 13 deletions src/envault/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@

Security:
- Default bind address is 127.0.0.1 (localhost only).
- If --api-key is provided, all endpoints (except /health) require
an Authorization: Bearer <api-key> header. Requests without a
valid token receive 401 Unauthorized.
- If --api-key is not provided, a warning is printed at startup
recommending authentication for production use.
- When API credentials are configured, secret endpoints require credentials
accepted by the selected auth mode. /health and /auth/info remain public.
- Configure authentication using ENVAULT_API_KEY, ENVAULT_API_TOKEN, or an
OAuth2 endpoint. Without credentials, binding is limited to localhost.
"""

from __future__ import annotations
Expand Down Expand Up @@ -82,6 +81,9 @@ def _check_bearer_token(self) -> bool:
return False

token = auth_header[len("Bearer ") :]
if not token:
self._send_error(401, "Unauthorized: Bearer token required")
return False

# If OAuth2 introspection URL is configured, validate via introspection
if self.oauth_introspect_url:
Expand All @@ -92,7 +94,7 @@ def _check_bearer_token(self) -> bool:
return self._oauth2_userinfo(token)

# Otherwise, fall back to static token check
if token != (self.api_token or ""):
if not self.api_token or token != self.api_token:
self._send_error(401, "Unauthorized: invalid Bearer token")
return False

Expand Down Expand Up @@ -487,10 +489,20 @@ def run_server(
store_name : str | None
Named store from config to use; if *None* the default store is used.
api_key : str | None
Bearer token for API authentication. If provided, all /secrets
endpoints require an Authorization: Bearer <api-key> header.
If *None*, the ENVAULT_API_KEY env var is checked; if that is also
unset, auth is disabled (with a warning).
Credential for X-API-Key authentication. If *None*, read ENVAULT_API_KEY.
The selected auth mode determines which header is accepted.
api_token : str | None
Static Bearer token. If *None*, read ENVAULT_API_TOKEN.
oauth_introspect_url : str | None
OAuth2 introspection endpoint, or read ENVAULT_OAUTH_INTROSPECT_URL.
oauth_userinfo_url : str | None
OAuth2 userinfo endpoint, or read ENVAULT_OAUTH_USERINFO_URL.
oauth_client_id : str | None
OAuth2 client ID, or read ENVAULT_OAUTH_CLIENT_ID.
oauth_client_secret : str | None
OAuth2 client secret, or read ENVAULT_OAUTH_CLIENT_SECRET.
auth_mode : str
Credential mode: bearer, api-key, oauth2, or any.
"""

# Resolve encryption key (same auth model as decrypt command)
Expand Down Expand Up @@ -555,7 +567,18 @@ def run_server(
else:
store_instance = get_store("")

handler_class = create_handler(store_instance, config, encrypt_key, resolved_api_key)
handler_class = create_handler(
store=store_instance,
config=config,
encrypt_key=encrypt_key,
api_key=resolved_api_key,
api_token=api_token,
oauth_introspect_url=oauth_introspect_url,
oauth_userinfo_url=oauth_userinfo_url,
oauth_client_id=oauth_client_id,
oauth_client_secret=oauth_client_secret,
auth_mode=auth_mode,
)
server = HTTPServer((host, port), handler_class)

from rich.console import Console
Expand All @@ -566,8 +589,8 @@ def run_server(
console.print(" GET /secrets?prefix=X — filter keys by prefix")
console.print(" GET /secrets/{key} — get decrypted value")
console.print(" GET /health — store connectivity check")
if resolved_api_key:
console.print("[green]🔒[/green] API authentication enabled (Bearer token required)")
if has_any_auth:
console.print("[green]🔒[/green] API authentication enabled")
else:
console.print("[yellow]⚠[/yellow] No API key set — secrets endpoints are unauthenticated!")
console.print("[dim] Set --api-key flag or ENVAULT_API_KEY env var to enable auth[/dim]")
Expand Down
1 change: 1 addition & 0 deletions tests/test_auth_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
cache expiry, scope/audience validation, error paths), MultiAuth fallback
logic, and build_auth_from_env factory.
"""

from __future__ import annotations

import json
Expand Down
2 changes: 1 addition & 1 deletion tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_parse_env_content_basic():


def test_parse_env_content_strips_symmetric_quotes():
content = 'A="quoted"\nB=' + "'single'\n" + "C=un\"matched\n"
content = 'A="quoted"\nB=' + "'single'\n" + 'C=un"matched\n'
parsed = _parse_env_content(content)
assert parsed["A"] == "quoted"
assert parsed["B"] == "single"
Expand Down
Loading
Loading