219 lines
9.6 KiB
Markdown
219 lines
9.6 KiB
Markdown
---
|
|
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. |