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
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ docs/
tests/
scripts/
.zensical.runtime.toml
.env
.env.example
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
QR_CODE_API_KEYS=
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

## 📝 Quickstart

The published image is [`ghcr.io/toshy/qr-code-api`](ghcr.io/toshy/qr-code-api).
The published image is available at [`ghcr.io/toshy/qr-code-api`](ghcr.io/toshy/qr-code-api).

By default, the container runs in **CLI mode**; pass `server` as the argument to instead start the **FastAPI server**.

Expand Down Expand Up @@ -75,6 +75,36 @@ The server listens on port `8000`. For each major API version `v{N}` (e.g. `v1`)
| `/v{N}/readme` | `GET` | Rendered README documentation |
| `/v{N}/openapi.json` | `GET` | OpenAPI specification |

#### 🔐 Authentication (optional)

The `POST /v{N}/qr` endpoint can be protected by a shared API key. Authentication is **disabled by default** and activates only when the `QR_CODE_API_KEYS` environment variable is set to a non-empty, comma-separated list of valid keys.

Keys are sent as a Bearer token in the `Authorization` header:

```
Authorization: Bearer <your-api-key>
```

Run with auth enabled:

```sh
docker run --rm -p 8000:8000 \
-e QR_CODE_API_KEYS="$(openssl rand -hex 32)" \
ghcr.io/toshy/qr-code-api:latest server
```

Call it:

```sh
curl -X POST http://localhost:8000/v1/qr \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${QR_CODE_API_KEYS}" \
-d '{"data":"https://example.com"}' \
--output qr.png
```

The Swagger UI at `/v{N}/docs` exposes an **Authorize** button so you can paste a key once and use "Try it out" interactively. The remaining endpoints (`/docs`, `/redoc`, `/readme`, `/openapi.json`) stay public so the docs are always reachable.

#### `docker run`

```sh
Expand Down
7 changes: 6 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ services:
dockerfile: Dockerfile
target: dev
user: "${PUID:-1000}:${PGID:-1000}"
env_file:
- path: .env
required: false
- path: .env.example
required: true
stop_signal: SIGINT
volumes:
- .:/app
ports:
- "8000:8000"
command: ["python", "-m", "qr", "server", "--host", "0.0.0.0", "--port", "8000", "--reload"]
command: ["python", "-m", "qr", "server", "--host", "0.0.0.0", "--port", "8000", "--reload"]
4 changes: 3 additions & 1 deletion docs/version/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ _Structure_
"data": "https://example.com",
"fill_color": { "r": 0, "g": 47, "b": 167 },
"back_color": { "r": 167, "g": 120, "b": 0 },
"module_drawer": "square_module"
"module_drawer": {
"type": "square_module"
}
}
```

Expand Down
3 changes: 2 additions & 1 deletion qr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from qr.generate import generate_qr_image
from qr.log.logger import get_logger
from qr.log.formatter import CustomJSONFormatter
from qr.auth import require_api_key

version_prefix = "v1"

Expand Down Expand Up @@ -158,7 +159,7 @@ async def log_request(request: Request, call_next):
description="Generate a QR code in PNG, JPEG, WebP, or SVG format. "
"Supports color masks, embedded images, and optional JSON data URI output.",
response_description="Returns the QR code as an image or a JSON data URI.",
dependencies=[Depends(require_json)],
dependencies=[Depends(require_json), Depends(require_api_key)],
)
async def create_qr(request: CreateQrRequest, raw_request: Request):
try:
Expand Down
63 changes: 63 additions & 0 deletions qr/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Optional API-key authentication for the QR Code API.

Authentication is enabled when the ``QR_CODE_API_KEYS`` environment variable is set
to a non-empty, comma-separated list of valid keys. When unset or empty,
authentication is disabled (no-op) so the service remains zero-config for
local development and existing deployments.

Keys are transported as Bearer tokens in the ``Authorization`` header:

Authorization: Bearer <key>

The Swagger UI (``/v{N}/docs``) automatically renders an "Authorize" button
that accepts the token, courtesy of FastAPI's ``HTTPBearer`` security scheme.
"""

import hmac
import os
from typing import Optional

from fastapi import HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

API_KEYS_ENV_VAR = "QR_CODE_API_KEYS"

_bearer_scheme = HTTPBearer(auto_error=False, bearerFormat="API key")


def _load_keys() -> set[str]:
raw = os.environ.get(API_KEYS_ENV_VAR, "")
return {k.strip() for k in raw.split(",") if k.strip()}


def auth_enabled() -> bool:
"""Whether API-key authentication is currently active."""
return bool(_load_keys())


async def require_api_key(
creds: Optional[HTTPAuthorizationCredentials] = Security(_bearer_scheme),
) -> str:
"""FastAPI dependency enforcing a valid API key when auth is enabled."""
valid_keys = _load_keys()
if not valid_keys:
# Auth disabled: accept anonymous requests.
return ""

if creds is None or not creds.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token.",
headers={"WWW-Authenticate": 'Bearer realm="qr-code-api"'},
)

presented = creds.credentials
# Constant-time comparison against every configured key to mitigate timing attacks.
if not any(hmac.compare_digest(presented, valid) for valid in valid_keys):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid bearer token.",
headers={"WWW-Authenticate": 'Bearer realm="qr-code-api"'},
)

return presented
51 changes: 51 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,54 @@ def test_create_qr_unknown_field(snapshot):
response = client.post("/v1/qr", json=payload)
assert response.status_code == 200
assert response.content == snapshot


# ---------------------------------------------------------------------------
# Authentication (Bearer token via QR_CODE_API_KEYS env var)
# ---------------------------------------------------------------------------

_VALID_KEY = "test-secret-key"
_AUTH_PAYLOAD = {
"data": "auth check",
"module_drawer": {"type": PIL_DRAWERS[0].value},
}


def test_auth_disabled_by_default(monkeypatch):
monkeypatch.delenv("QR_CODE_API_KEYS", raising=False)
response = client.post("/v1/qr", json=_AUTH_PAYLOAD)
assert response.status_code == 200


def test_auth_enabled_missing_token_returns_401(monkeypatch):
monkeypatch.setenv("QR_CODE_API_KEYS", _VALID_KEY)
response = client.post("/v1/qr", json=_AUTH_PAYLOAD)
assert response.status_code == 401


def test_auth_enabled_invalid_token_returns_401(monkeypatch):
monkeypatch.setenv("QR_CODE_API_KEYS", _VALID_KEY)
response = client.post(
"/v1/qr",
json=_AUTH_PAYLOAD,
headers={"Authorization": "Bearer wrong-key"},
)
assert response.status_code == 401


def test_auth_enabled_valid_token_returns_200(monkeypatch):
monkeypatch.setenv("QR_CODE_API_KEYS", f"another-key,{_VALID_KEY}")
response = client.post(
"/v1/qr",
json=_AUTH_PAYLOAD,
headers={"Authorization": f"Bearer {_VALID_KEY}"},
)
assert response.status_code == 200


def test_auth_does_not_protect_docs_endpoints(monkeypatch):
monkeypatch.setenv("QR_CODE_API_KEYS", _VALID_KEY)
assert client.get("/v1/openapi.json").status_code == 200
# Swagger/ReDoc HTML pages
assert client.get("/v1/docs").status_code == 200
assert client.get("/v1/redoc").status_code == 200