Phase 16: Petal authenticates for itself
Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+85
-7
@@ -126,12 +126,89 @@ real suggestions in ~3s over the VPN.
|
||||
|
||||
---
|
||||
|
||||
## 4. Interim edge gate (delete when Phase 16 lands)
|
||||
## 4. Sign-in (Authentik OIDC)
|
||||
|
||||
Petal authenticates nobody yet — `StaticResolver` hands every request the same
|
||||
`local` user. On a public host that means anyone who finds the hostname can read
|
||||
and write documents and fill the disk with image uploads, so Traefik holds the
|
||||
door with basic auth until the OIDC flow exists.
|
||||
Petal is an OIDC client in its own right: it runs the login itself rather than
|
||||
trusting a header from the proxy. Nothing about the container has to be
|
||||
unreachable for that to be safe.
|
||||
|
||||
Login turns on only when `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID` and
|
||||
`AUTHENTIK_CLIENT_SECRET` are all set. With any of them missing Petal falls back
|
||||
to the single hardcoded `local` user — which is what local development wants,
|
||||
and what makes the interim edge gate below still necessary until this is
|
||||
configured.
|
||||
|
||||
### Register Petal in Authentik
|
||||
|
||||
In the Authentik admin UI (**Applications → Providers → Create → OAuth2/OpenID
|
||||
Provider**):
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Client type | Confidential |
|
||||
| Redirect URI | `https://petal.parodia.dev/auth/callback` (strict) |
|
||||
| Scopes | `openid`, `profile`, `email` |
|
||||
| Signing key | any (Petal fetches the JWKS from discovery) |
|
||||
|
||||
Then create an **Application** bound to that provider, and copy the client id,
|
||||
the client secret, and the provider's **OpenID Configuration Issuer** (it looks
|
||||
like `https://auth.parodia.dev/application/o/petal/` — the issuer, not the
|
||||
`.well-known` URL; Petal appends that itself).
|
||||
|
||||
Put them in `.env`:
|
||||
|
||||
```
|
||||
AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/
|
||||
AUTHENTIK_CLIENT_ID=…
|
||||
AUTHENTIK_CLIENT_SECRET=…
|
||||
PETAL_ALLOWED_SUBS=her@example.com,me@example.com
|
||||
```
|
||||
|
||||
`PETAL_ALLOWED_SUBS` is the guest list: comma-separated OIDC subject ids and/or
|
||||
email addresses. Authentik fronts several applications on this host, and being a
|
||||
valid user there does not mean being a user here. Leaving it empty lets in
|
||||
everyone Authentik authenticates. Emails are accepted alongside subject ids
|
||||
precisely so the list can be written *before* anyone has logged in — a subject
|
||||
is an opaque uuid that doesn't exist until first sign-in.
|
||||
|
||||
A valid login that isn't on the list gets a warm bilingual "this Petal isn't
|
||||
yours to write in" page, and no account is provisioned.
|
||||
|
||||
### Checking it
|
||||
|
||||
```bash
|
||||
curl -si https://petal.parodia.dev/api/docs | head -1 # 401 without a session
|
||||
curl -si https://petal.parodia.dev/auth/login | grep -i location # → Authentik
|
||||
docker compose logs petal | grep '^.*auth:' # issuer + redirect at boot
|
||||
```
|
||||
|
||||
The startup log prints the redirect URI it will use; if Authentik rejects the
|
||||
login with a redirect-uri mismatch, compare that line against what's registered.
|
||||
|
||||
Discovery is lazy and retried, so an Authentik outage blocks *new* logins but
|
||||
leaves existing sessions working — those only need Petal's own database.
|
||||
|
||||
### Sessions
|
||||
|
||||
Opaque token in a `petal_session` cookie (`HttpOnly`, `SameSite=Lax`, `Secure`
|
||||
on https); the `sessions` table stores only its SHA-256, so a database copy
|
||||
yields nothing usable. Thirty-day sliding expiry — every request pushes it out,
|
||||
throttled to one write an hour. `/auth/logout` deletes the row, not just the
|
||||
cookie. Expired rows are pruned at startup.
|
||||
|
||||
To sign someone out everywhere immediately:
|
||||
|
||||
```bash
|
||||
docker compose exec petal sh -c \
|
||||
"sqlite3 /data/petal.db \"DELETE FROM sessions WHERE user_id = '<sub>'\""
|
||||
```
|
||||
|
||||
### Interim edge gate (delete once the above is configured)
|
||||
|
||||
Until `AUTHENTIK_*` is filled in, Petal authenticates nobody — `StaticResolver`
|
||||
hands every request the same `local` user. On a public host that means anyone who
|
||||
finds the hostname can read and write documents and fill the disk with image
|
||||
uploads, so Traefik holds the door with basic auth.
|
||||
|
||||
Generate a credential:
|
||||
|
||||
@@ -141,8 +218,9 @@ htpasswd -nbB petal 'your-password' # or any bcrypt htpasswd generator
|
||||
|
||||
and put the resulting `user:hash` pair in `.env` as `PETAL_BASIC_AUTH`.
|
||||
|
||||
When Phase 16 lands, delete the `petal-auth` middleware label, the
|
||||
`petal-health` router labels, and this section.
|
||||
Once OIDC is configured and a real login works, delete the `petal-auth`
|
||||
middleware label, the `petal-health` router labels, and this subsection. Keeping
|
||||
both is harmless but means two passwords to get to one editor.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,10 +54,17 @@ TTS_VOICE_ZH=zh_CN-huayan-medium
|
||||
TTS_AUDIO_FORMAT=mp3
|
||||
TTS_TIMEOUT=15s
|
||||
|
||||
# --- Auth (Phase 16 — not wired yet) -----------------------------------------
|
||||
# Authentik already runs on this host. Filled in when the OIDC flow lands.
|
||||
# SESSION_SECRET=
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev
|
||||
# --- Auth (Authentik OIDC) ---------------------------------------------------
|
||||
# Authentik already runs on this host. Set all three and Petal authenticates
|
||||
# for itself; leave any unset and it falls back to the single `local` user
|
||||
# (which on a public host means the Traefik basic-auth gate must stay).
|
||||
#
|
||||
# AUTHENTIK_URL is the provider's issuer, and the redirect URI to register in
|
||||
# Authentik is https://petal.parodia.dev/auth/callback.
|
||||
# AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/
|
||||
# AUTHENTIK_CLIENT_ID=petal
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
#
|
||||
# Who may sign in: comma-separated subject ids and/or emails. Empty = anyone
|
||||
# Authentik authenticates, which is wider than this instance wants.
|
||||
# PETAL_ALLOWED_SUBS=
|
||||
|
||||
Reference in New Issue
Block a user