Public UI behind forge sign-in (SPEC §8.3) #16

Manually merged
krisbuild merged 7 commits from web-auth into main 2026-09-28 00:29:44 +02:00
Owner

The UI and API leave the "trusted LAN" model: they are served publicly behind sign-in through the forge (SPEC §1, §7.1.1, new §8.3).

  • Sign-in: Forgejo OAuth2 with PKCE S256 and a state value bound to a cookie and single-use. It requests only the read:user scope, and the user's token is dropped after one /api/v1/user call.
  • Sessions: a random 256-bit cookie (Secure; HttpOnly; SameSite=Lax). Only its SHA-256 is stored, in web_sessions, with a TTL (default 7 days).
  • Read access: you see a repo's graphs, logs and artifacts if the forge says you can read that repo. This is checked with the bot token against the collaborator-permission endpoint, cached for 60 s, and denied if the forge can't answer. A graph you can't read returns 404, and lists are filtered in SQL.
  • Operator actions: one middleware requires an admin for every non-GET, so a new route is protected by default. The same check is available as the Admin extractor for operator GETs. Cookie-authenticated non-GETs must carry an Origin (or Referer) matching external_url.
  • API clients: Authorization: Bearer <forge PAT>. Agent tokens are refused there and never sent to the forge.
  • Routes that authenticate their own caller: /healthz, the HMAC webhook, /agent, /api/artifacts/{hash}, /git/ (from #11), and /auth/*. A route-walking test checks that no other route answers an anonymous request with a 2xx.
  • Startup requirements: [auth] is required. disabled = true exists for local runs and is refused unless the listener is on loopback. With sign-in enabled, startup requires https external_url and agent_auth = "required". The module defaults listen_addr to loopback.

⚠️ Deploying this is a coordinated switch: every agent needs a token, and pixienix needs the OAuth app, the secrets and the nginx config first. See docs/forgejo-setup.md.

🤖 Generated with Claude Code

The UI and API leave the "trusted LAN" model: they are served publicly behind sign-in through the forge (SPEC §1, §7.1.1, new §8.3). - **Sign-in:** Forgejo OAuth2 with PKCE S256 and a `state` value bound to a cookie and single-use. It requests only the `read:user` scope, and the user's token is dropped after one `/api/v1/user` call. - **Sessions:** a random 256-bit cookie (`Secure; HttpOnly; SameSite=Lax`). Only its SHA-256 is stored, in `web_sessions`, with a TTL (default 7 days). - **Read access:** you see a repo's graphs, logs and artifacts if the forge says you can read that repo. This is checked with the bot token against the collaborator-permission endpoint, cached for 60 s, and denied if the forge can't answer. A graph you can't read returns 404, and lists are filtered in SQL. - **Operator actions:** one middleware requires an admin for every non-GET, so a new route is protected by default. The same check is available as the `Admin` extractor for operator GETs. Cookie-authenticated non-GETs must carry an `Origin` (or `Referer`) matching `external_url`. - **API clients:** `Authorization: Bearer <forge PAT>`. Agent tokens are refused there and never sent to the forge. - **Routes that authenticate their own caller:** `/healthz`, the HMAC webhook, `/agent`, `/api/artifacts/{hash}`, `/git/` (from #11), and `/auth/*`. A route-walking test checks that no other route answers an anonymous request with a 2xx. - **Startup requirements:** `[auth]` is required. `disabled = true` exists for local runs and is refused unless the listener is on loopback. With sign-in enabled, startup requires https `external_url` and `agent_auth = "required"`. The module defaults `listen_addr` to loopback. ⚠️ Deploying this is a coordinated switch: every agent needs a token, and pixienix needs the OAuth app, the secrets and the nginx config first. See `docs/forgejo-setup.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Web sign-in through the forge: the UI and API stop trusting the LAN (SPEC §8.3)
All checks were successful
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
8d6387011f
The control plane is served publicly behind one TLS proxy, so nothing is
trusted for being on the network any more:

- Sign-in: Forgejo's OAuth2 authorization-code flow with PKCE (oauth2 crate),
  `state` bound to the browser by a short-lived cookie. The token asks for
  read:user only, is used once for /api/v1/user and dropped.
- Sessions: 256 random bits in a Secure/HttpOnly/SameSite=Lax cookie, only a
  SHA-256 kept (`web_sessions`, SPEC §9), absolute expiry, POST /auth/logout.
- Scripts: `Authorization: Bearer <forge token>` resolved via /api/v1/user.
- Reads mirror forge repo access, asked with the control plane's own token
  through the collaborator-permission endpoint and cached; unreadable graphs
  are 404s and lists are filtered in the query. Artifacts follow the repo that
  produced them; agents keep the upload token.
- Acting needs `[auth].admins`: every non-GET behind the session gate, plus the
  `auth::Admin` extractor for operator GETs. Cookie state changes must come
  from external_url's origin.
- `[auth]` is mandatory: forge login, or `disabled = true` on a loopback
  listener only (proxied requests refused). Login requires agent_auth =
  "required" and an artifact upload token.
- NixOS module: controlPlane.auth.{clientId,clientSecretEnv,admins}, listen
  on 127.0.0.1:1337 by default.

SPEC §1 non-goal and §7.1.1 rewritten, §8.3 added; forgejo-setup covers the
OAuth application and the nginx site.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

CI failure is infrastructure (the drv push from the eval node raced nix/dep/source on nuxbox), not this change.

@krisbuild rerun

CI failure is infrastructure (the drv push from the eval node raced `nix/dep/source` on nuxbox), not this change. @krisbuild rerun
Collaborator

unknown merge-queue command rerun.

unknown merge-queue command `rerun`.
Merge origin/main into web-auth: git proxy and single-binary test suites
All checks were successful
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/test-deps succeeded
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
d91762dbbf
The git proxy's /git/ routes join the self-authenticating group (agent
token, never a sign-in redirect). Artifact requests carrying an agent token
are held to the agent listener's rules on the main listener too; everything
else follows sign-in. With agents uploading under their own tokens, the
shared artifact upload token is optional with sign-in: without it only agent
tokens write. SPEC §7.1.1/§8.3 reconciled; web_auth moves to tests/it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/main into web-auth: agent enrollment, node health
All checks were successful
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test-deps succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
a8515d99ca
Enrollment's operator routes sit behind the sign-in gate: the listing
takes `Admin`, approve/reject (API and /ui/tokens forms) are non-GETs in the
signed-in router, so admin and same-origin apply. The request itself stays
on self-authenticating `/agent`. SPEC §7.1.1/§8.3 and forgejo-setup: approval
is a signed-in admin's, not the LAN UI's. The route walker covers the new
routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/main into web-auth: handshake, forge outbox, failure summaries
Some checks reported errors
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/deployed superseded
krisbuild/kris/krisbuild/nix/build superseded
krisbuild/kris/krisbuild/nix/test-deps superseded
krisbuild/kris/krisbuild/nix/test superseded
krisbuild/kris/krisbuild/nix/kb-check superseded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: superseded by 958de954e72640f075d1ff10923243d0d9914e90
05c906353f
`/ui/merge-queue` lands in the signed-in router, and it and
`/api/merge-queue` list only the queues of repos the caller may read
(SPEC §8.3). #15's handshake refusal stays behind `/agent`'s token check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
web auth: read the OAuth client id from the environment too
Some checks reported errors
krisbuild/kris/krisbuild/nix/workspace-deps cached
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/deployed succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test-deps succeeded
krisbuild/kris/krisbuild/nix/test superseded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: superseded by 5357fd3d244f17aa2fc9d9087c1f32d36528881c
958de954e7
`[auth].client_id_env` (default KB_OAUTH_CLIENT_ID) names the variable the
client id is read from when no literal `client_id` is set, so a deployment
that registers the Forgejo OAuth app itself can write id and secret into the
env file in one step. Startup names both keys when neither yields an id.
The NixOS module gains `auth.clientIdEnv`, always renders `[auth]`, and
checks an env-only configuration. The setup guide covers both routes and a
loopback webhook target for a forge on the same host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/main into web-auth: restart-safe control plane, acked delivery, run supervisor
Some checks failed
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/deployed cached
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test-deps succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
krisbuild/queue the merge conflicts in Cargo.lock, Cargo.toml, crates/kb-control-plane/Cargo.toml, crates/kb-control-plane/src/config.rs
5357fd3d24
No new HTTP routes. The socket unit's default listen stream follows the
module's loopback default (127.0.0.1:1337) so the inherited listener matches
the rendered listen_addr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Reviewed across its merges: one sign-in gate over everything except the self-authenticating routes (/healthz, HMAC webhook, /agent, /git/, artifacts, /auth/*); every non-GET needs an admin plus a same-origin check; enrollment listing/approval is admin-only; repo-scoped lists (graphs, merge queue) filter by what the forge lets the caller read. Client id/secret come from the environment. The socket-activation default now follows the loopback listen_addr (127.0.0.1:1337). CI green on this head.

Deploy note: after this lands, a control plane without [auth] refuses to start. The matching pixienix change is ready on krisbuild-web-auth.

@krisbuild r+

Reviewed across its merges: one sign-in gate over everything except the self-authenticating routes (`/healthz`, HMAC webhook, `/agent`, `/git/`, artifacts, `/auth/*`); every non-GET needs an admin plus a same-origin check; enrollment listing/approval is admin-only; repo-scoped lists (graphs, merge queue) filter by what the forge lets the caller read. Client id/secret come from the environment. The socket-activation default now follows the loopback `listen_addr` (127.0.0.1:1337). CI green on this head. Deploy note: after this lands, a control plane without `[auth]` refuses to start. The matching pixienix change is ready on `krisbuild-web-auth`. @krisbuild r+
Collaborator

Removed from the merge queue: the merge conflicts in Cargo.lock, Cargo.toml, crates/kb-control-plane/Cargo.toml, crates/kb-control-plane/src/config.rs, crates/kb-control-plane/src/lib.rs, flake.nix.

Removed from the merge queue: the merge conflicts in Cargo.lock, Cargo.toml, crates/kb-control-plane/Cargo.toml, crates/kb-control-plane/src/config.rs, crates/kb-control-plane/src/lib.rs, flake.nix.
Merge origin/main into web-auth: per-node attic tokens
All checks were successful
krisbuild/kris/krisbuild/nix/hello cached
krisbuild/kris/krisbuild/nix/world cached
krisbuild/kris/krisbuild/nix/workspace-deps succeeded
krisbuild/kris/krisbuild/nix/clippy succeeded
krisbuild/kris/krisbuild/nix/build succeeded
krisbuild/kris/krisbuild/nix/deployed succeeded
krisbuild/kris/krisbuild/nix/kb-check succeeded
krisbuild/kris/krisbuild/nix/test-deps succeeded
krisbuild/kris/krisbuild/nix/test succeeded
krisbuild/kris/krisbuild krisbuild kris/krisbuild: all tasks succeeded
krisbuild/queue merged
1aa208f666
`[auth]` and `[attic]` both resolve at load and both validate; the module
keeps `auth.*` beside `extraEnvironmentFiles`, `agent.cachePush` and
`agent.attic`, with the env-only client-id check updated for the
EnvironmentFile list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

Merged main (#13): [auth] and [attic] coexist in config (both resolved and validated at load), the module keeps auth.* beside #13's extraEnvironmentFiles/cachePush/attic.*, and the lockfile only adds this PR's crates. #13's token issuance in ws.rs is unchanged. CI green on this head; priority so it lands before main moves again.

Deploy note: after this lands, a control plane without [auth] refuses to start; the matching pixienix change is ready on krisbuild-web-auth.

@krisbuild r+ p=5

Merged main (#13): `[auth]` and `[attic]` coexist in config (both resolved and validated at load), the module keeps `auth.*` beside #13's `extraEnvironmentFiles`/`cachePush`/`attic.*`, and the lockfile only adds this PR's crates. #13's token issuance in `ws.rs` is unchanged. CI green on this head; priority so it lands before main moves again. Deploy note: after this lands, a control plane without `[auth]` refuses to start; the matching pixienix change is ready on `krisbuild-web-auth`. @krisbuild r+ p=5
krisbuild manually merged commit dd2dc7db65 into main 2026-09-28 00:29:44 +02:00
Collaborator

Merged as dd2dc7db65.

Merged as dd2dc7db6580.
Sign in to join this conversation.
No reviewers
No labels
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
kris/krisbuild!16
No description provided.