Authentication decisions (AUTH)#
Decisions behind identity resolution and the OIDC configuration guards. For configuration, see the security guidance and the backend settings reference.
Primary code: reporting/authnz/__init__.py, reporting/services/oauth_client.py,
reporting/routes/auth.py.
AUTH-001 — A split-hostname issuer mismatch is reported, never reconciled#
Applies to: oauth_client.verify_issuer_consistency, OIDC_INTERNAL_AUTHORITY
Durable identity is (iss, sub) exactly as the token carries it. When
OIDC_INTERNAL_AUTHORITY differs from OIDC_AUTHORITY and the two authorities
advertise different issuers, Seizu logs the mismatch at startup (fatal only
under OIDC_REQUIRE_CONSISTENT_ISSUER) and otherwise leaves the two identities
alone. It does not rewrite the internal issuer to the external one before
get_or_create_user.
Why the guard exists: an IDP that derives its issuer from the request host —
Authentik does — forks one human into two user records, one per authentication
path, and every owner-scoped surface diverges silently: private reports, query
history, chat threads (namespaced by user_id), scheduled chats, and MCP action
confirmations. The observed symptom is an MCP-initiated confirmation that 404s
as “Confirmation not found” when opened in the browser, with no other signal
anywhere. The startup comparison turns a class of mystery duplicate accounts
into one line in the deploy log.
Why not canonicalize the issuer: aliasing the internal issuer onto the
external one would silently make user identity a function of mutable deployment
config — editing OIDC_AUTHORITY, or dropping the internal one, would re-key
every existing record. Nothing in the token proves the two URLs are the same
IDP; only the IDP’s own configuration does. Pinning JWT_ISSUER is not a fix
either: it pins exactly one value, so the other path 401s outright. With two
issuers in play there is no correct single setting, so the fix belongs at the
IDP, and Seizu’s job is to say so loudly.
Why best-effort, and advisory by default: the backend frequently cannot
reach the external hostname — that unreachability is the entire reason
OIDC_INTERNAL_AUTHORITY exists — so an unverifiable check downgrades to a
warning rather than taking the app down, and an already-running deployment with
this shape keeps serving while it is fixed.
Don’t: make the check fatal by default, block startup on a discovery fetch
failure, or normalize iss anywhere between token validation and
get_or_create_user.
AUTH-002 — The dev stack reaches Authentik only as localhost:9000#
Applies to: scripts/dev_oidc_loopback.py, scripts/dev_entrypoint.sh
The backend container runs a small stdlib forwarder on its own loopback :9000,
pointed at authentik-server:9000 by DEV_OIDC_LOOPBACK_TARGET, so the
backend’s discovery and token exchange use the same URL the browser, MCP clients
and the CLI use. OIDC_INTERNAL_AUTHORITY is therefore unset in dev, and
JWKS_URL points at localhost:9000 like everything else.
dev_entrypoint.sh supervises the forwarder and gunicorn together rather than
backgrounding one and execing the other: whichever exits first takes the
container down, and SIGTERM is forwarded so shutdown isn’t a kill-timeout wait.
Without that, a forwarder that failed to bind or crashed later would leave a
container passing its port-8080 healthcheck with authentication quietly broken —
the same silent failure this entry exists to remove. Verified by killing the
forwarder: gunicorn is signalled and the container exits 137.
Supervision is why the forwarder is also gated on
DEVELOPMENT_ONLY_REQUIRE_AUTH (parsed exactly as bool_env does, unset
included): tying the container’s life to a process is only defensible where that
process is load-bearing. The default unauthenticated stack would otherwise be
taken down by the failure of a forwarder nothing was using.
Why: Authentik has no fixed-issuer setting — OAuth2Provider.get_issuer()
calls request.build_absolute_uri(), so iss and every advertised endpoint
follow the request Host, and issuer_mode only selects the path. Reaching it
under a second hostname is therefore enough to fork one login into two users
(AUTH-001), which is what made an MCP-initiated confirmation unopenable from the
dev browser. This is split-horizon DNS in miniature: one name and port, resolved
differently inside the network than outside, which is how the problem is solved
in production.
Rejected — spoofing the Host header on the backend’s OIDC calls: httpx
will do it, but over HTTPS it forces a choice between a certificate valid for
the internal name and an SNI override for the external one, and any Host-
routing proxy in front of the IDP may answer 421. A dev-only convenience is not
worth that in the shared auth path.
Rejected — a DNS name resolving to 127.0.0.1 (idp.localtest.me) with a
docker network alias: it needs no hosts-file edit and was verified working,
but the issuer includes the port, so any quickstart that forwards the IDP on
a port other than 9000 (a VM port-forward, say) re-forks it under a new name.
It also makes first-run depend on public DNS, which fails offline and under
resolvers with DNS-rebinding protection.
Rejected — a sidecar container sharing the backend’s network namespace: the
original implementation, a socat service with network_mode: "service:seizu".
It works until anything recreates the backend — which scripts/chat_harness.py
does routinely — and then fails in the worst available way: the sidecar keeps
reporting running while attached to the dead namespace, so nothing restarts
it, and the new backend has nothing on localhost:9000. Measured directly:
after up -d --force-recreate --no-deps seizu, discovery from inside the new
container returned ConnectError [Errno 111] Connection refused with the
sidecar still listed as up. Running in-process also drops a root container with
default capabilities out of the backend’s network namespace, where the loopback
OIDC exchange is plaintext HTTP.
Don’t: add a second hostname for Authentik to the dev config — including a
“just for the backend” one. Binding both 127.0.0.1 and ::1 is load-bearing:
localhost resolves to ::1 first inside the container, and an IPv4-only
listener gets connection-refused for half the lookups.
AUTH-003 — Optional local services reuse Authentik without reusing Seizu’s client#
Applies to: .config/dev/authentik/blueprints/seizu.yaml,
.config/dev/external-mcp-proxy/, the external-mcp Compose profile
The local external MCP OAuth proxy has its own confidential Authentik client,
because Obot requires a client secret while Seizu and its CLI intentionally use
a public PKCE/device client. Both applications use the same Authentik tenant,
users, and browser SSO session. The proxy’s supervised in-container loopback
forwarder reaches Authentik as localhost:9000, matching AUTH-002; the GitHub
PAT remains a separate credential used only on the proxy-to-GitHub MCP hop.
For the single-user local profile, make external_mcp_login registers a
per-login public PKCE client with Obot, completes authorization through that
same Authentik application, and stores the resulting proxy bearer in the local
.env; it never handles or replaces the GitHub PAT.
Why not a Google development client: it splits local authentication across two identity providers, makes the external-MCP path test a different login and session model from Seizu, and requires every developer to provision unrelated cloud OAuth credentials.
Why not reuse the seizu OAuth client: changing that public client to
confidential would break the CLI’s device flow and the no-secret PKCE contract.
A separate client is the standard OAuth boundary and still provides unified
SSO because Authentik owns both applications.
Don’t: point the proxy at authentik-server:9000; Authentik derives its
issuer from that host and creates the split-host identity described by AUTH-001.