Skip to content

feat(auth): password + session cookies, dual cookie/Bearer middleware (ADR-0001 Child A — refs #54)#58

Merged
thinmintdev merged 1 commit into
mainfrom
feat/adr-0001-a-password-auth
May 17, 2026
Merged

feat(auth): password + session cookies, dual cookie/Bearer middleware (ADR-0001 Child A — refs #54)#58
thinmintdev merged 1 commit into
mainfrom
feat/adr-0001-a-password-auth

Conversation

@thinmintdev
Copy link
Copy Markdown
Contributor

Summary

Wave 1 of ADR-0001 — additive FastAPI password auth + signed session cookies, alongside the existing bearer-token surface. No existing edge-auth install is broken; the Caddyfile, PUBLIC_PATHS, and --auth=basic installer flag are explicitly untouched and reserved for Child B (#56).

Parent: #54 · This issue: #55 · ADR: docs/adr/0001-collapse-edge-auth-into-fastapi.md

Does not close #54 — Children B (#56) and C (#57) must also land before #54 can close.

New endpoints (all under /api/auth)

Method Path Auth Notes
POST /login public Validates {username, password} and sets hal0_session cookie (HttpOnly, SameSite=Lax, Secure-when-TLS)
POST /logout public Clears the cookie; 204
POST /password conditional Public iff no password set (first-run claim ownership); writer scope thereafter; min length 8
GET /status public Extended with password_set: bool and auth_mode: "open" | "password"
GET /login public Legacy compat shim — returns a hint pointing at the POST endpoint

Middleware changes (hal0.api.middleware.auth)

  • require_token grows a session-cookie path: precedence is Bearer → cookie → X-Forwarded-Email. Present-but-invalid cookies hard-fail with auth.invalid (clean re-login path).
  • require_writer / require_admin enforce a CSRF tripwire when auth came via cookie and the verb mutates: either X-Requested-With: XMLHttpRequest or X-CSRF-Token matching the session cookie's first 16 chars. Bearer auth bypasses the check (per the ADR's threat model). GET/HEAD/OPTIONS are exempt (RFC 7231 §4.2.1).
  • New AuthIdentity.source = "session" plus session_token field on the dataclass for the CSRF binding.
  • New typed error CSRFRequired (auth.csrf_required, 403).

Storage

  • TokenStore gains a top-level password_hash: str | None field. get_password_hash() / set_password_hash() round-trip through the existing atomic-write path so a single tokens.toml backup covers both surfaces.
  • Session signing key generated on first use under HAL0_HOME/etc/hal0/keyring (secrets.token_urlsafe(32), atomic write, mode 0600).
  • New deps in pyproject.toml: bcrypt>=4.0 (cost 12, parity with Caddy basicauth format), PyJWT>=2.8 (HS256).

Tests

tests/api/test_password_auth.py20 new tests, all passing. Covers:

  • First-run set-password without auth
  • Second set-password without auth → 401
  • Second set-password with writer Bearer → 200
  • Login happy path, wrong password, wrong username, login before any password set
  • Cookie + X-Requested-With writer call → 200
  • Cookie + X-CSRF-Token writer call → 200
  • Cookie writer call without tripwire → 403 auth.csrf_required
  • Cookie with garbage CSRF token → 403
  • Bearer writer call without tripwire → 200 (CSRF bypass)
  • Cookie reader call without tripwire → 200 (safe verbs exempt)
  • Logout clears cookie; subsequent writer falls to 401
  • Invalid cookie → 401 auth.invalid
  • HttpOnly + SameSite=Lax always present
  • Secure flag set when X-Forwarded-Proto: https
  • /api/auth/status reports password_set and auth_mode correctly

Full suite still green: 856 passed, 6 skipped (no regressions on token-only auth, middleware, scope matrix, or any other existing tests).

Out of scope (hard gates per the brief)

  • packaging/caddy/Caddyfile.template — Child B
  • PUBLIC_PATHS frozenset — Child B
  • --auth=basic installer flag — Child B
  • Wizard UI — Child B
  • README / PLAN / upgrade notes — Child C

CI note

Hal0ai org Actions are currently billing-blocked, so this PR's CI will fail in ~2 seconds with no logs. Verified locally with:

PYTHONPATH=<worktree>/src uv run --no-sync pytest tests/api/test_password_auth.py -v
PYTHONPATH=<worktree>/src uv run --no-sync pytest tests/

Both green. The user will admin-merge.

Test plan

  • Local: pytest tests/api/test_password_auth.py — green
  • Local: pytest tests/ — no regression on the existing 836 tests
  • Manual: fresh install → GET /api/auth/status shows password_set: false, auth_mode: "open"
  • Manual: curl -X POST /api/auth/password -d '{"password":"correcthorse"}' (no auth) succeeds once
  • Manual: second POST /api/auth/password without auth → 401
  • Manual: POST /api/auth/login → cookie issued, /api/auth/me returns source: "session"

🤖 Generated with Claude Code

… (ADR-0001 Child A — refs #54)

Add a FastAPI-side password + session-cookie surface that composes
with the existing bearer-token auth, ahead of the Caddy reduction in
Child B (#56). Wave 1 / additive — no existing edge-auth install is
broken.

New module ``hal0.api.auth.password``:
- ``hash_password`` / ``verify_password`` (bcrypt cost 12, matching the
  format Caddy basic_auth uses, easing Child B's migration).
- ``create_session_token`` / ``verify_session_token`` (HS256 JWT, key
  derived from ``HAL0_HOME/keyring``, generated atomically on first
  use, default TTL 7 days).

``TokenStore`` extended with a top-level ``password_hash`` field —
``get_password_hash`` / ``set_password_hash`` round-trip via the same
atomic-write path the token rows use, so a single backup of
tokens.toml covers both surfaces.

New endpoints under ``/api/auth``:
- ``POST /login`` — validate ``{username, password}`` and set the
  ``hal0_session`` cookie (HttpOnly, SameSite=Lax, Secure when TLS
  fronted via ``X-Forwarded-Proto`` or the request scheme).
- ``POST /logout`` — clear the cookie (204).
- ``POST /password`` — set or rotate. Allowed without auth iff
  ``password_hash is None`` (first-run claim ownership); requires
  writer scope thereafter. Min length 8.
- ``GET /status`` — extended with ``password_set`` and ``auth_mode``
  (``"open"`` | ``"password"``) so the wizard can branch without a
  second probe.

Middleware extensions (``hal0.api.middleware.auth``):
- ``require_token`` grows a cookie path: Bearer → session cookie →
  X-Forwarded-Email. A present-but-invalid cookie hard-fails with
  ``auth.invalid`` so an expired session prompts a clean re-login.
- ``require_writer`` / ``require_admin`` enforce a CSRF tripwire when
  auth arrived via cookie *and* the verb is mutating: either
  ``X-Requested-With: XMLHttpRequest`` or ``X-CSRF-Token`` matching the
  first 16 chars of the session cookie. Bearer bypasses, matching the
  ADR's "Bearer can't be sent cross-origin without explicit opt-in"
  reasoning. GET/HEAD/OPTIONS are exempt (RFC 7231 §4.2.1 safe verbs).

Tests at ``tests/api/test_password_auth.py`` cover the issue-spec
scenarios: first-run set, second set without/with auth, login happy
+ wrong-creds, cookie+CSRF accepted, cookie-without-tripwire rejected,
Bearer exemption, logout clears cookie, ``status`` payload reports
``password_set`` / ``auth_mode``. 20 new tests, full suite still green
(856 passed).

Hard-gated out of scope (per the brief): Caddyfile, PUBLIC_PATHS,
``--auth=basic`` installer flag, wizard UI, docs — all reserved for
Children B (#56) and C (#57).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thinmintdev thinmintdev merged commit a405cf7 into main May 17, 2026
0 of 6 checks passed
thinmintdev added a commit that referenced this pull request May 17, 2026
…d C — refs #54) (#60)

Wave 2, Child C of ADR-0001. Documentation pass for the auth collapse
shipped in PRs #58 (Child A — FastAPI password + session cookies +
dual cookie/Bearer middleware) and #59 (Child B — Caddyfile reduction
+ PUBLIC_PATHS deletion + --no-tls flag + HAL0_PUBLIC_HOST /
HAL0_HARNESS_TLS rename).

installer/README.md
-------------------
Rewrites the auth section to describe the single FastAPI auth layer.
Drops every reference to --auth=basic / HAL0_ADMIN_USER /
HAL0_ADMIN_PASSWORD / HAL0_HOSTNAME / Caddy basic_auth / htpasswd.
Documents the --no-tls flag (FastAPI binds 0.0.0.0:8080, reachable at
http://<host>:8080/). Renames HAL0_HOSTNAME to HAL0_PUBLIC_HOST in the
env-var table. Adds an "Upgrade notes (pre-v1)" subsection explaining
that existing --auth=basic installs lose edge auth on upgrade and the
two mitigations — set a password in the wizard, or --no-tls behind your
own reverse proxy. Calls out the wizard's password-setup step (POST
/api/auth/password, public on first run per Child A).

PLAN.md
-------
§1 "Auth + reverse proxy" rewritten to reflect the single FastAPI layer
and a "Trust posture" subsection: hal0 defaults to open on the LAN;
password auth is opt-in via the dashboard wizard; programmatic clients
use Bearer tokens unchanged from #29. Drops the Caddy basic_auth /
PUBLIC_PATHS prose; narrows Caddy's scope to TLS termination + reverse
proxy. §10 (harness flags) renames HAL0_HARNESS_AUTH → HAL0_HARNESS_TLS
to match what the harness scripts actually look for.

docs/api-errors.md
------------------
Adds a brief note in the 401 section linking to ADR-0001 / PR #58 and
naming the new endpoints (POST /api/auth/login, /api/auth/logout,
/api/auth/password). The envelope shapes themselves are unchanged.

tests/harness/FINDINGS.md
-------------------------
Prepends "FIXED BY ARCHITECTURE REMOVAL (ADR-0001)" notes to the three
historical entries that the auth collapse renders structurally
unrepeatable: §10 (Caddy basic_auth swallows PUBLIC_PATHS — the
original #28 critical bug, fixed in PR #49 and now historical because
Caddy no longer has matchers or basicauth per PR #59), §16 (basic_auth
password unrecoverable post-install — source of the #43 HITL decision,
fixed by deletion because credential capture moved into the wizard per
PRs #58 + #59), and §21 (/api/metrics/prometheus orphan in
PUBLIC_PATHS — fixed by deletion because PUBLIC_PATHS is gone). The
original report bodies are preserved verbatim below each note for
historical reference. Re-run instructions updated to the new
HAL0_HARNESS_TLS knob.

README.md, tests/harness/README.md
----------------------------------
Sync the auth-posture summary in the repo root README and the harness
opt-in flags table to match the new single-FastAPI model and the
HAL0_HARNESS_TLS rename. These weren't called out in the spec but were
left stale after Child B; updating them keeps the docs internally
consistent.

Closes #43 and #51 (per the parent ADR plan). Issue close comments
follow the merge via the gh CLI; this PR body is the GH-semantics
hook in case the manual close doesn't land.

closes #43
closes #51
refs #54
refs #57

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thinmintdev added a commit that referenced this pull request May 21, 2026
ADR-0001 (Collapse edge auth into FastAPI) is implemented. Child A
(#58 — FastAPI password auth), Child B (#59 — Caddyfile reduction +
--no-tls), and Child C (#60 — docs pass) all landed. Flips the
header from Proposed → Accepted, records proposal/acceptance dates
separately, names the implementing PRs, and appends an Outcome
section summarizing what shipped against the original Decision.

Adds #28 (the critical basic_auth ordering bug) to the closed-on-land
list per tests/harness/FINDINGS.md §10. README.md and installer/README.md
were already brought into line with the v1 single-FastAPI-layer reality
in PR #60 — no further changes needed there.
@thinmintdev thinmintdev deleted the feat/adr-0001-a-password-auth branch May 21, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ADR-0001] Collapse edge auth into FastAPI — parent

1 participant