# Authentication decisions (`AUTH`) Decisions behind identity resolution and the OIDC configuration guards. For configuration, see the [security guidance](../../install/security.md) and the [backend settings reference](../../install/backend.md). 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 `exec`ing 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.