9.6 KiB
| created | path | project | tags | type | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-06-22 | Sources/Dev | petal-dispatch-phase-4-1-oidc-auth |
|
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=1is live (checkpoint 1093). App-layer auth is bypassed today; Traefik + Authentik gate the public path. The env flag name isPETAL_DISPATCH_OPEN_MODE. Flipping to0is 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 as6d3d586intraefik/petal-dispatch.ymlonpetal-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=Nbackfill and reconcile-key keyed render. A separate legacy/wsexists and is out of scope. SSE-through-tunnel testing targets/events/streamspecifically.
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-internaldoubles 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-dispatchbound to provider. - Authorization:
home-labgroup bound to the app;trucktrav(active, password set) is a member. - Long-lived refresh token configured;
offline_accessscope 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.devso 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: trueon 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:
issmatches issuer,audmatches client_id,expnot 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=0once validation is in place
3. Cloudflare Tunnel — public route through Traefik
- Re-add
dispatch.herbylab.devroute (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/streamwith a real stream before app work. A buffering proxy that breaks mid-stream forces a reconnect; confirm?from_seq=Nresume 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.jsonatdispatch.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:
- Backend middleware first — testable over the tailnet (internal name bypasses, public name 401s). This becomes the test oracle.
- Internal hostname + Traefik router — so the bypass path exists for dev/testing.
- Tunnel through Traefik — reachability + SSE-through-tunnel verification
on
/events/stream, tested with curl/browser before auth is in the loop. - 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_idinto the OIDC contract / app config.
Notes / Tradeoffs
- Tailnet stays trusted for the internal name. Anyone on the tailnet reaches
dispatch-internalwith 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.