announcer/auth.py
Kent c4a15799dd Mesh: én sentral fordeler til flere soner
Flere anlegg – hovedscene, resepsjon, crew – og ett sted å sende fra, med
avkryssing for hvilke soner meldingen skal til.

Sentralen er en vanlig announcer med [zones] i config. En sone er en vanlig
announcer med ANNOUNCER_MESH_SECRET. Samme image, ingen mode, og en sone kan
brukes alene hvis sentralen faller ut. Hver avhengighet med C-kode har
aarch64-wheel i uv.lock, så det samme bildet går på en Raspberry Pi uten å
kompilere noe.

Sentralen syntetiserer én gang og sender ferdig WAV (POST /api/play,
multipart). Sonen trenger da verken nett, edge-tts eller Pipers 670 MB for
det den får – Item.wav er hele endringen i avspillingen. Køene forblir per
sone: dispatcheren har egen tråd og egen kø, leverer til hver sone i hver sin
tråd med timeout, og går videre. En opptatt hovedscene holder ikke igjen
resepsjonen, og en sone som er nede feiler alene – synlig per sone i UI og
revisjonslogg.

Auth gjenbrukes: ny leverandør mesh på header X-Mesh-Key (ikke Bearer – den
leses av JWT-leverandøren, som avviser alt som ikke er et JWT før neste får
prøve), med én handling, play. To regler som fulgte med:

- play følger aldri med «alt åpent». Uten det kunne hvem som helst POSTe en
  WAV til en åpen sone og få den spilt. Testen for åpen modus fanget det.
- En legitimasjon som vises fram og er feil gir 401 også der gjester ellers
  har lov. Før slapp en feil PIN i åpen modus stille gjennom som gjest.

mesh teller ikke som innlogging for folk: en sone med bare nøkkelen har
fortsatt åpen side lokalt.

Planlagte sendinger husker sonene sine og fordeles gjennom dispatcheren;
planen ender som sent hvis minst én sone tok imot, med «hovedscene: kø #3,
crew: nede» som note.

Hodeløs Pi: install.sh kjører loginctl enable-linger, ellers finnes ikke
lydsocketen når ingen logger inn. docs/mesh.md tar også raspi-config for
jack framfor HDMI.

38 nye tester, 148 totalt. Fordelingen testes uten nett med stubbet
transport; selve HTTP-en testes mot en levende werkzeug-server, så den
håndrullede multiparten møter Flask sin parser. Verifisert i compose med
sentral og to soner: til alle, bare til én, og én sone stoppet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 02:22:05 +02:00

677 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Pluggbar autentisering for announcer.
Hele modellen er én datatype og én funksjon:
Principal – hvem dette er, hvilke roller, hva rollene får gjøre
require(action) – hent den, eller stopp forespørselen med 401/403
Rundt det ligger leverandører som prøves i rekkefølge. Hver av dem svarer på
én forespørsel med «dette er brukeren», «jeg fant ingen legitimasjon av min
type» (None) eller AuthError («fant noe, men det var ugyldig»):
pin – X-Pin-header, én delt kode. Virker uten nett, derfor beholdt.
jwt – Authorization: Bearer <jwt> fra crew-panelet. HS256 med delt
hemmelighet, eller RS256/ES256 mot panelets JWKS.
oidc – autorisasjonskode med PKCE mot crew-panelet; nettleseren logger
inn der og får en sesjonscookie her.
Er ingen leverandør konfigurert, er alt åpent – nøyaktig som før. Det er med
vilje: tjenesten skal kunne settes opp på en kveld uten identitetsleverandør.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from flask import Blueprint, abort, g, redirect, request, session, url_for
from config import ACTIONS, Config
log = logging.getLogger("announcer.auth")
SESSION_KEY = "principal"
NAME_CLAIMS = ("name", "preferred_username", "nickname", "given_name", "email")
class AuthError(Exception):
"""Legitimasjon fulgte med, men den holdt ikke."""
# --------------------------------------------------------------------------
# Principal
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Principal:
subject: str # stabil id: "pin", "jwt:4f2a", "oidc:kent"
name: str # vises i køen og i loggen
roles: tuple[str, ...]
method: str # none | anonymous | pin | jwt | oidc
permissions: frozenset[str]
@property
def anonymous(self) -> bool:
return self.method in ("none", "anonymous")
def can(self, action: str) -> bool:
return action in self.permissions
def public(self) -> dict[str, Any]:
return {
"subject": self.subject,
"name": self.name,
"roles": list(self.roles),
"method": self.method,
"can": sorted(self.permissions),
}
def _claim(claims: dict, path: str) -> Any:
"""Henter et claim, også nestet: "realm_access.roles"."""
cur: Any = claims
for part in path.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(part)
return cur
def _roles(value: Any) -> tuple[str, ...]:
"""Roller kommer som liste, streng eller mellomromsdelt streng."""
if isinstance(value, str):
return tuple(v for v in value.replace(",", " ").split() if v)
if isinstance(value, (list, tuple, set)):
return tuple(str(v).strip() for v in value if str(v).strip())
return ()
def _display_name(claims: dict, name_claim: str) -> str:
if name_claim:
value = _claim(claims, name_claim)
if value:
return str(value)
for key in NAME_CLAIMS:
if claims.get(key):
return str(claims[key])
return str(claims.get("sub") or "ukjent")
# --------------------------------------------------------------------------
# JWT-verifisering, delt av jwt- og oidc-leverandøren
# --------------------------------------------------------------------------
class TokenVerifier:
"""Verifiserer signatur, iss, aud og exp. JWKS caches av PyJWT selv."""
def __init__(self, section: dict[str, Any]) -> None:
self.issuer = str(section.get("issuer", "")).strip()
self.audience = str(section.get("audience", "")).strip()
self.secret = str(section.get("secret", "")).strip()
self.jwks_url = str(section.get("jwks_url", "")).strip()
self.leeway = int(section.get("leeway", 30))
# Tom liste: utled av hvordan nøkkelen kommer. Delt hemmelighet er
# symmetrisk (HS256), JWKS er asymmetrisk (RS256/ES256).
algs = [str(a) for a in section.get("algorithms") or []]
self.algorithms = algs or (["RS256", "ES256"] if self.jwks_url else ["HS256"])
self._jwks: Any = None
def _signing_key(self, token: str) -> Any:
if not self.jwks_url:
return self.secret
if self._jwks is None:
import jwt # lazy: PIN-oppsett skal ikke trenge pyjwt
self._jwks = jwt.PyJWKClient(self.jwks_url, cache_keys=True, lifespan=3600)
return self._jwks.get_signing_key_from_jwt(token).key
def verify(self, token: str) -> dict:
import jwt
if not (self.secret or self.jwks_url):
raise AuthError("JWT er ikke konfigurert")
try:
return jwt.decode(
token,
self._signing_key(token),
algorithms=self.algorithms,
audience=self.audience or None,
issuer=self.issuer or None,
leeway=self.leeway,
options={
"verify_aud": bool(self.audience),
"verify_iss": bool(self.issuer),
"require": ["exp"],
},
)
except jwt.ExpiredSignatureError as e:
raise AuthError("Tokenet er utløpt") from e
except jwt.InvalidAudienceError as e:
raise AuthError("Tokenet gjelder en annen tjeneste") from e
except jwt.InvalidTokenError as e:
raise AuthError(f"Ugyldig token ({e})") from e
except urllib.error.URLError as e:
raise AuthError(f"Fikk ikke hentet nøkler fra {self.jwks_url} ({e})") from e
# --------------------------------------------------------------------------
# Leverandører
# --------------------------------------------------------------------------
class Provider:
name = ""
def authenticate(self, req) -> Principal | None: # noqa: ANN001
return None
def describe(self) -> dict[str, Any]:
return {"method": self.name}
class PinProvider(Provider):
name = "pin"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.pin")
self.pin = str(s.get("pin", "")).strip()
self.role = str(s.get("role") or "admin")
self.label = str(s.get("label") or "PIN")
self.hint = str(s.get("hint", ""))
self.cfg = cfg
def authenticate(self, req) -> Principal | None: # noqa: ANN001
given = req.headers.get("X-Pin", "").strip()
if not given:
auth = req.headers.get("Authorization", "")
if auth[:4].lower() == "pin ":
given = auth[4:].strip()
if not given:
return None
# compare_digest, ikke ==, så svartiden ikke lekker hvor langt man kom.
if not hmac.compare_digest(given, self.pin):
raise AuthError("Feil PIN")
roles = (self.role,)
# En delt kode peker ikke på en person, og skal ikke late som. Vil man
# vite hvem som sendte hva, er det JWT eller OIDC man vil ha.
return Principal("pin", "delt PIN", roles, "pin", self.cfg.permissions(roles))
def describe(self) -> dict[str, Any]:
return {"method": "pin", "label": self.label, "hint": self.hint}
class MeshProvider(Provider):
"""Sentralen som leverer en WAV til denne sonen.
Egen header, ikke Authorization: Bearer – JwtProvider ville avvist en
nøkkel som ikke er et JWT før neste leverandør fikk prøve.
"""
name = "mesh"
def __init__(self, cfg: Config) -> None:
self.secret = str(cfg.get("mesh", "secret", "")).strip()
self.cfg = cfg
def authenticate(self, req) -> Principal | None: # noqa: ANN001
given = req.headers.get("X-Mesh-Key", "").strip()
if not given:
return None
if not hmac.compare_digest(given, self.secret):
raise AuthError("Feil mesh-nøkkel")
roles = ("mesh",)
return Principal("mesh", "sentral", roles, "mesh", self.cfg.permissions(roles))
class JwtProvider(Provider):
name = "jwt"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.jwt")
self.cfg = cfg
self.verifier = TokenVerifier(s)
self.role_claim = str(s.get("role_claim") or "roles")
self.name_claim = str(s.get("name_claim", ""))
self.default_role = str(s.get("default_role", ""))
self.session_seconds = int(s.get("session_seconds", 43200))
def principal(self, claims: dict, method: str = "jwt") -> Principal:
roles = _roles(_claim(claims, self.role_claim))
if not roles and self.default_role:
roles = (self.default_role,)
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
return Principal(
f"{method}:{sub}",
_display_name(claims, self.name_claim),
roles,
method,
self.cfg.permissions(roles),
)
def authenticate(self, req) -> Principal | None: # noqa: ANN001
auth = req.headers.get("Authorization", "")
if auth[:7].lower() != "bearer ":
return None
token = auth[7:].strip()
if not token:
return None
return self.principal(self.verifier.verify(token))
class OidcProvider(Provider):
"""Autorisasjonskode med PKCE. Innloggingen skjer i nettleseren, så
authenticate() finner aldri noe i en header – sesjonen tar seg av resten."""
name = "oidc"
def __init__(self, cfg: Config) -> None:
s = cfg.section("auth.oidc")
self.cfg = cfg
self.issuer = str(s.get("issuer", "")).strip().rstrip("/")
self.client_id = str(s.get("client_id", "")).strip()
self.client_secret = str(s.get("client_secret", "")).strip()
self.scopes = [str(x) for x in s.get("scopes") or ["openid"]]
self.label = str(s.get("label") or "Logg inn")
self.role_claim = str(s.get("role_claim") or "roles")
self.name_claim = str(s.get("name_claim", ""))
self.default_role = str(s.get("default_role", ""))
self._manual = {
"authorization_endpoint": str(s.get("authorization_endpoint", "")).strip(),
"token_endpoint": str(s.get("token_endpoint", "")).strip(),
"jwks_uri": str(s.get("jwks_url", "")).strip(),
"end_session_endpoint": str(s.get("end_session_endpoint", "")).strip(),
}
self._meta: dict[str, Any] | None = None
self._verifier: TokenVerifier | None = None
# -- oppdagelse -------------------------------------------------------
def metadata(self) -> dict[str, Any]:
"""Discovery hentes først ved første innlogging, aldri ved oppstart –
tjenesten skal starte selv om crew-panelet er nede."""
if self._meta is not None:
return self._meta
meta = {k: v for k, v in self._manual.items() if v}
if not (meta.get("authorization_endpoint") and meta.get("token_endpoint")):
url = f"{self.issuer}/.well-known/openid-configuration"
try:
fetched = _get_json(url)
except Exception as e: # noqa: BLE001
raise AuthError(f"Fikk ikke hentet OIDC-oppsett fra {url} ({e})") from e
meta = {**fetched, **meta}
self._meta = meta
return meta
def verifier(self) -> TokenVerifier:
if self._verifier is None:
meta = self.metadata()
self._verifier = TokenVerifier(
{
"issuer": meta.get("issuer") or self.issuer,
"audience": self.client_id,
"secret": self.client_secret,
"jwks_url": meta.get("jwks_uri", ""),
"algorithms": [],
}
)
return self._verifier
# -- flyten -----------------------------------------------------------
def redirect_uri(self) -> str:
base = str(self.cfg.get("server", "public_url", "")).strip().rstrip("/")
if base:
return f"{base}/auth/callback"
return url_for("auth.callback", _external=True)
def authorize_url(self) -> str:
verifier = secrets.token_urlsafe(64)
state = secrets.token_urlsafe(24)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
session["oidc_state"] = state
session["oidc_verifier"] = verifier
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri(),
"scope": " ".join(self.scopes),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
endpoint = self.metadata()["authorization_endpoint"]
sep = "&" if "?" in endpoint else "?"
return f"{endpoint}{sep}{urllib.parse.urlencode(params)}"
def exchange(self, code: str, verifier: str) -> dict:
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri(),
"client_id": self.client_id,
"code_verifier": verifier,
}
headers = {}
if self.client_secret:
basic = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
headers["Authorization"] = f"Basic {basic}"
try:
return _post_json(self.metadata()["token_endpoint"], data, headers)
except Exception as e: # noqa: BLE001
raise AuthError(f"Kodebyttet feilet ({e})") from e
def principal(self, claims: dict) -> Principal:
roles = _roles(_claim(claims, self.role_claim))
if not roles and self.default_role:
roles = (self.default_role,)
sub = str(claims.get("sub") or claims.get("email") or "ukjent")
return Principal(
f"oidc:{sub}",
_display_name(claims, self.name_claim),
roles,
"oidc",
self.cfg.permissions(roles),
)
def describe(self) -> dict[str, Any]:
return {"method": "oidc", "label": self.label, "login_url": "/auth/login"}
PROVIDERS = {"mesh": MeshProvider, "pin": PinProvider, "jwt": JwtProvider, "oidc": OidcProvider}
# --------------------------------------------------------------------------
# HTTP-hjelpere (stdlib, så vi slipper en ekstra avhengighet)
# --------------------------------------------------------------------------
def _get_json(url: str, timeout: int = 10) -> dict:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
return json.loads(r.read().decode())
def _post_json(url: str, form: dict, headers: dict | None = None, timeout: int = 10) -> dict:
body = urllib.parse.urlencode(form).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
**(headers or {}),
},
)
with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
return json.loads(r.read().decode())
# --------------------------------------------------------------------------
# Authenticator
# --------------------------------------------------------------------------
class Authenticator:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self.providers: list[Provider] = [
PROVIDERS[name](cfg) for name in cfg.active_providers()
]
# mesh er maskin-til-maskin og teller ikke som innlogging for folk:
# en sone som bare har mesh-nøkkel skal fortsatt ha åpen side lokalt.
self.human: list[Provider] = [p for p in self.providers if p.name != "mesh"]
self.enabled = bool(self.human)
def provider(self, name: str) -> Provider | None:
return next((p for p in self.providers if p.name == name), None)
# -- anonym -----------------------------------------------------------
def anonymous(self) -> Principal:
"""Uten konfigurerte leverandører får anonyme alt – dagens oppførsel.
Med leverandører får de det [roles].anonymous sier, som er ingenting
som standard. Sett den til ["view"] om statusskjermen skal henge på
veggen uten innlogging."""
if not self.enabled:
# Tomt navn: da står køen og loggen som før, uten en «bruker» som
# i praksis bare betyr «hvem som helst». Og aldri «play»: den er
# for en sentral med mesh-nøkkel, ikke for hvem som helst som
# finner en åpen sone og vil spille lyd på den.
return Principal("anonymous", "", ("anonymous",), "none",
frozenset(ACTIONS) - {"play"})
roles = ("anonymous",)
return Principal("anonymous", "ikke innlogget", roles, "anonymous", self.cfg.permissions(roles))
# -- sesjon -----------------------------------------------------------
def from_session(self) -> Principal | None:
raw = session.get(SESSION_KEY)
if not isinstance(raw, dict):
return None
if raw.get("exp") and float(raw["exp"]) < time.time():
session.pop(SESSION_KEY, None)
return None
roles = tuple(str(r) for r in raw.get("roles", ()))
return Principal(
str(raw.get("subject", "?")),
str(raw.get("name", "?")),
roles,
str(raw.get("method", "jwt")),
# Rettigheter slås opp på nytt hver gang, så en endring i
# [roles] slår inn uten at folk må logge inn igjen.
self.cfg.permissions(roles),
)
def remember(self, p: Principal, seconds: int) -> None:
session.permanent = True
session[SESSION_KEY] = {
"subject": p.subject,
"name": p.name,
"roles": list(p.roles),
"method": p.method,
"exp": time.time() + seconds,
}
# -- oppslag ----------------------------------------------------------
def resolve(self) -> tuple[Principal, str]:
"""Returnerer (principal, feilmelding). Kaster ikke – /api/config skal
kunne spørre «hvem er jeg?» uten å få 401 i fleisen."""
p = self.from_session()
if p is not None:
return p, ""
for provider in self.providers:
try:
p = provider.authenticate(request)
except AuthError as e:
return self.anonymous(), str(e)
if p is not None:
return p, ""
return self.anonymous(), ""
def describe(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"methods": [p.describe() for p in self.human],
"logout_url": "/auth/logout" if self.enabled else None,
}
_auth: Authenticator | None = None
def current() -> Principal:
"""Principalen for denne forespørselen, slått opp én gang."""
if not hasattr(g, "principal"):
assert _auth is not None, "auth.install() er ikke kalt"
g.principal, g.auth_error = _auth.resolve()
return g.principal
def error() -> str:
current()
return getattr(g, "auth_error", "")
def require(action: str) -> Principal:
"""Enten har denne brukeren lov, eller så stopper forespørselen her.
401 når vi ikke vet hvem det er (klienten skal logge inn), 403 når vi vet
hvem det er men rollen mangler handlingen.
"""
p = current()
if error():
# Noen viste fram en PIN, et token eller en mesh-nøkkel som ikke
# holdt. Det er en feil, ikke en anonym bruker – også på en åpen
# sone, der anonym ellers hadde fått lov.
abort(401, error())
if p.can(action):
return p
if p.anonymous:
abort(401, _login_hint())
abort(403, f"Rollen din ({', '.join(p.roles) or 'ingen'}) har ikke lov til dette.")
def _login_hint() -> str:
assert _auth is not None
names = {p.name for p in _auth.human}
if names == {"pin"}:
return "Feil eller manglende PIN."
if "oidc" in names:
return "Du må logge inn."
return "Innlogging kreves."
# --------------------------------------------------------------------------
# Oppsett og ruter
# --------------------------------------------------------------------------
def secret_key(cfg: Config) -> bytes:
"""Fast nøkkel, ellers logges alle ut ved hver restart av tjenesten."""
configured = str(cfg.get("server", "secret_key", "")).strip()
if configured:
return configured.encode()
path = cfg.path("state") / "secret_key"
if path.is_file():
return path.read_bytes()
path.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_bytes(32)
path.write_bytes(key)
path.chmod(0o600)
log.info("lagde ny sesjonsnøkkel i %s", path)
return key
bp = Blueprint("auth", __name__)
@bp.get("/auth/login")
def login():
oidc = _auth.provider("oidc") if _auth else None
if not isinstance(oidc, OidcProvider):
abort(404, "OIDC er ikke satt opp")
try:
return redirect(oidc.authorize_url())
except AuthError as e:
abort(502, str(e))
@bp.get("/auth/callback")
def callback():
oidc = _auth.provider("oidc") if _auth else None
if not isinstance(oidc, OidcProvider):
abort(404, "OIDC er ikke satt opp")
if err := request.args.get("error"):
abort(401, f"{err}: {request.args.get('error_description', '')}".strip(": "))
state = session.pop("oidc_state", None)
verifier = session.pop("oidc_verifier", None)
if not state or not verifier or request.args.get("state") != state:
abort(400, "Innloggingen kom ut av takt. Prøv på nytt.")
code = request.args.get("code", "")
if not code:
abort(400, "Ingen kode fra identitetsleverandøren")
try:
tokens = oidc.exchange(code, verifier)
id_token = tokens.get("id_token")
if not id_token:
abort(502, "Fikk ikke id_token fra identitetsleverandøren")
claims = oidc.verifier().verify(id_token)
except AuthError as e:
abort(401, str(e))
p = oidc.principal(claims)
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
_auth.remember(p, max(ttl, 3600))
log.info("innlogget %s (%s) via oidc", p.name, p.subject)
return redirect("/")
@bp.get("/auth/token")
def token_login():
"""Bro mens crew-panelet mangler OIDC: panelet lenker hit med et kortlevd
JWT, vi verifiserer det og setter sesjonscookien. Ett klikk, ingen PIN."""
provider = _auth.provider("jwt") if _auth else None
if not isinstance(provider, JwtProvider):
abort(404, "JWT er ikke satt opp")
raw = request.args.get("t") or request.args.get("token") or ""
if not raw.strip():
abort(400, "Mangler token")
try:
claims = provider.verifier.verify(raw.strip())
except AuthError as e:
abort(401, str(e))
p = provider.principal(claims)
ttl = int(claims.get("exp", 0) - time.time()) if claims.get("exp") else 0
_auth.remember(p, max(ttl, provider.session_seconds))
log.info("innlogget %s (%s) via token-lenke", p.name, p.subject)
return redirect("/")
@bp.get("/auth/logout")
def logout():
session.clear()
oidc = _auth.provider("oidc") if _auth else None
if isinstance(oidc, OidcProvider) and oidc._meta:
if end := oidc._meta.get("end_session_endpoint"):
return redirect(end)
return redirect("/")
def reconfigure(cfg: Config) -> Authenticator:
"""Bytter ut leverandørene uten å røre Flask-appen. For tester som vil
prøve samme app med og uten innlogging."""
global _auth
_auth = Authenticator(cfg)
return _auth
def install(app, cfg: Config) -> Authenticator: # noqa: ANN001
"""Kobler autentisering på Flask-appen. Returnerer Authenticatoren."""
global _auth
_auth = Authenticator(cfg)
app.secret_key = secret_key(cfg)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
# Secure bare bak TLS: på rå HTTP over LAN/NetBird ville cookien
# aldri blitt sendt, og ingen ville klart å logge inn.
SESSION_COOKIE_SECURE=bool(cfg.get("server", "behind_tls", False)),
)
app.register_blueprint(bp)
return _auth