feat: add multi-user authentication with JWT and project-level scoping#30
feat: add multi-user authentication with JWT and project-level scoping#30LuisErlacher merged 4 commits intodevfrom
Conversation
#6) Add username/password authentication with JWT tokens (jose) and project membership scoping. Users see only codebases they belong to; admins see all. First registered user auto-gets admin role. Changes: - New tables: remote_agent_users, remote_agent_project_members - Auth utilities: Bun.password (argon2id) hashing, jose JWT sign/verify - Hono middleware: Bearer token verification, public route skip list - REST routes: POST /api/auth/{register,login,refresh}, GET /api/auth/me - Project scoping: codebases/conversations filtered by membership - Web UI: login page, auth context, token refresh, user menu in top bar - CLI: archon login command stores tokens in ~/.archon/auth.json - SQLite adapter: new tables in createSchema, user_id column migration Fixes #6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🔍 Comprehensive PR Review — Multi-User AuthPR: #30 feat: add multi-user authentication with JWT and project-level scoping SummaryThis PR introduces a well-structured multi-user auth system with JWT tokens, bcrypt password hashing, project-level scoping, a web login UI, and a CLI login command. Security fundamentals are solid — Verdict:
🔴 Critical IssuesC-1: JWT
|
| Issue Title | Priority |
|---|---|
| "Add SSE auth via query param token" | P2 (TODO already in code) |
| "Rate limiting on auth endpoints" | P3 |
| "CLI auto token refresh / re-login" | P3 |
Reviewed by Archon comprehensive-pr-review workflow
Full artifacts: ~/.archon/workspaces/coleam00/Archon/artifacts/runs/832f7414d75821524eeccd36979e3d0a/review/
🔍 Comprehensive PR Review — Multi-User AuthPR: #30 feat: add multi-user authentication with JWT and project-level scoping SummaryThe JWT crypto primitives ( Verdict:
🔴 Critical IssuesC-1 and C-2: FK Forward-Reference Breaks EVERY Fresh Install (PostgreSQL + SQLite)📍
The incremental migration Fix: Move the C-3: API Reference Claims No Authentication Exists📍 Current text: "Authentication: None. There is no authentication on the API by default." — now false when View suggested fix## Authentication
Authentication is **opt-in**: set `JWT_SECRET` in your environment to enable it.
**When `JWT_SECRET` is unset** (default): No authentication. Local use only.
**When `JWT_SECRET` is set**: All `/api/*` routes require `Authorization: Bearer <token>`.
Public paths (no token needed): `/api/auth/*`, `/api/health`, `/api/openapi.json`, `/api/stream/*`.
Auth endpoints: POST /api/auth/register (first user = admin), /api/auth/login, /api/auth/refresh, GET /api/auth/me
Access tokens: 1h · Refresh tokens: 7d · CLI: `archon login`🟠 High IssuesH-1: Admin Election Race Condition📍
Recommended fix: Document the known race (CLAUDE.md permits documented intentional fallbacks for single-developer context): // NOTE: countUsers() + createUser() is non-atomic. Two simultaneous first registrations
// could both see count=0 and both receive role:'admin'.
// Acceptable for single-developer use (YAGNI). Add DB-level constraint if multi-tenant.H-2: CLI Password Echoed in Plaintext📍
async function readPassword(prompt: string): Promise<string> {
process.stdout.write(prompt);
const rl = createInterface({ input: process.stdin, output: null }); // null suppresses echo
return new Promise(resolve => {
rl.question('', answer => { rl.close(); process.stdout.write('\n'); resolve(answer); });
});
}H-3: Silent
|
| Gap | Rating | Risk |
|---|---|---|
JWT verifyToken (tampered/expired tokens, missing role claim) |
9/10 | Cryptographic foundation untested |
| Auth middleware public-path bypass + 401 returns | 9/10 | Entire API access surface |
| Register first-user=admin, login, refresh routes | 8/10 | One-shot admin promotion, no recovery |
Three new test files needed (full implementations in test-coverage-findings.md):
packages/core/src/auth/jwt.test.ts— add as new isolatedbun testinvocation inpackages/core/package.jsonpackages/server/src/middleware/auth.test.ts— add as new isolatedbun testinvocation inpackages/server/package.jsonpackages/server/src/routes/api.auth.test.ts— add as new isolatedbun testinvocation inpackages/server/package.json
H-9: DB Scoping Queries Untested (Authorization Bypass Risk)
📍 packages/core/src/db/codebases.ts:555 · packages/core/src/db/conversations.ts
listCodebasesForUser (INNER JOIN) and listConversationsForUser (dynamic SQL with manual $N indices) are the project-level access gates. A SQL typo or off-by-one = silent authorization bypass. Neither is tested.
H-10: Existing Test Files Missing Mocks for New DB Functions
📍 api.codebases.test.ts · api.conversations.test.ts
Both files mock @archon/core/db/conversations and @archon/core/db/codebases but omit listConversationsForUser, listCodebasesForUser, setConversationUserId, and any mock for @archon/core/db/users. Currently harmless (admin fallback path) — TypeError time bomb if any future change sets userId in test context.
H-11: Docs Stale in Three Locations
📍 CLAUDE.md · reference/database.md · reference/security.md
CLAUDE.md:~378: "8 Tables" → 10;remote_agent_usersandremote_agent_project_membersnot listeddatabase.md: "8 tables"; migration list stops at020(missing021and022); upgrade instructions incomplete for both PostgreSQL and Docker PostgreSQLsecurity.md:92: "The Web UI has no built-in user authentication" — false whenJWT_SECRETis set
🟡 Medium Issues
View 11 medium-priority issues
| # | Issue | Location | Notes |
|---|---|---|---|
| M-1 | getValidatedBody cast duplicated 3× in auth.ts (CLAUDE.md DRY Rule of Three) |
auth.ts:133-137 |
Extract to routes/utils.ts or create issue |
| M-2 | UserResponse/AuthTokenResponse/RefreshTokenResponse manually written (CLAUDE.md: use generated types) |
web/lib/api.ts:71-88 |
Add TODO; fix in follow-up after bun generate:types |
| M-3 | Silent session restore failure — no console.warn, network errors indistinguishable from auth errors |
AuthContext.tsx:62-64 |
Trivial one-liner fix |
| M-4 | Silent getCurrentUser failure clears valid session |
AuthContext.tsx:77-79 |
Trivial one-liner fix |
| M-5 | .env.example implies JWT_SECRET has an on/off toggle (it does not) |
.env.example:10 |
Trivial text fix |
| M-6 | migrations/000_combined.sql header "10 Tables" but numbered list uses 1b notation |
000_combined.sql:4 |
Renumber 1–10 consecutively |
| M-7 | getUserId non-admin branch never exercised in CI |
api.ts:872-876 |
Update existing test context mocks |
| M-8 | countUsers() Number() coercion on PostgreSQL string result untested |
db/users.ts |
Add users.test.ts |
| M-9 | archon login not documented in cli.md |
reference/cli.md |
Add login section before ### serve |
| M-10 | JWT_SECRET missing from getting-started configuration reference |
configuration.md |
Add one table row |
| M-11 | listConversationsForUser dynamic SQL $N parameter indices untested |
conversations.ts |
Add 3 parameter-binding tests |
🟢 Low Issues
View 8 low-priority suggestions
| Issue | Location | Suggestion |
|---|---|---|
authMiddleware discards error — JWT_SECRET misconfiguration invisible at default log level |
middleware/auth.ts:44-47 |
Log at warn when error message includes "JWT_SECRET" |
| CLI login conflates auth failure and credential-write failure in single catch | login.ts:65-67 |
Split into two try/catch blocks for precise user feedback |
setConversationUserId missing JSDoc while surrounding functions have it |
conversations.ts:618 |
Add one-liner JSDoc |
refreshSession comment misstates reason for raw fetch |
web/lib/api.ts:1627 |
Clarify: avoids injecting stale in-memory token, not just expired token |
listCodebasesForUser has no doc comment distinguishing semantics |
codebases.ts:555 |
Add: "Returns only codebases user is a member of via project_members. Admins use listCodebases()." |
| Existing test mocks missing new DB functions (fragile for future changes) | api.codebases.test.ts, api.conversations.test.ts |
Add new functions to prevent future TypeError |
CLAUDE.md CLI section missing login command entry |
CLAUDE.md |
Add bun run cli login example |
~/.archon/auth.json not documented in archon-directories reference |
archon-directories docs | Add entry for auth.json credential file |
✅ What's Good
- JWT implementation correct: HS256, 1h/7d expiry, payload validation after
jwtVerify,getSecret()throws early — all CLAUDE.md fail-fast compliant - No rolled crypto:
Bun.password(bcrypt) throughout sanitizeUser()stripspassword_hashfrom all API responses without exceptionauth.jsonwith0o600permissions — correct security hygiene- Hono
Promise<void>pattern for public-path middleware correctly resolved - SSE bypass marked with TODO — not silently ignored
- Refresh route validates user still exists in DB — correctly handles deleted users
isAuthenticatedfrom!!accessToken+isLoadinggate prevents flash-redirect on session restore- Global 401 event dispatch (
archon:unauthorized) for clean cross-component logout - LocalStorage try/catch marked
/* best-effort */— documented intentional fallback listConversationsForUserSQL includes(hidden IS NULL OR hidden = false)— correctly excludes background workflow conversations- Incremental migration
022_multi_user_auth.sqlis correctly ordered (users/members first, then ALTER TABLE)
📋 Suggested Follow-up Issues
| Issue Title | Priority |
|---|---|
| "Add token-in-query-param auth for SSE streaming" | P2 (TODO already in code) |
"Run bun generate:types and replace manual auth interfaces in @archon/web" |
P3 |
"Add archon logout command" |
P3 |
Reviewed by Archon comprehensive-pr-review workflow · 5 agents
Full artifacts: ~/.archon/workspaces/coleam00/Archon/artifacts/runs/832f7414d75821524eeccd36979e3d0a/review/
…nUserId Addresses comment-quality findings from code review: - Add JSDoc to listConversationsForUser explaining it returns only user-owned conversations (vs listConversations which returns all for admins) - Fix setConversationUserId JSDoc which incorrectly said "Update conversation title" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix critical FK ordering in migrations/000_combined.sql: move users/project_members tables before conversations to avoid forward FK references on fresh PostgreSQL installs - Fix same FK ordering bug in packages/core/src/db/adapters/sqlite.ts createSchema() - Extract getValidatedBody<T> into shared packages/server/src/routes/utils.ts; remove local duplicate from api.ts; update auth.ts to use shared import - Add ownership checks (user_id scoping) to GET/PATCH/DELETE /api/conversations/:id endpoints - Rename snake_case params to camelCase in users.ts (userId, codebaseId, role) - Add user_id field to Conversation interface in core/src/types/index.ts - Add password masking (terminal: false) to CLI login command - Fix SSE auth bypass comment and JWT_SECRET misconfiguration logging in auth middleware - Narrow silent catch for createProjectMember duplicate-key error; log non-duplicate failures - Fix misplaced JSDoc on setConversationUserId / updateConversationTitle in conversations.ts - Add race condition comment to register endpoint for countUsers + createUser non-atomic pattern - Add auth.refresh_completed log entry in refresh endpoint - Add console.warn to silent catch blocks in AuthContext.tsx - Update .env.example JWT_SECRET comment to clarify no-auth-without-secret implications - Add unit tests: jwt.test.ts, users.test.ts, auth.test.ts (middleware), api.auth.test.ts - Update existing test mocks (api.conversations.test.ts, api.codebases.test.ts) with new DB functions - Update docs: api.md, database.md, security.md, cli.md, configuration.md, CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix Report: PR #30 Code Review — Self-Fix PassAll review findings have been analyzed. The vast majority were already addressed in the original implementation. One additional JSDoc fix was applied. Summary Table
Change MadeCommit: Added JSDoc to Test ResultsAll tests pass:
The PR is ready for merge. |
Fix Report: PR #30 Review FindingsAll review findings have been addressed. Two commits were pushed to
Critical Fixes
High-Severity Fixes
Medium/Low Fixes
New Tests
DocumentationUpdated: ValidationAll tests pass (
|
… return type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Combines workflow_definitions, users, project_members, node_states, test_results tables. Renumbers migrations to avoid collision: 022_workflow_definitions, 023_multi_user_auth, 024_node_states. Updates CLAUDE.md to reflect 13 tables. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
remote_agent_users,remote_agent_project_members) and auser_idcolumn to conversationsarchon logincommand that stores tokens in~/.archon/auth.jsonFixes #6
Changes
Database (
migrations/022_multi_user_auth.sql,migrations/000_combined.sql)remote_agent_users: id, username (UNIQUE), password_hash, display_name, role (admin|user), timestampsremote_agent_project_members: (user_id, codebase_id) composite PK, role (owner|member)remote_agent_conversations.user_id: nullable FK for scoping conversations to their creatorcreateSchema()inpackages/core/src/db/adapters/sqlite.tsupdated to match (required for fresh SQLite databases that skip migration files)Auth utilities (
packages/core/src/auth/)password.ts:hashPassword/verifyPasswordusingBun.password(bcrypt, no external dep)jwt.ts:generateToken/verifyToken/refreshTokenusingjose; access tokens expire in 1 h, refresh tokens in 7 dindex.ts: re-exports both modulesAuth routes (
packages/server/src/routes/auth.ts)POST /api/auth/register— creates user; first registered user auto-getsadminrole; returns{ user, accessToken, refreshToken }POST /api/auth/login— verifies credentials; returns same shapePOST /api/auth/refresh— issues new token pair from a valid refresh tokenGET /api/auth/me— returns current user (requires Bearer token)packages/server/src/routes/schemas/auth.schemas.tsAuth middleware (
packages/server/src/middleware/auth.ts)Authorization: Bearer <token>, verifies viaverifyToken, setsc.set('userId', ...)andc.set('userRole', ...)/api/health,/api/auth/*,/webhooks/*JWT_SECRETis not set (e.g., tests), middleware is a no-op;getUserId()treats the caller as admin to preserve backward compatibilityProject scoping (
packages/server/src/routes/api.ts,packages/core/src/db/)listCodebasesForUser(userId, isAdmin)— admins see all codebases; members see only their assigned projectslistConversationsForUser(userId, isAdmin)— same scoping patternsetConversationUserId(id, userId)— sets creator on first messageownerinremote_agent_project_membersWeb UI (
packages/web/src/)contexts/AuthContext.tsx: React context; storesaccessToken/refreshToken/userinlocalStorage; provideslogin,logout,registerfunctions; auto-refreshes token on 401routes/LoginPage.tsx: login/register form with toggleApp.tsx:<AuthGuard>wraps all routes; redirects to/loginif unauthenticatedcomponents/layout/TopNav.tsx: user avatar/username + logout dropdownCLI (
packages/cli/src/commands/login.ts)archon loginprompts for server URL, username, and password{ accessToken, refreshToken, serverUrl }in~/.archon/auth.jsonAuthorizationheaderEnvironment
.env.example: documentsJWT_SECRET(required for production) andJWT_REFRESH_SECRET(optional, defaults toJWT_SECRET)Deviations from Plan
getUserIdthrows when no userId{ userId: undefined; isAdmin: true }JWT_SECRETis unset, preserves backward-compatible admin-level accessreturn next()in middlewareawait next(); return;for public pathsPromise<void>;@typescript-eslint/no-invalid-void-typeblocks void-union returnssqlite.tscreateSchema()createSchemamust stay in syncValidation
All checks pass (
bun run validate):--max-warnings 0)Test plan
DATABASE_URL, confirm tables created automaticallyadminuseraccessToken+refreshTokenGET /api/auth/mewith valid token → returns user infoPOST /api/auth/refreshwith valid refresh token → new token pair issued/login//loginarchon loginCLI command → token stored in~/.archon/auth.jsonmigrations/022_multi_user_auth.sql, confirm same behavior