wiki-vault/Sources/Dev/petal-dispatch-phase-4-1-oidc-auth.md

9.6 KiB

created path project tags type
2026-06-22 Sources/Dev petal-dispatch-phase-4-1-oidc-auth
petal-dispatch
lovebug
phase-4
oidc
authentik
traefik
cloudflare
android
kotlin
auth
p4
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
  • 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.