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
# --- API token (optional) ---
# When set, /api requires this token. The frontend nginx injects it server-side,
# so the browser never sees it. Leave empty to disable.
# When set, /api and the /api/ws live feed require this token. The frontend
# 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_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"
exit 1
fi
# /api/ws bypasses Basic auth (browsers don't reliably replay
# credentials on a WebSocket upgrade) and relies on the API token
# instead. Refuse to ship if that token is missing, or the live feed
# would be unauthenticated.
# CRYPTO_API_TOKEN is optional. When unset, the API and the /api/ws
# live feed are unauthenticated beyond the Traefik Basic auth that
# still guards everything except /api/ws. Set it to require a bearer
# token on /api (nginx injects it server-side, so the browser never
# sees it).
if [ -z "$CRYPTO_API_TOKEN" ]; then
echo "::error::CRYPTO_API_TOKEN must be set because /api/ws skips Basic auth; refusing to deploy"
exit 1
echo "Note: CRYPTO_API_TOKEN is not set; /api and /api/ws will not require a bearer token."
fi
- 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).
- `/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
WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx,
or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job
fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in
`nginx.conf` separate from `/api/` so upgrade headers are only set there.
WS upgrade. When `API_TOKEN` is set the WS requires it (header, injected by
nginx, or `?token=` for manual clients); when unset the path is open, matching
the rest of `/api`. `CRYPTO_API_TOKEN` is optional. Keep the `/api/ws` block
in `nginx.conf` separate from `/api/` so upgrade headers are only set there.
## 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
a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so
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
handshake. Because of this the deploy **requires** `CRYPTO_API_TOKEN` to be set —
without it the WebSocket would be unauthenticated.
Basic auth. If `API_TOKEN` is set, the WebSocket still requires it (nginx
injects it on the handshake). If `API_TOKEN` is unset, `/api/ws` is as open as
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
+23
View File
@@ -145,6 +145,29 @@ def test_api_token_enforced(runtime: Runtime) -> None:
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:
runtime.settings.api_token = "secret"
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)}"
# Browsers do not reliably replay Basic-auth credentials on a WebSocket
# upgrade, which left the live feed stuck "reconnecting". Give /api/ws its
# own higher-priority router that skips Basic auth; the backend still
# requires the API token, which nginx injects server-side. When no API
# token is configured this path is unauthenticated, so only use it on a
# trusted network.
# own higher-priority router that skips Basic auth so the feed connects.
# If CRYPTO_API_TOKEN is set, the backend still requires it (nginx injects
# it server-side); if it is unset, /api/ws is as open as the rest of /api.
- "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.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
# here, and so the API token is injected server-side on the handshake (the
# browser cannot set headers on a WebSocket).
# here, and so the optional API token is injected server-side on the
# handshake (the browser cannot set headers on a WebSocket).
location /api/ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;