Parent: #54 · ADR: docs/adr/0001-collapse-edge-auth-into-fastapi.md
Wave 1 / additive. Doesn't break any existing edge-auth install. Lands before Child B (Caddy reduction).
What to build
A new hal0.api.auth.password module + middleware extensions:
Storage
- Extend the existing auth store (currently the JSON token file in
HAL0_HOME) with a password_hash: str | None field. One file, not two.
- Bcrypt cost factor 12.
Endpoints
POST /api/auth/login — body {username, password}; on success sets a signed session cookie (HttpOnly, SameSite=Lax, Secure only when TLS terminated). Returns {user, scope}. 401 on bad creds.
POST /api/auth/logout — clears the session cookie. 204.
POST /api/auth/password — body {password}; sets/rotates the password. Requires writer scope when a password exists; allowed without auth when password_hash is None (the first-run "claim ownership" path).
GET /api/auth/status — already exists; extend payload with password_set: bool, auth_mode: "open" | "password".
Middleware
- Session cookie validates identically to a Bearer token in
require_token / require_writer. Browser UI uses cookie, programmatic clients keep using Bearer. The middleware should look at cookie first, fall back to Authorization: Bearer ….
- Cookie payload is a signed token (HS256 with a secret derived from
HAL0_HOME/keyring — generate if missing). Treats expiry consistently with how token expiry is handled today.
- CSRF: when the cookie is used to authenticate a writer route, require either an
X-Requested-With: XMLHttpRequest header or a CSRF token. Bearer auth bypasses (Bearer can't be sent cross-origin from a browser without explicit opt-in).
Tests
tests/api/test_password_auth.py — login happy/sad path, set-password first-run vs rotate, logout, cookie/Bearer interchangeability against an existing writer-protected route, CSRF rejection on cookie-only writer call.
Don't touch
- The Caddyfile or the
--auth=basic install flag — that's Child B's job.
PUBLIC_PATHS — that's Child B's job too.
- The wizard UI — that's wired up in Child B alongside the Caddy/install changes.
Acceptance
pytest tests/api/test_password_auth.py green.
- Full suite still green (no regression on token-only auth).
GET /api/auth/status returns password_set: false, auth_mode: "open" on a fresh install.
- A user can
curl -X POST /api/auth/password -d '{"password":"..."}' once (no auth, since none set), then subsequent POST /api/auth/password requires either login cookie or writer Bearer.
Branch / PR
Parent: #54 · ADR: docs/adr/0001-collapse-edge-auth-into-fastapi.md
Wave 1 / additive. Doesn't break any existing edge-auth install. Lands before Child B (Caddy reduction).
What to build
A new
hal0.api.auth.passwordmodule + middleware extensions:Storage
HAL0_HOME) with apassword_hash: str | Nonefield. One file, not two.Endpoints
POST /api/auth/login— body{username, password}; on success sets a signed session cookie (HttpOnly, SameSite=Lax,Secureonly when TLS terminated). Returns{user, scope}. 401 on bad creds.POST /api/auth/logout— clears the session cookie. 204.POST /api/auth/password— body{password}; sets/rotates the password. Requires writer scope when a password exists; allowed without auth whenpassword_hash is None(the first-run "claim ownership" path).GET /api/auth/status— already exists; extend payload withpassword_set: bool,auth_mode: "open" | "password".Middleware
require_token/require_writer. Browser UI uses cookie, programmatic clients keep using Bearer. The middleware should look at cookie first, fall back toAuthorization: Bearer ….HAL0_HOME/keyring— generate if missing). Treats expiry consistently with how token expiry is handled today.X-Requested-With: XMLHttpRequestheader or a CSRF token. Bearer auth bypasses (Bearer can't be sent cross-origin from a browser without explicit opt-in).Tests
tests/api/test_password_auth.py— login happy/sad path, set-password first-run vs rotate, logout, cookie/Bearer interchangeability against an existing writer-protected route, CSRF rejection on cookie-only writer call.Don't touch
--auth=basicinstall flag — that's Child B's job.PUBLIC_PATHS— that's Child B's job too.Acceptance
pytest tests/api/test_password_auth.pygreen.GET /api/auth/statusreturnspassword_set: false, auth_mode: "open"on a fresh install.curl -X POST /api/auth/password -d '{"password":"..."}'once (no auth, since none set), then subsequentPOST /api/auth/passwordrequires either login cookie or writer Bearer.Branch / PR
feat/adr-0001-a-password-authfeat(auth): password + session cookies, dual cookie/Bearer middleware (ADR-0001 Child A — refs #54)