api: enforce API tokens under a bare uvicorn start

Found while checking the production proxy path. BearerAuthMiddleware falls
OPEN when admin.routes.session_manager is None and no CODERAI_API_TOKEN is
set: with no user database to check a token against it lets the request
through rather than locking out an install with no auth configured. That is
defensible on a loopback workstation.

main.py and build_system_app() both initialise that database. A bare
`uvicorn codai.api.app:app` start — exactly how the GPU-less orchestrator
runs — did not, so once published through a reverse proxy every /v1/* call
was served WITHOUT credentials. Verified before and after: session_manager
None on import, /v1/models 200 with no header; now initialised at import
and 401 for no header, a wrong Bearer, and nginx's Basic.

The readiness probes stay open on purpose (/healthz, /health, /v1/health):
a probe behind auth is read as "backend down" by every health checker,
surya-ocr's vLLM client included.

The guide gains the three traps of that deployment: auth_basic and Bearer
share one header so the API path must not be basic-authed, proxy_buffering
must be off for streaming, and how to check in one curl whether tokens are
actually enforced.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 3703d7c3
......@@ -740,6 +740,38 @@ app.include_router(admin_router, tags=["Admin"])
# Registering them here gives one image that is both the orchestrator and its GUI.
# Guarded: a missing templates dir or config dir must not take the API down with
# it, which is the same contract the front has.
def _ensure_session_manager():
"""Make sure API tokens are actually ENFORCED under a bare uvicorn start.
BearerAuthMiddleware falls open when admin.routes.session_manager is None and
no CODERAI_API_TOKEN is set: with no user database to check a token against
and no env token configured, it lets the request through rather than locking
out an install that has no auth at all. That is defensible on a loopback
workstation and dangerous for a GPU-less orchestrator started as
`uvicorn codai.api.app:app` and published through a reverse proxy — every
/v1/* call would be served without credentials.
main.py and build_system_app() both initialise it; a bare uvicorn start does
not. Do it here so the tokens on the Tokens page mean what they say.
"""
try:
from codai.admin import routes as _ar
if _ar.session_manager is not None:
return
from codai.platform_paths import legacy_style_config_dir
cfg_dir = getattr(getattr(_ar, "config_manager", None), "config_dir", None) \
or legacy_style_config_dir()
_ar.init_session_manager(cfg_dir)
print(f"[api] API tokens enforced from {cfg_dir}", flush=True)
except Exception as exc:
print(f"[api] WARNING: could not initialise the session manager ({exc}); "
f"set CODERAI_API_TOKEN to refuse unauthenticated /v1 requests",
flush=True)
_ensure_session_manager()
def _register_local_ui_pages():
try:
from codai.frontproxy.ui_pages import register_ui_pages
......
......@@ -88,6 +88,70 @@ location ^~ /coderai/ {
`proxy_read_timeout` matters: a long render or a cold engine's first token can
take minutes, and nginx's 60 s default will kill it mid-answer.
**Three things will bite you on this path.**
**1. `auth_basic` and Bearer tokens collide.** They are the same HTTP header.
If you put `auth_basic` on `/coderai/`, nginx demands `Authorization: Basic …`
and rejects a client that sends `Authorization: Bearer sk-coderai-…` with 401
*before* the request ever reaches CoderAI. So basic-auth the **GUI** and leave
the **API** on its own tokens:
```nginx
# The GUI: a browser, protected by basic auth.
location ^~ /coderai/ {
auth_basic "coderai";
auth_basic_user_file /etc/nginx/coderai.htpasswd;
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Prefix /coderai;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
# The API: no basic auth — CoderAI's own Bearer tokens guard it.
location ^~ /coderai/v1/ {
auth_basic off;
proxy_pass http://127.0.0.1:8000/v1/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Prefix /coderai;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_buffering off; # or streamed responses arrive in one lump
}
```
`proxy_buffering off` on the API path matters for streaming: with buffering on,
nginx holds a token-by-token response and delivers it all at once.
**2. Leave the readiness probes unauthenticated.** `/healthz`, `/health` and
`/v1/health` must answer 200 without credentials. Every health checker — and
surya-ocr's vLLM client — reads a 401 as *backend down*. They are deliberately
exempt from the Bearer check inside CoderAI; do not put basic auth in front of
them either.
**3. Make sure your API tokens are actually enforced.** CoderAI's Bearer check
falls **open** when it has no user database to check against and no
`CODERAI_API_TOKEN` in the environment — reasonable on a loopback workstation,
dangerous the moment the app is published. Before 0.2.83 a bare
`uvicorn codai.api.app:app` start did not initialise that database, so `/v1/*`
was served *without credentials*. From 0.2.83 the app initialises it from the
config dir at import and logs `[api] API tokens enforced from <dir>`.
Verify it yourself, from the host, before you expose anything:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/v1/models
# 401 <- correct
# 200 <- OPEN: upgrade to 0.2.83, or set CODERAI_API_TOKEN and restart
```
On an older image, `-e CODERAI_API_TOKEN=<something-long>` closes it: with an
env token configured the middleware refuses anything that does not match.
**The GUI itself works under a sub-path** — the login form posts to
`/coderai/login`, assets resolve under `/coderai/static/admin/`, and the page's
`ROOT_PATH` is `/coderai`, so no nginx rewriting is needed.
---
## 2. Point it at RunPod
......
......@@ -198,3 +198,48 @@ def test_the_forwarded_prefix_middleware_is_installed():
assert "class _ForwardedPrefixMiddleware" in src
assert "app.add_middleware(_ForwardedPrefixMiddleware)" in src
assert "x-forwarded-prefix" in src
# ----------------------- exposed behind a proxy: tokens must be enforced
def test_api_tokens_are_enforced_under_a_bare_uvicorn_start():
"""BearerAuthMiddleware falls OPEN when admin.routes.session_manager is None
and no CODERAI_API_TOKEN is set. main.py initialises it; `uvicorn
codai.api.app:app` — the GPU-less orchestrator shape — did not, so every
/v1/* call was served without credentials once published through a proxy."""
import os, base64
os.environ.setdefault("CODERAI_SKIP_HEAVY", "1")
from fastapi.testclient import TestClient
from codai.api.app import app
from codai.admin import routes as ar
assert ar.session_manager is not None, "importing the app must initialise it"
c = TestClient(app, raise_server_exceptions=False)
for hdr in ({}, {"Authorization": "Bearer sk-definitely-not-a-token"},
{"Authorization": "Basic " + base64.b64encode(b"g:p").decode()}):
assert c.get("/v1/models", headers=hdr).status_code == 401, hdr
def test_readiness_probes_stay_open_when_tokens_are_enforced():
"""A probe behind auth is not a probe: surya and every orchestrator health
check would read 401 as 'backend down'."""
import os
os.environ.setdefault("CODERAI_SKIP_HEAVY", "1")
from fastapi.testclient import TestClient
from codai.api.app import app
c = TestClient(app, raise_server_exceptions=False)
for path in ("/healthz", "/health", "/v1/health"):
assert c.get(path).status_code == 200, path
def test_the_gui_emits_prefixed_urls_behind_a_sub_path_proxy():
"""Served at https://host/coderai/, every URL the page emits must carry the
prefix or the browser walks out of the deployment on the first click."""
import os, re
os.environ.setdefault("CODERAI_SKIP_HEAVY", "1")
from fastapi.testclient import TestClient
from codai.api.app import app
c = TestClient(app, raise_server_exceptions=False)
html = c.get("/login", headers={"X-Forwarded-Prefix": "/coderai"}).text
assert 'action="/coderai/login"' in html, "the login form would post outside the prefix"
assert "/coderai/static/admin/" in html, "assets would 404 through the proxy"
assert re.search(r"ROOT_PATH\s*=\s*['\"]/coderai", html), "JS fetches would miss the prefix"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment