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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ KAPPARI_EMAIL=your_email@example.com
# Used to generate JWT tokens for API access
KAPPARI_PASSWORD=your_password_here

# iOS users: the local database has no purchases table (the license lives in
# the system keychain), so license decryption is not possible. You can still
# authenticate with email and password only, which needs no database:
# from kappari import Auth
# auth = Auth()
# token = auth.authenticate_password_only()
# In this case KAPPARI_ROOT_DIR / KAPPARI_DB_FILE can be left unset.

# Device ID for license validation
# Must match the device UUID where Paprika 3 was originally licensed
# Used for RSA signature verification of license data stored in database
Expand Down
35 changes: 17 additions & 18 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,36 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.11'

enable-cache: true

- name: Set up Python
run: uv python install 3.11

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

run: uv sync --frozen

- name: Run tests with markdown report
run: |
pytest -v -m "not requires_database and not requires_credentials and not requires_network" \
uv run pytest -v -m "not requires_database and not requires_credentials and not requires_network" \
--md-report --md-report-flavor gfm --md-report-output test_results.md
# Skip tests that require external dependencies (database files, credentials, network access)
# Generate markdown report for GitHub job summary display


- name: Add test results to job summary
if: always()
run: |
if [ -f "test_results.md" ]; then
echo "## 📊 Test Results" >> $GITHUB_STEP_SUMMARY
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat test_results.md >> $GITHUB_STEP_SUMMARY
else
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "No test results file generated" >> $GITHUB_STEP_SUMMARY
fi

- name: Run ruff check
run: ruff check .
run: uv run ruff check .

- name: Run ruff format check
run: ruff format --check .
run: uv run ruff format --check .
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ This project provides **documentation and working code**. You can use it to unde

2. **Install for development:**
```bash
pip install -e ".[dev]"
uv sync
# or, with pip (requires pip 25.1+ for --group):
# pip install -e . --group dev
```

3. **Run tests:**
Expand Down
21 changes: 21 additions & 0 deletions authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Paprika uses a dual-layer authentication system: local RSA signature validation of license data followed by server authentication that returns a JWT token for API access. Licenses are bound to specific devices via machine-specific identifiers.

**Note:** The RSA-signed license is not actually required to obtain a token. The `v1` login endpoint (`POST /api/v1/account/login/`) accepts email and password alone and returns a token that works against the `v2` sync endpoints. See [Password-Only Authentication](#password-only-authentication) below. This matters on iOS, where the license is stored in the system keychain rather than the local SQLite database, so the `purchases` table is absent.

## Complete Authentication Flow

### Step 1: License Data Collection
Expand Down Expand Up @@ -130,6 +132,25 @@ This token is used in all subsequent API requests:
Authorization: Bearer JWT-TOKEN-HERE
```

## Password-Only Authentication

The license data and RSA signature are not strictly required to obtain a token. The older `v1` login endpoint authenticates with just email and password, and the token it returns is accepted by the `v2` sync endpoints.

```bash
curl -s -F "email=you@example.com" -F "password=YOUR_PASSWORD" \
-H "User-Agent: Paprika Recipe Manager 3/3.3.1 (iPhone; iOS 17.0)" \
"https://www.paprikaapp.com/api/v1/account/login/"
# -> {"result": {"token": "..."}}
```

Notes on behavior observed:

- `POST /api/v1/account/login/` accepts password-only login regardless of `User-Agent`.
- `POST /api/v2/account/login/` rejects password-only login with `{"error": {"message": "Invalid purchase receipt."}}` unless the `User-Agent` identifies a mobile client. Sending empty `data`/`signature` fields triggers the same error, so they must be omitted entirely. The simplest portable approach is to log in via `v1`.
- The token returned works for `GET /api/v2/sync/recipes/` and other authenticated endpoints.

This is the recommended path on iOS, where the license is stored in the system keychain rather than the SQLite database, so the `purchases` table needed for license decryption does not exist.

## Implementation Details

### API Endpoints
Expand Down
4 changes: 3 additions & 1 deletion crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ For the signature field specifically, after decryption the implementation checks

## Server Authentication Cryptography

The server authentication process requires both the decrypted license data and the decrypted RSA signature. The license data must be a valid JSON structure containing the user's license information, and the RSA signature must be a valid cryptographic signature of this license data using Paprika's private key (which was used by Paprika's servers during original license creation).
The license-based authentication process uses both the decrypted license data and the decrypted RSA signature. The license data must be a valid JSON structure containing the user's license information, and the RSA signature must be a valid cryptographic signature of this license data using Paprika's private key (which was used by Paprika's servers during original license creation).

**Note:** The RSA signature is not mandatory for obtaining a token. The `v1` login endpoint accepts email and password alone, and that token works against the `v2` sync endpoints. See [Password-Only Authentication](authentication.md#password-only-authentication). The license-based flow described here is what the desktop client uses, but it is not the only way in.

When authenticating with the server, a multipart form POST request is sent to `https://www.paprikaapp.com/api/v2/account/login/`. This request contains four fields: `email` (user's account email), `password` (user's account password), `data` (the decrypted license JSON as a string), and `signature` (the decrypted RSA signature, Base64-encoded if necessary). The multipart encoding follows a specific format where Content-Type headers come before Content-Disposition headers, and field names are unquoted.

Expand Down
31 changes: 29 additions & 2 deletions kappari/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ def decrypt_license_data(self):
"""Decrypt license data from SQLite database"""
log.debug("Starting license data decryption")

if not self.config.db_file:
log.error("No database configured for license decryption")
raise Exception(
"No database configured. Set KAPPARI_DB_FILE, or use "
"authenticate_password_only() which needs no database."
)

if not Path(self.config.db_file).exists():
log.error("Database not found: %s", self.config.db_file)
raise Exception(f"Database not found: {self.config.db_file}")
Expand Down Expand Up @@ -121,12 +128,32 @@ def _decrypt_data(self, encrypted_b64, password):
except Exception as e:
return f"Decryption failed: {e}"

def authenticate_password_only(self, email=None, password=None):
"""Authenticate with email and password only, no license required"""
email = email or self.config.email
password = password or self.config.password

if not email or not password:
raise Exception(
"Email and password required. "
"Set KAPPARI_EMAIL and KAPPARI_PASSWORD or pass them in."
)

log.info("Attempting password-only authentication: %s", email)

jwt_token = self.client.authenticate(email, password)

if jwt_token:
self.config.update_jwt_token(jwt_token)

return jwt_token

def authenticate(self, email, password):
"""Authenticate with server and get JWT token"""
if not self.license_data or not self.signature:
raise Exception(
"License data not decrypted. "
"Call decrypt_license_data() first."
"License data not decrypted. Call decrypt_license_data() "
"first, or use authenticate_password_only()."
)

# The license data needs to be sent as JSON
Expand Down
16 changes: 11 additions & 5 deletions kappari/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,21 @@ def _setup_database(self):
"KAPPARI_DB_BACKUP_DIR", "Database/Backups", None
)

# Validate required fields
# A database is not required for password-only authentication (e.g.
# iOS, where the local database has no purchases table). Operations
# that need it (license decryption) validate db_file themselves.
if not self.db_file:
raise ValueError(
"Database file could not be determined. "
"Set KAPPARI_ROOT_DIR or KAPPARI_DB_FILE in your .env file"
log.info(
"No database configured. License decryption is unavailable; "
"password-only authentication still works."
)

# Try to load email from database if not in env
if self._try_load_email_from_db and Path(self.db_file).exists():
if (
self._try_load_email_from_db
and self.db_file
and Path(self.db_file).exists()
):
self._load_email_from_database()

def _setup_api_config(self):
Expand Down
118 changes: 89 additions & 29 deletions kappari/network_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def _log_request(
for key, value in data.items():
if isinstance(value, tuple) and len(value) == 2:
# Multipart form data format (filename, content)
filename, content = value
_filename, content = value
if key in ["password", "signature"]:
log.debug(
" %s: [HIDDEN - %d chars]",
Expand Down Expand Up @@ -121,7 +121,7 @@ def _build_multipart_body(

for field_name, field_value in files.items():
if isinstance(field_value, tuple) and len(field_value) == 2:
filename, content = field_value
_filename, content = field_value
value = str(content)
else:
value = str(field_value)
Expand Down Expand Up @@ -283,21 +283,65 @@ def get(
log.error("GET request failed: %s", e)
return None

def _parse_login_response(self, response) -> Optional[str]:
"""Extract a JWT token from a login response, or None on failure."""
if response is None:
# Dry run or request failed
return None

log.info("Server response: %d", response.status_code)

if response.status_code == 200:
try:
result = response.json()
if "error" in result:
log.error("Authentication error: %s", result["error"])
return None
if "result" in result and "token" in result["result"]:
jwt_token = result["result"]["token"]
log.info("Authentication successful")
return jwt_token
log.error("Unexpected response format: %s", result)
return None
except json.JSONDecodeError:
log.error("Non-JSON response: %s", response.text)
return None

log.error("Authentication failed with status %d", response.status_code)
try:
error_data = response.json()
log.error("Error details: %s", error_data)
except (ValueError, json.JSONDecodeError):
log.error("Error response: %s", response.text)
return None

def authenticate(
self, email: str, password: str, license_data: str, signature: str
self,
email: str,
password: str,
license_data: Optional[str] = None,
signature: Optional[str] = None,
) -> Optional[str]:
"""
Authenticate with Paprika server and get JWT token.

When ``license_data`` and ``signature`` are both provided, the
license-based login is used. Otherwise a password-only login is
performed, which works for users (e.g. on iOS) whose local database
has no purchases table.

Args:
email: User email
password: User password
license_data: JSON license data string
signature: Base64-encoded RSA signature
license_data: JSON license data string (optional)
signature: Base64-encoded RSA signature (optional)

Returns:
JWT token string or None if failed
"""
if license_data is None or signature is None:
return self._authenticate_password_only(email, password)

# Prepare multipart form data
files = {
"email": (None, email),
Expand All @@ -307,36 +351,52 @@ def authenticate(
}

response = self.post("account/login/", files=files)
return self._parse_login_response(response)

if response is None:
# Dry run or request failed
return None
def _authenticate_password_only(
self, email: str, password: str
) -> Optional[str]:
"""
Log in with email and password only (no local license data).

log.info("Server response: %d", response.status_code)
The v1 login endpoint accepts credentials without a purchase receipt
on every platform, and the resulting token works against the v2 sync
endpoints. The v2 login endpoint rejects password-only logins unless
the request looks like it came from a mobile client.
"""
if not kappari_requests_available:
raise RuntimeError("requests library not available")

if response.status_code == 200:
try:
result = response.json()
if "result" in result and "token" in result["result"]:
jwt_token = result["result"]["token"]
log.info("Authentication successful")
return jwt_token
log.error("Unexpected response format: %s", result)
return None
except json.JSONDecodeError:
log.error("Non-JSON response: %s", response.text)
return None
else:
log.error(
"Authentication failed with status %d", response.status_code
url = "https://www.paprikaapp.com/api/v1/account/login/"
headers = {"User-Agent": self.config.user_agent}

if self.config.debug_api_requests:
self._log_request(
"POST", url, headers, {"email": email, "password": "[HIDDEN]"}
)
try:
error_data = response.json()
log.error("Error details: %s", error_data)
except (ValueError, json.JSONDecodeError):
log.error("Error response: %s", response.text)

if self.config.dry_run:
log.info("DRY RUN: Would POST password-only login to %s", url)
return None

try:
response = requests.post(
url,
data={"email": email, "password": password},
headers=headers,
timeout=self.config.api_timeout,
proxies=self.config.proxies if self.config.proxies else None,
verify=self.config.verify_ssl,
)
except requests.exceptions.RequestException as e:
log.error("Password-only login failed: %s", e)
return None

if self.config.debug_api_requests:
self._log_response(response)

return self._parse_login_response(response)

def make_authenticated_request(
self, endpoint: str, jwt_token: str, method: str = "GET", **kwargs
) -> Optional[requests.Response]:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies = [
include = ["kappari*"]
exclude = ["tests*"]

[project.optional-dependencies]
[dependency-groups]
dev = [
"ruff>=0.1.0",
"pytest>=7.0.0",
Expand Down
Loading
Loading