mcp: project-plan — petal-dispatch Phase 4.1 — OIDC Auth

This commit is contained in:
Lovebug MCP 2026-06-22 21:13:32 +00:00
parent cb113f75c5
commit 02a0b85e91

View File

@ -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.