diff --git a/Sources/Dev/petal-dispatch-phase-4-1-oidc-auth.md b/Sources/Dev/petal-dispatch-phase-4-1-oidc-auth.md new file mode 100644 index 0000000..381e5ab --- /dev/null +++ b/Sources/Dev/petal-dispatch-phase-4-1-oidc-auth.md @@ -0,0 +1,219 @@ +--- +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. \ No newline at end of file