Skip to content
3 changes: 2 additions & 1 deletion src/envault/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import time
from typing import Any
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


Expand Down Expand Up @@ -164,7 +165,7 @@ def _introspect(self, token: str) -> AuthResult:
import base64

url = f"{self._provider_url}/introspect"
body = f"token={token}".encode()
body = urlencode({"token": token}).encode()
headers: dict[str, str] = {
"Content-Type": "application/x-www-form-urlencoded",
}
Expand Down
22 changes: 19 additions & 3 deletions src/envault/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,32 @@ def _get_backup_dir(project_dir: Path | str = ".") -> Path:


def _load_manifest(backup_dir: Path) -> list[BackupEntry]:
"""Load the backup manifest from disk."""
"""Load the backup manifest from disk.

Skips individual corrupt entries rather than discarding the entire
manifest, preserving valid backups when one entry is malformed.
"""
manifest_path = backup_dir / BACKUP_MANIFEST
if not manifest_path.exists():
return []
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
return [BackupEntry.from_dict(entry) for entry in data]
except (json.JSONDecodeError, KeyError):
except json.JSONDecodeError:
return []

if not isinstance(data, list):
return []

entries: list[BackupEntry] = []
for entry in data:
if not isinstance(entry, dict):
continue
try:
entries.append(BackupEntry.from_dict(entry))
except (KeyError, TypeError):
continue
return entries


def _save_manifest(backup_dir: Path, entries: list[BackupEntry]) -> None:
"""Save the backup manifest to disk."""
Expand Down
34 changes: 3 additions & 31 deletions src/envault/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import base64
import json
import os
import secrets as _secrets
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
Expand Down Expand Up @@ -69,36 +68,6 @@ def _send_error(self, status: int, message: str) -> None:
"""Send a JSON error payload."""
self._send_json({"error": message}, status=status)

def _check_auth(self) -> bool:
"""Validate the Bearer token if API auth is enabled.

Returns True if the request is authorized (or auth is disabled).
Returns False if auth is required but missing/invalid (and sends 401).
"""
if not self.api_key:
# Auth not configured — allow all requests
return True

auth_header = self.headers.get("Authorization", "")
if not auth_header:
self._send_error(401, "Unauthorized: valid Bearer token required")
return False

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

if (
_secrets.compare_digest(token.strip(), self.api_key)
if self.api_key
else _secrets.compare_digest(token.strip(), "")
):
return True

self._send_error(401, "Unauthorized: valid Bearer token required")
return False

# ── Routing ──────────────────────────────────────────────────────────────

def _check_bearer_token(self) -> bool:
Expand Down Expand Up @@ -330,6 +299,9 @@ def do_GET(self) -> None: # noqa: N802 -- stdlib naming convention
if path == "/health":
# /health is always accessible (useful for load balancers)
self._handle_health()
elif path == "/auth/info":
# /auth/info is always accessible so clients can discover auth methods
self._handle_auth_info()
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
elif path == "/secrets":
if not self._check_auth():
return
Expand Down
10 changes: 8 additions & 2 deletions src/envault/stores/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,10 @@ def _api_post(self, path: str, data: dict) -> bool:
return resp.status_code in (200, 201)

def get(self, key: str) -> str | None:
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{key}%22")
from urllib.parse import quote

encoded_key = quote(key, safe="")
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{encoded_key}%22")
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
if not items:
return None
item_list = items if isinstance(items, list) else items.get("items", [])
Expand All @@ -370,9 +373,12 @@ def set(self, key: str, value: str) -> bool:
return self._api_post(f"/v1/vaults/{self.vault_id}/items", payload)

def delete(self, key: str) -> bool:
from urllib.parse import quote

import requests

items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{key}%22")
encoded_key = quote(key, safe="")
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{encoded_key}%22")
if not items:
return False
item_list = items if isinstance(items, list) else items.get("items", [])
Expand Down
37 changes: 37 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

import json

from envault.auth import OAuth2Auth


def test_oauth2_introspection_url_encodes_reserved_token_characters(monkeypatch):
captured: dict[str, object] = {}

class _Response:
status = 200

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, traceback):
return False

def read(self):
return json.dumps({"active": True, "sub": "synthetic-user"}).encode()

def fake_urlopen(request, timeout):
captured["request"] = request
captured["timeout"] = timeout
return _Response()

monkeypatch.setattr("envault.auth.urlopen", fake_urlopen)

result = OAuth2Auth(provider_url="https://identity.example", strategy="introspect").check(
{"Authorization": "Bearer token+with&reserved=value"}
)

assert result.success
request = captured["request"]
assert request.data == b"token=token%2Bwith%26reserved%3Dvalue"
assert captured["timeout"] == 10
Loading
Loading