admin: optional SSO through a trusted reverse proxy (v0.2.85)

Implements docs/admin-forward-auth.md. Off by default, switchable from
Settings -> Single sign-on, from config.json, or from the container's env
(CODERAI_ADMIN_FORWARD_AUTH=1|0, CODERAI_ADMIN_FORWARD_AUTH_SECRET) — the
env switch can disable it as well as enable it, so a unit file can override
a mounted config either way.

A request is forward-authed only when BOTH checks pass: the immediate peer
is in trusted_proxies AND the shared secret header matches (constant time).
Either alone is forgeable, so the identity header is not even read until
trust is established — a client sending X-Forwarded-User directly is
ignored. Enabled with no secret stays OFF with a log line, because without
one there is nothing separating the proxy from any other client.

It mints a real signed session and injects it into the current request, not
just a rendered page: the dashboard's own /admin/api/* calls authenticate
by cookie, so a page with 401ing AJAX would not be a working GUI. An
auto-created account gets a random password nobody is told and is never
asked to change it. keep_local_login=false makes /login refuse. Logout
honours post_logout_url, since returning to our own login would be met by
the proxy's headers and log the user straight back in.

GUI only: /v1/* keeps its bearer tokens whatever the proxy asserts, and the
readiness probes stay credential-free. The settings API reports
shared_secret_set as a boolean and never the secret.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 83bad126
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.2.84"
__version__ = "0.2.85"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -300,8 +300,22 @@ async def logout(request: Request):
cookie = request.cookies.get(SESSION_COOKIE_NAME)
session_manager.destroy_session(cookie)
response = RedirectResponse(url=_url(request, "/login"), status_code=302)
# With forward-auth the real session lives upstream, so returning to our own
# /login would be met by the proxy's headers and log the user straight back
# in. Send them to the upstream logout instead when one is configured.
_dest = _url(request, "/login")
try:
_fa = getattr(getattr(getattr(config_manager, "config", None), "admin", None),
"forward_auth", None)
if _fa is not None and _fa.enabled and (_fa.post_logout_url or "").strip():
_dest = _fa.post_logout_url.strip()
except Exception:
pass
response = RedirectResponse(url=_dest, status_code=302)
response.delete_cookie(SESSION_COOKIE_NAME)
# The middleware sets a plain "session" cookie for forward-authed users;
# clear that too or the next request walks back in with it.
response.delete_cookie("session")
return response
......@@ -3810,7 +3824,26 @@ def build_settings_dict(c, gpu_cards):
"""Pure ``Config`` → settings dict. Shared by the engine handler and the front
proxy (which holds the same Config) so both serve an identical
/admin/api/settings without the front having to round-trip to the engine."""
_fa = getattr(getattr(c, "admin", None), "forward_auth", None)
return {
# Admin GUI forward-auth (SSO). The secret is reported as a boolean only
# — the page must be able to show that one is set without handing it to
# every browser that opens Settings.
"admin": {
"forward_auth": {
"enabled": bool(getattr(_fa, "enabled", False)),
"user_header": getattr(_fa, "user_header", ""),
"groups_header": getattr(_fa, "groups_header", ""),
"admin_group": getattr(_fa, "admin_group", ""),
"shared_secret_header": getattr(_fa, "shared_secret_header", ""),
"shared_secret_set": bool(getattr(_fa, "shared_secret", "")),
"trusted_proxies": list(getattr(_fa, "trusted_proxies", []) or []),
"auto_create_users": bool(getattr(_fa, "auto_create_users", True)),
"keep_local_login": bool(getattr(_fa, "keep_local_login", False)),
"admin_without_groups": bool(getattr(_fa, "admin_without_groups", False)),
"post_logout_url": getattr(_fa, "post_logout_url", ""),
}
},
"server": {
"host": c.server.host,
"port": c.server.port,
......@@ -4157,6 +4190,40 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
c = config_manager.config
_settings_warnings: list = []
# Admin GUI: trusted forward-auth (SSO). Off by default; the API is never
# affected by it. Saved here so it can be switched from the Settings page
# as well as from config.json or the container's env.
if "admin" in data and isinstance(data["admin"], dict):
fa_in = data["admin"].get("forward_auth")
if isinstance(fa_in, dict):
fa = c.admin.forward_auth
for key in ("enabled", "auto_create_users", "keep_local_login",
"admin_without_groups"):
if key in fa_in:
setattr(fa, key, bool(fa_in[key]))
for key in ("user_header", "groups_header", "admin_group",
"shared_secret_header", "post_logout_url"):
if key in fa_in and isinstance(fa_in[key], str) and fa_in[key].strip():
setattr(fa, key, fa_in[key].strip())
# A blank secret in the payload means "leave it alone", so the page
# can render without echoing the secret back to the browser.
if isinstance(fa_in.get("shared_secret"), str) and fa_in["shared_secret"].strip():
fa.shared_secret = fa_in["shared_secret"].strip()
tp = fa_in.get("trusted_proxies")
if isinstance(tp, str):
tp = [x.strip() for x in tp.replace(";", ",").split(",") if x.strip()]
if isinstance(tp, list) and tp:
fa.trusted_proxies = [str(x).strip() for x in tp if str(x).strip()]
if fa.enabled and not fa.shared_secret:
_settings_warnings.append(
"Forward-auth is enabled but has no shared secret, so it stays "
"OFF: without one, anyone who can reach the port could send the "
"identity header.")
if fa.enabled and not fa.trusted_proxies:
_settings_warnings.append(
"Forward-auth has no trusted proxies, so it will refuse every "
"request: the peer address is half of the trust check.")
if "server" in data:
srv = data["server"]
c.server.host = srv.get("host", c.server.host)
......
......@@ -49,6 +49,50 @@
</div>
<div class="form-row" style="margin-top:.8rem;margin-bottom:0">
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="s-metrics-public"><span>Serve <code>/metrics</code> (Prometheus) without a credential</span></label>
</div>
<h3 style="margin-top:1.4rem">Single sign-on (trusted reverse proxy)</h3>
<div class="form-row" style="margin-bottom:0">
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="s-fa-enabled"><span>Accept an identity my reverse proxy has already authenticated</span></label>
<span class="form-hint">Off by default. When on, a request that arrives <b>from a trusted proxy</b> and carries <b>the right shared secret</b> is logged in as the user named in the identity header, with no password form. Both checks are required — either alone is forgeable — and anything missing fails closed. This is the <b>admin GUI only</b>: <code>/v1/*</code> keeps its API tokens whatever the proxy asserts.</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.6rem;margin-top:.6rem">
<div class="form-row" style="margin:0">
<label class="form-label">Identity header</label>
<input type="text" id="s-fa-user-header" class="form-input" placeholder="X-Forwarded-User">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Groups header</label>
<input type="text" id="s-fa-groups-header" class="form-input" placeholder="X-Forwarded-Groups">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Admin group</label>
<input type="text" id="s-fa-admin-group" class="form-input" placeholder="admins">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Secret header</label>
<input type="text" id="s-fa-secret-header" class="form-input" placeholder="X-Coderai-Proxy-Secret">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Shared secret</label>
<input type="password" id="s-fa-secret" class="form-input" placeholder="(unchanged)" autocomplete="new-password">
<span class="form-hint" id="s-fa-secret-state" style="font-size:11px"></span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Trusted proxies</label>
<input type="text" id="s-fa-trusted" class="form-input" placeholder="127.0.0.1, ::1">
<span class="form-hint" style="font-size:11px">The immediate peer address. Comma separated.</span>
</div>
</div>
<div class="form-row" style="margin-top:.6rem">
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="s-fa-autocreate"><span>Create the account on first sight</span></label>
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="s-fa-keep-local"><span>Keep the password form as a fallback</span></label>
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer"><input type="checkbox" id="s-fa-admin-nogroups"><span>Treat every forward-authed user as an admin when no groups header is sent</span></label>
<span class="form-hint">With the fallback off, the proxy is the only way in and <code>/login</code> refuses. The last option is explicit on purpose: guessing it either locks you out or hands admin to everyone upstream lets through.</span>
</div>
<div class="form-row" style="margin-top:.6rem;margin-bottom:0">
<label class="form-label">Logout goes to</label>
<input type="text" id="s-fa-post-logout" class="form-input" placeholder="https://your-sso/logout (blank = this install's /login)">
<span class="form-hint">The real session lives upstream, so returning to our own login would just log you back in.</span>
<span class="form-hint" style="font-size:11px">Off = a session, an API token or the cluster token is required. Exported: requests per key/model/kind/status/engine with wall time, engines and nodes up, VRAM, in-flight, RPC servers, discovery, RunPod spend.</span>
</div>
<div class="form-row" style="margin-top:1rem;margin-bottom:0">
......@@ -1797,6 +1841,25 @@ async function loadSettings(){
document.getElementById('s-key').value = d.server?.https_key_path ?? '';
document.getElementById('s-cert').value = d.server?.https_cert_path ?? '';
document.getElementById('s-metrics-public').checked = !!d.server?.metrics_public;
(function(){
const fa = (d.admin && d.admin.forward_auth) || {};
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value = (v==null?'':v); };
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked = !!v; };
chk('s-fa-enabled', fa.enabled);
set('s-fa-user-header', fa.user_header);
set('s-fa-groups-header', fa.groups_header);
set('s-fa-admin-group', fa.admin_group);
set('s-fa-secret-header', fa.shared_secret_header);
set('s-fa-trusted', (fa.trusted_proxies || []).join(', '));
chk('s-fa-autocreate', fa.auto_create_users);
chk('s-fa-keep-local', fa.keep_local_login);
chk('s-fa-admin-nogroups', fa.admin_without_groups);
set('s-fa-post-logout', fa.post_logout_url);
const st = document.getElementById('s-fa-secret-state');
if (st) st.textContent = fa.shared_secret_set
? 'A secret is set. Leave blank to keep it.'
: 'No secret set — the feature stays OFF until one is.';
})();
document.getElementById('s-queue-max').value = d.server?.queue_max_size ?? 6;
document.getElementById('s-internal-port-base').value = d.server?.internal_port_base ?? 8780;
// Default engine — surfaced when 2+ engines are running. Sourced from the
......@@ -2084,6 +2147,22 @@ async function loadSettings(){
async function saveSettings(silent){
const strOrNull = id => document.getElementById(id).value.trim() || null;
const data = {
admin: {
forward_auth: {
enabled: document.getElementById('s-fa-enabled').checked,
user_header: (document.getElementById('s-fa-user-header').value || '').trim(),
groups_header: (document.getElementById('s-fa-groups-header').value || '').trim(),
admin_group: (document.getElementById('s-fa-admin-group').value || '').trim(),
shared_secret_header: (document.getElementById('s-fa-secret-header').value || '').trim(),
// blank means "leave the stored secret alone" — the page never receives it
shared_secret: (document.getElementById('s-fa-secret').value || '').trim(),
trusted_proxies: (document.getElementById('s-fa-trusted').value || '').trim(),
auto_create_users: document.getElementById('s-fa-autocreate').checked,
keep_local_login: document.getElementById('s-fa-keep-local').checked,
admin_without_groups: document.getElementById('s-fa-admin-nogroups').checked,
post_logout_url: (document.getElementById('s-fa-post-logout').value || '').trim(),
},
},
server:{
host: document.getElementById('s-host').value.trim() || '0.0.0.0',
port: parseInt(document.getElementById('s-port').value) || 8000,
......
......@@ -113,6 +113,47 @@ class ServerConfig:
default_engine: Optional[str] = None
@dataclass
class AdminForwardAuthConfig:
"""Trust an identity a reverse proxy has already authenticated (SSO).
OFF by default and GUI-ONLY: it never changes how /v1/* authenticates.
When enabled, a request that comes from a trusted proxy AND carries the
right shared secret is logged in as the user named in ``user_header``,
with no password form. Both checks are required — a trusted header is a
forged-identity hole the moment it is honoured from anywhere else — and
anything missing fails CLOSED.
"""
enabled: bool = False
user_header: str = "X-Forwarded-User"
groups_header: str = "X-Forwarded-Groups"
admin_group: str = "admins"
shared_secret_header: str = "X-Coderai-Proxy-Secret"
#: The expected value. Blank = the feature stays off however `enabled` is
#: set: without a secret there is nothing to distinguish the proxy from a
#: client that simply sends the header.
shared_secret: str = ""
trusted_proxies: list = field(default_factory=lambda: ["127.0.0.1", "::1"])
auto_create_users: bool = True
#: Leave the password form working (a fallback for loopback/direct access).
#: False = the proxy is the only way in.
keep_local_login: bool = False
#: What to do when the proxy sends no groups header at all. False (default)
#: = the user is a non-admin; True = every forward-authed user is an admin.
#: Explicit on purpose: guessing here either locks the operator out or hands
#: admin to everyone upstream lets through.
admin_without_groups: bool = False
#: Where a logout goes. The real session lives upstream, so returning to our
#: own login would just auto-log-in again.
post_logout_url: str = ""
@dataclass
class AdminConfig:
"""Admin GUI settings."""
forward_auth: AdminForwardAuthConfig = field(default_factory=AdminForwardAuthConfig)
@dataclass
class BackendConfig:
"""Backend configuration."""
......@@ -961,6 +1002,7 @@ class Config:
version: str = "1.0"
server: ServerConfig = field(default_factory=ServerConfig)
backend: BackendConfig = field(default_factory=BackendConfig)
admin: AdminConfig = field(default_factory=AdminConfig)
models: ModelsConfig = field(default_factory=ModelsConfig)
offload: OffloadConfig = field(default_factory=OffloadConfig)
vulkan: VulkanConfig = field(default_factory=VulkanConfig)
......@@ -1155,6 +1197,9 @@ class ConfigManager:
version=config_data.get("version", "1.0"),
server=_dc(ServerConfig, config_data.get("server", {})),
backend=_dc(BackendConfig, config_data.get("backend", {})),
admin=AdminConfig(forward_auth=_dc(
AdminForwardAuthConfig,
(config_data.get("admin") or {}).get("forward_auth", {}))),
models=_dc(ModelsConfig, config_data.get("models", {})),
offload=_dc(OffloadConfig, config_data.get("offload", {})),
vulkan=_dc(VulkanConfig, config_data.get("vulkan", {})),
......@@ -1273,6 +1318,21 @@ class ConfigManager:
"engine_specs": self.config.server.engine_specs,
"default_engine": self.config.server.default_engine,
},
"admin": {
"forward_auth": {
"enabled": self.config.admin.forward_auth.enabled,
"user_header": self.config.admin.forward_auth.user_header,
"groups_header": self.config.admin.forward_auth.groups_header,
"admin_group": self.config.admin.forward_auth.admin_group,
"shared_secret_header": self.config.admin.forward_auth.shared_secret_header,
"shared_secret": self.config.admin.forward_auth.shared_secret,
"trusted_proxies": self.config.admin.forward_auth.trusted_proxies,
"auto_create_users": self.config.admin.forward_auth.auto_create_users,
"keep_local_login": self.config.admin.forward_auth.keep_local_login,
"admin_without_groups": self.config.admin.forward_auth.admin_without_groups,
"post_logout_url": self.config.admin.forward_auth.post_logout_url,
},
},
"backend": {
"type": self.config.backend.type,
"image_backend": self.config.backend.image_backend,
......
......@@ -24,10 +24,13 @@ from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.responses import RedirectResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
_FA_WARNED = False
def register_ui_pages(app: FastAPI, config_dir) -> bool:
"""Register local UI page + static routes on the front app.
......@@ -53,6 +56,109 @@ def register_ui_pages(app: FastAPI, config_dir) -> bool:
return False
sm = SessionManager(cfg_path)
def _fa_cfg():
"""The forward-auth settings, or None when the feature is off.
Read per request so toggling it in Settings takes effect without a
restart. Env wins over the file so a container can be switched from its
unit file without editing a mounted config:
CODERAI_ADMIN_FORWARD_AUTH=1|0 and CODERAI_ADMIN_FORWARD_AUTH_SECRET.
"""
import os
cfg = None
try:
from codai.admin import routes as _ar
mgr = getattr(_ar, "config_manager", None)
cfg = getattr(getattr(mgr, "config", None), "admin", None)
cfg = getattr(cfg, "forward_auth", None)
except Exception:
cfg = None
if cfg is None:
return None
env_on = os.environ.get("CODERAI_ADMIN_FORWARD_AUTH", "")
enabled = cfg.enabled
if env_on != "":
enabled = env_on.strip().lower() in ("1", "true", "yes", "on")
if not enabled:
return None
secret = os.environ.get("CODERAI_ADMIN_FORWARD_AUTH_SECRET", "") or cfg.shared_secret
if not secret:
# Enabled with no secret is not a configuration, it is an open door:
# anyone who can reach the port could send the header. Stay off and
# say so once.
global _FA_WARNED
if not _FA_WARNED:
print("[front] admin forward-auth is enabled but no shared secret is "
"set — staying OFF (set admin.forward_auth.shared_secret or "
"CODERAI_ADMIN_FORWARD_AUTH_SECRET)", flush=True)
_FA_WARNED = True
return None
return cfg, secret
def _fa_trusted(request: Request, cfg, secret: str) -> bool:
"""Both checks, or nothing: the immediate peer must be a trusted proxy
AND the shared secret must match. Either one alone is forgeable."""
import hmac
peer = (request.client.host if request.client else "") or ""
trusted = [str(t).strip() for t in (cfg.trusted_proxies or []) if str(t).strip()]
if peer not in trusted:
return False
sent = request.headers.get(cfg.shared_secret_header.lower(), "") or ""
if not sent or not hmac.compare_digest(sent, secret):
return False
return True
def _fa_identity(request: Request):
"""(username, is_admin) from a trusted proxy's headers, or (None, False).
Returns nothing at all unless the trust check passed, so a client that
sends X-Forwarded-User directly is simply ignored.
"""
got = _fa_cfg()
if not got:
return None, False
cfg, secret = got
if not _fa_trusted(request, cfg, secret):
return None, False
user = (request.headers.get(cfg.user_header.lower(), "") or "").strip()
if not user or "/" in user or "\\" in user or len(user) > 128:
return None, False
groups_raw = request.headers.get(cfg.groups_header.lower(), None)
if groups_raw is None:
is_admin = bool(cfg.admin_without_groups)
else:
groups = {g.strip().lower() for g in str(groups_raw).replace(";", ",").split(",")}
is_admin = (cfg.admin_group or "").strip().lower() in groups
return user, is_admin
def _fa_login(request: Request):
"""Establish a normal CoderAI session for a forward-authed user.
Returns the username, or None. Creates the account on first sight when
auto_create_users is on; otherwise the user must already exist, so an
upstream directory cannot mint CoderAI accounts by itself.
"""
import secrets
user, is_admin = _fa_identity(request)
if not user:
return None, False
got = _fa_cfg()
cfg = got[0] if got else None
known = any(u.get("username") == user
for u in (sm._load_auth_data().get("users") or []))
if not known:
if not (cfg and cfg.auto_create_users):
print(f"[front] forward-auth: refusing unknown user {user!r} "
f"(auto_create_users is off)", flush=True)
return None, False
# A random password nobody is told: this account is only ever
# reached through the proxy.
sm.create_user(user, secrets.token_urlsafe(32),
role="admin" if is_admin else "user")
print(f"[front] forward-auth: created user {user!r} "
f"(admin={is_admin})", flush=True)
return user, is_admin
def _user(request: Request) -> Optional[str]:
"""Locally validate whichever ``session``/``session_<port>`` cookie is
present. Validation is by HMAC signature against the shared secret, so the
......@@ -79,11 +185,56 @@ def register_ui_pages(app: FastAPI, config_dir) -> bool:
return None, _to(request, "/admin")
return u, None
# ------------------------------------------------------- forward auth (SSO)
# A session, not just a rendered page: the dashboard's own data calls go to
# /admin/api/*, which authenticate by session cookie. So mint the normal
# signed session, inject it into THIS request so those handlers see it, and
# set it on the response so the browser keeps it.
#
# /v1/* is explicitly excluded: this feature is GUI-only and must not change
# how the API authenticates.
@app.middleware("http")
async def _forward_auth_session(request: Request, call_next):
path = request.url.path
if path.startswith("/v1/") or path in ("/healthz", "/health"):
return await call_next(request)
if _user(request): # already has a session of ours
return await call_next(request)
user, _is_admin = _fa_login(request)
if not user:
return await call_next(request)
cookie = sm.create_session(user)
if cookie.endswith(".MUST_CHANGE"): # never force a password change
cookie = cookie[:-12] # on an account reached via SSO
raw = [(k, v) for k, v in request.scope.get("headers", [])
if k.lower() != b"cookie"]
existing = request.headers.get("cookie", "")
merged = f"session={cookie}" + (f"; {existing}" if existing else "")
raw.append((b"cookie", merged.encode()))
request.scope["headers"] = raw
response = await call_next(request)
try:
response.set_cookie("session", cookie, httponly=True, samesite="lax",
secure=request.url.scheme == "https", path="/")
except Exception:
pass
return response
# ---------------------------------------------------------------- pages
@app.get("/login", include_in_schema=False)
async def _login_page(request: Request):
if _user(request):
return _to(request, "/admin")
got = _fa_cfg()
if got and not got[0].keep_local_login:
# The proxy is the only way in. Showing a password box here would
# invite someone to look for a way around the proxy.
return JSONResponse(
{"detail": "This install authenticates through its reverse proxy. "
"Reach the GUI through it, or set "
"admin.forward_auth.keep_local_login to allow the "
"password form as a fallback."},
status_code=403)
return _tmpl(request, "login.html", {"error": None})
@app.get("/admin", include_in_schema=False)
......
# Admin GUI: trusted forward-auth (reuse an upstream login)
**Status: feature request / requirements.** This is what CoderAI needs to add so
**Status: IMPLEMENTED in 0.2.85.** (Originally a feature request; the
requirements below are what shipped, with the differences noted at the end.) This is what CoderAI needs to add so
a reverse proxy that has *already* authenticated a user can hand that identity to
CoderAI's admin GUI, instead of the user logging into CoderAI a second time.
......@@ -143,3 +144,63 @@ are written up in `/AI/sentenze/coderai-surya-fix.md` and are **not** part of th
request: engine#0 crash-looping GPU-less despite `backend.type=cpu` +
`server.engine_specs`, and `/v1/*` serving `200` without a bearer (tokens not
enforced). This forward-auth feature is independent of both.
---
## As shipped (0.2.85)
Config lives at `admin.forward_auth` in `config.json`, is editable from
**Settings → Single sign-on (trusted reverse proxy)**, and is **off by default**:
```json
"admin": {
"forward_auth": {
"enabled": false,
"user_header": "X-Forwarded-User",
"groups_header": "X-Forwarded-Groups",
"admin_group": "admins",
"shared_secret_header": "X-Coderai-Proxy-Secret",
"shared_secret": "",
"trusted_proxies": ["127.0.0.1", "::1"],
"auto_create_users": true,
"keep_local_login": false,
"admin_without_groups": false,
"post_logout_url": ""
}
}
```
Env overrides for a unit file that must not edit a mounted config:
`CODERAI_ADMIN_FORWARD_AUTH=1|0` and `CODERAI_ADMIN_FORWARD_AUTH_SECRET`. The env
switch can turn it **off** as well as on.
**Differences from the request, all deliberate:**
- `shared_secret` is a config/env value, not only a header *name*. The request
named the header but not where the expected value comes from.
- **Enabled with no secret stays OFF**, with one line in the log. Without a
secret there is nothing distinguishing the proxy from a client that sends the
header, so honouring it would be worse than not running the feature.
- `admin_without_groups` is the explicit decision the request asked for: when
the proxy sends no groups header, `false` (default) makes the user a
non-admin, `true` makes every forward-authed user an admin.
- The username from the header is validated (no slashes, ≤128 chars) before it
is used to look up or create an account.
- An auto-created account gets a random 32-byte password nobody is told, and is
never asked to change it — it exists only to be reached through the proxy.
- `GET /admin/api/settings` reports `shared_secret_set: true|false` and never
the secret, so opening Settings does not hand it to the browser. Saving with
the field blank keeps the stored value.
- A real signed session is minted and injected into the current request, not
just a rendered page: the dashboard's own `/admin/api/*` calls authenticate by
session cookie, and a page whose AJAX 401s is not a working GUI.
**Unchanged, as required:** `/v1/*` is untouched by this feature and keeps its
bearer tokens; `/healthz`, `/health` and `/v1/health` stay credential-free.
Separately, and found while testing this: the **front was not enforcing the API
bearer at all** — `GET /v1/models` answered 200 with no header on 0.2.83. Fixed
in 0.2.84; the front now refuses `/v1` without credentials instead of falling
open. That was the second item in the "not part of this request" note, and it
was real.
"""Trusted forward-auth for the admin GUI (SSO), per docs/admin-forward-auth.md.
The security contract is the whole point: a trusted header is a forged-identity
hole the moment it is honoured from an untrusted source. Every test here is
about refusing, except the two that prove it works when it should.
"""
import pathlib
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
UI = (ROOT / "codai/frontproxy/ui_pages.py").read_text()
def _cfg(**over):
from codai.config import AdminForwardAuthConfig
c = AdminForwardAuthConfig()
for k, v in over.items():
setattr(c, k, v)
return c
# ------------------------------------------------------------------ the switch
def test_off_by_default():
c = _cfg()
assert c.enabled is False
assert c.keep_local_login is False
assert c.admin_without_groups is False # never guess admin
assert c.auto_create_users is True
assert c.trusted_proxies == ["127.0.0.1", "::1"]
assert c.shared_secret == ""
def test_enabled_with_no_secret_stays_off():
"""Enabled without a secret is not a configuration, it is an open door."""
blk = UI.split("def _fa_cfg")[1].split("def _fa_trusted")[0]
assert "if not secret:" in blk
tail = blk.split("if not secret:")[1]
assert "staying OFF" in tail
assert "return None" in tail, "it must refuse to enable, not carry on"
def test_an_env_switch_exists_for_the_container():
blk = UI.split("def _fa_cfg")[1].split("def _fa_trusted")[0]
assert "CODERAI_ADMIN_FORWARD_AUTH" in blk
assert "CODERAI_ADMIN_FORWARD_AUTH_SECRET" in blk
# Env must be able to turn it OFF as well as on, or a unit file cannot
# disable a feature the mounted config enables.
assert '"1", "true", "yes", "on"' in blk
def test_the_config_round_trips(tmp_path):
import json
from codai.config import ConfigManager
(tmp_path / "config.json").write_text(json.dumps(
{"admin": {"forward_auth": {"enabled": True, "shared_secret": "s3cr3t",
"trusted_proxies": ["10.0.0.9"],
"keep_local_login": True}}}))
cm = ConfigManager(str(tmp_path)); cm.load()
fa = cm.config.admin.forward_auth
assert fa.enabled and fa.shared_secret == "s3cr3t"
assert fa.trusted_proxies == ["10.0.0.9"] and fa.keep_local_login is True
cm.save_config()
back = json.loads((tmp_path / "config.json").read_text())["admin"]["forward_auth"]
assert back["enabled"] is True and back["shared_secret"] == "s3cr3t"
# --------------------------------------------------------- the trust contract
def test_both_checks_are_required_not_either():
blk = UI.split("def _fa_trusted")[1].split("def _fa_identity")[0]
assert "peer not in trusted" in blk and "return False" in blk
assert "compare_digest" in blk, "the secret must not be compared with =="
# peer check and secret check are separate early returns, so neither alone
# can authenticate.
assert blk.count("return False") >= 2
def test_the_header_is_ignored_entirely_when_untrusted():
"""A client sending X-Forwarded-User: admin directly must get nothing."""
blk = UI.split("def _fa_identity")[1].split("def _fa_login")[0]
i = blk.index("_fa_trusted")
assert "return None, False" in blk[i:i + 160], \
"the identity must be read only AFTER the trust check passes"
assert blk.index("_fa_trusted") < blk.index("user_header"), \
"trust is checked before the username is even looked at"
def test_a_username_from_a_header_is_validated():
blk = UI.split("def _fa_identity")[1].split("def _fa_login")[0]
assert '"/" in user' in blk and "len(user) > 128" in blk
def test_admin_is_not_granted_without_an_explicit_decision():
blk = UI.split("def _fa_identity")[1].split("def _fa_login")[0]
assert "admin_without_groups" in blk
assert "admin_group" in blk
def test_an_unknown_user_is_refused_when_auto_create_is_off():
blk = UI.split("def _fa_login")[1].split("def _user")[0]
assert "auto_create_users" in blk
assert "refusing unknown user" in blk
def test_the_created_account_has_an_unusable_password():
"""The account exists only to be reached through the proxy."""
blk = UI.split("def _fa_login")[1].split("def _user")[0]
assert "secrets.token_urlsafe(32)" in blk
# ------------------------------------------------------------ what it touches
def test_the_api_is_never_affected():
"""GUI-only. /v1/* keeps its bearer tokens, whatever the proxy asserts."""
blk = UI.split("async def _forward_auth_session")[1].split("# ---")[0]
assert 'path.startswith("/v1/")' in blk
assert "return await call_next(request)" in blk.split('path.startswith("/v1/")')[1][:200]
def test_probes_are_not_touched():
blk = UI.split("async def _forward_auth_session")[1].split("# ---")[0]
assert '"/healthz", "/health"' in blk
def test_it_mints_a_real_session_so_the_dashboards_data_calls_work():
"""The pages' own /admin/api/* calls authenticate by session cookie — a
rendered page with 401ing AJAX is not a working GUI."""
blk = UI.split("async def _forward_auth_session")[1].split("# ---")[0]
assert "sm.create_session(user)" in blk
assert 'request.scope["headers"] = raw' in blk, "the cookie must reach this request"
assert "response.set_cookie" in blk, "and the browser must keep it"
assert "MUST_CHANGE" in blk, "an SSO account must not be asked to change a password"
def test_keep_local_login_false_refuses_the_password_form():
blk = UI.split("async def _login_page")[1].split("@app.get")[0]
assert "keep_local_login" in blk and "status_code=403" in blk
def test_logout_goes_upstream_when_configured():
"""Returning to our own /login would be met by the proxy's headers and log
the user straight back in."""
src = (ROOT / "codai/admin/routes.py").read_text()
blk = src.split("async def logout")[1].split("@router.post")[0]
assert "post_logout_url" in blk
assert 'response.delete_cookie("session")' in blk
# ------------------------------------------------------- switchable from the UI
def test_the_settings_api_round_trips_it():
from codai.admin.routes import build_settings_dict
from codai.config import Config
d = build_settings_dict(Config(), [])
fa = d["admin"]["forward_auth"]
assert fa["enabled"] is False
# The secret is reported as a boolean, never echoed to every browser that
# opens Settings.
assert "shared_secret" not in fa
assert fa["shared_secret_set"] is False
def test_saving_it_warns_instead_of_silently_doing_nothing():
src = (ROOT / "codai/admin/routes.py").read_text()
blk = src.split('if "admin" in data')[1].split('if "server" in data')[0]
assert "_settings_warnings.append" in blk
assert "no shared secret" in blk and "no trusted proxies" in blk
# A blank secret in the payload must not wipe the stored one.
assert 'fa_in["shared_secret"].strip()' in blk
@pytest.mark.parametrize("field", [
"s-fa-enabled", "s-fa-user-header", "s-fa-groups-header", "s-fa-admin-group",
"s-fa-secret-header", "s-fa-secret", "s-fa-trusted", "s-fa-autocreate",
"s-fa-keep-local", "s-fa-admin-nogroups", "s-fa-post-logout",
])
def test_every_sso_setting_is_on_the_settings_page(field):
"""Markup, loader and serialiser — a field missing from any one of the three
is a setting that silently will not stick."""
html = (ROOT / "codai/admin/templates/settings.html").read_text()
assert html.count(field) >= 3, f"{field}: {html.count(field)} occurrences"
This diff is collapsed.
"""The front must enforce the bearer on /v1, and must not fall open.
Reproduced on the published 0.2.83 image before this: GET /v1/models returned
200 with no Authorization header at all. The front answers some /v1 paths from
its own registry and proxies the rest, so whether a request needed a token
depended on which component served it.
"""
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
SRC = (ROOT / "codai/frontproxy/app.py").read_text()
def test_the_front_gates_v1():
assert '@app.middleware("http")' in SRC
assert "async def _api_bearer_gate" in SRC
blk = SRC.split("async def _api_bearer_gate")[1].split("def ")[0]
assert 'path.startswith("/v1/")' in blk
assert "status_code=401" in blk
assert "_api_request_authorised(request)" in blk
def test_probes_and_progress_are_exempt():
"""A probe behind auth reads as "backend down"; surya probes /health."""
from codai.frontproxy.app import _API_AUTH_EXEMPT
assert "/v1/health" in _API_AUTH_EXEMPT
for p in ("/v1/images/progress", "/v1/video/progress",
"/v1/audio/progress", "/v1/loras/progress"):
assert p in _API_AUTH_EXEMPT, p
# /healthz and /health are not under /v1 at all, so the gate never sees them.
assert not any(x in _API_AUTH_EXEMPT for x in ("/healthz", "/health"))
def test_it_accepts_the_same_credentials_the_engine_does():
blk = SRC.split("def _api_request_authorised")[1].split("app = FastAPI")[0]
assert "CODERAI_API_TOKEN" in blk # a locked capability image
assert "cluster" in blk and "token" in blk # a head forwarding to this node
assert "verify_token" in blk # auth.json API tokens
assert "validate_session" in blk # a logged-in browser
assert "x-coderai-broker-authed" in blk # aisbf relay
assert 'request.scope.get("server") == ("internal", 80)' in blk
def test_it_does_not_fall_open_without_credentials_configured():
"""The engine's middleware returns call_next when there is no user database
and no env token. That fallback is exactly how an exposed orchestrator
served /v1 to anyone, so the front must refuse instead."""
blk = SRC.split("def _api_request_authorised")[1].split("app = FastAPI")[0]
tail = blk.split("if not config_dir:")[1]
assert "return False" in tail.split("try:")[0], \
"no config dir must refuse, not allow"
assert blk.rstrip().endswith("return False"), \
"the helper must end by refusing, not by allowing"
def test_it_uses_constant_time_comparison():
blk = SRC.split("def _api_request_authorised")[1].split("app = FastAPI")[0]
assert "compare_digest" in blk
assert "== _env" not in blk and "== shared" not in blk
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