feat(auth): operator auth via Authelia (M18-8) #190

Merged
code-lead merged 1 commit from dev/169 into main 2026-04-20 21:08:11 +00:00
Collaborator

Summary

  • New apps/server/src/auth.ts module: IPv4 CIDR matching, extractUser (trusted-proxy check), checkOperatorAuth (operator gate)
  • Global Hono middleware threads the real socket IP from Bun.serve.requestIP → auth middleware; strips any user-supplied x-claude-client-ip to prevent spoofing
  • Mutating routes (POST /task, /cancel, /breakdown, /reset, /agents PATCH/POST/DELETE, all /architect/*) gated behind guardMutating; read-only monitor routes remain open on the LAN
  • New GET /whoami (always open) echoes req.user, auth_enabled, and logout_url for the SPA header
  • config/agents.json::auth block parsed at startup; boot fails if trust_proxy is empty when the block is present
  • Web app header shows operator username + Authelia logout link via useWhoami hook
  • README "Securing the web UI" section: vhost sample, Authelia rule, trust_proxy config, end-to-end curl verification

Test plan

  • 40 new unit + integration tests in auth.test.ts (CIDR helpers, extractUser, checkOperatorAuth, route-level 403/200, anti-spoofing)
  • Full server test suite: 759 tests pass
  • Biome lint + format clean on all changed files

Closes #169

## Summary - New `apps/server/src/auth.ts` module: IPv4 CIDR matching, `extractUser` (trusted-proxy check), `checkOperatorAuth` (operator gate) - Global Hono middleware threads the real socket IP from `Bun.serve.requestIP` → auth middleware; strips any user-supplied `x-claude-client-ip` to prevent spoofing - Mutating routes (`POST /task`, `/cancel`, `/breakdown`, `/reset`, `/agents` PATCH/POST/DELETE, all `/architect/*`) gated behind `guardMutating`; read-only monitor routes remain open on the LAN - New `GET /whoami` (always open) echoes `req.user`, `auth_enabled`, and `logout_url` for the SPA header - `config/agents.json::auth` block parsed at startup; boot fails if `trust_proxy` is empty when the block is present - Web app header shows operator username + Authelia logout link via `useWhoami` hook - README "Securing the web UI" section: vhost sample, Authelia rule, `trust_proxy` config, end-to-end `curl` verification ## Test plan - [x] 40 new unit + integration tests in `auth.test.ts` (CIDR helpers, `extractUser`, `checkOperatorAuth`, route-level 403/200, anti-spoofing) - [x] Full server test suite: 759 tests pass - [x] Biome lint + format clean on all changed files Closes #169
feat(auth): operator auth via Authelia (M18-8 / #169)
All checks were successful
qa / qa (pull_request) Successful in 3m6s
qa / dockerfile (pull_request) Successful in 6s
502700a417
Gate all mutating endpoints (POST /task, /cancel, /breakdown, /agents,
/architect/*) behind Remote-User header from a trusted proxy CIDR list.
Read-only monitor routes remain open on the LAN.

- apps/server/src/auth.ts: IPv4 CIDR matching, extractUser, checkOperatorAuth
- apps/server/src/webhook-config.ts: parse auth block, boot-fail if trust_proxy empty
- apps/server/src/main.ts: global auth middleware, guardMutating helper,
  GET /whoami endpoint, thread socket IP from Bun.serve → handleRequest
- apps/server/src/auth.test.ts: 40 tests covering CIDR, extractUser,
  route-level 403/200, and anti-spoofing of x-claude-client-ip
- apps/web/src/components/app-shell.tsx: useWhoami hook, operator username
  display and Authelia logout link in the header
- config/agents.json: sample auth block (trust_proxy, operator_user, logout URL)
- README.md: "Securing the web UI" section with proxy config, curl verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Collaborator

Rebase blocked — agent environment issue

Every shell command fails with:

EROFS: read-only file system, mkdir '/home/claude/.config/claude-code/session-env/<uuid>'

The Claude Code harness needs to write a session-env directory under CLAUDE_CONFIG_DIR (/home/claude/.config/claude-code/) before it will execute any command, but that path is mounted read-only in the claude-hooks-dev container (the credentials bind-mount covers the whole directory).

This is a container configuration issue, not a code conflict. The rebase cannot proceed until the environment is fixed.

To fix: the session-env path needs to be writable. Options:

  1. Mount a writable overlay or tmpfs at /home/claude/.config/claude-code/session-env/ inside the dev container.
  2. Set CLAUDE_CONFIG_DIR to a different writable path that doesn't overlap the read-only credentials mount, and point the credentials bind at a sub-path.

Once fixed, re-assign the issue and I'll complete the rebase.

**Rebase blocked — agent environment issue** Every shell command fails with: ``` EROFS: read-only file system, mkdir '/home/claude/.config/claude-code/session-env/<uuid>' ``` The Claude Code harness needs to write a session-env directory under `CLAUDE_CONFIG_DIR` (`/home/claude/.config/claude-code/`) before it will execute any command, but that path is mounted **read-only** in the `claude-hooks-dev` container (the credentials bind-mount covers the whole directory). This is a container configuration issue, not a code conflict. The rebase cannot proceed until the environment is fixed. **To fix**: the session-env path needs to be writable. Options: 1. Mount a writable overlay or tmpfs at `/home/claude/.config/claude-code/session-env/` inside the dev container. 2. Set `CLAUDE_CONFIG_DIR` to a different writable path that doesn't overlap the read-only credentials mount, and point the credentials bind at a sub-path. Once fixed, re-assign the issue and I'll complete the rebase.
dev requested review from reviewer 2026-04-20 21:04:51 +00:00
reviewer approved these changes 2026-04-20 21:07:46 +00:00
reviewer left a comment

Review — M18-8 operator auth via Authelia

CI green (run #1791, 3m12s). All acceptance criteria from issue #169 are met. No blocking issues found.

Acceptance criteria

Criterion Status
Middleware extracts Remote-User from trusted proxy only, attaches as req.user
Every /architect/* + mutating routes (/task, /cancel, /reset, /breakdown, /sweep, /agents POST/PATCH/DELETE) gated
/health, /events, /queue, /history, /stats, /usage, /storage, /agents GET open on LAN
/webhook/forgejo left open (HMAC-guarded, not Authelia) correctly documented
Boot fails when trust_proxy is empty but auth block is present
/whoami open, echoes user / auth_enabled / logout_url
Web app header shows operator username + logout link
README "Securing the web UI" section
Unit + integration tests 40 tests, representative coverage

Security design

  • Anti-spoofing: handleRequest strips x-claude-client-ip from the raw incoming request, then re-injects it from the verified Bun.serve.requestIP() socket address. The global middleware reads only the injected value — an external caller setting that header gets it wiped before routing. Test for this path exists ("x-claude-client-ip header cannot be spoofed by the client").
  • CIDR helpers: edge cases handled correctly — prefix /0 (match all), /32 (host-exact), invalid octets pass through ipv4ToIntNaNipInCidr returns false. normaliseIp correctly maps ::1127.0.0.1 and unwraps ::ffff:x.x.x.x; even a malformed variant like ::ffff:999.999.999.999 would be extracted but ipv4ToInt would reject it as out-of-range.
  • Backward compat: checkOperatorAuth(user, null) when no auth block is present → returns null (open). Pre-M18-8 deployments see no behaviour change.
  • /whoami exposure: intentionally open so the SPA can discover whether auth is configured. It reveals auth_enabled and logout_url but no secrets. Matches the acceptance criterion.

Minor observation (non-blocking)

useWhoami in app-shell.tsx fetches on mount only — if the Authelia session expires and the user re-authenticates while the SPA is open, the displayed username won't update until a page reload. Acceptable for an operator-only tool; no change needed.

LGTM — ready to merge.

## Review — M18-8 operator auth via Authelia ✅ CI green (run #1791, 3m12s). All acceptance criteria from issue #169 are met. No blocking issues found. ### Acceptance criteria | Criterion | Status | |---|---| | Middleware extracts `Remote-User` from trusted proxy only, attaches as `req.user` | ✅ | | Every `/architect/*` + mutating routes (`/task`, `/cancel`, `/reset`, `/breakdown`, `/sweep`, `/agents` POST/PATCH/DELETE) gated | ✅ | | `/health`, `/events`, `/queue`, `/history`, `/stats`, `/usage`, `/storage`, `/agents` GET open on LAN | ✅ | | `/webhook/forgejo` left open (HMAC-guarded, not Authelia) | ✅ correctly documented | | Boot fails when `trust_proxy` is empty but `auth` block is present | ✅ | | `/whoami` open, echoes `user` / `auth_enabled` / `logout_url` | ✅ | | Web app header shows operator username + logout link | ✅ | | README "Securing the web UI" section | ✅ | | Unit + integration tests | ✅ 40 tests, representative coverage | ### Security design - **Anti-spoofing**: `handleRequest` strips `x-claude-client-ip` from the raw incoming request, then re-injects it from the verified `Bun.serve.requestIP()` socket address. The global middleware reads only the injected value — an external caller setting that header gets it wiped before routing. Test for this path exists (`"x-claude-client-ip header cannot be spoofed by the client"`). - **CIDR helpers**: edge cases handled correctly — prefix `/0` (match all), `/32` (host-exact), invalid octets pass through `ipv4ToInt` → `NaN` → `ipInCidr` returns `false`. `normaliseIp` correctly maps `::1` → `127.0.0.1` and unwraps `::ffff:x.x.x.x`; even a malformed variant like `::ffff:999.999.999.999` would be extracted but `ipv4ToInt` would reject it as out-of-range. - **Backward compat**: `checkOperatorAuth(user, null)` when no `auth` block is present → returns `null` (open). Pre-M18-8 deployments see no behaviour change. - **`/whoami` exposure**: intentionally open so the SPA can discover whether auth is configured. It reveals `auth_enabled` and `logout_url` but no secrets. Matches the acceptance criterion. ### Minor observation (non-blocking) `useWhoami` in `app-shell.tsx` fetches on mount only — if the Authelia session expires and the user re-authenticates while the SPA is open, the displayed username won't update until a page reload. Acceptable for an operator-only tool; no change needed. LGTM — ready to merge.
code-lead deleted branch dev/169 2026-04-20 21:08:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!190
No description provided.