Files
home-network-k3s-impl/runbooks/oauth2-proxy-session.md
jakman85andClaude Opus 5 0a0adce4d8
gate / gate (pull_request) Successful in 1m41s
gate / conformance (pull_request) Has been skipped
docs(auth): bound crafty's post-fix ratio to its own pod; record the metrics gap
Coordinator raised crafty as counter-evidence: it carries the cookie fix and
showed the platform's worst ratio (81:1 over 24h from Loki), implying the fix
does not work. Bounded it exactly instead of in Loki -- crafty's pod started
2026-09-30T03:57:19Z with 0 restarts, so a 24h window is ~10h of the PRE-fix
process. Read off the pod's own log, whose boundary is the process start:

  crafty  3 'No valid authentication', 0 AuthSuccess, in 13h52m   <- not 81
          one client IP, 2 of the 3 on /ws (WebSocket reconnects), two
          isolated minutes (04:02, 15:22)

Post-fix crafty is idle, not broken. Correctly bounded it is evidence FOR the
fix. The ratio metric ranks CHATTINESS, not breakage: radarr's 156:1 is
/api/v3/command (62), /api/v3/queue/details (46), /signalr/negotiate (43) --
UI polling. Added that trap to the runbook, because ranking apps this way is
how a deployed fix gets written off.

That polling also sharpens the jackett mechanism: every poll arriving with a
cookie radarr cannot decrypt calls ClearSession again, so a left-open radarr
tab deletes jackett's cookie every few seconds. Explains the intermittency.

Re-weighted the diagnosis as asked: mechanisms 2 and 3 (5-minute session with
no --cookie-refresh; 302-instead-of-401 on XHR) are demonstrated from
jackett's own log and between them fully explain the report. Mechanism 1
(collision) is firing and jackett genuinely violates it, but causation is one
3-second adjacency, not a population -- necessary and firing, not proven
sufficient. Stated that way in the entry.

Metrics: confirmed no per-app sidecar exposes any (--metrics-address ABSENT,
ports ['http'] on all six); only the shared proxy has the flag and a metrics
port. Worth adding and DECLINED HERE for the structural reason only -- the
container's args AND ports both come from _oauth2_sidecar_container(), so it
hits the identical five-file blast radius. It belongs in the same single
renderer commit. Recorded, not silently skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-30 12:52:12 -05:00

13 KiB

oauth2-proxy-session — telling the auth layers apart from the sidecar log

Not alert-driven. This is an operator procedure for the per-app oauth2-proxy sidecars (jackett, pihole, qbittorrent, radarr, sonarr, crafty) and the shared forward-auth proxy in namespace oauth2-proxy. Use it when a gated UI behaves as though you are signed in and signed out at the same time: the page loads, but its buttons and saves quietly do nothing.

The sidecar log contains email addresses, session UUIDs and full callback URLs including an OAuth code. Read it, do not paste it. (Repo rule §6: secrets never enter a transcript.)

What this means here

Every gated app on home.keithjackson.dev has at least two independent authentications stacked on it, and the symptom rarely names the one that refused:

  1. Keycloak — the homelab realm issues the token. Failures here are visible: you see a login form, or an error page from Keycloak's own hostname.
  2. The oauth2-proxy sidecar — validates the session cookie and the group claim (media-users / platform-admins). This is the layer that fails silently, and the rest of this page is about reading it.
  3. The app's own login, where it has one — Crafty and Radarr/Sonarr each present a local login behind the Keycloak gate. See runbooks/crafty-admin-credential.md for that layer.

The sidecar's access log distinguishes all three, if you know the four lines to look for. Get it:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n jackett logs \
  -l app.kubernetes.io/name=jackett -c oauth2-proxy --tail=-1'

Substitute the namespace and label for the app you are chasing. Always pass --context k3s-prod: dev.sh merges both clusters and picks a current-context per invocation, and that default has already changed once. Do not run two dev.sh invocations at the same time.

The four lines, and what each one means

Log line What actually happened Which layer
[AuthSuccess] Authenticated via OAuth2: Session{... groups:[...]} Keycloak issued a token and the proxy accepted it. The IdP is fine. Keycloak ✔
[oauthproxy.go:1026] No valid authentication in request. Initiating login. No session cookie was presented at all. Either you never had one, or something deleted it — see below. sidecar
[stored_session.go:94] Error loading cookied session: cookie signature not valid, removing session A cookie of the right name arrived that this proxy could not decrypt. Almost always a different proxy's cookie. Note removing session: this proxy then deletes it. sidecar, cookie collision
an upstream path of - instead of / in an access-log line the proxy answered the request itself; it never reached the app. Compare POST - "/api/..." against POST / "/api/...". sidecar

That last column is the one people miss. The upstream field sits between the method and the quoted path. - means the proxy handled it (a redirect, a 401, its own /oauth2/* endpoints); / means it was proxied to the app. Count them:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n jackett logs \
  -l app.kubernetes.io/name=jackett -c oauth2-proxy --tail=-1 \
  | grep -v "\"/ping\"" | grep -E " (POST|PUT|DELETE) " | cut -c1-140'

If your writes show -, the app never saw them. On 2026-09-30, 4 of the 6 POSTs in jackett's log had -.

First commands

1. Did Keycloak ever succeed? No AuthSuccess at all means this is not a cookie problem — go to runbooks/keycloak-down.md or runbooks/keycloak-client-secrets.md.

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n <ns> logs \
  -l app.kubernetes.io/name=<app> -c oauth2-proxy --tail=-1 | grep -c AuthSuccess'

2. How long does a session last? Subtract created from expires on an AuthSuccess line. If it is exactly 5m, the session's lifetime is Keycloak's access-token lifetime and the proxy is not refreshing it:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n <ns> logs \
  -l app.kubernetes.io/name=<app> -c oauth2-proxy --tail=-1 \
  | grep AuthSuccess | tail -1 | grep -o "created:[^ ]* [^ ]*\|expires:[^ ]* [^ ]*"'

The same line says refresh_token:true — the proxy is holding a refresh token. It will only spend it if --cookie-refresh is set; the default is 0, meaning disabled.

3. Is this a cookie collision? Two checks, and you want both.

Names and scopes — every instance that emits the default name _oauth2_proxy on the shared .home.keithjackson.dev domain is in the collision set with every other one:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pods -A -o json \
  | grep -E "cookie-name|cookie-domain"'

An instance with no --cookie-name argument is using the default. That is the failure — the flag being absent, not wrong.

The smoking gun — search every proxy, not just the one you are debugging, for the removal line, and put the timestamps next to each other:

infra/devcontainer/dev.sh run bash -c 'for ns in jackett pihole qbittorrent radarr sonarr crafty; do \
  echo "== $ns"; kubectl --context k3s-prod -n $ns logs -l app.kubernetes.io/name=$ns \
  -c oauth2-proxy --tail=-1 2>/dev/null | grep stored_session; done'

4. Do the cookie-secrets actually differ? They should — but confirm the collision is between instances that genuinely cannot read each other. Compare hashes, never values:

infra/devcontainer/dev.sh run bash -c 'for ns in jackett pihole qbittorrent radarr sonarr crafty; do \
  printf "%s " "$ns"; kubectl --context k3s-prod -n $ns get secret $ns-oauth2-proxy \
  -o jsonpath="{.data.cookie-secret}" | sha256sum | cut -c1-12; done'

Never print .data or .stringData itself. A password has been leaked into a transcript here before.

Do not rank apps by their failure ratio

Counting No valid authentication against AuthSuccess per app looks like a health metric and is not one. It ranks chattiness, not breakage. An app whose UI polls or holds a WebSocket manufactures one failure line per retry, so a single dead cookie in one idle browser tab becomes scores of log lines, while an app nobody is using scores zero no matter how broken it is. Measured 2026-09-30: Radarr showed 156 unauthenticated requests against 1 AuthSuccess, but the top paths were /api/v3/command (62), /api/v3/queue/details (46) and /signalr/messages/negotiate (43) — polling, not 156 user-visible failures.

Bound the window to the current pod, not to wall-clock. A ratio measured over 24 h across a pod that restarted 13 h ago is mostly about the previous configuration, and that is exactly how a deployed fix gets written off as ineffective. The pod's own log has an exact boundary — its process start — so read it there:

infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n <ns> get pod \
  -l app.kubernetes.io/name=<app> -o jsonpath="{.items[*].status.startTime}{\"\n\"}"'

then count within that same pod's log. On 2026-09-30 this took Crafty from an apparent 81:1 over 24 h to 3 failures in its fixed pod's entire 13 h 52 m life, two of them /ws reconnects.

Likely causes, most likely first

  1. Cookie collision between two per-app proxies. Several instances emit the default _oauth2_proxy on .home.keithjackson.dev with different cookie-secrets. Whoever authenticates last overwrites the rest; the losers log cookie signature not valid, removing session and — because removing session clears the cookie on their --cookie-domain, which is the whole zone — delete a session belonging to an app in another namespace. The victim then logs No valid authentication with no cookie at all, seconds after a clean login, while its own expires is still minutes away. Read both logs side by side; one app's log alone makes this look like random session loss. Fix: a unique --cookie-name and a host-scoped --cookie-domain on the per-app sidecar. The shared forward-auth proxy keeps the zone-wide scope — cross-host auth is its job.
  2. Session pinned to a 5-minute access token with no refresh. --cookie-refresh unset means the proxy never spends the refresh token it holds, so the session expires with the access token. Set it to about half the measured token lifetime.
  3. An XHR got a 302 instead of a 401. The app's JavaScript follows the redirect invisibly, the live Keycloak SSO session satisfies it without a prompt, and the XHR resolves with HTML where it expected JSON. Nothing is logged as an error and the operator is never re-prompted — this is what "it just doesn't save, and reloading doesn't help" looks like. Worse on a write: a browser following a 302 re-issues a POST as a GET and drops the body, so the change is discarded silently. oauth2-proxy's built-in AJAX guard only fires on an exact Accept: application/json, which most app JavaScript does not send; make it deterministic with --api-route=<regex> (singular — --api-routes does not exist and the proxy refuses to start). Keep browser-navigable paths out of the regex so a plain page load still signs you in.
  4. Group membership. An AuthSuccess followed by 403 is the --allowed-group gate, not a session problem. Check the groups:[...] field on the AuthSuccess line.
  5. The app's own second login. Crafty, Radarr and Sonarr each have one. AuthSuccess followed by the app's own /login page is layer 3, not layer 2.

When to escalate or stop

  • Stop before you kubectl edit/patch/apply anything. Flux reconciles main; a hand-applied flag is drift that the next reconcile reverts or, worse, adopts. Argument changes go through a branch and a PR. Reading logs is always safe; changing the Deployment is not an agent action.

  • Do not hand-edit apps/**/<app>.yaml — those files are GENERATED. The five per-app sidecars (jackett, pihole, qbittorrent, radarr, sonarr) get their argument list from one shared function, _oauth2_sidecar_container() in catalog/render.py; catalog/<app>.yaml declares only auth.mode and auth.groups, and the auth schema is additionalProperties: false, so there is no per-app override hook. A hand-edit to the rendered file is caught by the merge gate's render-golden stage ("GOLDEN DRIFT ... does not match the canonical render") and would be erased by the next render anyway. Change the renderer, then regenerate:

    python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation
    

    Budget for the blast radius: because the function is shared, any change to it rewrites the sidecar block in all five rendered apps, and the gate requires all five be regenerated in the same commit. There is no way to change one app's sidecar arguments alone. Measured 2026-09-30. Coordinate before starting — this is a single change, not five.

  • Crafty is the exception and is not rendered from the catalog: its sidecar is hand-written in platform/crafty/deployment.yaml, so it can be, and was, fixed independently.

  • You cannot currently alert on any of this, and that is worth knowing before you promise monitoring. Measured 2026-09-30: no per-app sidecar sets --metrics-address and none declares a metrics container port, so oauth2-proxy's metrics listener never starts — the only oauth2-proxy series in Prometheus come from the shared proxy in namespace oauth2-proxy, which does set both. Until the renderer emits them for the per-app sidecars, every diagnosis on this page is log-reading only.

  • Validate an argument change before you open the PR, not after. The pinned image will tell you what it understood, without touching the cluster:

    podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help
    

    Running it with your full proposed argument list prints a Cookie settings: line and an API route - Path: line back at you, and rejects mutually exclusive flags outright (--session-cookie-minimal with --cookie-refresh, for one). A flag typo here is a CrashLoop on a service you have just told someone is fixed.

  • Do not rotate a cookie-secret to "clear" a bad session. It signs every live session out of that app and does nothing about the collision, which is caused by the names matching, not the secrets differing.

  • Escalate to Keycloak (runbooks/keycloak-down.md, runbooks/keycloak-client-secrets.md) if there is no AuthSuccess in any proxy's log — that is an IdP or client-secret problem and no cookie flag will help.

  • Hand off rather than guess if you see cookie signature not valid on a proxy that already has a unique --cookie-name. That combination should not occur; it means something is writing that name which is not in this inventory. Record what you measured.