From 1c0ffa50f4767b2af60c4c9e4ce540d906f7c134 Mon Sep 17 00:00:00 2001 From: Luis Erlacher Date: Sun, 12 Apr 2026 22:17:56 -0300 Subject: [PATCH 1/4] feat: add multi-user authentication with JWT and project-level scoping (#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) --- .env.example | 5 + bun.lock | 25 +- migrations/000_combined.sql | 47 +++- migrations/022_multi_user_auth.sql | 32 +++ packages/cli/src/cli.ts | 10 +- packages/cli/src/commands/login.ts | 69 +++++ packages/core/package.json | 2 + packages/core/src/auth/index.ts | 3 + packages/core/src/auth/jwt.ts | 38 +++ packages/core/src/auth/password.ts | 7 + packages/core/src/db/adapters/sqlite.ts | 40 +++ packages/core/src/db/codebases.ts | 11 + packages/core/src/db/conversations.ts | 42 +++ packages/core/src/db/index.ts | 1 + packages/core/src/db/users.ts | 84 ++++++ packages/core/src/index.ts | 2 + packages/core/src/types/index.ts | 16 ++ packages/server/src/index.ts | 8 + packages/server/src/middleware/auth.ts | 48 ++++ packages/server/src/routes/api.ts | 51 +++- packages/server/src/routes/auth.ts | 253 ++++++++++++++++++ .../server/src/routes/schemas/auth.schemas.ts | 56 ++++ packages/web/src/App.tsx | 63 +++-- packages/web/src/components/layout/TopNav.tsx | 28 +- packages/web/src/contexts/AuthContext.tsx | 122 +++++++++ packages/web/src/lib/api.ts | 82 +++++- packages/web/src/routes/LoginPage.tsx | 160 +++++++++++ 27 files changed, 1261 insertions(+), 44 deletions(-) create mode 100644 migrations/022_multi_user_auth.sql create mode 100644 packages/cli/src/commands/login.ts create mode 100644 packages/core/src/auth/index.ts create mode 100644 packages/core/src/auth/jwt.ts create mode 100644 packages/core/src/auth/password.ts create mode 100644 packages/core/src/db/users.ts create mode 100644 packages/server/src/middleware/auth.ts create mode 100644 packages/server/src/routes/auth.ts create mode 100644 packages/server/src/routes/schemas/auth.schemas.ts create mode 100644 packages/web/src/contexts/AuthContext.tsx create mode 100644 packages/web/src/routes/LoginPage.tsx diff --git a/.env.example b/.env.example index 325e49a6fb..285b80bc72 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,11 @@ GITEA_ALLOWED_USERS= # If not set, falls back to BOT_DISPLAY_NAME then config.botName # GITEA_BOT_MENTION=archon +# Authentication (JWT) +# Required when auth is enabled. Generate a secure value: +# openssl rand -base64 32 +# JWT_SECRET=your-secret-key-min-32-chars-change-this + # Server PORT=3000 # HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only. diff --git a/bun.lock b/bun.lock index 43f419a191..ba757b7ed6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/adapters": { "name": "@archon/adapters", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/core": "workspace:*", "@archon/git": "workspace:*", @@ -41,7 +41,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.2.13", + "version": "0.3.5", "bin": { "archon": "./src/cli.ts", }, @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@archon/core", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/git": "workspace:*", @@ -70,6 +70,7 @@ "@archon/paths": "workspace:*", "@archon/workflows": "workspace:*", "@openai/codex-sdk": "^0.116.0", + "jose": "^5.9.6", "pg": "^8.11.0", "zod": "^3", }, @@ -83,7 +84,7 @@ }, "packages/docs-web": { "name": "@archon/docs-web", - "version": "0.2.12", + "version": "0.3.5", "dependencies": { "@astrojs/starlight": "^0.38.0", "astro": "^6.1.0", @@ -92,7 +93,7 @@ }, "packages/git": { "name": "@archon/git", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/paths": "workspace:*", }, @@ -102,7 +103,7 @@ }, "packages/isolation": { "name": "@archon/isolation", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -113,7 +114,7 @@ }, "packages/paths": { "name": "@archon/paths", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "pino": "^9", "pino-pretty": "^13", @@ -124,7 +125,7 @@ }, "packages/server": { "name": "@archon/server", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", @@ -142,7 +143,7 @@ }, "packages/web": { "name": "@archon/web", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@dagrejs/dagre": "^2.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -194,7 +195,7 @@ }, "packages/workflows": { "name": "@archon/workflows", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -1580,7 +1581,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], @@ -2504,6 +2505,8 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 176963b40e..f74eb437da 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -1,8 +1,8 @@ -- Remote Coding Agent - Combined Schema --- Version: Combined (final state after migrations 001-020) +-- Version: Combined (final state after migrations 001-022) -- Description: Complete database schema (idempotent - safe to run multiple times) -- --- 8 Tables: +-- 10 Tables: -- 1. remote_agent_codebases -- 1b. remote_agent_codebase_env_vars -- 2. remote_agent_conversations @@ -11,6 +11,8 @@ -- 5. remote_agent_workflow_runs -- 6. remote_agent_workflow_events -- 7. remote_agent_messages +-- 8. remote_agent_users +-- 9. remote_agent_project_members -- -- Dropped tables (via migrations): -- - remote_agent_command_templates (017) @@ -74,6 +76,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_conversations ( title VARCHAR(255), deleted_at TIMESTAMP WITH TIME ZONE, hidden BOOLEAN DEFAULT FALSE, + user_id UUID REFERENCES remote_agent_users(id) ON DELETE SET NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), @@ -86,6 +89,8 @@ CREATE INDEX IF NOT EXISTS idx_conversations_hidden ON remote_agent_conversations(hidden); CREATE INDEX IF NOT EXISTS idx_conversations_codebase ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_conversations_user_id + ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL; COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS 'UUID reference to isolation_environments table (the only isolation reference)'; @@ -252,6 +257,40 @@ CREATE TABLE IF NOT EXISTS remote_agent_messages ( CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON remote_agent_messages(conversation_id, created_at ASC); +-- ============================================================================ +-- Table 8: Users +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE remote_agent_users IS + 'User accounts for authentication. First registered user gets admin role.'; + +-- ============================================================================ +-- Table 9: Project Members +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id + ON remote_agent_project_members(user_id); + +COMMENT ON TABLE remote_agent_project_members IS + 'Junction table: which users have access to which codebases. Roles: owner, member.'; + -- ============================================================================ -- Cleanup: Drop legacy objects from older schemas -- ============================================================================ @@ -312,3 +351,7 @@ ALTER TABLE remote_agent_sessions -- From migration 021: allow_env_keys on codebases ALTER TABLE remote_agent_codebases ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE; + +-- From migration 022: multi-user auth (user_id on conversations) +ALTER TABLE remote_agent_conversations + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES remote_agent_users(id) ON DELETE SET NULL; diff --git a/migrations/022_multi_user_auth.sql b/migrations/022_multi_user_auth.sql new file mode 100644 index 0000000000..f7d1098833 --- /dev/null +++ b/migrations/022_multi_user_auth.sql @@ -0,0 +1,32 @@ +-- Multi-user authentication: users, project memberships, conversation ownership +-- Tables: remote_agent_users, remote_agent_project_members +-- Column: remote_agent_conversations.user_id + +-- Users table +CREATE TABLE IF NOT EXISTS remote_agent_users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Project members junction +CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) +); + +-- Add user_id to conversations (nullable — existing rows keep NULL) +ALTER TABLE remote_agent_conversations + ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_conversations_user_id + ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id + ON remote_agent_project_members(user_id); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 96c0209666..e968cf334a 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -66,6 +66,7 @@ import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; import { serveCommand } from './commands/serve'; +import { loginCommand } from './commands/login'; import { closeDatabase } from '@archon/core'; import { setLogLevel, @@ -104,6 +105,7 @@ Commands: isolation cleanup --merged Remove environments with branches merged into main continue [msg] Continue work on an existing worktree with prior context complete [...] Complete branch lifecycle (remove worktree + branches) + login Log in to an Archon server serve Start the web UI server (downloads web UI on first run) validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files @@ -207,6 +209,7 @@ async function main(): Promise { 'allow-env-keys': { type: 'boolean' }, port: { type: 'string' }, 'download-only': { type: 'boolean' }, + 'server-url': { type: 'string' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -241,7 +244,7 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; + const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve', 'login']; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -548,6 +551,11 @@ async function main(): Promise { break; } + case 'login': { + const serverUrl = values['server-url'] as string | undefined; + return await loginCommand({ serverUrl }); + } + case 'serve': { const servePort = values.port !== undefined ? Number(values.port) : undefined; const downloadOnly = Boolean(values['download-only']); diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts new file mode 100644 index 0000000000..eb27b49a52 --- /dev/null +++ b/packages/cli/src/commands/login.ts @@ -0,0 +1,69 @@ +import { join } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +import { createInterface } from 'readline'; + +interface AuthFile { + accessToken: string; + refreshToken: string; + username: string; + serverUrl: string; +} + +async function readLine(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(prompt, answer => { + rl.close(); + resolve(answer); + }); + }); +} + +export interface LoginOptions { + serverUrl?: string; +} + +export async function loginCommand(opts: LoginOptions): Promise { + const serverUrl = opts.serverUrl ?? process.env.ARCHON_SERVER_URL ?? 'http://localhost:3090'; + + const username = await readLine('Username: '); + const password = await readLine('Password: '); + + try { + const res = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + console.error('Login failed: Invalid username or password'); + return 1; + } + + const data = (await res.json()) as { + user: { username: string }; + accessToken: string; + refreshToken: string; + }; + const authFile: AuthFile = { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + username: data.user.username, + serverUrl, + }; + + const archonHome = process.env.ARCHON_HOME ?? join(process.env.HOME ?? '~', '.archon'); + mkdirSync(archonHome, { recursive: true }); + writeFileSync(join(archonHome, 'auth.json'), JSON.stringify(authFile, null, 2), { + mode: 0o600, + }); + + console.log(`Logged in as ${data.user.username}`); + console.log(`Credentials saved to ${join(archonHome, 'auth.json')}`); + return 0; + } catch (error) { + console.error(`Login error: ${(error as Error).message}`); + return 1; + } +} diff --git a/packages/core/package.json b/packages/core/package.json index d0d93635b6..5242575c54 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,6 +9,7 @@ "./types": "./src/types/index.ts", "./db": "./src/db/index.ts", "./db/*": "./src/db/*.ts", + "./auth": "./src/auth/index.ts", "./clients": "./src/clients/index.ts", "./operations": "./src/operations/index.ts", "./operations/*": "./src/operations/*.ts", @@ -34,6 +35,7 @@ "@archon/paths": "workspace:*", "@archon/workflows": "workspace:*", "@openai/codex-sdk": "^0.116.0", + "jose": "^5.9.6", "pg": "^8.11.0", "zod": "^3" }, diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts new file mode 100644 index 0000000000..cf22d1578e --- /dev/null +++ b/packages/core/src/auth/index.ts @@ -0,0 +1,3 @@ +export { hashPassword, verifyPassword } from './password'; +export { generateAccessToken, generateRefreshToken, verifyToken } from './jwt'; +export type { TokenPayload } from './jwt'; diff --git a/packages/core/src/auth/jwt.ts b/packages/core/src/auth/jwt.ts new file mode 100644 index 0000000000..f9ff689b2f --- /dev/null +++ b/packages/core/src/auth/jwt.ts @@ -0,0 +1,38 @@ +import { SignJWT, jwtVerify } from 'jose'; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET environment variable is required'); + return new TextEncoder().encode(secret); +} + +export interface TokenPayload { + userId: string; + role: 'admin' | 'user'; +} + +export async function generateAccessToken(payload: TokenPayload): Promise { + return new SignJWT({ userId: payload.userId, role: payload.role }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .setIssuedAt() + .sign(getSecret()); +} + +export async function generateRefreshToken(payload: TokenPayload): Promise { + return new SignJWT({ userId: payload.userId, role: payload.role }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('7d') + .setIssuedAt() + .sign(getSecret()); +} + +export async function verifyToken(token: string): Promise { + const { payload } = await jwtVerify(token, getSecret()); + const userId = payload.userId; + const role = payload.role; + if (typeof userId !== 'string' || (role !== 'admin' && role !== 'user')) { + throw new Error('Invalid token payload'); + } + return { userId, role }; +} diff --git a/packages/core/src/auth/password.ts b/packages/core/src/auth/password.ts new file mode 100644 index 0000000000..419230cda0 --- /dev/null +++ b/packages/core/src/auth/password.ts @@ -0,0 +1,7 @@ +export async function hashPassword(plain: string): Promise { + return Bun.password.hash(plain); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return Bun.password.verify(plain, hash); +} diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 2864e4fc43..5c395d34e3 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -216,6 +216,23 @@ export class SqliteAdapter implements IDatabase { getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed'); } + // Conversations: user_id (multi-user auth) + try { + const convCols2 = this.db + .prepare("PRAGMA table_info('remote_agent_conversations')") + .all() as { name: string }[]; + const convColNames2 = new Set(convCols2.map(c => c.name)); + if (!convColNames2.has('user_id')) { + this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN user_id TEXT'); + } + // Create index after column exists (can't be in createSchema for existing DBs) + this.db.run( + 'CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL' + ); + } catch (e: unknown) { + getLog().warn({ err: e as Error }, 'db.sqlite_migration_conversations_user_id_failed'); + } + // Codebases columns (added in #983 — env-leak gate consent bit) try { const cbCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as { @@ -281,6 +298,7 @@ export class SqliteAdapter implements IDatabase { title TEXT, deleted_at TEXT, hidden INTEGER DEFAULT 0, + user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), last_activity_at TEXT DEFAULT (datetime('now')), @@ -353,6 +371,25 @@ export class SqliteAdapter implements IDatabase { created_at TEXT DEFAULT (datetime('now')) ); + -- Users table + CREATE TABLE IF NOT EXISTS remote_agent_users ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + -- Project members table + CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) + ); + -- Messages table (conversation history for Web UI) CREATE TABLE IF NOT EXISTS remote_agent_messages ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), @@ -383,6 +420,9 @@ export class SqliteAdapter implements IDatabase { CREATE INDEX IF NOT EXISTS idx_sessions_codebase ON remote_agent_sessions(codebase_id); CREATE INDEX IF NOT EXISTS idx_isolation_env_status ON remote_agent_isolation_environments(status); + CREATE INDEX IF NOT EXISTS idx_project_members_codebase ON remote_agent_project_members(codebase_id); + CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON remote_agent_project_members(user_id); + -- From PG migration 009: staleness detection for running workflows CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity ON remote_agent_workflow_runs(last_activity_at) WHERE status = 'running'; diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index b9f45578b6..5409740516 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -180,6 +180,17 @@ export async function listCodebases(): Promise { return result.rows; } +export async function listCodebasesForUser(userId: string): Promise { + const result = await pool.query( + `SELECT c.* FROM remote_agent_codebases c + INNER JOIN remote_agent_project_members pm ON pm.codebase_id = c.id + WHERE pm.user_id = $1 + ORDER BY c.name`, + [userId] + ); + return result.rows; +} + export async function deleteCodebase(id: string): Promise { getLog().debug({ codebaseId: id }, 'db.codebase_delete_cascade_started'); // First, unlink any sessions referencing this codebase (FK has no cascade) diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..011bd3fef9 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -219,6 +219,40 @@ export async function listConversations( return result.rows; } +export async function listConversationsForUser( + userId: string, + limit = 50, + platformType?: string, + codebaseId?: string, + excludeEmpty = false +): Promise { + const params: unknown[] = [userId]; + let sql = + 'SELECT * FROM remote_agent_conversations WHERE deleted_at IS NULL AND (hidden IS NULL OR hidden = false) AND user_id = $1'; + + if (excludeEmpty) { + sql += + ' AND (title IS NOT NULL OR EXISTS (SELECT 1 FROM remote_agent_messages WHERE conversation_id = remote_agent_conversations.id LIMIT 1))'; + } + + if (platformType) { + params.push(platformType); + sql += ` AND platform_type = $${String(params.length)}`; + } + + if (codebaseId) { + params.push(codebaseId); + sql += ` AND codebase_id = $${String(params.length)}`; + } + + sql += ' ORDER BY last_activity_at DESC NULLS LAST'; + params.push(limit); + sql += ` LIMIT $${String(params.length)}`; + + const result = await pool.query(sql, params); + return result.rows; +} + /** * Update last_activity_at for staleness tracking */ @@ -233,6 +267,14 @@ export async function touchConversation(id: string): Promise { /** * Update conversation title */ +export async function setConversationUserId(id: string, userId: string): Promise { + const dialect = getDialect(); + await pool.query( + `UPDATE remote_agent_conversations SET user_id = $1, updated_at = ${dialect.now()} WHERE id = $2`, + [userId, id] + ); +} + export async function updateConversationTitle(id: string, title: string): Promise { const dialect = getDialect(); const result = await pool.query( diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 10a72a2412..ac1e6ecdab 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -19,6 +19,7 @@ export * as codebaseDb from './codebases'; export * as sessionDb from './sessions'; export * as isolationEnvDb from './isolation-environments'; export * as workflowDb from './workflows'; +export * as usersDb from './users'; // Also export individual functions for direct imports export * from './conversations'; diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts new file mode 100644 index 0000000000..9de6794540 --- /dev/null +++ b/packages/core/src/db/users.ts @@ -0,0 +1,84 @@ +import { pool, getDialect } from './connection'; +import type { User, ProjectMember } from '../types'; +import { createLogger } from '@archon/paths'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('db.users'); + return cachedLog; +} + +export async function createUser(data: { + username: string; + password_hash: string; + display_name?: string; + role?: 'admin' | 'user'; +}): Promise { + const dialect = getDialect(); + const id = dialect.generateUuid(); + const now = dialect.now(); + const role = data.role ?? 'user'; + + const result = await pool.query( + `INSERT INTO remote_agent_users (id, username, password_hash, display_name, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, ${now}, ${now}) RETURNING *`, + [id, data.username, data.password_hash, data.display_name ?? null, role] + ); + if (!result.rows[0]) { + throw new Error('Failed to create user: INSERT succeeded but no row returned'); + } + getLog().info({ userId: id, username: data.username, role }, 'user.create_completed'); + return result.rows[0]; +} + +export async function getUserByUsername(username: string): Promise { + const result = await pool.query('SELECT * FROM remote_agent_users WHERE username = $1', [ + username, + ]); + return result.rows[0] ?? null; +} + +export async function getUserById(id: string): Promise { + const result = await pool.query('SELECT * FROM remote_agent_users WHERE id = $1', [id]); + return result.rows[0] ?? null; +} + +export async function countUsers(): Promise { + const result = await pool.query<{ count: string | number }>( + 'SELECT COUNT(*) as count FROM remote_agent_users' + ); + return Number(result.rows[0]?.count ?? 0); +} + +export async function createProjectMember( + user_id: string, + codebase_id: string, + role: 'owner' | 'member' +): Promise { + const result = await pool.query( + `INSERT INTO remote_agent_project_members (user_id, codebase_id, role) + VALUES ($1, $2, $3) RETURNING *`, + [user_id, codebase_id, role] + ); + if (!result.rows[0]) { + throw new Error('Failed to create project member: INSERT succeeded but no row returned'); + } + getLog().info({ user_id, codebase_id, role }, 'project_member.create_completed'); + return result.rows[0]; +} + +export async function getUserCodebaseIds(user_id: string): Promise { + const result = await pool.query<{ codebase_id: string }>( + 'SELECT codebase_id FROM remote_agent_project_members WHERE user_id = $1', + [user_id] + ); + return result.rows.map(r => r.codebase_id); +} + +export async function isMember(user_id: string, codebase_id: string): Promise { + const result = await pool.query<{ count: string | number }>( + 'SELECT COUNT(*) as count FROM remote_agent_project_members WHERE user_id = $1 AND codebase_id = $2', + [user_id, codebase_id] + ); + return Number(result.rows[0]?.count ?? 0) > 0; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e212eb10c9..044b3e4ebc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,8 @@ export { type HandleMessageContext, type AttachedFile, type Codebase, + type User, + type ProjectMember, type Session, type CommandResult, type IPlatformAdapter, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 549891f35e..121dfb9b0a 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -63,6 +63,22 @@ export interface Codebase { updated_at: Date; } +export interface User { + id: string; + username: string; + password_hash: string; + display_name: string | null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; +} + +export interface ProjectMember { + user_id: string; + codebase_id: string; + role: 'owner' | 'member'; +} + export const sessionMetadataSchema = z .object({ lastCommand: z.string().optional(), diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7152aec8b4..54b391679e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -58,6 +58,8 @@ import { MessagePersistence } from './adapters/web/persistence'; import { SSETransport } from './adapters/web/transport'; import { WorkflowEventBridge } from './adapters/web/workflow-bridge'; import { registerApiRoutes } from './routes/api'; +import { registerAuthRoutes } from './routes/auth'; +import { authMiddleware } from './middleware/auth'; import { handleMessage, pool, @@ -487,6 +489,12 @@ export async function startServer(opts: ServerOptions = {}): Promise { return c.json({ error: 'Internal server error' }, 500); }); + // Auth middleware (after CORS, before routes) + app.use('/api/*', authMiddleware); + + // Register auth routes first (login/register/refresh are public) + registerAuthRoutes(app); + // Register Web UI API routes registerApiRoutes(app, webAdapter, lockManager); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000000..22d7da7e93 --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,48 @@ +import type { Context, Next } from 'hono'; +import { verifyToken } from '@archon/core/auth'; +import { createLogger } from '@archon/paths'; + +const log = createLogger('middleware.auth'); + +const PUBLIC_PATHS = new Set([ + '/api/auth/login', + '/api/auth/register', + '/api/auth/refresh', + '/api/health', + '/api/health/db', + '/api/openapi.json', +]); + +const PUBLIC_PREFIXES = [ + '/webhooks/', + // SSE streaming — clients cannot set Authorization headers easily. + // Web UI access token is still required for all other endpoints. + // TODO: Add SSE auth via query param token in a follow-up. + '/api/stream/', +]; + +export async function authMiddleware(c: Context, next: Next): Promise { + const path = new URL(c.req.url).pathname; + + if (PUBLIC_PATHS.has(path) || PUBLIC_PREFIXES.some(p => path.startsWith(p))) { + await next(); + return; + } + + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + c.res = c.json({ error: 'Unauthorized' }, 401); + return; + } + + const token = authHeader.slice(7); + try { + const payload = await verifyToken(token); + c.set('userId', payload.userId); + c.set('userRole', payload.role); + await next(); + } catch { + log.debug({ path }, 'auth.token_invalid'); + c.res = c.json({ error: 'Unauthorized' }, 401); + } +} diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index cfade2c012..d1b29aa7bf 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -63,6 +63,7 @@ function getLog(): ReturnType { } import * as conversationDb from '@archon/core/db/conversations'; import * as codebaseDb from '@archon/core/db/codebases'; +import * as usersDb from '@archon/core/db/users'; import * as envVarDb from '@archon/core/db/env-vars'; import * as isolationEnvDb from '@archon/core/db/isolation-environments'; import * as workflowDb from '@archon/core/db/workflows'; @@ -868,6 +869,13 @@ export function registerApiRoutes( return c.json({ error: message, ...(detail ? { detail } : {}) }, status); } + function getUserId(c: Context): { userId: string | undefined; isAdmin: boolean } { + const userId = c.get('userId') as string | undefined; + const userRole = c.get('userRole') as string | undefined; + // When auth middleware is not applied (tests, JWT_SECRET not set), treat as admin + return { userId, isAdmin: !userId || userRole === 'admin' }; + } + /** * Validate that a caller-supplied `cwd` is rooted at a registered codebase path. * This prevents path traversal — callers cannot read/write outside known project roots. @@ -1038,17 +1046,22 @@ export function registerApiRoutes( return { accepted: true, status: result.status }; } - // GET /api/conversations - List conversations + // GET /api/conversations - List conversations (scoped by user) registerOpenApiRoute(getConversationsRoute, async c => { try { const platformType = c.req.query('platform') ?? undefined; const codebaseId = c.req.query('codebaseId') ?? undefined; - const conversations = await conversationDb.listConversations( - 50, - platformType, - codebaseId, - true - ); + const { userId, isAdmin } = getUserId(c); + const conversations = + isAdmin || !userId + ? await conversationDb.listConversations(50, platformType, codebaseId, true) + : await conversationDb.listConversationsForUser( + userId, + 50, + platformType, + codebaseId, + true + ); return c.json(conversations); } catch (error) { getLog().error({ err: error }, 'list_conversations_failed'); @@ -1094,6 +1107,12 @@ export function registerApiRoutes( ); webAdapter.setConversationDbId(conversation.platform_conversation_id, conversation.id); + // Set user_id on the conversation for ownership scoping + const { userId } = getUserId(c); + if (userId) { + await conversationDb.setConversationUserId(conversation.id, userId); + } + // If message provided, dispatch it atomically (avoids ghost "Untitled" conversations) if (message) { try { @@ -1454,10 +1473,14 @@ export function registerApiRoutes( }); }); - // GET /api/codebases - List codebases + // GET /api/codebases - List codebases (scoped by user membership) registerOpenApiRoute(listCodebasesRoute, async c => { try { - const codebases = await codebaseDb.listCodebases(); + const { userId, isAdmin } = getUserId(c); + const codebases = + isAdmin || !userId + ? await codebaseDb.listCodebases() + : await codebaseDb.listCodebasesForUser(userId); // Deduplicate by repository_url (keep most recently updated) const normalizeUrl = (url: string): string => url.replace(/\.git$/, ''); @@ -1536,6 +1559,16 @@ export function registerApiRoutes( return apiError(c, 500, 'Codebase created but not found'); } + // Auto-add creator as owner of the project + const { userId } = getUserId(c); + if (userId) { + try { + await usersDb.createProjectMember(userId, result.codebaseId, 'owner'); + } catch { + // Ignore duplicate membership (e.g., project already existed) + } + } + return c.json(codebase, result.alreadyExisted ? 200 : 201); } catch (error) { if (error instanceof EnvLeakError) { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000000..e74da8f54f --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,253 @@ +import { OpenAPIHono, createRoute } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import * as usersDb from '@archon/core/db/users'; +import { + hashPassword, + verifyPassword, + generateAccessToken, + generateRefreshToken, + verifyToken, +} from '@archon/core/auth'; +import type { User } from '@archon/core'; +import { createLogger } from '@archon/paths'; +import { errorSchema } from './schemas/common.schemas'; +import { + registerBodySchema, + loginBodySchema, + refreshBodySchema, + authResponseSchema, + refreshResponseSchema, + userSchema, +} from './schemas/auth.schemas'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('api.auth'); + return cachedLog; +} + +function jsonError(description: string): { + content: { 'application/json': { schema: typeof errorSchema } }; + description: string; +} { + return { content: { 'application/json': { schema: errorSchema } }, description }; +} + +function sanitizeUser(user: User): { + id: string; + username: string; + displayName: string | null; + role: 'admin' | 'user'; + createdAt: string; +} { + return { + id: user.id, + username: user.username, + displayName: user.display_name, + role: user.role, + createdAt: user.created_at, + }; +} + +// Route definitions +const registerRoute = createRoute({ + method: 'post', + path: '/api/auth/register', + tags: ['Auth'], + summary: 'Register a new user', + request: { + body: { content: { 'application/json': { schema: registerBodySchema } }, required: true }, + }, + responses: { + 201: { + content: { 'application/json': { schema: authResponseSchema } }, + description: 'User created', + }, + 409: jsonError('Username already taken'), + 500: jsonError('Server error'), + }, +}); + +const loginRoute = createRoute({ + method: 'post', + path: '/api/auth/login', + tags: ['Auth'], + summary: 'Login with username and password', + request: { + body: { content: { 'application/json': { schema: loginBodySchema } }, required: true }, + }, + responses: { + 200: { + content: { 'application/json': { schema: authResponseSchema } }, + description: 'OK', + }, + 401: jsonError('Invalid credentials'), + 500: jsonError('Server error'), + }, +}); + +const refreshRoute = createRoute({ + method: 'post', + path: '/api/auth/refresh', + tags: ['Auth'], + summary: 'Refresh access token', + request: { + body: { content: { 'application/json': { schema: refreshBodySchema } }, required: true }, + }, + responses: { + 200: { + content: { 'application/json': { schema: refreshResponseSchema } }, + description: 'OK', + }, + 401: jsonError('Invalid refresh token'), + 500: jsonError('Server error'), + }, +}); + +const meRoute = createRoute({ + method: 'get', + path: '/api/auth/me', + tags: ['Auth'], + summary: 'Get current user info', + responses: { + 200: { + content: { 'application/json': { schema: userSchema } }, + description: 'OK', + }, + 401: jsonError('Unauthorized'), + 500: jsonError('Server error'), + }, +}); + +export function registerAuthRoutes(app: OpenAPIHono): void { + function registerOpenApiRoute( + route: ReturnType, + handler: (c: Context) => Response | Promise + ): void { + app.openapi(route, handler as never); + } + + // POST /api/auth/register + registerOpenApiRoute(registerRoute, async (c: Context) => { + try { + const body = ( + c.req as unknown as { + valid(k: 'json'): { username: string; password: string; displayName?: string }; + } + ).valid('json'); + + const existing = await usersDb.getUserByUsername(body.username); + if (existing) { + return c.json({ error: 'Username already taken' }, 409); + } + + const userCount = await usersDb.countUsers(); + const role = userCount === 0 ? 'admin' : 'user'; + + const passwordHash = await hashPassword(body.password); + const user = await usersDb.createUser({ + username: body.username, + password_hash: passwordHash, + display_name: body.displayName, + role, + }); + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + getLog().info({ userId: user.id, role }, 'auth.register_completed'); + return c.json({ user: sanitizeUser(user), accessToken, refreshToken }, 201); + } catch (error) { + getLog().error({ err: error }, 'auth.register_failed'); + return c.json({ error: 'Registration failed' }, 500); + } + }); + + // POST /api/auth/login + registerOpenApiRoute(loginRoute, async (c: Context) => { + try { + const body = ( + c.req as unknown as { valid(k: 'json'): { username: string; password: string } } + ).valid('json'); + + getLog().info({ username: body.username }, 'auth.login_started'); + + const user = await usersDb.getUserByUsername(body.username); + if (!user) { + return c.json({ error: 'Invalid username or password' }, 401); + } + + const valid = await verifyPassword(body.password, user.password_hash); + if (!valid) { + return c.json({ error: 'Invalid username or password' }, 401); + } + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + getLog().info({ userId: user.id }, 'auth.login_completed'); + return c.json({ user: sanitizeUser(user), accessToken, refreshToken }); + } catch (error) { + getLog().error({ err: error }, 'auth.login_failed'); + return c.json({ error: 'Login failed' }, 500); + } + }); + + // POST /api/auth/refresh + registerOpenApiRoute(refreshRoute, async (c: Context) => { + try { + const body = (c.req as unknown as { valid(k: 'json'): { refreshToken: string } }).valid( + 'json' + ); + + let payload; + try { + payload = await verifyToken(body.refreshToken); + } catch { + return c.json({ error: 'Invalid refresh token' }, 401); + } + + const user = await usersDb.getUserById(payload.userId); + if (!user) { + return c.json({ error: 'Invalid refresh token' }, 401); + } + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + return c.json({ accessToken, refreshToken }); + } catch (error) { + getLog().error({ err: error }, 'auth.refresh_failed'); + return c.json({ error: 'Token refresh failed' }, 500); + } + }); + + // GET /api/auth/me + registerOpenApiRoute(meRoute, async (c: Context) => { + try { + const userId = c.get('userId') as string | undefined; + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const user = await usersDb.getUserById(userId); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + return c.json(sanitizeUser(user)); + } catch (error) { + getLog().error({ err: error }, 'auth.me_failed'); + return c.json({ error: 'Failed to get user info' }, 500); + } + }); +} diff --git a/packages/server/src/routes/schemas/auth.schemas.ts b/packages/server/src/routes/schemas/auth.schemas.ts new file mode 100644 index 0000000000..875d60970f --- /dev/null +++ b/packages/server/src/routes/schemas/auth.schemas.ts @@ -0,0 +1,56 @@ +import { z } from '@hono/zod-openapi'; + +export const registerBodySchema = z + .object({ + username: z.string().min(3).max(50), + password: z.string().min(8), + displayName: z.string().max(100).optional(), + }) + .strict() + .openapi('RegisterBody'); + +export const loginBodySchema = z + .object({ + username: z.string(), + password: z.string(), + }) + .strict() + .openapi('LoginBody'); + +export const refreshBodySchema = z + .object({ + refreshToken: z.string(), + }) + .strict() + .openapi('RefreshBody'); + +export const userSchema = z + .object({ + id: z.string(), + username: z.string(), + displayName: z.string().nullable(), + role: z.enum(['admin', 'user']), + createdAt: z.string(), + }) + .openapi('User'); + +export const authResponseSchema = z + .object({ + user: userSchema, + accessToken: z.string(), + refreshToken: z.string(), + }) + .openapi('AuthResponse'); + +export const refreshResponseSchema = z + .object({ + accessToken: z.string(), + refreshToken: z.string(), + }) + .openapi('RefreshResponse'); + +export type RegisterBody = z.infer; +export type LoginBody = z.infer; +export type AuthResponse = z.infer; +export type RefreshResponse = z.infer; +export type UserResponse = z.infer; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index d308640c9e..b41aab413e 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { QueryClientProvider } from '@tanstack/react-query'; import { Layout } from '@/components/layout/Layout'; import { ProjectProvider } from '@/contexts/ProjectContext'; +import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { queryClient } from '@/lib/query-client'; import { DashboardPage } from '@/routes/DashboardPage'; import { ChatPage } from '@/routes/ChatPage'; @@ -11,6 +12,7 @@ import { WorkflowsPage } from '@/routes/WorkflowsPage'; import { WorkflowExecutionPage } from '@/routes/WorkflowExecutionPage'; import { WorkflowBuilderPage } from '@/routes/WorkflowBuilderPage'; import { SettingsPage } from '@/routes/SettingsPage'; +import { LoginPage } from '@/routes/LoginPage'; interface ErrorBoundaryState { hasError: boolean; @@ -60,27 +62,54 @@ class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryStat } } +function RequireAuth({ children }: { children: ReactNode }): React.ReactElement { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + export function App(): React.ReactElement { return ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 45924f5004..76ff03e8ca 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,8 +1,9 @@ -import { NavLink, Link } from 'react-router'; +import { NavLink, Link, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Workflow, Settings, LogOut } from 'lucide-react'; import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { useAuth } from '@/contexts/AuthContext'; const tabs = [ { to: '/chat', end: false, icon: MessageSquare, label: 'Chat' }, @@ -12,6 +13,9 @@ const tabs = [ ] as const; export function TopNav(): React.ReactElement { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const { data: runningRuns } = useQuery({ queryKey: ['workflowRuns', { status: 'running' }], queryFn: () => listWorkflowRuns({ status: 'running', limit: 1 }), @@ -58,7 +62,25 @@ export function TopNav(): React.ReactElement { )} ))} - +
+ {user && ( +
+ {user.username} + +
+ )} +
+ v{import.meta.env.VITE_APP_VERSION as string} {updateCheck?.updateAvailable && updateCheck.releaseUrl && ( void; + logout: () => void; + isAuthenticated: boolean; + isLoading: boolean; +} + +const REFRESH_TOKEN_KEY = 'archon-refresh-token'; + +const authContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [accessToken, setAccessToken] = useState(null); + const [refreshTokenState, setRefreshTokenState] = useState(() => { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } + }); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const initializedRef = useRef(false); + + function clearTokens(): void { + setAccessToken(null); + setRefreshTokenState(null); + setUser(null); + setApiToken(null); + try { + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + /* best-effort */ + } + } + + // On mount, try to restore session via refresh token + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + if (!refreshTokenState) { + setIsLoading(false); + return; + } + refreshSession(refreshTokenState) + .then(data => { + setAccessToken(data.accessToken); + setRefreshTokenState(data.refreshToken); + setApiToken(data.accessToken); + try { + localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } catch { + /* best-effort */ + } + }) + .catch(() => { + clearTokens(); + }) + .finally(() => { + setIsLoading(false); + }); + }, [refreshTokenState]); + + // Fetch user profile once access token is set + useEffect(() => { + if (!accessToken) return; + getCurrentUser(accessToken) + .then(u => { + setUser(u); + }) + .catch(() => { + clearTokens(); + }); + }, [accessToken]); + + // Listen for 401 events from fetchJSON + useEffect(() => { + const handler = (): void => { + clearTokens(); + }; + window.addEventListener('archon:unauthorized', handler); + return (): void => { + window.removeEventListener('archon:unauthorized', handler); + }; + }, []); + + const login = useCallback((at: string, rt: string, u: UserResponse): void => { + setAccessToken(at); + setRefreshTokenState(rt); + setUser(u); + setApiToken(at); + try { + localStorage.setItem(REFRESH_TOKEN_KEY, rt); + } catch { + /* best-effort */ + } + }, []); + + const logout = useCallback((): void => { + clearTokens(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(authContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 6c81aa66b1..ece8bfd106 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -57,9 +57,47 @@ export interface HealthResponse { is_docker: boolean; } +// Module-level auth token — set by AuthContext via setApiToken() +let currentToken: string | null = null; + +export function setApiToken(token: string | null): void { + currentToken = token; +} + +export function getApiToken(): string | null { + return currentToken; +} + +export interface UserResponse { + id: string; + username: string; + displayName: string | null; + role: 'admin' | 'user'; + createdAt: string; +} + +export interface AuthTokenResponse { + user: UserResponse; + accessToken: string; + refreshToken: string; +} + +export interface RefreshTokenResponse { + accessToken: string; + refreshToken: string; +} + async function fetchJSON(url: string, options?: RequestInit): Promise { - const res = await fetch(url, options); + const headers = new Headers(options?.headers); + if (currentToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${currentToken}`); + } + + const res = await fetch(url, { ...options, headers }); if (!res.ok) { + if (res.status === 401) { + window.dispatchEvent(new CustomEvent('archon:unauthorized')); + } const body = await res.text(); const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body; const path = new URL(url, window.location.origin).pathname; @@ -70,6 +108,48 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { return res.json() as Promise; } +// Auth API functions +export async function loginApi(username: string, password: string): Promise { + return fetchJSON('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); +} + +export async function registerApi( + username: string, + password: string, + displayName?: string +): Promise { + return fetchJSON('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, displayName }), + }); +} + +export async function refreshSession(refreshToken: string): Promise { + // Don't use fetchJSON — we need to send without the current (expired) access token + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) { + throw new Error('Token refresh failed'); + } + return res.json() as Promise; +} + +export async function getCurrentUser(token: string): Promise { + const res = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to get current user'); + return res.json() as Promise; +} + // Conversations export async function listConversations(codebaseId?: string): Promise { const params = new URLSearchParams(); diff --git a/packages/web/src/routes/LoginPage.tsx b/packages/web/src/routes/LoginPage.tsx new file mode 100644 index 0000000000..c34d364d1b --- /dev/null +++ b/packages/web/src/routes/LoginPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuth } from '@/contexts/AuthContext'; +import { loginApi, registerApi } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type Mode = 'login' | 'register'; + +export function LoginPage(): React.ReactElement { + const [mode, setMode] = useState('login'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const auth = useAuth(); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + + try { + const result = + mode === 'login' + ? await loginApi(username, password) + : await registerApi(username, password, displayName || undefined); + + auth.login(result.accessToken, result.refreshToken, result.user); + navigate('/chat'); + } catch (err) { + const msg = (err as Error).message; + if (msg.includes('401')) { + setError('Invalid username or password'); + } else if (msg.includes('409')) { + setError('Username already taken'); + } else { + setError(msg); + } + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+ A +
+

Archon

+

+ {mode === 'login' ? 'Sign in to your account' : 'Create a new account'} +

+
+ +
void handleSubmit(e)} className="space-y-4"> +
+ + { + setUsername(e.target.value); + }} + required + minLength={mode === 'register' ? 3 : undefined} + autoFocus + /> +
+ +
+ + { + setPassword(e.target.value); + }} + required + minLength={mode === 'register' ? 8 : undefined} + /> +
+ + {mode === 'register' && ( +
+ + { + setDisplayName(e.target.value); + }} + /> +
+ )} + + {error &&

{error}

} + + +
+ +
+ {mode === 'login' ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +
+
+
+ ); +} From 134927d5764c31844a068d44a9089904b1c4638c Mon Sep 17 00:00:00 2001 From: Luis Erlacher Date: Sun, 12 Apr 2026 23:04:29 -0300 Subject: [PATCH 2/4] fix(review): add JSDoc to listConversationsForUser and setConversationUserId 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 --- packages/core/src/db/conversations.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 011bd3fef9..c25e726022 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -219,6 +219,10 @@ export async function listConversations( return result.rows; } +/** + * Returns only conversations owned by the given user (via user_id). + * Admins use listConversations(). Supports optional filtering by platform type and codebase. + */ export async function listConversationsForUser( userId: string, limit = 50, @@ -265,7 +269,8 @@ export async function touchConversation(id: string): Promise { } /** - * Update conversation title + * Assign a user as the owner of a conversation. + * Called once after conversation creation to establish user_id for scoped listing. */ export async function setConversationUserId(id: string, userId: string): Promise { const dialect = getDialect(); @@ -275,6 +280,9 @@ export async function setConversationUserId(id: string, userId: string): Promise ); } +/** + * Update conversation title + */ export async function updateConversationTitle(id: string, title: string): Promise { const dialect = getDialect(); const result = await pool.query( From acdba934c917f7b7caffceade36738bd3f59cc91 Mon Sep 17 00:00:00 2001 From: Luis Erlacher Date: Sun, 12 Apr 2026 23:10:53 -0300 Subject: [PATCH 3/4] fix(review): address PR #30 review findings for multi-user auth - 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 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 --- .env.example | 4 +- CLAUDE.md | 10 +- migrations/000_combined.sql | 100 +++---- packages/cli/src/commands/login.ts | 17 +- packages/core/package.json | 2 +- packages/core/src/auth/jwt.test.ts | 84 ++++++ packages/core/src/db/adapters/sqlite.ts | 40 +-- packages/core/src/db/codebases.ts | 1 + packages/core/src/db/users.test.ts | 116 ++++++++ packages/core/src/db/users.ts | 16 +- packages/core/src/types/index.ts | 1 + .../docs/getting-started/configuration.md | 1 + .../src/content/docs/reference/api.md | 24 +- .../src/content/docs/reference/cli.md | 23 +- .../src/content/docs/reference/database.md | 18 +- .../src/content/docs/reference/security.md | 2 +- packages/server/package.json | 2 +- packages/server/src/middleware/auth.test.ts | 135 +++++++++ packages/server/src/middleware/auth.ts | 17 +- packages/server/src/routes/api.auth.test.ts | 258 ++++++++++++++++++ .../server/src/routes/api.codebases.test.ts | 12 + .../src/routes/api.conversations.test.ts | 12 + packages/server/src/routes/api.ts | 41 ++- packages/server/src/routes/auth.ts | 22 +- packages/server/src/routes/utils.ts | 7 + packages/web/src/contexts/AuthContext.tsx | 7 +- packages/web/src/lib/api.ts | 3 +- 27 files changed, 860 insertions(+), 115 deletions(-) create mode 100644 packages/core/src/auth/jwt.test.ts create mode 100644 packages/core/src/db/users.test.ts create mode 100644 packages/server/src/middleware/auth.test.ts create mode 100644 packages/server/src/routes/api.auth.test.ts create mode 100644 packages/server/src/routes/utils.ts diff --git a/.env.example b/.env.example index 285b80bc72..57a4a2d323 100644 --- a/.env.example +++ b/.env.example @@ -119,7 +119,9 @@ GITEA_ALLOWED_USERS= # GITEA_BOT_MENTION=archon # Authentication (JWT) -# Required when auth is enabled. Generate a secure value: +# Set this to enable JWT-based authentication. Must be at least 32 characters. +# Without this, all API access bypasses auth (full admin access for any caller — dev/test only). +# Generate a secure value: # openssl rand -base64 32 # JWT_SECRET=your-secret-key-min-32-chars-change-this diff --git a/CLAUDE.md b/CLAUDE.md index f38cb29a98..5cb1d51470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## Core Principles **Single-Developer Tool** -- No multi-tenant complexity +- Designed for a single operator; multi-user authentication is opt-in via `JWT_SECRET` **Platform Agnostic** - Unified conversation interface across Slack/Telegram/GitHub/cli/web @@ -249,6 +249,10 @@ bun run cli serve bun run cli serve --port 4000 bun run cli serve --download-only # Download without starting +# Log in to an Archon server (stores credentials to ~/.archon/auth.json) +bun run cli login +bun run cli login --server-url http://my-server:3090 + # Show version bun run cli version ``` @@ -375,7 +379,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; ### Database Schema -**8 Tables (all prefixed with `remote_agent_`):** +**10 Tables (all prefixed with `remote_agent_`):** 1. **`codebases`** - Repository metadata and commands (JSONB) 2. **`conversations`** - Track platform conversations with titles and soft-delete support 3. **`sessions`** - Track AI SDK sessions with resume capability @@ -384,6 +388,8 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; 6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors) 7. **`messages`** - Conversation message history with tool call metadata (JSONB) 8. **`codebase_env_vars`** - Per-project env vars injected into Claude SDK subprocess env (managed via Web UI or `env:` in config) +9. **`users`** - User accounts for JWT authentication (username, password hash, role: admin|user) +10. **`project_members`** - Junction table: user-codebase access grants (roles: owner, member) **Key Patterns:** - Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`) diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index f74eb437da..af1f600b36 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -4,15 +4,15 @@ -- -- 10 Tables: -- 1. remote_agent_codebases --- 1b. remote_agent_codebase_env_vars --- 2. remote_agent_conversations --- 3. remote_agent_sessions --- 4. remote_agent_isolation_environments --- 5. remote_agent_workflow_runs --- 6. remote_agent_workflow_events --- 7. remote_agent_messages --- 8. remote_agent_users --- 9. remote_agent_project_members +-- 2. remote_agent_codebase_env_vars +-- 3. remote_agent_users +-- 4. remote_agent_project_members +-- 5. remote_agent_conversations +-- 6. remote_agent_sessions +-- 7. remote_agent_isolation_environments +-- 8. remote_agent_workflow_runs +-- 9. remote_agent_workflow_events +-- 10. remote_agent_messages -- -- Dropped tables (via migrations): -- - remote_agent_command_templates (017) @@ -42,7 +42,7 @@ COMMENT ON TABLE remote_agent_codebases IS 'Repository metadata: name, URL, working directory, AI assistant type, and command paths (JSONB)'; -- ============================================================================ --- Table 1b: Codebase Env Vars +-- Table 2: Codebase Env Vars -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars ( @@ -62,7 +62,41 @@ COMMENT ON TABLE remote_agent_codebase_env_vars IS 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.'; -- ============================================================================ --- Table 2: Conversations +-- Table 3: Users (must precede conversations — FK dependency) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE remote_agent_users IS + 'User accounts for authentication. First registered user gets admin role.'; + +-- ============================================================================ +-- Table 4: Project Members (must precede conversations — referenced in future queries) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id + ON remote_agent_project_members(user_id); + +COMMENT ON TABLE remote_agent_project_members IS + 'Junction table: which users have access to which codebases. Roles: owner, member.'; + +-- ============================================================================ +-- Table 5: Conversations (now safe — remote_agent_users exists) -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_conversations ( @@ -96,7 +130,7 @@ COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS 'UUID reference to isolation_environments table (the only isolation reference)'; -- ============================================================================ --- Table 3: Sessions +-- Table 6: Sessions -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_sessions ( @@ -131,7 +165,7 @@ COMMENT ON COLUMN remote_agent_sessions.ended_reason IS 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.'; -- ============================================================================ --- Table 4: Isolation Environments +-- Table 7: Isolation Environments -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_isolation_environments ( @@ -185,7 +219,7 @@ COMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS 'Identifier for the work (issue number, PR number, thread hash, etc.)'; -- ============================================================================ --- Table 5: Workflow Runs +-- Table 8: Workflow Runs -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_workflow_runs ( @@ -220,7 +254,7 @@ COMMENT ON TABLE remote_agent_workflow_runs IS 'Tracks workflow execution state for resumption and observability'; -- ============================================================================ --- Table 6: Workflow Events +-- Table 9: Workflow Events -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_workflow_events ( @@ -242,7 +276,7 @@ COMMENT ON TABLE remote_agent_workflow_events IS 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)'; -- ============================================================================ --- Table 7: Messages +-- Table 10: Messages -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_messages ( @@ -257,40 +291,6 @@ CREATE TABLE IF NOT EXISTS remote_agent_messages ( CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON remote_agent_messages(conversation_id, created_at ASC); --- ============================================================================ --- Table 8: Users --- ============================================================================ - -CREATE TABLE IF NOT EXISTS remote_agent_users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(50) UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name VARCHAR(100), - role VARCHAR(20) NOT NULL DEFAULT 'user', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - -COMMENT ON TABLE remote_agent_users IS - 'User accounts for authentication. First registered user gets admin role.'; - --- ============================================================================ --- Table 9: Project Members --- ============================================================================ - -CREATE TABLE IF NOT EXISTS remote_agent_project_members ( - user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, - codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, - role VARCHAR(20) NOT NULL DEFAULT 'member', - PRIMARY KEY (user_id, codebase_id) -); - -CREATE INDEX IF NOT EXISTS idx_project_members_user_id - ON remote_agent_project_members(user_id); - -COMMENT ON TABLE remote_agent_project_members IS - 'Junction table: which users have access to which codebases. Roles: owner, member.'; - -- ============================================================================ -- Cleanup: Drop legacy objects from older schemas -- ============================================================================ diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index eb27b49a52..ff3f04c278 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -19,6 +19,21 @@ async function readLine(prompt: string): Promise { }); } +async function readPassword(prompt: string): Promise { + process.stdout.write(prompt); + // output: undefined with terminal: false suppresses echo so the password is not shown in the terminal. + // We avoid `output: null` due to TypeScript type constraints, but setting terminal to false + // prevents readline from writing input characters back to stdout. + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + return new Promise(resolve => { + rl.question('', answer => { + rl.close(); + process.stdout.write('\n'); // advance to next line after silent entry + resolve(answer); + }); + }); +} + export interface LoginOptions { serverUrl?: string; } @@ -27,7 +42,7 @@ export async function loginCommand(opts: LoginOptions): Promise { const serverUrl = opts.serverUrl ?? process.env.ARCHON_SERVER_URL ?? 'http://localhost:3090'; const username = await readLine('Username: '); - const password = await readLine('Password: '); + const password = await readPassword('Password: '); try { const res = await fetch(`${serverUrl}/api/auth/login`, { diff --git a/packages/core/package.json b/packages/core/package.json index 5242575c54..c9b482f911 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,7 +24,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/auth/jwt.test.ts && bun test src/db/users.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/auth/jwt.test.ts b/packages/core/src/auth/jwt.test.ts new file mode 100644 index 0000000000..6873ea1912 --- /dev/null +++ b/packages/core/src/auth/jwt.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; + +const ORIGINAL_SECRET = process.env.JWT_SECRET; + +describe('jwt', () => { + beforeEach(() => { + process.env.JWT_SECRET = 'test-secret-at-least-32-chars-long!!'; + }); + + afterEach(() => { + if (ORIGINAL_SECRET === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = ORIGINAL_SECRET; + } + }); + + test('generateAccessToken returns a verifiable JWT with correct payload', async () => { + const { generateAccessToken, verifyToken } = await import('./jwt'); + const payload = { userId: 'user-123', role: 'admin' as const }; + const token = await generateAccessToken(payload); + const decoded = await verifyToken(token); + expect(decoded.userId).toBe('user-123'); + expect(decoded.role).toBe('admin'); + }); + + test('generateRefreshToken returns a verifiable JWT with correct payload', async () => { + const { generateRefreshToken, verifyToken } = await import('./jwt'); + const payload = { userId: 'user-456', role: 'user' as const }; + const token = await generateRefreshToken(payload); + const decoded = await verifyToken(token); + expect(decoded.userId).toBe('user-456'); + expect(decoded.role).toBe('user'); + }); + + test('verifyToken throws on tampered signature', async () => { + const { generateAccessToken, verifyToken } = await import('./jwt'); + const token = await generateAccessToken({ userId: 'u1', role: 'user' }); + const tampered = token.slice(0, -5) + 'XXXXX'; + await expect(verifyToken(tampered)).rejects.toThrow(); + }); + + test('verifyToken throws on missing role claim', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const noRole = await new SignJWT({ userId: 'u1' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(secret); + await expect(verifyToken(noRole)).rejects.toThrow('Invalid token payload'); + }); + + test('verifyToken throws on invalid role value', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const badRole = await new SignJWT({ userId: 'u1', role: 'superadmin' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(secret); + await expect(verifyToken(badRole)).rejects.toThrow('Invalid token payload'); + }); + + test('generateAccessToken throws when JWT_SECRET is not set', async () => { + delete process.env.JWT_SECRET; + const { generateAccessToken } = await import('./jwt'); + await expect(generateAccessToken({ userId: 'u', role: 'user' })).rejects.toThrow( + 'JWT_SECRET environment variable is required' + ); + }); + + test('verifyToken throws on expired token', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + // Use 1 second expiry and manually create expired JWT + const expired = await new SignJWT({ userId: 'u1', role: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(Math.floor(Date.now() / 1000) - 10) // 10 seconds in the past + .sign(secret); + await expect(verifyToken(expired)).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 5c395d34e3..82005c56a9 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -286,7 +286,26 @@ export class SqliteAdapter implements IDatabase { UNIQUE(codebase_id, key) ); - -- Conversations table + -- Users table (must precede conversations — FK dependency) + CREATE TABLE IF NOT EXISTS remote_agent_users ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + -- Project members table (must precede conversations for logical ordering) + CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) + ); + + -- Conversations table (now safe — remote_agent_users exists) CREATE TABLE IF NOT EXISTS remote_agent_conversations ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), platform_type TEXT NOT NULL, @@ -371,25 +390,6 @@ export class SqliteAdapter implements IDatabase { created_at TEXT DEFAULT (datetime('now')) ); - -- Users table - CREATE TABLE IF NOT EXISTS remote_agent_users ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name TEXT, - role TEXT NOT NULL DEFAULT 'user', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - - -- Project members table - CREATE TABLE IF NOT EXISTS remote_agent_project_members ( - user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, - codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'member', - PRIMARY KEY (user_id, codebase_id) - ); - -- Messages table (conversation history for Web UI) CREATE TABLE IF NOT EXISTS remote_agent_messages ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index 5409740516..c0327c8830 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -180,6 +180,7 @@ export async function listCodebases(): Promise { return result.rows; } +/** Returns only codebases the given user is a member of (via remote_agent_project_members). Admins use listCodebases(). */ export async function listCodebasesForUser(userId: string): Promise { const result = await pool.query( `SELECT c.* FROM remote_agent_codebases c diff --git a/packages/core/src/db/users.test.ts b/packages/core/src/db/users.test.ts new file mode 100644 index 0000000000..0510858894 --- /dev/null +++ b/packages/core/src/db/users.test.ts @@ -0,0 +1,116 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; +import { createQueryResult, mockPostgresDialect } from '../test/mocks/database'; + +const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); + +mock.module('./connection', () => ({ + pool: { query: mockQuery }, + getDialect: () => mockPostgresDialect, +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + debug: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { createUser, getUserByUsername, getUserById, countUsers, isMember } from './users'; + +describe('users db', () => { + beforeEach(() => { + mockQuery.mockClear(); + }); + + describe('countUsers', () => { + test('returns 0 when table is empty — PostgreSQL returns count as string', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '0' }])); + expect(await countUsers()).toBe(0); + }); + + test('returns correct value when count is a numeric string', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '5' }])); + expect(await countUsers()).toBe(5); + }); + + test('returns correct value when count is already a number', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: 3 }])); + expect(await countUsers()).toBe(3); + }); + + test('returns 0 when rows array is empty', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await countUsers()).toBe(0); + }); + }); + + describe('createUser', () => { + test('returns the created user row', async () => { + const mockRow = { + id: 'uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'admin', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }; + mockQuery.mockResolvedValueOnce(createQueryResult([mockRow])); + const result = await createUser({ username: 'alice', password_hash: 'h', role: 'admin' }); + expect(result.username).toBe('alice'); + expect(result.role).toBe('admin'); + }); + + test('throws with descriptive message when INSERT returns no row', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + await expect(createUser({ username: 'alice', password_hash: 'h' })).rejects.toThrow( + 'Failed to create user: INSERT succeeded but no row returned' + ); + }); + }); + + describe('getUserByUsername', () => { + test('returns null when user not found', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await getUserByUsername('nobody')).toBeNull(); + }); + + test('returns user when found', async () => { + const mockRow = { + id: 'uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'user', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }; + mockQuery.mockResolvedValueOnce(createQueryResult([mockRow])); + const result = await getUserByUsername('alice'); + expect(result?.username).toBe('alice'); + }); + }); + + describe('getUserById', () => { + test('returns null when user not found', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await getUserById('non-existent')).toBeNull(); + }); + }); + + describe('isMember', () => { + test('returns true when membership row exists', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '1' }])); + expect(await isMember('user-1', 'codebase-1')).toBe(true); + }); + + test('returns false when no membership', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '0' }])); + expect(await isMember('user-1', 'codebase-1')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts index 9de6794540..af4760494e 100644 --- a/packages/core/src/db/users.ts +++ b/packages/core/src/db/users.ts @@ -51,34 +51,34 @@ export async function countUsers(): Promise { } export async function createProjectMember( - user_id: string, - codebase_id: string, + userId: string, + codebaseId: string, role: 'owner' | 'member' ): Promise { const result = await pool.query( `INSERT INTO remote_agent_project_members (user_id, codebase_id, role) VALUES ($1, $2, $3) RETURNING *`, - [user_id, codebase_id, role] + [userId, codebaseId, role] ); if (!result.rows[0]) { throw new Error('Failed to create project member: INSERT succeeded but no row returned'); } - getLog().info({ user_id, codebase_id, role }, 'project_member.create_completed'); + getLog().info({ userId, codebaseId, role }, 'project_member.create_completed'); return result.rows[0]; } -export async function getUserCodebaseIds(user_id: string): Promise { +export async function getUserCodebaseIds(userId: string): Promise { const result = await pool.query<{ codebase_id: string }>( 'SELECT codebase_id FROM remote_agent_project_members WHERE user_id = $1', - [user_id] + [userId] ); return result.rows.map(r => r.codebase_id); } -export async function isMember(user_id: string, codebase_id: string): Promise { +export async function isMember(userId: string, codebaseId: string): Promise { const result = await pool.query<{ count: string | number }>( 'SELECT COUNT(*) as count FROM remote_agent_project_members WHERE user_id = $1 AND codebase_id = $2', - [user_id, codebase_id] + [userId, codebaseId] ); return Number(result.rows[0]?.count ?? 0) > 0; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 121dfb9b0a..7e46086701 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -29,6 +29,7 @@ export interface Conversation { hidden: boolean; deleted_at: Date | null; last_activity_at: Date | null; // For staleness detection + user_id: string | null; // FK to remote_agent_users (multi-user auth ownership) created_at: Date; updated_at: Date; } diff --git a/packages/docs-web/src/content/docs/getting-started/configuration.md b/packages/docs-web/src/content/docs/getting-started/configuration.md index ec836f1202..757615d1ea 100644 --- a/packages/docs-web/src/content/docs/getting-started/configuration.md +++ b/packages/docs-web/src/content/docs/getting-started/configuration.md @@ -19,6 +19,7 @@ Set these in your shell or `.env` file: | `CLAUDE_API_KEY` | No | Anthropic API key for pay-per-use (alternative to global auth) | | `CODEX_ACCESS_TOKEN` | Yes (for Codex) | Codex access token (see [AI Assistants](/getting-started/ai-assistants/)) | | `DATABASE_URL` | No | PostgreSQL connection string (default: SQLite) | +| `JWT_SECRET` | No | Secret key for JWT signing (min 32 chars). Set to enable authentication. Generate with `openssl rand -base64 32`. | | `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, `error` | | `PORT` | No | Server port (default: 3090, Docker: 3000) | diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0e2fa8aa37..303be15667 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -32,7 +32,29 @@ You can feed this into tools like Swagger UI or use it to generate typed API cli ## Authentication -None. Archon is a single-developer tool -- there is no authentication on the API by default. If you expose Archon on a network, use a reverse proxy or firewall to restrict access. +Archon supports optional JWT-based authentication. Set `JWT_SECRET` in your environment to enable it. + +When `JWT_SECRET` is set, all `/api/*` endpoints (except `/api/auth/login`, `/api/auth/register`, +`/api/auth/refresh`, `/api/health`, `/api/health/db`, and `/api/openapi.json`) require a `Bearer` token in the +`Authorization` header. SSE streaming endpoints (`/api/stream/*`) are currently exempt pending a follow-up. + +When `JWT_SECRET` is **not** set (default), all endpoints are accessible without authentication — +backward-compatible default for single-developer local use. In this mode every caller is treated as admin. + +### Auth Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/auth/register` | Create a new user account (first user gets `admin` role) | +| POST | `/api/auth/login` | Log in; returns `accessToken` (1h) and `refreshToken` (7d) | +| POST | `/api/auth/refresh` | Exchange a refresh token for a new access token | +| GET | `/api/auth/me` | Return the currently authenticated user | + +Include the token in all subsequent requests: + +```bash +curl -H "Authorization: Bearer " http://localhost:3090/api/conversations +``` --- diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index f2821a1b8b..0b72e98fe9 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -50,7 +50,7 @@ archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth su archon workflow run assist --cwd /path/to/repo --no-worktree "Quick question" ``` -**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, and `serve` commands work anywhere. +**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, `serve`, and `login` commands work anywhere. ## Commands @@ -303,6 +303,27 @@ archon complete feature-auth --force # bypass uncommitted-changes check Use this after a PR is merged and you no longer need the worktree or branches. Accepts multiple branch names in one call. +### `login` + +Authenticate against an Archon server and save credentials locally. Required when `JWT_SECRET` is configured on the server. + +```bash +archon login +archon login --server-url http://my-server:3090 +``` + +Prompts for username and password, calls `/api/auth/login`, and writes the access token, +refresh token, username, and server URL to `~/.archon/auth.json` (mode `0600`). + +**Flags:** + +| Flag | Effect | +|------|--------| +| `--server-url ` | Override server URL (default: `ARCHON_SERVER_URL` env var or `http://localhost:3090`) | + +**Note:** The CLI does not currently auto-refresh expired tokens. Re-run `archon login` when +your access token expires (default lifetime: 1 hour). + ### `serve` Start the web UI server. On first run, downloads a pre-built web UI tarball from the matching GitHub release, verifies the SHA-256 checksum, and extracts it. Subsequent runs use the cached copy. diff --git a/packages/docs-web/src/content/docs/reference/database.md b/packages/docs-web/src/content/docs/reference/database.md index 6cab854622..042a019ec4 100644 --- a/packages/docs-web/src/content/docs/reference/database.md +++ b/packages/docs-web/src/content/docs/reference/database.md @@ -64,6 +64,8 @@ psql $DATABASE_URL < migrations/017_drop_command_templates.sql psql $DATABASE_URL < migrations/018_fix_workflow_status_default.sql psql $DATABASE_URL < migrations/019_workflow_resume_path.sql psql $DATABASE_URL < migrations/020_codebase_env_vars.sql +psql $DATABASE_URL < migrations/021_allow_env_keys.sql +psql $DATABASE_URL < migrations/022_multi_user_auth.sql ``` ## Local PostgreSQL via Docker @@ -94,6 +96,8 @@ docker compose exec postgres psql -U postgres -d remote_coding_agent \i /migrations/018_fix_workflow_status_default.sql \i /migrations/019_workflow_resume_path.sql \i /migrations/020_codebase_env_vars.sql +\i /migrations/021_allow_env_keys.sql +\i /migrations/022_multi_user_auth.sql \q ``` @@ -101,6 +105,8 @@ Or from your host machine (requires `psql` installed): ```bash psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/020_codebase_env_vars.sql +psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/021_allow_env_keys.sql +psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/022_multi_user_auth.sql # ... and so on for each migration not yet applied ``` @@ -119,7 +125,7 @@ psql $DATABASE_URL -c "\dt" ## Schema Overview -The database has 8 tables, all prefixed with `remote_agent_`: +The database has 10 tables, all prefixed with `remote_agent_`: 1. **`remote_agent_codebases`** - Repository metadata - Commands stored as JSONB: `{command_name: {path, description}}` @@ -160,6 +166,14 @@ The database has 8 tables, all prefixed with `remote_agent_`: - Injected into Claude SDK subprocess environment at execution time - Managed via Web UI Settings panel; `env:` in `.archon/config.yaml` for CLI users +9. **`remote_agent_users`** - User accounts for authentication + - Username, bcrypt password hash, display name, role (`admin` | `user`) + - First registered user automatically receives the `admin` role + +10. **`remote_agent_project_members`** - Codebase access control + - Junction table: user-codebase, roles: `owner`, `member` + - Admins see all codebases; members see only their assigned codebases + ## Migration List | Migration | Description | @@ -185,3 +199,5 @@ The database has 8 tables, all prefixed with `remote_agent_`: | `018_fix_workflow_status_default.sql` | Fix workflow status default value | | `019_workflow_resume_path.sql` | Workflow resume path support | | `020_codebase_env_vars.sql` | Per-project environment variables | +| `021_allow_env_keys.sql` | Per-codebase env-leak gate consent bit | +| `022_multi_user_auth.sql` | Multi-user auth: users table, project members, conversation user_id | diff --git a/packages/docs-web/src/content/docs/reference/security.md b/packages/docs-web/src/content/docs/reference/security.md index 26e26d169a..b9cfbf1428 100644 --- a/packages/docs-web/src/content/docs/reference/security.md +++ b/packages/docs-web/src/content/docs/reference/security.md @@ -89,7 +89,7 @@ Each platform adapter supports an optional user whitelist via environment variab - Every incoming message or webhook is checked before processing. - Unauthorized users are silently rejected -- no error response is sent back. - Unauthorized attempts are logged with masked user identifiers for auditing. -- The Web UI has no built-in user authentication. Use `CADDY_BASIC_AUTH` or form auth when exposing it publicly (see [Docker / Deployment](/reference/configuration/#docker--deployment) variables). +- When `JWT_SECRET` is set, the Web UI requires users to log in via the built-in `/login` page before accessing any protected routes. When `JWT_SECRET` is not set (default), the server operates in open-access mode — suitable for local single-developer use only. For network-exposed deployments, always set `JWT_SECRET`. You can additionally use `CADDY_BASIC_AUTH` or form auth for deployments without `JWT_SECRET` (see [Docker / Deployment](/reference/configuration/#docker--deployment) variables). ## Webhook Security diff --git a/packages/server/package.json b/packages/server/package.json index 7de8c49955..d1e56e37b2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", - "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts", + "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts && bun test src/middleware/auth.test.ts && bun test src/routes/api.auth.test.ts", "type-check": "bun x tsc --noEmit", "setup-auth": "bun src/scripts/setup-auth.ts" }, diff --git a/packages/server/src/middleware/auth.test.ts b/packages/server/src/middleware/auth.test.ts new file mode 100644 index 0000000000..1a8804a0be --- /dev/null +++ b/packages/server/src/middleware/auth.test.ts @@ -0,0 +1,135 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Mock setup — must be declared before any dynamic imports of mocked modules +// --------------------------------------------------------------------------- + +const mockVerifyToken = mock(async (_token: string) => ({ + userId: 'user-abc', + role: 'user' as const, +})); + +mock.module('@archon/core/auth', () => ({ + verifyToken: mockVerifyToken, +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + debug: mock(() => undefined), + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { Hono } from 'hono'; +import { authMiddleware } from './auth'; + +function makeApp(): Hono { + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.get('/api/protected', c => c.json({ ok: true })); + app.post('/api/auth/login', c => c.json({ ok: true })); + app.post('/api/auth/register', c => c.json({ ok: true })); + app.post('/api/auth/refresh', c => c.json({ ok: true })); + app.get('/api/health', c => c.json({ ok: true })); + app.get('/api/health/db', c => c.json({ ok: true })); + app.get('/api/openapi.json', c => c.json({ ok: true })); + app.get('/api/stream/conv-1', c => c.json({ ok: true })); + app.post('/webhooks/github', c => c.json({ ok: true })); + return app; +} + +describe('authMiddleware', () => { + beforeEach(() => { + mockVerifyToken.mockClear(); + mockVerifyToken.mockImplementation(async (_token: string) => ({ + userId: 'user-abc', + role: 'user' as const, + })); + }); + + test('passes /api/auth/login without a token', async () => { + const res = await makeApp().request('/api/auth/login', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/auth/register without a token', async () => { + const res = await makeApp().request('/api/auth/register', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/auth/refresh without a token', async () => { + const res = await makeApp().request('/api/auth/refresh', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/health without a token', async () => { + const res = await makeApp().request('/api/health'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/openapi.json without a token', async () => { + const res = await makeApp().request('/api/openapi.json'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/stream/* without a token', async () => { + const res = await makeApp().request('/api/stream/conv-1'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /webhooks/* without a token', async () => { + const res = await makeApp().request('/webhooks/github', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('returns 401 when Authorization header is missing', async () => { + const res = await makeApp().request('/api/protected'); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + test('returns 401 when Authorization header lacks Bearer prefix', async () => { + const res = await makeApp().request('/api/protected', { + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }); + expect(res.status).toBe(401); + }); + + test('returns 401 when token verification fails', async () => { + mockVerifyToken.mockRejectedValueOnce(new Error('Token expired')); + const res = await makeApp().request('/api/protected', { + headers: { Authorization: 'Bearer bad-token' }, + }); + expect(res.status).toBe(401); + }); + + test('sets userId and userRole on valid token and calls next', async () => { + let capturedUserId: string | undefined; + let capturedRole: string | undefined; + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.get('/api/protected', c => { + capturedUserId = c.get('userId') as string; + capturedRole = c.get('userRole') as string; + return c.json({ ok: true }); + }); + const res = await app.request('/api/protected', { + headers: { Authorization: 'Bearer valid-token' }, + }); + expect(res.status).toBe(200); + expect(capturedUserId).toBe('user-abc'); + expect(capturedRole).toBe('user'); + }); +}); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 22d7da7e93..c1f84861a7 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -14,10 +14,11 @@ const PUBLIC_PATHS = new Set([ ]); const PUBLIC_PREFIXES = [ + // Webhooks: authenticated via HMAC signature (not JWT) '/webhooks/', - // SSE streaming — clients cannot set Authorization headers easily. - // Web UI access token is still required for all other endpoints. - // TODO: Add SSE auth via query param token in a follow-up. + // SSE streaming: bypassed entirely — EventSource cannot send custom headers. + // KNOWN GAP: any caller knowing a conversationId can subscribe without a token. + // TODO: Add token-in-query-param auth for SSE in a follow-up. '/api/stream/', ]; @@ -41,8 +42,14 @@ export async function authMiddleware(c: Context, next: Next): Promise { c.set('userId', payload.userId); c.set('userRole', payload.role); await next(); - } catch { - log.debug({ path }, 'auth.token_invalid'); + } catch (e) { + const err = e as Error; + // Misconfiguration (JWT_SECRET missing) must be visible at warn level to aid diagnosis + if (err.message?.includes('JWT_SECRET')) { + log.warn({ err, path }, 'auth.jwt_secret_missing'); + } else { + log.debug({ path, errorType: err.constructor?.name }, 'auth.token_invalid'); + } c.res = c.json({ error: 'Unauthorized' }, 401); } } diff --git a/packages/server/src/routes/api.auth.test.ts b/packages/server/src/routes/api.auth.test.ts new file mode 100644 index 0000000000..6fc4c1c87f --- /dev/null +++ b/packages/server/src/routes/api.auth.test.ts @@ -0,0 +1,258 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { OpenAPIHono } from '@hono/zod-openapi'; + +// --------------------------------------------------------------------------- +// Mock setup — must be declared before any dynamic imports of mocked modules +// --------------------------------------------------------------------------- + +const mockCountUsers = mock(async () => 0); +const mockGetUserByUsername = mock( + async (_u: string) => + null as null | { + id: string; + username: string; + password_hash: string; + display_name: null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; + } +); +const mockCreateUser = mock( + async (data: { + username: string; + password_hash: string; + display_name?: string; + role?: 'admin' | 'user'; + }) => ({ + id: 'user-uuid-1', + username: data.username, + password_hash: data.password_hash, + display_name: data.display_name ?? null, + role: data.role ?? 'user', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }) +); +const mockGetUserById = mock( + async (_id: string) => + null as null | { + id: string; + username: string; + password_hash: string; + display_name: null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; + } +); + +mock.module('@archon/core/db/users', () => ({ + countUsers: mockCountUsers, + getUserByUsername: mockGetUserByUsername, + createUser: mockCreateUser, + getUserById: mockGetUserById, +})); + +mock.module('@archon/core/auth', () => ({ + hashPassword: mock(async (p: string) => `hashed:${p}`), + verifyPassword: mock(async (plain: string, hash: string) => hash === `hashed:${plain}`), + generateAccessToken: mock(async () => 'access-token-123'), + generateRefreshToken: mock(async () => 'refresh-token-456'), + verifyToken: mock(async () => ({ userId: 'user-uuid-1', role: 'user' as const })), +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + debug: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { registerAuthRoutes } from './auth'; +import { validationErrorHook } from './openapi-defaults'; + +function makeApp(): OpenAPIHono { + const app = new OpenAPIHono({ defaultHook: validationErrorHook }); + registerAuthRoutes(app); + return app; +} + +// --------------------------------------------------------------------------- +// POST /api/auth/register +// --------------------------------------------------------------------------- + +describe('POST /api/auth/register', () => { + beforeEach(() => { + mockCountUsers.mockClear(); + mockGetUserByUsername.mockClear(); + mockCreateUser.mockClear(); + }); + + test('first user receives admin role', async () => { + mockCountUsers.mockResolvedValueOnce(0); + mockGetUserByUsername.mockResolvedValueOnce(null); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as { user: { role: string } }; + expect(body.user.role).toBe('admin'); + const createArg = mockCreateUser.mock.calls[0]?.[0] as { role?: string }; + expect(createArg.role).toBe('admin'); + }); + + test('second user receives user role', async () => { + mockCountUsers.mockResolvedValueOnce(1); + mockGetUserByUsername.mockResolvedValueOnce(null); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'bob', password: 'password456' }), + }); + + expect(res.status).toBe(201); + const createArg = mockCreateUser.mock.calls[0]?.[0] as { role?: string }; + expect(createArg.role).toBe('user'); + }); + + test('returns 409 when username is already taken', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'existing', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(409); + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + + test('rejects passwords shorter than 8 characters', async () => { + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'short' }), + }); + expect(res.status).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/login +// --------------------------------------------------------------------------- + +describe('POST /api/auth/login', () => { + beforeEach(() => { + mockGetUserByUsername.mockClear(); + }); + + test('returns tokens on valid credentials', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'hashed:password123', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { accessToken: string; refreshToken: string }; + expect(body.accessToken).toBe('access-token-123'); + expect(body.refreshToken).toBe('refresh-token-456'); + }); + + test('returns 401 when user does not exist', async () => { + mockGetUserByUsername.mockResolvedValueOnce(null); + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'nobody', password: 'password123' }), + }); + expect(res.status).toBe(401); + }); + + test('returns 401 when password is wrong', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'hashed:correct', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }); + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/refresh +// --------------------------------------------------------------------------- + +describe('POST /api/auth/refresh', () => { + beforeEach(() => { + mockGetUserById.mockClear(); + }); + + test('returns new tokens for a valid refresh token', async () => { + mockGetUserById.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'user' as const, + created_at: '', + updated_at: '', + }); + const res = await makeApp().request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: 'valid-refresh-token' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accessToken: string }; + expect(body.accessToken).toBeTruthy(); + }); + + test('returns 401 when user no longer exists in DB', async () => { + mockGetUserById.mockResolvedValueOnce(null); + const res = await makeApp().request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: 'valid-refresh-token' }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/server/src/routes/api.codebases.test.ts b/packages/server/src/routes/api.codebases.test.ts index 0265a359e1..91b0454bea 100644 --- a/packages/server/src/routes/api.codebases.test.ts +++ b/packages/server/src/routes/api.codebases.test.ts @@ -108,6 +108,8 @@ mock.module('@archon/git', () => ({ mock.module('@archon/core/db/conversations', () => ({ findConversationByPlatformId: mock(async () => null), listConversations: mock(async () => []), + listConversationsForUser: mock(async () => []), + setConversationUserId: mock(async () => {}), getOrCreateConversation: mock(async () => ({ id: 'internal-uuid-123', platform_conversation_id: 'web-test-abc', @@ -117,6 +119,7 @@ mock.module('@archon/core/db/conversations', () => ({ platform_type: 'web', deleted_at: null, codebase_id: null, + user_id: null, })), softDeleteConversation: mock(async () => {}), updateConversationTitle: mock(async () => {}), @@ -126,11 +129,20 @@ mock.module('@archon/core/db/conversations', () => ({ const mockUpdateCodebaseAllowEnvKeys = mock(async (_id: string, _v: boolean) => {}); mock.module('@archon/core/db/codebases', () => ({ listCodebases: mockListCodebases, + listCodebasesForUser: mock(async () => []), getCodebase: mockGetCodebase, deleteCodebase: mockDeleteCodebase, updateCodebaseAllowEnvKeys: mockUpdateCodebaseAllowEnvKeys, })); +mock.module('@archon/core/db/users', () => ({ + createProjectMember: mock(async () => ({ + user_id: 'user-1', + codebase_id: 'codebase-1', + role: 'owner', + })), +})); + mock.module('@archon/core/db/isolation-environments', () => ({ listByCodebase: mockListByCodebase, updateStatus: mockUpdateStatus, diff --git a/packages/server/src/routes/api.conversations.test.ts b/packages/server/src/routes/api.conversations.test.ts index c5b53d9122..5e3f0d56e9 100644 --- a/packages/server/src/routes/api.conversations.test.ts +++ b/packages/server/src/routes/api.conversations.test.ts @@ -64,6 +64,8 @@ mock.module('@archon/core/db/conversations', () => ({ softDeleteConversation: mockSoftDeleteConversation, updateConversationTitle: mockUpdateConversationTitle, listConversations: mock(async () => []), + listConversationsForUser: mock(async () => []), + setConversationUserId: mock(async () => {}), getOrCreateConversation: mock(async () => ({ id: 'internal-uuid-123', platform_conversation_id: 'web-test-abc', @@ -73,6 +75,7 @@ mock.module('@archon/core/db/conversations', () => ({ platform_type: 'web', deleted_at: null, codebase_id: null, + user_id: null, })), })); @@ -87,9 +90,18 @@ mock.module('@archon/core/db/messages', () => ({ })); mock.module('@archon/core/db/codebases', () => ({ listCodebases: mock(async () => [{ default_cwd: '/tmp/project' }]), + listCodebasesForUser: mock(async () => []), getCodebase: mock(async () => null), })); +mock.module('@archon/core/db/users', () => ({ + createProjectMember: mock(async () => ({ + user_id: 'user-1', + codebase_id: 'codebase-1', + role: 'owner', + })), +})); + import { registerApiRoutes } from './api'; const MOCK_CONV = { diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index d1b29aa7bf..94f788f96c 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -122,6 +122,7 @@ import { configResponseSchema, codebaseEnvironmentsResponseSchema, } from './schemas/config.schemas'; +import { getValidatedBody } from './utils'; // Read app version once at module load (root package.json is 4 levels up from src/routes/) let appVersion = 'unknown'; @@ -872,7 +873,10 @@ export function registerApiRoutes( function getUserId(c: Context): { userId: string | undefined; isAdmin: boolean } { const userId = c.get('userId') as string | undefined; const userRole = c.get('userRole') as string | undefined; - // When auth middleware is not applied (tests, JWT_SECRET not set), treat as admin + // When JWT_SECRET is unset, authMiddleware is effectively disabled: no userId is + // injected into the context. We fall back to isAdmin=true so that existing tests + // and local dev setups without auth configured continue to work. + // WARNING: In production, JWT_SECRET must be set — without it every caller is treated as admin. return { userId, isAdmin: !userId || userRole === 'admin' }; } @@ -1077,6 +1081,12 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only access their own conversations. + // Return 404 (not 403) to avoid leaking existence of other users' conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } return c.json(conv); } catch (error) { getLog().error({ err: error, platformId }, 'get_conversation_failed'); @@ -1163,6 +1173,11 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only modify their own conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } if (title !== undefined) { await conversationDb.updateConversationTitle(conv.id, title.slice(0, 255)); } @@ -1184,6 +1199,11 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only delete their own conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } await conversationDb.softDeleteConversation(conv.id); return c.json({ success: true }); } catch (error) { @@ -1564,8 +1584,18 @@ export function registerApiRoutes( if (userId) { try { await usersDb.createProjectMember(userId, result.codebaseId, 'owner'); - } catch { - // Ignore duplicate membership (e.g., project already existed) + } catch (e) { + const err = e as Error & { code?: string }; + // PostgreSQL unique violation: 23505; SQLite: UNIQUE constraint failed + const isDuplicate = + err.code === '23505' || (err.message?.includes('UNIQUE constraint failed') ?? false); + if (!isDuplicate) { + // Unexpected error — log for debugging but don't fail the codebase creation + getLog().warn( + { err, userId, codebaseId: result.codebaseId }, + 'codebase.owner_membership_failed' + ); + } } } @@ -1758,11 +1788,6 @@ export function registerApiRoutes( app.openapi(route, handler as never); } - /** Access Zod-validated body from a handler registered via registerOpenApiRoute. */ - function getValidatedBody(c: Context, _schema: z.ZodType): T { - return (c.req as unknown as { valid(k: 'json'): T }).valid('json'); - } - // Serve OpenAPI spec app.doc('/api/openapi.json', { openapi: '3.0.0', diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index e74da8f54f..d738bd337a 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -10,6 +10,7 @@ import { } from '@archon/core/auth'; import type { User } from '@archon/core'; import { createLogger } from '@archon/paths'; +import { getValidatedBody } from './utils'; import { errorSchema } from './schemas/common.schemas'; import { registerBodySchema, @@ -130,17 +131,19 @@ export function registerAuthRoutes(app: OpenAPIHono): void { // POST /api/auth/register registerOpenApiRoute(registerRoute, async (c: Context) => { try { - const body = ( - c.req as unknown as { - valid(k: 'json'): { username: string; password: string; displayName?: string }; - } - ).valid('json'); + const body = getValidatedBody<{ username: string; password: string; displayName?: string }>( + c + ); const existing = await usersDb.getUserByUsername(body.username); if (existing) { return c.json({ error: 'Username already taken' }, 409); } + // NOTE: countUsers() + createUser() is non-atomic. Two simultaneous registrations + // on a fresh install could both see count=0 and both receive role:'admin'. + // Acceptable for a single-developer tool (YAGNI — no concurrent first-registration risk). + // If multi-tenant use is ever added, replace with a DB-level constraint. const userCount = await usersDb.countUsers(); const role = userCount === 0 ? 'admin' : 'user'; @@ -169,9 +172,7 @@ export function registerAuthRoutes(app: OpenAPIHono): void { // POST /api/auth/login registerOpenApiRoute(loginRoute, async (c: Context) => { try { - const body = ( - c.req as unknown as { valid(k: 'json'): { username: string; password: string } } - ).valid('json'); + const body = getValidatedBody<{ username: string; password: string }>(c); getLog().info({ username: body.username }, 'auth.login_started'); @@ -202,9 +203,7 @@ export function registerAuthRoutes(app: OpenAPIHono): void { // POST /api/auth/refresh registerOpenApiRoute(refreshRoute, async (c: Context) => { try { - const body = (c.req as unknown as { valid(k: 'json'): { refreshToken: string } }).valid( - 'json' - ); + const body = getValidatedBody<{ refreshToken: string }>(c); let payload; try { @@ -224,6 +223,7 @@ export function registerAuthRoutes(app: OpenAPIHono): void { generateRefreshToken(tokenPayload), ]); + getLog().info({ userId: user.id }, 'auth.refresh_completed'); return c.json({ accessToken, refreshToken }); } catch (error) { getLog().error({ err: error }, 'auth.refresh_failed'); diff --git a/packages/server/src/routes/utils.ts b/packages/server/src/routes/utils.ts new file mode 100644 index 0000000000..5609ea308e --- /dev/null +++ b/packages/server/src/routes/utils.ts @@ -0,0 +1,7 @@ +import type { Context } from 'hono'; +import type { z } from '@hono/zod-openapi'; + +/** Access Zod-validated body from a handler registered via app.openapi(). */ +export function getValidatedBody(c: Context, _schema?: z.ZodType): T { + return (c.req as unknown as { valid(k: 'json'): T }).valid('json'); +} diff --git a/packages/web/src/contexts/AuthContext.tsx b/packages/web/src/contexts/AuthContext.tsx index b7cff4de97..9d6b995009 100644 --- a/packages/web/src/contexts/AuthContext.tsx +++ b/packages/web/src/contexts/AuthContext.tsx @@ -59,7 +59,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React /* best-effort */ } }) - .catch(() => { + .catch((err: unknown) => { + // Expected on token expiry; unexpected on server errors — log for diagnostics + console.warn('[AuthContext] Session restore failed:', err); clearTokens(); }) .finally(() => { @@ -74,7 +76,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }): React .then(u => { setUser(u); }) - .catch(() => { + .catch((err: unknown) => { + console.warn('[AuthContext] Failed to fetch user profile:', err); clearTokens(); }); }, [accessToken]); diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index ece8bfd106..e27dabd882 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -130,7 +130,8 @@ export async function registerApi( } export async function refreshSession(refreshToken: string): Promise { - // Don't use fetchJSON — we need to send without the current (expired) access token + // Use raw fetch rather than fetchJSON to avoid accidentally injecting a stale + // in-memory access token into the refresh request via fetchJSON's auto-inject logic. const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, From adace9d3e8f3a9a2ecbd5b44568de3a23d392a61 Mon Sep 17 00:00:00 2001 From: Luis Erlacher Date: Sun, 12 Apr 2026 23:17:05 -0300 Subject: [PATCH 4/4] simplify: reduce duplication in JWT token generation and sanitizeUser return type Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/auth/jwt.ts | 14 +++++++------- packages/server/src/routes/auth.ts | 9 ++------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/core/src/auth/jwt.ts b/packages/core/src/auth/jwt.ts index f9ff689b2f..b3988e6eee 100644 --- a/packages/core/src/auth/jwt.ts +++ b/packages/core/src/auth/jwt.ts @@ -11,20 +11,20 @@ export interface TokenPayload { role: 'admin' | 'user'; } -export async function generateAccessToken(payload: TokenPayload): Promise { +async function signToken(payload: TokenPayload, expirationTime: string): Promise { return new SignJWT({ userId: payload.userId, role: payload.role }) .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime('1h') + .setExpirationTime(expirationTime) .setIssuedAt() .sign(getSecret()); } +export async function generateAccessToken(payload: TokenPayload): Promise { + return signToken(payload, '1h'); +} + export async function generateRefreshToken(payload: TokenPayload): Promise { - return new SignJWT({ userId: payload.userId, role: payload.role }) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime('7d') - .setIssuedAt() - .sign(getSecret()); + return signToken(payload, '7d'); } export async function verifyToken(token: string): Promise { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index d738bd337a..acc8b5057e 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -19,6 +19,7 @@ import { authResponseSchema, refreshResponseSchema, userSchema, + type UserResponse, } from './schemas/auth.schemas'; let cachedLog: ReturnType | undefined; @@ -34,13 +35,7 @@ function jsonError(description: string): { return { content: { 'application/json': { schema: errorSchema } }, description }; } -function sanitizeUser(user: User): { - id: string; - username: string; - displayName: string | null; - role: 'admin' | 'user'; - createdAt: string; -} { +function sanitizeUser(user: User): UserResponse { return { id: user.id, username: user.username,