Let a home deployment hold its own Authentik session

A home site forwarded every identity route to auth_base_url, so it never knew
who was visiting and could only ever offer "Sign in". It now runs the OIDC
flow itself and shows "My Account" and, for admins, "Admin" -- both pointing
at the deployment that owns those pages.

There is no user table on a home deployment, so the session cookie carries the
claims we need (username, email, admin flag) rather than a row id. It is
HMAC-signed, so it is tamper-evident, and holds nothing secret. The admin test
mirrors upsert_oidc_user: app_admin_emails or an admin group in the claims.

Also decouples oidc_issuer from auth_base_url. They are different hosts --
Authentik on one, the accounts app on another -- and deriving one from the
other only worked when they happened to coincide. OIDC_ISSUER is now its own
variable, required unless APP_ROLE=home.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ryan
2026-08-15 14:05:22 +02:00
parent 60f98756c4
commit 6fef7c1dc7
3 changed files with 78 additions and 18 deletions
+68 -13
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import base64
import hashlib
import html
import json
import mimetypes
import urllib.parse
from http import HTTPStatus
@@ -26,7 +28,16 @@ STATIC = ROOT / "static"
#: The only routes a role="home" deployment answers. Everything else belongs to the deployment
#: that holds the database and the OIDC client, and is reached through config.auth_base_url.
HOME_ROUTES = {("GET", "/"), ("GET", "/trusted-domains")}
#: Served by a home deployment itself. Everything else is forwarded to the deployment that owns
#: identity. The auth routes are here so a home site can hold its own Authentik session: it needs
#: to know who you are to show "My Account" and "Admin", even though it owns no user table.
HOME_ROUTES = {
("GET", "/"),
("GET", "/trusted-domains"),
("GET", "/auth/start"),
("GET", "/auth/callback"),
("POST", "/logout"),
}
def esc(value: object) -> str:
@@ -38,6 +49,11 @@ class WebApp:
self.config = config
# A home deployment has no database credentials at all, so there is nothing to point at.
self.db = None if config.role == "home" else Database(config.database_url)
#: Whether this deployment can run the OIDC flow itself. A home site without these falls
#: back to handing sign-in off to auth_base_url.
self.oidc_ready = bool(
config.oidc_issuer and config.oidc_client_id and config.oidc_client_secret
)
def dispatch(self, handler) -> None:
parsed = urlparse(handler.path)
@@ -87,16 +103,21 @@ class WebApp:
return {key: values[0] for key, values in parse_qs(raw).items()}
def current_user(self, handler) -> dict[str, str | None] | None:
if self.db is None:
return None
cookie = SimpleCookie(handler.headers.get("Cookie"))
morsel = cookie.get("snreg_session")
if not morsel:
return None
user_id = unsign(morsel.value, self.config.secret)
if not user_id:
value = unsign(morsel.value, self.config.secret)
if not value:
return None
return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(user_id)}")
if self.db is None:
# No user table here, so the claims we care about travel in the cookie itself. It is
# signed, so the contents are tamper-evident; nothing secret goes in.
try:
return json.loads(base64.urlsafe_b64decode(value.encode()).decode())
except Exception:
return None
return self.db.one(f"SELECT * FROM snreg_users WHERE id = {sql_literal(value)}")
def require_user(self, handler) -> dict[str, str | None] | None:
user = self.current_user(handler)
@@ -168,9 +189,22 @@ class WebApp:
def nav(self, user: dict[str, str | None] | None) -> str:
if not user:
return f'<a class="nav-button" href="{self.off_box("/auth/start")}?next=/profile">Sign in</a>'
admin = '<a class="nav-button" href="/admin">Admin</a>' if user.get("is_admin") == "t" else ""
return f'<a class="nav-button" href="/">Home</a><a class="nav-button" href="/profile">Profile</a>{admin}<form method="post" action="/logout"><button class="nav-button">Sign out</button></form>'
# Sign in here when this deployment can run the flow itself; otherwise hand off to the
# deployment that owns identity.
if self.oidc_ready:
start, nxt = "/auth/start", "/" if self.config.role == "home" else "/profile"
else:
start, nxt = self.off_box("/auth/start"), "/profile"
return f'<a class="nav-button" href="{start}?next={nxt}">Sign in</a>'
# off_box leaves these relative on a full deployment and absolute on a home one, so the
# same markup serves both.
admin = (
f'<a class="nav-button" href="{self.off_box("/admin")}">Admin</a>'
if user.get("is_admin") == "t"
else ""
)
account = f'<a class="nav-button" href="{self.off_box("/profile")}">My Account</a>'
return f'<a class="nav-button" href="/">Home</a>{account}{admin}<form method="post" action="/logout"><button class="nav-button">Sign out</button></form>'
def redirect(self, handler, location: str) -> None:
handler.send_response(303)
@@ -183,6 +217,22 @@ class WebApp:
cookie += "; Secure"
handler.send_header("Set-Cookie", cookie)
def set_home_session(self, handler, claims: dict) -> None:
"""Session for a deployment with no user table, built straight from the OIDC claims."""
groups = groups_from_claims(claims)
email = str(claims.get("email") or "").lower()
payload = {
"username": str(claims.get("preferred_username") or email.split("@", 1)[0] or ""),
"full_name": str(claims.get("name") or ""),
"email": email,
# Same rule the full deployment applies in upsert_oidc_user.
"is_admin": "t"
if email in self.config.app_admin_emails or groups & self.config.admin_groups
else "f",
}
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
self.set_session(handler, encoded)
def off_box(self, path: str) -> str:
"""Absolute URL for a page this deployment does not serve; unchanged when it does.
@@ -239,9 +289,11 @@ class WebApp:
if not self.config.oidc_client_id or not self.config.oidc_client_secret:
return self.render(handler, "Sign in unavailable", "<p>Authentik OIDC is not configured for Charon yet.</p>", HTTPStatus.SERVICE_UNAVAILABLE)
query = parse_qs(urlparse(handler.path).query)
next_path = query.get("next", ["/profile"])[0]
# A home deployment does not serve /profile, so land back on its own front page.
default_next = "/" if self.config.role == "home" else "/profile"
next_path = query.get("next", [default_next])[0]
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/profile"
next_path = default_next
state = sign(next_path, self.config.secret)
self.redirect(handler, authorization_url(self.config, state, next_path))
@@ -255,12 +307,15 @@ class WebApp:
try:
token = exchange_code(self.config, code)
claims = userinfo(self.config, token["access_token"])
user = self.upsert_oidc_user(claims)
user = None if self.db is None else self.upsert_oidc_user(claims)
except Exception as exc:
return self.render(handler, "Sign in failed", f"<p>{esc(exc)}</p>", HTTPStatus.BAD_GATEWAY)
handler.send_response(303)
handler.send_header("Location", next_path)
self.set_session(handler, user["id"] or "")
if user is None:
self.set_home_session(handler, claims)
else:
self.set_session(handler, user["id"] or "")
handler.end_headers()
def upsert_oidc_user(self, claims: dict) -> dict[str, str | None]: