Make the API token fully optional #5

Merged
matitos merged 1 commits from fix/optional-api-token into main 2026-09-19 16:35:37 +02:00
7 changed files with 46 additions and 21 deletions

No files matched your search

+4 -2
View File
@@ -44,8 +44,10 @@ MAX_POSITION_PCT=1.0
MAX_TRADES_PER_DAY=0 MAX_TRADES_PER_DAY=0
# --- API token (optional) --- # --- API token (optional) ---
# When set, /api requires this token. The frontend nginx injects it server-side, # When set, /api and the /api/ws live feed require this token. The frontend
# so the browser never sees it. Leave empty to disable. # nginx injects it server-side, so the browser never sees it. Leave empty for
# no token; the app works fully without it. (The Traefik Basic auth still
# guards the UI in the deployed stack.)
API_TOKEN= API_TOKEN=
API_TOKEN_HEADER=X-API-Token API_TOKEN_HEADER=X-API-Token
+6 -6
View File
@@ -147,13 +147,13 @@ jobs:
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy" echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
exit 1 exit 1
fi fi
# /api/ws bypasses Basic auth (browsers don't reliably replay # CRYPTO_API_TOKEN is optional. When unset, the API and the /api/ws
# credentials on a WebSocket upgrade) and relies on the API token # live feed are unauthenticated beyond the Traefik Basic auth that
# instead. Refuse to ship if that token is missing, or the live feed # still guards everything except /api/ws. Set it to require a bearer
# would be unauthenticated. # token on /api (nginx injects it server-side, so the browser never
# sees it).
if [ -z "$CRYPTO_API_TOKEN" ]; then if [ -z "$CRYPTO_API_TOKEN" ]; then
echo "::error::CRYPTO_API_TOKEN must be set because /api/ws skips Basic auth; refusing to deploy" echo "Note: CRYPTO_API_TOKEN is not set; /api and /api/ws will not require a bearer token."
exit 1
fi fi
- name: Log in to Docker Hub (optional) - name: Log in to Docker Hub (optional)
+4 -4
View File
@@ -164,10 +164,10 @@ real migration step.
depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists). depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists).
- `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router - `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router
(`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a (`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a
WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx, WS upgrade. When `API_TOKEN` is set the WS requires it (header, injected by
or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job nginx, or `?token=` for manual clients); when unset the path is open, matching
fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in the rest of `/api`. `CRYPTO_API_TOKEN` is optional. Keep the `/api/ws` block
`nginx.conf` separate from `/api/` so upgrade headers are only set there. in `nginx.conf` separate from `/api/` so upgrade headers are only set there.
## Backtesting ## Backtesting
+4 -3
View File
@@ -136,9 +136,10 @@ auth, not instead of it.
`/api/ws` is special: browsers do not reliably replay Basic-auth credentials on `/api/ws` is special: browsers do not reliably replay Basic-auth credentials on
a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so
Traefik serves that one path on a separate higher-priority router **without** Traefik serves that one path on a separate higher-priority router **without**
Basic auth. It is still gated by `API_TOKEN`, which nginx injects on the Basic auth. If `API_TOKEN` is set, the WebSocket still requires it (nginx
handshake. Because of this the deploy **requires** `CRYPTO_API_TOKEN` to be set — injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as
without it the WebSocket would be unauthenticated. the rest of `/api`, which is fine for a trusted/private deployment but is the
reason to set a token if the host is exposed.
## Risk controls ## Risk controls
+23
View File
@@ -145,6 +145,29 @@ def test_api_token_enforced(runtime: Runtime) -> None:
assert client.get("/api/health").status_code == 200 assert client.get("/api/health").status_code == 200
def test_websocket_connects_without_any_token(runtime: Runtime) -> None:
# API_TOKEN is unset by default: the API and the live feed must work with no
# token at all, even when (as nginx does) an empty token header is sent.
runtime.settings.api_token = ""
app = create_app(runtime.settings, runtime)
with (
TestClient(app) as client,
client.websocket_connect("/api/ws", headers={"X-API-Token": ""}) as websocket,
):
message = websocket.receive_json()
assert message["type"] == "status"
assert message["data"]["running"] is False
def test_api_works_without_token(runtime: Runtime) -> None:
runtime.settings.api_token = ""
app = create_app(runtime.settings, runtime)
with TestClient(app) as client:
assert client.get("/api/status").status_code == 200
assert client.get("/api/strategies").status_code == 200
assert client.post("/api/backtest", json={"strategy_key": "sma_cross"}).status_code != 401
def test_websocket_rejects_missing_token(runtime: Runtime) -> None: def test_websocket_rejects_missing_token(runtime: Runtime) -> None:
runtime.settings.api_token = "secret" runtime.settings.api_token = "secret"
app = create_app(runtime.settings, runtime) app = create_app(runtime.settings, runtime)
+3 -4
View File
@@ -98,10 +98,9 @@ services:
- "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}" - "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}"
# Browsers do not reliably replay Basic-auth credentials on a WebSocket # Browsers do not reliably replay Basic-auth credentials on a WebSocket
# upgrade, which left the live feed stuck "reconnecting". Give /api/ws its # upgrade, which left the live feed stuck "reconnecting". Give /api/ws its
# own higher-priority router that skips Basic auth; the backend still # own higher-priority router that skips Basic auth so the feed connects.
# requires the API token, which nginx injects server-side. When no API # If CRYPTO_API_TOKEN is set, the backend still requires it (nginx injects
# token is configured this path is unauthenticated, so only use it on a # it server-side); if it is unset, /api/ws is as open as the rest of /api.
# trusted network.
- "traefik.http.routers.crypto-trading-ws.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`) && PathPrefix(`/api/ws`)" - "traefik.http.routers.crypto-trading-ws.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`) && PathPrefix(`/api/ws`)"
- "traefik.http.routers.crypto-trading-ws.entrypoints=websecure" - "traefik.http.routers.crypto-trading-ws.entrypoints=websecure"
- "traefik.http.routers.crypto-trading-ws.tls.certresolver=myresolvercd" - "traefik.http.routers.crypto-trading-ws.tls.certresolver=myresolvercd"
+2 -2
View File
@@ -9,8 +9,8 @@ server {
} }
# WebSocket feed. Kept in its own block so the upgrade headers are only set # WebSocket feed. Kept in its own block so the upgrade headers are only set
# here, and so the API token is injected server-side on the handshake (the # here, and so the optional API token is injected server-side on the
# browser cannot set headers on a WebSocket). # handshake (the browser cannot set headers on a WebSocket).
location /api/ws { location /api/ws {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;