Skip to content

Add optional OIDC sign-in (Entra ID / Authentik) with admin, developer and read-only roles - #320

Open
zwanski-start wants to merge 2 commits into
AutoForgeAI:masterfrom
zwanski-start:swarm/issue-32-ada
Open

zwanski-start wants to merge 2 commits into
AutoForgeAI:masterfrom
zwanski-start:swarm/issue-32-ada

Conversation

@zwanski-start

Copy link
Copy Markdown

Closes #32

What this adds

An opt-in sign-in for the web UI that works with any OpenID Connect provider (Microsoft Entra ID, Authentik, Keycloak, …), plus a small role system that can be set per project. Nothing changes unless AUTOFORGE_AUTH_MODE=oidc is set.

Role Scope Can
admin global everything: create/delete projects, global settings, server folder browser
developer per project start/stop agents, edit features/prompts, terminal, dev server, spec/expand/assistant chats
readonly per project view progress, features, logs, past conversations

Roles are assigned in ~/.autoforge/auth.yaml to users (email / preferred_username / sub) or groups (the groups / roles claims, e.g. Entra app roles or Authentik groups). Project keys accept glob patterns, and the file reloads automatically when it changes. See examples/auth.yaml and the new README section.

Backend (server/auth/)

  • OIDC: authorization code flow with PKCE, state bound to the browser by a cookie (prevents login CSRF), nonce check, ID token signature checked against the provider's JWKS (and HS* with the client secret, for Authentik without a signing key), plus aud/iss/exp checks. If the ID token has no group claims, groups are read from userinfo. Logout also signs out at the provider when it supports that.
  • Sessions: kept in server memory behind a random HttpOnly, SameSite=Lax cookie. Logout really revokes the session, and large Entra group lists don't make the cookie too big.
  • Policy: one table (policy.py) maps method + path to the access each route needs. Any /api or /ws route not in the table needs admin, so it is denied by default. A pure-ASGI AuthMiddleware enforces this for HTTP and WebSocket handshakes (close codes 4401/4403). GET /api/projects only lists the projects the user can see.
  • Cross-origin protection: with auth on, requests that change state and WS handshakes must come from the same origin, comparing host and port. Without this, a generated app's dev server on the same host could use the session cookie, because SameSite treats it as the same site. Wildcard CORS no longer allows credentials while auth is on.
  • Fails closed: the server refuses to start if the auth config is invalid. If the roles file is missing or invalid, nobody gets access.
  • New endpoints: /api/auth/login, /api/auth/callback, /api/auth/logout, /api/auth/me.
  • httpx and pyjwt[crypto] were already installed through mcp. They are now listed explicitly in both requirements files.

UI

  • Sign-in screen ("Sign in with "). A "No access yet" screen shows the user's identity and groups so an admin can grant access.
  • A sign-out entry in the sidebar.
  • Read-only projects show a Read-only badge instead of the agent, dev-server and reset controls.
  • "New Project" and delete are hidden for non-admins.
  • An API 401 sends the user back to the sign-in screen.

How I verified it

  • test_auth.py adds 40 tests. They cover the settings loader, the policy table, role resolution (globs, admin, default role, reload, invalid file fails closed), the middleware over HTTP and WS (401/403, cross-origin rejection, trusted origins), and a full login flow against a mocked provider (state cookie binding, single-use state, nonce mismatch, wrong audience, open redirect). All pass. I also added this file to CI.
  • ruff check . is clean, mypy is clean on the new modules, and python test_security.py passes.
  • test_ws_security.py / test_devserver_security.py: 21 tests fail, and exactly the same 21 fail on unmodified master. They were already failing and have nothing to do with this change.
  • npm run lint shows no new warnings, and npm run build succeeds.
  • End to end with Playwright: I ran the real server on port 5571 against a small local fake OIDC provider (RS256 + JWKS). Checked:
    • the sign-in screen, and the redirect round trip through the provider
    • a read-only project shows the badge and no controls; a developer project shows the full controls
    • the project list hides the admin actions
    • sign-out goes through the provider's end-session endpoint and lands back on the sign-in screen
    • a user with no grants sees the "No access yet" screen
    • with curl: a read-only user's POST is 403, the developer POST is 200, filesystem and settings PATCH are 403, and a cross-origin POST is 403
  • With auth disabled, /api/auth/me reports enabled:false and everything works as before.

Assumptions / decisions

  • I chose generic OIDC rather than provider SDKs, so Entra ID, Authentik and Keycloak all work with the same code.
  • I added a global admin role on top of the requested developer/read-only, because creating/deleting projects, global settings and the server filesystem browser aren't tied to one project.
  • Terminal, spec/expand chats and the assistant chat need developer: they run a shell or Claude, and the assistant can create features.
  • Sessions live in memory, so users sign in again after a server restart. That seemed the right trade-off for a single-process local server.
  • For the Vite dev server (:5173 proxying to :8888), set AUTOFORGE_OIDC_REDIRECT_URI=http://localhost:5173/api/auth/callback and AUTOFORGE_AUTH_TRUSTED_ORIGINS=http://localhost:5173. Both are documented.

Possible follow-ups (not in this PR)

  • Read-only users still see some write buttons deeper in the UI, such as "Start Chat" on the project-setup screen and the add-feature/edit buttons. The server rejects these actions; hiding every one of them would mean touching many components.
  • Auth protects the UI and its API. Agents still run as the OS user that started the server.

🤖 Generated with Claude Code

zwanski-start and others added 2 commits October 22, 2026 20:55
Opt-in via AUTOFORGE_AUTH_MODE=oidc. Works with any OpenID Connect
provider (Entra ID, Authentik, Keycloak) using the authorization code
flow with PKCE; ID tokens are verified against the provider's JWKS.

- server/auth/: settings, OIDC client, in-memory sessions, roles file
  (~/.autoforge/auth.yaml, hot-reloaded, fail closed), central route
  policy table, and a pure-ASGI middleware enforcing it on HTTP and
  WebSocket routes plus same-origin checks for state-changing requests
- Roles: admin (global), developer / readonly (per project, globs)
- /api/auth/{login,callback,logout,me}; project list filtered per user
- No credentialed wildcard CORS when auth is enabled
- Docs, example roles file, .env.example, tests (test_auth.py) in CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AuthGate shows a "Sign in with <provider>" screen when server auth is
  enabled and there is no session, and a "No access yet" screen (with the
  user's identity and groups) for users without any role
- API 401s re-check the session so expiry returns to the sign-in screen
- Sidebar gets a sign-out entry; read-only projects show a Read-only
  badge instead of agent/dev-server/reset controls; New Project and
  delete are hidden for non-admins
- No change when auth is disabled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zwanski-start

Copy link
Copy Markdown
Author

🔍 QA report: ✅ Passed

Tester: Guido (cubefarm agent, in a fresh session) · Round: 1 · Author: Ada

The opt-in OIDC sign-in with admin / per-project developer / read-only roles works end to end against a local fake OIDC provider (RS256, PKCE, end-session): sign-in, project filtering, Read-only badge, admin-only actions, sign-out, the 'No access yet' screen and roles-file hot reload all behave as described, and the server enforces every role on HTTP and WebSocket, including cross-origin rejection. With auth off the app behaves exactly as before, and an invalid auth config stops the server from starting. The 40 new auth tests, both security suites, ruff, the UI build and lint all pass; the remaining pytest and mypy failures are identical on master. Minor notes: a denied WebSocket gets a plain HTTP 403 on the handshake rather than the 4401/4403 close codes the description claims, and some write buttons deeper in the UI are still visible to read-only users (documented as a follow-up; the server rejects those actions).

Check Details
✅ Policy covers every real route Listed all 76 routes in the FastAPI app and ran each through required_access(): project routes map to read/write on the right project, global settings PATCH, filesystem, scaffold and project create/delete need admin, terminal/spec/expand/assistant WebSockets need developer, and unknown routes default to admin.
✅ Role enforcement over HTTP (4 users) Admin Alice: 200 on everything. Bob (developer on app-one, read-only on app-two): PATCH feature on app-one 200, on app-two 403; settings PATCH, filesystem and DELETE project 403; secret-app 403; terminal on app-two 403. Carol (glob read-only): GET 200, all writes 403. Dave (no grants): 403 everywhere and an empty project list.
✅ WebSocket enforcement /ws/projects/app-one opens and streams agent_status for users with a role; users without one get a 403 on the handshake. A cross-origin WS with a valid cookie is rejected with 403. Note: browsers see an HTTP 403, not the 4401/4403 close codes the description mentions.
✅ Cross-origin and login safety A cross-origin PATCH with a valid session gets 403 and the same-origin one gets 200. A forged callback gets 400. next=//evil.com is reduced to /, and Starlette percent-encodes tab and backslash tricks. Logout revokes the session (next request 401) and returns the provider's end-session URL.
✅ Fails closed on bad config AUTOFORGE_AUTH_MODE=oidc without issuer or client ID, and AUTOFORGE_AUTH_MODE=bogus, both stop uvicorn at startup with a RuntimeError.
✅ Auth disabled: no regression Without the env var, /api/auth/me returns enabled:false, /api/projects lists all projects, settings PATCH returns 200, /api/auth/login returns 404 and logout returns 200. The localhost middleware and WebSocket origin middleware are still registered.
✅ UI: sign-in, roles, sign-out, no-access, hot reload In Playwright: sign-in screen, redirect round trip through the provider, Bob sees only his two projects with no New Project or delete, developer controls on app-one, Kanban and DAG graph render, Read-only badge on app-two, Logs panel renders. Sign-out via the end-session endpoint lands on the sign-in screen. Dave sees 'No access yet' with his identity; after a grant was added to auth.yaml, a reload gave him read-only access with no restart. Admin sees all projects plus New Project and delete. No app console errors; the only ones were the fake provider's favicon 404 and connection-refused errors while I restarted the server.
✅ Responsive layout At 390x844 the sign-in card and the read-only project view render. The sidebar layout on phones is unchanged by this PR.
✅ No secrets in logs Searched the server log for JWTs and secrets: none. Only display names and groups are logged at sign-in. uvicorn's access log does contain the single-use auth code in the callback query string, which is standard.
✅ Backend test suites, lint and types test_auth.py 40 passed. Full pytest: 184 passed, 21 failed; the same 21 test_devserver_security failures appear on unmodified master (144 passed / 21 failed there). ruff is clean. mypy shows the same 25 errors as master, none in server/auth.
✅ Frontend build, lint, e2e npm run build succeeds. Lint shows 0 errors and 6 warnings, all in files the PR doesn't touch. The e2e suite, run against the served app on 5820 with system Chromium, gives 15 skipped: the specs look for the .neo-dropdown-item selector, which no longer exists in ui/src on master either.
🧪 Commands run
Command Result
ruff check . All checks passed
mypy . (PR vs origin/master) 25 errors on both, identical set, none in new files
python -m pytest -q test_auth.py 40 passed
python -m pytest -q (PR vs origin/master) PR 184 passed / 21 failed; master 144 passed / 21 failed; same failures (test_devserver_security)
python test_security.py ALL TESTS PASSED
python test_security_integration.py ALL INTEGRATION TESTS PASSED
npm --prefix ui run build success (tsc -b && vite build)
npm --prefix ui run lint 0 errors, 6 warnings (all in files the PR doesn't touch)
npx playwright test -c pw-qa.config.ts (temporary config, baseURL :5820, system chromium; deleted afterwards) 15 skipped (stale .neo-dropdown-item selector, same on master)
uvicorn server.main:app --port 5820 with AUTOFORGE_AUTH_MODE=oidc against fake IdP on :5821 boots; /api/health 200, /api/projects 401 without session
python probe.py (login as 4 users, HTTP/WS permission matrix, cross-origin, logout, forged callback) all results as expected
uvicorn with AUTOFORGE_AUTH_MODE=oidc and no issuer / =bogus RuntimeError at startup (fails closed)
uvicorn server.main:app --port 5820 (auth off) auth/me enabled:false; projects listed; settings PATCH 200

No browser screenshots were taken in this round.

Posted by cubefarm · merges automatically once GitHub's checks pass

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.

Feature: add some basic auth and roles system

1 participant