--- created: '2026-06-22' path: Sources/Dev project: petal-dispatch-phase-4-1-oidc-auth tags: - petal-dispatch - lovebug - phase-4 - oidc - authentik - traefik - cloudflare - android - kotlin - auth - p4 type: project-plan --- # petal-dispatch Phase 4.1 — OIDC Auth ## Goal Replace Tailscale-as-access-control with OIDC. The Android app authenticates against Authentik (Auth Code + PKCE); petal-dispatch validates the resulting JWTs. Tailscale drops out of the *access path* — the app reaches the backend over the public Cloudflare Tunnel with a bearer token instead of requiring the tailnet. Scoped narrowly to this auth change. Does **not** touch the Phase 4 REST+SSE rewrite beyond adding the validation middleware and the new hostname. **Supersedes prior auth lean.** Phase 4 prep (thread 588, checkpoint 1094) listed three auth paths — (a) tailnet-only via Tailscale, (b) Authentik OIDC + PKCE, (c) bearer-per-device — and leaned (a) for tempo. 4.1 deliberately picks **(b)**, the anticipated-but-deferred path, now that the app is real and "log in once, stay connected" is the goal. This is a chosen upgrade, not a contradiction. ## Scope **In:** - JWT validation middleware in petal-dispatch (app-layer) - Two-hostname split (public = authenticated, internal = bypass) - Cloudflare Tunnel route through Traefik for the public hostname - Android AppAuth integration (PKCE, token storage, silent refresh) - App Links verification (`assetlinks.json`) **Out:** - Web SSE client auth (stays tailnet-only via the internal hostname, untouched) - Any change to the Phase 4 transport rewrite itself - Migrating the MCP auth (separate, working, leave alone) ## Current State (thread 588 reconciliation) What already exists in production, so this builds on it rather than re-deriving: - **`OPEN_MODE=1` is live** (checkpoint 1093). App-layer auth is bypassed today; Traefik + Authentik gate the public path. The env flag name is `PETAL_DISPATCH_OPEN_MODE`. Flipping to `0` is a known pending decision. - **Traefik already routes `dispatch.herbylab.dev` -> `10.0.21.207:8001`** (herbys-dev). The IP-flip (`10.0.11.10 -> 10.0.21.207`) is committed as `6d3d586` in `traefik/petal-dispatch.yml` on `petal-power/petal-dispatch`. The public router target is already correct — this work adds the internal router and wires the tunnel in front, it does not re-point the existing route. - **Off-LAN access today is Tailscale split-DNS** (checkpoint 1098): `https://dispatch.herbylab.dev/` works from any tailnet device anywhere via Knot split-DNS + subnet routing. 4.1 replaces this *for the app* — the app stops needing the tailnet — while the web client keeps using it via the internal hostname. - **Streaming endpoint is `/events/stream`** (SSE) with `?from_seq=N` backfill and reconcile-key keyed render. A separate legacy `/ws` exists and is out of scope. SSE-through-tunnel testing targets `/events/stream` specifically. ## Resolved Architecture ### Topology ``` Internal: client (Host: dispatch-internal.herbylab.dev) -> Knot -> Traefik -> petal-dispatch :8001 [JWT bypassed] Public: client (Host: dispatch.herbylab.dev) -> CF edge -> cloudflared -> Traefik -> petal-dispatch :8001 [JWT required] ``` ### Decisions (settled) - **Auth lives in petal-dispatch app middleware** — it is the only component in both paths. Bearer tokens belong at the app, not Traefik forward-auth (which is browser/cookie-shaped and wrong for a native app). - **Two hostnames, one backend.** Public requires JWT; internal bypasses. - **Branch signal = Host header.** The app reads which hostname was requested and enforces or bypasses accordingly. - **Tunnel routes through Traefik** (not direct to `:8001`) — consistent with the "all traffic through Traefik" invariant. - **App always uses the public hostname** — it always authenticates, even on the tailnet at home. This keeps the app exercising the real auth path every time. - **`dispatch-internal` doubles as the dev bypass.** Dev tooling / curl / the web client target the internal name and skip auth. No separate enforcement toggle needed. ### Auth model (Authentik — already configured) - OAuth2/OIDC Provider: Client type **Public** (PKCE), signing key = default self-signed cert, encryption = none. - Application `petal-dispatch` bound to provider. - Authorization: `home-lab` group bound to the app; `trucktrav` (active, password set) is a member. - Long-lived refresh token configured; `offline_access` scope enabled for persistent "stay logged in" behavior. ### OIDC contract Full contract (issuer, jwks_uri, discovery, client_id, redirect, grant) lives at: ``` /opt/backups/petal-dispatch/android-auth.md ``` ## Security-Critical Config Dependencies These are the gotchas that make the Host-based branch *sound* rather than forgeable. All three must hold. - [ ] **cloudflared `httpHostHeader` = `dispatch.herbylab.dev`** so Traefik's public router matches and the app sees the real public Host. - [ ] **Traefik `passHostHeader: true`** (default) — confirm it is not overridden on the service, so the app receives the original Host. - [ ] **Internal router bound to a tailnet-only entrypoint.** The internal hostname must be unreachable from the tunnel side. If a router lets tunnel-originated traffic match `Host(dispatch-internal.herbylab.dev)`, a public attacker could spoof the bypass. This binding is what upgrades "app trusts the Host header" from fragile to sound. ## Tasks ### 1. Infrastructure — new internal hostname - [ ] Add Knot record for `dispatch-internal.herbylab.dev` (tailnet/LAN resolution only) - [ ] Add Traefik router `Host(dispatch-internal.herbylab.dev)` -> petal-dispatch `:8001` - [ ] Bind the internal router to a **tailnet-only entrypoint** - [ ] Confirm `passHostHeader: true` on the petal-dispatch service ### 2. Backend — JWT validation middleware - [ ] Add app-layer middleware to petal-dispatch - [ ] Fetch signing keys from `jwks_uri`; cache with sane refresh - [ ] Verify token signature - [ ] Validate claims: `iss` matches issuer, `aud` matches client_id, `exp` not expired - [ ] Branch on Host header: public hostname -> require valid JWT (401 otherwise); internal hostname -> bypass - [ ] Confirm middleware applies cleanly to **both REST and SSE** routes - [ ] Flip `PETAL_DISPATCH_OPEN_MODE=0` once validation is in place ### 3. Cloudflare Tunnel — public route through Traefik - [ ] Re-add `dispatch.herbylab.dev` route (removed earlier; CF has no disable toggle) - [ ] Service target = **Traefik VM**, not `localhost:8001` - [ ] Set `httpHostHeader` / Origin Server Name so Traefik matches + TLS validates against the wildcard cert - [ ] Decide HTTPS-to-origin vs HTTP-to-origin-on-trusted-LAN (open below) - [ ] **Verify SSE survives the tunnel** — test `/events/stream` with a real stream before app work. A buffering proxy that breaks mid-stream forces a reconnect; confirm `?from_seq=N` resume recovers cleanly (relevant given the prior SSE buffer-overrun history, checkpoint 1101). - [ ] Keep route dark until middleware enforces auth (never expose unauthenticated backend) ### 4. Android — AppAuth integration - [ ] Integrate AppAuth; consume discovery URL + client_id - [ ] Implement Auth Code + PKCE via system browser - [ ] Store access + refresh tokens securely - [ ] Silent refresh before access-token expiry - [ ] Attach bearer header to all REST + SSE requests - [ ] App targets the **public** hostname always ### 5. App Links — verified redirect - [ ] Host `assetlinks.json` at `dispatch.herbylab.dev/.well-known/` - [ ] Verify the App Links redirect (`https://dispatch.herbylab.dev/oauth/callback`) resolves end-to-end ## Build Order Reachability and auth are independent; the app needs both. Build to isolate one variable at a time: 1. **Backend middleware first** — testable over the tailnet (internal name bypasses, public name 401s). This becomes the test oracle. 2. **Internal hostname + Traefik router** — so the bypass path exists for dev/testing. 3. **Tunnel through Traefik** — reachability + SSE-through-tunnel verification on `/events/stream`, tested with curl/browser before auth is in the loop. 4. **Android app** — ties reachability + auth together; previously-401 endpoint returns 200 with a real token. Hard constraint: **tunnel-before-app** (app needs the public URL + hosted `assetlinks.json`). Backend-vs-tunnel order is flexible. ## Open Items - [ ] **Tunnel-to-origin TLS choice:** HTTPS end-to-end (purist; needs SNI/origin server name lined up with wildcard cert) vs HTTP-to-origin on trusted LAN (pragmatic; Cloudflare still encrypts the public leg). Decide before re-wiring. - [ ] Supply `client_id` into the OIDC contract / app config. ## Notes / Tradeoffs - **Tailnet stays trusted for the internal name.** Anyone on the tailnet reaches `dispatch-internal` with no auth — same trust model as today, not a regression. If the web client ever needs real auth, it gets the public name + browser OIDC in a later phase. - **Rejected:** trusting an `X-Internal`-style "pre-auth'd" header. A spoofable header would make the bypass forgeable. The Host-based branch is safe only because routing guarantees the internal name is internal-only (hence the entrypoint binding above). - **Rejected:** auth at Traefik forward-auth. Browser/cookie-shaped; the public tunnel path bypasses Traefik-as-gate anyway, and native apps want token-shaped auth. - petal-dispatch **never** sees the refresh token — that lives only between the app and Authentik. Backend validates the short-lived access token only.